feat(web): react-table admin tables + RHF/zod forms; docs reflect the Stack A dependency set

Members and shares-audit render through @tanstack/react-table (sortable,
spec-first); org rename, hub signup policy, and onboarding create/join are
react-hook-form + zod with inline errors replacing toast-on-typo. 55/55.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VbiaaVM2ACxeRi8ySG9ybc
This commit is contained in:
Snow Lee
2026-07-19 13:15:24 -07:00
co-authored by Claude Fable 5
parent 2e835d266d
commit fad9ae4f6b
11 changed files with 544 additions and 263 deletions
+1 -1
View File
File diff suppressed because one or more lines are too long
+3 -3
View File
@@ -494,9 +494,9 @@ conflicts. Set `BDRIVE_HOME` to relocate all beardrive state (used heavily in te
### Web frontend
The hub's web UI is a React + TypeScript app in `internal/webapp/frontend`
(Vite; runtime dependencies are just react, react-dom,
@tanstack/react-query, and lucide-react for icons — routing is a small
in-repo history router). Its
(Vite + Tailwind v4 + shadcn/ui components owned in-repo; TanStack
query/table/virtual, react-hook-form + zod, cmdk, sonner, lucide-react —
routing stays a small in-repo history router). Its
**built output is committed** at `internal/webapp/static`, the `go:embed`
target, so building or `go install`-ing the binary never needs Node.
+13 -2
View File
@@ -116,10 +116,10 @@ test("hub settings: policy view, save round-trip, pending queue empty", async ({
// Toggle approval on, save, revert
const app = page.locator(".admin-item.toggle").nth(1).locator("input");
await app.check();
await page.click(".admin > .pbtn");
await page.click('.admin button:has-text("Save policy")');
await expectToast(page, "policy saved");
await app.uncheck();
await page.click(".admin > .pbtn");
await page.click('.admin button:has-text("Save policy")');
await expectToast(page, "policy saved");
await expect(page.locator(".admin-empty", { hasText: "No one is waiting" })).toBeVisible();
});
@@ -133,3 +133,14 @@ test("navigating away closes an open admin panel", async ({ page }) => {
await expect(page.locator("#content h1")).toHaveText("Wiki");
await expect(page.locator(".admin")).toHaveCount(0);
});
test("members table sorts by email", async ({ page }) => {
await login(page);
await openOrgSettings(page);
const emails = page.locator(".admin-table .admin-item .ai-main");
await expect(emails.first()).toBeVisible();
const before = await emails.allTextContents();
await page.click('.admin-table th:has-text("Member")');
const after = await emails.allTextContents();
expect([...before].reverse()).toEqual(after);
});
@@ -1,10 +1,25 @@
import { useRef } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { Button } from "@/components/ui/button";
import { toast } from "../toast";
// Onboarding: a signed-in account with no projects shouldn't hit a blank
// sidebar. Explain that access comes from an invite, let them paste one,
// and — since any member can — offer to start a new project.
// and — since any member can — offer to start a new project. Both inputs
// are RHF+zod forms with inline errors (no toast-on-typo).
const joinSchema = z.object({
invite: z
.string()
.trim()
.refine((v) => /join\/([0-9a-f]+)/.test(v) || /^[0-9a-f]{8,}$/.test(v), {
message: "That doesn't look like an invite link.",
}),
});
const createSchema = z.object({
name: z.string().trim().min(1, "Give the project a name.").max(60, "Keep it under 60 characters."),
});
export function EmptyState({
authEnabled,
onCreate,
@@ -12,18 +27,14 @@ export function EmptyState({
authEnabled: boolean;
onCreate: (name: string) => void;
}) {
const invite = useRef<HTMLInputElement>(null);
const name = useRef<HTMLInputElement>(null);
const join = () => {
const v = invite.current!.value.trim();
const m = v.match(/join\/([0-9a-f]+)/) || v.match(/^([0-9a-f]{8,})$/);
if (!m) {
toast("That doesn't look like an invite link.", true);
return;
}
location.href = "/join/" + m[1];
};
const joinForm = useForm<z.infer<typeof joinSchema>>({
resolver: zodResolver(joinSchema),
defaultValues: { invite: "" },
});
const createForm = useForm<z.infer<typeof createSchema>>({
resolver: zodResolver(createSchema),
defaultValues: { name: "" },
});
return (
<div className="onboard">
@@ -33,23 +44,47 @@ export function EmptyState({
<div className="ob-card">
<h3>Have an invite link?</h3>
<p>A teammate can send you a join link. Paste it here:</p>
<div className="ob-row">
<input id="ob-invite" type="text" placeholder="https://…/join/…" autoComplete="off" ref={invite} />
<Button id="ob-join" variant="primary" onClick={join}>
<form
className="ob-row"
onSubmit={joinForm.handleSubmit(({ invite }) => {
const m = invite.match(/join\/([0-9a-f]+)/) || invite.match(/^([0-9a-f]{8,})$/);
location.href = "/join/" + m![1];
})}
>
<input
id="ob-invite"
type="text"
placeholder="https://…/join/…"
autoComplete="off"
{...joinForm.register("invite")}
/>
<Button id="ob-join" variant="primary" type="submit">
Join
</Button>
</div>
</form>
{joinForm.formState.errors.invite && (
<p className="field-err">{joinForm.formState.errors.invite.message}</p>
)}
</div>
)}
<div className="ob-card">
<h3>Or start a new project</h3>
<p>Create a shared space for your team's files.</p>
<div className="ob-row">
<input id="ob-name" type="text" placeholder="Project name, e.g. wiki" autoComplete="off" ref={name} />
<Button id="ob-create" variant="primary" onClick={() => onCreate(name.current!.value.trim())}>
<form className="ob-row" onSubmit={createForm.handleSubmit(({ name }) => onCreate(name))}>
<input
id="ob-name"
type="text"
placeholder="Project name, e.g. wiki"
autoComplete="off"
{...createForm.register("name")}
/>
<Button id="ob-create" variant="primary" type="submit">
Create
</Button>
</div>
</form>
{createForm.formState.errors.name && (
<p className="field-err">{createForm.formState.errors.name.message}</p>
)}
</div>
</div>
);
@@ -1,9 +1,19 @@
import { useEffect, useState } from "react";
import { useEffect } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { getJSON, postJSON } from "../api/http";
import type { AdminPolicy } from "../api/types";
import { usePending } from "../hooks/useHub";
import { toast } from "../toast";
import { Button } from "@/components/ui/button";
const policySchema = z.object({
require_verification: z.boolean(),
require_approval: z.boolean(),
});
type PolicyForm = z.infer<typeof policySchema>;
/* Hub-admin settings: signup/access policy. Verification & approval are
live toggles; the domain allowlist and admin list are shown read-only
@@ -16,14 +26,12 @@ export function HubSettings() {
queryFn: () => getJSON<AdminPolicy>("/api/admin/policy"),
});
const { data: pending } = usePending(true);
const [ver, setVer] = useState(false);
const [app, setApp] = useState(false);
useEffect(() => {
if (pol) {
setVer(pol.require_verification && pol.mailer);
setApp(pol.require_approval);
}
}, [pol]);
const form = useForm<PolicyForm>({
resolver: zodResolver(policySchema),
values: pol
? { require_verification: pol.require_verification && pol.mailer, require_approval: pol.require_approval }
: { require_verification: false, require_approval: false },
});
useEffect(() => {
if (error) toast((error as Error).message, true);
}, [error]);
@@ -47,40 +55,38 @@ export function HubSettings() {
</p>
<h3>New-account vetting</h3>
<div className="admin-list">
<PolicyToggle
label="Require email verification"
desc={
pol.mailer
? "New accounts must click an emailed link before they can sign in — proves they control the address."
: "Configure SMTP on the server (auth.smtp) to enable email verification."
}
checked={ver}
disabled={!pol.mailer}
onChange={setVer}
/>
<PolicyToggle
label="Require admin approval"
desc="New accounts wait for a hub admin to approve them before they gain access."
checked={app}
onChange={setApp}
/>
</div>
<button
className="pbtn"
style={{ marginTop: 14 }}
onClick={async () => {
<form
onSubmit={form.handleSubmit(async (v) => {
try {
await postJSON("/api/admin/policy", { require_verification: ver, require_approval: app });
await postJSON("/api/admin/policy", v);
toast("Signup policy saved.");
qc.invalidateQueries({ queryKey: ["admin", "policy"] });
} catch (e) {
toast((e as Error).message, true);
}
}}
})}
>
Save policy
</button>
<div className="admin-list">
<PolicyToggle
label="Require email verification"
desc={
pol.mailer
? "New accounts must click an emailed link before they can sign in — proves they control the address."
: "Configure SMTP on the server (auth.smtp) to enable email verification."
}
disabled={!pol.mailer}
inputProps={form.register("require_verification")}
/>
<PolicyToggle
label="Require admin approval"
desc="New accounts wait for a hub admin to approve them before they gain access."
inputProps={form.register("require_approval")}
/>
</div>
<Button variant="primary" type="submit" style={{ marginTop: 14 }}>
Save policy
</Button>
</form>
<h3>Who can sign up</h3>
<div className="admin-list">
@@ -116,9 +122,9 @@ export function HubSettings() {
{(pending || []).map((u) => (
<div className="admin-item" key={u.id}>
<span className="ai-main">{(u.name ? u.name + " · " : "") + u.email}</span>
<button className="pbtn" onClick={() => act(u.id, "approve", u.email)}>
<Button variant="primary" onClick={() => act(u.id, "approve", u.email)}>
Approve
</button>
</Button>
<button className="ai-del" onClick={() => act(u.id, "deny", u.email)}>
Deny
</button>
@@ -132,15 +138,13 @@ export function HubSettings() {
function PolicyToggle({
label,
desc,
checked,
disabled,
onChange,
inputProps,
}: {
label: string;
desc: string;
checked: boolean;
disabled?: boolean;
onChange: (v: boolean) => void;
inputProps: ReturnType<ReturnType<typeof useForm<PolicyForm>>["register"]>;
}) {
return (
<label className="admin-item toggle" style={disabled ? { opacity: 0.55 } : undefined}>
@@ -148,12 +152,7 @@ function PolicyToggle({
<div className="tg-label">{label}</div>
<div className="tg-desc">{desc}</div>
</span>
<input
type="checkbox"
checked={checked}
disabled={disabled}
onChange={(e) => onChange(e.target.checked)}
/>
<input type="checkbox" disabled={disabled} {...inputProps} />
</label>
);
}
@@ -1,14 +1,36 @@
import { useState } from "react";
import { useMemo, useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import {
createColumnHelper,
flexRender,
getCoreRowModel,
getSortedRowModel,
useReactTable,
type SortingState,
} from "@tanstack/react-table";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { api, getJSON, postJSON } from "../api/http";
import type { Org, OrgInviteInfo, OrgShareInfo, Project } from "../api/types";
import { modalConfirm, modalPrompt } from "../modal";
import { toast } from "../toast";
import { copyText } from "../util";
import { Button } from "@/components/ui/button";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
/* The org admin panel: members (owners can change roles / remove), rename,
projects (rename / delete), invite links (create / revoke), and an
org-wide audit of public shares. */
org-wide audit of public shares. Members and shares render through
react-table (sortable); the rename is an RHF+zod form. */
const renameSchema = z.object({
name: z.string().trim().min(1, "Give the organization a name.").max(60, "Keep it under 60 characters."),
});
type RenameForm = z.infer<typeof renameSchema>;
type Member = Org["members"][number];
export function OrgAdmin({
org,
projects,
@@ -22,12 +44,16 @@ export function OrgAdmin({
}) {
const qc = useQueryClient();
const owner = org.role === "owner";
const [renameVal, setRenameVal] = useState(org.name);
const refreshOrgs = () => qc.invalidateQueries({ queryKey: ["orgs"] });
const refreshInvites = () => qc.invalidateQueries({ queryKey: ["invites", org.id] });
const refreshShares = () => qc.invalidateQueries({ queryKey: ["orgShares", org.id] });
const renameForm = useForm<RenameForm>({
resolver: zodResolver(renameSchema),
values: { name: org.name },
});
const { data: invites } = useQuery({
queryKey: ["invites", org.id],
queryFn: () => getJSON<{ invites: OrgInviteInfo[] }>(`/api/orgs/${org.id}/invites`),
@@ -48,90 +74,30 @@ export function OrgAdmin({
<h1 id="org-title">{org.name + (owner ? "" : " · member")}</h1>
{owner && (
<div className="admin-row">
<input
id="org-rename"
type="text"
value={renameVal}
onChange={(e) => setRenameVal(e.target.value)}
/>
<button
className="pbtn"
id="org-rename-btn"
onClick={async () => {
try {
await api("PATCH", "/api/orgs/" + org.id, { name: renameVal.trim() });
toast("Renamed.");
refreshOrgs();
} catch (e) {
toast((e as Error).message, true);
}
}}
>
<form
className="admin-row"
onSubmit={renameForm.handleSubmit(async ({ name }) => {
try {
await api("PATCH", "/api/orgs/" + org.id, { name });
toast("Renamed.");
refreshOrgs();
} catch (e) {
toast((e as Error).message, true);
}
})}
>
<input id="org-rename" type="text" {...renameForm.register("name")} />
<Button variant="primary" id="org-rename-btn" type="submit">
Rename org
</button>
</div>
</Button>
{renameForm.formState.errors.name && (
<span className="field-err">{renameForm.formState.errors.name.message}</span>
)}
</form>
)}
<h3>Members</h3>
<div className="admin-list">
{org.members.map((m) => {
const isSelf = !!myEmail && m.email.toLowerCase() === myEmail.toLowerCase();
return (
<div className="admin-item" key={m.email}>
<span className="ai-main">{m.email + (isSelf ? " (you)" : "")}</span>
{owner && !isSelf ? (
<>
<select
value={m.role}
onChange={async (e) => {
try {
await api(
"PATCH",
`/api/orgs/${org.id}/members/${encodeURIComponent(m.email)}`,
{ role: e.target.value },
);
toast("Role updated.");
} catch (err) {
toast((err as Error).message, true);
}
refreshOrgs();
}}
>
<option value="owner">owner</option>
<option value="member">member</option>
</select>
<button
className="ai-del"
onClick={async () => {
if (
!(await modalConfirm(
"Remove member",
`Remove ${m.email} from ${org.name}?`,
"Remove",
true,
))
)
return;
try {
await api("DELETE", `/api/orgs/${org.id}/members/${encodeURIComponent(m.email)}`);
toast("Removed.");
refreshOrgs();
} catch (err) {
toast((err as Error).message, true);
}
}}
>
Remove
</button>
</>
) : (
<span className="ai-tag">{m.role}</span>
)}
</div>
);
})}
</div>
<MembersTable org={org} owner={owner} myEmail={myEmail} onChanged={refreshOrgs} />
{owner && (
<>
@@ -141,8 +107,8 @@ export function OrgAdmin({
{orgProjects.map((p) => (
<div className="admin-item" key={p.id}>
<span className="ai-main">{p.name}</span>
<button
className="ai-btn"
<Button
variant="subtle"
onClick={async () => {
const name = await modalPrompt("Rename project", "New name", p.name, "Rename");
if (!name || name === p.name) return;
@@ -156,7 +122,7 @@ export function OrgAdmin({
}}
>
Rename
</button>
</Button>
<button
className="ai-del"
onClick={async () => {
@@ -186,8 +152,8 @@ export function OrgAdmin({
<div className="admin-h">
<h3>Invite links</h3>
<button
className="pbtn"
<Button
variant="primary"
onClick={async () => {
try {
const out = await postJSON<{ url: string }>(`/api/orgs/${org.id}/invites`);
@@ -200,7 +166,7 @@ export function OrgAdmin({
}}
>
New invite
</button>
</Button>
</div>
<div className="admin-list">
{invites && invites.length === 0 && (
@@ -252,51 +218,240 @@ export function OrgAdmin({
</div>
<h3>Public share links</h3>
<div className="admin-list">
{shares && shares.length === 0 && <div className="admin-empty">No public shares.</div>}
{(shares || []).map((sh) => (
<div className="admin-item" key={sh.token}>
<span
className="ai-main mono"
style={{ cursor: "pointer" }}
title={sh.url}
onClick={() => window.open(sh.url, "_blank")}
>
{sh.path}
</span>
<span className="ai-tag">
{(sh.project_name || "") +
(sh.creator ? " · by " + sh.creator : "") +
(sh.created ? " · " + new Date(sh.created).toLocaleDateString() : "")}
</span>
<button
className="ai-del"
onClick={async () => {
if (
!(await modalConfirm(
"Revoke share link",
`Revoke the public link to “${sh.path}”? Anyone with the URL will lose access.`,
"Revoke",
true,
))
)
return;
try {
await api("DELETE", "/api/shares/" + sh.token);
toast("Share revoked.");
refreshShares();
} catch (e) {
toast((e as Error).message, true);
}
}}
>
Revoke
</button>
</div>
))}
</div>
<SharesTable shares={shares || []} onChanged={refreshShares} />
</>
)}
</div>
);
}
/* ---- members (react-table: sortable email/role) ---- */
function MembersTable({
org,
owner,
myEmail,
onChanged,
}: {
org: Org;
owner: boolean;
myEmail: string;
onChanged: () => void;
}) {
const [sorting, setSorting] = useState<SortingState>([{ id: "email", desc: false }]);
const col = useMemo(() => createColumnHelper<Member>(), []);
const columns = useMemo(
() => [
col.accessor("email", {
id: "email",
header: "Member",
cell: (c) => {
const isSelf = !!myEmail && c.getValue().toLowerCase() === myEmail.toLowerCase();
return <span className="ai-main">{c.getValue() + (isSelf ? " (you)" : "")}</span>;
},
}),
col.accessor("role", {
id: "role",
header: "Role",
cell: (c) => {
const m = c.row.original;
const isSelf = !!myEmail && m.email.toLowerCase() === myEmail.toLowerCase();
if (!owner || isSelf) return <span className="ai-tag">{m.role}</span>;
return (
<>
<select
value={m.role}
onChange={async (e) => {
try {
await api("PATCH", `/api/orgs/${org.id}/members/${encodeURIComponent(m.email)}`, {
role: e.target.value,
});
toast("Role updated.");
} catch (err) {
toast((err as Error).message, true);
}
onChanged();
}}
>
<option value="owner">owner</option>
<option value="member">member</option>
</select>
<button
className="ai-del"
onClick={async () => {
if (
!(await modalConfirm("Remove member", `Remove ${m.email} from ${org.name}?`, "Remove", true))
)
return;
try {
await api("DELETE", `/api/orgs/${org.id}/members/${encodeURIComponent(m.email)}`);
toast("Removed.");
onChanged();
} catch (err) {
toast((err as Error).message, true);
}
}}
>
Remove
</button>
</>
);
},
}),
],
[col, org.id, org.name, owner, myEmail],
);
const table = useReactTable({
data: org.members,
columns,
state: { sorting },
onSortingChange: setSorting,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
});
return (
<Table className="admin-table">
<TableHeader>
{table.getHeaderGroups().map((hg) => (
<TableRow key={hg.id}>
{hg.headers.map((h) => (
<TableHead
key={h.id}
onClick={h.column.getToggleSortingHandler()}
data-sort={h.column.getIsSorted() || undefined}
>
{flexRender(h.column.columnDef.header, h.getContext())}
{h.column.getIsSorted() === "asc" ? " ↑" : h.column.getIsSorted() === "desc" ? " ↓" : ""}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows.map((r) => (
<TableRow key={r.id} className="admin-item">
{r.getVisibleCells().map((c) => (
<TableCell key={c.id}>{flexRender(c.column.columnDef.cell, c.getContext())}</TableCell>
))}
</TableRow>
))}
</TableBody>
</Table>
);
}
/* ---- shares audit (react-table: sortable path/project) ---- */
function SharesTable({
shares,
onChanged,
}: {
shares: OrgShareInfo[];
onChanged: () => void;
}) {
const [sorting, setSorting] = useState<SortingState>([]);
const col = useMemo(() => createColumnHelper<OrgShareInfo>(), []);
const columns = useMemo(
() => [
col.accessor("path", {
header: "Path",
cell: (c) => (
<span
className="ai-main mono"
style={{ cursor: "pointer" }}
title={c.row.original.url}
onClick={() => window.open(c.row.original.url, "_blank")}
>
{c.getValue()}
</span>
),
}),
col.accessor((s) => s.project_name || "", {
id: "project",
header: "Project",
cell: (c) => (
<span className="ai-tag">
{(c.getValue() || "") +
(c.row.original.creator ? " · by " + c.row.original.creator : "") +
(c.row.original.created ? " · " + new Date(c.row.original.created).toLocaleDateString() : "")}
</span>
),
}),
col.display({
id: "actions",
header: "",
cell: (c) => (
<button
className="ai-del"
onClick={async () => {
const sh = c.row.original;
if (
!(await modalConfirm(
"Revoke share link",
`Revoke the public link to “${sh.path}”? Anyone with the URL will lose access.`,
"Revoke",
true,
))
)
return;
try {
await api("DELETE", "/api/shares/" + sh.token);
toast("Share revoked.");
onChanged();
} catch (e) {
toast((e as Error).message, true);
}
}}
>
Revoke
</button>
),
}),
],
[col, onChanged],
);
const table = useReactTable({
data: shares,
columns,
state: { sorting },
onSortingChange: setSorting,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
});
if (shares.length === 0)
return (
<div className="admin-list">
<div className="admin-empty">No public shares.</div>
</div>
);
return (
<Table className="admin-table">
<TableHeader>
{table.getHeaderGroups().map((hg) => (
<TableRow key={hg.id}>
{hg.headers.map((h) => (
<TableHead key={h.id} onClick={h.column.getToggleSortingHandler()}>
{flexRender(h.column.columnDef.header, h.getContext())}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows.map((r) => (
<TableRow key={r.id} className="admin-item">
{r.getVisibleCells().map((c) => (
<TableCell key={c.id}>{flexRender(c.column.columnDef.cell, c.getContext())}</TableCell>
))}
</TableRow>
))}
</TableBody>
</Table>
);
}
+15
View File
@@ -136,6 +136,21 @@ button, input, a.btn { font-family: inherit; }
#tree .row.collapsed .chev { transform: rotate(-90deg); }
/* org bar */
.field-err { color: var(--del); font-size: 12px; margin: 6px 2px 0; }
/* react-table admin tables ride the .admin-item/.ai-* vocabulary */
.admin-table { width: 100%; border-collapse: collapse; }
.admin-table th {
text-align: left; font-size: 11px; font-weight: 600; letter-spacing: .05em;
text-transform: uppercase; color: var(--text-ghost); padding: 6px 10px;
border-bottom: 1px solid var(--border); cursor: pointer; user-select: none;
}
.admin-table th:hover { color: var(--text-dim); }
.admin-table td { padding: 0; border-bottom: 1px solid var(--border); }
.admin-table tr.admin-item { display: table-row; }
.admin-table tr.admin-item td { padding: 8px 10px; }
.admin-table tr.admin-item td:first-child { width: 100%; }
/* ---- project menu ---- */
.nav-menu { list-style: none; margin: 6px 0 0; padding: 0; }
.nav-menu .row .ico { width: 15px; height: 15px; flex: none; color: var(--text-ghost); }
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -5,8 +5,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>BearDrive</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>&#128059;</text></svg>">
<script type="module" crossorigin src="/assets/index-ESH0Hl7P.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-MFSXFxvy.css">
<script type="module" crossorigin src="/assets/index-CKZ61mhX.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DNKHQHAn.css">
</head>
<body>
<div id="root"></div>