Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/web/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { TermsPage } from './pages/TermsPage'
import { DmcaPage } from './pages/DmcaPage'
import { ContactPage } from './pages/ContactPage'
import { LibraryPage } from './pages/LibraryPage'
import { LibraryShelfPage } from './pages/LibraryShelfPage'
import { UserBookDetailPage } from './pages/UserBookDetailPage'
import { StatsPage } from './pages/StatsPage'
import { VocabularyReviewPage } from './pages/VocabularyReviewPage'
Expand Down Expand Up @@ -97,6 +98,7 @@ function LanguageRoutes() {
<Route path="/dmca" element={<DmcaPage />} />
<Route path="/contact" element={<ContactPage />} />
<Route path="/library" element={<LibraryPage />} />
<Route path="/library/shelf/:shelfId" element={<LibraryShelfPage />} />
<Route path="/stats" element={<StatsPage />} />
<Route path="/vocabulary" element={<VocabularyPage />} />
<Route path="/vocabulary/review" element={<VocabularyReviewPage />} />
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/api/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,7 @@ export interface LibraryItem {
language: string
coverPath: string | null
createdAt: string
author: string | null
}

export interface LibraryResponse {
Expand Down
172 changes: 172 additions & 0 deletions apps/web/src/components/library/AddToCollectionButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
import { useEffect, useRef, useState } from 'react'
import { useTranslation } from '../../hooks/useTranslation'
import { useCollections, invalidateCollectionsCache } from '../../hooks/useCollections'
import { addBookToCollection, type BookType } from '../../api/collections'

export type Toast = { msg: string; tone: 'success' | 'error' }

interface CommonProps {
bookId: string
bookType: BookType
onToast?: (toast: Toast) => void
}

interface MenuVariantProps extends CommonProps {
variant: 'menu'
close: () => void
}

interface ButtonVariantProps extends CommonProps {
variant: 'button'
className?: string
}

type Props = MenuVariantProps | ButtonVariantProps

export function AddToCollectionButton(props: Props) {
const { t } = useTranslation()
const { collections } = useCollections()
const [expanded, setExpanded] = useState(false)
const [busy, setBusy] = useState(false)
const [localToast, setLocalToast] = useState<Toast | null>(null)
const wrapRef = useRef<HTMLDivElement>(null)

useEffect(() => {
if (!localToast) return
const ms = localToast.tone === 'error' ? 4000 : 2500
const id = window.setTimeout(() => setLocalToast(null), ms)
return () => window.clearTimeout(id)
}, [localToast])

useEffect(() => {
if (!expanded || props.variant !== 'button') return
const onDown = (e: MouseEvent) => {
if (wrapRef.current && !wrapRef.current.contains(e.target as Node)) setExpanded(false)
}
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') setExpanded(false)
}
document.addEventListener('mousedown', onDown)
document.addEventListener('keydown', onKey)
return () => {
document.removeEventListener('mousedown', onDown)
document.removeEventListener('keydown', onKey)
}
}, [expanded, props.variant])

const emitToast = (toast: Toast) => {
if (props.onToast) props.onToast(toast)
else setLocalToast(toast)
}

const handlePick = async (collectionId: string, name: string) => {
if (busy) return
setBusy(true)
try {
await addBookToCollection(collectionId, props.bookId, props.bookType)
invalidateCollectionsCache()
emitToast({ msg: t('library.actions.addedToCollection', { name }), tone: 'success' })
setExpanded(false)
if (props.variant === 'menu') props.close()
} catch {
emitToast({ msg: t('library.actions.addToCollectionFailed'), tone: 'error' })
} finally {
setBusy(false)
}
}

if (props.variant === 'menu') {
if (collections.length === 0) {
return (
<button
className="book-card-menu__item"
role="menuitem"
disabled
aria-disabled="true"
title={t('library.actions.addToCollectionEmpty')}
>
{t('library.actions.addToCollection')}
</button>
)
}
return (
<>
<button
className="book-card-menu__item"
role="menuitem"
aria-haspopup="menu"
aria-expanded={expanded}
onClick={(e) => { e.preventDefault(); setExpanded((v) => !v) }}
>
{t('library.actions.addToCollection')}
<span aria-hidden="true" style={{ marginLeft: 'auto', opacity: 0.6 }}>{expanded ? '▾' : '▸'}</span>
</button>
{expanded && (
<div className="book-card-menu__submenu" role="menu">
{collections.map((c) => (
<button
key={c.id}
className="book-card-menu__item"
role="menuitem"
disabled={busy}
onClick={() => handlePick(c.id, c.name)}
>
{c.name}
</button>
))}
</div>
)}
</>
)
}

// variant === 'button'
const baseClass = props.className || 'add-to-collection-button'
if (collections.length === 0) {
return (
<button
type="button"
className={baseClass}
disabled
aria-disabled="true"
title={t('library.actions.addToCollectionEmpty')}
>
{t('library.actions.addToCollection')}
</button>
)
}
return (
<div className="add-to-collection" ref={wrapRef}>
{localToast && (
<div className={`add-to-collection__toast add-to-collection__toast--${localToast.tone}`} role="status" aria-live="polite">
{localToast.msg}
</div>
)}
<button
type="button"
className={baseClass}
aria-haspopup="menu"
aria-expanded={expanded}
onClick={() => setExpanded((v) => !v)}
>
{t('library.actions.addToCollection')}
</button>
{expanded && (
<div className="add-to-collection__popover" role="menu">
{collections.map((c) => (
<button
key={c.id}
type="button"
className="add-to-collection__option"
role="menuitem"
disabled={busy}
onClick={() => handlePick(c.id, c.name)}
>
{c.name}
</button>
))}
</div>
)}
</div>
)
}
87 changes: 6 additions & 81 deletions apps/web/src/components/library/BookActionMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,9 @@ import { useNavigate } from 'react-router-dom'
import { useTranslation } from '../../hooks/useTranslation'
import { useLanguage } from '../../context/LanguageContext'
import { useBookActions } from '../../hooks/useBookActions'
import { useCollections, invalidateCollectionsCache } from '../../hooks/useCollections'
import { addBookToCollection, type BookType } from '../../api/collections'
import { ConfirmDeleteModal } from './ConfirmDeleteModal'
import { UserBookEditModal } from './UserBookEditModal'
import { AddToCollectionButton, type Toast as AddToast } from './AddToCollectionButton'
import type { LibraryItem } from '../../api/auth'
import type { UserBook } from '../../api/userBooks'

Expand Down Expand Up @@ -136,7 +135,7 @@ function Trigger({ open, setOpen, t }: { open: boolean; setOpen: (v: boolean) =>
)
}

type Toast = { msg: string; tone: 'success' | 'error' }
type Toast = AddToast

function SavedMenu({
book, isFinished, onMarkFinished, onMarkUnfinished, onRemove,
Expand Down Expand Up @@ -169,10 +168,10 @@ function SavedMenu({
>
{isFinished ? t('library.actions.markUnfinished') : t('library.actions.markFinished')}
</button>
<AddToCollectionItem
<AddToCollectionButton
variant="menu"
bookId={book.editionId}
bookType="savedbook"
t={t}
close={() => setOpen(false)}
onToast={setToast}
/>
Expand All @@ -197,80 +196,6 @@ function SavedMenu({
)
}

function AddToCollectionItem({
bookId, bookType, t, close, onToast,
}: {
bookId: string
bookType: BookType
t: SharedRender['t']
close: () => void
onToast: (toast: Toast) => void
}) {
const { collections } = useCollections()
const [expanded, setExpanded] = useState(false)
const [busy, setBusy] = useState(false)

const handlePick = async (collectionId: string, name: string) => {
if (busy) return
setBusy(true)
try {
await addBookToCollection(collectionId, bookId, bookType)
invalidateCollectionsCache()
onToast({ msg: t('library.actions.addedToCollection', { name }), tone: 'success' })
setExpanded(false)
close()
} catch {
onToast({ msg: t('library.actions.addToCollectionFailed'), tone: 'error' })
} finally {
setBusy(false)
}
}

if (collections.length === 0) {
return (
<button
className="book-card-menu__item"
role="menuitem"
disabled
aria-disabled="true"
title={t('library.actions.addToCollectionEmpty')}
>
{t('library.actions.addToCollection')}
</button>
)
}

return (
<>
<button
className="book-card-menu__item"
role="menuitem"
aria-haspopup="menu"
aria-expanded={expanded}
onClick={(e) => { e.preventDefault(); setExpanded((v) => !v) }}
>
{t('library.actions.addToCollection')}
<span aria-hidden="true" style={{ marginLeft: 'auto', opacity: 0.6 }}>{expanded ? '▾' : '▸'}</span>
</button>
{expanded && (
<div className="book-card-menu__submenu" role="menu">
{collections.map((c) => (
<button
key={c.id}
className="book-card-menu__item"
role="menuitem"
disabled={busy}
onClick={() => handlePick(c.id, c.name)}
>
{c.name}
</button>
))}
</div>
)}
</>
)
}

function UserBookMenu({
book, onChange,
wrapRef, open, setOpen, confirmDelete, setConfirmDelete, t, navigate, language,
Expand Down Expand Up @@ -332,10 +257,10 @@ function UserBookMenu({
</button>
)}
{isReady && (
<AddToCollectionItem
<AddToCollectionButton
variant="menu"
bookId={book.id}
bookType="userbook"
t={t}
close={close}
onToast={setToast}
/>
Expand Down
6 changes: 3 additions & 3 deletions apps/web/src/components/library/LibraryShelves.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,14 +32,14 @@ export function LibraryShelves({ hasAnyContent }: LibraryShelvesProps) {
title={t('library.shelves.continueReading.title')}
subtitle={t('library.shelves.continueReading.subtitle')}
items={shelves.continueReading}
viewAllHref="/library?status=reading"
viewAllHref="/library/shelf/continueReading"
/>
<LibraryShelf
shelfId="recentlyAdded"
title={t('library.shelves.recentlyAdded.title')}
subtitle={t('library.shelves.recentlyAdded.subtitle')}
items={shelves.recentlyAdded}
viewAllHref="/library?status=all&sort=added"
viewAllHref="/library/shelf/recentlyAdded"
/>
<LibraryShelf
shelfId="quickReads"
Expand All @@ -52,7 +52,7 @@ export function LibraryShelves({ hasAnyContent }: LibraryShelvesProps) {
title={t('library.shelves.finishedThisMonth.title')}
subtitle={t('library.shelves.finishedThisMonth.subtitle')}
items={shelves.finishedThisMonth}
viewAllHref="/library?status=finished"
viewAllHref="/library/shelf/finishedThisMonth"
/>
</div>
)
Expand Down
3 changes: 2 additions & 1 deletion apps/web/src/components/library/UserBookCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -189,12 +189,13 @@ export function UserBookCard({ book, onDelete, onRetry, onCancel, onUpdate, prog
<Link
to={destination}
className="user-book-card__title"
title={book.title}
onClick={(e) => !isReady && e.preventDefault()}
>
{book.title}
</Link>
{book.author && (
<div className="user-book-card__author">{book.author}</div>
<div className="user-book-card__author" title={book.author}>{book.author}</div>
)}
{excerpt && (
<div
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/hooks/__tests__/useLibraryFilter.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import type { UserBook } from '../../api/userBooks'

const lib = (editionId: string, title: string): LibraryItem => ({
editionId, slug: title.toLowerCase(), title, language: 'en', coverPath: null,
createdAt: '2026-04-01T00:00:00Z',
createdAt: '2026-04-01T00:00:00Z', author: null,
})
const prog = (editionId: string, percent: number): ReadingProgressDto => ({
editionId, chapterId: 'c1', chapterSlug: 'ch1', locator: '{}', percent, updatedAt: '2026-04-25T00:00:00Z',
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/hooks/__tests__/useLibrarySort.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import type { LibraryItem, ReadingProgressDto } from '../../api/auth'
import type { UserBook } from '../../api/userBooks'

const lib = (editionId: string, title: string, createdAt: string): LibraryItem => ({
editionId, slug: title.toLowerCase(), title, language: 'en', coverPath: null, createdAt,
editionId, slug: title.toLowerCase(), title, language: 'en', coverPath: null, createdAt, author: null,
})

const prog = (editionId: string, percent: number, updatedAt: string): ReadingProgressDto => ({
Expand Down
Loading
Loading