mirror of
https://github.com/truewhile/MeBox.git
synced 2026-10-05 04:56:37 +08:00
Add library covers and configurable footer visibility
This commit is contained in:
@@ -18,6 +18,7 @@ func Register(r *gin.Engine, cfg *config.Config, log *zap.Logger, svc *service.C
|
|||||||
{
|
{
|
||||||
api.GET("/health", healthCheck)
|
api.GET("/health", healthCheck)
|
||||||
api.GET("/version", versionInfo)
|
api.GET("/version", versionInfo)
|
||||||
|
api.GET("/public/ui-config", publicUIConfigHandler(svc))
|
||||||
|
|
||||||
// Telegram Bot webhook — called by Telegram servers, no auth.
|
// Telegram Bot webhook — called by Telegram servers, no auth.
|
||||||
api.POST("/telegram/webhook", telegramWebhookHandler(svc))
|
api.POST("/telegram/webhook", telegramWebhookHandler(svc))
|
||||||
|
|||||||
@@ -16,11 +16,12 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type createLibraryReq struct {
|
type createLibraryReq struct {
|
||||||
Name string `json:"name" binding:"required"`
|
Name string `json:"name" binding:"required"`
|
||||||
Path string `json:"path"`
|
Path string `json:"path"`
|
||||||
Paths []string `json:"paths"`
|
Paths []string `json:"paths"`
|
||||||
Roots []service.LibraryRootInput `json:"roots"`
|
Roots []service.LibraryRootInput `json:"roots"`
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
|
CoverURL string `json:"cover_url"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func listLibrariesHandler(svc *service.Container) gin.HandlerFunc {
|
func listLibrariesHandler(svc *service.Container) gin.HandlerFunc {
|
||||||
@@ -98,7 +99,7 @@ func createLibraryHandler(svc *service.Container) gin.HandlerFunc {
|
|||||||
if len(roots) == 0 && strings.TrimSpace(req.Path) != "" {
|
if len(roots) == 0 && strings.TrimSpace(req.Path) != "" {
|
||||||
roots = append(roots, service.LibraryRootInput{Path: req.Path})
|
roots = append(roots, service.LibraryRootInput{Path: req.Path})
|
||||||
}
|
}
|
||||||
l, err := svc.Media.CreateLibraryWithRoots(c.Request.Context(), req.Name, req.Type, roots)
|
l, err := svc.Media.CreateLibraryWithRootsAndCover(c.Request.Context(), req.Name, req.Type, req.CoverURL, roots)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
@@ -111,6 +112,30 @@ func createLibraryHandler(svc *service.Container) gin.HandlerFunc {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type updateLibraryReq struct {
|
||||||
|
CoverURL string `json:"cover_url"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateLibraryHandler(svc *service.Container) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
var req updateLibraryReq
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := svc.Media.UpdateLibraryCover(c.Request.Context(), c.Param("id"), req.CoverURL); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
lib, err := svc.Repo.Library.FindByID(c.Request.Context(), c.Param("id"))
|
||||||
|
if err != nil || lib == nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "library not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, lib)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func deleteLibraryHandler(svc *service.Container) gin.HandlerFunc {
|
func deleteLibraryHandler(svc *service.Container) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
id := c.Param("id")
|
id := c.Param("id")
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"github.com/ShukeBta/MediaStationGo/internal/service"
|
||||||
|
)
|
||||||
|
|
||||||
|
const hideCommunityLinksSettingKey = "ui.hide_community_links_for_users"
|
||||||
|
|
||||||
|
func publicUIConfigHandler(svc *service.Container) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
hide := false
|
||||||
|
if svc != nil && svc.Repo != nil && svc.Repo.Setting != nil {
|
||||||
|
if value, err := svc.Repo.Setting.Get(c.Request.Context(), hideCommunityLinksSettingKey); err == nil {
|
||||||
|
hide = value == "true" || value == "1"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"hide_community_links_for_users": hide})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/glebarez/sqlite"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
|
||||||
|
"github.com/ShukeBta/MediaStationGo/internal/model"
|
||||||
|
"github.com/ShukeBta/MediaStationGo/internal/repository"
|
||||||
|
"github.com/ShukeBta/MediaStationGo/internal/service"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPublicUIConfigExposesCommunityLinkVisibility(t *testing.T) {
|
||||||
|
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := db.AutoMigrate(&model.Setting{}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
repos := repository.New(db)
|
||||||
|
if err := repos.Setting.Set(t.Context(), hideCommunityLinksSettingKey, "true"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
svc := &service.Container{Repo: repos, Log: zap.NewNop()}
|
||||||
|
r := gin.New()
|
||||||
|
r.GET("/api/public/ui-config", publicUIConfigHandler(svc))
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/public/ui-config", nil)
|
||||||
|
resp := httptest.NewRecorder()
|
||||||
|
r.ServeHTTP(resp, req)
|
||||||
|
if resp.Code != http.StatusOK || !strings.Contains(resp.Body.String(), `"hide_community_links_for_users":true`) {
|
||||||
|
t.Fatalf("status=%d body=%s", resp.Code, resp.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,6 +24,7 @@ func registerAuthedLibraryRoutes(authed *gin.RouterGroup, svc *service.Container
|
|||||||
authed.GET("/libraries", listLibrariesHandler(svc))
|
authed.GET("/libraries", listLibrariesHandler(svc))
|
||||||
authed.POST("/libraries", middleware.AdminRequired(), createLibraryHandler(svc))
|
authed.POST("/libraries", middleware.AdminRequired(), createLibraryHandler(svc))
|
||||||
authed.GET("/libraries/:id", getLibraryHandler(svc))
|
authed.GET("/libraries/:id", getLibraryHandler(svc))
|
||||||
|
authed.PATCH("/libraries/:id", middleware.AdminRequired(), updateLibraryHandler(svc))
|
||||||
authed.DELETE("/libraries/:id", middleware.AdminRequired(), deleteLibraryHandler(svc))
|
authed.DELETE("/libraries/:id", middleware.AdminRequired(), deleteLibraryHandler(svc))
|
||||||
authed.GET("/libraries/:id/roots", middleware.AdminRequired(), listLibraryRootsHandler(svc))
|
authed.GET("/libraries/:id/roots", middleware.AdminRequired(), listLibraryRootsHandler(svc))
|
||||||
authed.POST("/libraries/:id/roots", middleware.AdminRequired(), createLibraryRootHandler(svc))
|
authed.POST("/libraries/:id/roots", middleware.AdminRequired(), createLibraryRootHandler(svc))
|
||||||
|
|||||||
@@ -3,11 +3,12 @@ package model
|
|||||||
// Library 表示一个逻辑媒体库。Path 保留为兼容字段,指向第一条 LibraryRoot。
|
// Library 表示一个逻辑媒体库。Path 保留为兼容字段,指向第一条 LibraryRoot。
|
||||||
type Library struct {
|
type Library struct {
|
||||||
Base
|
Base
|
||||||
Name string `gorm:"size:128;not null" json:"name"`
|
Name string `gorm:"size:128;not null" json:"name"`
|
||||||
Path string `gorm:"size:1024;not null" json:"path"`
|
Path string `gorm:"size:1024;not null" json:"path"`
|
||||||
Type string `gorm:"size:16;not null;default:movie" json:"type"` // movie / tv / anime / music
|
Type string `gorm:"size:16;not null;default:movie" json:"type"` // movie / tv / anime / music
|
||||||
Enabled bool `gorm:"default:true" json:"enabled"`
|
CoverURL string `gorm:"size:1024" json:"cover_url,omitempty"`
|
||||||
Roots []LibraryRoot `gorm:"foreignKey:LibraryID" json:"roots,omitempty"`
|
Enabled bool `gorm:"default:true" json:"enabled"`
|
||||||
|
Roots []LibraryRoot `gorm:"foreignKey:LibraryID" json:"roots,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// LibraryRoot 是逻辑媒体库下的一条真实物理/挂载路径。
|
// LibraryRoot 是逻辑媒体库下的一条真实物理/挂载路径。
|
||||||
|
|||||||
@@ -24,6 +24,10 @@ func (s *MediaService) CreateLibrary(ctx context.Context, name, path, kind strin
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *MediaService) CreateLibraryWithRoots(ctx context.Context, name, kind string, inputs []LibraryRootInput) (*model.Library, error) {
|
func (s *MediaService) CreateLibraryWithRoots(ctx context.Context, name, kind string, inputs []LibraryRootInput) (*model.Library, error) {
|
||||||
|
return s.CreateLibraryWithRootsAndCover(ctx, name, kind, "", inputs)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *MediaService) CreateLibraryWithRootsAndCover(ctx context.Context, name, kind, coverURL string, inputs []LibraryRootInput) (*model.Library, error) {
|
||||||
if strings.TrimSpace(name) == "" {
|
if strings.TrimSpace(name) == "" {
|
||||||
return nil, errors.New("name required")
|
return nil, errors.New("name required")
|
||||||
}
|
}
|
||||||
@@ -35,6 +39,11 @@ func (s *MediaService) CreateLibraryWithRoots(ctx context.Context, name, kind st
|
|||||||
if existing, err := s.findLogicalLibrary(ctx, name, kind); err != nil {
|
if existing, err := s.findLogicalLibrary(ctx, name, kind); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
} else if existing != nil {
|
} else if existing != nil {
|
||||||
|
if strings.TrimSpace(coverURL) != "" {
|
||||||
|
if err := s.UpdateLibraryCover(ctx, existing.ID, coverURL); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
lib, err := s.appendLibraryRoots(ctx, existing, roots)
|
lib, err := s.appendLibraryRoots(ctx, existing, roots)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -42,7 +51,7 @@ func (s *MediaService) CreateLibraryWithRoots(ctx context.Context, name, kind st
|
|||||||
s.invalidateMediaCache(ctx)
|
s.invalidateMediaCache(ctx)
|
||||||
return lib, nil
|
return lib, nil
|
||||||
}
|
}
|
||||||
lib := &model.Library{Name: strings.TrimSpace(name), Path: roots[0].Path, Type: kind, Enabled: true}
|
lib := &model.Library{Name: strings.TrimSpace(name), Path: roots[0].Path, Type: kind, CoverURL: strings.TrimSpace(coverURL), Enabled: true}
|
||||||
if err := s.repo.Library.CreateWithRoots(ctx, lib, roots); err != nil {
|
if err := s.repo.Library.CreateWithRoots(ctx, lib, roots); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -50,6 +59,11 @@ func (s *MediaService) CreateLibraryWithRoots(ctx context.Context, name, kind st
|
|||||||
return lib, nil
|
return lib, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *MediaService) UpdateLibraryCover(ctx context.Context, libraryID, coverURL string) error {
|
||||||
|
return s.repo.DB.WithContext(ctx).Model(&model.Library{}).Where("id = ?", libraryID).
|
||||||
|
Update("cover_url", strings.TrimSpace(coverURL)).Error
|
||||||
|
}
|
||||||
|
|
||||||
func (s *MediaService) findLogicalLibrary(ctx context.Context, name, kind string) (*model.Library, error) {
|
func (s *MediaService) findLogicalLibrary(ctx context.Context, name, kind string) (*model.Library, error) {
|
||||||
if s == nil || s.repo == nil || s.repo.Library == nil {
|
if s == nil || s.repo == nil || s.repo.Library == nil {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
|
|||||||
@@ -53,6 +53,30 @@ func TestCreateLibraryWithRootsAppendsToExistingLogicalLibrary(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCreateLibraryWithRootsStoresAndUpdatesCustomCover(t *testing.T) {
|
||||||
|
db := newServiceTestDB(t, &model.Library{}, &model.LibraryRoot{}, &model.Media{})
|
||||||
|
repos := repository.New(db)
|
||||||
|
svc := NewMediaService(&config.Config{}, zap.NewNop(), repos)
|
||||||
|
|
||||||
|
lib, err := svc.CreateLibraryWithRootsAndCover(t.Context(), "收藏", "movie", "https://example.com/cover.jpg", []LibraryRootInput{{Path: t.TempDir()}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if lib.CoverURL != "https://example.com/cover.jpg" {
|
||||||
|
t.Fatalf("cover_url = %q", lib.CoverURL)
|
||||||
|
}
|
||||||
|
if err := svc.UpdateLibraryCover(t.Context(), lib.ID, "https://example.com/new.jpg"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
updated, err := repos.Library.FindByID(t.Context(), lib.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if updated.CoverURL != "https://example.com/new.jpg" {
|
||||||
|
t.Fatalf("updated cover_url = %q", updated.CoverURL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestCreateLibraryWithRootsKeepsDifferentTypesSeparate(t *testing.T) {
|
func TestCreateLibraryWithRootsKeepsDifferentTypesSeparate(t *testing.T) {
|
||||||
rootMovie := t.TempDir()
|
rootMovie := t.TempDir()
|
||||||
rootTV := t.TempDir()
|
rootTV := t.TempDir()
|
||||||
|
|||||||
@@ -99,8 +99,11 @@ export const libraryAPI = {
|
|||||||
create: (name: string, path: string, type: string) =>
|
create: (name: string, path: string, type: string) =>
|
||||||
api.post<Library>('/libraries', { name, path, type }).then((r) => r.data),
|
api.post<Library>('/libraries', { name, path, type }).then((r) => r.data),
|
||||||
|
|
||||||
createWithRoots: (name: string, type: string, roots: LibraryRootInput[]) =>
|
createWithRoots: (name: string, type: string, roots: LibraryRootInput[], coverURL = '') =>
|
||||||
api.post<Library>('/libraries', { name, type, roots }).then((r) => r.data),
|
api.post<Library>('/libraries', { name, type, roots, cover_url: coverURL }).then((r) => r.data),
|
||||||
|
|
||||||
|
update: (id: string, payload: { cover_url: string }) =>
|
||||||
|
api.patch<Library>(`/libraries/${id}`, payload).then((r) => r.data),
|
||||||
|
|
||||||
remove: (id: string) => api.delete(`/libraries/${id}`).then((r) => r.data),
|
remove: (id: string) => api.delete(`/libraries/${id}`).then((r) => r.data),
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { api } from './client'
|
||||||
|
|
||||||
|
export interface PublicUIConfig {
|
||||||
|
hide_community_links_for_users: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export const publicAPI = {
|
||||||
|
uiConfig: () => api.get<PublicUIConfig>('/public/ui-config').then((response) => response.data),
|
||||||
|
}
|
||||||
@@ -1,5 +1,9 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
import { Github, Globe, Send } from 'lucide-react'
|
import { Github, Globe, Send } from 'lucide-react'
|
||||||
|
|
||||||
|
import { publicAPI } from '../api/public'
|
||||||
|
import { useAuthStore } from '../stores/auth'
|
||||||
|
|
||||||
const LINKS = [
|
const LINKS = [
|
||||||
{ href: 'https://github.com/ShukeBta/MediaStationGo', icon: Github, label: '开源仓库' },
|
{ href: 'https://github.com/ShukeBta/MediaStationGo', icon: Github, label: '开源仓库' },
|
||||||
{ href: 'https://github.com/ShukeBta', icon: Globe, label: '作者主页' },
|
{ href: 'https://github.com/ShukeBta', icon: Globe, label: '作者主页' },
|
||||||
@@ -7,6 +11,14 @@ const LINKS = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
export function AppFooter({ className = '' }: { className?: string }) {
|
export function AppFooter({ className = '' }: { className?: string }) {
|
||||||
|
const role = useAuthStore((state) => state.user?.role)
|
||||||
|
const [hideForUsers, setHideForUsers] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
publicAPI.uiConfig().then((config) => setHideForUsers(config.hide_community_links_for_users)).catch(() => undefined)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
if (hideForUsers && role !== 'admin') return null
|
||||||
return (
|
return (
|
||||||
<footer className={`flex items-center justify-center gap-1 ${className}`}>
|
<footer className={`flex items-center justify-center gap-1 ${className}`}>
|
||||||
{LINKS.map((link, i) => (
|
{LINKS.map((link, i) => (
|
||||||
|
|||||||
@@ -10,9 +10,11 @@ export function AdminLibraryPanel() {
|
|||||||
<AdminLibraryCreateForm
|
<AdminLibraryCreateForm
|
||||||
name={createForm.name}
|
name={createForm.name}
|
||||||
type={createForm.type}
|
type={createForm.type}
|
||||||
|
coverURL={createForm.coverURL}
|
||||||
roots={createForm.roots}
|
roots={createForm.roots}
|
||||||
onNameChange={createForm.setName}
|
onNameChange={createForm.setName}
|
||||||
onTypeChange={createForm.setType}
|
onTypeChange={createForm.setType}
|
||||||
|
onCoverURLChange={createForm.setCoverURL}
|
||||||
onRootChange={createForm.updateRoot}
|
onRootChange={createForm.updateRoot}
|
||||||
onAddRoot={createForm.addRoot}
|
onAddRoot={createForm.addRoot}
|
||||||
onRemoveRoot={createForm.removeRoot}
|
onRemoveRoot={createForm.removeRoot}
|
||||||
@@ -28,6 +30,8 @@ export function AdminLibraryPanel() {
|
|||||||
onRemoveRoot={rootActions.removeLibraryRoot}
|
onRemoveRoot={rootActions.removeLibraryRoot}
|
||||||
onScanLibrary={libraryActions.scanLibrary}
|
onScanLibrary={libraryActions.scanLibrary}
|
||||||
onRemoveLibrary={libraryActions.removeLibrary}
|
onRemoveLibrary={libraryActions.removeLibrary}
|
||||||
|
onAddLibraryRoot={libraryActions.addLibraryRoot}
|
||||||
|
onEditLibraryCover={libraryActions.editLibraryCover}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -6,9 +6,11 @@ import type { RootDraft } from './adminLibraryPanelModel'
|
|||||||
type CreateFormProps = {
|
type CreateFormProps = {
|
||||||
name: string
|
name: string
|
||||||
type: string
|
type: string
|
||||||
|
coverURL: string
|
||||||
roots: RootDraft[]
|
roots: RootDraft[]
|
||||||
onNameChange: (value: string) => void
|
onNameChange: (value: string) => void
|
||||||
onTypeChange: (value: string) => void
|
onTypeChange: (value: string) => void
|
||||||
|
onCoverURLChange: (value: string) => void
|
||||||
onRootChange: (index: number, patch: Partial<RootDraft>) => void
|
onRootChange: (index: number, patch: Partial<RootDraft>) => void
|
||||||
onAddRoot: () => void
|
onAddRoot: () => void
|
||||||
onRemoveRoot: (index: number) => void
|
onRemoveRoot: (index: number) => void
|
||||||
@@ -18,9 +20,11 @@ type CreateFormProps = {
|
|||||||
export function AdminLibraryCreateForm({
|
export function AdminLibraryCreateForm({
|
||||||
name,
|
name,
|
||||||
type,
|
type,
|
||||||
|
coverURL,
|
||||||
roots,
|
roots,
|
||||||
onNameChange,
|
onNameChange,
|
||||||
onTypeChange,
|
onTypeChange,
|
||||||
|
onCoverURLChange,
|
||||||
onRootChange,
|
onRootChange,
|
||||||
onAddRoot,
|
onAddRoot,
|
||||||
onRemoveRoot,
|
onRemoveRoot,
|
||||||
@@ -42,6 +46,12 @@ export function AdminLibraryCreateForm({
|
|||||||
<option value="anime">动漫</option>
|
<option value="anime">动漫</option>
|
||||||
<option value="music">音乐</option>
|
<option value="music">音乐</option>
|
||||||
</select>
|
</select>
|
||||||
|
<input
|
||||||
|
className="input-base md:col-span-2"
|
||||||
|
placeholder="自定义封面 URL(可选)"
|
||||||
|
value={coverURL}
|
||||||
|
onChange={(e) => onCoverURLChange(e.target.value)}
|
||||||
|
/>
|
||||||
<div className="md:col-span-4 space-y-2">
|
<div className="md:col-span-4 space-y-2">
|
||||||
{roots.map((root, index) => (
|
{roots.map((root, index) => (
|
||||||
<CreateRootRow
|
<CreateRootRow
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { MouseEvent, ReactNode } from 'react'
|
import type { MouseEvent, ReactNode } from 'react'
|
||||||
import { MoreVertical, Power, PowerOff, RefreshCw, Save, Trash2 } from 'lucide-react'
|
import { Image, MoreVertical, Plus, Power, PowerOff, RefreshCw, Save, Trash2 } from 'lucide-react'
|
||||||
|
|
||||||
import type { Library, LibraryRoot } from '../types'
|
import type { Library, LibraryRoot } from '../types'
|
||||||
import type { RootDraft } from './adminLibraryPanelModel'
|
import type { RootDraft } from './adminLibraryPanelModel'
|
||||||
@@ -15,6 +15,8 @@ type LibraryTableProps = {
|
|||||||
onRemoveRoot: (library: Library, root: LibraryRoot) => void
|
onRemoveRoot: (library: Library, root: LibraryRoot) => void
|
||||||
onScanLibrary: (library: Library) => void
|
onScanLibrary: (library: Library) => void
|
||||||
onRemoveLibrary: (library: Library) => void
|
onRemoveLibrary: (library: Library) => void
|
||||||
|
onAddLibraryRoot: (library: Library) => void
|
||||||
|
onEditLibraryCover: (library: Library) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AdminLibraryTable({ libs, ...actions }: LibraryTableProps) {
|
export function AdminLibraryTable({ libs, ...actions }: LibraryTableProps) {
|
||||||
@@ -46,7 +48,12 @@ type LibraryTableRowProps = Omit<LibraryTableProps, 'libs'> & {
|
|||||||
function LibraryTableRow({ library, ...actions }: LibraryTableRowProps) {
|
function LibraryTableRow({ library, ...actions }: LibraryTableRowProps) {
|
||||||
return (
|
return (
|
||||||
<tr className="border-t border-gray-200">
|
<tr className="border-t border-gray-200">
|
||||||
<td className="py-2 pr-3 font-medium text-ink-600">{library.name}</td>
|
<td className="py-2 pr-3 font-medium text-ink-600">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{library.cover_url && <img src={library.cover_url} alt="" className="h-10 w-8 rounded object-cover" />}
|
||||||
|
<span>{library.name}</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
<td className="py-1.5 text-ink-100">
|
<td className="py-1.5 text-ink-100">
|
||||||
<LibraryRootsCell library={library} {...actions} />
|
<LibraryRootsCell library={library} {...actions} />
|
||||||
</td>
|
</td>
|
||||||
@@ -170,12 +177,18 @@ function RootActionButtons({ library, root, draft, ...actions }: RootEditorProps
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function LibraryActionsCell({ library, onScanLibrary, onRemoveLibrary }: LibraryTableRowProps) {
|
function LibraryActionsCell({ library, onScanLibrary, onRemoveLibrary, onAddLibraryRoot, onEditLibraryCover }: LibraryTableRowProps) {
|
||||||
return (
|
return (
|
||||||
<ActionMenu label="媒体库操作">
|
<ActionMenu label="媒体库操作">
|
||||||
<MenuButton icon={<RefreshCw size={14} />} label="扫描" onClick={() => onScanLibrary(library)}>
|
<MenuButton icon={<RefreshCw size={14} />} label="扫描" onClick={() => onScanLibrary(library)}>
|
||||||
扫描
|
扫描
|
||||||
</MenuButton>
|
</MenuButton>
|
||||||
|
<MenuButton icon={<Plus size={14} />} label="添加来源" onClick={() => onAddLibraryRoot(library)}>
|
||||||
|
添加来源
|
||||||
|
</MenuButton>
|
||||||
|
<MenuButton icon={<Image size={14} />} label="自定义封面" onClick={() => onEditLibraryCover(library)}>
|
||||||
|
自定义封面
|
||||||
|
</MenuButton>
|
||||||
<MenuButton danger icon={<Trash2 size={14} />} label="删除" onClick={() => onRemoveLibrary(library)}>
|
<MenuButton danger icon={<Trash2 size={14} />} label="删除" onClick={() => onRemoveLibrary(library)}>
|
||||||
删除
|
删除
|
||||||
</MenuButton>
|
</MenuButton>
|
||||||
|
|||||||
@@ -129,7 +129,9 @@ export function LibrariesContent({ previews }: { previews: LibraryPreview[] }) {
|
|||||||
|
|
||||||
function LibraryEntryCard({ preview }: { preview: LibraryPreview }) {
|
function LibraryEntryCard({ preview }: { preview: LibraryPreview }) {
|
||||||
const library = preview.library
|
const library = preview.library
|
||||||
const artwork = libraryArtworkItems(preview.cards)
|
const artwork = library.cover_url
|
||||||
|
? [{ src: library.cover_url, version: library.updated_at }]
|
||||||
|
: libraryArtworkItems(preview.cards)
|
||||||
const displayPath = libraryDisplayPath(library.path)
|
const displayPath = libraryDisplayPath(library.path)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -137,7 +139,7 @@ function LibraryEntryCard({ preview }: { preview: LibraryPreview }) {
|
|||||||
to={`/library/${library.id}`}
|
to={`/library/${library.id}`}
|
||||||
className="group flex overflow-hidden rounded-3xl border border-sand-200 bg-white p-3 shadow-card transition-all hover:-translate-y-0.5 hover:border-brand-200 hover:shadow-card-hover"
|
className="group flex overflow-hidden rounded-3xl border border-sand-200 bg-white p-3 shadow-card transition-all hover:-translate-y-0.5 hover:border-brand-200 hover:shadow-card-hover"
|
||||||
>
|
>
|
||||||
<div className="grid h-24 w-36 shrink-0 grid-cols-2 gap-1 overflow-hidden rounded-2xl bg-[linear-gradient(135deg,#fff7ed,#f8fafc)]">
|
<div className={`grid h-24 w-36 shrink-0 gap-1 overflow-hidden rounded-2xl bg-[linear-gradient(135deg,#fff7ed,#f8fafc)] ${artwork.length > 1 ? 'grid-cols-2' : 'grid-cols-1'}`}>
|
||||||
{artwork.length > 0 ? (
|
{artwork.length > 0 ? (
|
||||||
artwork.map(({ src, version }, index) => (
|
artwork.map(({ src, version }, index) => (
|
||||||
<img
|
<img
|
||||||
|
|||||||
@@ -5,6 +5,13 @@ export const generalSettingsGroup: SettingGroup = {
|
|||||||
label: '常规',
|
label: '常规',
|
||||||
description: '语言 / 转码引擎参数(API 密钥请在管理后台 → 外部API 配置)',
|
description: '语言 / 转码引擎参数(API 密钥请在管理后台 → 外部API 配置)',
|
||||||
items: [
|
items: [
|
||||||
|
{
|
||||||
|
key: 'ui.hide_community_links_for_users',
|
||||||
|
label: '对普通用户隐藏社区页脚链接',
|
||||||
|
type: 'toggle',
|
||||||
|
hint: '开启后,登录页及普通用户登录后的页脚不显示 TG 群组、开源仓库和作者主页;管理员仍可见。',
|
||||||
|
defaultValue: 'false',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: 'tmdb.language',
|
key: 'tmdb.language',
|
||||||
label: 'TMDb 元数据语言',
|
label: 'TMDb 元数据语言',
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ function useCreateLibraryForm(refresh: () => Promise<void>) {
|
|||||||
const [name, setName] = useState('')
|
const [name, setName] = useState('')
|
||||||
const [roots, setRoots] = useState<RootDraft[]>([emptyRootDraft()])
|
const [roots, setRoots] = useState<RootDraft[]>([emptyRootDraft()])
|
||||||
const [type, setType] = useState('movie')
|
const [type, setType] = useState('movie')
|
||||||
|
const [coverURL, setCoverURL] = useState('')
|
||||||
|
|
||||||
const handleCreate = async (e: FormEvent) => {
|
const handleCreate = async (e: FormEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
@@ -40,10 +41,11 @@ function useCreateLibraryForm(refresh: () => Promise<void>) {
|
|||||||
toast.error('请至少填写一个路径')
|
toast.error('请至少填写一个路径')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
await libraryAPI.createWithRoots(name, type, payload)
|
await libraryAPI.createWithRoots(name, type, payload, coverURL.trim())
|
||||||
toast.success('媒体库已保存')
|
toast.success('媒体库已保存')
|
||||||
setName('')
|
setName('')
|
||||||
setRoots([emptyRootDraft()])
|
setRoots([emptyRootDraft()])
|
||||||
|
setCoverURL('')
|
||||||
await refresh()
|
await refresh()
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
toast.error(apiErrorMessage(err, '创建失败'))
|
toast.error(apiErrorMessage(err, '创建失败'))
|
||||||
@@ -57,9 +59,11 @@ function useCreateLibraryForm(refresh: () => Promise<void>) {
|
|||||||
return {
|
return {
|
||||||
name,
|
name,
|
||||||
type,
|
type,
|
||||||
|
coverURL,
|
||||||
roots,
|
roots,
|
||||||
setName,
|
setName,
|
||||||
setType,
|
setType,
|
||||||
|
setCoverURL,
|
||||||
updateRoot,
|
updateRoot,
|
||||||
addRoot: () => setRoots((prev) => [...prev, emptyRootDraft()]),
|
addRoot: () => setRoots((prev) => [...prev, emptyRootDraft()]),
|
||||||
removeRoot: (index: number) => setRoots((prev) => (prev.length <= 1 ? prev : prev.filter((_, i) => i !== index))),
|
removeRoot: (index: number) => setRoots((prev) => (prev.length <= 1 ? prev : prev.filter((_, i) => i !== index))),
|
||||||
@@ -153,5 +157,22 @@ function useLibraryActions(refresh: () => Promise<void>) {
|
|||||||
await refresh()
|
await refresh()
|
||||||
}
|
}
|
||||||
|
|
||||||
return { scanLibrary, removeLibrary }
|
const addLibraryRoot = async (library: Library) => {
|
||||||
|
const path = window.prompt(`为「${library.name}」添加来源目录:`)
|
||||||
|
if (!path?.trim()) return
|
||||||
|
const name = window.prompt('来源名称(可选):') ?? ''
|
||||||
|
await libraryAPI.addRoot(library.id, { path: path.trim(), name: name.trim(), enabled: true })
|
||||||
|
toast.success('来源目录已添加')
|
||||||
|
await refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
const editLibraryCover = async (library: Library) => {
|
||||||
|
const coverURL = window.prompt('自定义封面 URL(留空可清除):', library.cover_url ?? '')
|
||||||
|
if (coverURL === null) return
|
||||||
|
await libraryAPI.update(library.id, { cover_url: coverURL.trim() })
|
||||||
|
toast.success(coverURL.trim() ? '媒体库封面已保存' : '媒体库封面已清除')
|
||||||
|
await refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
return { scanLibrary, removeLibrary, addLibraryRoot, editLibraryCover }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ export interface Library {
|
|||||||
name: string
|
name: string
|
||||||
path: string
|
path: string
|
||||||
type: string
|
type: string
|
||||||
|
cover_url?: string
|
||||||
enabled: boolean
|
enabled: boolean
|
||||||
roots?: LibraryRoot[]
|
roots?: LibraryRoot[]
|
||||||
created_at: string
|
created_at: string
|
||||||
|
|||||||
Reference in New Issue
Block a user