diff --git a/README.md b/README.md index d5d6365..90957fa 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,7 @@

English + Telegram 群组

diff --git a/internal/handler/handler.go b/internal/handler/handler.go index 7b82853..b546450 100644 --- a/internal/handler/handler.go +++ b/internal/handler/handler.go @@ -150,23 +150,6 @@ func Register(r *gin.Engine, cfg *config.Config, log *zap.Logger, svc *service.C // SSE event stream. authed.GET("/events", sseHandler(svc)) - // Download clients. - authed.GET("/download-clients", listDownloadClientsHandler(svc)) - authed.POST("/download-clients", middleware.AdminRequired(), createDownloadClientHandler(svc)) - authed.GET("/download-clients/:id", getDownloadClientHandler(svc)) - authed.PUT("/download-clients/:id", middleware.AdminRequired(), updateDownloadClientHandler(svc)) - authed.DELETE("/download-clients/:id", middleware.AdminRequired(), deleteDownloadClientHandler(svc)) - authed.POST("/download-clients/:id/test", middleware.AdminRequired(), testDownloadClientHandler(svc)) - - // Notify channels. - authed.GET("/notify-channels", listNotifyChannelsHandler(svc)) - authed.GET("/notify-channels/types", getNotifyChannelTypesHandler(svc)) - authed.POST("/notify-channels", middleware.AdminRequired(), createNotifyChannelHandler(svc)) - authed.GET("/notify-channels/:id", getNotifyChannelHandler(svc)) - authed.PUT("/notify-channels/:id", middleware.AdminRequired(), updateNotifyChannelHandler(svc)) - authed.DELETE("/notify-channels/:id", middleware.AdminRequired(), deleteNotifyChannelHandler(svc)) - authed.POST("/notify-channels/:id/test", middleware.AdminRequired(), testNotifyChannelHandler(svc)) - // Scheduler. authed.GET("/scheduler/tasks", schedulerListTasksHandler(svc)) authed.POST("/scheduler/tasks/:id/run", middleware.AdminRequired(), schedulerRunTaskHandler(svc)) @@ -263,7 +246,6 @@ func Register(r *gin.Engine, cfg *config.Config, log *zap.Logger, svc *service.C authed.POST("/download/sync", middleware.AdminRequired(), downloadSyncHandler(svc)) authed.POST("/download/start-auto-sync", middleware.AdminRequired(), downloadAutoSyncHandler(svc)) authed.GET("/download/tasks", downloadTasksAliasHandler(svc)) - authed.POST("/download/add", addDownloadHandler(svc)) // ── Assistant (multi-turn AI chat) ── authed.GET("/admin/assistant/sessions", listAssistantSessionsHandler(svc)) diff --git a/web/index.html b/web/index.html index 6924ea0..6965c8e 100644 --- a/web/index.html +++ b/web/index.html @@ -3,12 +3,12 @@ - + MediaStationGo diff --git a/web/src/api/downloadClient.ts b/web/src/api/downloadClient.ts deleted file mode 100644 index 3eb6ebb..0000000 --- a/web/src/api/downloadClient.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { api } from './client' -import type { DownloadClient } from '../types' - -export interface DownloadClientCreateParams { - name: string - type: 'qbittorrent' | 'transmission' | 'aria2' - host: string - username?: string - password?: string - is_default?: boolean - extra?: Record -} - -export interface DownloadClientUpdateParams { - name?: string - type?: 'qbittorrent' | 'transmission' | 'aria2' - host?: string - username?: string - password?: string - is_default?: boolean - enabled?: boolean - extra?: Record -} - -export const downloadClientAPI = { - list: () => - api - .get<{ code: number; data: DownloadClient[] }>('/download-clients') - .then((r) => r.data.data), - - get: (id: string) => - api - .get<{ code: number; data: DownloadClient }>(`/download-clients/${id}`) - .then((r) => r.data.data), - - create: (params: DownloadClientCreateParams) => - api - .post<{ code: number; data: DownloadClient }>('/download-clients', params) - .then((r) => r.data.data), - - update: (id: string, params: DownloadClientUpdateParams) => - api - .put<{ code: number; data: DownloadClient }>(`/download-clients/${id}`, params) - .then((r) => r.data.data), - - delete: (id: string) => - api.delete(`/download-clients/${id}`).then((r) => r.data), - - test: (id: string) => - api.post(`/download-clients/${id}/test`).then((r) => r.data), -} diff --git a/web/src/api/notify.ts b/web/src/api/notify.ts deleted file mode 100644 index 17cc671..0000000 --- a/web/src/api/notify.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { api } from './client' -import type { NotifyChannel, NotifyProviderInfo } from '../types' - -export interface NotifyChannelCreateParams { - name: string - type: 'telegram' | 'wechat' | 'bark' | 'webhook' | 'email' - enabled?: boolean - config: Record - events?: string[] -} - -export interface NotifyChannelUpdateParams { - name?: string - enabled?: boolean - config?: Record - events?: string[] -} - -export const notifyAPI = { - list: () => - api - .get<{ code: number; data: NotifyChannel[] }>('/notify-channels') - .then((r) => r.data.data), - - get: (id: string) => - api - .get<{ code: number; data: NotifyChannel }>(`/notify-channels/${id}`) - .then((r) => r.data.data), - - getTypes: () => - api - .get<{ code: number; data: NotifyProviderInfo[] }>('/notify-channels/types') - .then((r) => r.data.data), - - create: (params: NotifyChannelCreateParams) => - api - .post<{ code: number; data: NotifyChannel }>('/notify-channels', params) - .then((r) => r.data.data), - - update: (id: string, params: NotifyChannelUpdateParams) => - api - .put<{ code: number; data: NotifyChannel }>(`/notify-channels/${id}`, params) - .then((r) => r.data.data), - - delete: (id: string) => - api.delete(`/notify-channels/${id}`).then((r) => r.data), - - test: (id: string) => - api.post(`/notify-channels/${id}/test`).then((r) => r.data), -} diff --git a/web/src/components/AppFooter.tsx b/web/src/components/AppFooter.tsx new file mode 100644 index 0000000..2b92479 --- /dev/null +++ b/web/src/components/AppFooter.tsx @@ -0,0 +1,41 @@ +import { Github, Globe, Send } from 'lucide-react' + +const LINKS = [ + { + href: 'https://github.com/ShukeBta/MediaStationGo', + icon: Github, + label: '开源仓库', + }, + { + href: 'https://github.com/ShukeBta', + icon: Globe, + label: '作者主页', + }, + { + href: 'https://t.me/MediaStationGo', + icon: Send, + label: 'TG 群组', + }, +] + +export function AppFooter({ className = '' }: { className?: string }) { + return ( +

+ ) +} diff --git a/web/src/components/DownloadClientCard.tsx b/web/src/components/DownloadClientCard.tsx deleted file mode 100644 index 8cdcabc..0000000 --- a/web/src/components/DownloadClientCard.tsx +++ /dev/null @@ -1,250 +0,0 @@ -import { FormEvent, useEffect, useState } from 'react' -import toast from 'react-hot-toast' -import { Plug, Trash2, Zap, Settings, Loader2 } from 'lucide-react' - -import { downloadClientAPI, type DownloadClientCreateParams } from '../api/downloadClient' -import type { DownloadClient } from '../types' - -const CLIENT_TYPES = [ - { value: 'qbittorrent', label: 'qBittorrent' }, - { value: 'transmission', label: 'Transmission' }, - { value: 'aria2', label: 'Aria2' }, -] as const - -const TYPE_LABELS: Record = { - qbittorrent: 'qBittorrent', - transmission: 'Transmission', - aria2: 'Aria2', -} - -export function DownloadClientCard() { - const [clients, setClients] = useState([]) - const [showForm, setShowForm] = useState(false) - const [testing, setTesting] = useState(null) - const [form, setForm] = useState({ - name: '', - type: 'qbittorrent', - host: '', - username: '', - password: '', - is_default: false, - }) - - const refresh = () => downloadClientAPI.list().then(setClients) - useEffect(() => { - refresh().catch(() => undefined) - }, []) - - const handleSubmit = async (e: FormEvent) => { - e.preventDefault() - try { - await downloadClientAPI.create(form) - toast.success('下载客户端已创建') - setForm({ name: '', type: 'qbittorrent', host: '', username: '', password: '', is_default: false }) - setShowForm(false) - await refresh() - } catch { - toast.error('创建失败') - } - } - - const handleTest = async (id: string) => { - setTesting(id) - try { - await downloadClientAPI.test(id) - toast.success('连接测试成功') - } catch { - toast.error('连接测试失败') - } finally { - setTesting(null) - } - } - - const handleDelete = async (id: string, name: string) => { - if (!confirm(`确定删除「${name}」?`)) return - await downloadClientAPI.delete(id) - toast.success('已删除') - await refresh() - } - - const handleToggleDefault = async (client: DownloadClient) => { - await downloadClientAPI.update(client.id, { is_default: true }) - toast.success(`已设「${client.name}」为默认客户端`) - await refresh() - } - - const handleToggleEnabled = async (client: DownloadClient) => { - await downloadClientAPI.update(client.id, { enabled: !client.enabled }) - toast.success(client.enabled ? '已禁用' : '已启用') - await refresh() - } - - return ( -
-
-

下载客户端

- -
- - {showForm && ( -
-
- setForm({ ...form, name: e.target.value })} - /> - -
- setForm({ ...form, host: e.target.value })} - /> -
- setForm({ ...form, username: e.target.value })} - /> - setForm({ ...form, password: e.target.value })} - /> -
- -
- - -
-
- )} - -
- {clients.map((client) => ( -
-
-
- -
-
-
- {client.name} - - {TYPE_LABELS[client.type]} - - {client.is_default && ( - - 默认 - - )} - {!client.enabled && ( - - 已禁用 - - )} -
- {client.host} -
-
-
- {!client.is_default && ( - - )} - - - -
-
- ))} - {clients.length === 0 && ( -
- 暂无下载客户端,点击上方按钮添加 -
- )} -
-
- ) -} diff --git a/web/src/components/Layout.tsx b/web/src/components/Layout.tsx index 4a1d2cd..df98717 100644 --- a/web/src/components/Layout.tsx +++ b/web/src/components/Layout.tsx @@ -36,11 +36,11 @@ import { } from 'lucide-react' import clsx from 'clsx' +import { AppFooter } from './AppFooter' import { libraryAPI } from '../api/library' import { useAuthStore } from '../stores/auth' import type { Library } from '../types' -// Top-level chrome: a fixed left rail + scrollable content area. export function Layout() { const navigate = useNavigate() const user = useAuthStore((s) => s.user) @@ -58,96 +58,132 @@ export function Layout() { return (
- + {/* Logo */} + +
+ +
+ + MediaStation + + -
- + {/* Navigation */} + + + {/* Bottom: logout */} +
+ +
+ +
+ + {/* ── Main content ── */} +
+
+ +
+
) } +/** Section divider — visible only when sidebar is expanded */ +function SectionHeader({ label }: { label: string }) { + return ( +
+ + {label} + +
+ ) +} + +/** Single nav link — icon always visible, label fades in on expand */ function SidebarLink({ to, icon, @@ -165,15 +201,23 @@ function SidebarLink({ end={end} className={({ isActive }) => clsx( - 'flex items-center gap-3 rounded-lg px-3 py-2 text-sm transition', + 'flex items-center gap-3 rounded-lg px-3 py-2 text-sm transition-colors', isActive - ? 'bg-primary-400/10 text-primary-400' - : 'text-slate-300 hover:bg-white/5 hover:text-white', + ? 'bg-brand-500/10 text-brand-400' + : 'text-cream-400 hover:bg-cream-900/10 hover:text-cream-200', ) } > - {icon} - {label} + {icon} + + {label} + ) } diff --git a/web/src/components/MediaCard.tsx b/web/src/components/MediaCard.tsx index 880664b..120f036 100644 --- a/web/src/components/MediaCard.tsx +++ b/web/src/components/MediaCard.tsx @@ -1,14 +1,10 @@ import { Link } from 'react-router-dom' -import { Film } from 'lucide-react' +import { Film, Play } from 'lucide-react' import { imageURL } from '../api/client' import type { Media } from '../types' -// Compact poster tile used by every listing surface (home / library / search). -// -// Props: -// - media: the Media row to render -// - progress: 0..1 fractional resume position (optional) +/** Compact poster tile — used by library, search, favourites, poster wall, etc. */ export function MediaCard({ media, progress, @@ -19,34 +15,51 @@ export function MediaCard({ return ( -
+ {/* Poster */} +
{media.poster_url ? ( {media.title} ) : ( -
+
)} + + {/* Hover overlay */} +
+ + + 播放 + +
+ + {/* Progress bar */} {progress !== undefined && progress > 0 && progress < 1 && ( -
+
)}
-
-

{media.title}

- {media.year > 0 &&

{media.year}

} + + {/* Info */} +
+

+ {media.title} +

+ {media.year > 0 && ( +

{media.year}

+ )}
) diff --git a/web/src/components/NotifyChannelCard.tsx b/web/src/components/NotifyChannelCard.tsx deleted file mode 100644 index 6cb84a8..0000000 --- a/web/src/components/NotifyChannelCard.tsx +++ /dev/null @@ -1,282 +0,0 @@ -import { FormEvent, useEffect, useState } from 'react' -import toast from 'react-hot-toast' -import { Bell, Trash2, Loader2 } from 'lucide-react' - -import { notifyAPI, type NotifyChannelCreateParams } from '../api/notify' -import type { NotifyChannel } from '../types' - -const CHANNEL_TYPES = [ - { value: 'telegram', label: 'Telegram' }, - { value: 'wechat', label: 'Server酱' }, - { value: 'bark', label: 'Bark' }, - { value: 'webhook', label: 'Webhook' }, - { value: 'email', label: 'Email' }, -] as const - -const TYPE_LABELS: Record = { - telegram: 'Telegram', - wechat: 'Server酱', - bark: 'Bark', - webhook: 'Webhook', - email: 'Email', -} - -const EVENT_OPTIONS = [ - { value: 'subscription_hit', label: '订阅命中' }, - { value: 'download_complete', label: '下载完成' }, - { value: 'scrape_failed', label: '刮削失败' }, - { value: 'system_alert', label: '系统告警' }, -] - -const CONFIG_FIELDS: Record = { - telegram: [ - { key: 'bot_token', label: 'Bot Token', placeholder: '123456:ABC-DEF...' }, - { key: 'chat_id', label: 'Chat ID', placeholder: '你的 Chat ID' }, - { key: 'parse_mode', label: 'Parse Mode', placeholder: 'HTML (可选)' }, - ], - wechat: [{ key: 'sendkey', label: 'SendKey', placeholder: 'SCT...' }], - bark: [ - { key: 'server_url', label: '服务器地址', placeholder: 'https://api.day.app (可选)' }, - { key: 'device_key', label: 'Device Key', placeholder: '你的 Bark Key' }, - ], - webhook: [ - { key: 'url', label: 'Webhook URL', placeholder: 'https://...' }, - { key: 'method', label: 'HTTP 方法', placeholder: 'POST (可选)' }, - { key: 'headers_json', label: 'Headers JSON', placeholder: '{"Authorization":"Bearer ..."} (可选)' }, - { key: 'body_template', label: 'Body 模板', placeholder: '{{title}}: {{message}} (可选)' }, - ], - email: [ - { key: 'smtp_host', label: 'SMTP 地址', placeholder: 'smtp.gmail.com' }, - { key: 'smtp_port', label: 'SMTP 端口', placeholder: '465' }, - { key: 'username', label: '用户名', placeholder: '' }, - { key: 'password', label: '密码', placeholder: '', type: 'password' }, - { key: 'from', label: '发件人', placeholder: 'noreply@example.com' }, - { key: 'to', label: '收件人', placeholder: 'user@example.com (多个用逗号分隔)' }, - { key: 'tls', label: 'TLS', placeholder: 'true/false' }, - ], -} - -export function NotifyChannelCard() { - const [channels, setChannels] = useState([]) - const [showForm, setShowForm] = useState(false) - const [testing, setTesting] = useState(null) - const [form, setForm] = useState({ - name: '', - type: 'telegram', - enabled: true, - config: {}, - events: [], - }) - - const refresh = () => notifyAPI.list().then(setChannels) - useEffect(() => { - refresh().catch(() => undefined) - }, []) - - const handleTypeChange = (type: string) => { - setForm({ - ...form, - type: type as NotifyChannelCreateParams['type'], - config: {}, - }) - } - - const handleConfigChange = (key: string, value: string) => { - setForm({ ...form, config: { ...form.config, [key]: value } }) - } - - const handleEventToggle = (event: string) => { - const events = form.events || [] - const idx = events.indexOf(event) - if (idx >= 0) { - events.splice(idx, 1) - } else { - events.push(event) - } - setForm({ ...form, events: [...events] }) - } - - const handleSubmit = async (e: FormEvent) => { - e.preventDefault() - try { - await notifyAPI.create(form) - toast.success('通知渠道已创建') - setForm({ name: '', type: 'telegram', enabled: true, config: {}, events: [] }) - setShowForm(false) - await refresh() - } catch { - toast.error('创建失败') - } - } - - const handleTest = async (id: string) => { - setTesting(id) - try { - await notifyAPI.test(id) - toast.success('测试通知已发送') - } catch { - toast.error('测试通知发送失败') - } finally { - setTesting(null) - } - } - - const handleDelete = async (id: string, name: string) => { - if (!confirm(`确定删除「${name}」?`)) return - await notifyAPI.delete(id) - toast.success('已删除') - await refresh() - } - - const handleToggleEnabled = async (channel: NotifyChannel) => { - await notifyAPI.update(channel.id, { enabled: !channel.enabled }) - toast.success(channel.enabled ? '已禁用' : '已启用') - await refresh() - } - - const configFields = CONFIG_FIELDS[form.type] || [] - - return ( -
-
-

通知渠道

- -
- - {showForm && ( -
-
- setForm({ ...form, name: e.target.value })} - /> - -
- - {configFields.map((field) => ( - handleConfigChange(field.key, e.target.value)} - /> - ))} - -
- -
- {EVENT_OPTIONS.map((event) => ( - - ))} -
-
- -
- - -
-
- )} - -
- {channels.map((channel) => ( -
-
-
- -
-
-
- {channel.name} - - {TYPE_LABELS[channel.type]} - - {!channel.enabled && ( - - 已禁用 - - )} -
- - 事件: {channel.events || '无'} - -
-
-
- - - -
-
- ))} - {channels.length === 0 && ( -
- 暂无通知渠道,点击上方按钮添加 -
- )} -
-
- ) -} diff --git a/web/src/index.css b/web/src/index.css index deba348..89046b6 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -13,35 +13,150 @@ body, } body { - background: radial-gradient( - ellipse at 20% 50%, - rgba(0, 240, 255, 0.08) 0%, - transparent 50% - ), - radial-gradient( - ellipse at 80% 20%, - rgba(168, 85, 247, 0.08) 0%, - transparent 50% - ), - #060a13; - color: #f5f7fa; + background-color: #111110; + color: #e8e4d9; margin: 0; } +/* ─── SVG 噪点纹理 ─── */ +body::before { + content: ''; + position: fixed; + inset: 0; + z-index: 9999; + pointer-events: none; + opacity: 0.035; + background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E"); + background-repeat: repeat; + background-size: 256px 256px; +} + +/* ─── Scrollbar ─── */ +::-webkit-scrollbar { + width: 6px; +} +::-webkit-scrollbar-track { + background: transparent; +} +::-webkit-scrollbar-thumb { + background: #2e2e2a; + border-radius: 999px; +} +::-webkit-scrollbar-thumb:hover { + background: #3a3a35; +} + +/* ─── Selection ─── */ +::selection { + background: rgba(201, 149, 74, 0.3); + color: #e8e4d9; +} + +/* ─── Focus ring ─── */ +*:focus-visible { + outline: 2px solid #c9954a; + outline-offset: 2px; + border-radius: 2px; +} + @layer components { + /* ─── Surface Card — 替代 glass-panel ─── */ + .surface-card { + @apply rounded-lg border border-cream-900/20 bg-surface-400 px-4 py-3; + /* No shadow, no blur. Pure material feel. */ + } + .surface-card-elevated { + @apply surface-card; + box-shadow: 0 2px 12px rgba(0, 0, 0, 0.4); + } + + /* ─── 暖色分割线 ─── */ + .warm-divider { + @apply border-t border-cream-900/15; + } + + /* ─── 琥珀色按钮 — 替代 neon-button ─── */ + .amber-btn { + @apply inline-flex items-center justify-center gap-2 rounded-lg border border-brand-500/30 + bg-brand-500/10 px-4 py-2 text-sm font-medium text-brand-400 transition-all + hover:border-brand-500/50 hover:bg-brand-500/20 hover:text-brand-300 + active:scale-[0.98] disabled:cursor-not-allowed disabled:opacity-40; + } + .amber-btn--solid { + @apply amber-btn border-transparent bg-brand-600 text-cream-100 + hover:bg-brand-500 hover:text-white; + } + .amber-btn--ghost { + @apply amber-btn border-transparent bg-transparent hover:bg-cream-900/10; + } + + /* ─── 鼠尾草按钮 ─── */ + .sage-btn { + @apply inline-flex items-center justify-center gap-2 rounded-lg border border-sage-400/20 + bg-sage-400/10 px-4 py-2 text-sm font-medium text-sage-300 transition-all + hover:border-sage-400/40 hover:bg-sage-400/20 hover:text-sage-200 + active:scale-[0.98] disabled:cursor-not-allowed disabled:opacity-40; + } + + /* ─── 表单输入 ─── */ + .field-input { + @apply w-full rounded-lg border border-cream-900/20 bg-surface-600 px-4 py-2.5 + text-sm text-cream-200 placeholder:text-cream-500/40 + transition-colors + focus:border-brand-500/50 focus:bg-surface-500 focus:outline-none + disabled:cursor-not-allowed disabled:opacity-50; + } + .field-label { + @apply block text-xs font-medium uppercase tracking-wider text-cream-400 mb-1.5; + } + + /* ─── 标签/徽章 ─── */ + .badge { + @apply inline-flex items-center rounded-full px-2 py-0.5 text-2xs font-medium; + } + .badge--brand { + @apply badge bg-brand-500/15 text-brand-400; + } + .badge--sage { + @apply badge bg-sage-400/15 text-sage-300; + } + .badge--neutral { + @apply badge bg-cream-900/20 text-cream-400; + } + + /* ─── 页面标题 ─── */ + .page-heading { + @apply font-display text-2xl font-bold tracking-tight text-cream-100; + } + .page-subtitle { + @apply mt-1 text-sm text-cream-400; + } + + /* ─── 表格(管理面板用) ─── */ + .data-table { + @apply w-full text-left text-sm; + } + .data-table thead { + @apply border-b border-cream-900/20; + } + .data-table th { + @apply px-4 py-3 text-xs font-medium uppercase tracking-wider text-cream-400; + } + .data-table td { + @apply px-4 py-3 text-cream-300; + } + .data-table tbody tr { + @apply border-b border-cream-900/10 transition-colors hover:bg-cream-900/5; + } + + /* ─── 旧版兼容别名(逐步废弃) ─── */ .glass-panel { - @apply rounded-2xl border border-white/10 bg-surface-800/60 p-6 shadow-lg backdrop-blur-md; + @apply surface-card; } - .neon-button { - @apply inline-flex items-center justify-center gap-2 rounded-lg border border-primary-400/40 - bg-primary-400/10 px-4 py-2 font-medium text-primary-400 transition - hover:border-primary-400 hover:bg-primary-400/20 hover:text-white - active:scale-95 disabled:cursor-not-allowed disabled:opacity-50; + @apply amber-btn; } - .input-base { - @apply w-full rounded-lg border border-white/10 bg-surface-900/60 px-4 py-2 text-sm - text-white placeholder:text-slate-500 focus:border-primary-400 focus:outline-none; + @apply field-input; } } diff --git a/web/src/pages/AdminPage.tsx b/web/src/pages/AdminPage.tsx index 6955546..9408a4a 100644 --- a/web/src/pages/AdminPage.tsx +++ b/web/src/pages/AdminPage.tsx @@ -4,26 +4,16 @@ import { Trash2 } from 'lucide-react' import { adminAPI } from '../api/admin' import { libraryAPI } from '../api/library' -import { schedulerAPI, type JobStatus } from '../api/scheduler' import type { Library, User } from '../types' -import { DownloadClientCard } from '../components/DownloadClientCard' -import { NotifyChannelCard } from '../components/NotifyChannelCard' import { APIConfigsPanel } from '../components/APIConfigsPanel' -import { SitesPage } from './SitesPage' export function AdminPage() { - const [tab, setTab] = useState< - 'sites' | 'library' | 'users' | 'settings' | 'api' | 'downloads' | 'notify' | 'scheduler' - >('sites') + const [tab, setTab] = useState<'library' | 'users' | 'settings' | 'api'>('library') const tabs = [ - { key: 'sites' as const, label: '站点管理' }, { key: 'library' as const, label: '媒体库' }, { key: 'users' as const, label: '用户' }, { key: 'api' as const, label: '外部API' }, { key: 'settings' as const, label: '系统设置' }, - { key: 'downloads' as const, label: '下载客户端' }, - { key: 'notify' as const, label: '通知渠道' }, - { key: 'scheduler' as const, label: '定时任务' }, ] return (
@@ -45,14 +35,10 @@ export function AdminPage() { ))}
- {tab === 'sites' && } {tab === 'library' && } {tab === 'users' && } {tab === 'api' && } {tab === 'settings' && } - {tab === 'downloads' && } - {tab === 'notify' && } - {tab === 'scheduler' && }
) } @@ -267,64 +253,3 @@ function SettingsPanel() {
) } - -function SchedulerPanel() { - const [tasks, setTasks] = useState([]) - const [loading, setLoading] = useState(false) - - const refresh = () => { - setLoading(true) - schedulerAPI.status().then(setTasks).finally(() => setLoading(false)) - } - useEffect(() => { - refresh() - }, []) - - return ( -
- {loading &&

加载中...

} - {!loading && tasks.length === 0 && ( -

暂无定时任务

- )} - {!loading && tasks.length > 0 && ( - - - - - - - - - - - {tasks.map((t) => ( - - - - - - - ))} - -
名称间隔上次运行操作
{t.name}{t.interval} - {t.last_run ? new Date(t.last_run).toLocaleString() : '-'} - - -
- )} -
- ) -} diff --git a/web/src/pages/HomePage.tsx b/web/src/pages/HomePage.tsx index 81bfc73..e38d739 100644 --- a/web/src/pages/HomePage.tsx +++ b/web/src/pages/HomePage.tsx @@ -1,15 +1,12 @@ import { useEffect, useState } from 'react' +import { Link } from 'react-router-dom' +import { ArrowRight, Play, Clock, Film, Compass, Sparkles } from 'lucide-react' import { mediaAPI } from '../api/library' import { playbackAPI, type HistoryItem } from '../api/playback' -import { MediaCard } from '../components/MediaCard' +import { imageURL } from '../api/client' import type { Media } from '../types' -// Landing page composes three rows: -// 1. Continue Watching — items with a positive position from the user's -// playback history (excluding completed ones). -// 2. Recently Added — most recent media across every library. -// 3. Empty state hint — shown when both rows are empty. export function HomePage() { const [recent, setRecent] = useState([]) const [history, setHistory] = useState([]) @@ -30,50 +27,221 @@ export function HomePage() { const empty = !loading && recent.length === 0 && history.length === 0 return ( -
-
-

