feat(web): app shell + mounts admin page
This commit is contained in:
parent
2c6bc8818d
commit
c2692a0ceb
|
|
@ -0,0 +1,44 @@
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { cn } from "~/lib/utils";
|
||||||
|
|
||||||
|
const NAV = [
|
||||||
|
{ hrefKey: "nav.library", href: "library", id: "library" },
|
||||||
|
{ hrefKey: "nav.browse", href: "browse", id: "browse" },
|
||||||
|
{ hrefKey: "nav.mounts", href: "mounts", id: "mounts" },
|
||||||
|
{ hrefKey: "nav.danmaku", href: "danmaku", id: "danmaku" },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export function AppShell(props: {
|
||||||
|
locale: string;
|
||||||
|
active: (typeof NAV)[number]["id"];
|
||||||
|
labels: Record<string, string>;
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
|
const base = `/${props.locale}`;
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen bg-background text-foreground">
|
||||||
|
<aside className="hidden w-56 shrink-0 border-r border-border bg-muted/40 md:flex md:flex-col md:p-4">
|
||||||
|
<div className="mb-6 px-2 text-sm font-semibold tracking-wide">
|
||||||
|
{props.labels["meta.brand"] ?? "弹弹 Web"}
|
||||||
|
</div>
|
||||||
|
<nav className="flex flex-col gap-1" aria-label="sidebar">
|
||||||
|
{NAV.map((item) => (
|
||||||
|
<a
|
||||||
|
key={item.id}
|
||||||
|
href={`${base}/${item.href}`}
|
||||||
|
className={cn(
|
||||||
|
"rounded-md px-3 py-2 text-sm transition-colors",
|
||||||
|
props.active === item.id
|
||||||
|
? "bg-background font-medium shadow-sm"
|
||||||
|
: "text-muted-foreground hover:bg-background/60",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{props.labels[item.hrefKey] ?? item.href}
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
</aside>
|
||||||
|
<main className="flex min-w-0 flex-1 flex-col p-4 md:p-6">{props.children}</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,138 @@
|
||||||
|
import { Button, Input } from "@app/ui";
|
||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { createTRPCReactClient, trpc } from "~/lib/trpc";
|
||||||
|
|
||||||
|
function MountsInner() {
|
||||||
|
const utils = trpc.useUtils();
|
||||||
|
const list = trpc.media.mountList.useQuery();
|
||||||
|
const create = trpc.media.mountCreate.useMutation({
|
||||||
|
onSuccess: () => utils.media.mountList.invalidate(),
|
||||||
|
});
|
||||||
|
const test = trpc.media.mountTest.useMutation();
|
||||||
|
const del = trpc.media.mountDelete.useMutation({
|
||||||
|
onSuccess: () => utils.media.mountList.invalidate(),
|
||||||
|
});
|
||||||
|
const [form, setForm] = useState({
|
||||||
|
name: "",
|
||||||
|
baseUrl: "",
|
||||||
|
username: "",
|
||||||
|
password: "",
|
||||||
|
rootPath: "/",
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<h1 className="text-xl font-semibold">挂载管理</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
OpenList 请填写其 WebDAV 地址;凭据仅保存在服务端。
|
||||||
|
</p>
|
||||||
|
<form
|
||||||
|
className="grid gap-3 rounded-lg border border-border bg-card p-4 md:grid-cols-2"
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
create.mutate({
|
||||||
|
name: form.name,
|
||||||
|
type: "webdav",
|
||||||
|
baseUrl: form.baseUrl,
|
||||||
|
username: form.username || undefined,
|
||||||
|
password: form.password || undefined,
|
||||||
|
rootPath: form.rootPath || "/",
|
||||||
|
enabled: true,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
placeholder="名称,如 OpenList"
|
||||||
|
value={form.name}
|
||||||
|
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
placeholder="https://example.com/dav"
|
||||||
|
value={form.baseUrl}
|
||||||
|
onChange={(e) => setForm({ ...form, baseUrl: e.target.value })}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
placeholder="用户名(可选)"
|
||||||
|
value={form.username}
|
||||||
|
onChange={(e) => setForm({ ...form, username: e.target.value })}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
placeholder="密码 / 令牌"
|
||||||
|
value={form.password}
|
||||||
|
onChange={(e) => setForm({ ...form, password: e.target.value })}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
placeholder="根路径 /"
|
||||||
|
value={form.rootPath}
|
||||||
|
onChange={(e) => setForm({ ...form, rootPath: e.target.value })}
|
||||||
|
/>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button type="submit" disabled={create.isPending}>
|
||||||
|
保存
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() =>
|
||||||
|
test.mutate({
|
||||||
|
baseUrl: form.baseUrl,
|
||||||
|
username: form.username || undefined,
|
||||||
|
password: form.password || undefined,
|
||||||
|
rootPath: form.rootPath || "/",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
测试连接
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{test.data && (
|
||||||
|
<p className={test.data.ok ? "text-green-600" : "text-red-600"}>
|
||||||
|
{test.data.message}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<ul className="divide-y divide-border rounded-lg border border-border">
|
||||||
|
{(list.data?.mounts ?? []).map((m) => (
|
||||||
|
<li key={m.id} className="flex items-center justify-between gap-3 p-3">
|
||||||
|
<div>
|
||||||
|
<div className="font-medium">{m.name}</div>
|
||||||
|
<div className="text-xs text-muted-foreground break-all">
|
||||||
|
{m.baseUrl}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => del.mutate({ id: m.id })}
|
||||||
|
>
|
||||||
|
删除
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
{list.data?.mounts.length === 0 && (
|
||||||
|
<li className="p-4 text-sm text-muted-foreground">尚未添加挂载</li>
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = createTRPCReactClient();
|
||||||
|
const qc = new QueryClient();
|
||||||
|
|
||||||
|
export function MountsBody() {
|
||||||
|
return (
|
||||||
|
<QueryClientProvider client={qc}>
|
||||||
|
<trpc.Provider client={client} queryClient={qc}>
|
||||||
|
<MountsInner />
|
||||||
|
</trpc.Provider>
|
||||||
|
</QueryClientProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,19 @@
|
||||||
|
---
|
||||||
|
import { COOKIE_LANG, isLocale, localePath, resolvePreferredLocale, type Locale } from "@app/i18n";
|
||||||
|
import Mounts from "~/views/mounts.astro";
|
||||||
|
|
||||||
|
const raw = Astro.params["locale"];
|
||||||
|
const normalized = raw === "zh-cn" ? "zh-CN" : raw;
|
||||||
|
|
||||||
|
if (!normalized || !isLocale(normalized)) {
|
||||||
|
const preferred = resolvePreferredLocale(
|
||||||
|
Astro.cookies.get(COOKIE_LANG)?.value,
|
||||||
|
Astro.request.headers.get("accept-language"),
|
||||||
|
);
|
||||||
|
return Astro.redirect(localePath(preferred, "/mounts"));
|
||||||
|
}
|
||||||
|
|
||||||
|
const locale: Locale = normalized;
|
||||||
|
---
|
||||||
|
|
||||||
|
<Mounts locale={locale} />
|
||||||
|
|
@ -0,0 +1,39 @@
|
||||||
|
---
|
||||||
|
import { alternatePath, type Locale, t } from "@app/i18n";
|
||||||
|
import { Toaster } from "@app/ui";
|
||||||
|
import { AppShell } from "~/components/AppShell";
|
||||||
|
import { MountsBody } from "~/components/pages/MountsBody";
|
||||||
|
import AppLayout from "~/layouts/AppLayout.astro";
|
||||||
|
|
||||||
|
export interface Props {
|
||||||
|
locale: Locale;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { locale } = Astro.props;
|
||||||
|
const other: Locale = locale === "zh-CN" ? "en" : "zh-CN";
|
||||||
|
const labels: Record<string, string> = {
|
||||||
|
"meta.brand": t(locale, "meta.brand"),
|
||||||
|
"nav.library": t(locale, "nav.library"),
|
||||||
|
"nav.browse": t(locale, "nav.browse"),
|
||||||
|
"nav.mounts": t(locale, "nav.mounts"),
|
||||||
|
"nav.danmaku": t(locale, "nav.danmaku"),
|
||||||
|
};
|
||||||
|
---
|
||||||
|
|
||||||
|
<AppLayout
|
||||||
|
title={t(locale, "mounts.title")}
|
||||||
|
locale={locale}
|
||||||
|
nav={[]}
|
||||||
|
switchHref={alternatePath(Astro.url.pathname, other)}
|
||||||
|
switchLabel={t(other, `locale.${other}` as never)}
|
||||||
|
seo={{
|
||||||
|
title: `${t(locale, "mounts.title")} · ${t(locale, "meta.brand")}`,
|
||||||
|
path: Astro.url.pathname,
|
||||||
|
robots: ["noindex", "nofollow"],
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<AppShell locale={locale} active="mounts" labels={labels}>
|
||||||
|
<MountsBody client:load />
|
||||||
|
</AppShell>
|
||||||
|
<Toaster client:load />
|
||||||
|
</AppLayout>
|
||||||
Loading…
Reference in New Issue