feat: persist pinned libraries per user with cross-device sync (#20)

Store pinned library IDs on the user record and expose GET/PUT
/me/pinned-libraries endpoints. The web client now loads and saves pins
through the API, migrates legacy localStorage data once, and applies
pinned ordering on both the libraries and home pages.

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-03 08:47:06 +08:00
committed by GitHub
parent 1025e3c693
commit 1e291df647
12 changed files with 367 additions and 15 deletions
+8 -3
View File
@@ -5,6 +5,8 @@ import { historyAPI } from '../api/history'
import type { HistoryItem } from '../api/playback'
import type { Library, Media } from '../types'
import type { SeriesCard } from '../utils/groupSeries'
import { usePinnedLibraries } from '../hooks/usePinnedLibraries'
import { sortByPinnedIds } from '../utils/pinnedLibraries'
import {
ContinueWatchingSection,
HomeCarouselSection,
@@ -22,6 +24,7 @@ export function HomePage() {
const [libraryData, setLibraryData] = useState<Record<string, { cards: SeriesCard[]; items: Media[]; total: number }>>({})
const [history, setHistory] = useState<HistoryItem[]>([])
const [loading, setLoading] = useState(true)
const { pinnedIds } = usePinnedLibraries()
useEffect(() => {
let cancelled = false
@@ -126,6 +129,8 @@ export function HomePage() {
return candidateMedia.slice(0, 10)
}, [libraries, libraryData])
const sortedLibraries = useMemo(() => sortByPinnedIds(libraries, pinnedIds), [libraries, pinnedIds])
const empty =
!loading &&
libraries.length === 0 &&
@@ -150,9 +155,9 @@ export function HomePage() {
{history.length > 0 && <ContinueWatchingSection history={history} />}
{/* 3. 媒体库卡片区 */}
{libraries.length > 0 && (
{sortedLibraries.length > 0 && (
<HomeLibrariesSection
libraries={libraries}
libraries={sortedLibraries}
libraryData={libraryData}
libraryCounts={libraryCounts}
/>
@@ -160,7 +165,7 @@ export function HomePage() {
{/* 4. 各媒体库内容展示行 */}
<div className="space-y-10">
{libraries.map((lib) => {
{sortedLibraries.map((lib) => {
const cards = libraryData[lib.id]?.cards || []
if (cards.length === 0) return null
return (
+6 -5
View File
@@ -3,17 +3,18 @@ import { useCallback, useEffect, useMemo, useState } from 'react'
import { libraryAPI } from '../api/library'
import { toolsAPI } from '../api/tools'
import { openManageLibrariesDialog } from '../components/manageLibrariesDialog'
import { usePinnedLibraries } from '../hooks/usePinnedLibraries'
import {
LibrariesContent,
LibrariesEmptyState,
LibrariesHeader,
} from './LibrariesPageSections'
import type { LibraryPreview } from './librariesPageModel'
import { readPinnedLibraryIds, sortLibraryPreviews, togglePinnedLibraryId } from '../utils/pinnedLibraries'
import { sortLibraryPreviews } from '../utils/pinnedLibraries'
export function LibrariesPage() {
const [previews, setPreviews] = useState<LibraryPreview[]>([])
const [pinnedIds, setPinnedIds] = useState<string[]>(() => readPinnedLibraryIds())
const { pinnedIds, loading: pinnedLoading, togglePin } = usePinnedLibraries()
const [loading, setLoading] = useState(true)
const [repairing, setRepairing] = useState(false)
const [repairEpisodeArtwork, setRepairEpisodeArtwork] = useState(false)
@@ -62,12 +63,12 @@ export function LibrariesPage() {
const sortedPreviews = useMemo(() => sortLibraryPreviews(previews, pinnedIds), [previews, pinnedIds])
const handleTogglePin = useCallback((libraryId: string) => {
setPinnedIds(togglePinnedLibraryId(libraryId))
}, [])
void togglePin(libraryId)
}, [togglePin])
const total = useMemo(() => previews.reduce((sum, preview) => sum + preview.total, 0), [previews])
if (loading) {
if (loading || pinnedLoading) {
return <p className="px-2 py-8 text-sm text-sand-500">媒体库加载中…</p>
}