feat: uv config other feats

- add uv configuration for the backend
- update frontend to make auth work
- add new auth endpoints
- add bookmars feat
- add reader feat
This commit is contained in:
2026-06-03 22:06:01 -05:00
parent 730c748f5f
commit 6b4c0c43f8
137 changed files with 20319 additions and 2340 deletions
@@ -0,0 +1,140 @@
.shelf {
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 90;
background: #fff;
border-top: 1px solid #e5e7eb;
box-shadow: 0 -4px 24px rgba(0, 0, 0, 0.08);
transition: max-height 0.25s ease;
}
.shelfCollapsed {
max-height: 52px;
}
.shelfExpanded {
max-height: min(42vh, 320px);
}
.shelfHandle {
width: 100%;
display: flex;
align-items: center;
gap: 10px;
padding: 12px 16px;
border: none;
background: #fafafa;
cursor: pointer;
font: inherit;
text-align: left;
}
.shelfHandle:hover {
background: #f3f4f6;
}
.shelfHandleTitle {
font-weight: 700;
font-size: 14px;
color: #1f2937;
}
.shelfHandleCount {
font-size: 13px;
color: #6b7280;
flex: 1;
}
.shelfChevron {
font-size: 14px;
color: #9333ea;
font-weight: 700;
}
.shelfBody {
overflow-y: auto;
max-height: calc(min(42vh, 320px) - 52px);
padding: 12px 16px 16px;
border-top: 1px solid #f3f4f6;
}
.shelfGrid {
display: flex;
gap: 12px;
overflow-x: auto;
padding-bottom: 4px;
scroll-snap-type: x proximity;
}
.shelfCard {
flex: 0 0 120px;
scroll-snap-align: start;
cursor: pointer;
border-radius: 10px;
overflow: hidden;
background: #fff;
border: 1px solid #e5e7eb;
transition: transform 0.15s, box-shadow 0.15s;
}
.shelfCard:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.shelfCover {
position: relative;
height: 160px;
background: #f0f0f0;
}
.shelfCover img {
width: 100%;
height: 100%;
object-fit: cover;
}
.shelfCoverPlaceholder {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
font-size: 32px;
}
.shelfBadge {
position: absolute;
top: 6px;
right: 6px;
background: #f3e8ff;
color: #9333ea;
font-size: 9px;
font-weight: 700;
padding: 2px 6px;
border-radius: 999px;
}
.shelfMeta {
padding: 8px;
}
.shelfTitle {
margin: 0;
font-size: 12px;
font-weight: 600;
color: #1f2937;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.shelfAuthor {
margin: 2px 0 0;
font-size: 11px;
color: #6b7280;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
@@ -0,0 +1,92 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18n-lite";
import styles from "./FinishedBooksShelf.module.css";
export interface ShelfBook {
id: number;
title: string;
author: string;
cover_image: string | null;
format: string;
genre: string;
}
interface FinishedBooksShelfProps {
books: ShelfBook[];
defaultExpanded?: boolean;
onOpenBook: (book: ShelfBook) => void;
onContextMenu: (e: React.MouseEvent, book: ShelfBook) => void;
}
export function FinishedBooksShelf({
books,
defaultExpanded = false,
onOpenBook,
onContextMenu,
}: FinishedBooksShelfProps) {
const { t } = useTranslation();
const [expanded, setExpanded] = useState(defaultExpanded);
useEffect(() => {
if (defaultExpanded) setExpanded(true);
}, [defaultExpanded]);
if (books.length === 0) return null;
return (
<div
className={`${styles.shelf} ${expanded ? styles.shelfExpanded : styles.shelfCollapsed}`}
aria-label={t("library.finishedSection")}
>
<button
type="button"
className={styles.shelfHandle}
onClick={() => setExpanded((v) => !v)}
aria-expanded={expanded}
>
<span className={styles.shelfHandleTitle}>{t("library.finishedSection")}</span>
<span className={styles.shelfHandleCount}>
{t("library.finishedCount", { count: String(books.length) })}
</span>
<span className={styles.shelfChevron} aria-hidden>
{expanded ? "▾" : "▴"}
</span>
</button>
{expanded && (
<div className={styles.shelfBody}>
<div className={styles.shelfGrid}>
{books.map((book) => (
<div
key={book.id}
role="button"
tabIndex={0}
className={styles.shelfCard}
onClick={() => onOpenBook(book)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onOpenBook(book);
}
}}
onContextMenu={(e) => onContextMenu(e, book)}
>
<div className={styles.shelfCover}>
{book.cover_image ? (
<img src={book.cover_image} alt="" />
) : (
<span className={styles.shelfCoverPlaceholder}>📖</span>
)}
<span className={styles.shelfBadge}>{t("library.readingStatus.finished")}</span>
</div>
<div className={styles.shelfMeta}>
<p className={styles.shelfTitle}>{book.title}</p>
<p className={styles.shelfAuthor}>{book.author}</p>
</div>
</div>
))}
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,176 @@
import { useTranslation } from "react-i18n-lite";
import { readingStatusKey } from "@/locales";
import styles from "../../pages/Library.module.css";
export const LIBRARY_STATUS_COLORS: Record<string, { bg: string; text: string }> = {
want_to_read: { bg: "#dbeafe", text: "#1d4ed8" },
reading: { bg: "#dcfce7", text: "#16a34a" },
finished: { bg: "#f3e8ff", text: "#9333ea" },
dnf: { bg: "#fef3c7", text: "#b45309" },
};
export interface LibraryBookCardData {
id: number;
title: string;
author: string;
genre: string;
format: string;
cover_image: string | null;
reading_status: string;
progressPercent: number | null;
}
function badgeLabel(
book: LibraryBookCardData,
t: (key: string, params?: Record<string, string>) => string,
): string {
if (book.reading_status === "reading" && book.progressPercent != null && book.progressPercent > 0) {
return t("library.readingStatus.readingWithProgress", {
percent: String(book.progressPercent),
});
}
return t(readingStatusKey(book.reading_status));
}
interface LibraryBookCardProps {
book: LibraryBookCardData;
isMobile: boolean;
onOpen: () => void;
onContextMenu: (e: React.MouseEvent) => void;
}
export function LibraryBookCard({
book,
isMobile,
onOpen,
onContextMenu,
}: LibraryBookCardProps) {
const { t } = useTranslation();
const sc = LIBRARY_STATUS_COLORS[book.reading_status] ?? { bg: "#f3f4f6", text: "#6b7280" };
const showProgressBar =
book.reading_status === "reading" &&
book.progressPercent != null &&
book.progressPercent > 0;
return (
<div
onClick={onOpen}
onContextMenu={onContextMenu}
className={styles.bookCard}
style={{ display: isMobile ? "flex" : "block" }}
>
<div
style={{
width: isMobile ? 80 : "100%",
height: isMobile ? 120 : 180,
background: "#f0f0f0",
display: "flex",
alignItems: "center",
justifyContent: "center",
position: "relative",
flexShrink: 0,
}}
>
{book.cover_image ? (
<img
src={book.cover_image}
alt={book.title}
style={{ width: "100%", height: "100%", objectFit: "cover" }}
/>
) : (
<span style={{ fontSize: isMobile ? 32 : 48 }}>📖</span>
)}
{showProgressBar && (
<div className={styles.coverProgressBar}>
<div
className={styles.coverProgressFill}
style={{ width: `${book.progressPercent}%` }}
/>
</div>
)}
{!isMobile && (
<span
style={{
position: "absolute",
top: 8,
right: 8,
background: sc.bg,
color: sc.text,
fontSize: 11,
fontWeight: 600,
padding: "2px 8px",
borderRadius: 999,
lineHeight: "18px",
maxWidth: "calc(100% - 16px)",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{badgeLabel(book, t)}
</span>
)}
</div>
<div style={{ padding: isMobile ? "8px 12px" : 12, flex: 1 }}>
<div
style={{
display: "flex",
alignItems: "flex-start",
justifyContent: "space-between",
gap: 8,
}}
>
<div style={{ minWidth: 0, flex: 1 }}>
<h3
style={{
fontSize: 14,
fontWeight: 600,
color: "#1f2937",
margin: 0,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{book.title}
</h3>
<p style={{ fontSize: 12, color: "#6b7280", margin: "2px 0" }}>
{book.author || t("common.unknownAuthor")}
</p>
</div>
{isMobile && (
<span
style={{
background: sc.bg,
color: sc.text,
fontSize: 10,
fontWeight: 600,
padding: "2px 6px",
borderRadius: 999,
whiteSpace: "nowrap",
flexShrink: 0,
}}
>
{badgeLabel(book, t)}
</span>
)}
</div>
{book.genre && (
<span
style={{
fontSize: 11,
color: "#4f46e5",
background: "#eef2ff",
padding: "1px 6px",
borderRadius: 4,
display: "inline-block",
marginTop: 4,
}}
>
{book.genre}
</span>
)}
</div>
</div>
);
}