aboutsummaryrefslogtreecommitdiff
path: root/web/ui/src/routes/dashboard
diff options
context:
space:
mode:
authorbenj <benj@rse8.com>2026-04-10 11:13:34 +0800
committerbenj <benj@rse8.com>2026-04-10 11:13:34 +0800
commit493746b14c1251a45b061d2e3edd9160c929d2b9 (patch)
tree1607cceb94c1aac1a17a01bb5c0d71b97342e892 /web/ui/src/routes/dashboard
parentc041641634650c31e03c70dcad132fd94cb08e63 (diff)
downloadtidyindex-493746b14c1251a45b061d2e3edd9160c929d2b9.tar
tidyindex-493746b14c1251a45b061d2e3edd9160c929d2b9.tar.gz
tidyindex-493746b14c1251a45b061d2e3edd9160c929d2b9.tar.bz2
tidyindex-493746b14c1251a45b061d2e3edd9160c929d2b9.tar.lz
tidyindex-493746b14c1251a45b061d2e3edd9160c929d2b9.tar.xz
tidyindex-493746b14c1251a45b061d2e3edd9160c929d2b9.tar.zst
tidyindex-493746b14c1251a45b061d2e3edd9160c929d2b9.zip
a basic ui and landing web interface for tidyindex.com
Diffstat (limited to 'web/ui/src/routes/dashboard')
-rw-r--r--web/ui/src/routes/dashboard/+layout.server.ts12
-rw-r--r--web/ui/src/routes/dashboard/+layout.svelte90
-rw-r--r--web/ui/src/routes/dashboard/+page.server.ts7
-rw-r--r--web/ui/src/routes/dashboard/account/+page.server.ts107
-rw-r--r--web/ui/src/routes/dashboard/account/+page.svelte238
-rw-r--r--web/ui/src/routes/dashboard/keys/+page.server.ts68
-rw-r--r--web/ui/src/routes/dashboard/keys/+page.svelte220
-rw-r--r--web/ui/src/routes/dashboard/usage/+page.server.ts13
-rw-r--r--web/ui/src/routes/dashboard/usage/+page.svelte152
9 files changed, 907 insertions, 0 deletions
diff --git a/web/ui/src/routes/dashboard/+layout.server.ts b/web/ui/src/routes/dashboard/+layout.server.ts
new file mode 100644
index 0000000..8eebc76
--- /dev/null
+++ b/web/ui/src/routes/dashboard/+layout.server.ts
@@ -0,0 +1,12 @@
+import { redirect } from '@sveltejs/kit';
+
+import type { LayoutServerLoad } from './$types';
+
+export const load: LayoutServerLoad = async ({ locals }) => {
+ if (!locals.account) {
+ throw redirect(303, '/');
+ }
+ return {
+ account: locals.account
+ };
+};
diff --git a/web/ui/src/routes/dashboard/+layout.svelte b/web/ui/src/routes/dashboard/+layout.svelte
new file mode 100644
index 0000000..9137c75
--- /dev/null
+++ b/web/ui/src/routes/dashboard/+layout.svelte
@@ -0,0 +1,90 @@
+<script lang="ts">
+ import { page } from '$app/stores';
+ import BrandMark from '$lib/components/BrandMark.svelte';
+ import Footer from '$lib/components/Footer.svelte';
+ import { PLANS } from '$lib/plans';
+
+ let { data, children } = $props();
+
+ const tabs = [
+ { id: '01', slug: 'keys', label: 'Keys' },
+ { id: '02', slug: 'usage', label: 'Usage' },
+ { id: '03', slug: 'account', label: 'Account' }
+ ];
+
+ let currentPath = $derived($page.url.pathname);
+ let account = $derived(data.account);
+ let planInfo = $derived(account ? PLANS[account.plan] : PLANS.free);
+</script>
+
+<svelte:head>
+ <title>Tidy Index — Dashboard</title>
+</svelte:head>
+
+<header class="app-header">
+ <div class="container app-header-inner">
+ <a href="/dashboard/keys" class="brand" aria-label="Tidy Index dashboard home">
+ <BrandMark />
+ <span>Tidy&nbsp;Index</span>
+ </a>
+
+ <div class="app-header-meta">
+ <span class="plan-chip">
+ plan:&nbsp;<strong>{planInfo.name.toLowerCase()}</strong>
+ </span>
+ {#if account?.email}
+ <span title={account.email}>{account.email}</span>
+ {:else if account?.pending_email}
+ <span class="pending-chip" title="Sign-in link sent — awaiting verification">
+ pending&nbsp;·&nbsp;{account.pending_email}
+ </span>
+ {:else}
+ <span class="text-mute">anonymous</span>
+ {/if}
+ </div>
+ </div>
+</header>
+
+<div class="container">
+ {#if !account?.email && !account?.pending_email}
+ <div class="banner">
+ <span>
+ You're using an anonymous session.&nbsp;
+ <strong>Sign in with an email so you don't lose access to your keys.</strong>
+ </span>
+ <a href="/dashboard/account?focus=email" class="btn btn-sm btn-ghost">Sign in</a>
+ </div>
+ {:else if account?.pending_email && !account?.email}
+ <div class="banner">
+ <span>
+ We sent a sign-in link to&nbsp;<strong>{account.pending_email}</strong>.
+ Check your inbox to finish signing in.
+ </span>
+ <a href="/dashboard/account" class="btn btn-sm btn-ghost">Manage</a>
+ </div>
+ {/if}
+
+ <nav class="tabs" aria-label="Dashboard sections">
+ <div class="tabs-inner">
+ {#each tabs as tab}
+ {@const active = currentPath.startsWith(`/dashboard/${tab.slug}`)}
+ <a
+ href="/dashboard/{tab.slug}"
+ class="tab {active ? 'active' : ''}"
+ aria-current={active ? 'page' : undefined}
+ >
+ <span class="tab-num">§&nbsp;{tab.id}</span>
+ <span>{tab.label}</span>
+ </a>
+ {/each}
+ </div>
+ </nav>
+</div>
+
+<main class="app-main">
+ <div class="container">
+ {@render children()}
+ </div>
+</main>
+
+<Footer />
diff --git a/web/ui/src/routes/dashboard/+page.server.ts b/web/ui/src/routes/dashboard/+page.server.ts
new file mode 100644
index 0000000..32e2307
--- /dev/null
+++ b/web/ui/src/routes/dashboard/+page.server.ts
@@ -0,0 +1,7 @@
+import { redirect } from '@sveltejs/kit';
+
+import type { PageServerLoad } from './$types';
+
+export const load: PageServerLoad = async () => {
+ throw redirect(303, '/dashboard/keys');
+};
diff --git a/web/ui/src/routes/dashboard/account/+page.server.ts b/web/ui/src/routes/dashboard/account/+page.server.ts
new file mode 100644
index 0000000..5581b52
--- /dev/null
+++ b/web/ui/src/routes/dashboard/account/+page.server.ts
@@ -0,0 +1,107 @@
+import { fail, redirect, type Actions } from '@sveltejs/kit';
+
+import { queries } from '$lib/server/db';
+import {
+ attachEmailToAccount,
+ clearSession,
+ createSession
+} from '$lib/server/auth';
+import { PLANS, PLAN_ORDER, type PlanId } from '$lib/plans';
+import type { PageServerLoad } from './$types';
+
+export const load: PageServerLoad = async ({ locals }) => {
+ const account = locals.account!;
+ const keys = queries.keysForAccount.all(account.id);
+ return {
+ account,
+ keyCount: keys.length,
+ plans: PLAN_ORDER.map((id) => PLANS[id]),
+ currentPlan: account.plan
+ };
+};
+
+export const actions: Actions = {
+ /**
+ * Anonymous → pending. The user has typed an email; we save it as
+ * pending_email and (eventually) send a magic link. Email sending is
+ * not wired up yet — the UI just transitions to the pending state.
+ */
+ requestSignInLink: async ({ request, locals }) => {
+ const account = locals.account!;
+ const form = await request.formData();
+ const email = ((form.get('email') ?? '') as string).trim().toLowerCase();
+
+ if (!email || !email.includes('@') || email.length > 254) {
+ return fail(400, { error: 'Enter a valid email address.' });
+ }
+
+ queries.setPendingEmail.run(email, account.id);
+ return { linkSent: true, email };
+ },
+
+ /** Pending → anonymous. User abandons the verification. */
+ cancelPendingSignIn: async ({ locals }) => {
+ const account = locals.account!;
+ queries.clearPendingEmail.run(account.id);
+ return { cancelled: true };
+ },
+
+ /**
+ * Pending → signed in. Dev-only shortcut so the signed-in UI is
+ * reachable without a working magic-link verification flow. Will be
+ * removed once /auth/callback promotes pending_email itself.
+ *
+ * Goes through attachEmailToAccount so we get the same conflict
+ * handling as real verification: if another account already owns the
+ * email, current account merges into it (keys move over, current is
+ * deleted, session cookie reissued).
+ */
+ markVerified: async ({ locals, cookies }) => {
+ const account = locals.account!;
+ if (!account.pending_email) {
+ return fail(400, { error: 'No pending email to verify.' });
+ }
+ try {
+ const result = attachEmailToAccount(account.id, account.pending_email);
+ if (result.accountId === account.id) {
+ // Email attached cleanly to this account — clear pending state.
+ queries.clearPendingEmail.run(account.id);
+ } else {
+ // Merged into a pre-existing account that already owned this
+ // email. The current account is gone; reissue the cookie so
+ // subsequent requests load the surviving account.
+ createSession(cookies, result.accountId);
+ }
+ return { verified: true, merged: result.accountId !== account.id };
+ } catch (e) {
+ return fail(400, {
+ error: e instanceof Error ? e.message : 'Could not verify.'
+ });
+ }
+ },
+
+ switchPlan: async ({ request, locals }) => {
+ const account = locals.account!;
+ const form = await request.formData();
+ const planId = ((form.get('plan') ?? '') as string) as PlanId;
+
+ if (!PLAN_ORDER.includes(planId)) {
+ return fail(400, { error: 'Unknown plan' });
+ }
+ if (planId === 'enterprise') {
+ return fail(400, {
+ error: 'Enterprise is contact-only. Email contact@tidyindex.com.'
+ });
+ }
+
+ queries.updateAccountPlan.run(planId, account.id);
+ return { switchedTo: planId };
+ },
+
+ deleteAccount: async ({ locals, cookies }) => {
+ const account = locals.account!;
+ queries.deleteAccount.run(account.id);
+ clearSession(cookies);
+ throw redirect(303, '/');
+ }
+};
diff --git a/web/ui/src/routes/dashboard/account/+page.svelte b/web/ui/src/routes/dashboard/account/+page.svelte
new file mode 100644
index 0000000..287c210
--- /dev/null
+++ b/web/ui/src/routes/dashboard/account/+page.svelte
@@ -0,0 +1,238 @@
+<script lang="ts">
+ import { tick } from 'svelte';
+ import { enhance } from '$app/forms';
+ import { afterNavigate, invalidateAll } from '$app/navigation';
+ import { pushToast } from '$lib/stores/toasts';
+ import { PLANS } from '$lib/plans';
+ import type { PageData, ActionData } from './$types';
+
+ let { data, form }: { data: PageData; form: ActionData } = $props();
+
+ let confirmDelete = $state(false);
+
+ // When arriving via "Sign in" (which links to .../account?focus=email),
+ // drop the cursor straight into the email field. preventScroll keeps the
+ // page from jumping when focus() is called.
+ afterNavigate(async (nav) => {
+ if (nav.to?.url.searchParams.get('focus') !== 'email') return;
+ await tick();
+ document.getElementById('email')?.focus({ preventScroll: true });
+ });
+
+ $effect(() => {
+ if (!form) return;
+ if ('linkSent' in form && form.linkSent) {
+ pushToast(`Sign-in link sent to ${form.email}`, 'success');
+ invalidateAll();
+ } else if ('cancelled' in form && form.cancelled) {
+ pushToast('Sign-in cancelled', 'success');
+ invalidateAll();
+ } else if ('verified' in form && form.verified) {
+ pushToast('Signed in', 'success');
+ invalidateAll();
+ } else if ('switchedTo' in form && form.switchedTo) {
+ pushToast(`Switched to ${PLANS[form.switchedTo].name}`, 'success');
+ invalidateAll();
+ } else if ('error' in form && form.error) {
+ pushToast(form.error, 'error');
+ }
+ });
+</script>
+
+<p class="section-marker">§ 03 &nbsp;&middot;&nbsp; account</p>
+<h1 class="page-title">Account.</h1>
+<p class="page-subtitle">
+ {#if data.account.email}
+ Manage your sign-in and review what we know about you.
+ {:else if data.account.pending_email}
+ Finish signing in to lock your keys to this email.
+ {:else}
+ Sign in with email so your keys follow you across browsers.
+ {/if}
+</p>
+
+<div class="card">
+ <div class="card-head">
+ <h2 class="card-title">Sign in</h2>
+ </div>
+
+ {#if data.account.email}
+ <!-- Signed in -->
+ <p class="card-sub">
+ Signed in as <strong>{data.account.email}</strong>.
+ </p>
+ <form method="POST" action="/auth/logout" class="mt-16">
+ <button type="submit" class="btn btn-ghost btn-sm">Sign out</button>
+ </form>
+ {:else if data.account.pending_email}
+ <!-- Pending verification -->
+ <p class="card-sub">
+ We sent a sign-in link to
+ <strong>{data.account.pending_email}</strong>. Click it from your inbox
+ to finish signing in.
+ </p>
+ <div class="row mt-16">
+ <form method="POST" action="?/requestSignInLink" use:enhance>
+ <input type="hidden" name="email" value={data.account.pending_email} />
+ <button type="submit" class="btn btn-ghost btn-sm">Resend link</button>
+ </form>
+ <form method="POST" action="?/cancelPendingSignIn" use:enhance>
+ <button type="submit" class="btn btn-ghost btn-sm">Cancel</button>
+ </form>
+ <form method="POST" action="?/markVerified" use:enhance>
+ <button type="submit" class="btn btn-accent btn-sm" title="Dev shortcut: skips real verification">
+ (dev) Mark verified
+ </button>
+ </form>
+ </div>
+ {:else}
+ <!-- Anonymous -->
+ <p class="card-sub">
+ You're using an anonymous session. Sign in with an email so you don't
+ lose access to your keys if you clear cookies or switch browsers.
+ </p>
+ <form method="POST" action="?/requestSignInLink" use:enhance class="mt-16">
+ <div class="field">
+ <label class="field-label" for="email">email address</label>
+ <input
+ id="email"
+ name="email"
+ type="email"
+ class="input"
+ placeholder="you@company.com"
+ required
+ />
+ </div>
+ <button type="submit" class="btn btn-accent btn-sm">
+ Send sign-in link
+ </button>
+ </form>
+ {/if}
+</div>
+
+<div class="card mt-24">
+ <div class="card-head">
+ <h2 class="card-title">Plan</h2>
+ </div>
+ <p class="card-sub">
+ Usage resets on the first of every month. You can change plans any time, and
+ we prorate mid-cycle. (Billing is stubbed in this demo — switching plans just
+ updates the dashboard.)
+ </p>
+
+ <div class="plan-grid mt-16">
+ {#each data.plans as plan}
+ {@const isCurrent = plan.id === data.currentPlan}
+ <div class="plan-card {isCurrent ? 'current' : ''}">
+ {#if isCurrent}
+ <span class="badge">Current</span>
+ {/if}
+
+ <p class="plan-name">{plan.name}</p>
+ <p class="plan-price">
+ {plan.priceLabel}
+ {#if plan.period}<small>&nbsp;{plan.period}</small>{/if}
+ </p>
+
+ <ul class="plan-features">
+ {#each plan.features as f}
+ <li>{f}</li>
+ {/each}
+ </ul>
+
+ <div class="plan-cta">
+ {#if plan.id === 'enterprise'}
+ <a
+ href="mailto:contact@tidyindex.com?subject=Enterprise%20inquiry"
+ class="btn btn-ghost"
+ style="width: 100%;"
+ >
+ Contact us
+ </a>
+ {:else if isCurrent}
+ <button class="btn btn-ghost" style="width: 100%;" disabled>
+ Current plan
+ </button>
+ {:else}
+ <form method="POST" action="?/switchPlan" use:enhance>
+ <input type="hidden" name="plan" value={plan.id} />
+ <button
+ class="btn {plan.id === 'pro' ? 'btn-accent' : 'btn-ghost'}"
+ style="width: 100%;"
+ type="submit"
+ >
+ {plan.cta}
+ </button>
+ </form>
+ {/if}
+ </div>
+ </div>
+ {/each}
+ </div>
+
+ <p class="help mt-24">
+ Need something not listed here — custom datasets, on-prem deployment, higher
+ rate limits? Reply to any email from us, or reach out at
+ <a href="mailto:contact@tidyindex.com">contact@tidyindex.com</a>.
+ </p>
+</div>
+
+<div class="card mt-24">
+ <div class="card-head">
+ <h2 class="card-title">What we know about you</h2>
+ </div>
+ <dl class="usage-table" style="display:block;">
+ <div class="row-between" style="padding:10px 0; border-bottom:1px solid var(--c-rule);">
+ <dt class="text-mono text-mute">account id</dt>
+ <dd class="text-mono" style="margin:0;">{data.account.id}</dd>
+ </div>
+ <div class="row-between" style="padding:10px 0; border-bottom:1px solid var(--c-rule);">
+ <dt class="text-mono text-mute">plan</dt>
+ <dd class="text-mono" style="margin:0;">{data.account.plan}</dd>
+ </div>
+ <div class="row-between" style="padding:10px 0; border-bottom:1px solid var(--c-rule);">
+ <dt class="text-mono text-mute">created</dt>
+ <dd class="text-mono" style="margin:0;">
+ {new Date(data.account.created_at).toLocaleDateString('en-US', {
+ month: 'short',
+ day: 'numeric',
+ year: 'numeric'
+ })}
+ </dd>
+ </div>
+ <div class="row-between" style="padding:10px 0;">
+ <dt class="text-mono text-mute">keys on file</dt>
+ <dd class="text-mono" style="margin:0;">{data.keyCount}</dd>
+ </div>
+ </dl>
+</div>
+
+<div class="danger-zone">
+ <p class="danger-zone-title">Danger zone</p>
+ <p class="danger-zone-body">
+ Deleting your account will revoke every key and permanently erase your
+ usage history. This cannot be undone.
+ </p>
+ {#if confirmDelete}
+ <div class="row">
+ <form method="POST" action="?/deleteAccount" use:enhance>
+ <button type="submit" class="btn btn-danger btn-sm">
+ Yes, delete everything
+ </button>
+ </form>
+ <button
+ class="btn btn-ghost btn-sm"
+ onclick={() => (confirmDelete = false)}
+ >
+ Cancel
+ </button>
+ </div>
+ {:else}
+ <button
+ class="btn btn-danger btn-sm"
+ onclick={() => (confirmDelete = true)}
+ >
+ Delete account
+ </button>
+ {/if}
+</div>
diff --git a/web/ui/src/routes/dashboard/keys/+page.server.ts b/web/ui/src/routes/dashboard/keys/+page.server.ts
new file mode 100644
index 0000000..5491283
--- /dev/null
+++ b/web/ui/src/routes/dashboard/keys/+page.server.ts
@@ -0,0 +1,68 @@
+import { fail, type Actions } from '@sveltejs/kit';
+
+import {
+ createKey,
+ listKeys,
+ revokeKey,
+ countActiveKeys
+} from '$lib/server/keys';
+import { PLANS } from '$lib/plans';
+import type { PageServerLoad } from './$types';
+
+export const load: PageServerLoad = async ({ locals }) => {
+ const account = locals.account!;
+ const keys = listKeys(account.id);
+ return {
+ keys,
+ activeCount: countActiveKeys(account.id),
+ plan: PLANS[account.plan]
+ };
+};
+
+export const actions: Actions = {
+ create: async ({ request, locals }) => {
+ const account = locals.account!;
+ const form = await request.formData();
+ const name = ((form.get('name') ?? '') as string).trim();
+ const scopes = form.getAll('scopes').map((s) => s.toString());
+
+ if (!name) {
+ return fail(400, { error: 'Give the key a name so you can recognize it later.' });
+ }
+
+ const plan = PLANS[account.plan];
+ const active = countActiveKeys(account.id);
+ if (Number.isFinite(plan.maxKeys) && active >= plan.maxKeys) {
+ return fail(403, {
+ error: `Your ${plan.name} plan allows ${plan.maxKeys} active key${
+ plan.maxKeys === 1 ? '' : 's'
+ }. Revoke one or upgrade your plan first.`
+ });
+ }
+
+ const created = createKey({
+ accountId: account.id,
+ name,
+ scopes
+ });
+
+ return {
+ created: {
+ id: created.id,
+ plaintext: created.plaintext,
+ name: created.name
+ }
+ };
+ },
+
+ revoke: async ({ request, locals }) => {
+ const account = locals.account!;
+ const form = await request.formData();
+ const id = (form.get('id') ?? '').toString();
+ if (!id) return fail(400, { error: 'Missing key id.' });
+
+ const ok = revokeKey(account.id, id);
+ if (!ok) return fail(404, { error: 'Key not found.' });
+ return { revokedId: id };
+ }
+};
diff --git a/web/ui/src/routes/dashboard/keys/+page.svelte b/web/ui/src/routes/dashboard/keys/+page.svelte
new file mode 100644
index 0000000..b0a066f
--- /dev/null
+++ b/web/ui/src/routes/dashboard/keys/+page.svelte
@@ -0,0 +1,220 @@
+<script lang="ts">
+ import { enhance } from '$app/forms';
+ import { DATASETS } from '$lib/datasets';
+ import { maskKey, scopeSummary } from '$lib/keys';
+ import { pushToast } from '$lib/stores/toasts';
+ import type { PageData, ActionData } from './$types';
+
+ let { data, form }: { data: PageData; form: ActionData } = $props();
+
+ let showCreate = $state(false);
+ let name = $state('');
+ let selected = $state<Set<string>>(new Set());
+ let allScopes = $state(true);
+
+ function toggleChip(slug: string) {
+ if (allScopes) {
+ allScopes = false;
+ selected = new Set([slug]);
+ return;
+ }
+ const next = new Set(selected);
+ if (next.has(slug)) next.delete(slug);
+ else next.add(slug);
+ if (next.size === 0) {
+ allScopes = true;
+ selected = new Set();
+ } else {
+ selected = next;
+ }
+ }
+
+ function selectAll() {
+ allScopes = true;
+ selected = new Set();
+ }
+
+ function fmtDate(ts: number): string {
+ return new Date(ts).toLocaleDateString('en-US', {
+ month: 'short',
+ day: 'numeric',
+ year: 'numeric'
+ });
+ }
+
+ async function copy(text: string) {
+ try {
+ await navigator.clipboard.writeText(text);
+ pushToast('Copied to clipboard', 'success');
+ } catch {
+ pushToast("Couldn't copy — select it manually", 'error');
+ }
+ }
+
+ // When a new key is returned in `form`, show toast + scroll to top.
+ $effect(() => {
+ if (form && 'created' in form && form.created) {
+ pushToast(`Key "${form.created.name}" created`, 'success');
+ showCreate = false;
+ name = '';
+ selected = new Set();
+ allScopes = true;
+ } else if (form && 'revokedId' in form && form.revokedId) {
+ pushToast('Key revoked', 'success');
+ } else if (form && 'error' in form && form.error) {
+ pushToast(form.error, 'error');
+ }
+ });
+</script>
+
+<p class="section-marker">§ 01 &nbsp;&middot;&nbsp; api keys</p>
+<div class="row-between mb-24">
+ <div>
+ <h1 class="page-title">Your API keys.</h1>
+ <p class="page-subtitle">
+ {data.activeCount} active, {data.keys.length - data.activeCount} revoked. Your
+ {data.plan.name} plan allows
+ {Number.isFinite(data.plan.maxKeys) ? data.plan.maxKeys : 'unlimited'}
+ active key{data.plan.maxKeys === 1 ? '' : 's'}.
+ </p>
+ </div>
+ {#if !showCreate}
+ <button class="btn btn-primary" onclick={() => (showCreate = true)}>
+ + New key
+ </button>
+ {/if}
+</div>
+
+{#if form && 'created' in form && form.created}
+ <div class="key-reveal">
+ <div class="key-reveal-head">
+ <span class="badge">New key</span>
+ <strong>Copy this key now — it won't be shown again.</strong>
+ </div>
+ <div class="key-reveal-value">
+ <span style="flex: 1;">{form.created.plaintext}</span>
+ <button class="btn btn-sm btn-ghost" onclick={() => copy(form.created!.plaintext)}>
+ Copy
+ </button>
+ </div>
+ <p class="key-reveal-warn">
+ Store it somewhere safe. Once you leave this page we only keep the hash.
+ </p>
+ </div>
+{/if}
+
+{#if showCreate}
+ <form
+ method="POST"
+ action="?/create"
+ class="card card-accent mb-24"
+ use:enhance
+ >
+ <div class="card-head">
+ <h2 class="card-title">New key</h2>
+ <button type="button" class="btn btn-sm btn-ghost" onclick={() => (showCreate = false)}>
+ Cancel
+ </button>
+ </div>
+
+ <div class="field">
+ <label class="field-label" for="name">name</label>
+ <input
+ id="name"
+ name="name"
+ class="input"
+ placeholder="e.g. production ingest"
+ bind:value={name}
+ required
+ />
+ <p class="help">Just for you — how you'll recognize this key in the list.</p>
+ </div>
+
+ <div class="field">
+ <span class="field-label">dataset scope</span>
+ <div class="chip-grid">
+ <button
+ type="button"
+ class="chip chip-all {allScopes ? 'active' : ''}"
+ onclick={selectAll}
+ >
+ all datasets
+ </button>
+ {#each DATASETS as slug}
+ <button
+ type="button"
+ class="chip {selected.has(slug) ? 'active' : ''}"
+ onclick={() => toggleChip(slug)}
+ >
+ {slug}
+ </button>
+ {/each}
+ </div>
+ <p class="help">
+ {#if allScopes}
+ This key will work against every dataset in the catalog.
+ {:else}
+ This key will only work against the {selected.size} selected dataset{selected.size === 1 ? '' : 's'}.
+ {/if}
+ </p>
+ </div>
+
+ {#if allScopes}
+ <input type="hidden" name="scopes" value="*" />
+ {:else}
+ {#each [...selected] as slug}
+ <input type="hidden" name="scopes" value={slug} />
+ {/each}
+ {/if}
+
+ <div class="row mt-16">
+ <button type="submit" class="btn btn-accent">Create key</button>
+ <button type="button" class="btn btn-ghost" onclick={() => (showCreate = false)}>
+ Cancel
+ </button>
+ </div>
+ </form>
+{/if}
+
+{#if data.keys.length === 0}
+ <div class="empty-state">
+ <p class="empty-title">No keys yet.</p>
+ <p>Click "+ New key" to create your first one.</p>
+ </div>
+{:else}
+ {#each data.keys as key (key.id)}
+ <div class="card">
+ <div class="key-row">
+ <div class="key-row-main">
+ <div class="key-name">
+ {key.name}
+ {#if key.active}
+ <span class="badge badge-success">Active</span>
+ {:else}
+ <span class="badge badge-muted">Revoked</span>
+ {/if}
+ </div>
+ <div class="key-mask">{maskKey(key.key_prefix)}</div>
+ <div class="scope-summary">
+ scope: <code>{scopeSummary(key.scopes)}</code>
+ </div>
+ <div class="key-meta">
+ <span><span class="meta-label">created</span> {fmtDate(key.created_at)}</span>
+ {#if key.last_used_at}
+ <span><span class="meta-label">last used</span> {fmtDate(key.last_used_at)}</span>
+ {:else}
+ <span><span class="meta-label">last used</span> never</span>
+ {/if}
+ </div>
+ </div>
+
+ {#if key.active}
+ <form method="POST" action="?/revoke" use:enhance>
+ <input type="hidden" name="id" value={key.id} />
+ <button class="btn btn-sm btn-danger" type="submit">Revoke</button>
+ </form>
+ {/if}
+ </div>
+ </div>
+ {/each}
+{/if}
diff --git a/web/ui/src/routes/dashboard/usage/+page.server.ts b/web/ui/src/routes/dashboard/usage/+page.server.ts
new file mode 100644
index 0000000..34ff002
--- /dev/null
+++ b/web/ui/src/routes/dashboard/usage/+page.server.ts
@@ -0,0 +1,13 @@
+import { PLANS } from '$lib/plans';
+import { usageByDataset, usageByKey, usageCountThisMonth } from '$lib/server/usage';
+import type { PageServerLoad } from './$types';
+
+export const load: PageServerLoad = async ({ locals }) => {
+ const account = locals.account!;
+ return {
+ plan: PLANS[account.plan],
+ total: usageCountThisMonth(account.id),
+ byDataset: usageByDataset(account.id),
+ byKey: usageByKey(account.id)
+ };
+};
diff --git a/web/ui/src/routes/dashboard/usage/+page.svelte b/web/ui/src/routes/dashboard/usage/+page.svelte
new file mode 100644
index 0000000..883460c
--- /dev/null
+++ b/web/ui/src/routes/dashboard/usage/+page.svelte
@@ -0,0 +1,152 @@
+<script lang="ts">
+ import { pushToast } from '$lib/stores/toasts';
+ import type { PageData } from './$types';
+
+ let { data }: { data: PageData } = $props();
+
+ const monthName = new Date().toLocaleDateString('en-US', {
+ month: 'long',
+ year: 'numeric'
+ });
+
+ function fmt(n: number): string {
+ return n.toLocaleString('en-US');
+ }
+
+ const curlExample = `curl https://api.tidyindex.com/v1/datasets/irs-990/records/20-0049703 \\
+ -H "Authorization: Bearer YOUR_API_KEY"`;
+
+ async function copyCurl() {
+ try {
+ await navigator.clipboard.writeText(curlExample);
+ pushToast('Copied', 'success');
+ } catch {
+ pushToast("Couldn't copy", 'error');
+ }
+ }
+
+ let pct = $derived.by(() => {
+ if (!Number.isFinite(data.plan.requestsPerMonth)) return 0;
+ const p = (data.total / data.plan.requestsPerMonth) * 100;
+ return Math.min(100, Math.max(0, p));
+ });
+
+ let limitLabel = $derived(
+ Number.isFinite(data.plan.requestsPerMonth)
+ ? fmt(data.plan.requestsPerMonth)
+ : 'unlimited'
+ );
+</script>
+
+<p class="section-marker">§ 02 &nbsp;&middot;&nbsp; usage</p>
+<h1 class="page-title">This month.</h1>
+<p class="page-subtitle">
+ Requests counted against your plan limit for {monthName}.
+</p>
+
+<div class="card">
+ <div class="usage-summary">
+ <div>
+ <p class="text-mono text-mute" style="margin:0 0 6px;">
+ requests / {limitLabel}
+ </p>
+ <p class="usage-count">
+ {fmt(data.total)}
+ {#if Number.isFinite(data.plan.requestsPerMonth)}
+ <small>&nbsp;of {limitLabel}</small>
+ {/if}
+ </p>
+ </div>
+ <div>
+ <span class="badge">{data.plan.name.toLowerCase()} plan</span>
+ </div>
+ </div>
+ {#if Number.isFinite(data.plan.requestsPerMonth)}
+ <div class="usage-bar" aria-label="Usage bar">
+ <div class="usage-bar-fill" style="width: {pct}%"></div>
+ </div>
+ {/if}
+</div>
+
+<div class="card mt-24">
+ <div class="card-head">
+ <h2 class="card-title">By dataset</h2>
+ <p class="card-sub">Which datasets drew the most traffic.</p>
+ </div>
+ {#if data.byDataset.length === 0}
+ <div class="empty-state">
+ <p class="empty-title">No requests yet this month.</p>
+ <p>Usage data will show up here once your keys start making calls.</p>
+ </div>
+ {:else}
+ <table class="usage-table">
+ <thead>
+ <tr>
+ <th>dataset</th>
+ <th style="text-align:right;">requests</th>
+ <th style="text-align:right; width: 140px;">share</th>
+ </tr>
+ </thead>
+ <tbody>
+ {#each data.byDataset as row}
+ <tr>
+ <td>{row.dataset}</td>
+ <td class="num">{fmt(row.count)}</td>
+ <td class="num">
+ {data.total > 0
+ ? ((row.count / data.total) * 100).toFixed(1)
+ : '0.0'}%
+ </td>
+ </tr>
+ {/each}
+ </tbody>
+ </table>
+ {/if}
+</div>
+
+<div class="card mt-24">
+ <div class="card-head">
+ <h2 class="card-title">By key</h2>
+ <p class="card-sub">Traffic attributed to each of your keys.</p>
+ </div>
+ {#if data.byKey.length === 0}
+ <div class="empty-state">
+ <p class="empty-title">No keys yet.</p>
+ <p>Create one on the Keys tab.</p>
+ </div>
+ {:else}
+ <table class="usage-table">
+ <thead>
+ <tr>
+ <th>key</th>
+ <th style="text-align:right;">requests</th>
+ </tr>
+ </thead>
+ <tbody>
+ {#each data.byKey as row}
+ <tr>
+ <td>{row.name}</td>
+ <td class="num">{fmt(row.count)}</td>
+ </tr>
+ {/each}
+ </tbody>
+ </table>
+ {/if}
+</div>
+
+<div class="card mt-24">
+ <div class="card-head">
+ <h2 class="card-title">API base URL</h2>
+ </div>
+ <p class="card-sub">All endpoints live under this host.</p>
+ <pre class="code-block mt-16">https://api.tidyindex.com/v1</pre>
+</div>
+
+<div class="card mt-24">
+ <div class="card-head">
+ <h2 class="card-title">Quick start</h2>
+ <button class="btn btn-sm btn-ghost" onclick={copyCurl}>Copy curl</button>
+ </div>
+ <p class="card-sub">Fetch one record from the IRS 990 dataset.</p>
+ <pre class="code-block mt-16">{curlExample}</pre>
+</div>