diff --git a/web/src/pages/LibrariesPage.tsx b/web/src/pages/LibrariesPage.tsx index 6f59f0d..f7541d5 100644 --- a/web/src/pages/LibrariesPage.tsx +++ b/web/src/pages/LibrariesPage.tsx @@ -9,9 +9,11 @@ import { LibrariesHeader, } from './LibrariesPageSections' import type { LibraryPreview } from './librariesPageModel' +import { readPinnedLibraryIds, sortLibraryPreviews, togglePinnedLibraryId } from '../utils/pinnedLibraries' export function LibrariesPage() { const [previews, setPreviews] = useState([]) + const [pinnedIds, setPinnedIds] = useState(() => readPinnedLibraryIds()) const [loading, setLoading] = useState(true) const [repairing, setRepairing] = useState(false) const [repairEpisodeArtwork, setRepairEpisodeArtwork] = useState(false) @@ -57,6 +59,12 @@ export function LibrariesPage() { loadLibraries().catch(() => undefined) }, [loadLibraries]) + const sortedPreviews = useMemo(() => sortLibraryPreviews(previews, pinnedIds), [previews, pinnedIds]) + + const handleTogglePin = useCallback((libraryId: string) => { + setPinnedIds(togglePinnedLibraryId(libraryId)) + }, []) + const total = useMemo(() => previews.reduce((sum, preview) => sum + preview.total, 0), [previews]) if (loading) { @@ -79,7 +87,7 @@ export function LibrariesPage() { {previews.length === 0 ? ( ) : ( - + )} ) diff --git a/web/src/pages/LibrariesPageSections.tsx b/web/src/pages/LibrariesPageSections.tsx index 5075d58..6154720 100644 --- a/web/src/pages/LibrariesPageSections.tsx +++ b/web/src/pages/LibrariesPageSections.tsx @@ -1,7 +1,7 @@ import type { ReactNode } from 'react' import { Link } from 'react-router-dom' import { motion } from 'framer-motion' -import { ArrowRight, Film, FolderOpen, Library as LibraryIcon, Music, PlayCircle, RefreshCw, Sparkles, Tv } from 'lucide-react' +import { ArrowRight, Film, FolderOpen, Library as LibraryIcon, Music, Pin, PlayCircle, RefreshCw, Sparkles, Tv } from 'lucide-react' import { imageURL } from '../api/client' import { EpisodeArtworkToggle } from '../components/EpisodeArtworkToggle' @@ -9,6 +9,7 @@ import { MediaCard } from '../components/MediaCard' import { seriesCardLink } from '../utils/groupSeries' import { libraryDisplayPath } from './libraryDisplayModel' import { libraryArtworkItems, type LibraryPreview } from './librariesPageModel' +import { isLibraryPinned } from '../utils/pinnedLibraries' const TYPE_ICONS: Record = { movie: , @@ -102,13 +103,26 @@ export function LibrariesEmptyState() { ) } -export function LibrariesContent({ previews }: { previews: LibraryPreview[] }) { +export function LibrariesContent({ + previews, + pinnedIds, + onTogglePin, +}: { + previews: LibraryPreview[] + pinnedIds: string[] + onTogglePin: (libraryId: string) => void +}) { + const pinnedCount = previews.filter((preview) => isLibraryPinned(preview.library.id, pinnedIds)).length + return ( <>

媒体库入口

-

按目录进入完整媒体库;下方每个目录也会直接展示最新内容。

+

+ 按目录进入完整媒体库;下方每个目录也会直接展示最新内容。 + {pinnedCount > 0 ? ` 已置顶 ${pinnedCount} 个媒体库。` : ' 点击卡片右上角图钉可置顶常用媒体库。'} +

{previews.map((preview, index) => ( @@ -118,7 +132,11 @@ export function LibrariesContent({ previews }: { previews: LibraryPreview[] }) { animate={{ opacity: 1, y: 0 }} transition={{ delay: index * 0.03 }} > - + onTogglePin(preview.library.id)} + /> ))}
@@ -132,7 +150,10 @@ export function LibrariesContent({ previews }: { previews: LibraryPreview[] }) { animate={{ opacity: 1, y: 0 }} transition={{ delay: index * 0.03 }} > - + ))}
@@ -140,7 +161,15 @@ export function LibrariesContent({ previews }: { previews: LibraryPreview[] }) { ) } -function LibraryEntryCard({ preview }: { preview: LibraryPreview }) { +function LibraryEntryCard({ + preview, + pinned, + onTogglePin, +}: { + preview: LibraryPreview + pinned: boolean + onTogglePin: () => void +}) { const library = preview.library const artwork = library.cover_url ? [{ src: library.cover_url, version: library.updated_at }] @@ -148,10 +177,35 @@ function LibraryEntryCard({ preview }: { preview: LibraryPreview }) { const displayPath = libraryDisplayPath(library.path) return ( - + +
1 ? 'grid-cols-2' : 'grid-cols-1'}`}> {artwork.length > 0 ? ( artwork.map(({ src, version }, index) => ( @@ -173,7 +227,7 @@ function LibraryEntryCard({ preview }: { preview: LibraryPreview }) {
-
+
{TYPE_LABELS[library.type] ?? library.type} @@ -183,6 +237,7 @@ function LibraryEntryCard({ preview }: { preview: LibraryPreview }) {

{library.name} + {pinned ? 置顶 : null}

{displayPath} @@ -195,11 +250,12 @@ function LibraryEntryCard({ preview }: { preview: LibraryPreview }) {

- + +
) } -function LibraryShelf({ preview }: { preview: LibraryPreview }) { +function LibraryShelf({ preview, pinned }: { preview: LibraryPreview; pinned?: boolean }) { const library = preview.library const cards = preview.cards.slice(0, 10) const displayPath = libraryDisplayPath(library.path) @@ -212,7 +268,10 @@ function LibraryShelf({ preview }: { preview: LibraryPreview }) { {TYPE_ICONS[library.type] ?? } {TYPE_LABELS[library.type] ?? library.type} -

{library.name}

+

+ {library.name} + {pinned ? 置顶 : null} +

{displayPath} · {preview.total.toLocaleString()} 个条目 · 最新 {cards.length} 部

diff --git a/web/src/utils/pinnedLibraries.ts b/web/src/utils/pinnedLibraries.ts new file mode 100644 index 0000000..7b3f75a --- /dev/null +++ b/web/src/utils/pinnedLibraries.ts @@ -0,0 +1,48 @@ +const STORAGE_KEY = 'mmtl_pinned_libraries' + +function parsePinnedIds(raw: string | null): string[] { + if (!raw) return [] + try { + const parsed = JSON.parse(raw) + if (!Array.isArray(parsed)) return [] + return parsed.filter((id): id is string => typeof id === 'string' && id.trim() !== '') + } catch { + return [] + } +} + +export function readPinnedLibraryIds(): string[] { + if (typeof window === 'undefined') return [] + return parsePinnedIds(window.localStorage.getItem(STORAGE_KEY)) +} + +export function writePinnedLibraryIds(ids: string[]): void { + if (typeof window === 'undefined') return + window.localStorage.setItem(STORAGE_KEY, JSON.stringify(ids)) +} + +export function togglePinnedLibraryId(id: string): string[] { + const current = readPinnedLibraryIds() + const index = current.indexOf(id) + const next = index >= 0 ? current.filter((item) => item !== id) : [...current, id] + writePinnedLibraryIds(next) + return next +} + +export function isLibraryPinned(id: string, pinnedIds: string[]): boolean { + return pinnedIds.includes(id) +} + +export function sortLibraryPreviews(items: T[], pinnedIds: string[]): T[] { + if (pinnedIds.length === 0) return items + const rank = new Map(pinnedIds.map((id, index) => [id, index])) + return [...items].sort((a, b) => { + const aRank = rank.get(a.library.id) + const bRank = rank.get(b.library.id) + const aPinned = aRank !== undefined + const bPinned = bRank !== undefined + if (aPinned !== bPinned) return aPinned ? -1 : 1 + if (aPinned && bPinned) return aRank - bRank + return 0 + }) +}