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
+6
View File
@@ -12,6 +12,12 @@ export const profileAPI = {
}) =>
api.patch<User>('/me', patch).then((r) => r.data),
getPinnedLibraries: () =>
api.get<{ library_ids: string[] }>('/me/pinned-libraries').then((r) => r.data.library_ids ?? []),
setPinnedLibraries: (libraryIds: string[]) =>
api.put<{ library_ids: string[] }>('/me/pinned-libraries', { library_ids: libraryIds }).then((r) => r.data.library_ids ?? []),
adminUpdateRole: (id: string, role: 'admin' | 'user') =>
api.patch<User>(`/admin/users/${id}/role`, { role }).then((r) => r.data),
}
+52
View File
@@ -0,0 +1,52 @@
import { useCallback, useEffect, useState } from 'react'
import {
loadPinnedLibraryIds,
savePinnedLibraryIds,
togglePinnedLibraryId,
} from '../utils/pinnedLibraries'
export function usePinnedLibraries() {
const [pinnedIds, setPinnedIds] = useState<string[]>([])
const [loading, setLoading] = useState(true)
const [syncing, setSyncing] = useState(false)
useEffect(() => {
let cancelled = false
setLoading(true)
loadPinnedLibraryIds()
.then((ids) => {
if (!cancelled) setPinnedIds(ids)
})
.catch(() => {
if (!cancelled) setPinnedIds([])
})
.finally(() => {
if (!cancelled) setLoading(false)
})
return () => {
cancelled = true
}
}, [])
const togglePin = useCallback(async (libraryId: string) => {
let previous: string[] = []
let optimistic: string[] = []
setPinnedIds((current) => {
previous = current
optimistic = togglePinnedLibraryId(current, libraryId)
return optimistic
})
setSyncing(true)
try {
const saved = await savePinnedLibraryIds(optimistic)
setPinnedIds(saved)
} catch {
setPinnedIds(previous)
} finally {
setSyncing(false)
}
}, [])
return { pinnedIds, loading, syncing, togglePin }
}
+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>
}
+1
View File
@@ -10,6 +10,7 @@ export interface User {
force_password_reset: boolean
is_active: boolean
allowed_library_ids?: string[]
pinned_library_ids?: string[]
is_default_admin?: boolean
is_protected?: boolean
realtime_online?: boolean
+56 -7
View File
@@ -1,4 +1,7 @@
import { profileAPI } from '../api/profile'
const STORAGE_KEY = 'mebox_pinned_libraries'
const MIGRATION_KEY = 'mebox_pinned_libraries_migrated'
function parsePinnedIds(raw: string | null): string[] {
if (!raw) return []
@@ -11,28 +14,74 @@ function parsePinnedIds(raw: string | null): string[] {
}
}
export function readPinnedLibraryIds(): string[] {
export function readLegacyPinnedLibraryIds(): string[] {
if (typeof window === 'undefined') return []
return parsePinnedIds(window.localStorage.getItem(STORAGE_KEY))
}
export function writePinnedLibraryIds(ids: string[]): void {
function writeLegacyPinnedLibraryIds(ids: string[]): void {
if (typeof window === 'undefined') return
if (ids.length === 0) {
window.localStorage.removeItem(STORAGE_KEY)
return
}
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(ids))
}
export function togglePinnedLibraryId(id: string): string[] {
const current = readPinnedLibraryIds()
function markPinnedLibrariesMigrated(): void {
if (typeof window === 'undefined') return
window.localStorage.setItem(MIGRATION_KEY, '1')
window.localStorage.removeItem(STORAGE_KEY)
}
function hasPinnedLibrariesMigrated(): boolean {
if (typeof window === 'undefined') return true
return window.localStorage.getItem(MIGRATION_KEY) === '1'
}
export async function loadPinnedLibraryIds(): Promise<string[]> {
const remote = await profileAPI.getPinnedLibraries()
if (!hasPinnedLibrariesMigrated()) {
const legacy = readLegacyPinnedLibraryIds()
if (legacy.length > 0 && remote.length === 0) {
const migrated = await profileAPI.setPinnedLibraries(legacy)
markPinnedLibrariesMigrated()
return migrated
}
markPinnedLibrariesMigrated()
}
return remote
}
export async function savePinnedLibraryIds(ids: string[]): Promise<string[]> {
const saved = await profileAPI.setPinnedLibraries(ids)
writeLegacyPinnedLibraryIds(saved)
return saved
}
export function togglePinnedLibraryId(current: string[], id: string): string[] {
const index = current.indexOf(id)
const next = index >= 0 ? current.filter((item) => item !== id) : [...current, id]
writePinnedLibraryIds(next)
return next
return index >= 0 ? current.filter((item) => item !== id) : [...current, id]
}
export function isLibraryPinned(id: string, pinnedIds: string[]): boolean {
return pinnedIds.includes(id)
}
export function sortByPinnedIds<T extends { id: string }>(items: T[], pinnedIds: string[]): T[] {
if (pinnedIds.length === 0) return items
const rank = new Map(pinnedIds.map((pinnedId, index) => [pinnedId, index]))
return [...items].sort((a, b) => {
const aRank = rank.get(a.id)
const bRank = rank.get(b.id)
const aPinned = aRank !== undefined
const bPinned = bRank !== undefined
if (aPinned !== bPinned) return aPinned ? -1 : 1
if (aPinned && bPinned) return aRank - bRank
return 0
})
}
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]))