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