feat(libraries): add pin-to-top for media library list (#10)

Allow users to pin frequently used libraries to the top of the
Libraries page. Pinned order is stored in localStorage and applied
to both entry cards and preview shelves.

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: truewhile <truewhile@users.noreply.github.com>
This commit is contained in:
truewhile
2026-09-02 11:35:20 +08:00
committed by GitHub
parent 1c1da494b7
commit 791722aec6
3 changed files with 129 additions and 14 deletions
+9 -1
View File
@@ -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<LibraryPreview[]>([])
const [pinnedIds, setPinnedIds] = useState<string[]>(() => 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 ? (
<LibrariesEmptyState />
) : (
<LibrariesContent previews={previews} />
<LibrariesContent previews={sortedPreviews} pinnedIds={pinnedIds} onTogglePin={handleTogglePin} />
)}
</div>
)
+72 -13
View File
@@ -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<string, ReactNode> = {
movie: <Film size={18} />,
@@ -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 (
<>
<section className="space-y-4">
<div>
<h2 className="font-display text-2xl font-bold text-ink-600">媒体库入口</h2>
<p className="text-sm text-ink-50">按目录进入完整媒体库;下方每个目录也会直接展示最新内容。</p>
<p className="text-sm text-ink-50">
按目录进入完整媒体库;下方每个目录也会直接展示最新内容。
{pinnedCount > 0 ? ` 已置顶 ${pinnedCount} 个媒体库。` : ' 点击卡片右上角图钉可置顶常用媒体库。'}
</p>
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 sm:gap-4 xl:grid-cols-3">
{previews.map((preview, index) => (
@@ -118,7 +132,11 @@ export function LibrariesContent({ previews }: { previews: LibraryPreview[] }) {
animate={{ opacity: 1, y: 0 }}
transition={{ delay: index * 0.03 }}
>
<LibraryEntryCard preview={preview} />
<LibraryEntryCard
preview={preview}
pinned={isLibraryPinned(preview.library.id, pinnedIds)}
onTogglePin={() => onTogglePin(preview.library.id)}
/>
</motion.div>
))}
</div>
@@ -132,7 +150,10 @@ export function LibrariesContent({ previews }: { previews: LibraryPreview[] }) {
animate={{ opacity: 1, y: 0 }}
transition={{ delay: index * 0.03 }}
>
<LibraryShelf preview={preview} />
<LibraryShelf
preview={preview}
pinned={isLibraryPinned(preview.library.id, pinnedIds)}
/>
</motion.div>
))}
</section>
@@ -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 (
<Link
to={`/library/${library.id}`}
className="group flex overflow-hidden rounded-2xl sm:rounded-3xl border border-sand-200 bg-white p-2.5 sm:p-3 shadow-card transition-all hover:-translate-y-0.5 hover:border-brand-200 hover:shadow-card-hover"
<div
className={
'group relative flex overflow-hidden rounded-2xl sm:rounded-3xl border bg-white p-2.5 sm:p-3 shadow-card transition-all hover:-translate-y-0.5 hover:shadow-card-hover ' +
(pinned ? 'border-brand-300 ring-1 ring-brand-100' : 'border-sand-200 hover:border-brand-200')
}
>
<button
type="button"
onClick={(event) => {
event.preventDefault()
event.stopPropagation()
onTogglePin()
}}
className={
'absolute right-2 top-2 z-10 rounded-full border p-1.5 transition ' +
(pinned
? 'border-brand-200 bg-brand-50 text-brand-600 hover:bg-brand-100'
: 'border-sand-200 bg-white/95 text-sand-500 opacity-0 hover:bg-gray-50 hover:text-brand-600 group-hover:opacity-100')
}
title={pinned ? '取消置顶' : '置顶媒体库'}
aria-label={pinned ? '取消置顶' : '置顶媒体库'}
aria-pressed={pinned}
>
<Pin size={14} className={pinned ? 'fill-current' : ''} />
</button>
<Link
to={`/library/${library.id}`}
className="flex min-w-0 flex-1"
>
<div className={`grid h-20 w-24 sm:h-24 sm:w-36 shrink-0 gap-1 overflow-hidden rounded-xl sm:rounded-2xl bg-[linear-gradient(135deg,#fff7ed,#f8fafc)] ${artwork.length > 1 ? 'grid-cols-2' : 'grid-cols-1'}`}>
{artwork.length > 0 ? (
artwork.map(({ src, version }, index) => (
@@ -173,7 +227,7 @@ function LibraryEntryCard({ preview }: { preview: LibraryPreview }) {
</div>
<div className="flex min-w-0 flex-1 flex-col justify-between px-3 sm:px-4 py-0.5 sm:py-1">
<div>
<div className="mb-1 flex items-center justify-between gap-1.5">
<div className="mb-1 flex items-center justify-between gap-1.5 pr-8">
<span className="inline-flex rounded-full bg-sand-100 px-2 py-0.5 text-[10px] font-bold text-sand-600">
{TYPE_LABELS[library.type] ?? library.type}
</span>
@@ -183,6 +237,7 @@ function LibraryEntryCard({ preview }: { preview: LibraryPreview }) {
</div>
<h2 className="line-clamp-2 font-display text-sm font-bold leading-tight text-ink-600 group-hover:text-brand-600 sm:truncate sm:text-xl sm:font-black">
{library.name}
{pinned ? <span className="ml-1.5 align-middle text-[10px] font-bold text-brand-600">置顶</span> : null}
</h2>
<p className="mt-0.5 line-clamp-1 break-all text-[11px] text-ink-50 sm:mt-1 sm:text-xs" title={library.path}>
{displayPath}
@@ -195,11 +250,12 @@ function LibraryEntryCard({ preview }: { preview: LibraryPreview }) {
</span>
</div>
</div>
</Link>
</Link>
</div>
)
}
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] ?? <LibraryIcon size={14} />}
{TYPE_LABELS[library.type] ?? library.type}
</div>
<h2 className="line-clamp-2 font-display text-xl sm:text-2xl font-black text-ink-600 sm:truncate">{library.name}</h2>
<h2 className="line-clamp-2 font-display text-xl sm:text-2xl font-black text-ink-600 sm:truncate">
{library.name}
{pinned ? <span className="ml-2 align-middle text-xs font-bold text-brand-600">置顶</span> : null}
</h2>
<p className="mt-1 line-clamp-1 break-all text-xs text-ink-50">
<span title={library.path}>{displayPath}</span> · {preview.total.toLocaleString()} 个条目 · 最新 {cards.length} 部
</p>
+48
View File
@@ -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<T extends { library: { id: string } }>(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
})
}