diff --git a/internal/handler/pinned_libraries.go b/internal/handler/pinned_libraries.go index f977d46..3ed533e 100644 --- a/internal/handler/pinned_libraries.go +++ b/internal/handler/pinned_libraries.go @@ -6,6 +6,7 @@ import ( "github.com/gin-gonic/gin" "github.com/truewhile/MeBox/internal/middleware" + "github.com/truewhile/MeBox/internal/model" "github.com/truewhile/MeBox/internal/service" ) @@ -13,6 +14,45 @@ type pinnedLibrariesReq struct { LibraryIDs []string `json:"library_ids"` } +type libraryTagsReq struct { + Tags []model.LibraryTagSet `json:"tags"` +} + +func getLibraryTagsHandler(svc *service.Container) gin.HandlerFunc { + return func(c *gin.Context) { + uid, _ := c.Get(middleware.CtxUserID) + tags, err := svc.Profile.GetLibraryTags(c.Request.Context(), uid.(string)) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + if tags == nil { + tags = []model.LibraryTagSet{} + } + c.JSON(http.StatusOK, gin.H{"tags": tags}) + } +} + +func setLibraryTagsHandler(svc *service.Container) gin.HandlerFunc { + return func(c *gin.Context) { + var req libraryTagsReq + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + uid, _ := c.Get(middleware.CtxUserID) + tags, err := svc.Profile.SetLibraryTags(c.Request.Context(), uid.(string), req.Tags) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + if tags == nil { + tags = []model.LibraryTagSet{} + } + c.JSON(http.StatusOK, gin.H{"tags": tags}) + } +} + func getPinnedLibrariesHandler(svc *service.Container) gin.HandlerFunc { return func(c *gin.Context) { uid, _ := c.Get(middleware.CtxUserID) diff --git a/internal/handler/routes_authenticated_core.go b/internal/handler/routes_authenticated_core.go index 81d8a95..72ed820 100644 --- a/internal/handler/routes_authenticated_core.go +++ b/internal/handler/routes_authenticated_core.go @@ -12,6 +12,8 @@ func registerAuthedUserAndLicenseRoutes(authed *gin.RouterGroup, svc *service.Co authed.PATCH("/me", updateProfileHandler(svc)) authed.GET("/me/pinned-libraries", getPinnedLibrariesHandler(svc)) authed.PUT("/me/pinned-libraries", setPinnedLibrariesHandler(svc)) + authed.GET("/me/library-tags", getLibraryTagsHandler(svc)) + authed.PUT("/me/library-tags", setLibraryTagsHandler(svc)) authed.POST("/me/password", changePasswordHandler(svc)) authed.POST("/me/logout", logoutHandler(svc)) authed.GET("/me/temporary-password", temporaryPasswordHandler(svc)) diff --git a/internal/handler/routes_authenticated_test.go b/internal/handler/routes_authenticated_test.go index b62a443..d7e6ae1 100644 --- a/internal/handler/routes_authenticated_test.go +++ b/internal/handler/routes_authenticated_test.go @@ -27,6 +27,8 @@ func TestAuthenticatedRouteSurfacesAreRegistered(t *testing.T) { "GET /api/me", "GET /api/me/pinned-libraries", "PUT /api/me/pinned-libraries", + "GET /api/me/library-tags", + "PUT /api/me/library-tags", "GET /api/auth/permissions", "GET /api/libraries", "GET /api/media", diff --git a/internal/model/library_tag.go b/internal/model/library_tag.go new file mode 100644 index 0000000..dc68772 --- /dev/null +++ b/internal/model/library_tag.go @@ -0,0 +1,118 @@ +package model + +import ( + "encoding/json" + "strings" +) + +// MaxLibraryTags 是单个用户可创建的标签数量上限,避免恶意写入过大的 JSON。 +const MaxLibraryTags = 50 + +// MaxLibraryTagNameLen 是单个标签名的最大字符长度(按 rune 计数)。 +const MaxLibraryTagNameLen = 24 + +// LibraryTagSet 是用户自定义的媒体库标签分组。 +// LibraryIDs 保存该标签下媒体库的 ID(含远程 Emby 挂载库的 embyremote~ 形式), +// 顺序即媒体库在该标签内的展示顺序。 +type LibraryTagSet struct { + Name string `json:"name"` + LibraryIDs []string `json:"library_ids"` +} + +// DecodeLibraryTags 解析 LibraryTags 字段,忽略损坏的数据。 +func (u *User) DecodeLibraryTags() []LibraryTagSet { + if u == nil || strings.TrimSpace(u.LibraryTags) == "" { + return nil + } + var tags []LibraryTagSet + if err := json.Unmarshal([]byte(u.LibraryTags), &tags); err != nil { + return nil + } + out := make([]LibraryTagSet, 0, len(tags)) + for _, tag := range NormalizeLibraryTags(tags) { + out = append(out, tag) + } + if len(out) == 0 { + return nil + } + return out +} + +// EncodeLibraryTags 把标签集合序列化为可写入 LibraryTags 字段的 JSON 字符串。 +// 空集合序列化为空字符串,便于用零值表达"没有标签"。 +func EncodeLibraryTags(tags []LibraryTagSet) (string, error) { + if len(tags) == 0 { + return "", nil + } + raw, err := json.Marshal(tags) + if err != nil { + return "", err + } + return string(raw), nil +} + +// NormalizeLibraryTags 清洗标签集合:去空白、丢弃空名标签、去掉标签名与 +// 标签内媒体库 ID 的重复项,并把标签名重复的项合并。保持传入顺序。 +func NormalizeLibraryTags(tags []LibraryTagSet) []LibraryTagSet { + if len(tags) == 0 { + return nil + } + out := make([]LibraryTagSet, 0, len(tags)) + indexByName := make(map[string]int, len(tags)) + for _, tag := range tags { + name := TruncateLibraryTagName(tag.Name) + if name == "" { + continue + } + if len(out) >= MaxLibraryTags { + break + } + key := strings.ToLower(name) + pos, exists := indexByName[key] + if !exists { + if len(out) >= MaxLibraryTags { + break + } + out = append(out, LibraryTagSet{Name: name, LibraryIDs: []string{}}) + pos = len(out) - 1 + indexByName[key] = pos + } + seen := make(map[string]struct{}, len(out[pos].LibraryIDs)) + for _, id := range out[pos].LibraryIDs { + seen[id] = struct{}{} + } + for _, id := range tag.LibraryIDs { + trimmed := strings.TrimSpace(id) + if trimmed == "" { + continue + } + if _, ok := seen[trimmed]; ok { + continue + } + seen[trimmed] = struct{}{} + out[pos].LibraryIDs = append(out[pos].LibraryIDs, trimmed) + } + } + if len(out) == 0 { + return nil + } + for i := range out { + if out[i].LibraryIDs == nil { + out[i].LibraryIDs = []string{} + } + } + return out +} + +// TruncateLibraryTagName 去掉首尾空白并按 rune 截断到长度上限。 +func TruncateLibraryTagName(name string) string { + name = strings.TrimSpace(name) + if name == "" { + return "" + } + runes := []rune(name) + if len(runes) > MaxLibraryTagNameLen { + runes = runes[:MaxLibraryTagNameLen] + } + return strings.TrimSpace(string(runes)) +} diff --git a/internal/model/library_tag_test.go b/internal/model/library_tag_test.go new file mode 100644 index 0000000..7294067 --- /dev/null +++ b/internal/model/library_tag_test.go @@ -0,0 +1,79 @@ +package model + +import "testing" + +func TestNormalizeLibraryTagsMergesDuplicatesAndTrims(t *testing.T) { + tags := []LibraryTagSet{ + {Name: " 动画 ", LibraryIDs: []string{" a ", "", "b", "a"}}, + {Name: "动画", LibraryIDs: []string{"c", "b"}}, + {Name: " ", LibraryIDs: []string{"x"}}, + {Name: "电影", LibraryIDs: nil}, + } + got := NormalizeLibraryTags(tags) + if len(got) != 2 { + t.Fatalf("NormalizeLibraryTags len = %d, want 2 (%#v)", len(got), got) + } + if got[0].Name != "动画" { + t.Fatalf("first tag name = %q, want 动画", got[0].Name) + } + want := []string{"a", "b", "c"} + if len(got[0].LibraryIDs) != len(want) { + t.Fatalf("first tag ids = %v, want %v", got[0].LibraryIDs, want) + } + for i := range want { + if got[0].LibraryIDs[i] != want[i] { + t.Fatalf("first tag ids = %v, want %v", got[0].LibraryIDs, want) + } + } + if got[1].Name != "电影" || got[1].LibraryIDs == nil { + t.Fatalf("empty tag should survive with an empty non-nil id list: %#v", got[1]) + } +} + +func TestNormalizeLibraryTagsCapsCountAndNameLength(t *testing.T) { + long := make([]rune, MaxLibraryTagNameLen+10) + for i := range long { + long[i] = 'x' + } + got := NormalizeLibraryTags([]LibraryTagSet{{Name: string(long)}}) + if len(got) != 1 { + t.Fatalf("len = %d, want 1", len(got)) + } + if runes := []rune(got[0].Name); len(runes) != MaxLibraryTagNameLen { + t.Fatalf("name length = %d, want %d", len(runes), MaxLibraryTagNameLen) + } + + many := make([]LibraryTagSet, 0, MaxLibraryTags+5) + for i := 0; i < MaxLibraryTags+5; i++ { + many = append(many, LibraryTagSet{Name: string(rune('a' + i%26)) + "-" + string(rune('a'+i/26))}) + } + if capped := NormalizeLibraryTags(many); len(capped) > MaxLibraryTags { + t.Fatalf("capped len = %d, want <= %d", len(capped), MaxLibraryTags) + } +} + +func TestEncodeDecodeLibraryTagsRoundTrip(t *testing.T) { + user := &User{} + if encoded, err := EncodeLibraryTags(nil); err != nil || encoded != "" { + t.Fatalf("EncodeLibraryTags(nil) = %q, %v; want \"\", nil", encoded, err) + } + raw, err := EncodeLibraryTags([]LibraryTagSet{{Name: "动画", LibraryIDs: []string{"lib-1"}}}) + if err != nil { + t.Fatalf("EncodeLibraryTags: %v", err) + } + user.LibraryTags = raw + decoded := user.DecodeLibraryTags() + if len(decoded) != 1 || decoded[0].Name != "动画" || len(decoded[0].LibraryIDs) != 1 || decoded[0].LibraryIDs[0] != "lib-1" { + t.Fatalf("DecodeLibraryTags = %#v", decoded) + } + + user.LibraryTags = "{not json" + if decoded := user.DecodeLibraryTags(); decoded != nil { + t.Fatalf("corrupt payload should decode to nil, got %#v", decoded) + } + + user.PopulateComputedFields() + if user.LibraryTagList != nil { + t.Fatalf("PopulateComputedFields should mirror DecodeLibraryTags, got %#v", user.LibraryTagList) + } +} diff --git a/internal/model/user.go b/internal/model/user.go index 6750b87..8de221c 100644 --- a/internal/model/user.go +++ b/internal/model/user.go @@ -28,6 +28,11 @@ type User struct { // PinnedLibraryIDs 存储用户置顶的媒体库 ID 列表(JSON 字符串),顺序即置顶优先级。 PinnedLibraryIDs string `gorm:"type:text" json:"-"` PinnedLibraryList []string `gorm:"-" json:"pinned_library_ids,omitempty"` + // LibraryTags 存储用户自定义的媒体库标签分组(JSON 字符串), + // 形如 [{"name":"动画","library_ids":["lib-1","lib-2"]}]。标签属于用户本人, + // 用于在媒体库页把同一标签下的媒体库聚合到一起。 + LibraryTags string `gorm:"type:text" json:"-"` + LibraryTagList []LibraryTagSet `gorm:"-" json:"library_tags,omitempty"` // SubtitleChineseMode 是网页播放器外挂字幕的简繁转换偏好: // original / simplified / traditional。 SubtitleChineseMode string `gorm:"size:16;not null;default:original" json:"subtitle_chinese_mode"` @@ -103,4 +108,5 @@ func (u *User) PopulateComputedFields() { } u.AllowedLibraryList = u.DecodeAllowedLibraryIDs() u.PinnedLibraryList = u.DecodePinnedLibraryIDs() + u.LibraryTagList = u.DecodeLibraryTags() } diff --git a/internal/service/profile.go b/internal/service/profile.go index c5127de..ea958ed 100644 --- a/internal/service/profile.go +++ b/internal/service/profile.go @@ -144,6 +144,85 @@ func (p *ProfileService) SetPinnedLibraryIDs(ctx context.Context, userID string, return normalized, nil } +// GetLibraryTags returns the user's library tag groups, filtered to libraries +// the user can still access. Empty tags are kept so an editor does not lose a +// tag that was just created. +func (p *ProfileService) GetLibraryTags(ctx context.Context, userID string) ([]model.LibraryTagSet, 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") + } + tags := model.NormalizeLibraryTags(user.DecodeLibraryTags()) + if len(tags) == 0 { + return []model.LibraryTagSet{}, nil + } + visibility := UserDefaultMediaVisibility(ctx, p.repo, userID) + accessible, err := p.accessibleLibraryIDSet(ctx, visibility) + if err != nil { + return nil, err + } + for i := range tags { + tags[i].LibraryIDs = filterPinnedLibraryIDs(tags[i].LibraryIDs, accessible) + if tags[i].LibraryIDs == nil { + tags[i].LibraryIDs = []string{} + } + } + return tags, nil +} + +// SetLibraryTags persists the user's library tag groups after dropping +// inaccessible libraries. A library belongs to at most one tag: the first tag +// that lists it wins, so the saved state always matches the tab UI. +func (p *ProfileService) SetLibraryTags(ctx context.Context, userID string, tags []model.LibraryTagSet) ([]model.LibraryTagSet, 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 := model.NormalizeLibraryTags(tags) + if len(normalized) > model.MaxLibraryTags { + normalized = normalized[:model.MaxLibraryTags] + } + claimed := make(map[string]struct{}) + for i := range normalized { + filtered := make([]string, 0, len(normalized[i].LibraryIDs)) + for _, id := range filterPinnedLibraryIDs(normalized[i].LibraryIDs, accessible) { + if _, taken := claimed[id]; taken { + continue + } + claimed[id] = struct{}{} + filtered = append(filtered, id) + } + normalized[i].LibraryIDs = filtered + } + raw, err := model.EncodeLibraryTags(normalized) + if err != nil { + return nil, err + } + if err := p.repo.User.UpdateFields(ctx, userID, map[string]any{ + "library_tags": raw, + }); err != nil { + return nil, err + } + if normalized == nil { + normalized = []model.LibraryTagSet{} + } + 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 { diff --git a/internal/service/profile_library_tags_test.go b/internal/service/profile_library_tags_test.go new file mode 100644 index 0000000..a33187d --- /dev/null +++ b/internal/service/profile_library_tags_test.go @@ -0,0 +1,150 @@ +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 TestProfileLibraryTagsFiltersInaccessibleAndKeepsOneTagPerLibrary(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{}, &model.EmbyMount{}); 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.SetLibraryTags(t.Context(), user.ID, []model.LibraryTagSet{ + {Name: " 热门 ", LibraryIDs: []string{libA.ID, libHidden.ID, "missing", libA.ID}}, + {Name: "热门", LibraryIDs: []string{libB.ID, libA.ID}}, + {Name: "", LibraryIDs: []string{libB.ID}}, + }) + if err != nil { + t.Fatalf("SetLibraryTags: %v", err) + } + if len(got) != 1 { + t.Fatalf("SetLibraryTags returned %d tags, want 1 (%#v)", len(got), got) + } + if got[0].Name != "热门" { + t.Fatalf("tag name = %q, want 热门", got[0].Name) + } + // libA 已在第一个标签里占位,第二个标签里的 libA 应被丢弃。 + want := []string{libA.ID, libB.ID} + if len(got[0].LibraryIDs) != len(want) { + t.Fatalf("tag ids = %v, want %v", got[0].LibraryIDs, want) + } + for i := range want { + if got[0].LibraryIDs[i] != want[i] { + t.Fatalf("tag ids = %v, want %v", got[0].LibraryIDs, want) + } + } + + loaded, err := svc.GetLibraryTags(t.Context(), user.ID) + if err != nil { + t.Fatalf("GetLibraryTags: %v", err) + } + if len(loaded) != 1 || loaded[0].Name != "热门" || len(loaded[0].LibraryIDs) != len(want) { + t.Fatalf("GetLibraryTags = %#v, want one tag with %v", loaded, want) + } +} + +func TestProfileLibraryTagsKeepsEmptyTagAndMountedEmby(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{}, &model.EmbyMount{}); 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) + } + mount := &model.EmbyMount{ + AccountID: "acct-1", + RemoteViewID: "view-42", + RemoteViewName: "Remote Movies", + Enabled: true, + } + if err := repos.EmbyMount.Create(t.Context(), mount); err != nil { + t.Fatal(err) + } + remoteID := EncodeEmbyRemoteID(mount.ID, mount.RemoteViewID) + + got, err := svc.SetLibraryTags(t.Context(), user.ID, []model.LibraryTagSet{ + {Name: "远程", LibraryIDs: []string{remoteID, "embyremote~missing~view"}}, + {Name: "以后再用", LibraryIDs: []string{}}, + }) + if err != nil { + t.Fatalf("SetLibraryTags: %v", err) + } + if len(got) != 2 { + t.Fatalf("SetLibraryTags = %#v, want 2 tags", got) + } + if len(got[0].LibraryIDs) != 1 || got[0].LibraryIDs[0] != remoteID { + t.Fatalf("remote tag ids = %v, want [%s]", got[0].LibraryIDs, remoteID) + } + if got[1].Name != "以后再用" || len(got[1].LibraryIDs) != 0 { + t.Fatalf("empty tag should survive as-is, got %#v", got[1]) + } + + loaded, err := svc.GetLibraryTags(t.Context(), user.ID) + if err != nil { + t.Fatalf("GetLibraryTags: %v", err) + } + if len(loaded) != 2 || len(loaded[1].LibraryIDs) != 0 { + t.Fatalf("GetLibraryTags = %#v, want the empty tag preserved", loaded) + } +} + +func TestProfileLibraryTagsEmptyStateReturnsEmptySlice(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{}, &model.EmbyMount{}); 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) + } + if tags, err := svc.GetLibraryTags(t.Context(), user.ID); err != nil || tags == nil || len(tags) != 0 { + t.Fatalf("GetLibraryTags = %#v, %v; want empty non-nil slice", tags, err) + } + if tags, err := svc.SetLibraryTags(t.Context(), user.ID, nil); err != nil || tags == nil || len(tags) != 0 { + t.Fatalf("SetLibraryTags(nil) = %#v, %v; want empty non-nil slice", tags, err) + } +} diff --git a/web/src/api/profile.ts b/web/src/api/profile.ts index dcfa6ce..be82318 100644 --- a/web/src/api/profile.ts +++ b/web/src/api/profile.ts @@ -1,5 +1,5 @@ import { api } from './client' -import type { User } from '../types' +import type { LibraryTagSet, User } from '../types' export const profileAPI = { get: () => api.get('/me').then((r) => r.data), @@ -21,6 +21,12 @@ export const profileAPI = { setPinnedLibraries: (libraryIds: string[]) => api.put<{ library_ids: string[] }>('/me/pinned-libraries', { library_ids: libraryIds }).then((r) => r.data.library_ids ?? []), + getLibraryTags: () => + api.get<{ tags: LibraryTagSet[] | null }>('/me/library-tags').then((r) => r.data.tags ?? []), + + setLibraryTags: (tags: LibraryTagSet[]) => + api.put<{ tags: LibraryTagSet[] | null }>('/me/library-tags', { tags }).then((r) => r.data.tags ?? []), + adminUpdateRole: (id: string, role: 'admin' | 'user') => api.patch(`/admin/users/${id}/role`, { role }).then((r) => r.data), } diff --git a/web/src/components/LibraryTagBar.tsx b/web/src/components/LibraryTagBar.tsx new file mode 100644 index 0000000..b5f8b6b --- /dev/null +++ b/web/src/components/LibraryTagBar.tsx @@ -0,0 +1,164 @@ +import { useEffect, useMemo, useRef, useState } from 'react' +import { Layers, Plus, Settings2, X } from 'lucide-react' + +import { MAX_LIBRARY_TAGS } from '../utils/libraryTags' +import type { LibraryTagTab } from '../utils/libraryTags' + +/** + * 媒体库标签栏:固定「全部」+ 每个标签一个页签,选中后只展示该标签下的媒体库。 + * 只是管理员/普通用户都能用的展示层过滤,不改动媒体库本身。 + */ +export function LibraryTagBar({ + tabs, + selectedTagId, + onSelect, + onCreate, + onManage, + manageLabel = '管理标签', + showCreate = true, + busy = false, +}: { + tabs: LibraryTagTab[] + selectedTagId: string + onSelect: (tagId: string) => void + onCreate?: (name: string) => void + onManage?: () => void + manageLabel?: string + showCreate?: boolean + busy?: boolean +}) { + const [creating, setCreating] = useState(false) + const [draft, setDraft] = useState('') + const inputRef = useRef(null) + // 同一帧内 blur 与 Enter 都可能触发提交:用一个标记保证只提交一次。 + const submittedRef = useRef(false) + + useEffect(() => { + if (creating) { + submittedRef.current = false + inputRef.current?.focus() + } + }, [creating]) + + const hasTags = useMemo(() => tabs.some((tab) => !tab.isAll), [tabs]) + + const submit = () => { + if (submittedRef.current) return + submittedRef.current = true + const name = draft.trim() + setDraft('') + setCreating(false) + if (!name) return + onCreate?.(name) + } + + const cancel = () => { + submittedRef.current = true + setDraft('') + setCreating(false) + } + + return ( +
+
+ {tabs.map((tab) => { + const active = tab.id === selectedTagId + return ( + + ) + })} +
+ + {(showCreate || onManage) && ( +
+ {showCreate && onCreate && (creating || !hasTags) && ( +
+ setDraft(event.target.value)} + onKeyDown={(event) => { + if (event.key === 'Enter') { + event.preventDefault() + submit() + } + if (event.key === 'Escape') { + event.preventDefault() + setDraft('') + setCreating(false) + } + }} + onBlur={() => submit()} + maxLength={24} + placeholder="新标签名" + className="h-6 w-24 bg-transparent text-xs font-semibold text-[var(--app-text)] outline-none placeholder:text-[var(--app-muted)]" + title={`最多 24 个字,当前最多 ${MAX_LIBRARY_TAGS} 个标签`} + /> + + +
+ )} + + {showCreate && onCreate && !creating && hasTags && ( + + )} + + {onManage && ( + + )} +
+ )} +
+ ) +} diff --git a/web/src/components/ManageLibraryTagsDialogView.tsx b/web/src/components/ManageLibraryTagsDialogView.tsx new file mode 100644 index 0000000..1a4295e --- /dev/null +++ b/web/src/components/ManageLibraryTagsDialogView.tsx @@ -0,0 +1,304 @@ +import { useEffect, useMemo, useState } from 'react' +import { Check, Loader2, Pencil, Plus, Tag, Trash2, X } from 'lucide-react' +import toast from 'react-hot-toast' + +import { confirmAction } from './confirmAction' +import type { Library } from '../types' +import { MAX_LIBRARY_TAGS, normalizeTagName, tagNameError, type LibraryTag } from '../utils/libraryTags' + +export type ManageLibraryTagsDialogProps = { + tags: LibraryTag[] + libraries: Library[] + saving: boolean + onCreate: (name: string) => Promise + onRename: (from: string, to: string) => Promise + onRemove: (name: string) => Promise + onAssign: (libraryId: string, tagName: string) => Promise + onClose: () => void +} + +/** + * 标签管理对话框:左侧维护标签(新建 / 重命名 / 删除),右侧把每个媒体库归入 + * 某个标签。一个媒体库同时只属于一个标签,未选择即为「未分类」。 + */ +export function ManageLibraryTagsDialogView({ + tags, + libraries, + saving, + onCreate, + onRename, + onRemove, + onAssign, + onClose, +}: ManageLibraryTagsDialogProps) { + const [draftName, setDraftName] = useState('') + const [renaming, setRenaming] = useState('') + const [renameDraft, setRenameDraft] = useState('') + const [busyLibrary, setBusyLibrary] = useState('') + + useEffect(() => { + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') onClose() + } + window.addEventListener('keydown', onKeyDown) + return () => window.removeEventListener('keydown', onKeyDown) + }, [onClose]) + + const tagByLibrary = useMemo(() => { + const map = new Map() + for (const tag of tags) { + for (const id of tag.library_ids) { + if (!map.has(id)) map.set(id, tag.name) + } + } + return map + }, [tags]) + + const handleCreate = async () => { + const error = tagNameError(draftName, tags) + if (error) { + toast.error(error) + return + } + if (tags.length >= MAX_LIBRARY_TAGS) { + toast.error(`最多 ${MAX_LIBRARY_TAGS} 个标签`) + return + } + const created = await onCreate(normalizeTagName(draftName)) + if (created) { + setDraftName('') + toast.success(`标签「${created}」已创建`) + } else { + toast.error('标签创建失败') + } + } + + const handleRename = async (from: string) => { + const error = tagNameError(renameDraft, tags, from) + if (error) { + toast.error(error) + return + } + await onRename(from, renameDraft) + setRenaming('') + setRenameDraft('') + } + + const handleRemove = async (tag: LibraryTag) => { + const confirmed = await confirmAction({ + title: '删除标签', + message: `确定删除标签「${tag.name}」?\n媒体库本身不会被删除,只会回到「未分类」。`, + confirmText: '删除', + }) + if (!confirmed) return + await onRemove(tag.name) + } + + const handleAssign = async (library: Library, tagName: string) => { + setBusyLibrary(library.id) + try { + await onAssign(library.id, tagName) + } finally { + setBusyLibrary('') + } + } + + return ( +
+
event.stopPropagation()} + > +
+
+ +

管理标签

+ {saving && } +
+ +
+ +
+
+

标签

+
+ setDraftName(event.target.value)} + onKeyDown={(event) => { + if (event.key === 'Enter') { + event.preventDefault() + void handleCreate() + } + }} + maxLength={24} + placeholder="新建标签名" + className="input-base !py-2 text-sm" + /> + +
+ +
+ {tags.length === 0 ? ( +

+ 还没有标签。先创建一个,再把媒体库归入其中。 +

+ ) : ( + tags.map((tag) => ( +
+ {renaming === tag.name ? ( + <> + setRenameDraft(event.target.value)} + onKeyDown={(event) => { + if (event.key === 'Enter') { + event.preventDefault() + void handleRename(tag.name) + } + if (event.key === 'Escape') { + setRenaming('') + setRenameDraft('') + } + }} + maxLength={24} + className="min-w-0 flex-1 rounded-lg border border-[var(--app-border)] bg-[var(--app-panel)] px-2 py-1 text-xs font-semibold text-[var(--app-text)] outline-none focus:border-brand-500" + /> + + + + ) : ( + <> + + {tag.name} + + + {tag.library_ids.length} + + + + + )} +
+ )) + )} +
+
+ +
+

+ 媒体库归类({libraries.length}) +

+
+ {libraries.length === 0 ? ( +

+ 暂无可归类的媒体库。 +

+ ) : ( + libraries.map((library) => ( +
+ + {library.name} + + {busyLibrary === library.id && ( + + )} + +
+ )) + )} +
+
+
+ +
+ 标签按用户保存;一个媒体库同时只属于一个标签。 + +
+
+
+ ) +} diff --git a/web/src/components/manageLibraryTagsDialog.tsx b/web/src/components/manageLibraryTagsDialog.tsx new file mode 100644 index 0000000..ceaa126 --- /dev/null +++ b/web/src/components/manageLibraryTagsDialog.tsx @@ -0,0 +1,27 @@ +import { createRoot } from 'react-dom/client' + +import { + ManageLibraryTagsDialogView, + type ManageLibraryTagsDialogProps, +} from './ManageLibraryTagsDialogView' + +export type OpenManageLibraryTagsDialogOptions = Omit + +/** 以独立根节点打开标签管理对话框,关闭后 resolve。 */ +export function openManageLibraryTagsDialog( + options: OpenManageLibraryTagsDialogOptions, +): Promise { + return new Promise((resolve) => { + const host = document.createElement('div') + document.body.appendChild(host) + const root = createRoot(host) + + const close = () => { + root.unmount() + host.remove() + resolve() + } + + root.render() + }) +} diff --git a/web/src/hooks/useLibraryTags.ts b/web/src/hooks/useLibraryTags.ts new file mode 100644 index 0000000..280a43f --- /dev/null +++ b/web/src/hooks/useLibraryTags.ts @@ -0,0 +1,242 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import toast from 'react-hot-toast' + +import { + ALL_TAG_ID, + attachLibraryToTag, + dedupeLibraryTags, + loadLibraryTags, + normalizeLibraryTags, + normalizeTagName, + readCachedLibraryTags, + readSelectedTagId, + resolveSelectedTagId, + saveLibraryTags, + writeCachedLibraryTags, + writeSelectedTagId, + type LibraryTag, +} from '../utils/libraryTags' + +export type UseLibraryTagsResult = { + /** 当前用户维护的标签(顺序即标签栏顺序)。 */ + tags: LibraryTag[] + /** 标签数据是否仍在首次加载中。 */ + loading: boolean + /** 是否有写入在途(可用于禁用重复点击)。 */ + saving: boolean + /** 接口不可用(例如旧后端)时降级为本地标签。 */ + loadError: boolean + /** 当前选中的标签栏:ALL_TAG_ID 表示「全部」。 */ + selectedTagId: string + setSelectedTagId: (tagId: string) => void + createTag: (name: string) => Promise + renameTag: (from: string, to: string) => Promise + removeTag: (name: string) => Promise + /** 覆盖某个标签下的媒体库集合(保持传入顺序)。 */ + setTagLibraries: (name: string, libraryIds: string[]) => Promise + /** 把媒体库挂到标签下;tagName 为空表示移出所有标签。 */ + assignLibrary: (libraryId: string, tagName: string) => Promise +} + +/** + * 媒体库标签的用户级读写。 + * + * 写入采用「先乐观更新、串行提交、失败回滚」:标签是整份替换(`PUT`), + * 并发请求可能让旧快照最后落库,因此所有写操作串行排队,每次发送的都是 + * 排队时的最新本地状态。接口不可用(旧后端/网络异常)时退回本地存储, + * 标签栏依旧可用,不会因为一次失败就让标签凭空消失。 + */ +export function useLibraryTags(): UseLibraryTagsResult { + const [tags, setTags] = useState([]) + const [loading, setLoading] = useState(true) + const [saving, setSaving] = useState(false) + const [loadError, setLoadError] = useState(false) + const [selectedTagId, setSelectedTagIdState] = useState(() => readSelectedTagId()) + + const tagsRef = useRef([]) + const selectedRef = useRef(selectedTagId) + const savedRef = useRef([]) + const chainRef = useRef>(Promise.resolve()) + const pendingRef = useRef(0) + // 后端没有该接口(旧版本)时退化为仅本地存储,不再反复提示保存失败。 + const localOnlyRef = useRef(false) + + useEffect(() => { + tagsRef.current = tags + }, [tags]) + + useEffect(() => { + selectedRef.current = selectedTagId + }, [selectedTagId]) + + useEffect(() => { + let cancelled = false + setLoading(true) + loadLibraryTags() + .then((rows) => { + if (cancelled) return + tagsRef.current = rows + savedRef.current = rows + setTags(rows) + setLoadError(false) + }) + .catch((err) => { + if (cancelled) return + // 旧后端没有 /me/library-tags:用本地缓存兜底,标签栏照常可用。 + const cached = readCachedLibraryTags() + tagsRef.current = cached + savedRef.current = cached + setTags(cached) + setLoadError(true) + // 接口不存在时(旧后端)只做本地读写,避免每次编辑都弹一次保存失败。 + localOnlyRef.current = isMissingEndpoint(err) + }) + .finally(() => { + if (!cancelled) setLoading(false) + }) + return () => { + cancelled = true + } + }, []) + + // 选中的标签可能被删除,落回「全部」以免标签栏空白。 + useEffect(() => { + const resolved = resolveSelectedTagId(tags, selectedTagId) + if (resolved !== selectedTagId) { + selectedRef.current = resolved + setSelectedTagIdState(resolved) + } + }, [tags, selectedTagId]) + + const setSelectedTagId = useCallback((tagId: string) => { + const next = tagId && tagId !== ALL_TAG_ID ? tagId : ALL_TAG_ID + selectedRef.current = next + writeSelectedTagId(next) + setSelectedTagIdState(next) + }, []) + + const mutate = useCallback((updater: (current: LibraryTag[]) => LibraryTag[]) => { + const rollback = savedRef.current + const next = dedupeLibraryTags(normalizeLibraryTags(updater(tagsRef.current))) + tagsRef.current = next + setTags(next) + + pendingRef.current += 1 + setSaving(true) + const task = chainRef.current.then(async () => { + // 发送排队时的最新本地状态:链上更早的写入已经被后面的状态取代。 + const snapshot = tagsRef.current + if (localOnlyRef.current) { + // 已知后端不支持标签接口:只落本地缓存,不再发请求。 + if (tagsRef.current === snapshot) savedRef.current = snapshot + writeCachedLibraryTags(snapshot) + return + } + try { + const saved = await saveLibraryTags(snapshot) + savedRef.current = saved + setLoadError(false) + if (tagsRef.current === snapshot) { + tagsRef.current = saved + setTags(saved) + } + } catch (err) { + if (isMissingEndpoint(err)) { + // 旧后端不支持标签接口:本次修改保留在本地存储,标签栏照常可用。 + localOnlyRef.current = true + savedRef.current = snapshot + writeCachedLibraryTags(snapshot) + setLoadError(true) + return + } + // 用最后一次成功保存的状态回滚,避免前端显示一份服务端并不存在的标签。 + tagsRef.current = rollback + savedRef.current = rollback + setTags(rollback) + setLoadError(true) + toast.error('标签保存失败,请稍后重试') + } + }) + chainRef.current = task.catch(() => undefined) + return task.finally(() => { + pendingRef.current = Math.max(0, pendingRef.current - 1) + if (pendingRef.current === 0) setSaving(false) + }) + }, []) + + const createTag = useCallback( + async (name: string) => { + const trimmed = normalizeTagName(name) + if (!trimmed) return '' + if (tagsRef.current.some((tag) => tag.name.toLowerCase() === trimmed.toLowerCase())) { + return trimmed + } + await mutate((current) => [...current, { name: trimmed, library_ids: [] }]) + return tagsRef.current.some((tag) => tag.name === trimmed) ? trimmed : '' + }, + [mutate], + ) + + const renameTag = useCallback( + async (from: string, to: string) => { + const trimmed = normalizeTagName(to) + if (!trimmed || trimmed === from) return + await mutate((current) => + current.map((tag) => (tag.name === from ? { ...tag, name: trimmed } : tag)), + ) + // 重命名后选中项要跟着换到新名字,否则标签栏会落回「全部」。 + if (selectedRef.current === from) setSelectedTagId(trimmed) + }, + [mutate, setSelectedTagId], + ) + + const removeTag = useCallback( + async (name: string) => { + await mutate((current) => current.filter((tag) => tag.name !== name)) + if (selectedRef.current === name) setSelectedTagId(ALL_TAG_ID) + }, + [mutate, setSelectedTagId], + ) + + const setTagLibraries = useCallback( + async (name: string, libraryIds: string[]) => { + await mutate((current) => + current.map((tag) => (tag.name === name ? { ...tag, library_ids: libraryIds } : tag)), + ) + }, + [mutate], + ) + + const assignLibrary = useCallback( + async (libraryId: string, tagName: string) => { + await mutate((current) => { + if (tagName) return attachLibraryToTag(current, libraryId, tagName) + return current.map((tag) => ({ + ...tag, + library_ids: tag.library_ids.filter((id) => id !== libraryId), + })) + }) + }, + [mutate], + ) + + return { + tags, + loading, + saving, + loadError, + selectedTagId, + setSelectedTagId, + createTag, + renameTag, + removeTag, + setTagLibraries, + assignLibrary, + } +} + +/** 判断错误是否说明后端没有这个接口(旧版本 / 反向代理未更新)。 */ +function isMissingEndpoint(err: unknown): boolean { + const status = (err as { response?: { status?: number } })?.response?.status + return status === 404 || status === 405 || status === 501 +} diff --git a/web/src/pages/HomePage.tsx b/web/src/pages/HomePage.tsx index 3777c3e..e720442 100644 --- a/web/src/pages/HomePage.tsx +++ b/web/src/pages/HomePage.tsx @@ -6,6 +6,10 @@ import type { HistoryItem } from '../api/playback' import type { Library, Media } from '../types' import type { SeriesCard } from '../utils/groupSeries' import { fetchLibraries, peekLibraries } from '../utils/libraryCache' +import { buildLibraryTagTabs, filterLibrariesByTag } from '../utils/libraryTags' +import { LibraryTagBar } from '../components/LibraryTagBar' +import { openManageLibraryTagsDialog } from '../components/manageLibraryTagsDialog' +import { useLibraryTags } from '../hooks/useLibraryTags' import { usePinnedLibraries } from '../hooks/usePinnedLibraries' import { sortByPinnedIds } from '../utils/pinnedLibraries' import { partitionPreviewIDs } from '../utils/remoteEmby' @@ -30,6 +34,7 @@ export function HomePage() { const [librariesLoading, setLibrariesLoading] = useState(true) const [historyLoading, setHistoryLoading] = useState(true) const { pinnedIds } = usePinnedLibraries() + const libraryTags = useLibraryTags() // 1. 媒体库元数据极速加载(不带 preview,毫秒级秒开首屏)。 // 会话内已有缓存则先用缓存立即渲染,后台仍刷新一次兜底。 @@ -87,6 +92,29 @@ export function HomePage() { const sortedLibraries = useMemo(() => sortByPinnedIds(libraries, pinnedIds), [libraries, pinnedIds]) + // 标签过滤只作用于展示层:选中的标签栏决定哪部分媒体库参与轮播/入口网格/内容行。 + const effectiveTagId = libraryTags.selectedTagId + const tagTabs = useMemo( + () => buildLibraryTagTabs(sortedLibraries, libraryTags.tags), + [sortedLibraries, libraryTags.tags], + ) + const taggedLibraries = useMemo( + () => filterLibrariesByTag(sortedLibraries, libraryTags.tags, effectiveTagId), + [sortedLibraries, libraryTags.tags, effectiveTagId], + ) + + const handleManageTags = useCallback(() => { + void openManageLibraryTagsDialog({ + tags: libraryTags.tags, + libraries, + saving: libraryTags.saving, + onCreate: libraryTags.createTag, + onRename: libraryTags.renameTag, + onRemove: libraryTags.removeTag, + onAssign: libraryTags.assignLibrary, + }) + }, [libraries, libraryTags]) + // 按需拉取卡片预览管理。同一个库可能先以 4 张封面用于入口网格, // 稍后需要 10 张用于内容横排,因此缓存的是已加载数量而不是简单布尔值。 const fetchedPreviewLimitsRef = useRef>(new Map()) @@ -153,14 +181,14 @@ export function HomePage() { // 3. 首屏只预取轮播和前三行所需的预览;入口卡片进入视口后自行按批加载。 useEffect(() => { - if (sortedLibraries.length === 0) return - const carouselLibIds = sortedLibraries + if (taggedLibraries.length === 0) return + const carouselLibIds = taggedLibraries .filter((l) => l.carousel_enabled === true) .map((l) => l.id) - const topRowLibIds = sortedLibraries.slice(0, 3).map((l) => l.id) + const topRowLibIds = taggedLibraries.slice(0, 3).map((l) => l.id) const shelfTargets = Array.from(new Set([...carouselLibIds, ...topRowLibIds])) void fetchPreviews(shelfTargets, 10) - }, [sortedLibraries, fetchPreviews]) + }, [taggedLibraries, fetchPreviews]) // 4. 媒体库展示行渐进流式加载:默认先检视前 3 个库,随向下滚动逐步检视后续库 const INITIAL_ROWS = 3 @@ -171,26 +199,26 @@ export function HomePage() { const revealMoreLibraries = useCallback(() => { setVisibleTargetCount((prev) => { - if (prev >= sortedLibraries.length) return prev - return Math.min(prev + STEP_ROWS, sortedLibraries.length) + if (prev >= taggedLibraries.length) return prev + return Math.min(prev + STEP_ROWS, taggedLibraries.length) }) - }, [sortedLibraries.length]) + }, [taggedLibraries.length]) // 随 visibleTargetCount 增加,按需触发后续库的预览加载 useEffect(() => { - if (sortedLibraries.length === 0) return - const currentTargets = sortedLibraries.slice(0, visibleTargetCount).map((l) => l.id) + if (taggedLibraries.length === 0) return + const currentTargets = taggedLibraries.slice(0, visibleTargetCount).map((l) => l.id) void fetchPreviews(currentTargets, 10) - }, [sortedLibraries, visibleTargetCount, fetchPreviews]) + }, [taggedLibraries, visibleTargetCount, fetchPreviews]) // 当前已拉取并确认有内容的媒体库行 const visibleLibraries = useMemo(() => { - return sortedLibraries + return taggedLibraries .slice(0, visibleTargetCount) .filter((lib) => (libraryData[lib.id]?.cards?.length ?? 0) > 0) - }, [sortedLibraries, visibleTargetCount, libraryData]) + }, [taggedLibraries, visibleTargetCount, libraryData]) - const hasMoreLibraries = visibleTargetCount < sortedLibraries.length + const hasMoreLibraries = visibleTargetCount < taggedLibraries.length // 底部哨兵监听与滚动双保险(触底解锁后续媒体库行) useEffect(() => { @@ -258,7 +286,7 @@ export function HomePage() { // series-type libs (loaded via /series), so gate on cards instead. const carouselItems = useMemo(() => { const candidateMedia: Media[] = [] - const effectiveSelectedIds = libraries + const effectiveSelectedIds = taggedLibraries .filter((l) => l.carousel_enabled === true) .map((l) => l.id) @@ -275,7 +303,7 @@ export function HomePage() { // Fallback to all loaded items with artwork if (candidateMedia.length === 0) { - for (const lib of libraries) { + for (const lib of taggedLibraries) { const data = libraryData[lib.id] if (data) { for (const card of data.cards) { @@ -286,14 +314,13 @@ export function HomePage() { } return candidateMedia.slice(0, 10) - }, [libraries, libraryData]) + }, [taggedLibraries, libraryData]) // 库列表还没回来先展示整页 loading;库为空时再等一下播放记录, // 以免在"空站点"和"有观看记录"两个终态之间闪空白。 if (librariesLoading || (libraries.length === 0 && historyLoading)) { return } - const empty = libraries.length === 0 && history.length === 0 @@ -313,14 +340,32 @@ export function HomePage() { {historyLoading && } {!historyLoading && history.length > 0 && } - {/* 3. 媒体库卡片区(每页展示 20 个媒体库) */} + {/* 3. 媒体库标签栏 + 卡片区(每页展示 20 个媒体库) */} {sortedLibraries.length > 0 && ( - +
+ { + void libraryTags.createTag(name) + }} + onManage={handleManageTags} + busy={libraryTags.saving} + /> + {taggedLibraries.length === 0 ? ( +

+ 「{effectiveTagId}」标签下还没有媒体库,可在「管理标签」里把媒体库归入该标签。 +

+ ) : ( + + )} +
)} {/* 4. 各媒体库内容展示行(向下滑动渐进流式加载,不受上方20个分页限制) */} diff --git a/web/src/pages/LibrariesPage.tsx b/web/src/pages/LibrariesPage.tsx index 7f936bb..5914b03 100644 --- a/web/src/pages/LibrariesPage.tsx +++ b/web/src/pages/LibrariesPage.tsx @@ -2,8 +2,11 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { libraryAPI } from '../api/library' import { toolsAPI } from '../api/tools' +import { LibraryTagBar } from '../components/LibraryTagBar' +import { openManageLibraryTagsDialog } from '../components/manageLibraryTagsDialog' import { openManageLibrariesDialog } from '../components/manageLibrariesDialog' import { useEpisodeArtworkPreference } from '../hooks/useEpisodeArtworkPreference' +import { useLibraryTags } from '../hooks/useLibraryTags' import { usePinnedLibraries } from '../hooks/usePinnedLibraries' import { useAuthStore } from '../stores/auth' import { @@ -15,6 +18,8 @@ import type { LibraryPreview } from './librariesPageModel' import type { Library } from '../types' import type { SeriesCard } from '../utils/groupSeries' import { fetchLibraries, invalidateLibraries, peekLibraries } from '../utils/libraryCache' +import { ALL_TAG_ID, buildLibraryTagTabs, filterLibrariesByTag } from '../utils/libraryTags' +import type { LibraryTag } from '../utils/libraryTags' import { sortLibraryPreviews } from '../utils/pinnedLibraries' import { partitionPreviewIDs } from '../utils/remoteEmby' @@ -23,6 +28,7 @@ export function LibrariesPage() { const [libraries, setLibraries] = useState([]) const [libraryData, setLibraryData] = useState>({}) const { pinnedIds, loading: pinnedLoading, togglePin } = usePinnedLibraries() + const libraryTags = useLibraryTags() const [loading, setLoading] = useState(true) const [repairing, setRepairing] = useState(false) const [repairEpisodeArtwork, setRepairEpisodeArtwork] = useEpisodeArtworkPreference() @@ -146,7 +152,31 @@ export function LibrariesPage() { void togglePin(libraryId) }, [togglePin]) - const total = useMemo(() => previews.reduce((sum, preview) => sum + preview.total, 0), [previews]) + const handleManageTags = useCallback(() => { + void openManageLibraryTagsDialog({ + tags: libraryTags.tags, + libraries, + saving: libraryTags.saving, + onCreate: libraryTags.createTag, + onRename: libraryTags.renameTag, + onRemove: libraryTags.removeTag, + onAssign: libraryTags.assignLibrary, + }) + }, [libraries, libraryTags]) + + const effectiveTagId = libraryTags.selectedTagId + const tagTabs = useMemo( + () => buildLibraryTagTabs(sortedPreviews.map((preview) => preview.library), libraryTags.tags), + [sortedPreviews, libraryTags.tags], + ) + const taggedPreviews = useMemo( + () => filterPreviewsByTag(sortedPreviews, libraryTags.tags, effectiveTagId), + [sortedPreviews, libraryTags.tags, effectiveTagId], + ) + const taggedTotal = useMemo( + () => taggedPreviews.reduce((sum, preview) => sum + preview.total, 0), + [taggedPreviews], + ) if (loading || pinnedLoading) { return

媒体库加载中…

@@ -155,8 +185,8 @@ export function LibrariesPage() { return (
+ { + void libraryTags.createTag(name) + }} + onManage={handleManageTags} + busy={libraryTags.saving} + /> + {previews.length === 0 ? ( + ) : taggedPreviews.length === 0 ? ( +

+ 「{effectiveTagId}」标签下还没有媒体库,可在「管理标签」里把媒体库归入该标签。 +

) : ( ) } + +/** 按选中标签过滤媒体库预览(ALL_TAG_ID 时原样返回,保留置顶/手动排序)。 */ +function filterPreviewsByTag(previews: LibraryPreview[], tags: LibraryTag[], tagId: string): LibraryPreview[] { + if (!tagId || tagId === ALL_TAG_ID) return previews + const ordered = filterLibrariesByTag( + previews.map((preview) => ({ id: preview.library.id })), + tags, + tagId, + ) + const byId = new Map(previews.map((preview) => [preview.library.id, preview])) + const out: LibraryPreview[] = [] + for (const item of ordered) { + const preview = byId.get(item.id) + if (preview) out.push(preview) + } + return out +} diff --git a/web/src/types/auth.ts b/web/src/types/auth.ts index d4cd29a..51620c9 100644 --- a/web/src/types/auth.ts +++ b/web/src/types/auth.ts @@ -1,3 +1,5 @@ +import type { LibraryTagSet } from './library' + export interface User { id: string username: string @@ -16,6 +18,8 @@ export interface User { is_active: boolean allowed_library_ids?: string[] pinned_library_ids?: string[] + /** 当前用户维护的媒体库标签分组。 */ + library_tags?: LibraryTagSet[] is_default_admin?: boolean is_protected?: boolean realtime_online?: boolean diff --git a/web/src/types/library.ts b/web/src/types/library.ts index 4eb8f1c..e696839 100644 --- a/web/src/types/library.ts +++ b/web/src/types/library.ts @@ -28,6 +28,15 @@ export interface Library { total?: number } +/** + * 用户自定义的媒体库标签分组。标签属于当前用户本人,用于在媒体库页把 + * 同一标签下的媒体库聚合到一起。一个媒体库同时只属于一个标签。 + */ +export interface LibraryTagSet { + name: string + library_ids: string[] +} + export interface ScanResult { library_id: string visited: number diff --git a/web/src/utils/libraryTags.test.ts b/web/src/utils/libraryTags.test.ts new file mode 100644 index 0000000..de69038 --- /dev/null +++ b/web/src/utils/libraryTags.test.ts @@ -0,0 +1,94 @@ +import { + ALL_TAG_ID, + attachLibraryToTag, + buildLibraryTagTabs, + dedupeLibraryTags, + detachLibraryFromTag, + filterLibrariesByTag, + normalizeLibraryTags, + normalizeTagName, + resolveSelectedTagId, + tagNameError, +} from './libraryTags.ts' + +function check(name: string, condition: boolean) { + if (!condition) throw new Error(`libraryTags: ${name}`) +} + +const libs = [ + { id: 'lib-a', total: 10 }, + { id: 'lib-b', total: 5 }, + { id: 'lib-c', total: 0 }, +] + +check('empty input normalizes to empty list', normalizeLibraryTags(null).length === 0) +check('trims and drops blank names', normalizeLibraryTags([{ name: ' ', library_ids: [] }]).length === 0) +check( + 'merges duplicate tag names case-insensitively', + normalizeLibraryTags([ + { name: 'Anime', library_ids: ['a'] }, + { name: 'anime', library_ids: ['b', 'a'] }, + ]).length === 1, +) +check( + 'dedupes ids inside one tag', + normalizeLibraryTags([{ name: '动画', library_ids: ['a', '', 'a', 'b'] }])[0].library_ids.join(',') === 'a,b', +) +check('truncates over-long names to 24 chars', Array.from(normalizeTagName('x'.repeat(40))).length === 24) + +const tabs = buildLibraryTagTabs(libs, [ + { name: '动画', library_ids: ['lib-b', 'missing'] }, + { name: '电影', library_ids: [] }, +]) +check('tab bar always starts with 全部', tabs[0].id === ALL_TAG_ID && tabs[0].count === 3) +check('tag tab keeps creation order', tabs[1].name === '动画' && tabs[2].name === '电影') +check('tag count ignores unavailable libraries', tabs[1].count === 1) + +check( + 'filtering keeps tag order and drops unknown ids', + filterLibrariesByTag(libs, [{ name: '动画', library_ids: ['lib-c', 'missing', 'lib-a'] }], '动画') + .map((lib) => lib.id) + .join(',') === 'lib-c,lib-a', +) +check('全部 returns every library in original order', filterLibrariesByTag(libs, [], ALL_TAG_ID).length === 3) +check('unknown tag yields no libraries', filterLibrariesByTag(libs, [], 'nope').length === 0) + +check( + 'selected tag falls back to 全部 when the tag is gone', + resolveSelectedTagId([{ name: '动画', library_ids: [] }], '动画') === '动画' && + resolveSelectedTagId([{ name: '动画', library_ids: [] }], '电影') === ALL_TAG_ID, +) + +const claimed = dedupeLibraryTags([ + { name: '动画', library_ids: ['a', 'b'] }, + { name: '电影', library_ids: ['b', 'c'] }, +]) +check('one library belongs to a single tag', claimed[0].library_ids.join(',') === 'a,b') +check('later tag loses the shared library', claimed[1].library_ids.join(',') === 'c') + +const attached = attachLibraryToTag( + [ + { name: '动画', library_ids: ['a'] }, + { name: '电影', library_ids: ['b'] }, + ], + 'a', + '电影', +) +check('attach moves the library out of its old tag', attached[0].library_ids.length === 0) +check('attach appends to the target tag', attached[1].library_ids.join(',') === 'b,a') + +const detached = detachLibraryFromTag( + [{ name: '电影', library_ids: ['b', 'a'] }], + 'a', + '电影', +) +check('detach removes only the requested library', detached[0].library_ids.join(',') === 'b') + +check('blank name is rejected', tagNameError(' ', []) !== '') +check('duplicate name is rejected', tagNameError('动画', [{ name: '动画', library_ids: [] }]) !== '') +check( + 'renaming a tag to itself is allowed', + tagNameError('动画', [{ name: '动画', library_ids: [] }], '动画') === '', +) + +console.log('libraryTags.test.ts ok') diff --git a/web/src/utils/libraryTags.ts b/web/src/utils/libraryTags.ts new file mode 100644 index 0000000..3399b51 --- /dev/null +++ b/web/src/utils/libraryTags.ts @@ -0,0 +1,234 @@ +import type { Library, LibraryTagSet } from '../types' + +/** 与后端 model.MaxLibraryTagNameLen 保持一致。 */ +export const MAX_TAG_NAME_LENGTH = 24 +/** 与后端 model.MaxLibraryTags 保持一致。 */ +export const MAX_LIBRARY_TAGS = 50 + +/** 「全部」伪标签:不参与持久化,仅用于标签栏选中项。 */ +export const ALL_TAG_ID = '__all__' + +export type LibraryTag = LibraryTagSet + +export type LibraryTagTab = { + /** 选中的稳定标识:ALL_TAG_ID 或标签名。 */ + id: string + name: string + count: number + /** 是否为「全部」标签栏 */ + isAll: boolean +} + +const STORAGE_KEY = 'mebox_library_tags' +const SELECTED_KEY = 'mebox_library_tag_selected' + +/** 标签名去空白并按字符数截断,与后端清洗规则一致。 */ +export function normalizeTagName(name: string): string { + return Array.from(name.trim()).slice(0, MAX_TAG_NAME_LENGTH).join('').trim() +} + +export function normalizeLibraryTags(tags: unknown): LibraryTag[] { + if (!Array.isArray(tags)) return [] + const out: LibraryTag[] = [] + const indexByName = new Map() + for (const raw of tags) { + if (!raw || typeof raw !== 'object') continue + const name = normalizeTagName(String((raw as LibraryTagSet).name ?? '')) + if (!name) continue + const key = name.toLowerCase() + let pos = indexByName.get(key) + if (pos === undefined) { + if (out.length >= MAX_LIBRARY_TAGS) break + out.push({ name, library_ids: [] }) + pos = out.length - 1 + indexByName.set(key, pos) + } + const ids = (raw as LibraryTagSet).library_ids + if (!Array.isArray(ids)) continue + const seen = new Set(out[pos].library_ids) + for (const id of ids) { + const trimmed = String(id ?? '').trim() + if (!trimmed || seen.has(trimmed)) continue + seen.add(trimmed) + out[pos].library_ids.push(trimmed) + } + } + return out +} + +/** 读取本地兜底缓存:接口不可用时标签栏仍然可用。 */ +export function readCachedLibraryTags(): LibraryTag[] { + if (typeof window === 'undefined') return [] + try { + return normalizeLibraryTags(JSON.parse(window.localStorage.getItem(STORAGE_KEY) ?? '[]')) + } catch { + return [] + } +} + +export function writeCachedLibraryTags(tags: LibraryTag[]): void { + if (typeof window === 'undefined') return + try { + if (tags.length === 0) { + window.localStorage.removeItem(STORAGE_KEY) + return + } + window.localStorage.setItem(STORAGE_KEY, JSON.stringify(tags)) + } catch { + // 私密模式等场景忽略存储失败。 + } +} + +export function readSelectedTagId(): string { + if (typeof window === 'undefined') return ALL_TAG_ID + try { + return window.localStorage.getItem(SELECTED_KEY)?.trim() || ALL_TAG_ID + } catch { + return ALL_TAG_ID + } +} + +export function writeSelectedTagId(tagId: string): void { + if (typeof window === 'undefined') return + try { + if (!tagId || tagId === ALL_TAG_ID) { + window.localStorage.removeItem(SELECTED_KEY) + return + } + window.localStorage.setItem(SELECTED_KEY, tagId) + } catch { + // 同上。 + } +} + +export async function loadLibraryTags(): Promise { + const { profileAPI } = await import('../api/profile') + const remote = normalizeLibraryTags(await profileAPI.getLibraryTags()) + writeCachedLibraryTags(remote) + return remote +} + +export async function saveLibraryTags(tags: LibraryTag[]): Promise { + const { profileAPI } = await import('../api/profile') + const saved = normalizeLibraryTags(await profileAPI.setLibraryTags(normalizeLibraryTags(tags))) + writeCachedLibraryTags(saved) + return saved +} + +/** + * 把标签集合中的媒体库 ID 收敛为「每个库只归属一个标签」:越靠前的标签优先, + * 后面的标签里重复出现的库会被移除。与后端 SetLibraryTags 的语义一致。 + */ +export function dedupeLibraryTags(tags: LibraryTag[]): LibraryTag[] { + const claimed = new Set() + return tags.map((tag) => { + const libraryIds: string[] = [] + for (const id of tag.library_ids) { + if (claimed.has(id)) continue + claimed.add(id) + libraryIds.push(id) + } + return { ...tag, library_ids: libraryIds } + }) +} + +/** 标签名是否可用:非空、未超长、且(除了自身以外)没有重名。 */ +export function tagNameError(name: string, tags: LibraryTag[], exceptName?: string): string { + const trimmed = normalizeTagName(name) + if (!trimmed) return '标签名不能为空' + if (Array.from(name.trim()).length > MAX_TAG_NAME_LENGTH) { + return `标签名最多 ${MAX_TAG_NAME_LENGTH} 个字` + } + const key = trimmed.toLowerCase() + const selfKey = exceptName ? normalizeTagName(exceptName).toLowerCase() : '' + if (key !== selfKey && tags.some((tag) => tag.name.toLowerCase() === key)) { + return '标签名已存在' + } + return '' +} + +export function findTagIdByName(tags: LibraryTag[], name: string): string { + const key = normalizeTagName(name).toLowerCase() + if (!key) return '' + return tags.find((tag) => tag.name.toLowerCase() === key)?.name ?? '' +} + +export function tagLibraryIds(tags: LibraryTag[], tagId: string): string[] { + if (!tagId || tagId === ALL_TAG_ID) return [] + const tag = tags.find((item) => item.name === tagId) + return tag ? tag.library_ids : [] +} + +export function isLibraryInTag(tags: LibraryTag[], tagId: string, libraryId: string): boolean { + return tagLibraryIds(tags, tagId).includes(libraryId) +} + +/** + * 按标签过滤媒体库:`tagId` 为「全部」时原样返回(保持原有置顶/手动排序), + * 否则只保留该标签下的库,并保持标签里记录的顺序。 + */ +export function filterLibrariesByTag( + libraries: T[], + tags: LibraryTag[], + tagId: string, +): T[] { + if (!tagId || tagId === ALL_TAG_ID) return libraries + const ids = tagLibraryIds(tags, tagId) + if (ids.length === 0) return [] + const byId = new Map(libraries.map((lib) => [lib.id, lib])) + const out: T[] = [] + for (const id of ids) { + const lib = byId.get(id) + if (lib) out.push(lib) + } + return out +} + +/** 生成标签栏模型:首项固定为「全部」,其后按标签顺序追加。 */ +export function buildLibraryTagTabs(libraries: Library[], tags: LibraryTag[]): LibraryTagTab[] { + const tabs: LibraryTagTab[] = [ + { id: ALL_TAG_ID, name: '全部', count: libraries.length, isAll: true }, + ] + if (tags.length === 0) return tabs + const available = new Set(libraries.map((lib) => lib.id)) + for (const tag of tags) { + tabs.push({ + id: tag.name, + name: tag.name, + count: tag.library_ids.filter((id) => available.has(id)).length, + isAll: false, + }) + } + return tabs +} + +/** 选中的标签是否仍存在;被删除或被过滤掉时回落到「全部」。 */ +export function resolveSelectedTagId(tags: LibraryTag[], selectedId: string): string { + if (!selectedId || selectedId === ALL_TAG_ID) return ALL_TAG_ID + return tags.some((tag) => tag.name === selectedId) ? selectedId : ALL_TAG_ID +} + +/** 标签下的媒体库条目总数(用于标题文案)。 */ +export function sumLibraryTotals(libraries: Library[]): number { + return libraries.reduce((sum, lib) => sum + (lib.total ?? 0), 0) +} + +/** 把一个媒体库挂到标签下:会先从原标签移除,保证一库一标签。 */ +export function attachLibraryToTag(tags: LibraryTag[], libraryId: string, tagName: string): LibraryTag[] { + const target = findTagIdByName(tags, tagName) + if (!target) return tags + const next = tags.map((tag) => ({ + ...tag, + library_ids: tag.library_ids.filter((id) => id !== libraryId), + })) + return next.map((tag) => + tag.name === target ? { ...tag, library_ids: [...tag.library_ids, libraryId] } : tag, + ) +} + +/** 从标签下移除媒体库。 */ +export function detachLibraryFromTag(tags: LibraryTag[], libraryId: string, tagName: string): LibraryTag[] { + return tags.map((tag) => + tag.name === tagName ? { ...tag, library_ids: tag.library_ids.filter((id) => id !== libraryId) } : tag, + ) +}