diff --git a/README.md b/README.md
index d5d6365..90957fa 100644
--- a/README.md
+++ b/README.md
@@ -10,6 +10,7 @@
+
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 && (
-
- )}
-
-
- {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 (
-
)
}
+/** 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 ? (
})
) : (
-
+
)}
+
+ {/* 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 && (
-
- )}
-
-
- {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 ? (
+
})
+ ) : (
+
+
+
+ )}
+
+
+ {/* 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 ? (
+
})
+ ) : (
+
+
+
+ )}
+ {/* 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 (
+
+
+
+
)
}
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)' },
},
},
},