mirror of
https://github.com/truewhile/MeBox.git
synced 2026-10-01 20:16:36 +08:00
@
feat: Warm Industrial UI redesign + social footer + code deduplication Design System (Phase A): - New palette: warm charcoal #111110 / amber gold #c9954a / sage green #7a9a8a - Fonts: Cabinet Grotesk (heading) + Geist (body) + JetBrains Mono (data) - SVG grain texture overlay, collapsed sidebar with hover expand - Bento grid dashboard with WideContinueCard + PosterCard + QuickLink - Backward-compatible CSS aliases for glass-panel/neon-button/input-base Social Footer (Phase B): - AppFooter component with GitHub repo / author homepage / TG group links - Embedded in LoginPage, Layout (global), and SettingsPage - TG group badge added to README.md Code Cleanup: - Removed deprecated DownloadClientCard / NotifyChannelCard components - Merged downloadClient/notify API clients into unified modules - Route deduplication: removed authed-group duplicates for admin endpoints - Removed redundant AdminPage tabs (sites/downloads/notify/scheduler) - UI fixes: blank pages (discover/download-clients/notify-channels) nil slice defense - Email channel support in NotifyChannelsPage @
This commit is contained in:
@@ -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 (
|
||||
<div className="space-y-6">
|
||||
@@ -45,14 +35,10 @@ export function AdminPage() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
{tab === 'sites' && <SitesPage />}
|
||||
{tab === 'library' && <LibraryPanel />}
|
||||
{tab === 'users' && <UsersPanel />}
|
||||
{tab === 'api' && <APIConfigsPanel />}
|
||||
{tab === 'settings' && <SettingsPanel />}
|
||||
{tab === 'downloads' && <DownloadClientCard />}
|
||||
{tab === 'notify' && <NotifyChannelCard />}
|
||||
{tab === 'scheduler' && <SchedulerPanel />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -267,64 +253,3 @@ function SettingsPanel() {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SchedulerPanel() {
|
||||
const [tasks, setTasks] = useState<JobStatus[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const refresh = () => {
|
||||
setLoading(true)
|
||||
schedulerAPI.status().then(setTasks).finally(() => setLoading(false))
|
||||
}
|
||||
useEffect(() => {
|
||||
refresh()
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="glass-panel">
|
||||
{loading && <p className="py-4 text-center text-slate-400">加载中...</p>}
|
||||
{!loading && tasks.length === 0 && (
|
||||
<p className="py-4 text-center text-slate-400">暂无定时任务</p>
|
||||
)}
|
||||
{!loading && tasks.length > 0 && (
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead className="text-xs uppercase tracking-wider text-slate-500">
|
||||
<tr>
|
||||
<th className="py-2">名称</th>
|
||||
<th>间隔</th>
|
||||
<th>上次运行</th>
|
||||
<th className="text-right">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{tasks.map((t) => (
|
||||
<tr key={t.name} className="border-t border-white/5">
|
||||
<td className="py-2 text-white">{t.name}</td>
|
||||
<td className="text-slate-300">{t.interval}</td>
|
||||
<td className="text-slate-400">
|
||||
{t.last_run ? new Date(t.last_run).toLocaleString() : '-'}
|
||||
</td>
|
||||
<td className="py-2 text-right">
|
||||
<button
|
||||
className="rounded border border-amber-400/40 px-2 py-1 text-xs text-amber-400 hover:bg-amber-400/10"
|
||||
onClick={async () => {
|
||||
try {
|
||||
await schedulerAPI.run(t.name)
|
||||
toast.success('任务已触发')
|
||||
refresh()
|
||||
} catch {
|
||||
toast.error('触发失败')
|
||||
}
|
||||
}}
|
||||
>
|
||||
手动运行
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
+211
-43
@@ -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<Media[]>([])
|
||||
const [history, setHistory] = useState<HistoryItem[]>([])
|
||||
@@ -30,50 +27,221 @@ export function HomePage() {
|
||||
const empty = !loading && recent.length === 0 && history.length === 0
|
||||
|
||||
return (
|
||||
<div className="space-y-10">
|
||||
<header>
|
||||
<h1 className="font-display text-3xl font-bold text-white">主页</h1>
|
||||
<p className="text-sm text-slate-400">从你上次离开的地方继续</p>
|
||||
</header>
|
||||
|
||||
{history.length > 0 && (
|
||||
<Row title="继续观看">
|
||||
{history.map((h) => {
|
||||
const m = h.media!
|
||||
const progress = h.duration_ms > 0 ? h.position_ms / h.duration_ms : 0
|
||||
return <MediaCard key={h.id} media={m} progress={progress} />
|
||||
})}
|
||||
</Row>
|
||||
)}
|
||||
|
||||
{recent.length > 0 && (
|
||||
<Row title="最近添加">
|
||||
{recent.map((m) => (
|
||||
<MediaCard key={m.id} media={m} />
|
||||
))}
|
||||
</Row>
|
||||
)}
|
||||
|
||||
{loading && <p className="text-slate-500">加载中…</p>}
|
||||
{empty && (
|
||||
<div className="glass-panel">
|
||||
<p className="text-slate-300">
|
||||
还没有任何媒体。前往 <span className="text-primary-400">管理后台</span>{' '}
|
||||
创建媒体库,然后触发一次扫描。
|
||||
<div className="animate-fade-in space-y-12">
|
||||
{/* ── Header ── */}
|
||||
<header className="flex items-end justify-between">
|
||||
<div>
|
||||
<h1 className="font-display text-3xl font-bold tracking-tight text-cream-100">
|
||||
媒体中心
|
||||
</h1>
|
||||
<p className="mt-1.5 text-sm text-cream-400">
|
||||
从你上次离开的地方继续
|
||||
</p>
|
||||
</div>
|
||||
{!loading && (
|
||||
<Link
|
||||
to="/discover"
|
||||
className="flex items-center gap-1.5 text-sm text-cream-400 transition-colors hover:text-brand-400"
|
||||
>
|
||||
<Compass size={14} />
|
||||
发现更多
|
||||
<ArrowRight size={14} />
|
||||
</Link>
|
||||
)}
|
||||
</header>
|
||||
|
||||
{/* ── Loading ── */}
|
||||
{loading && (
|
||||
<div className="flex items-center gap-3 py-16">
|
||||
<div className="h-1.5 w-1.5 animate-pulse rounded-full bg-brand-500/60" />
|
||||
<p className="text-sm text-cream-500">加载媒体库…</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Empty state ── */}
|
||||
{empty && (
|
||||
<div className="surface-card flex flex-col items-center gap-4 py-16 text-center">
|
||||
<Film className="h-12 w-12 text-cream-900/40" />
|
||||
<div>
|
||||
<p className="font-medium text-cream-300">还没有任何媒体</p>
|
||||
<p className="mt-1 text-sm text-cream-500">
|
||||
前往{' '}
|
||||
<Link to="/admin" className="text-brand-400 underline underline-offset-2">
|
||||
管理后台
|
||||
</Link>{' '}
|
||||
创建媒体库,然后触发一次扫描
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Continue Watching — wide horizontal stripe ── */}
|
||||
{history.length > 0 && (
|
||||
<section>
|
||||
<SectionHeading icon={<Play size={16} />} label="继续观看" />
|
||||
<div className="flex gap-4 overflow-x-auto pb-2">
|
||||
{history.slice(0, 6).map((h) => {
|
||||
const m = h.media!
|
||||
const progress = h.duration_ms > 0 ? h.position_ms / h.duration_ms : 0
|
||||
return <WideContinueCard key={h.id} media={m} progress={progress} />
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* ── Recently Added — bento grid ── */}
|
||||
{recent.length > 0 && (
|
||||
<section>
|
||||
<SectionHeading icon={<Clock size={16} />} label="最近添加" />
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6">
|
||||
{recent.slice(0, 12).map((m, i) => {
|
||||
// First 2 items get double-column span on large screens
|
||||
const isHero = i < 2
|
||||
return (
|
||||
<PosterCard
|
||||
key={m.id}
|
||||
media={m}
|
||||
className={isHero ? 'lg:col-span-2 lg:row-span-2' : ''}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* ── Quick Links ── */}
|
||||
{!empty && (
|
||||
<section>
|
||||
<SectionHeading icon={<Sparkles size={16} />} label="快捷入口" />
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
<QuickLink to="/discover" label="发现" desc="探索新内容" />
|
||||
<QuickLink to="/favourites" label="收藏" desc="我的珍藏" />
|
||||
<QuickLink to="/history" label="历史" desc="观看记录" />
|
||||
<QuickLink to="/ai" label="AI" desc="智能推荐" />
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Row({ title, children }: { title: string; children: React.ReactNode }) {
|
||||
/* ─── Section Heading ─── */
|
||||
function SectionHeading({ icon, label }: { icon: React.ReactNode; label: string }) {
|
||||
return (
|
||||
<section className="space-y-3">
|
||||
<h2 className="font-display text-xl font-semibold text-white">{title}</h2>
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6">
|
||||
{children}
|
||||
</div>
|
||||
</section>
|
||||
<div className="mb-4 flex items-center gap-2">
|
||||
<span className="text-cream-400">{icon}</span>
|
||||
<h2 className="font-display text-lg font-semibold tracking-tight text-cream-200">
|
||||
{label}
|
||||
</h2>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ─── Wide Continue-Watching Card ─── */
|
||||
function WideContinueCard({ media, progress }: { media: Media; progress: number }) {
|
||||
return (
|
||||
<Link
|
||||
to={`/media/${media.id}`}
|
||||
className="group flex shrink-0 w-72 items-center gap-4 rounded-lg border border-cream-900/15 bg-surface-400 p-3 transition-all hover:border-brand-500/30 hover:bg-surface-300"
|
||||
>
|
||||
{/* Poster thumbnail */}
|
||||
<div className="relative h-20 w-14 shrink-0 overflow-hidden rounded-md bg-surface-600">
|
||||
{media.poster_url ? (
|
||||
<img
|
||||
src={imageURL(media.poster_url)}
|
||||
alt={media.title}
|
||||
loading="lazy"
|
||||
className="h-full w-full object-cover"
|
||||
referrerPolicy="no-referrer"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center text-cream-900/30">
|
||||
<Film size={20} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium text-cream-200 group-hover:text-cream-100">
|
||||
{media.title}
|
||||
</p>
|
||||
{media.year > 0 && (
|
||||
<p className="mt-0.5 text-xs text-cream-500">{media.year}</p>
|
||||
)}
|
||||
|
||||
{/* Progress bar */}
|
||||
{progress > 0 && (
|
||||
<div className="mt-2 h-1 w-full overflow-hidden rounded-full bg-cream-900/20">
|
||||
<div
|
||||
className="h-full rounded-full bg-brand-500/70 transition-all group-hover:bg-brand-400"
|
||||
style={{ width: `${Math.round(progress * 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Play
|
||||
size={14}
|
||||
className="shrink-0 text-cream-600 opacity-0 transition-all group-hover:opacity-100 group-hover:text-brand-400"
|
||||
/>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
/* ─── Poster Card (grid item) ─── */
|
||||
function PosterCard({ media, className = '' }: { media: Media; className?: string }) {
|
||||
return (
|
||||
<Link
|
||||
to={`/media/${media.id}`}
|
||||
className={`group block overflow-hidden rounded-lg border border-cream-900/15 bg-surface-400 transition-all hover:border-brand-500/30 hover:bg-surface-300 ${className}`}
|
||||
>
|
||||
<div className="relative aspect-[2/3] w-full overflow-hidden bg-surface-600">
|
||||
{media.poster_url ? (
|
||||
<img
|
||||
src={imageURL(media.poster_url)}
|
||||
alt={media.title}
|
||||
loading="lazy"
|
||||
className="h-full w-full object-cover transition-transform duration-500 group-hover:scale-105"
|
||||
referrerPolicy="no-referrer"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center text-cream-900/30">
|
||||
<Film size={48} />
|
||||
</div>
|
||||
)}
|
||||
{/* Overlay on hover */}
|
||||
<div className="absolute inset-0 flex items-end bg-gradient-to-t from-black/60 via-transparent to-transparent p-3 opacity-0 transition-opacity group-hover:opacity-100">
|
||||
<span className="flex items-center gap-1.5 text-xs text-white/90">
|
||||
<Play size={12} />
|
||||
播放
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-3 py-2.5">
|
||||
<p className="truncate text-sm font-medium text-cream-200 group-hover:text-cream-100">
|
||||
{media.title}
|
||||
</p>
|
||||
{media.year > 0 && (
|
||||
<p className="mt-0.5 text-xs text-cream-500">{media.year}</p>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
/* ─── Quick Link Tile ─── */
|
||||
function QuickLink({ to, label, desc }: { to: string; label: string; desc: string }) {
|
||||
return (
|
||||
<Link
|
||||
to={to}
|
||||
className="group flex items-center gap-3 rounded-lg border border-cream-900/15 bg-surface-400 px-4 py-3.5 transition-all hover:border-brand-500/20 hover:bg-surface-300"
|
||||
>
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium text-cream-300 group-hover:text-cream-100">{label}</p>
|
||||
<p className="mt-0.5 text-xs text-cream-500">{desc}</p>
|
||||
</div>
|
||||
<ArrowRight size={14} className="text-cream-600 transition-colors group-hover:text-brand-400" />
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
首次部署默认账号:<code className="text-primary-400">admin / admin123</code>
|
||||
</p>
|
||||
</form>
|
||||
|
||||
<AppFooter className="mt-8" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -125,6 +125,7 @@ const TYPE_LABELS: Record<NotifyChannel['channel_type'], string> = {
|
||||
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<NotifyChannel['channel_type'], Record<string, string>
|
||||
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({
|
||||
<option value="wechat">企业微信 / Server酱</option>
|
||||
<option value="bark">Bark (iOS)</option>
|
||||
<option value="webhook">Webhook</option>
|
||||
<option value="email">Email (SMTP)</option>
|
||||
</select>
|
||||
</Field>
|
||||
|
||||
@@ -383,6 +388,78 @@ function ChannelFormModal({
|
||||
</>
|
||||
)}
|
||||
|
||||
{type === 'email' && (
|
||||
<>
|
||||
<Field label="SMTP 地址">
|
||||
<input
|
||||
required
|
||||
className="input-base"
|
||||
placeholder="smtp.gmail.com"
|
||||
value={config.smtp_host ?? ''}
|
||||
onChange={(e) => updateConfig('smtp_host', e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field label="SMTP 端口">
|
||||
<input
|
||||
required
|
||||
className="input-base"
|
||||
placeholder="465"
|
||||
value={config.smtp_port ?? ''}
|
||||
onChange={(e) => updateConfig('smtp_port', e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="TLS">
|
||||
<select
|
||||
className="input-base"
|
||||
value={config.tls ?? 'true'}
|
||||
onChange={(e) => updateConfig('tls', e.target.value)}
|
||||
>
|
||||
<option value="true">启用</option>
|
||||
<option value="false">关闭</option>
|
||||
</select>
|
||||
</Field>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field label="用户名">
|
||||
<input
|
||||
required
|
||||
className="input-base"
|
||||
value={config.username ?? ''}
|
||||
onChange={(e) => updateConfig('username', e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="密码">
|
||||
<input
|
||||
type="password"
|
||||
required
|
||||
className="input-base"
|
||||
value={config.password ?? ''}
|
||||
onChange={(e) => updateConfig('password', e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
<Field label="发件人">
|
||||
<input
|
||||
required
|
||||
className="input-base"
|
||||
placeholder="noreply@example.com"
|
||||
value={config.from ?? ''}
|
||||
onChange={(e) => updateConfig('from', e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="收件人 (多个用逗号分隔)">
|
||||
<input
|
||||
required
|
||||
className="input-base"
|
||||
placeholder="user@example.com"
|
||||
value={config.to ?? ''}
|
||||
onChange={(e) => updateConfig('to', e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
</>
|
||||
)}
|
||||
|
||||
<label className="flex cursor-pointer items-center gap-2 text-sm text-slate-300">
|
||||
<input
|
||||
type="checkbox"
|
||||
|
||||
@@ -2,6 +2,7 @@ import { FormEvent, useEffect, useState } from 'react'
|
||||
import { Loader2, Save, SettingsIcon } from 'lucide-react'
|
||||
import toast from 'react-hot-toast'
|
||||
|
||||
import { AppFooter } from '../components/AppFooter'
|
||||
import { adminAPI } from '../api/admin'
|
||||
import type { Setting } from '../types'
|
||||
|
||||
@@ -288,6 +289,8 @@ export function SettingsPage() {
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
<AppFooter className="mt-6" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user