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:
Kiro
2026-05-14 15:26:29 +00:00
commit d5cf5fb4b2
60 changed files with 7592 additions and 0 deletions
+56
View File
@@ -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>
)
}
+15
View File
@@ -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),
}
+22
View File
@@ -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),
}
+39
View File
@@ -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)}`
}
+35
View File
@@ -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),
}
+116
View File
@@ -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>
)
}
+33
View File
@@ -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>
)
}
+23
View File
@@ -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}</>
}
+47
View File
@@ -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;
}
}
+22
View File
@@ -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>,
)
+253
View File
@@ -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>
)
}
+44
View File
@@ -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>
)
}
+68
View File
@@ -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>
)
}
+84
View File
@@ -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>
)
}
+87
View File
@@ -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>
)
}
+53
View File
@@ -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>
)
}
+42
View File
@@ -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>
)
}
+27
View File
@@ -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' },
),
)
+74
View File
@@ -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
}
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />