mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-30 11:46:36 +08:00
feat: bootstrap MediaStationGo (Go + React rewrite of MediaStation)
Adopt the cropflre/nowen-video tech stack and rebuild the project from
scratch:
- Backend: Go 1.25 + Gin + GORM + SQLite (WAL) + JWT + WebSocket hub.
Layered packages config / database / model / repository / service /
middleware / handler. Default admin (admin/admin123) seeded on first
run; /api routes for auth, libraries, media, stream and admin
panels. WebSocket scan-progress events at /api/ws.
- Frontend: React 18 + Vite 5 + Tailwind 3.4 + Zustand + axios +
react-router 6 + lucide-react + framer-motion + hls.js. Pages for
Login / Home / Library / Search / MediaDetail / Player / Admin
(Library, Users, Settings tabs).
- Distribution: multi-arch Dockerfile (frontend -> backend -> Alpine
runtime), docker-compose.yml, GitHub Actions for CI and GHCR
publish, Makefile, env-prefixed config (MEDIASTATION_*).
- Docs: README, CONTRIBUTING, .env.example, config.example.yaml.
Backend builds, vets and tests pass. Frontend builds via tsc -b && vite
build (250 kB JS / 16 kB CSS, gzipped 84 / 4 kB).
This commit is contained in:
@@ -0,0 +1,56 @@
|
||||
import { Navigate, Route, Routes } from 'react-router-dom'
|
||||
|
||||
import { Layout } from './components/Layout'
|
||||
import { RequireAuth, RequireAdmin } from './components/RequireAuth'
|
||||
import { AdminPage } from './pages/AdminPage'
|
||||
import { HomePage } from './pages/HomePage'
|
||||
import { LibraryPage } from './pages/LibraryPage'
|
||||
import { LoginPage } from './pages/LoginPage'
|
||||
import { MediaDetailPage } from './pages/MediaDetailPage'
|
||||
import { PlayerPage } from './pages/PlayerPage'
|
||||
import { SearchPage } from './pages/SearchPage'
|
||||
|
||||
// Top-level route table.
|
||||
//
|
||||
// Public:
|
||||
// /login
|
||||
//
|
||||
// Authenticated:
|
||||
// / → home (continue watching + recently added)
|
||||
// /library/:id → library content grid
|
||||
// /search → keyword search
|
||||
// /media/:id → media detail
|
||||
// /play/:id → fullscreen player
|
||||
//
|
||||
// Admin only:
|
||||
// /admin → users / settings / activity log
|
||||
export default function App() {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route
|
||||
path="/"
|
||||
element={
|
||||
<RequireAuth>
|
||||
<Layout />
|
||||
</RequireAuth>
|
||||
}
|
||||
>
|
||||
<Route index element={<HomePage />} />
|
||||
<Route path="library/:id" element={<LibraryPage />} />
|
||||
<Route path="search" element={<SearchPage />} />
|
||||
<Route path="media/:id" element={<MediaDetailPage />} />
|
||||
<Route path="play/:id" element={<PlayerPage />} />
|
||||
<Route
|
||||
path="admin"
|
||||
element={
|
||||
<RequireAdmin>
|
||||
<AdminPage />
|
||||
</RequireAdmin>
|
||||
}
|
||||
/>
|
||||
</Route>
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { api } from './client'
|
||||
import type { AccessLog, Setting, User } from '../types'
|
||||
|
||||
export const adminAPI = {
|
||||
listUsers: () => api.get<User[]>('/admin/users').then((r) => r.data),
|
||||
|
||||
deleteUser: (id: string) => api.delete(`/admin/users/${id}`).then((r) => r.data),
|
||||
|
||||
listSettings: () => api.get<Setting[]>('/admin/settings').then((r) => r.data),
|
||||
|
||||
updateSetting: (key: string, value: string) =>
|
||||
api.put('/admin/settings', { key, value }).then((r) => r.data),
|
||||
|
||||
recentLogs: () => api.get<AccessLog[]>('/admin/logs').then((r) => r.data),
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { api } from './client'
|
||||
import type { User } from '../types'
|
||||
|
||||
export interface LoginResponse {
|
||||
token: string
|
||||
user: User
|
||||
}
|
||||
|
||||
export const authAPI = {
|
||||
login: (username: string, password: string) =>
|
||||
api.post<LoginResponse>('/auth/login', { username, password }).then((r) => r.data),
|
||||
|
||||
register: (username: string, password: string) =>
|
||||
api.post<User>('/auth/register', { username, password }).then((r) => r.data),
|
||||
|
||||
me: () => api.get<User>('/me').then((r) => r.data),
|
||||
|
||||
changePassword: (oldPassword: string, newPassword: string) =>
|
||||
api
|
||||
.post('/me/password', { old_password: oldPassword, new_password: newPassword })
|
||||
.then((r) => r.data),
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import axios, { AxiosError } from 'axios'
|
||||
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
|
||||
// Single axios instance used by every API helper. Adds the JWT to outgoing
|
||||
// requests and routes 401s back to the login page.
|
||||
export const api = axios.create({
|
||||
baseURL: '/api',
|
||||
timeout: 30000,
|
||||
})
|
||||
|
||||
api.interceptors.request.use((config) => {
|
||||
const token = useAuthStore.getState().token
|
||||
if (token) {
|
||||
config.headers = config.headers ?? {}
|
||||
config.headers.Authorization = `Bearer ${token}`
|
||||
}
|
||||
return config
|
||||
})
|
||||
|
||||
api.interceptors.response.use(
|
||||
(resp) => resp,
|
||||
(err: AxiosError) => {
|
||||
if (err.response?.status === 401) {
|
||||
useAuthStore.getState().logout()
|
||||
if (typeof window !== 'undefined' && window.location.pathname !== '/login') {
|
||||
window.location.href = '/login'
|
||||
}
|
||||
}
|
||||
return Promise.reject(err)
|
||||
},
|
||||
)
|
||||
|
||||
// Helper that returns a streaming URL with the JWT appended as a query
|
||||
// parameter so <video src> works without an Authorization header.
|
||||
export function streamURL(mediaId: string): string {
|
||||
const token = useAuthStore.getState().token ?? ''
|
||||
return `/api/stream/${encodeURIComponent(mediaId)}?token=${encodeURIComponent(token)}`
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { api } from './client'
|
||||
import type { Library, Media, ScanResult } from '../types'
|
||||
|
||||
export interface MediaPage {
|
||||
items: Media[]
|
||||
total: number
|
||||
page: number
|
||||
page_size: number
|
||||
}
|
||||
|
||||
export const libraryAPI = {
|
||||
list: () => api.get<Library[]>('/libraries').then((r) => r.data),
|
||||
|
||||
create: (name: string, path: string, type: string) =>
|
||||
api.post<Library>('/libraries', { name, path, type }).then((r) => r.data),
|
||||
|
||||
remove: (id: string) => api.delete(`/libraries/${id}`).then((r) => r.data),
|
||||
|
||||
scan: (id: string) =>
|
||||
api.post<ScanResult>(`/libraries/${id}/scan`).then((r) => r.data),
|
||||
|
||||
listMedia: (id: string, page = 1, pageSize = 50) =>
|
||||
api
|
||||
.get<MediaPage>(`/libraries/${id}/media`, {
|
||||
params: { page, page_size: pageSize },
|
||||
})
|
||||
.then((r) => r.data),
|
||||
}
|
||||
|
||||
export const mediaAPI = {
|
||||
search: (q: string, limit = 50) =>
|
||||
api.get<{ items: Media[] }>('/media', { params: { q, limit } }).then((r) => r.data),
|
||||
|
||||
get: (id: string) => api.get<Media>(`/media/${id}`).then((r) => r.data),
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Link, NavLink, Outlet, useNavigate } from 'react-router-dom'
|
||||
import {
|
||||
Film,
|
||||
Home,
|
||||
LogOut,
|
||||
Search,
|
||||
Settings,
|
||||
Library as LibraryIcon,
|
||||
} from 'lucide-react'
|
||||
import clsx from 'clsx'
|
||||
|
||||
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)
|
||||
const logout = useAuthStore((s) => s.logout)
|
||||
const [libraries, setLibraries] = useState<Library[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
libraryAPI.list().then(setLibraries).catch(() => undefined)
|
||||
}, [])
|
||||
|
||||
const handleLogout = () => {
|
||||
logout()
|
||||
navigate('/login')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-full">
|
||||
<aside className="hidden w-64 shrink-0 flex-col border-r border-white/5 bg-surface-900/80 px-4 py-6 backdrop-blur md:flex">
|
||||
<Link to="/" className="mb-8 flex items-center gap-2 px-2">
|
||||
<Film className="h-6 w-6 text-primary-400" />
|
||||
<span className="font-display text-xl font-bold tracking-wide text-white">
|
||||
MediaStationGo
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
<nav className="flex-1 space-y-1">
|
||||
<SidebarLink to="/" icon={<Home size={18} />} label="首页" end />
|
||||
<SidebarLink to="/search" icon={<Search size={18} />} label="搜索" />
|
||||
|
||||
<div className="mt-6 px-2 text-xs uppercase tracking-wider text-slate-500">
|
||||
媒体库
|
||||
</div>
|
||||
{libraries.length === 0 && (
|
||||
<div className="px-2 py-1 text-sm text-slate-500">暂无媒体库</div>
|
||||
)}
|
||||
{libraries.map((lib) => (
|
||||
<SidebarLink
|
||||
key={lib.id}
|
||||
to={`/library/${lib.id}`}
|
||||
icon={<LibraryIcon size={18} />}
|
||||
label={lib.name}
|
||||
/>
|
||||
))}
|
||||
|
||||
{user?.role === 'admin' && (
|
||||
<>
|
||||
<div className="mt-6 px-2 text-xs uppercase tracking-wider text-slate-500">
|
||||
管理
|
||||
</div>
|
||||
<SidebarLink to="/admin" icon={<Settings size={18} />} label="管理后台" />
|
||||
</>
|
||||
)}
|
||||
</nav>
|
||||
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="mt-4 flex items-center gap-2 rounded-lg px-3 py-2 text-sm text-slate-400 transition hover:bg-white/5 hover:text-white"
|
||||
>
|
||||
<LogOut size={16} />
|
||||
退出登录 ({user?.username})
|
||||
</button>
|
||||
</aside>
|
||||
|
||||
<main className="flex-1 overflow-y-auto px-6 py-8">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarLink({
|
||||
to,
|
||||
icon,
|
||||
label,
|
||||
end,
|
||||
}: {
|
||||
to: string
|
||||
icon: React.ReactNode
|
||||
label: string
|
||||
end?: boolean
|
||||
}) {
|
||||
return (
|
||||
<NavLink
|
||||
to={to}
|
||||
end={end}
|
||||
className={({ isActive }) =>
|
||||
clsx(
|
||||
'flex items-center gap-3 rounded-lg px-3 py-2 text-sm transition',
|
||||
isActive
|
||||
? 'bg-primary-400/10 text-primary-400'
|
||||
: 'text-slate-300 hover:bg-white/5 hover:text-white',
|
||||
)
|
||||
}
|
||||
>
|
||||
{icon}
|
||||
<span className="truncate">{label}</span>
|
||||
</NavLink>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
import { Film } from 'lucide-react'
|
||||
|
||||
import type { Media } from '../types'
|
||||
|
||||
// Compact poster tile used by every listing surface (home / library / search).
|
||||
export function MediaCard({ media }: { media: Media }) {
|
||||
return (
|
||||
<Link
|
||||
to={`/media/${media.id}`}
|
||||
className="group block overflow-hidden rounded-xl border border-white/5 bg-surface-800/60 transition hover:border-primary-400/50 hover:shadow-lg hover:shadow-primary-400/20"
|
||||
>
|
||||
<div className="aspect-[2/3] w-full bg-surface-900">
|
||||
{media.poster_url ? (
|
||||
<img
|
||||
src={media.poster_url}
|
||||
alt={media.title}
|
||||
loading="lazy"
|
||||
className="h-full w-full object-cover transition group-hover:scale-105"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center text-slate-600">
|
||||
<Film size={48} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="px-3 py-2">
|
||||
<p className="truncate text-sm font-medium text-white">{media.title}</p>
|
||||
{media.year > 0 && <p className="text-xs text-slate-400">{media.year}</p>}
|
||||
</div>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { ReactNode } from 'react'
|
||||
import { Navigate, useLocation } from 'react-router-dom'
|
||||
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
|
||||
// Route guard: redirects to /login when no token is present.
|
||||
export function RequireAuth({ children }: { children: ReactNode }) {
|
||||
const token = useAuthStore((s) => s.token)
|
||||
const location = useLocation()
|
||||
if (!token) {
|
||||
return <Navigate to="/login" replace state={{ from: location }} />
|
||||
}
|
||||
return <>{children}</>
|
||||
}
|
||||
|
||||
// Route guard: only allows users with role === "admin".
|
||||
export function RequireAdmin({ children }: { children: ReactNode }) {
|
||||
const user = useAuthStore((s) => s.user)
|
||||
if (user?.role !== 'admin') {
|
||||
return <Navigate to="/" replace />
|
||||
}
|
||||
return <>{children}</>
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
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;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.glass-panel {
|
||||
@apply rounded-2xl border border-white/10 bg-surface-800/60 p-6 shadow-lg backdrop-blur-md;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
import { Toaster } from 'react-hot-toast'
|
||||
|
||||
import App from './App'
|
||||
import './index.css'
|
||||
|
||||
// Application root: BrowserRouter + global toast container.
|
||||
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
|
||||
<React.StrictMode>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
<Toaster
|
||||
position="top-right"
|
||||
toastOptions={{
|
||||
className: '!bg-surface-800 !text-white !border !border-white/10',
|
||||
}}
|
||||
/>
|
||||
</BrowserRouter>
|
||||
</React.StrictMode>,
|
||||
)
|
||||
@@ -0,0 +1,253 @@
|
||||
import { FormEvent, useEffect, useState } from 'react'
|
||||
import toast from 'react-hot-toast'
|
||||
import { Trash2 } from 'lucide-react'
|
||||
|
||||
import { adminAPI } from '../api/admin'
|
||||
import { libraryAPI } from '../api/library'
|
||||
import type { Library, User } from '../types'
|
||||
|
||||
// Single-page admin console. We split the surface into three panels:
|
||||
// • Libraries — create / delete / scan
|
||||
// • Users — list / delete (cannot delete self)
|
||||
// • Settings — generic key/value store viewer
|
||||
//
|
||||
// Each panel manages its own loading / refresh state.
|
||||
export function AdminPage() {
|
||||
const [tab, setTab] = useState<'library' | 'users' | 'settings'>('library')
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h1 className="font-display text-3xl font-bold text-white">管理后台</h1>
|
||||
<div className="flex gap-2 border-b border-white/10">
|
||||
{(['library', 'users', 'settings'] as const).map((k) => (
|
||||
<button
|
||||
key={k}
|
||||
onClick={() => setTab(k)}
|
||||
className={
|
||||
'border-b-2 px-4 py-2 text-sm transition ' +
|
||||
(tab === k
|
||||
? 'border-primary-400 text-primary-400'
|
||||
: 'border-transparent text-slate-400 hover:text-white')
|
||||
}
|
||||
>
|
||||
{k === 'library' ? '媒体库' : k === 'users' ? '用户' : '设置'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{tab === 'library' && <LibraryPanel />}
|
||||
{tab === 'users' && <UsersPanel />}
|
||||
{tab === 'settings' && <SettingsPanel />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function LibraryPanel() {
|
||||
const [libs, setLibs] = useState<Library[]>([])
|
||||
const [name, setName] = useState('')
|
||||
const [path, setPath] = useState('')
|
||||
const [type, setType] = useState('movie')
|
||||
|
||||
const refresh = () => libraryAPI.list().then(setLibs)
|
||||
useEffect(() => {
|
||||
refresh().catch(() => undefined)
|
||||
}, [])
|
||||
|
||||
const handleCreate = async (e: FormEvent) => {
|
||||
e.preventDefault()
|
||||
try {
|
||||
await libraryAPI.create(name, path, type)
|
||||
toast.success('媒体库已创建')
|
||||
setName('')
|
||||
setPath('')
|
||||
await refresh()
|
||||
} catch (err: unknown) {
|
||||
const msg =
|
||||
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ??
|
||||
'创建失败'
|
||||
toast.error(msg)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<form onSubmit={handleCreate} className="glass-panel grid gap-3 md:grid-cols-4">
|
||||
<input
|
||||
required
|
||||
className="input-base"
|
||||
placeholder="名称"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
<input
|
||||
required
|
||||
className="input-base md:col-span-2"
|
||||
placeholder="服务器路径,如 /media/movies"
|
||||
value={path}
|
||||
onChange={(e) => setPath(e.target.value)}
|
||||
/>
|
||||
<select className="input-base" value={type} onChange={(e) => setType(e.target.value)}>
|
||||
<option value="movie">电影</option>
|
||||
<option value="tv">电视剧</option>
|
||||
<option value="anime">动漫</option>
|
||||
<option value="music">音乐</option>
|
||||
</select>
|
||||
<button type="submit" className="neon-button md:col-span-4">
|
||||
新建媒体库
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="glass-panel">
|
||||
<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>
|
||||
{libs.map((l) => (
|
||||
<tr key={l.id} className="border-t border-white/5">
|
||||
<td className="py-2 text-white">{l.name}</td>
|
||||
<td className="text-slate-300">{l.path}</td>
|
||||
<td className="text-slate-300">{l.type}</td>
|
||||
<td className="space-x-2 py-2 text-right">
|
||||
<button
|
||||
className="rounded border border-primary-400/40 px-2 py-1 text-xs text-primary-400 hover:bg-primary-400/10"
|
||||
onClick={async () => {
|
||||
const r = await libraryAPI.scan(l.id)
|
||||
toast.success(`扫描完成,新增 ${r.added}`)
|
||||
}}
|
||||
>
|
||||
扫描
|
||||
</button>
|
||||
<button
|
||||
className="rounded border border-red-400/40 px-2 py-1 text-xs text-red-400 hover:bg-red-400/10"
|
||||
onClick={async () => {
|
||||
if (!confirm(`确定删除「${l.name}」?`)) return
|
||||
await libraryAPI.remove(l.id)
|
||||
toast.success('已删除')
|
||||
await refresh()
|
||||
}}
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function UsersPanel() {
|
||||
const [users, setUsers] = useState<User[]>([])
|
||||
const refresh = () => adminAPI.listUsers().then(setUsers)
|
||||
useEffect(() => {
|
||||
refresh().catch(() => undefined)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="glass-panel">
|
||||
<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>
|
||||
{users.map((u) => (
|
||||
<tr key={u.id} className="border-t border-white/5">
|
||||
<td className="py-2 text-white">{u.username}</td>
|
||||
<td className="text-slate-300">{u.role}</td>
|
||||
<td className="text-slate-400">
|
||||
{u.last_login_at ? new Date(u.last_login_at).toLocaleString() : '从未登录'}
|
||||
</td>
|
||||
<td className="py-2 text-right">
|
||||
<button
|
||||
className="rounded border border-red-400/40 px-2 py-1 text-xs text-red-400 hover:bg-red-400/10"
|
||||
onClick={async () => {
|
||||
if (!confirm(`确定删除「${u.username}」?`)) return
|
||||
await adminAPI.deleteUser(u.id)
|
||||
toast.success('已删除')
|
||||
await refresh()
|
||||
}}
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SettingsPanel() {
|
||||
const [items, setItems] = useState<{ key: string; value: string }[]>([])
|
||||
const [k, setK] = useState('')
|
||||
const [v, setV] = useState('')
|
||||
|
||||
const refresh = () => adminAPI.listSettings().then(setItems)
|
||||
useEffect(() => {
|
||||
refresh().catch(() => undefined)
|
||||
}, [])
|
||||
|
||||
const onSave = async (e: FormEvent) => {
|
||||
e.preventDefault()
|
||||
await adminAPI.updateSetting(k, v)
|
||||
toast.success('已保存')
|
||||
setK('')
|
||||
setV('')
|
||||
await refresh()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<form onSubmit={onSave} className="glass-panel grid gap-3 md:grid-cols-3">
|
||||
<input
|
||||
required
|
||||
className="input-base"
|
||||
placeholder="键 (如 tmdb_api_key)"
|
||||
value={k}
|
||||
onChange={(e) => setK(e.target.value)}
|
||||
/>
|
||||
<input
|
||||
required
|
||||
className="input-base"
|
||||
placeholder="值"
|
||||
value={v}
|
||||
onChange={(e) => setV(e.target.value)}
|
||||
/>
|
||||
<button type="submit" className="neon-button">
|
||||
保存
|
||||
</button>
|
||||
</form>
|
||||
<div className="glass-panel">
|
||||
<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>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((it) => (
|
||||
<tr key={it.key} className="border-t border-white/5">
|
||||
<td className="py-2 text-white">{it.key}</td>
|
||||
<td className="text-slate-300">{it.value}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
import { mediaAPI } from '../api/library'
|
||||
import { MediaCard } from '../components/MediaCard'
|
||||
import type { Media } from '../types'
|
||||
|
||||
// Landing page: shows the most recently added media items across every
|
||||
// library. Once the scraper / continue-watching subsystems land we will
|
||||
// fold in additional rows here.
|
||||
export function HomePage() {
|
||||
const [items, setItems] = useState<Media[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
mediaAPI
|
||||
.search('', 24)
|
||||
.then((d) => setItems(d.items))
|
||||
.finally(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<header>
|
||||
<h1 className="font-display text-3xl font-bold text-white">最近添加</h1>
|
||||
<p className="text-sm text-slate-400">最新被扫描入库的媒体内容</p>
|
||||
</header>
|
||||
|
||||
{loading && <p className="text-slate-500">加载中…</p>}
|
||||
{!loading && items.length === 0 && (
|
||||
<div className="glass-panel">
|
||||
<p className="text-slate-300">
|
||||
还没有任何媒体。前往 <span className="text-primary-400">管理后台</span>{' '}
|
||||
创建媒体库,然后触发一次扫描。
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6">
|
||||
{items.map((m) => (
|
||||
<MediaCard key={m.id} media={m} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useParams } from 'react-router-dom'
|
||||
import toast from 'react-hot-toast'
|
||||
|
||||
import { libraryAPI } from '../api/library'
|
||||
import type { Media } from '../types'
|
||||
import { MediaCard } from '../components/MediaCard'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
|
||||
// Browse a single library. Admins also get a "scan" button that triggers
|
||||
// a fresh scan run on the backend.
|
||||
export function LibraryPage() {
|
||||
const { id = '' } = useParams()
|
||||
const role = useAuthStore((s) => s.user?.role)
|
||||
const [items, setItems] = useState<Media[]>([])
|
||||
const [total, setTotal] = useState(0)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [scanning, setScanning] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return
|
||||
setLoading(true)
|
||||
libraryAPI
|
||||
.listMedia(id)
|
||||
.then((d) => {
|
||||
setItems(d.items)
|
||||
setTotal(d.total)
|
||||
})
|
||||
.finally(() => setLoading(false))
|
||||
}, [id])
|
||||
|
||||
const handleScan = async () => {
|
||||
setScanning(true)
|
||||
try {
|
||||
const r = await libraryAPI.scan(id)
|
||||
toast.success(`扫描完成:新增 ${r.added} 个文件`)
|
||||
const d = await libraryAPI.listMedia(id)
|
||||
setItems(d.items)
|
||||
setTotal(d.total)
|
||||
} catch {
|
||||
toast.error('扫描失败')
|
||||
} finally {
|
||||
setScanning(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="font-display text-3xl font-bold text-white">
|
||||
媒体库 <span className="text-slate-500">({total})</span>
|
||||
</h1>
|
||||
{role === 'admin' && (
|
||||
<button onClick={handleScan} disabled={scanning} className="neon-button">
|
||||
{scanning ? '扫描中…' : '立即扫描'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{loading && <p className="text-slate-500">加载中…</p>}
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6">
|
||||
{items.map((m) => (
|
||||
<MediaCard key={m.id} media={m} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { FormEvent, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import toast from 'react-hot-toast'
|
||||
import { Film } from 'lucide-react'
|
||||
|
||||
import { authAPI } from '../api/auth'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
|
||||
// Single-form login screen. The first install seeds admin/admin123 — we
|
||||
// surface that hint when no JWT exists yet.
|
||||
export function LoginPage() {
|
||||
const navigate = useNavigate()
|
||||
const setSession = useAuthStore((s) => s.setSession)
|
||||
const [username, setUsername] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const handleSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault()
|
||||
setLoading(true)
|
||||
try {
|
||||
const data = await authAPI.login(username, password)
|
||||
setSession(data.token, data.user)
|
||||
toast.success(`欢迎回来,${data.user.username}`)
|
||||
navigate('/')
|
||||
} catch (err: unknown) {
|
||||
const msg =
|
||||
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ??
|
||||
'登录失败,请检查用户名与密码'
|
||||
toast.error(msg)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-full items-center justify-center px-4 py-10">
|
||||
<form onSubmit={handleSubmit} className="glass-panel w-full max-w-md">
|
||||
<div className="mb-8 flex flex-col items-center gap-2">
|
||||
<Film className="h-10 w-10 text-primary-400" />
|
||||
<h1 className="font-display text-2xl font-bold tracking-wide text-white">
|
||||
MediaStationGo
|
||||
</h1>
|
||||
<p className="text-sm text-slate-400">登录到你的家庭媒体中心</p>
|
||||
</div>
|
||||
|
||||
<label className="mb-4 block">
|
||||
<span className="mb-1 block text-sm text-slate-300">用户名</span>
|
||||
<input
|
||||
className="input-base"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
autoComplete="username"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="mb-6 block">
|
||||
<span className="mb-1 block text-sm text-slate-300">密码</span>
|
||||
<input
|
||||
type="password"
|
||||
className="input-base"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
autoComplete="current-password"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="neon-button w-full justify-center"
|
||||
>
|
||||
{loading ? '登录中…' : '登录'}
|
||||
</button>
|
||||
|
||||
<p className="mt-6 text-center text-xs text-slate-500">
|
||||
首次部署默认账号:<code className="text-primary-400">admin / admin123</code>
|
||||
</p>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Link, useParams } from 'react-router-dom'
|
||||
import { Play } from 'lucide-react'
|
||||
|
||||
import { mediaAPI } from '../api/library'
|
||||
import type { Media } from '../types'
|
||||
|
||||
function fmtDuration(sec: number): string {
|
||||
if (!sec || sec <= 0) return '—'
|
||||
const h = Math.floor(sec / 3600)
|
||||
const m = Math.floor((sec % 3600) / 60)
|
||||
return h > 0 ? `${h}h ${m}m` : `${m}m`
|
||||
}
|
||||
|
||||
function fmtSize(bytes: number): string {
|
||||
if (!bytes || bytes <= 0) return '—'
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||
let v = bytes
|
||||
let i = 0
|
||||
while (v >= 1024 && i < units.length - 1) {
|
||||
v /= 1024
|
||||
i++
|
||||
}
|
||||
return `${v.toFixed(2)} ${units[i]}`
|
||||
}
|
||||
|
||||
// Detail screen for a single media item: poster, summary and a Play CTA.
|
||||
export function MediaDetailPage() {
|
||||
const { id = '' } = useParams()
|
||||
const [media, setMedia] = useState<Media | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return
|
||||
setLoading(true)
|
||||
mediaAPI
|
||||
.get(id)
|
||||
.then(setMedia)
|
||||
.finally(() => setLoading(false))
|
||||
}, [id])
|
||||
|
||||
if (loading) return <p className="text-slate-500">加载中…</p>
|
||||
if (!media) return <p className="text-slate-500">媒体不存在</p>
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div className="flex flex-col gap-6 md:flex-row">
|
||||
<div className="aspect-[2/3] w-48 shrink-0 overflow-hidden rounded-xl bg-surface-800">
|
||||
{media.poster_url ? (
|
||||
<img src={media.poster_url} alt={media.title} className="h-full w-full object-cover" />
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center text-slate-600">
|
||||
无海报
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 space-y-4">
|
||||
<h1 className="font-display text-3xl font-bold text-white">{media.title}</h1>
|
||||
{media.year > 0 && <p className="text-slate-400">{media.year}</p>}
|
||||
{media.overview && <p className="text-slate-300">{media.overview}</p>}
|
||||
|
||||
<div className="flex flex-wrap gap-2 text-xs text-slate-400">
|
||||
{media.video_codec && <Badge>{media.video_codec.toUpperCase()}</Badge>}
|
||||
{media.width > 0 && (
|
||||
<Badge>
|
||||
{media.width}×{media.height}
|
||||
</Badge>
|
||||
)}
|
||||
<Badge>{fmtDuration(media.duration_sec)}</Badge>
|
||||
<Badge>{fmtSize(media.size_bytes)}</Badge>
|
||||
{media.container && <Badge>{media.container.toUpperCase()}</Badge>}
|
||||
</div>
|
||||
|
||||
<Link to={`/play/${media.id}`} className="neon-button">
|
||||
<Play size={18} /> 播放
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Badge({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<span className="rounded border border-white/10 bg-white/5 px-2 py-0.5">{children}</span>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useNavigate, useParams } from 'react-router-dom'
|
||||
import { ArrowLeft } from 'lucide-react'
|
||||
|
||||
import { mediaAPI } from '../api/library'
|
||||
import { streamURL } from '../api/client'
|
||||
import type { Media } from '../types'
|
||||
|
||||
// Fullscreen, dark-themed video page. We currently use direct play via
|
||||
// /api/stream/:id — the future task is wiring HLS.js for transcoded streams.
|
||||
export function PlayerPage() {
|
||||
const { id = '' } = useParams()
|
||||
const navigate = useNavigate()
|
||||
const ref = useRef<HTMLVideoElement>(null)
|
||||
const [media, setMedia] = useState<Media | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return
|
||||
mediaAPI.get(id).then(setMedia)
|
||||
}, [id])
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') navigate(-1)
|
||||
}
|
||||
window.addEventListener('keydown', onKey)
|
||||
return () => window.removeEventListener('keydown', onKey)
|
||||
}, [navigate])
|
||||
|
||||
return (
|
||||
<div className="-m-6 flex min-h-screen flex-col bg-black md:-m-8">
|
||||
<button
|
||||
onClick={() => navigate(-1)}
|
||||
className="absolute left-4 top-4 z-10 flex items-center gap-2 rounded-full bg-black/60 px-3 py-1.5 text-sm text-white backdrop-blur transition hover:bg-black/80"
|
||||
>
|
||||
<ArrowLeft size={16} /> 返回
|
||||
</button>
|
||||
<div className="flex flex-1 items-center justify-center">
|
||||
{media ? (
|
||||
<video
|
||||
ref={ref}
|
||||
src={streamURL(media.id)}
|
||||
controls
|
||||
autoPlay
|
||||
className="max-h-screen w-full max-w-[1600px] bg-black"
|
||||
/>
|
||||
) : (
|
||||
<p className="text-slate-500">加载中…</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { ChangeEvent, useEffect, useState } from 'react'
|
||||
|
||||
import { mediaAPI } from '../api/library'
|
||||
import { MediaCard } from '../components/MediaCard'
|
||||
import type { Media } from '../types'
|
||||
|
||||
// Search-as-you-type with a 300ms debounce.
|
||||
export function SearchPage() {
|
||||
const [q, setQ] = useState('')
|
||||
const [items, setItems] = useState<Media[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => {
|
||||
setLoading(true)
|
||||
mediaAPI
|
||||
.search(q, 60)
|
||||
.then((d) => setItems(d.items))
|
||||
.finally(() => setLoading(false))
|
||||
}, 300)
|
||||
return () => clearTimeout(t)
|
||||
}, [q])
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h1 className="font-display text-3xl font-bold text-white">搜索</h1>
|
||||
<input
|
||||
autoFocus
|
||||
className="input-base"
|
||||
placeholder="按标题搜索…"
|
||||
value={q}
|
||||
onChange={(e: ChangeEvent<HTMLInputElement>) => setQ(e.target.value)}
|
||||
/>
|
||||
{loading && <p className="text-slate-500">搜索中…</p>}
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6">
|
||||
{items.map((m) => (
|
||||
<MediaCard key={m.id} media={m} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { create } from 'zustand'
|
||||
import { persist } from 'zustand/middleware'
|
||||
|
||||
import type { User } from '../types'
|
||||
|
||||
// Single source of truth for the authenticated user + JWT.
|
||||
// Persisted to localStorage so a page reload does not drop the session.
|
||||
interface AuthState {
|
||||
token: string | null
|
||||
user: User | null
|
||||
setSession: (token: string, user: User) => void
|
||||
setUser: (user: User) => void
|
||||
logout: () => void
|
||||
}
|
||||
|
||||
export const useAuthStore = create<AuthState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
token: null,
|
||||
user: null,
|
||||
setSession: (token, user) => set({ token, user }),
|
||||
setUser: (user) => set({ user }),
|
||||
logout: () => set({ token: null, user: null }),
|
||||
}),
|
||||
{ name: 'mediastationgo-auth' },
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,74 @@
|
||||
// Domain types mirrored from the Go backend (internal/model).
|
||||
|
||||
export interface User {
|
||||
id: string
|
||||
username: string
|
||||
role: 'admin' | 'user'
|
||||
email?: string
|
||||
avatar_url?: string
|
||||
force_password_reset: boolean
|
||||
last_login_at?: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface Library {
|
||||
id: string
|
||||
name: string
|
||||
path: string
|
||||
type: string
|
||||
enabled: boolean
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface Media {
|
||||
id: string
|
||||
library_id: string
|
||||
series_id?: string
|
||||
title: string
|
||||
original_name?: string
|
||||
path: string
|
||||
size_bytes: number
|
||||
duration_sec: number
|
||||
width: number
|
||||
height: number
|
||||
video_codec?: string
|
||||
audio_codec?: string
|
||||
container?: string
|
||||
poster_url?: string
|
||||
backdrop_url?: string
|
||||
overview?: string
|
||||
rating: number
|
||||
year: number
|
||||
season_num: number
|
||||
episode_num: number
|
||||
scrape_status: string
|
||||
tmdb_id: number
|
||||
bangumi_id: number
|
||||
nsfw: boolean
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface ScanResult {
|
||||
library_id: string
|
||||
visited: number
|
||||
added: number
|
||||
}
|
||||
|
||||
export interface Setting {
|
||||
key: string
|
||||
value: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface AccessLog {
|
||||
id: string
|
||||
user_id: string
|
||||
action: string
|
||||
target: string
|
||||
ip: string
|
||||
detail: string
|
||||
created_at: string
|
||||
}
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
Reference in New Issue
Block a user