feat: add error boundaries, loading skeletons, page transitions, and empty states

- ErrorBoundary: global error boundary with ShieldAI branding, retry/report
- Skeleton: SkeletonText, SkeletonCard, SkeletonAvatar, SkeletonTable
- PageTransition: fade-in + translate-y on route change, respects reduced motion
- EmptyState: reusable component with icon, title, description, action
- Button: add ariaLabel prop support
- Toast: add aria-live=polite region
- Dashboard: replace pulse divs with SkeletonCard fallbacks
- Service pages: add skeleton layouts, empty states, mutation loading states
- 404 page: polished with ShieldAI branding and home navigation
- app.tsx: add ErrorBoundary, PageTransition, skip-to-content link
- app.css: add page-enter animation with prefers-reduced-motion support
This commit is contained in:
2026-05-25 18:05:29 -04:00
parent c02457c66a
commit 20dc5bf785
18 changed files with 771 additions and 179 deletions

View File

@@ -1,12 +1,22 @@
import { createSignal, createResource, For, Show } from "solid-js";
import { createSignal, createResource, For, Show, Suspense } from "solid-js";
import { Title } from "@solidjs/meta";
import { Sidebar, TopBar } from "~/components/dashboard";
import { Button, Input, Card, Badge } from "~/components/ui";
import { Button, Input, Card, Badge, EmptyState, SkeletonText, SkeletonTable } from "~/components/ui";
import { api } from "~/lib/api";
function WatchlistIcon() {
return (
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
<circle cx="12" cy="12" r="3" />
</svg>
);
}
export default function DarkWatchPage() {
const [sidebarOpen, setSidebarOpen] = createSignal(false);
const [itemValue, setItemValue] = createSignal("");
const [adding, setAdding] = createSignal(false);
const [watchlist, { refetch: refetchWatchlist }] = createResource(
() => api.darkwatch.getWatchlist.query(),
{ initialValue: [] },
@@ -18,10 +28,15 @@ export default function DarkWatchPage() {
async function addItem() {
const val = itemValue().trim();
if (!val) return;
const type = val.includes("@") ? "EMAIL" : "PHONE";
await api.darkwatch.addWatchlistItem.mutate({ type, value: val });
setItemValue("");
refetchWatchlist();
setAdding(true);
try {
const type = val.includes("@") ? "EMAIL" : "PHONE";
await api.darkwatch.addWatchlistItem.mutate({ type, value: val });
setItemValue("");
refetchWatchlist();
} finally {
setAdding(false);
}
}
async function removeItem(itemId: string) {
@@ -35,7 +50,7 @@ export default function DarkWatchPage() {
<Sidebar open={sidebarOpen()} onClose={() => setSidebarOpen(false)} />
<div class="flex-1 flex flex-col min-w-0">
<TopBar onMenuToggle={() => setSidebarOpen(v => !v)} />
<main class="flex-1 overflow-y-auto p-6">
<main id="main-content" class="flex-1 overflow-y-auto p-6">
<div class="max-w-4xl mx-auto">
<h1 class="text-2xl font-bold text-[var(--color-text-primary)] mb-6">DarkWatch</h1>
@@ -47,7 +62,7 @@ export default function DarkWatchPage() {
value={itemValue()}
onInput={(e) => setItemValue(e.currentTarget.value)}
/>
<Button onClick={addItem}>Add</Button>
<Button onClick={addItem} loading={adding()}>Add</Button>
</div>
</Card>
@@ -55,45 +70,60 @@ export default function DarkWatchPage() {
<div class="px-4 py-3 border-b border-[var(--color-border)]/50">
<h2 class="text-sm font-semibold text-[var(--color-text-primary)]">Watchlist</h2>
</div>
<div class="divide-y divide-[var(--color-border)]/50">
<For each={watchlist()}>
{(item: Record<string, unknown>) => (
<div class="px-4 py-3 flex items-center justify-between">
<div>
<p class="text-sm text-[var(--color-text-primary)]">{String(item.value ?? "")}</p>
<p class="text-xs text-[var(--color-text-tertiary)]">{String(item.type ?? "")}</p>
</div>
<Button variant="ghost" size="sm" onClick={() => removeItem(String(item.id))}>
Remove
</Button>
</div>
)}
</For>
</div>
<Suspense fallback={<div class="p-4"><SkeletonTable rows={3} columns={2} /></div>}>
<Show when={watchlist().length === 0} fallback={
<div class="divide-y divide-[var(--color-border)]/50">
<For each={watchlist()}>
{(item: Record<string, unknown>) => (
<div class="px-4 py-3 flex items-center justify-between">
<div>
<p class="text-sm text-[var(--color-text-primary)]">{String(item.value ?? "")}</p>
<p class="text-xs text-[var(--color-text-tertiary)]">{String(item.type ?? "")}</p>
</div>
<Button variant="ghost" size="sm" onClick={() => removeItem(String(item.id))}>
Remove
</Button>
</div>
)}
</For>
</div>
}>
<EmptyState
icon={<WatchlistIcon />}
title="No watchlist items yet"
description="Add an email or phone number to monitor for dark web exposure."
action={{ label: "Add first item", onClick: () => document.querySelector<HTMLInputElement>("input")?.focus() }}
/>
</Show>
</Suspense>
</Card>
<Card>
<div class="px-4 py-3 border-b border-[var(--color-border)]/50">
<h2 class="text-sm font-semibold text-[var(--color-text-primary)]">Recent Exposures</h2>
</div>
<div class="divide-y divide-[var(--color-border)]/50">
<For each={(exposures()?.items ?? []).slice(0, 10)}>
{(exp: Record<string, unknown>) => (
<div class="px-4 py-3">
<p class="text-sm font-medium text-[var(--color-text-primary)]">{String(exp.title ?? "")}</p>
<p class="text-xs text-[var(--color-text-secondary)]">{String(exp.description ?? "")}</p>
<Badge variant={(String(exp.severity ?? "") === "HIGH" || String(exp.severity ?? "") === "CRITICAL") ? "error" : "warning"}>
{String(exp.severity ?? "")}
</Badge>
</div>
)}
</For>
<Show when={!exposures()?.items?.length}>
<div class="px-4 py-8 text-center text-sm text-[var(--color-text-tertiary)]">
No exposures found
<Suspense fallback={<div class="p-4"><SkeletonText lines={3} /></div>}>
<Show when={(exposures()?.items ?? []).length > 0} fallback={
<EmptyState
title="No exposures found"
description="Your watchlist items haven't appeared in any known data breaches yet."
/>
}>
<div class="divide-y divide-[var(--color-border)]/50">
<For each={(exposures()?.items ?? []).slice(0, 10)}>
{(exp: Record<string, unknown>) => (
<div class="px-4 py-3">
<p class="text-sm font-medium text-[var(--color-text-primary)]">{String(exp.title ?? "")}</p>
<p class="text-xs text-[var(--color-text-secondary)]">{String(exp.description ?? "")}</p>
<Badge variant={(String(exp.severity ?? "") === "HIGH" || String(exp.severity ?? "") === "CRITICAL") ? "error" : "warning"}>
{String(exp.severity ?? "")}
</Badge>
</div>
)}
</For>
</div>
</Show>
</div>
</Suspense>
</Card>
</div>
</main>

View File

@@ -1,6 +1,7 @@
import { createSignal, Suspense } from "solid-js";
import { Title } from "@solidjs/meta";
import { Sidebar, TopBar, ThreatScoreWidget, AlertFeedWidget, ExposureWidget, VoicePrintWidget, SpamShieldWidget, HomeTitleWidget, RemoveBrokersWidget, QuickActionsWidget } from "~/components/dashboard";
import { SkeletonCard } from "~/components/ui";
export default function DashboardPage() {
const [sidebarOpen, setSidebarOpen] = createSignal(false);
@@ -11,38 +12,38 @@ export default function DashboardPage() {
<Sidebar open={sidebarOpen()} onClose={() => setSidebarOpen(false)} />
<div class="flex-1 flex flex-col min-w-0">
<TopBar onMenuToggle={() => setSidebarOpen(v => !v)} />
<main class="flex-1 overflow-y-auto p-6">
<main id="main-content" class="flex-1 overflow-y-auto p-6">
<div class="max-w-7xl mx-auto">
<h1 class="text-2xl font-bold text-[var(--color-text-primary)] mb-6">Dashboard</h1>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<Suspense fallback={<div class="h-64 rounded-xl bg-[var(--color-bg-secondary)] animate-pulse" />}>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6" aria-live="polite" aria-label="Dashboard widgets">
<Suspense fallback={<SkeletonCard class="h-64" />}>
<ThreatScoreWidget />
</Suspense>
<Suspense fallback={<div class="h-64 rounded-xl bg-[var(--color-bg-secondary)] animate-pulse" />}>
<Suspense fallback={<SkeletonCard class="h-64" />}>
<QuickActionsWidget />
</Suspense>
<Suspense fallback={<div class="h-80 rounded-xl bg-[var(--color-bg-secondary)] animate-pulse" />}>
<Suspense fallback={<SkeletonCard class="h-80" />}>
<AlertFeedWidget />
</Suspense>
<div class="space-y-6">
<Suspense fallback={<div class="h-40 rounded-xl bg-[var(--color-bg-secondary)] animate-pulse" />}>
<Suspense fallback={<SkeletonCard class="h-40" />}>
<ExposureWidget />
</Suspense>
<Suspense fallback={<div class="h-40 rounded-xl bg-[var(--color-bg-secondary)] animate-pulse" />}>
<Suspense fallback={<SkeletonCard class="h-40" />}>
<SpamShieldWidget />
</Suspense>
</div>
<Suspense fallback={<div class="h-48 rounded-xl bg-[var(--color-bg-secondary)] animate-pulse" />}>
<Suspense fallback={<SkeletonCard class="h-48" />}>
<VoicePrintWidget />
</Suspense>
<Suspense fallback={<div class="h-48 rounded-xl bg-[var(--color-bg-secondary)] animate-pulse" />}>
<Suspense fallback={<SkeletonCard class="h-48" />}>
<HomeTitleWidget />
</Suspense>
<Suspense fallback={<div class="h-48 rounded-xl bg-[var(--color-bg-secondary)] animate-pulse" />}>
<Suspense fallback={<SkeletonCard class="h-48" />}>
<RemoveBrokersWidget />
</Suspense>
</div>

View File

@@ -1,11 +1,21 @@
import { createSignal, createResource, For, Show } from "solid-js";
import { createSignal, createResource, For, Show, Suspense } from "solid-js";
import { Title } from "@solidjs/meta";
import { Sidebar, TopBar } from "~/components/dashboard";
import { Button, Input, Card } from "~/components/ui";
import { Button, Input, Card, EmptyState, SkeletonTable } from "~/components/ui";
import { api } from "~/lib/api";
function HomeIcon() {
return (
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
<polyline points="9 22 9 12 15 12 15 22" />
</svg>
);
}
export default function HomeTitlePage() {
const [sidebarOpen, setSidebarOpen] = createSignal(false);
const [adding, setAdding] = createSignal(false);
const [address, setAddress] = createSignal("");
const [properties, { refetch }] = createResource(
() => api.hometitle.getProperties.query(),
@@ -13,9 +23,14 @@ export default function HomeTitlePage() {
);
async function addProperty() {
await api.hometitle.addProperty.mutate({ address: address(), parcelId: "", ownerName: "" });
setAddress("");
refetch();
setAdding(true);
try {
await api.hometitle.addProperty.mutate({ address: address(), parcelId: "", ownerName: "" });
setAddress("");
refetch();
} finally {
setAdding(false);
}
}
async function removeProperty(propertyId: string) {
@@ -29,7 +44,7 @@ export default function HomeTitlePage() {
<Sidebar open={sidebarOpen()} onClose={() => setSidebarOpen(false)} />
<div class="flex-1 flex flex-col min-w-0">
<TopBar onMenuToggle={() => setSidebarOpen(v => !v)} />
<main class="flex-1 overflow-y-auto p-6">
<main id="main-content" class="flex-1 overflow-y-auto p-6">
<div class="max-w-4xl mx-auto">
<h1 class="text-2xl font-bold text-[var(--color-text-primary)] mb-6">HomeTitle</h1>
@@ -41,7 +56,7 @@ export default function HomeTitlePage() {
value={address()}
onInput={(e) => setAddress(e.currentTarget.value)}
/>
<Button onClick={addProperty}>Add</Button>
<Button onClick={addProperty} loading={adding()}>Add</Button>
</div>
</Card>
@@ -49,23 +64,29 @@ export default function HomeTitlePage() {
<div class="px-4 py-3 border-b border-[var(--color-border)]/50">
<h2 class="text-sm font-semibold text-[var(--color-text-primary)]">Monitored Properties</h2>
</div>
<div class="divide-y divide-[var(--color-border)]/50">
<For each={properties()}>
{(prop: Record<string, unknown>) => (
<div class="px-4 py-3 flex items-center justify-between">
<p class="text-sm text-[var(--color-text-primary)]">{String(prop.address ?? "")}</p>
<Button variant="ghost" size="sm" onClick={() => removeProperty(String(prop.id))}>
Remove
</Button>
</div>
)}
</For>
<Show when={properties().length === 0}>
<div class="px-4 py-8 text-center text-sm text-[var(--color-text-tertiary)]">
No properties monitored
<Suspense fallback={<div class="p-4"><SkeletonTable rows={4} columns={2} /></div>}>
<Show when={properties().length > 0} fallback={
<EmptyState
icon={<HomeIcon />}
title="No properties monitored"
description="Add a property address to monitor for title fraud and ownership changes."
action={{ label: "Add property", onClick: () => document.querySelector<HTMLInputElement>("input")?.focus() }}
/>
}>
<div class="divide-y divide-[var(--color-border)]/50">
<For each={properties()}>
{(prop: Record<string, unknown>) => (
<div class="px-4 py-3 flex items-center justify-between">
<p class="text-sm text-[var(--color-text-primary)]">{String(prop.address ?? "")}</p>
<Button variant="ghost" size="sm" onClick={() => removeProperty(String(prop.id))}>
Remove
</Button>
</div>
)}
</For>
</div>
</Show>
</div>
</Suspense>
</Card>
</div>
</main>

View File

@@ -1,9 +1,19 @@
import { createSignal, createResource, For, Show } from "solid-js";
import { createSignal, createResource, For, Show, Suspense } from "solid-js";
import { Title } from "@solidjs/meta";
import { Sidebar, TopBar } from "~/components/dashboard";
import { Button, Card } from "~/components/ui";
import { Button, Card, EmptyState, SkeletonCard, SkeletonTable } from "~/components/ui";
import { api } from "~/lib/api";
function BrokerIcon() {
return (
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10" />
<path d="M8 12h8" />
<path d="M12 8v8" />
</svg>
);
}
export default function RemoveBrokersPage() {
const [sidebarOpen, setSidebarOpen] = createSignal(false);
const [brokers] = createResource(
@@ -31,70 +41,86 @@ export default function RemoveBrokersPage() {
<Sidebar open={sidebarOpen()} onClose={() => setSidebarOpen(false)} />
<div class="flex-1 flex flex-col min-w-0">
<TopBar onMenuToggle={() => setSidebarOpen(v => !v)} />
<main class="flex-1 overflow-y-auto p-6">
<main id="main-content" class="flex-1 overflow-y-auto p-6">
<div class="max-w-4xl mx-auto">
<h1 class="text-2xl font-bold text-[var(--color-text-primary)] mb-6">RemoveBrokers</h1>
<Show when={stats()}>
<div class="grid grid-cols-3 gap-4 mb-6">
<Card class="p-4 text-center">
<p class="text-2xl font-bold text-[var(--color-brand-primary)]">
{String((stats() as Record<string, unknown>)?.totalRequests ?? 0)}
</p>
<p class="text-xs text-[var(--color-text-tertiary)]">Total Requests</p>
</Card>
<Card class="p-4 text-center">
<p class="text-2xl font-bold text-[var(--color-success)]">
{String((stats() as Record<string, unknown>)?.completedRequests ?? 0)}
</p>
<p class="text-xs text-[var(--color-text-tertiary)]">Completed</p>
</Card>
<Card class="p-4 text-center">
<p class="text-2xl font-bold text-[var(--color-warning)]">
{String((stats() as Record<string, unknown>)?.pendingRequests ?? 0)}
</p>
<p class="text-xs text-[var(--color-text-tertiary)]">Pending</p>
</Card>
</div>
</Show>
<Suspense fallback={<div class="grid grid-cols-3 gap-4 mb-6"><SkeletonCard class="h-24" /><SkeletonCard class="h-24" /><SkeletonCard class="h-24" /></div>}>
<Show when={stats()}>
<div class="grid grid-cols-3 gap-4 mb-6">
<Card class="p-4 text-center">
<p class="text-2xl font-bold text-[var(--color-brand-primary)]">
{String((stats() as Record<string, unknown>)?.totalRequests ?? 0)}
</p>
<p class="text-xs text-[var(--color-text-tertiary)]">Total Requests</p>
</Card>
<Card class="p-4 text-center">
<p class="text-2xl font-bold text-[var(--color-success)]">
{String((stats() as Record<string, unknown>)?.completedRequests ?? 0)}
</p>
<p class="text-xs text-[var(--color-text-tertiary)]">Completed</p>
</Card>
<Card class="p-4 text-center">
<p class="text-2xl font-bold text-[var(--color-warning)]">
{String((stats() as Record<string, unknown>)?.pendingRequests ?? 0)}
</p>
<p class="text-xs text-[var(--color-text-tertiary)]">Pending</p>
</Card>
</div>
</Show>
</Suspense>
<Card class="mb-6">
<div class="px-4 py-3 border-b border-[var(--color-border)]/50">
<h2 class="text-sm font-semibold text-[var(--color-text-primary)]">Data Brokers</h2>
</div>
<div class="divide-y divide-[var(--color-border)]/50">
<For each={brokers()}>
{(broker: Record<string, unknown>) => (
<div class="px-4 py-3 flex items-center justify-between">
<p class="text-sm text-[var(--color-text-primary)]">{String(broker.name ?? "")}</p>
<Button size="sm" onClick={() => createRequest(String(broker.id))}>
Opt Out
</Button>
</div>
)}
</For>
</div>
<Suspense fallback={<div class="p-4"><SkeletonTable rows={4} columns={2} /></div>}>
<Show when={brokers().length > 0} fallback={
<EmptyState
icon={<BrokerIcon />}
title="No data brokers found"
description="Your broker registry is empty. We'll scan for brokers automatically."
/>
}>
<div class="divide-y divide-[var(--color-border)]/50">
<For each={brokers()}>
{(broker: Record<string, unknown>) => (
<div class="px-4 py-3 flex items-center justify-between">
<p class="text-sm text-[var(--color-text-primary)]">{String(broker.name ?? "")}</p>
<Button size="sm" onClick={() => createRequest(String(broker.id))}>
Opt Out
</Button>
</div>
)}
</For>
</div>
</Show>
</Suspense>
</Card>
<Card>
<div class="px-4 py-3 border-b border-[var(--color-border)]/50">
<h2 class="text-sm font-semibold text-[var(--color-text-primary)]">Removal Requests</h2>
</div>
<div class="divide-y divide-[var(--color-border)]/50">
<For each={removalRequests()?.items ?? []}>
{(req: Record<string, unknown>) => (
<div class="px-4 py-3">
<p class="text-sm text-[var(--color-text-primary)]">{String(req.brokerName ?? "")}</p>
<p class="text-xs text-[var(--color-text-tertiary)]">Status: {String(req.status ?? "")}</p>
</div>
)}
</For>
<Show when={!(removalRequests()?.items?.length)}>
<div class="px-4 py-8 text-center text-sm text-[var(--color-text-tertiary)]">
No removal requests yet
<Suspense fallback={<div class="p-4"><SkeletonTable rows={3} columns={2} /></div>}>
<Show when={(removalRequests()?.items ?? []).length > 0} fallback={
<EmptyState
title="No removal requests yet"
description="Opt out of data brokers above to generate removal requests."
/>
}>
<div class="divide-y divide-[var(--color-border)]/50">
<For each={removalRequests()?.items ?? []}>
{(req: Record<string, unknown>) => (
<div class="px-4 py-3">
<p class="text-sm text-[var(--color-text-primary)]">{String(req.brokerName ?? "")}</p>
<p class="text-xs text-[var(--color-text-tertiary)]">Status: {String(req.status ?? "")}</p>
</div>
)}
</For>
</div>
</Show>
</div>
</Suspense>
</Card>
</div>
</main>

View File

@@ -27,7 +27,7 @@ export default function SettingsPage() {
<Sidebar open={sidebarOpen()} onClose={() => setSidebarOpen(false)} />
<div class="flex-1 flex flex-col min-w-0">
<TopBar onMenuToggle={() => setSidebarOpen(v => !v)} />
<main class="flex-1 overflow-y-auto p-6">
<main id="main-content" class="flex-1 overflow-y-auto p-6">
<div class="max-w-2xl mx-auto">
<h1 class="text-2xl font-bold text-[var(--color-text-primary)] mb-6">Settings</h1>

View File

@@ -1,12 +1,21 @@
import { createSignal, createResource, For, Show } from "solid-js";
import { createSignal, createResource, For, Show, Suspense } from "solid-js";
import { Title } from "@solidjs/meta";
import { Sidebar, TopBar } from "~/components/dashboard";
import { Button, Input, Card, Badge } from "~/components/ui";
import { Button, Input, Card, Badge, EmptyState, SkeletonTable, SkeletonText } from "~/components/ui";
import { api } from "~/lib/api";
function ShieldIcon() {
return (
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" />
</svg>
);
}
export default function SpamShieldPage() {
const [sidebarOpen, setSidebarOpen] = createSignal(false);
const [phoneNumber, setPhoneNumber] = createSignal("");
const [checking, setChecking] = createSignal(false);
const [checkResult, setCheckResult] = createSignal<Record<string, unknown> | null>(null);
const [rulesResult, { refetch }] = createResource(
() => api.spamshield.getRules.query(),
@@ -19,8 +28,13 @@ export default function SpamShieldPage() {
};
async function checkNumber() {
const result = await api.spamshield.checkNumber.query({ phoneNumber: phoneNumber() });
setCheckResult(result as Record<string, unknown>);
setChecking(true);
try {
const result = await api.spamshield.checkNumber.query({ phoneNumber: phoneNumber() });
setCheckResult(result as Record<string, unknown>);
} finally {
setChecking(false);
}
}
async function deleteRule(ruleId: string) {
@@ -34,7 +48,7 @@ export default function SpamShieldPage() {
<Sidebar open={sidebarOpen()} onClose={() => setSidebarOpen(false)} />
<div class="flex-1 flex flex-col min-w-0">
<TopBar onMenuToggle={() => setSidebarOpen(v => !v)} />
<main class="flex-1 overflow-y-auto p-6">
<main id="main-content" class="flex-1 overflow-y-auto p-6">
<div class="max-w-4xl mx-auto">
<h1 class="text-2xl font-bold text-[var(--color-text-primary)] mb-6">SpamShield</h1>
@@ -46,7 +60,7 @@ export default function SpamShieldPage() {
value={phoneNumber()}
onInput={(e) => setPhoneNumber(e.currentTarget.value)}
/>
<Button onClick={checkNumber}>Check</Button>
<Button onClick={checkNumber} loading={checking()}>Check</Button>
</div>
<Show when={checkResult()}>
<div class="mt-3 text-sm text-[var(--color-text-secondary)]">
@@ -59,26 +73,32 @@ export default function SpamShieldPage() {
<div class="px-4 py-3 border-b border-[var(--color-border)]/50">
<h2 class="text-sm font-semibold text-[var(--color-text-primary)]">Blocking Rules</h2>
</div>
<div class="divide-y divide-[var(--color-border)]/50">
<For each={rules()}>
{(rule: Record<string, unknown>) => (
<div class="px-4 py-3 flex items-center justify-between">
<div>
<p class="text-sm text-[var(--color-text-primary)]">{String(rule.pattern ?? "")}</p>
<Badge variant="info">{String(rule.action ?? "")}</Badge>
</div>
<Button variant="ghost" size="sm" onClick={() => deleteRule(String(rule.id))}>
Delete
</Button>
</div>
)}
</For>
<Show when={rules().length === 0}>
<div class="px-4 py-8 text-center text-sm text-[var(--color-text-tertiary)]">
No rules configured
<Suspense fallback={<div class="p-4"><SkeletonText lines={4} /></div>}>
<Show when={rules().length > 0} fallback={
<EmptyState
icon={<ShieldIcon />}
title="No custom rules"
description="Create blocking rules to automatically filter unwanted calls."
action={{ label: "Create rule", onClick: () => {} }}
/>
}>
<div class="divide-y divide-[var(--color-border)]/50">
<For each={rules()}>
{(rule: Record<string, unknown>) => (
<div class="px-4 py-3 flex items-center justify-between">
<div>
<p class="text-sm text-[var(--color-text-primary)]">{String(rule.pattern ?? "")}</p>
<Badge variant="info">{String(rule.action ?? "")}</Badge>
</div>
<Button variant="ghost" size="sm" onClick={() => deleteRule(String(rule.id))}>
Delete
</Button>
</div>
)}
</For>
</div>
</Show>
</div>
</Suspense>
</Card>
</div>
</main>

View File

@@ -1,9 +1,20 @@
import { createSignal, createResource, For, Show } from "solid-js";
import { createSignal, createResource, For, Show, Suspense } from "solid-js";
import { Title } from "@solidjs/meta";
import { Sidebar, TopBar } from "~/components/dashboard";
import { Button, Card, Badge } from "~/components/ui";
import { Button, Card, EmptyState, SkeletonTable } from "~/components/ui";
import { api } from "~/lib/api";
function VoiceIcon() {
return (
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z" />
<path d="M19 10v2a7 7 0 0 1-14 0v-2" />
<line x1="12" y1="19" x2="12" y2="23" />
<line x1="8" y1="23" x2="16" y2="23" />
</svg>
);
}
export default function VoicePrintPage() {
const [sidebarOpen, setSidebarOpen] = createSignal(false);
const [enrollments, { refetch }] = createResource(
@@ -22,7 +33,7 @@ export default function VoicePrintPage() {
<Sidebar open={sidebarOpen()} onClose={() => setSidebarOpen(false)} />
<div class="flex-1 flex flex-col min-w-0">
<TopBar onMenuToggle={() => setSidebarOpen(v => !v)} />
<main class="flex-1 overflow-y-auto p-6">
<main id="main-content" class="flex-1 overflow-y-auto p-6">
<div class="max-w-4xl mx-auto">
<h1 class="text-2xl font-bold text-[var(--color-text-primary)] mb-6">VoicePrint</h1>
@@ -30,26 +41,32 @@ export default function VoicePrintPage() {
<div class="px-4 py-3 border-b border-[var(--color-border)]/50">
<h2 class="text-sm font-semibold text-[var(--color-text-primary)]">Voice Enrollments</h2>
</div>
<div class="divide-y divide-[var(--color-border)]/50">
<For each={enrollments()}>
{(enr: Record<string, unknown>) => (
<div class="px-4 py-3 flex items-center justify-between">
<div>
<p class="text-sm text-[var(--color-text-primary)]">{String(enr.name ?? "")}</p>
<p class="text-xs text-[var(--color-text-tertiary)]">Created {String(enr.createdAt ?? "")}</p>
</div>
<Button variant="ghost" size="sm" onClick={() => deleteEnrollment(String(enr.id))}>
Delete
</Button>
</div>
)}
</For>
<Show when={enrollments().length === 0}>
<div class="px-4 py-8 text-center text-sm text-[var(--color-text-tertiary)]">
No voice enrollments yet
<Suspense fallback={<div class="p-4"><SkeletonTable rows={4} columns={2} /></div>}>
<Show when={enrollments().length > 0} fallback={
<EmptyState
icon={<VoiceIcon />}
title="No voice enrollments yet"
description="Enroll your voice to enable voice biometric authentication."
action={{ label: "Enroll voice", onClick: () => {} }}
/>
}>
<div class="divide-y divide-[var(--color-border)]/50">
<For each={enrollments()}>
{(enr: Record<string, unknown>) => (
<div class="px-4 py-3 flex items-center justify-between">
<div>
<p class="text-sm text-[var(--color-text-primary)]">{String(enr.name ?? "")}</p>
<p class="text-xs text-[var(--color-text-tertiary)]">Created {String(enr.createdAt ?? "")}</p>
</div>
<Button variant="ghost" size="sm" onClick={() => deleteEnrollment(String(enr.id))}>
Delete
</Button>
</div>
)}
</For>
</div>
</Show>
</div>
</Suspense>
</Card>
</div>
</main>

View File

@@ -1,19 +1,56 @@
import { Title } from "@solidjs/meta";
import { HttpStatusCode } from "@solidjs/start";
import { A } from "@solidjs/router";
import { Button } from "~/components/ui";
export default function NotFound() {
return (
<main>
<Title>Not Found</Title>
<main class="min-h-[60vh] flex items-center justify-center px-6">
<Title>Not Found ShieldAI</Title>
<HttpStatusCode code={404} />
<h1>Page Not Found</h1>
<p>
Visit{" "}
<a href="https://start.solidjs.com" target="_blank">
start.solidjs.com
</a>{" "}
to learn how to build SolidStart apps.
</p>
<div class="flex flex-col items-center text-center max-w-md gap-6">
<svg
width="48"
height="56"
viewBox="0 0 28 32"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<defs>
<linearGradient
id="notfound-shield-grad"
x1="0"
y1="0"
x2="28"
y2="32"
gradientUnits="userSpaceOnUse"
>
<stop stop-color="var(--color-brand-primary)" />
<stop offset="1" stop-color="var(--color-brand-accent)" />
</linearGradient>
</defs>
<path
d="M14 0L26 6V16C26 24 14 32 14 32S2 24 2 16V6L14 0Z"
fill="url(#notfound-shield-grad)"
/>
<path
d="M10 16L13 19L19 13"
stroke="white"
stroke-width="2.5"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
<div class="space-y-2">
<h1 class="text-4xl font-bold text-[var(--color-text-primary)]">404</h1>
<p class="text-[var(--color-text-secondary)]">
The page you're looking for doesn't exist or has been moved.
</p>
</div>
<A href="/">
<Button>Go home</Button>
</A>
</div>
</main>
);
}