mirror of
https://github.com/truewhile/MeBox.git
synced 2026-10-08 14:26:37 +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:
@@ -0,0 +1,49 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"github.com/truewhile/MeBox/internal/middleware"
|
||||||
|
"github.com/truewhile/MeBox/internal/service"
|
||||||
|
)
|
||||||
|
|
||||||
|
type pinnedLibrariesReq struct {
|
||||||
|
LibraryIDs []string `json:"library_ids"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func getPinnedLibrariesHandler(svc *service.Container) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
uid, _ := c.Get(middleware.CtxUserID)
|
||||||
|
ids, err := svc.Profile.GetPinnedLibraryIDs(c.Request.Context(), uid.(string))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if ids == nil {
|
||||||
|
ids = []string{}
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"library_ids": ids})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func setPinnedLibrariesHandler(svc *service.Container) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
var req pinnedLibrariesReq
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
uid, _ := c.Get(middleware.CtxUserID)
|
||||||
|
ids, err := svc.Profile.SetPinnedLibraryIDs(c.Request.Context(), uid.(string), req.LibraryIDs)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if ids == nil {
|
||||||
|
ids = []string{}
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"library_ids": ids})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,6 +10,8 @@ import (
|
|||||||
func registerAuthedUserAndLicenseRoutes(authed *gin.RouterGroup, svc *service.Container) {
|
func registerAuthedUserAndLicenseRoutes(authed *gin.RouterGroup, svc *service.Container) {
|
||||||
authed.GET("/me", meHandler(svc))
|
authed.GET("/me", meHandler(svc))
|
||||||
authed.PATCH("/me", updateProfileHandler(svc))
|
authed.PATCH("/me", updateProfileHandler(svc))
|
||||||
|
authed.GET("/me/pinned-libraries", getPinnedLibrariesHandler(svc))
|
||||||
|
authed.PUT("/me/pinned-libraries", setPinnedLibrariesHandler(svc))
|
||||||
authed.POST("/me/password", changePasswordHandler(svc))
|
authed.POST("/me/password", changePasswordHandler(svc))
|
||||||
authed.POST("/me/logout", logoutHandler(svc))
|
authed.POST("/me/logout", logoutHandler(svc))
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ func TestAuthenticatedRouteSurfacesAreRegistered(t *testing.T) {
|
|||||||
|
|
||||||
for _, want := range []string{
|
for _, want := range []string{
|
||||||
"GET /api/me",
|
"GET /api/me",
|
||||||
|
"GET /api/me/pinned-libraries",
|
||||||
|
"PUT /api/me/pinned-libraries",
|
||||||
"GET /api/auth/permissions",
|
"GET /api/auth/permissions",
|
||||||
"GET /api/libraries",
|
"GET /api/libraries",
|
||||||
"GET /api/media",
|
"GET /api/media",
|
||||||
|
|||||||
@@ -25,6 +25,9 @@ type User struct {
|
|||||||
// 为空时代表不限制(全库可访问)。
|
// 为空时代表不限制(全库可访问)。
|
||||||
AllowedLibraryIDs string `gorm:"type:text" json:"-"`
|
AllowedLibraryIDs string `gorm:"type:text" json:"-"`
|
||||||
AllowedLibraryList []string `gorm:"-" json:"allowed_library_ids,omitempty"`
|
AllowedLibraryList []string `gorm:"-" json:"allowed_library_ids,omitempty"`
|
||||||
|
// PinnedLibraryIDs 存储用户置顶的媒体库 ID 列表(JSON 字符串),顺序即置顶优先级。
|
||||||
|
PinnedLibraryIDs string `gorm:"type:text" json:"-"`
|
||||||
|
PinnedLibraryList []string `gorm:"-" json:"pinned_library_ids,omitempty"`
|
||||||
// ExpiredAt is the account expiry time. Nil means the account never
|
// ExpiredAt is the account expiry time. Nil means the account never
|
||||||
// expires. When set and in the past, the account is treated as expired
|
// expires. When set and in the past, the account is treated as expired
|
||||||
// (login blocked) until an admin or a redemption code renews it.
|
// (login blocked) until an admin or a redemption code renews it.
|
||||||
@@ -59,10 +62,30 @@ func (u *User) DecodeAllowedLibraryIDs() []string {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DecodePinnedLibraryIDs 解析 PinnedLibraryIDs 字段。
|
||||||
|
func (u *User) DecodePinnedLibraryIDs() []string {
|
||||||
|
if u == nil || strings.TrimSpace(u.PinnedLibraryIDs) == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var ids []string
|
||||||
|
if err := json.Unmarshal([]byte(u.PinnedLibraryIDs), &ids); err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var out []string
|
||||||
|
for _, id := range ids {
|
||||||
|
trimmed := strings.TrimSpace(id)
|
||||||
|
if trimmed != "" {
|
||||||
|
out = append(out, trimmed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
// PopulateComputedFields 填充非 DB 虚拟计算字段(如 AllowedLibraryList)。
|
// PopulateComputedFields 填充非 DB 虚拟计算字段(如 AllowedLibraryList)。
|
||||||
func (u *User) PopulateComputedFields() {
|
func (u *User) PopulateComputedFields() {
|
||||||
if u == nil {
|
if u == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
u.AllowedLibraryList = u.DecodeAllowedLibraryIDs()
|
u.AllowedLibraryList = u.DecodeAllowedLibraryIDs()
|
||||||
|
u.PinnedLibraryList = u.DecodePinnedLibraryIDs()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package service
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
@@ -81,6 +82,106 @@ func (p *ProfileService) UpdateProfile(ctx context.Context, userID string, patch
|
|||||||
return p.repo.User.FindByID(ctx, userID)
|
return p.repo.User.FindByID(ctx, userID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetPinnedLibraryIDs returns the user's pinned library IDs, filtered to libraries
|
||||||
|
// they can still access.
|
||||||
|
func (p *ProfileService) GetPinnedLibraryIDs(ctx context.Context, userID string) ([]string, error) {
|
||||||
|
user, err := p.repo.User.FindByID(ctx, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if user == nil {
|
||||||
|
return nil, errors.New("user not found")
|
||||||
|
}
|
||||||
|
visibility := UserDefaultMediaVisibility(ctx, p.repo, userID)
|
||||||
|
accessible, err := p.accessibleLibraryIDSet(ctx, visibility)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return filterPinnedLibraryIDs(user.DecodePinnedLibraryIDs(), accessible), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetPinnedLibraryIDs persists the user's pinned library order after filtering to
|
||||||
|
// accessible, enabled libraries.
|
||||||
|
func (p *ProfileService) SetPinnedLibraryIDs(ctx context.Context, userID string, ids []string) ([]string, error) {
|
||||||
|
if userID == "" {
|
||||||
|
return nil, errors.New("missing user id")
|
||||||
|
}
|
||||||
|
user, err := p.repo.User.FindByID(ctx, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if user == nil {
|
||||||
|
return nil, errors.New("user not found")
|
||||||
|
}
|
||||||
|
visibility := UserDefaultMediaVisibility(ctx, p.repo, userID)
|
||||||
|
accessible, err := p.accessibleLibraryIDSet(ctx, visibility)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
normalized := filterPinnedLibraryIDs(normalizePinnedLibraryIDs(ids), accessible)
|
||||||
|
raw, err := json.Marshal(normalized)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := p.repo.User.UpdateFields(ctx, userID, map[string]any{
|
||||||
|
"pinned_library_ids": string(raw),
|
||||||
|
}); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return normalized, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *ProfileService) accessibleLibraryIDSet(ctx context.Context, visibility MediaVisibility) (map[string]struct{}, error) {
|
||||||
|
libs, err := p.repo.Library.List(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out := make(map[string]struct{})
|
||||||
|
for _, lib := range libs {
|
||||||
|
if !lib.Enabled {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !LibraryVisibleForUser(ctx, p.repo, lib, visibility) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out[lib.ID] = struct{}{}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizePinnedLibraryIDs(ids []string) []string {
|
||||||
|
if len(ids) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
seen := make(map[string]struct{}, len(ids))
|
||||||
|
out := make([]string, 0, len(ids))
|
||||||
|
for _, id := range ids {
|
||||||
|
trimmed := strings.TrimSpace(id)
|
||||||
|
if trimmed == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := seen[trimmed]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[trimmed] = struct{}{}
|
||||||
|
out = append(out, trimmed)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func filterPinnedLibraryIDs(ids []string, accessible map[string]struct{}) []string {
|
||||||
|
if len(ids) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := make([]string, 0, len(ids))
|
||||||
|
for _, id := range ids {
|
||||||
|
if _, ok := accessible[id]; ok {
|
||||||
|
out = append(out, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
// AdminUpdateRole lets administrators promote / demote another user. The
|
// AdminUpdateRole lets administrators promote / demote another user. The
|
||||||
// caller is expected to gate the route with AdminRequired.
|
// caller is expected to gate the route with AdminRequired.
|
||||||
func (p *ProfileService) AdminUpdateRole(ctx context.Context, userID, role string) (*model.User, error) {
|
func (p *ProfileService) AdminUpdateRole(ctx context.Context, userID, role string) (*model.User, error) {
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/glebarez/sqlite"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
|
||||||
|
"github.com/truewhile/MeBox/internal/model"
|
||||||
|
"github.com/truewhile/MeBox/internal/repository"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestProfilePinnedLibrariesFiltersInaccessibleAndPreservesOrder(t *testing.T) {
|
||||||
|
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := db.AutoMigrate(&model.User{}, &model.Library{}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
repos := repository.New(db)
|
||||||
|
svc := NewProfileService(zap.NewNop(), repos)
|
||||||
|
|
||||||
|
user := &model.User{Username: "viewer", PasswordHash: "hash", Role: "user"}
|
||||||
|
if err := repos.User.Create(t.Context(), user); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
libA := &model.Library{Name: "Movies", Path: "/media/movies", Type: "movie", Enabled: true}
|
||||||
|
libB := &model.Library{Name: "TV", Path: "/media/tv", Type: "tv", Enabled: true}
|
||||||
|
libHidden := &model.Library{Name: "Adult", Path: "/media/adult", Type: "movie", Enabled: true}
|
||||||
|
for _, lib := range []*model.Library{libA, libB, libHidden} {
|
||||||
|
if err := repos.Library.Create(t.Context(), lib); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := repos.User.UpdateFields(t.Context(), user.ID, map[string]any{
|
||||||
|
"allowed_library_ids": `["` + libA.ID + `","` + libB.ID + `"]`,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := svc.SetPinnedLibraryIDs(t.Context(), user.ID, []string{
|
||||||
|
libB.ID, libHidden.ID, libA.ID, libB.ID, "missing",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SetPinnedLibraryIDs: %v", err)
|
||||||
|
}
|
||||||
|
want := []string{libB.ID, libA.ID}
|
||||||
|
if len(got) != len(want) || got[0] != want[0] || got[1] != want[1] {
|
||||||
|
t.Fatalf("SetPinnedLibraryIDs = %v, want %v", got, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
loaded, err := svc.GetPinnedLibraryIDs(t.Context(), user.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetPinnedLibraryIDs: %v", err)
|
||||||
|
}
|
||||||
|
if len(loaded) != len(want) || loaded[0] != want[0] || loaded[1] != want[1] {
|
||||||
|
t.Fatalf("GetPinnedLibraryIDs = %v, want %v", loaded, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,6 +12,12 @@ export const profileAPI = {
|
|||||||
}) =>
|
}) =>
|
||||||
api.patch<User>('/me', patch).then((r) => r.data),
|
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') =>
|
adminUpdateRole: (id: string, role: 'admin' | 'user') =>
|
||||||
api.patch<User>(`/admin/users/${id}/role`, { role }).then((r) => r.data),
|
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 { HistoryItem } from '../api/playback'
|
||||||
import type { Library, Media } from '../types'
|
import type { Library, Media } from '../types'
|
||||||
import type { SeriesCard } from '../utils/groupSeries'
|
import type { SeriesCard } from '../utils/groupSeries'
|
||||||
|
import { usePinnedLibraries } from '../hooks/usePinnedLibraries'
|
||||||
|
import { sortByPinnedIds } from '../utils/pinnedLibraries'
|
||||||
import {
|
import {
|
||||||
ContinueWatchingSection,
|
ContinueWatchingSection,
|
||||||
HomeCarouselSection,
|
HomeCarouselSection,
|
||||||
@@ -22,6 +24,7 @@ export function HomePage() {
|
|||||||
const [libraryData, setLibraryData] = useState<Record<string, { cards: SeriesCard[]; items: Media[]; total: number }>>({})
|
const [libraryData, setLibraryData] = useState<Record<string, { cards: SeriesCard[]; items: Media[]; total: number }>>({})
|
||||||
const [history, setHistory] = useState<HistoryItem[]>([])
|
const [history, setHistory] = useState<HistoryItem[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
|
const { pinnedIds } = usePinnedLibraries()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
@@ -126,6 +129,8 @@ export function HomePage() {
|
|||||||
return candidateMedia.slice(0, 10)
|
return candidateMedia.slice(0, 10)
|
||||||
}, [libraries, libraryData])
|
}, [libraries, libraryData])
|
||||||
|
|
||||||
|
const sortedLibraries = useMemo(() => sortByPinnedIds(libraries, pinnedIds), [libraries, pinnedIds])
|
||||||
|
|
||||||
const empty =
|
const empty =
|
||||||
!loading &&
|
!loading &&
|
||||||
libraries.length === 0 &&
|
libraries.length === 0 &&
|
||||||
@@ -150,9 +155,9 @@ export function HomePage() {
|
|||||||
{history.length > 0 && <ContinueWatchingSection history={history} />}
|
{history.length > 0 && <ContinueWatchingSection history={history} />}
|
||||||
|
|
||||||
{/* 3. 媒体库卡片区 */}
|
{/* 3. 媒体库卡片区 */}
|
||||||
{libraries.length > 0 && (
|
{sortedLibraries.length > 0 && (
|
||||||
<HomeLibrariesSection
|
<HomeLibrariesSection
|
||||||
libraries={libraries}
|
libraries={sortedLibraries}
|
||||||
libraryData={libraryData}
|
libraryData={libraryData}
|
||||||
libraryCounts={libraryCounts}
|
libraryCounts={libraryCounts}
|
||||||
/>
|
/>
|
||||||
@@ -160,7 +165,7 @@ export function HomePage() {
|
|||||||
|
|
||||||
{/* 4. 各媒体库内容展示行 */}
|
{/* 4. 各媒体库内容展示行 */}
|
||||||
<div className="space-y-10">
|
<div className="space-y-10">
|
||||||
{libraries.map((lib) => {
|
{sortedLibraries.map((lib) => {
|
||||||
const cards = libraryData[lib.id]?.cards || []
|
const cards = libraryData[lib.id]?.cards || []
|
||||||
if (cards.length === 0) return null
|
if (cards.length === 0) return null
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -3,17 +3,18 @@ import { useCallback, useEffect, useMemo, useState } from 'react'
|
|||||||
import { libraryAPI } from '../api/library'
|
import { libraryAPI } from '../api/library'
|
||||||
import { toolsAPI } from '../api/tools'
|
import { toolsAPI } from '../api/tools'
|
||||||
import { openManageLibrariesDialog } from '../components/manageLibrariesDialog'
|
import { openManageLibrariesDialog } from '../components/manageLibrariesDialog'
|
||||||
|
import { usePinnedLibraries } from '../hooks/usePinnedLibraries'
|
||||||
import {
|
import {
|
||||||
LibrariesContent,
|
LibrariesContent,
|
||||||
LibrariesEmptyState,
|
LibrariesEmptyState,
|
||||||
LibrariesHeader,
|
LibrariesHeader,
|
||||||
} from './LibrariesPageSections'
|
} from './LibrariesPageSections'
|
||||||
import type { LibraryPreview } from './librariesPageModel'
|
import type { LibraryPreview } from './librariesPageModel'
|
||||||
import { readPinnedLibraryIds, sortLibraryPreviews, togglePinnedLibraryId } from '../utils/pinnedLibraries'
|
import { sortLibraryPreviews } from '../utils/pinnedLibraries'
|
||||||
|
|
||||||
export function LibrariesPage() {
|
export function LibrariesPage() {
|
||||||
const [previews, setPreviews] = useState<LibraryPreview[]>([])
|
const [previews, setPreviews] = useState<LibraryPreview[]>([])
|
||||||
const [pinnedIds, setPinnedIds] = useState<string[]>(() => readPinnedLibraryIds())
|
const { pinnedIds, loading: pinnedLoading, togglePin } = usePinnedLibraries()
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [repairing, setRepairing] = useState(false)
|
const [repairing, setRepairing] = useState(false)
|
||||||
const [repairEpisodeArtwork, setRepairEpisodeArtwork] = useState(false)
|
const [repairEpisodeArtwork, setRepairEpisodeArtwork] = useState(false)
|
||||||
@@ -62,12 +63,12 @@ export function LibrariesPage() {
|
|||||||
const sortedPreviews = useMemo(() => sortLibraryPreviews(previews, pinnedIds), [previews, pinnedIds])
|
const sortedPreviews = useMemo(() => sortLibraryPreviews(previews, pinnedIds), [previews, pinnedIds])
|
||||||
|
|
||||||
const handleTogglePin = useCallback((libraryId: string) => {
|
const handleTogglePin = useCallback((libraryId: string) => {
|
||||||
setPinnedIds(togglePinnedLibraryId(libraryId))
|
void togglePin(libraryId)
|
||||||
}, [])
|
}, [togglePin])
|
||||||
|
|
||||||
const total = useMemo(() => previews.reduce((sum, preview) => sum + preview.total, 0), [previews])
|
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>
|
return <p className="px-2 py-8 text-sm text-sand-500">媒体库加载中…</p>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ export interface User {
|
|||||||
force_password_reset: boolean
|
force_password_reset: boolean
|
||||||
is_active: boolean
|
is_active: boolean
|
||||||
allowed_library_ids?: string[]
|
allowed_library_ids?: string[]
|
||||||
|
pinned_library_ids?: string[]
|
||||||
is_default_admin?: boolean
|
is_default_admin?: boolean
|
||||||
is_protected?: boolean
|
is_protected?: boolean
|
||||||
realtime_online?: boolean
|
realtime_online?: boolean
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
|
import { profileAPI } from '../api/profile'
|
||||||
|
|
||||||
const STORAGE_KEY = 'mebox_pinned_libraries'
|
const STORAGE_KEY = 'mebox_pinned_libraries'
|
||||||
|
const MIGRATION_KEY = 'mebox_pinned_libraries_migrated'
|
||||||
|
|
||||||
function parsePinnedIds(raw: string | null): string[] {
|
function parsePinnedIds(raw: string | null): string[] {
|
||||||
if (!raw) return []
|
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 []
|
if (typeof window === 'undefined') return []
|
||||||
return parsePinnedIds(window.localStorage.getItem(STORAGE_KEY))
|
return parsePinnedIds(window.localStorage.getItem(STORAGE_KEY))
|
||||||
}
|
}
|
||||||
|
|
||||||
export function writePinnedLibraryIds(ids: string[]): void {
|
function writeLegacyPinnedLibraryIds(ids: string[]): void {
|
||||||
if (typeof window === 'undefined') return
|
if (typeof window === 'undefined') return
|
||||||
|
if (ids.length === 0) {
|
||||||
|
window.localStorage.removeItem(STORAGE_KEY)
|
||||||
|
return
|
||||||
|
}
|
||||||
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(ids))
|
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(ids))
|
||||||
}
|
}
|
||||||
|
|
||||||
export function togglePinnedLibraryId(id: string): string[] {
|
function markPinnedLibrariesMigrated(): void {
|
||||||
const current = readPinnedLibraryIds()
|
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 index = current.indexOf(id)
|
||||||
const next = index >= 0 ? current.filter((item) => item !== id) : [...current, id]
|
return index >= 0 ? current.filter((item) => item !== id) : [...current, id]
|
||||||
writePinnedLibraryIds(next)
|
|
||||||
return next
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isLibraryPinned(id: string, pinnedIds: string[]): boolean {
|
export function isLibraryPinned(id: string, pinnedIds: string[]): boolean {
|
||||||
return pinnedIds.includes(id)
|
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[] {
|
export function sortLibraryPreviews<T extends { library: { id: string } }>(items: T[], pinnedIds: string[]): T[] {
|
||||||
if (pinnedIds.length === 0) return items
|
if (pinnedIds.length === 0) return items
|
||||||
const rank = new Map(pinnedIds.map((id, index) => [id, index]))
|
const rank = new Map(pinnedIds.map((id, index) => [id, index]))
|
||||||
|
|||||||
Reference in New Issue
Block a user