mirror of
https://github.com/truewhile/MeBox.git
synced 2026-10-01 03:56:38 +08:00
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:
@@ -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),
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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 (
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]))
|
||||
|
||||
Reference in New Issue
Block a user