feat: add admin dashboard

This commit is contained in:
2026-04-29 09:44:57 -04:00
parent e319a5d092
commit baf7a497eb
11 changed files with 939 additions and 67 deletions

View File

@@ -1,31 +1,370 @@
export function App() {
return (
<main class="shell">
<section class="hero">
<p class="eyebrow">MD Hub Secure</p>
<h1>Preact foundation for future hydrated features</h1>
<p class="lede">
The Go server is the source of truth for rendering document pages.
This app is the scaffold for search, sync, comments, and design
tooling that will hydrate over time.
</p>
</section>
import {
Activity,
Database,
FolderGit2,
LogIn,
LogOut,
RefreshCw,
Shield,
UserPlus,
Users,
} from "lucide-preact";
import { useEffect, useState } from "preact/hooks";
<section class="grid">
<article class="card">
<h2>Current scope</h2>
<p>
Routing, bundling, TypeScript, and styles are in place for later
feature islands.
</p>
</article>
<article class="card">
<h2>Next milestone</h2>
<p>
Sync protocol UI, offline cache status, and browser-side search.
</p>
</article>
type Role = "admin" | "editor" | "viewer";
type User = {
id: string;
email: string;
displayName: string;
role: Role;
createdAt: string;
lastSeenAt?: string;
};
type DocumentRecord = {
path: string;
title: string;
currentHash: string;
updatedAt: string;
};
type Workspace = {
sourceDir: string;
storeDir: string;
databasePath: string;
documentCount: number;
userCount: number;
webDistDir: string;
};
type WorkspaceResponse = {
workspace: Workspace;
documents: DocumentRecord[];
};
type Session = {
user: User;
};
const sessionKey = "mdhub.admin.session";
export function App() {
const [session, setSession] = useState<Session | null>(() => readSession());
const [users, setUsers] = useState<User[]>([]);
const [workspace, setWorkspace] = useState<WorkspaceResponse | null>(null);
const [status, setStatus] = useState("Idle");
async function refresh() {
if (!session) {
return;
}
setStatus("Refreshing");
const [usersResponse, workspaceResponse] = await Promise.all([
api<{ users: User[] }>("/api/admin/users"),
api<WorkspaceResponse>("/api/admin/workspace"),
]);
setUsers(usersResponse.users);
setWorkspace(workspaceResponse);
setStatus("Current");
}
useEffect(() => {
void refresh();
}, [session?.user.email]);
async function handleLogin(email: string, displayName: string) {
const response = await api<{ user: User }>("/api/admin/login", {
method: "POST",
body: JSON.stringify({ email, displayName, role: "admin" }),
});
const next = { user: response.user };
localStorage.setItem(sessionKey, JSON.stringify(next));
setSession(next);
}
async function handleCreateUser(input: {
email: string;
displayName: string;
role: Role;
}) {
await api<{ user: User }>("/api/admin/users", {
method: "POST",
body: JSON.stringify(input),
});
await refresh();
}
async function handleSync() {
setStatus("Syncing");
await api("/api/admin/workspace/sync", { method: "POST" });
await refresh();
}
function logout() {
localStorage.removeItem(sessionKey);
setSession(null);
setUsers([]);
setWorkspace(null);
setStatus("Idle");
}
if (!session) {
return <LoginScreen onLogin={handleLogin} />;
}
return (
<main class="admin-shell">
<aside class="admin-sidebar">
<a class="brand" href="/docs">
<Shield size={20} />
<span>MD Hub Secure</span>
</a>
<nav>
<a class="is-active" href="#workspace">
<FolderGit2 size={17} />
<span>Workspace</span>
</a>
<a href="#users">
<Users size={17} />
<span>Users</span>
</a>
</nav>
<button class="ghost-button" type="button" onClick={logout}>
<LogOut size={16} />
<span>Sign out</span>
</button>
</aside>
<section class="admin-main">
<header class="admin-topbar">
<div>
<p class="eyebrow">Admin</p>
<h1>Workspace control</h1>
</div>
<div class="topbar-actions">
<span class="status-pill">
<Activity size={14} />
{status}
</span>
<button type="button" class="primary-button" onClick={handleSync}>
<RefreshCw size={16} />
Sync
</button>
</div>
</header>
<WorkspacePanel data={workspace} />
<UsersPanel users={users} onCreateUser={handleCreateUser} />
</section>
</main>
);
}
function LoginScreen(props: {
onLogin: (email: string, displayName: string) => Promise<void>;
}) {
const [email, setEmail] = useState("admin@example.com");
const [displayName, setDisplayName] = useState("Admin");
const [error, setError] = useState("");
async function submit(event: Event) {
event.preventDefault();
setError("");
try {
await props.onLogin(email, displayName);
} catch (cause) {
setError(cause instanceof Error ? cause.message : "Login failed");
}
}
return (
<main class="login-shell">
<form class="login-panel" onSubmit={submit}>
<div class="login-mark">
<Shield size={22} />
</div>
<h1>Admin login</h1>
<label>
Email
<input
type="email"
value={email}
onInput={(event) => setEmail(event.currentTarget.value)}
required
/>
</label>
<label>
Display name
<input
type="text"
value={displayName}
onInput={(event) => setDisplayName(event.currentTarget.value)}
required
/>
</label>
{error && <p class="form-error">{error}</p>}
<button type="submit" class="primary-button">
<LogIn size={16} />
Sign in
</button>
</form>
</main>
);
}
function WorkspacePanel(props: { data: WorkspaceResponse | null }) {
const workspace = props.data?.workspace;
return (
<section id="workspace" class="panel">
<div class="panel-header">
<div>
<p class="eyebrow">Workspace</p>
<h2>Storage and documents</h2>
</div>
<Database size={19} />
</div>
<div class="metric-grid">
<Metric label="Documents" value={workspace?.documentCount ?? 0} />
<Metric label="Users" value={workspace?.userCount ?? 0} />
<Metric label="Database" value={workspace?.databasePath ?? "Loading"} />
<Metric label="Content" value={workspace?.sourceDir ?? "Loading"} />
</div>
<div class="document-table">
<div class="table-row table-row--head">
<span>Document</span>
<span>Hash</span>
<span>Updated</span>
</div>
{(props.data?.documents ?? []).map((document) => (
<div class="table-row" key={document.path}>
<a href={`/docs/${document.path.replace(/\.md$/, "")}`}>
{document.title}
</a>
<code>{document.currentHash.slice(0, 12)}</code>
<span>{formatDate(document.updatedAt)}</span>
</div>
))}
</div>
</section>
);
}
function UsersPanel(props: {
users: User[];
onCreateUser: (input: {
email: string;
displayName: string;
role: Role;
}) => Promise<void>;
}) {
const [email, setEmail] = useState("");
const [displayName, setDisplayName] = useState("");
const [role, setRole] = useState<Role>("viewer");
async function submit(event: Event) {
event.preventDefault();
await props.onCreateUser({ email, displayName, role });
setEmail("");
setDisplayName("");
setRole("viewer");
}
return (
<section id="users" class="panel">
<div class="panel-header">
<div>
<p class="eyebrow">Users</p>
<h2>Accounts and roles</h2>
</div>
<Users size={19} />
</div>
<form class="user-form" onSubmit={submit}>
<input
type="email"
placeholder="email@example.com"
value={email}
onInput={(event) => setEmail(event.currentTarget.value)}
required
/>
<input
type="text"
placeholder="Display name"
value={displayName}
onInput={(event) => setDisplayName(event.currentTarget.value)}
required
/>
<select
value={role}
onChange={(event) => setRole(event.currentTarget.value as Role)}
>
<option value="viewer">Viewer</option>
<option value="editor">Editor</option>
<option value="admin">Admin</option>
</select>
<button type="submit" class="primary-button">
<UserPlus size={16} />
Add
</button>
</form>
<div class="user-list">
{props.users.map((user) => (
<article class="user-row" key={user.id}>
<div>
<strong>{user.displayName}</strong>
<span>{user.email}</span>
</div>
<span class="role-pill">{user.role}</span>
</article>
))}
</div>
</section>
);
}
function Metric(props: { label: string; value: string | number }) {
return (
<article class="metric">
<span>{props.label}</span>
<strong>{props.value}</strong>
</article>
);
}
async function api<T = unknown>(path: string, init?: RequestInit): Promise<T> {
const response = await fetch(path, {
...init,
headers: {
"Content-Type": "application/json",
...(init?.headers ?? {}),
},
});
if (!response.ok) {
const text = await response.text();
throw new Error(text || response.statusText);
}
return response.json() as Promise<T>;
}
function readSession(): Session | null {
try {
const raw = localStorage.getItem(sessionKey);
return raw ? (JSON.parse(raw) as Session) : null;
} catch {
return null;
}
}
function formatDate(value: string) {
if (!value) {
return "";
}
return new Intl.DateTimeFormat(undefined, {
dateStyle: "medium",
timeStyle: "short",
}).format(new Date(value));
}