主页

-

从你上次离开的地方继续

-
- - {history.length > 0 && ( - - {history.map((h) => { - const m = h.media! - const progress = h.duration_ms > 0 ? h.position_ms / h.duration_ms : 0 - return - })} - - )} - - {recent.length > 0 && ( - - {recent.map((m) => ( - - ))} - - )} - - {loading &&

加载中…

} - {empty && ( -
-

- 还没有任何媒体。前往 管理后台{' '} - 创建媒体库,然后触发一次扫描。 +

+ {/* ── Header ── */} +
+
+

+ 媒体中心 +

+

+ 从你上次离开的地方继续

+ {!loading && ( + + + 发现更多 + + + )} +
+ + {/* ── Loading ── */} + {loading && ( +
+
+

加载媒体库…

+
+ )} + + {/* ── Empty state ── */} + {empty && ( +
+ +
+

还没有任何媒体

+

+ 前往{' '} + + 管理后台 + {' '} + 创建媒体库,然后触发一次扫描 +

+
+
+ )} + + {/* ── Continue Watching — wide horizontal stripe ── */} + {history.length > 0 && ( +
+ } label="继续观看" /> +
+ {history.slice(0, 6).map((h) => { + const m = h.media! + const progress = h.duration_ms > 0 ? h.position_ms / h.duration_ms : 0 + return + })} +
+
+ )} + + {/* ── Recently Added — bento grid ── */} + {recent.length > 0 && ( +
+ } label="最近添加" /> +
+ {recent.slice(0, 12).map((m, i) => { + // First 2 items get double-column span on large screens + const isHero = i < 2 + return ( + + ) + })} +
+
+ )} + + {/* ── Quick Links ── */} + {!empty && ( +
+ } label="快捷入口" /> +
+ + + + +
+
)}
) } -function Row({ title, children }: { title: string; children: React.ReactNode }) { +/* ─── Section Heading ─── */ +function SectionHeading({ icon, label }: { icon: React.ReactNode; label: string }) { return ( -
-

{title}

-
- {children} -
-
+
+ {icon} +

+ {label} +

+
+ ) +} + +/* ─── Wide Continue-Watching Card ─── */ +function WideContinueCard({ media, progress }: { media: Media; progress: number }) { + return ( + + {/* Poster thumbnail */} +
+ {media.poster_url ? ( + {media.title} + ) : ( +
+ +
+ )} +
+ + {/* Info */} +
+

+ {media.title} +

+ {media.year > 0 && ( +

{media.year}

+ )} + + {/* Progress bar */} + {progress > 0 && ( +
+
+
+ )} +
+ + + + ) +} + +/* ─── Poster Card (grid item) ─── */ +function PosterCard({ media, className = '' }: { media: Media; className?: string }) { + return ( + +
+ {media.poster_url ? ( + {media.title} + ) : ( +
+ +
+ )} + {/* Overlay on hover */} +
+ + + 播放 + +
+
+
+

+ {media.title} +

+ {media.year > 0 && ( +

{media.year}

+ )} +
+ + ) +} + +/* ─── Quick Link Tile ─── */ +function QuickLink({ to, label, desc }: { to: string; label: string; desc: string }) { + return ( + +
+

{label}

+

{desc}

+
+ + ) } diff --git a/web/src/pages/LoginPage.tsx b/web/src/pages/LoginPage.tsx index e9bedd9..a670de4 100644 --- a/web/src/pages/LoginPage.tsx +++ b/web/src/pages/LoginPage.tsx @@ -3,6 +3,7 @@ import { useNavigate } from 'react-router-dom' import toast from 'react-hot-toast' import { Film } from 'lucide-react' +import { AppFooter } from '../components/AppFooter' import { authAPI } from '../api/auth' import { useAuthStore } from '../stores/auth' @@ -79,6 +80,8 @@ export function LoginPage() { 首次部署默认账号:admin / admin123

+ +
) } diff --git a/web/src/pages/NotifyChannelsPage.tsx b/web/src/pages/NotifyChannelsPage.tsx index 034af8a..3a5b62a 100644 --- a/web/src/pages/NotifyChannelsPage.tsx +++ b/web/src/pages/NotifyChannelsPage.tsx @@ -125,6 +125,7 @@ const TYPE_LABELS: Record = { wechat: '企业微信', bark: 'Bark', webhook: 'Webhook', + email: 'Email', } function ChannelCard({ @@ -188,6 +189,8 @@ function channelSummary(ch: NotifyChannel): string { return `Device ${String(cfg.device_key ?? '').slice(0, 10)}…` case 'webhook': return `${cfg.method ?? 'POST'} ${cfg.url ?? ''}` + case 'email': + return `SMTP ${cfg.smtp_host ?? '-'}:${cfg.smtp_port ?? '-'} → ${cfg.to ?? '-'}` default: return '' } @@ -200,6 +203,7 @@ const EMPTY_CONFIG: Record wechat: { sendkey: '' }, bark: { device_key: '', server: '' }, webhook: { url: '', method: 'POST', headers: '', body_template: '' }, + email: { smtp_host: '', smtp_port: '465', username: '', password: '', from: '', to: '', tls: 'true' }, } function ChannelFormModal({ @@ -282,6 +286,7 @@ function ChannelFormModal({ + @@ -383,6 +388,78 @@ function ChannelFormModal({ )} + {type === 'email' && ( + <> + + updateConfig('smtp_host', e.target.value)} + /> + +
+ + updateConfig('smtp_port', e.target.value)} + /> + + + + +
+
+ + updateConfig('username', e.target.value)} + /> + + + updateConfig('password', e.target.value)} + /> + +
+ + updateConfig('from', e.target.value)} + /> + + + updateConfig('to', e.target.value)} + /> + + + )} +
) } diff --git a/web/src/types/index.ts b/web/src/types/index.ts index 7939cc6..dec063e 100644 --- a/web/src/types/index.ts +++ b/web/src/types/index.ts @@ -258,7 +258,7 @@ export interface NotifyChannel { id: string name: string type: 'telegram' | 'wechat' | 'bark' | 'webhook' | 'email' - channel_type: 'telegram' | 'wechat' | 'bark' | 'webhook' + channel_type: 'telegram' | 'wechat' | 'bark' | 'webhook' | 'email' enabled: boolean events: string config: Record diff --git a/web/tailwind.config.js b/web/tailwind.config.js index 840d49b..65da19f 100644 --- a/web/tailwind.config.js +++ b/web/tailwind.config.js @@ -4,24 +4,107 @@ export default { theme: { extend: { fontFamily: { - display: ['Orbitron', 'sans-serif'], - body: ['Inter', 'PingFang SC', 'Microsoft YaHei', 'sans-serif'], + display: ['"Cabinet Grotesk"', 'system-ui', 'sans-serif'], + body: ['Geist', '"PingFang SC"', '"Microsoft YaHei"', 'sans-serif'], + mono: ['"JetBrains Mono"', '"Fira Code"', 'monospace'], }, colors: { - primary: { - 400: '#00F0FF', - 500: '#00D4E0', - 600: '#00A8B8', + // ── New Warm Industrial palette ── + brand: { + DEFAULT: '#c9954a', + 50: '#fdf8f0', + 100: '#f9edda', + 200: '#f2d8b0', + 300: '#e8bc7d', + 400: '#dca45c', + 500: '#c9954a', + 600: '#b07a38', + 700: '#936031', + 800: '#794e2d', + 900: '#654128', + 950: '#3a2214', }, - accent: { - 400: '#A855F7', - 500: '#8A2BE2', + sage: { + DEFAULT: '#7a9a8a', + 50: '#f3f7f4', + 100: '#e4ece6', + 200: '#c9d9ce', + 300: '#a6bfad', + 400: '#7a9a8a', + 500: '#5e7e6e', + 600: '#496558', + 700: '#3c5247', + 800: '#334339', + 900: '#2c3831', + 950: '#151f19', }, surface: { - 700: '#1a2332', - 800: '#121a27', - 900: '#0b1120', - 950: '#060a13', + DEFAULT: '#1a1a18', + 50: '#2e2e2a', + 100: '#2a2a26', + 200: '#242420', + 300: '#1e1e1b', + 400: '#1a1a18', + 500: '#161614', + 600: '#111110', + // Backward-compat (old 700-950 → warm charcoal) + 700: '#242420', + 800: '#1a1a18', + 900: '#111110', + 950: '#0d0d0c', + }, + cream: { + DEFAULT: '#e8e4d9', + 50: '#faf8f4', + 100: '#f3f0e8', + 200: '#e8e4d9', + 300: '#c4bfb5', + 400: '#a09b92', + 500: '#7d7871', + 600: '#605c56', + 700: '#4a4742', + 800: '#363330', + 900: '#242220', + }, + + // ── Backward-compat aliases (old class names → new warm colors) ── + primary: { + 400: '#dca45c', // was cyan → now brand-400 + 500: '#c9954a', // was cyan → now brand-500 + 600: '#b07a38', // was cyan → now brand-600 + }, + accent: { + 400: '#7a9a8a', // was purple → now sage-400 + 500: '#5e7e6e', // was purple → now sage-500 + }, + }, + fontSize: { + '2xs': ['0.625rem', { lineHeight: '0.875rem' }], + }, + spacing: { + '18': '4.5rem', + '88': '22rem', + }, + transitionDuration: { + '400': '400ms', + }, + animation: { + 'slide-in-left': 'slideInLeft 0.3s ease-out', + 'fade-in': 'fadeIn 0.4s ease-out', + 'scale-in': 'scaleIn 0.3s ease-out', + }, + keyframes: { + slideInLeft: { + '0%': { opacity: '0', transform: 'translateX(-8px)' }, + '100%': { opacity: '1', transform: 'translateX(0)' }, + }, + fadeIn: { + '0%': { opacity: '0' }, + '100%': { opacity: '1' }, + }, + scaleIn: { + '0%': { opacity: '0', transform: 'scale(0.96)' }, + '100%': { opacity: '1', transform: 'scale(1)' }, }, }, },