mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-28 11:16:37 +08:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c723664e46 | |||
| 5faa6975d0 | |||
| cbfae0252e |
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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<User>('/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<User>(`/admin/users/${id}/role`, { role }).then((r) => r.data),
|
||||
}
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { ArrowDown, ArrowUp, ArrowUpDown, Check } from 'lucide-react'
|
||||
|
||||
import {
|
||||
LIBRARY_LIST_SORT_OPTIONS,
|
||||
type LibraryListSortField,
|
||||
type LibraryListSortOption,
|
||||
type LibraryListSortOrder,
|
||||
} from '../utils/libraryListSort'
|
||||
|
||||
type LibraryListSortDropdownProps = {
|
||||
value: LibraryListSortField
|
||||
order: LibraryListSortOrder
|
||||
onChange: (field: LibraryListSortField, order: LibraryListSortOrder) => void
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function LibraryListSortDropdown({
|
||||
value,
|
||||
order,
|
||||
onChange,
|
||||
className = '',
|
||||
}: LibraryListSortDropdownProps) {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const dropdownRef = useRef<HTMLDivElement>(null)
|
||||
const currentOption = LIBRARY_LIST_SORT_OPTIONS.find((opt) => opt.id === value) ?? LIBRARY_LIST_SORT_OPTIONS[0]
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
||||
setIsOpen(false)
|
||||
}
|
||||
}
|
||||
if (isOpen) {
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
}
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside)
|
||||
}
|
||||
}, [isOpen])
|
||||
|
||||
const handleSelect = (option: LibraryListSortOption) => {
|
||||
if (value === option.id) {
|
||||
onChange(option.id, order === 'asc' ? 'desc' : 'asc')
|
||||
} else {
|
||||
onChange(option.id, option.defaultOrder)
|
||||
}
|
||||
setIsOpen(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`relative inline-block text-left ${className}`} ref={dropdownRef}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsOpen((prev) => !prev)}
|
||||
className="inline-flex h-9 items-center gap-1.5 rounded-xl border border-sand-200 bg-white/90 px-3 py-1.5 text-xs font-semibold text-ink-600 shadow-sm transition-all hover:border-brand-300 hover:bg-brand-50/50 hover:text-brand-700 sm:h-10 sm:text-sm"
|
||||
title="更改媒体库列表排序"
|
||||
>
|
||||
<ArrowUpDown size={14} className="text-sand-500" />
|
||||
<span>排序: {currentOption.label}</span>
|
||||
{order === 'asc' ? (
|
||||
<ArrowUp size={13} className="font-bold text-brand-600" />
|
||||
) : (
|
||||
<ArrowDown size={13} className="font-bold text-brand-600" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<div className="absolute left-0 z-50 mt-1.5 w-44 max-w-[calc(100vw-2rem)] origin-top-left rounded-2xl border border-sand-200/80 bg-white/95 p-1.5 shadow-xl backdrop-blur-md transition-all animate-in fade-in-0 zoom-in-95 sm:left-auto sm:right-0 sm:origin-top-right">
|
||||
<div className="border-b border-sand-100 px-2.5 py-1.5 text-[11px] font-bold text-sand-500">
|
||||
排序方式
|
||||
</div>
|
||||
<div className="space-y-0.5 py-1">
|
||||
{LIBRARY_LIST_SORT_OPTIONS.map((option) => {
|
||||
const isSelected = value === option.id
|
||||
return (
|
||||
<button
|
||||
key={option.id}
|
||||
type="button"
|
||||
onClick={() => handleSelect(option)}
|
||||
className={`flex w-full items-center justify-between rounded-xl px-3 py-2 text-xs font-medium transition-colors ${
|
||||
isSelected
|
||||
? 'bg-brand-50 font-semibold text-brand-700'
|
||||
: 'text-ink-600 hover:bg-sand-50 hover:text-brand-600'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{isSelected && <Check size={13} className="text-brand-600" />}
|
||||
<span className={isSelected ? '' : 'pl-5'}>{option.label}</span>
|
||||
</div>
|
||||
{isSelected && (
|
||||
<div className="flex items-center text-brand-600">
|
||||
{order === 'asc' ? (
|
||||
<ArrowUp size={14} className="stroke-[2.5]" />
|
||||
) : (
|
||||
<ArrowDown size={14} className="stroke-[2.5]" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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<HTMLInputElement | null>(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 (
|
||||
<div className="flex flex-wrap items-center gap-2 border-b border-[var(--app-border)] pb-3">
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{tabs.map((tab) => {
|
||||
const active = tab.id === selectedTagId
|
||||
return (
|
||||
<button
|
||||
key={tab.id}
|
||||
type="button"
|
||||
onClick={() => onSelect(tab.id)}
|
||||
aria-pressed={active}
|
||||
className={`inline-flex items-center gap-1.5 rounded-xl border px-3 py-1.5 text-xs font-bold transition-colors ${
|
||||
active
|
||||
? 'border-brand-500/60 bg-brand-500/10 text-brand-600'
|
||||
: 'border-[var(--app-border)] bg-[var(--app-panel-soft)] text-[var(--app-subtle)] hover:border-brand-500/40 hover:text-[var(--app-text)]'
|
||||
}`}
|
||||
title={tab.isAll ? '显示全部媒体库' : `只看「${tab.name}」标签下的媒体库`}
|
||||
>
|
||||
{tab.isAll && <Layers size={13} />}
|
||||
<span className="max-w-[8rem] truncate">{tab.name}</span>
|
||||
<span className={`font-mono text-[10px] ${active ? 'text-brand-500' : 'text-[var(--app-muted)]'}`}>
|
||||
{tab.count}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{(showCreate || onManage) && (
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{showCreate && onCreate && (creating || !hasTags) && (
|
||||
<div className="inline-flex items-center gap-1 rounded-xl border border-[var(--app-border)] bg-[var(--app-panel-soft)] px-2 py-1">
|
||||
<input
|
||||
ref={inputRef}
|
||||
value={draft}
|
||||
onChange={(event) => 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} 个标签`}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onClick={submit}
|
||||
disabled={busy || !draft.trim()}
|
||||
className="rounded-lg p-1 text-brand-500 transition-colors hover:bg-[var(--app-hover)] disabled:opacity-40"
|
||||
title="创建标签"
|
||||
>
|
||||
<Plus size={13} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onClick={cancel}
|
||||
className="rounded-lg p-1 text-[var(--app-muted)] transition-colors hover:bg-[var(--app-hover)]"
|
||||
title="取消"
|
||||
>
|
||||
<X size={13} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showCreate && onCreate && !creating && hasTags && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCreating(true)}
|
||||
disabled={busy || tabs.length - 1 >= MAX_LIBRARY_TAGS}
|
||||
className="inline-flex items-center gap-1 rounded-xl border border-dashed border-[var(--app-border)] px-2.5 py-1.5 text-xs font-bold text-[var(--app-muted)] transition-colors hover:border-brand-500/50 hover:text-brand-500 disabled:opacity-40"
|
||||
title="新建标签"
|
||||
>
|
||||
<Plus size={13} />
|
||||
<span>新建标签</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{onManage && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onManage}
|
||||
className="inline-flex items-center gap-1 rounded-xl border border-[var(--app-border)] bg-[var(--app-panel-soft)] px-2.5 py-1.5 text-xs font-bold text-[var(--app-subtle)] transition-colors hover:border-brand-500/40 hover:text-brand-500"
|
||||
title="重命名 / 删除标签,调整标签下的媒体库"
|
||||
>
|
||||
<Settings2 size={13} />
|
||||
<span>{manageLabel}</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,488 @@
|
||||
import { useCallback, 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,
|
||||
filterLibrariesForTagging,
|
||||
normalizeTagName,
|
||||
tagNameError,
|
||||
type LibraryTag,
|
||||
} from '../utils/libraryTags'
|
||||
|
||||
export type ManageLibraryTagsDialogProps = {
|
||||
tags: LibraryTag[]
|
||||
libraries: Library[]
|
||||
saving: boolean
|
||||
onCreate: (name: string) => Promise<string>
|
||||
onRename: (from: string, to: string) => Promise<void>
|
||||
onRemove: (name: string) => Promise<void>
|
||||
/** 单个媒体库归类;tagName 为空表示移出所有标签。 */
|
||||
onAssign: (libraryId: string, tagName: string) => Promise<void>
|
||||
/** 批量归类;tagName 为空表示批量移出所有标签。一次写入。 */
|
||||
onAssignBatch: (libraryIds: string[], tagName: string) => Promise<void>
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
/** 「未分类」在筛选/批量目标里的哨兵值,与标签名不会冲突。 */
|
||||
const UNTAGGED = '__untagged__'
|
||||
|
||||
/**
|
||||
* 标签管理对话框:左侧维护标签(新建 / 重命名 / 删除),右侧把媒体库归入
|
||||
* 某个标签。一个媒体库同时只属于一个标签,未选择即为「未分类」。
|
||||
*
|
||||
* 右侧支持搜索、按当前标签筛选、多选后一次性批量归类,避免逐条点击下拉框。
|
||||
*/
|
||||
export function ManageLibraryTagsDialogView({
|
||||
tags,
|
||||
libraries,
|
||||
saving,
|
||||
onCreate,
|
||||
onRename,
|
||||
onRemove,
|
||||
onAssign,
|
||||
onAssignBatch,
|
||||
onClose,
|
||||
}: ManageLibraryTagsDialogProps) {
|
||||
const [draftName, setDraftName] = useState('')
|
||||
const [renaming, setRenaming] = useState('')
|
||||
const [renameDraft, setRenameDraft] = useState('')
|
||||
const [busyLibrary, setBusyLibrary] = useState('')
|
||||
const [keyword, setKeyword] = useState('')
|
||||
const [tagFilter, setTagFilter] = useState('')
|
||||
const [selectedIds, setSelectedIds] = useState<string[]>([])
|
||||
const [batchTag, setBatchTag] = useState('')
|
||||
const [applying, setApplying] = useState(false)
|
||||
|
||||
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<string, string>()
|
||||
for (const tag of tags) {
|
||||
for (const id of tag.library_ids) {
|
||||
if (!map.has(id)) map.set(id, tag.name)
|
||||
}
|
||||
}
|
||||
return map
|
||||
}, [tags])
|
||||
|
||||
// 标签被删除/重命名后,批量目标与筛选值可能已经失效,落回默认项。
|
||||
useEffect(() => {
|
||||
if (batchTag && batchTag !== UNTAGGED && !tags.some((tag) => tag.name === batchTag)) {
|
||||
setBatchTag('')
|
||||
}
|
||||
if (tagFilter && tagFilter !== UNTAGGED && !tags.some((tag) => tag.name === tagFilter)) {
|
||||
setTagFilter('')
|
||||
}
|
||||
}, [tags, batchTag, tagFilter])
|
||||
|
||||
// 媒体库被删除后清掉已选项,避免批量应用时带着不存在的 ID。
|
||||
useEffect(() => {
|
||||
const available = new Set(libraries.map((lib) => lib.id))
|
||||
setSelectedIds((prev) => {
|
||||
const next = prev.filter((id) => available.has(id))
|
||||
return next.length === prev.length ? prev : next
|
||||
})
|
||||
}, [libraries])
|
||||
|
||||
const visibleLibraries = useMemo(() => {
|
||||
const searched = filterLibrariesForTagging(libraries, keyword)
|
||||
if (!tagFilter) return searched
|
||||
return searched.filter((lib) => {
|
||||
const current = tagByLibrary.get(lib.id) ?? ''
|
||||
return tagFilter === UNTAGGED ? current === '' : current === tagFilter
|
||||
})
|
||||
}, [libraries, keyword, tagFilter, tagByLibrary])
|
||||
|
||||
const selectedSet = useMemo(() => new Set(selectedIds), [selectedIds])
|
||||
const visibleSelectedCount = visibleLibraries.reduce(
|
||||
(sum, lib) => sum + (selectedSet.has(lib.id) ? 1 : 0),
|
||||
0,
|
||||
)
|
||||
const allVisibleSelected = visibleLibraries.length > 0 && visibleSelectedCount === visibleLibraries.length
|
||||
|
||||
const toggleLibrary = useCallback((libraryId: string) => {
|
||||
setSelectedIds((prev) =>
|
||||
prev.includes(libraryId) ? prev.filter((id) => id !== libraryId) : [...prev, libraryId],
|
||||
)
|
||||
}, [])
|
||||
|
||||
const toggleVisible = useCallback(() => {
|
||||
const visibleIds = visibleLibraries.map((lib) => lib.id)
|
||||
setSelectedIds((prev) => {
|
||||
const current = new Set(prev)
|
||||
const everySelected = visibleIds.length > 0 && visibleIds.every((id) => current.has(id))
|
||||
if (everySelected) return prev.filter((id) => !visibleIds.includes(id))
|
||||
for (const id of visibleIds) current.add(id)
|
||||
return Array.from(current)
|
||||
})
|
||||
}, [visibleLibraries])
|
||||
|
||||
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('')
|
||||
}
|
||||
}
|
||||
|
||||
const handleApplyBatch = async () => {
|
||||
if (selectedIds.length === 0 || applying) return
|
||||
setApplying(true)
|
||||
try {
|
||||
await onAssignBatch(selectedIds, batchTag)
|
||||
const count = selectedIds.length
|
||||
toast.success(
|
||||
batchTag ? `已将 ${count} 个媒体库归入「${batchTag}」` : `已将 ${count} 个媒体库移出标签`,
|
||||
)
|
||||
setSelectedIds([])
|
||||
} finally {
|
||||
setApplying(false)
|
||||
}
|
||||
}
|
||||
|
||||
const busy = saving || applying
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-[100] flex items-center justify-center bg-black/35 p-2 backdrop-blur-sm sm:p-4"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
className="flex h-[calc(100dvh-1rem)] w-full max-w-5xl flex-col overflow-hidden rounded-2xl border border-white/70 bg-[var(--app-panel)] shadow-2xl sm:h-[82vh] sm:rounded-3xl"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between border-b border-[var(--app-border)] px-4 py-3 sm:px-6 sm:py-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Tag size={18} className="text-brand-500" />
|
||||
<h3 className="font-display text-base font-bold text-[var(--app-text)] sm:text-lg">管理标签</h3>
|
||||
{busy && <Loader2 size={14} className="animate-spin text-[var(--app-muted)]" />}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="rounded-xl p-1.5 text-[var(--app-muted)] transition hover:bg-[var(--app-hover)] hover:text-[var(--app-text)]"
|
||||
title="关闭"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid flex-1 grid-cols-1 gap-4 overflow-hidden p-3 sm:p-6 md:grid-cols-[minmax(0,1fr)_minmax(0,1.5fr)]">
|
||||
<section className="flex min-h-0 flex-col gap-3">
|
||||
<h4 className="text-xs font-bold uppercase tracking-widest text-[var(--app-muted)]">标签</h4>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<input
|
||||
value={draftName}
|
||||
onChange={(event) => setDraftName(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault()
|
||||
void handleCreate()
|
||||
}
|
||||
}}
|
||||
maxLength={24}
|
||||
placeholder="新建标签名"
|
||||
className="input-base !py-2 text-sm"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleCreate()}
|
||||
disabled={busy || !draftName.trim()}
|
||||
className="btn-outline shrink-0 !px-3 !py-2 text-xs"
|
||||
title="创建标签"
|
||||
>
|
||||
<Plus size={14} />
|
||||
新建
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 space-y-1.5 overflow-y-auto pr-1">
|
||||
{tags.length === 0 ? (
|
||||
<p className="rounded-xl border border-dashed border-[var(--app-border)] px-3 py-6 text-center text-xs text-[var(--app-muted)]">
|
||||
还没有标签。先创建一个,再把媒体库归入其中。
|
||||
</p>
|
||||
) : (
|
||||
tags.map((tag) => (
|
||||
<div
|
||||
key={tag.name}
|
||||
className="flex items-center gap-2 rounded-xl border border-[var(--app-border)] bg-[var(--app-panel-soft)] px-2.5 py-2"
|
||||
>
|
||||
{renaming === tag.name ? (
|
||||
<>
|
||||
<input
|
||||
autoFocus
|
||||
value={renameDraft}
|
||||
onChange={(event) => 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"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleRename(tag.name)}
|
||||
className="rounded-lg p-1 text-brand-500 transition-colors hover:bg-[var(--app-hover)]"
|
||||
title="保存名称"
|
||||
>
|
||||
<Check size={14} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setRenaming('')
|
||||
setRenameDraft('')
|
||||
}}
|
||||
className="rounded-lg p-1 text-[var(--app-muted)] transition-colors hover:bg-[var(--app-hover)]"
|
||||
title="取消"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTagFilter(tag.name)}
|
||||
className="min-w-0 flex-1 truncate text-left text-sm font-bold text-[var(--app-text)] transition-colors hover:text-brand-500"
|
||||
title={`只看「${tag.name}」标签下的媒体库`}
|
||||
>
|
||||
{tag.name}
|
||||
</button>
|
||||
<span className="shrink-0 font-mono text-[10px] text-[var(--app-muted)]">
|
||||
{tag.library_ids.length}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setRenaming(tag.name)
|
||||
setRenameDraft(tag.name)
|
||||
}}
|
||||
className="rounded-lg p-1 text-[var(--app-muted)] transition-colors hover:bg-[var(--app-hover)] hover:text-brand-500"
|
||||
title="重命名"
|
||||
>
|
||||
<Pencil size={13} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleRemove(tag)}
|
||||
className="rounded-lg p-1 text-[var(--app-muted)] transition-colors hover:bg-[var(--app-hover)] hover:text-red-500"
|
||||
title="删除标签"
|
||||
>
|
||||
<Trash2 size={13} />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="flex min-h-0 flex-col gap-3 border-t border-[var(--app-border)] pt-4 md:border-l md:border-t-0 md:pl-4 md:pt-0">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<h4 className="text-xs font-bold uppercase tracking-widest text-[var(--app-muted)]">
|
||||
媒体库归类({libraries.length})
|
||||
</h4>
|
||||
<span className="text-xs text-[var(--app-muted)]">勾选多个后可一次性设置标签</span>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<input
|
||||
value={keyword}
|
||||
onChange={(event) => setKeyword(event.target.value)}
|
||||
placeholder="搜索名称 / 路径 / 类型"
|
||||
className="input-base h-9 min-w-[10rem] flex-1 !py-0 text-xs"
|
||||
/>
|
||||
<select
|
||||
value={tagFilter}
|
||||
onChange={(event) => setTagFilter(event.target.value)}
|
||||
className="input-base h-9 w-32 shrink-0 !py-0 text-xs"
|
||||
title="筛选媒体库"
|
||||
>
|
||||
<option value="">全部标签</option>
|
||||
<option value={UNTAGGED}>未分类</option>
|
||||
{tags.map((tag) => (
|
||||
<option key={tag.name} value={tag.name}>
|
||||
{tag.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2 rounded-xl border border-[var(--app-border)] bg-[var(--app-panel-soft)] px-2.5 py-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleVisible}
|
||||
disabled={visibleLibraries.length === 0}
|
||||
className="inline-flex items-center gap-1.5 rounded-lg border border-[var(--app-border)] bg-[var(--app-panel)] px-2.5 py-1 text-xs font-bold text-[var(--app-subtle)] transition-colors hover:border-brand-500/40 hover:text-brand-500 disabled:opacity-40"
|
||||
title="全选 / 取消全选当前列表中的媒体库"
|
||||
>
|
||||
<Check size={13} />
|
||||
{allVisibleSelected ? '取消全选' : '全选'}
|
||||
</button>
|
||||
<span className="text-xs font-semibold text-[var(--app-subtle)]">
|
||||
已选 {selectedIds.length} 个
|
||||
</span>
|
||||
<select
|
||||
value={batchTag}
|
||||
onChange={(event) => setBatchTag(event.target.value)}
|
||||
className="input-base ml-auto h-8 w-36 shrink-0 !py-0 text-xs"
|
||||
title="选择要批量设置的标签"
|
||||
>
|
||||
<option value="">未分类(移出标签)</option>
|
||||
{tags.map((tag) => (
|
||||
<option key={tag.name} value={tag.name}>
|
||||
{tag.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleApplyBatch()}
|
||||
disabled={busy || selectedIds.length === 0}
|
||||
className="btn-outline shrink-0 !px-3 !py-1.5 text-xs"
|
||||
title="把选中媒体库的标签一次性设为左侧所选值"
|
||||
>
|
||||
{applying && <Loader2 size={13} className="animate-spin" />}
|
||||
应用到已选
|
||||
</button>
|
||||
{selectedIds.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelectedIds([])}
|
||||
disabled={applying}
|
||||
className="rounded-lg p-1 text-[var(--app-muted)] transition-colors hover:bg-[var(--app-hover)]"
|
||||
title="清空选择"
|
||||
>
|
||||
<X size={13} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 space-y-1.5 overflow-y-auto pr-1">
|
||||
{visibleLibraries.length === 0 ? (
|
||||
<p className="rounded-xl border border-dashed border-[var(--app-border)] px-3 py-6 text-center text-xs text-[var(--app-muted)]">
|
||||
{libraries.length === 0 ? '暂无可归类的媒体库。' : '没有符合当前筛选条件的媒体库。'}
|
||||
</p>
|
||||
) : (
|
||||
visibleLibraries.map((library) => {
|
||||
const current = tagByLibrary.get(library.id) ?? ''
|
||||
const checked = selectedSet.has(library.id)
|
||||
return (
|
||||
<div
|
||||
key={library.id}
|
||||
className={`flex items-center gap-2 rounded-xl border px-2.5 py-2 transition-colors ${
|
||||
checked
|
||||
? 'border-brand-500/50 bg-brand-500/5'
|
||||
: 'border-[var(--app-border)] bg-[var(--app-panel-soft)]'
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={() => toggleLibrary(library.id)}
|
||||
disabled={applying}
|
||||
className="h-4 w-4 shrink-0 accent-brand-400"
|
||||
title="勾选后可批量设置标签"
|
||||
/>
|
||||
<span
|
||||
className="min-w-0 flex-1 truncate text-xs font-semibold text-[var(--app-text)]"
|
||||
title={library.path || library.name}
|
||||
>
|
||||
{library.name}
|
||||
</span>
|
||||
{busyLibrary === library.id && (
|
||||
<Loader2 size={13} className="shrink-0 animate-spin text-[var(--app-muted)]" />
|
||||
)}
|
||||
<select
|
||||
value={current}
|
||||
disabled={busy || busyLibrary === library.id}
|
||||
onChange={(event) => void handleAssign(library, event.target.value)}
|
||||
className="input-base h-8 w-32 shrink-0 !py-0 text-xs"
|
||||
title="选择该媒体库所属标签"
|
||||
>
|
||||
<option value="">未分类</option>
|
||||
{tags.map((tag) => (
|
||||
<option key={tag.name} value={tag.name}>
|
||||
{tag.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-3 border-t border-[var(--app-border)] px-4 py-3 sm:px-6">
|
||||
<span className="text-xs text-[var(--app-muted)]">标签按用户保存;一个媒体库同时只属于一个标签。</span>
|
||||
<button type="button" onClick={onClose} className="btn-outline !px-4 !py-2 text-sm">
|
||||
完成
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import toast from 'react-hot-toast'
|
||||
|
||||
import {
|
||||
ALL_TAG_ID,
|
||||
assignLibrariesToTag,
|
||||
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<string>
|
||||
renameTag: (from: string, to: string) => Promise<void>
|
||||
removeTag: (name: string) => Promise<void>
|
||||
/** 覆盖某个标签下的媒体库集合(保持传入顺序)。 */
|
||||
setTagLibraries: (name: string, libraryIds: string[]) => Promise<void>
|
||||
/** 把媒体库挂到标签下;tagName 为空表示移出所有标签。 */
|
||||
assignLibrary: (libraryId: string, tagName: string) => Promise<void>
|
||||
/** 批量把媒体库挂到标签下;tagName 为空表示批量移出所有标签。一次写入。 */
|
||||
assignLibraries: (libraryIds: string[], tagName: string) => Promise<void>
|
||||
}
|
||||
|
||||
/**
|
||||
* 媒体库标签的用户级读写。
|
||||
*
|
||||
* 写入采用「先乐观更新、串行提交、失败回滚」:标签是整份替换(`PUT`),
|
||||
* 并发请求可能让旧快照最后落库,因此所有写操作串行排队,每次发送的都是
|
||||
* 排队时的最新本地状态。接口不可用(旧后端/网络异常)时退回本地存储,
|
||||
* 标签栏依旧可用,不会因为一次失败就让标签凭空消失。
|
||||
*/
|
||||
export function useLibraryTags(): UseLibraryTagsResult {
|
||||
const [tags, setTags] = useState<LibraryTag[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [loadError, setLoadError] = useState(false)
|
||||
const [selectedTagId, setSelectedTagIdState] = useState(() => readSelectedTagId())
|
||||
|
||||
const tagsRef = useRef<LibraryTag[]>([])
|
||||
const selectedRef = useRef(selectedTagId)
|
||||
const savedRef = useRef<LibraryTag[]>([])
|
||||
const chainRef = useRef<Promise<unknown>>(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],
|
||||
)
|
||||
|
||||
const assignLibraries = useCallback(
|
||||
async (libraryIds: string[], tagName: string) => {
|
||||
await mutate((current) => assignLibrariesToTag(current, libraryIds, tagName))
|
||||
},
|
||||
[mutate],
|
||||
)
|
||||
|
||||
return {
|
||||
tags,
|
||||
loading,
|
||||
saving,
|
||||
loadError,
|
||||
selectedTagId,
|
||||
setSelectedTagId,
|
||||
createTag,
|
||||
renameTag,
|
||||
removeTag,
|
||||
setTagLibraries,
|
||||
assignLibrary,
|
||||
assignLibraries,
|
||||
}
|
||||
}
|
||||
|
||||
/** 判断错误是否说明后端没有这个接口(旧版本 / 反向代理未更新)。 */
|
||||
function isMissingEndpoint(err: unknown): boolean {
|
||||
const status = (err as { response?: { status?: number } })?.response?.status
|
||||
return status === 404 || status === 405 || status === 501
|
||||
}
|
||||
+67
-19
@@ -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 { ManageLibraryTagsDialogView } from '../components/ManageLibraryTagsDialogView'
|
||||
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,19 @@ 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 [libraryTagsOpen, setLibraryTagsOpen] = useState(false)
|
||||
|
||||
// 按需拉取卡片预览管理。同一个库可能先以 4 张封面用于入口网格,
|
||||
// 稍后需要 10 张用于内容横排,因此缓存的是已加载数量而不是简单布尔值。
|
||||
const fetchedPreviewLimitsRef = useRef<Map<string, number>>(new Map())
|
||||
@@ -153,14 +171,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 +189,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 +276,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 +293,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 +304,13 @@ export function HomePage() {
|
||||
}
|
||||
|
||||
return candidateMedia.slice(0, 10)
|
||||
}, [libraries, libraryData])
|
||||
}, [taggedLibraries, libraryData])
|
||||
|
||||
// 库列表还没回来先展示整页 loading;库为空时再等一下播放记录,
|
||||
// 以免在"空站点"和"有观看记录"两个终态之间闪空白。
|
||||
if (librariesLoading || (libraries.length === 0 && historyLoading)) {
|
||||
return <HomeLoadingState />
|
||||
}
|
||||
|
||||
const empty =
|
||||
libraries.length === 0 &&
|
||||
history.length === 0
|
||||
@@ -313,15 +330,46 @@ export function HomePage() {
|
||||
{historyLoading && <ContinueWatchingSkeleton />}
|
||||
{!historyLoading && history.length > 0 && <ContinueWatchingSection history={history} />}
|
||||
|
||||
{/* 3. 媒体库卡片区(每页展示 20 个媒体库) */}
|
||||
{/* 3. 媒体库标签栏 + 卡片区(每页展示 20 个媒体库) */}
|
||||
{sortedLibraries.length > 0 && (
|
||||
<section className="space-y-6">
|
||||
<LibraryTagBar
|
||||
tabs={tagTabs}
|
||||
selectedTagId={effectiveTagId}
|
||||
onSelect={libraryTags.setSelectedTagId}
|
||||
onCreate={(name) => {
|
||||
void libraryTags.createTag(name)
|
||||
}}
|
||||
onManage={() => setLibraryTagsOpen(true)}
|
||||
busy={libraryTags.saving}
|
||||
/>
|
||||
{libraryTagsOpen && (
|
||||
<ManageLibraryTagsDialogView
|
||||
tags={libraryTags.tags}
|
||||
libraries={libraries}
|
||||
saving={libraryTags.saving}
|
||||
onCreate={libraryTags.createTag}
|
||||
onRename={libraryTags.renameTag}
|
||||
onRemove={libraryTags.removeTag}
|
||||
onAssign={libraryTags.assignLibrary}
|
||||
onAssignBatch={libraryTags.assignLibraries}
|
||||
onClose={() => setLibraryTagsOpen(false)}
|
||||
/>
|
||||
)}
|
||||
{taggedLibraries.length === 0 ? (
|
||||
<p className="rounded-2xl border border-dashed border-[var(--app-border)] bg-[var(--app-panel)] px-4 py-12 text-center text-sm text-[var(--app-muted)]">
|
||||
「{effectiveTagId}」标签下还没有媒体库,可在「管理标签」里把媒体库归入该标签。
|
||||
</p>
|
||||
) : (
|
||||
<HomeLibrariesSection
|
||||
libraries={sortedLibraries}
|
||||
libraries={taggedLibraries}
|
||||
libraryData={libraryData}
|
||||
libraryCounts={libraryCounts}
|
||||
onNeedPreviews={fetchPreviews}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* 4. 各媒体库内容展示行(向下滑动渐进流式加载,不受上方20个分页限制) */}
|
||||
{(visibleLibraries.length > 0 || hasMoreLibraries) && (
|
||||
|
||||
@@ -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 { ManageLibraryTagsDialogView } from '../components/ManageLibraryTagsDialogView'
|
||||
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 {
|
||||
@@ -14,8 +17,16 @@ import {
|
||||
import type { LibraryPreview } from './librariesPageModel'
|
||||
import type { Library } from '../types'
|
||||
import type { SeriesCard } from '../utils/groupSeries'
|
||||
import {
|
||||
readLibraryListSort,
|
||||
sortLibraryPreviewsByField,
|
||||
writeLibraryListSort,
|
||||
type LibraryListSortField,
|
||||
type LibraryListSortOrder,
|
||||
} from '../utils/libraryListSort'
|
||||
import { fetchLibraries, invalidateLibraries, peekLibraries } from '../utils/libraryCache'
|
||||
import { sortLibraryPreviews } from '../utils/pinnedLibraries'
|
||||
import { ALL_TAG_ID, buildLibraryTagTabs, filterLibrariesByTag } from '../utils/libraryTags'
|
||||
import type { LibraryTag } from '../utils/libraryTags'
|
||||
import { partitionPreviewIDs } from '../utils/remoteEmby'
|
||||
|
||||
export function LibrariesPage() {
|
||||
@@ -23,10 +34,13 @@ export function LibrariesPage() {
|
||||
const [libraries, setLibraries] = useState<Library[]>([])
|
||||
const [libraryData, setLibraryData] = useState<Record<string, { cards: SeriesCard[]; total: number }>>({})
|
||||
const { pinnedIds, loading: pinnedLoading, togglePin } = usePinnedLibraries()
|
||||
const libraryTags = useLibraryTags()
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [repairing, setRepairing] = useState(false)
|
||||
const [repairEpisodeArtwork, setRepairEpisodeArtwork] = useEpisodeArtworkPreference()
|
||||
const [repairMsg, setRepairMsg] = useState('')
|
||||
const [sortField, setSortField] = useState<LibraryListSortField>(() => readLibraryListSort().field)
|
||||
const [sortOrder, setSortOrder] = useState<LibraryListSortOrder>(() => readLibraryListSort().order)
|
||||
|
||||
// 缓存每个库已加载到的预览数量:入口网格只需要 2 张,横向货架需要 10 张。
|
||||
const fetchedPreviewLimitsRef = useRef<Map<string, number>>(new Map())
|
||||
@@ -140,13 +154,35 @@ export function LibrariesPage() {
|
||||
})
|
||||
}, [libraries, libraryData])
|
||||
|
||||
const sortedPreviews = useMemo(() => sortLibraryPreviews(previews, pinnedIds), [previews, pinnedIds])
|
||||
const sortedPreviews = useMemo(
|
||||
() => sortLibraryPreviewsByField(previews, pinnedIds, sortField, sortOrder),
|
||||
[previews, pinnedIds, sortField, sortOrder],
|
||||
)
|
||||
|
||||
const handleSortChange = useCallback((field: LibraryListSortField, order: LibraryListSortOrder) => {
|
||||
setSortField(field)
|
||||
setSortOrder(order)
|
||||
writeLibraryListSort(field, order)
|
||||
}, [])
|
||||
|
||||
const handleTogglePin = useCallback((libraryId: string) => {
|
||||
void togglePin(libraryId)
|
||||
}, [togglePin])
|
||||
|
||||
const total = useMemo(() => previews.reduce((sum, preview) => sum + preview.total, 0), [previews])
|
||||
const [libraryTagsOpen, setLibraryTagsOpen] = useState(false)
|
||||
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 <p className="px-2 py-8 text-sm text-sand-500">媒体库加载中…</p>
|
||||
@@ -155,22 +191,54 @@ export function LibrariesPage() {
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<LibrariesHeader
|
||||
previewCount={previews.length}
|
||||
total={total}
|
||||
previewCount={taggedPreviews.length}
|
||||
total={taggedTotal}
|
||||
isAdmin={isAdmin}
|
||||
repairMsg={repairMsg}
|
||||
repairEpisodeArtwork={repairEpisodeArtwork}
|
||||
repairing={repairing}
|
||||
sortField={sortField}
|
||||
sortOrder={sortOrder}
|
||||
onSortChange={handleSortChange}
|
||||
onRepairEpisodeArtworkChange={setRepairEpisodeArtwork}
|
||||
onRepairRescrape={handleRepairRescrape}
|
||||
onManageLibraries={handleManageLibraries}
|
||||
/>
|
||||
|
||||
<LibraryTagBar
|
||||
tabs={tagTabs}
|
||||
selectedTagId={effectiveTagId}
|
||||
onSelect={libraryTags.setSelectedTagId}
|
||||
onCreate={(name) => {
|
||||
void libraryTags.createTag(name)
|
||||
}}
|
||||
onManage={() => setLibraryTagsOpen(true)}
|
||||
busy={libraryTags.saving}
|
||||
/>
|
||||
|
||||
{libraryTagsOpen && (
|
||||
<ManageLibraryTagsDialogView
|
||||
tags={libraryTags.tags}
|
||||
libraries={libraries}
|
||||
saving={libraryTags.saving}
|
||||
onCreate={libraryTags.createTag}
|
||||
onRename={libraryTags.renameTag}
|
||||
onRemove={libraryTags.removeTag}
|
||||
onAssign={libraryTags.assignLibrary}
|
||||
onAssignBatch={libraryTags.assignLibraries}
|
||||
onClose={() => setLibraryTagsOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{previews.length === 0 ? (
|
||||
<LibrariesEmptyState isAdmin={isAdmin} />
|
||||
) : taggedPreviews.length === 0 ? (
|
||||
<p className="rounded-3xl border border-dashed border-[var(--app-border)] bg-[var(--app-panel)] px-4 py-16 text-center text-sm text-[var(--app-muted)]">
|
||||
「{effectiveTagId}」标签下还没有媒体库,可在「管理标签」里把媒体库归入该标签。
|
||||
</p>
|
||||
) : (
|
||||
<LibrariesContent
|
||||
previews={sortedPreviews}
|
||||
previews={taggedPreviews}
|
||||
pinnedIds={pinnedIds}
|
||||
onTogglePin={handleTogglePin}
|
||||
onNeedPreviews={fetchPreviews}
|
||||
@@ -179,3 +247,20 @@ export function LibrariesPage() {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** 按选中标签过滤媒体库预览(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
|
||||
}
|
||||
|
||||
@@ -3,7 +3,9 @@ import { Link } from 'react-router-dom'
|
||||
import { Library as LibraryIcon, RefreshCw, Sparkles } from 'lucide-react'
|
||||
|
||||
import { EpisodeArtworkToggle } from '../components/EpisodeArtworkToggle'
|
||||
import { LibraryListSortDropdown } from '../components/LibraryListSortDropdown'
|
||||
import { useRememberedListPosition } from '../hooks/useListPositionMemory'
|
||||
import type { LibraryListSortField, LibraryListSortOrder } from '../utils/libraryListSort'
|
||||
import { HomeLibrariesSection, HomeLibraryRowSection } from './HomePageSections'
|
||||
import type { LibraryPreview } from './librariesPageModel'
|
||||
|
||||
@@ -18,6 +20,9 @@ export function LibrariesHeader({
|
||||
repairMsg,
|
||||
repairEpisodeArtwork,
|
||||
repairing,
|
||||
sortField,
|
||||
sortOrder,
|
||||
onSortChange,
|
||||
onRepairEpisodeArtworkChange,
|
||||
onRepairRescrape,
|
||||
onManageLibraries,
|
||||
@@ -28,6 +33,9 @@ export function LibrariesHeader({
|
||||
repairMsg: string
|
||||
repairEpisodeArtwork: boolean
|
||||
repairing: boolean
|
||||
sortField: LibraryListSortField
|
||||
sortOrder: LibraryListSortOrder
|
||||
onSortChange: (field: LibraryListSortField, order: LibraryListSortOrder) => void
|
||||
onRepairEpisodeArtworkChange: (value: boolean) => void
|
||||
onRepairRescrape: () => void
|
||||
onManageLibraries: () => void
|
||||
@@ -40,8 +48,10 @@ export function LibrariesHeader({
|
||||
共 {previewCount} 个目录 · {total.toLocaleString()} 个条目。每个目录直接展示最新入库内容。
|
||||
</p>
|
||||
</div>
|
||||
{isAdmin && (
|
||||
<div className="flex flex-wrap items-center gap-2 sm:gap-3">
|
||||
<LibraryListSortDropdown value={sortField} order={sortOrder} onChange={onSortChange} />
|
||||
{isAdmin && (
|
||||
<>
|
||||
{repairMsg && <span className="w-full text-xs text-ink-50">{repairMsg}</span>}
|
||||
<EpisodeArtworkToggle
|
||||
checked={repairEpisodeArtwork}
|
||||
@@ -74,9 +84,10 @@ export function LibrariesHeader({
|
||||
>
|
||||
管理媒体库
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import type { Library } from '../types'
|
||||
|
||||
export type LibraryListSortField = 'name' | 'sort_order'
|
||||
export type LibraryListSortOrder = 'asc' | 'desc'
|
||||
|
||||
export type LibraryListSortOption = {
|
||||
id: LibraryListSortField
|
||||
label: string
|
||||
defaultOrder: LibraryListSortOrder
|
||||
}
|
||||
|
||||
export const LIBRARY_LIST_SORT_OPTIONS: LibraryListSortOption[] = [
|
||||
{ id: 'name', label: '库名', defaultOrder: 'desc' },
|
||||
{ id: 'sort_order', label: '手动顺序', defaultOrder: 'asc' },
|
||||
]
|
||||
|
||||
export const DEFAULT_LIBRARY_LIST_SORT_FIELD: LibraryListSortField = 'name'
|
||||
export const DEFAULT_LIBRARY_LIST_SORT_ORDER: LibraryListSortOrder = 'desc'
|
||||
|
||||
const FIELD_KEY = 'mebox_libraries_sort_field'
|
||||
const ORDER_KEY = 'mebox_libraries_sort_order'
|
||||
|
||||
export function readLibraryListSort(): { field: LibraryListSortField; order: LibraryListSortOrder } {
|
||||
if (typeof window === 'undefined') {
|
||||
return { field: DEFAULT_LIBRARY_LIST_SORT_FIELD, order: DEFAULT_LIBRARY_LIST_SORT_ORDER }
|
||||
}
|
||||
const rawField = window.localStorage.getItem(FIELD_KEY)
|
||||
const rawOrder = window.localStorage.getItem(ORDER_KEY)
|
||||
const field: LibraryListSortField =
|
||||
rawField === 'name' || rawField === 'sort_order' ? rawField : DEFAULT_LIBRARY_LIST_SORT_FIELD
|
||||
const order: LibraryListSortOrder =
|
||||
rawOrder === 'asc' || rawOrder === 'desc'
|
||||
? rawOrder
|
||||
: field === 'name'
|
||||
? DEFAULT_LIBRARY_LIST_SORT_ORDER
|
||||
: 'asc'
|
||||
return { field, order }
|
||||
}
|
||||
|
||||
export function writeLibraryListSort(field: LibraryListSortField, order: LibraryListSortOrder): void {
|
||||
if (typeof window === 'undefined') return
|
||||
window.localStorage.setItem(FIELD_KEY, field)
|
||||
window.localStorage.setItem(ORDER_KEY, order)
|
||||
}
|
||||
|
||||
function compareNames(a: string, b: string, order: LibraryListSortOrder): number {
|
||||
const cmp = a.localeCompare(b, 'zh-CN', { numeric: true, sensitivity: 'base' })
|
||||
return order === 'asc' ? cmp : -cmp
|
||||
}
|
||||
|
||||
function compareSortOrder(a: number, b: number, order: LibraryListSortOrder): number {
|
||||
const cmp = a - b
|
||||
return order === 'asc' ? cmp : -cmp
|
||||
}
|
||||
|
||||
function librarySortKey(library: Library): { name: string; sortOrder: number } {
|
||||
return {
|
||||
name: (library.name || '').trim(),
|
||||
sortOrder: library.sort_order ?? 0,
|
||||
}
|
||||
}
|
||||
|
||||
function compareLibraries(a: Library, b: Library, field: LibraryListSortField, order: LibraryListSortOrder): number {
|
||||
const ka = librarySortKey(a)
|
||||
const kb = librarySortKey(b)
|
||||
if (field === 'name') {
|
||||
const byName = compareNames(ka.name, kb.name, order)
|
||||
if (byName !== 0) return byName
|
||||
return compareSortOrder(ka.sortOrder, kb.sortOrder, 'asc') || a.id.localeCompare(b.id)
|
||||
}
|
||||
const byOrder = compareSortOrder(ka.sortOrder, kb.sortOrder, order)
|
||||
if (byOrder !== 0) return byOrder
|
||||
return compareNames(ka.name, kb.name, 'asc') || a.id.localeCompare(b.id)
|
||||
}
|
||||
|
||||
/**
|
||||
* 置顶组始终在前;组内按 field/order 排序。
|
||||
* pinnedIds 的相对顺序只决定「是否置顶」,组内不再保留置顶拖拽序。
|
||||
*/
|
||||
export function sortLibraryPreviewsByField<T extends { library: Library }>(
|
||||
items: T[],
|
||||
pinnedIds: string[],
|
||||
field: LibraryListSortField,
|
||||
order: LibraryListSortOrder,
|
||||
): T[] {
|
||||
if (items.length < 2) return items
|
||||
|
||||
const pinnedRank = new Map(pinnedIds.map((id, index) => [id, index]))
|
||||
const pinned: T[] = []
|
||||
const rest: T[] = []
|
||||
for (const item of items) {
|
||||
if (pinnedRank.has(item.library.id)) pinned.push(item)
|
||||
else rest.push(item)
|
||||
}
|
||||
|
||||
const byField = (a: T, b: T) => compareLibraries(a.library, b.library, field, order)
|
||||
pinned.sort(byField)
|
||||
rest.sort(byField)
|
||||
return [...pinned, ...rest]
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import {
|
||||
ALL_TAG_ID,
|
||||
assignLibrariesToTag,
|
||||
attachLibrariesToTag,
|
||||
attachLibraryToTag,
|
||||
buildLibraryTagTabs,
|
||||
dedupeLibraryTags,
|
||||
detachLibrariesFromTag,
|
||||
detachLibraryFromTag,
|
||||
filterLibrariesByTag,
|
||||
filterLibrariesForTagging,
|
||||
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')
|
||||
|
||||
const batchAttached = attachLibrariesToTag(
|
||||
[
|
||||
{ name: '动画', library_ids: ['a', 'x'] },
|
||||
{ name: '电影', library_ids: ['b'] },
|
||||
],
|
||||
['b', 'x', 'b', ' missing '],
|
||||
'动画',
|
||||
)
|
||||
check('batch attach moves every id out of its old tag', batchAttached[1].library_ids.length === 0)
|
||||
check('batch attach keeps order and dedupes ids', batchAttached[0].library_ids.join(',') === 'a,b,x,missing')
|
||||
check('batch attach ignores unknown target tag', attachLibrariesToTag([{ name: '动画', library_ids: [] }], ['a'], '不存在')[0].library_ids.length === 0)
|
||||
check('batch attach with no ids is a no-op', attachLibrariesToTag([{ name: '动画', library_ids: ['a'] }], [], '动画')[0].library_ids.join(',') === 'a')
|
||||
|
||||
const batchDetached = detachLibrariesFromTag(
|
||||
[
|
||||
{ name: '动画', library_ids: ['a', 'b'] },
|
||||
{ name: '电影', library_ids: ['b', 'c'] },
|
||||
],
|
||||
['b', 'c'],
|
||||
)
|
||||
check('batch detach removes ids from every tag', batchDetached[0].library_ids.join(',') === 'a')
|
||||
check('batch detach clears emptied tags', batchDetached[1].library_ids.length === 0)
|
||||
check(
|
||||
'assign with empty tag name clears everything',
|
||||
assignLibrariesToTag([{ name: '动画', library_ids: ['a'] }], ['a'], '')[0].library_ids.length === 0,
|
||||
)
|
||||
check(
|
||||
'assign with a tag routes to attach',
|
||||
assignLibrariesToTag([{ name: '动画', library_ids: [] }], ['a'], '动画')[0].library_ids.join(',') === 'a',
|
||||
)
|
||||
|
||||
const searchable = [
|
||||
{ id: 'lib-a', name: '电影库', path: '/media/movies', type: 'movie' },
|
||||
{ id: 'lib-b', name: 'TV Shows', path: '/media/tv', type: 'tv' },
|
||||
]
|
||||
check('tagging search matches name', filterLibrariesForTagging(searchable as never, '电影').length === 1)
|
||||
check('tagging search matches path', filterLibrariesForTagging(searchable as never, 'media/tv').length === 1)
|
||||
check('tagging search matches type case-insensitively', filterLibrariesForTagging(searchable as never, 'MOVIE').length === 1)
|
||||
check('tagging search with blank keyword returns all', filterLibrariesForTagging(searchable as never, ' ').length === 2)
|
||||
|
||||
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')
|
||||
@@ -0,0 +1,298 @@
|
||||
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<string, number>()
|
||||
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<LibraryTag[]> {
|
||||
const { profileAPI } = await import('../api/profile')
|
||||
const remote = normalizeLibraryTags(await profileAPI.getLibraryTags())
|
||||
writeCachedLibraryTags(remote)
|
||||
return remote
|
||||
}
|
||||
|
||||
export async function saveLibraryTags(tags: LibraryTag[]): Promise<LibraryTag[]> {
|
||||
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<string>()
|
||||
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<T extends { id: string }>(
|
||||
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,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量把一个或多个媒体库挂到标签下:先从各自原标签移除,再按传入顺序追加,
|
||||
* 保证「一库一标签」。整批只产生一次写入。
|
||||
*/
|
||||
export function attachLibrariesToTag(
|
||||
tags: LibraryTag[],
|
||||
libraryIds: string[],
|
||||
tagName: string,
|
||||
): LibraryTag[] {
|
||||
const target = findTagIdByName(tags, tagName)
|
||||
const moving = normalizeLibraryIds(libraryIds)
|
||||
if (!target || moving.length === 0) return tags
|
||||
const movingSet = new Set(moving)
|
||||
const next = tags.map((tag) => ({
|
||||
...tag,
|
||||
library_ids: tag.library_ids.filter((id) => !movingSet.has(id)),
|
||||
}))
|
||||
return next.map((tag) =>
|
||||
tag.name === target ? { ...tag, library_ids: [...tag.library_ids, ...moving] } : tag,
|
||||
)
|
||||
}
|
||||
|
||||
/** 批量把媒体库移出所有标签(即变为「未分类」)。 */
|
||||
export function detachLibrariesFromTag(tags: LibraryTag[], libraryIds: string[]): LibraryTag[] {
|
||||
const moving = new Set(normalizeLibraryIds(libraryIds))
|
||||
if (moving.size === 0) return tags
|
||||
return tags.map((tag) => ({
|
||||
...tag,
|
||||
library_ids: tag.library_ids.filter((id) => !moving.has(id)),
|
||||
}))
|
||||
}
|
||||
|
||||
/** 批量归类:tagName 为空表示移出所有标签。 */
|
||||
export function assignLibrariesToTag(
|
||||
tags: LibraryTag[],
|
||||
libraryIds: string[],
|
||||
tagName: string,
|
||||
): LibraryTag[] {
|
||||
return tagName ? attachLibrariesToTag(tags, libraryIds, tagName) : detachLibrariesFromTag(tags, libraryIds)
|
||||
}
|
||||
|
||||
/** 去掉空白与重复,保留首次出现的顺序。 */
|
||||
export function normalizeLibraryIds(libraryIds: unknown): string[] {
|
||||
if (!Array.isArray(libraryIds)) return []
|
||||
const seen = new Set<string>()
|
||||
const out: string[] = []
|
||||
for (const raw of libraryIds) {
|
||||
const trimmed = String(raw ?? '').trim()
|
||||
if (!trimmed || seen.has(trimmed)) continue
|
||||
seen.add(trimmed)
|
||||
out.push(trimmed)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** 媒体库列表的模糊匹配(名称 / 路径 / 类型),供标签管理里的搜索框使用。 */
|
||||
export function filterLibrariesForTagging(libraries: Library[], keyword: string): Library[] {
|
||||
const needle = keyword.trim().toLowerCase()
|
||||
if (!needle) return libraries
|
||||
return libraries.filter((lib) =>
|
||||
`${lib.name} ${lib.path} ${lib.type}`.toLowerCase().includes(needle),
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user