diff options
| author | benj <benj@rse8.com> | 2026-04-10 11:13:34 +0800 |
|---|---|---|
| committer | benj <benj@rse8.com> | 2026-04-10 11:13:34 +0800 |
| commit | 493746b14c1251a45b061d2e3edd9160c929d2b9 (patch) | |
| tree | 1607cceb94c1aac1a17a01bb5c0d71b97342e892 /web/ui/src/routes/dashboard | |
| parent | c041641634650c31e03c70dcad132fd94cb08e63 (diff) | |
| download | tidyindex-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.ts | 12 | ||||
| -rw-r--r-- | web/ui/src/routes/dashboard/+layout.svelte | 90 | ||||
| -rw-r--r-- | web/ui/src/routes/dashboard/+page.server.ts | 7 | ||||
| -rw-r--r-- | web/ui/src/routes/dashboard/account/+page.server.ts | 107 | ||||
| -rw-r--r-- | web/ui/src/routes/dashboard/account/+page.svelte | 238 | ||||
| -rw-r--r-- | web/ui/src/routes/dashboard/keys/+page.server.ts | 68 | ||||
| -rw-r--r-- | web/ui/src/routes/dashboard/keys/+page.svelte | 220 | ||||
| -rw-r--r-- | web/ui/src/routes/dashboard/usage/+page.server.ts | 13 | ||||
| -rw-r--r-- | web/ui/src/routes/dashboard/usage/+page.svelte | 152 |
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 Index</span> + </a> + + <div class="app-header-meta"> + <span class="plan-chip"> + plan: <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 · {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. + <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 <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">§ {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 · 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> {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 · 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 · 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> 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> |
