Add library covers and configurable footer visibility

This commit is contained in:
ShukeBta
2026-07-16 13:17:52 +08:00
parent cc056077ee
commit 06b25564ea
18 changed files with 232 additions and 21 deletions
+5 -2
View File
@@ -99,8 +99,11 @@ export const libraryAPI = {
create: (name: string, path: string, type: string) =>
api.post<Library>('/libraries', { name, path, type }).then((r) => r.data),
createWithRoots: (name: string, type: string, roots: LibraryRootInput[]) =>
api.post<Library>('/libraries', { name, type, roots }).then((r) => r.data),
createWithRoots: (name: string, type: string, roots: LibraryRootInput[], coverURL = '') =>
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),
+9
View File
@@ -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),
}
+12
View File
@@ -1,5 +1,9 @@
import { useEffect, useState } from 'react'
import { Github, Globe, Send } from 'lucide-react'
import { publicAPI } from '../api/public'
import { useAuthStore } from '../stores/auth'
const LINKS = [
{ href: 'https://github.com/ShukeBta/MediaStationGo', icon: Github, label: '开源仓库' },
{ href: 'https://github.com/ShukeBta', icon: Globe, label: '作者主页' },
@@ -7,6 +11,14 @@ const LINKS = [
]
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 (
<footer className={`flex items-center justify-center gap-1 ${className}`}>
{LINKS.map((link, i) => (
+4
View File
@@ -10,9 +10,11 @@ export function AdminLibraryPanel() {
<AdminLibraryCreateForm
name={createForm.name}
type={createForm.type}
coverURL={createForm.coverURL}
roots={createForm.roots}
onNameChange={createForm.setName}
onTypeChange={createForm.setType}
onCoverURLChange={createForm.setCoverURL}
onRootChange={createForm.updateRoot}
onAddRoot={createForm.addRoot}
onRemoveRoot={createForm.removeRoot}
@@ -28,6 +30,8 @@ export function AdminLibraryPanel() {
onRemoveRoot={rootActions.removeLibraryRoot}
onScanLibrary={libraryActions.scanLibrary}
onRemoveLibrary={libraryActions.removeLibrary}
onAddLibraryRoot={libraryActions.addLibraryRoot}
onEditLibraryCover={libraryActions.editLibraryCover}
/>
</div>
)
@@ -6,9 +6,11 @@ import type { RootDraft } from './adminLibraryPanelModel'
type CreateFormProps = {
name: string
type: string
coverURL: string
roots: RootDraft[]
onNameChange: (value: string) => void
onTypeChange: (value: string) => void
onCoverURLChange: (value: string) => void
onRootChange: (index: number, patch: Partial<RootDraft>) => void
onAddRoot: () => void
onRemoveRoot: (index: number) => void
@@ -18,9 +20,11 @@ type CreateFormProps = {
export function AdminLibraryCreateForm({
name,
type,
coverURL,
roots,
onNameChange,
onTypeChange,
onCoverURLChange,
onRootChange,
onAddRoot,
onRemoveRoot,
@@ -42,6 +46,12 @@ export function AdminLibraryCreateForm({
<option value="anime">动漫</option>
<option value="music">音乐</option>
</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">
{roots.map((root, index) => (
<CreateRootRow
+16 -3
View File
@@ -1,5 +1,5 @@
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 { RootDraft } from './adminLibraryPanelModel'
@@ -15,6 +15,8 @@ type LibraryTableProps = {
onRemoveRoot: (library: Library, root: LibraryRoot) => void
onScanLibrary: (library: Library) => void
onRemoveLibrary: (library: Library) => void
onAddLibraryRoot: (library: Library) => void
onEditLibraryCover: (library: Library) => void
}
export function AdminLibraryTable({ libs, ...actions }: LibraryTableProps) {
@@ -46,7 +48,12 @@ type LibraryTableRowProps = Omit<LibraryTableProps, 'libs'> & {
function LibraryTableRow({ library, ...actions }: LibraryTableRowProps) {
return (
<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">
<LibraryRootsCell library={library} {...actions} />
</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 (
<ActionMenu label="媒体库操作">
<MenuButton icon={<RefreshCw size={14} />} label="扫描" onClick={() => onScanLibrary(library)}>
扫描
</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>
+4 -2
View File
@@ -129,7 +129,9 @@ export function LibrariesContent({ previews }: { previews: LibraryPreview[] }) {
function LibraryEntryCard({ preview }: { preview: LibraryPreview }) {
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)
return (
@@ -137,7 +139,7 @@ function LibraryEntryCard({ preview }: { preview: LibraryPreview }) {
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"
>
<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.map(({ src, version }, index) => (
<img
+7
View File
@@ -5,6 +5,13 @@ export const generalSettingsGroup: SettingGroup = {
label: '常规',
description: '语言 / 转码引擎参数(API 密钥请在管理后台 → 外部API 配置)',
items: [
{
key: 'ui.hide_community_links_for_users',
label: '对普通用户隐藏社区页脚链接',
type: 'toggle',
hint: '开启后,登录页及普通用户登录后的页脚不显示 TG 群组、开源仓库和作者主页;管理员仍可见。',
defaultValue: 'false',
},
{
key: 'tmdb.language',
label: 'TMDb 元数据语言',
+23 -2
View File
@@ -31,6 +31,7 @@ function useCreateLibraryForm(refresh: () => Promise<void>) {
const [name, setName] = useState('')
const [roots, setRoots] = useState<RootDraft[]>([emptyRootDraft()])
const [type, setType] = useState('movie')
const [coverURL, setCoverURL] = useState('')
const handleCreate = async (e: FormEvent) => {
e.preventDefault()
@@ -40,10 +41,11 @@ function useCreateLibraryForm(refresh: () => Promise<void>) {
toast.error('请至少填写一个路径')
return
}
await libraryAPI.createWithRoots(name, type, payload)
await libraryAPI.createWithRoots(name, type, payload, coverURL.trim())
toast.success('媒体库已保存')
setName('')
setRoots([emptyRootDraft()])
setCoverURL('')
await refresh()
} catch (err: unknown) {
toast.error(apiErrorMessage(err, '创建失败'))
@@ -57,9 +59,11 @@ function useCreateLibraryForm(refresh: () => Promise<void>) {
return {
name,
type,
coverURL,
roots,
setName,
setType,
setCoverURL,
updateRoot,
addRoot: () => setRoots((prev) => [...prev, emptyRootDraft()]),
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()
}
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 }
}
+1
View File
@@ -14,6 +14,7 @@ export interface Library {
name: string
path: string
type: string
cover_url?: string
enabled: boolean
roots?: LibraryRoot[]
created_at: string