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:
@@ -572,3 +572,24 @@
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes page-enter {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-page-enter {
|
||||
animation: page-enter 200ms ease-out;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.animate-page-enter {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import { ThemeProvider } from "./lib/theme";
|
||||
import { ClerkProvider } from "clerk-solidjs/start";
|
||||
import { ClerkLoaded, ClerkLoading, useAuth } from "clerk-solidjs";
|
||||
import { AppShell } from "./components/layout";
|
||||
import { ToastProvider } from "./components/ui";
|
||||
import { ToastProvider, ErrorBoundary, PageTransition } from "./components/ui";
|
||||
|
||||
import "./app.css";
|
||||
|
||||
@@ -71,7 +71,19 @@ export default function App() {
|
||||
root={(props) => (
|
||||
<ClerkApp>
|
||||
<AppShell>
|
||||
<Suspense>{props.children}</Suspense>
|
||||
<a
|
||||
href="#main-content"
|
||||
class="sr-only focus:not-sr-only focus:fixed focus:top-4 focus:left-4 focus:z-[100] focus:px-4 focus:py-2 focus:bg-[var(--color-bg)] focus:text-[var(--color-text-primary)] focus:rounded-lg focus:shadow-lg focus:outline-2 focus:outline-[var(--color-focus-ring)]"
|
||||
>
|
||||
Skip to main content
|
||||
</a>
|
||||
<ErrorBoundary>
|
||||
<Suspense>
|
||||
<PageTransition>
|
||||
{props.children}
|
||||
</PageTransition>
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
</AppShell>
|
||||
</ClerkApp>
|
||||
)}
|
||||
|
||||
@@ -13,6 +13,7 @@ interface ButtonProps {
|
||||
children: JSX.Element;
|
||||
onClick?: (e: MouseEvent) => void;
|
||||
type?: "button" | "submit" | "reset";
|
||||
ariaLabel?: string;
|
||||
}
|
||||
|
||||
const variantClasses: Record<ButtonVariant, string> = {
|
||||
@@ -67,6 +68,7 @@ export default function Button(props: ButtonProps) {
|
||||
type={props.type ?? "button"}
|
||||
disabled={isDisabled()}
|
||||
onClick={props.onClick}
|
||||
aria-label={props.ariaLabel}
|
||||
class={cn(
|
||||
"inline-flex items-center justify-center font-medium transition-all duration-200 focus-visible:outline-2 focus-visible:outline-offset-2 disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer",
|
||||
variantClasses[variant()],
|
||||
|
||||
44
web/src/components/ui/EmptyState.tsx
Normal file
44
web/src/components/ui/EmptyState.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import type { JSX } from "solid-js";
|
||||
import { cn } from "~/lib/utils";
|
||||
import Button from "./Button";
|
||||
|
||||
interface EmptyStateProps {
|
||||
icon?: JSX.Element;
|
||||
title: string;
|
||||
description?: string;
|
||||
action?: {
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
};
|
||||
class?: string;
|
||||
}
|
||||
|
||||
export default function EmptyState(props: EmptyStateProps) {
|
||||
return (
|
||||
<div
|
||||
class={cn(
|
||||
"flex flex-col items-center justify-center text-center py-12 px-6",
|
||||
props.class,
|
||||
)}
|
||||
>
|
||||
{props.icon && (
|
||||
<div class="mb-4 text-[var(--color-text-tertiary)]">
|
||||
{props.icon}
|
||||
</div>
|
||||
)}
|
||||
<h3 class="text-lg font-semibold text-[var(--color-text-primary)] mb-1">
|
||||
{props.title}
|
||||
</h3>
|
||||
{props.description && (
|
||||
<p class="text-sm text-[var(--color-text-secondary)] max-w-sm mb-6">
|
||||
{props.description}
|
||||
</p>
|
||||
)}
|
||||
{props.action && (
|
||||
<Button onClick={props.action.onClick} size="sm">
|
||||
{props.action.label}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
114
web/src/components/ui/ErrorBoundary.tsx
Normal file
114
web/src/components/ui/ErrorBoundary.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
import { createSignal, type JSX } from "solid-js";
|
||||
import { ErrorBoundary as SolidErrorBoundary } from "solid-js";
|
||||
import Button from "./Button";
|
||||
|
||||
interface ErrorFallbackProps {
|
||||
error: Error;
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
function ShieldLogo() {
|
||||
return (
|
||||
<svg
|
||||
width="48"
|
||||
height="56"
|
||||
viewBox="0 0 28 32"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<defs>
|
||||
<linearGradient
|
||||
id="error-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(#error-shield-grad)"
|
||||
/>
|
||||
<path
|
||||
d="M10 16L13 19L19 13"
|
||||
stroke="white"
|
||||
stroke-width="2.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function ErrorFallback(props: ErrorFallbackProps) {
|
||||
const [expanded, setExpanded] = createSignal(false);
|
||||
|
||||
function handleReport() {
|
||||
console.error("[ErrorBoundary] Error details:", props.error);
|
||||
const text = `Error: ${props.error.message}\nStack: ${props.error.stack}`;
|
||||
const blob = new Blob([text], { type: "text/plain" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = "error-report.txt";
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="flex items-center justify-center min-h-[400px] p-8">
|
||||
<div class="flex flex-col items-center text-center max-w-md gap-6">
|
||||
<ShieldLogo />
|
||||
<div class="space-y-2">
|
||||
<h2 class="text-xl font-bold text-[var(--color-text-primary)]">
|
||||
Something went wrong
|
||||
</h2>
|
||||
<p class="text-sm text-[var(--color-text-secondary)]">
|
||||
An unexpected error occurred. Please try again or report the issue.
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex gap-3">
|
||||
<Button onClick={props.reset}>Try again</Button>
|
||||
<Button variant="secondary" onClick={handleReport}>
|
||||
Report issue
|
||||
</Button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
class="text-xs text-[var(--color-text-tertiary)] hover:text-[var(--color-text-secondary)] transition-colors cursor-pointer"
|
||||
aria-label="Toggle error details"
|
||||
>
|
||||
{expanded() ? "Hide" : "Show"} error details
|
||||
</button>
|
||||
{expanded() && (
|
||||
<pre class="text-xs text-left text-[var(--color-text-tertiary)] bg-[var(--color-bg-secondary)] p-3 rounded-lg overflow-auto max-w-full">
|
||||
{props.error.message}
|
||||
{"\n\n"}
|
||||
{props.error.stack}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ErrorBoundaryProps {
|
||||
children: JSX.Element;
|
||||
}
|
||||
|
||||
export default function ErrorBoundary(props: ErrorBoundaryProps) {
|
||||
return (
|
||||
<SolidErrorBoundary
|
||||
fallback={(error, reset) => (
|
||||
<ErrorFallback error={error} reset={reset} />
|
||||
)}
|
||||
>
|
||||
{props.children}
|
||||
</SolidErrorBoundary>
|
||||
);
|
||||
}
|
||||
34
web/src/components/ui/PageTransition.tsx
Normal file
34
web/src/components/ui/PageTransition.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import { createEffect, createSignal, type JSX } from "solid-js";
|
||||
import { useLocation } from "@solidjs/router";
|
||||
import { cn } from "~/lib/utils";
|
||||
|
||||
interface PageTransitionProps {
|
||||
children: JSX.Element;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
export default function PageTransition(props: PageTransitionProps) {
|
||||
const location = useLocation();
|
||||
const [entering, setEntering] = createSignal(true);
|
||||
|
||||
createEffect(() => {
|
||||
location.pathname;
|
||||
setEntering(false);
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => setEntering(true));
|
||||
});
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
class={cn(
|
||||
!entering() && "opacity-0",
|
||||
entering() && "opacity-100 translate-y-0 animate-page-enter",
|
||||
"motion-reduce:opacity-100 motion-reduce:translate-y-0 motion-reduce:animate-none",
|
||||
props.class,
|
||||
)}
|
||||
>
|
||||
{props.children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
96
web/src/components/ui/Skeleton.tsx
Normal file
96
web/src/components/ui/Skeleton.tsx
Normal file
@@ -0,0 +1,96 @@
|
||||
import { For } from "solid-js";
|
||||
import { cn } from "~/lib/utils";
|
||||
|
||||
interface SkeletonTextProps {
|
||||
lines?: number;
|
||||
width?: string;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
export function SkeletonText(props: SkeletonTextProps) {
|
||||
const lines = () => props.lines ?? 3;
|
||||
return (
|
||||
<div class={cn("space-y-2", props.class)} role="status" aria-label="Loading">
|
||||
<For each={Array.from({ length: lines() })}>
|
||||
{(_, i) => (
|
||||
<div
|
||||
class="h-3 rounded bg-[var(--color-bg-tertiary)] animate-pulse"
|
||||
style={{ width: i() === lines() - 1 ? "60%" : "100%" }}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface SkeletonCardProps {
|
||||
class?: string;
|
||||
}
|
||||
|
||||
export function SkeletonCard(props: SkeletonCardProps) {
|
||||
return (
|
||||
<div
|
||||
class={cn(
|
||||
"rounded-xl border border-[var(--color-border)]/50 overflow-hidden",
|
||||
props.class,
|
||||
)}
|
||||
role="status"
|
||||
aria-label="Loading"
|
||||
>
|
||||
<div class="p-4 space-y-3">
|
||||
<div class="h-4 w-2/3 rounded bg-[var(--color-bg-tertiary)] animate-pulse" />
|
||||
<div class="h-3 w-full rounded bg-[var(--color-bg-tertiary)] animate-pulse" />
|
||||
<div class="h-3 w-4/5 rounded bg-[var(--color-bg-tertiary)] animate-pulse" />
|
||||
</div>
|
||||
<div class="border-t border-[var(--color-border)]/50 p-4">
|
||||
<div class="h-3 w-1/3 rounded bg-[var(--color-bg-tertiary)] animate-pulse" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface SkeletonAvatarProps {
|
||||
size?: number;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
export function SkeletonAvatar(props: SkeletonAvatarProps) {
|
||||
const size = () => props.size ?? 40;
|
||||
return (
|
||||
<div
|
||||
class={cn("rounded-full bg-[var(--color-bg-tertiary)] animate-pulse shrink-0", props.class)}
|
||||
style={{ width: `${size()}px`, height: `${size()}px` }}
|
||||
role="status"
|
||||
aria-label="Loading"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
interface SkeletonTableProps {
|
||||
rows?: number;
|
||||
columns?: number;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
export function SkeletonTable(props: SkeletonTableProps) {
|
||||
const rows = () => props.rows ?? 5;
|
||||
const columns = () => props.columns ?? 4;
|
||||
return (
|
||||
<div class={cn("space-y-2", props.class)} role="status" aria-label="Loading">
|
||||
<For each={Array.from({ length: rows() })}>
|
||||
{() => (
|
||||
<div class="flex gap-4 px-4 py-3">
|
||||
<For each={Array.from({ length: columns() })}>
|
||||
{(_, i) => (
|
||||
<div
|
||||
class="h-3 rounded bg-[var(--color-bg-tertiary)] animate-pulse"
|
||||
style={{ width: i() === 0 ? "30%" : `${15 + Math.random() * 20}%` }}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -106,6 +106,7 @@ function ToastContainer() {
|
||||
class="fixed bottom-4 right-4 z-50 flex flex-col gap-2 max-w-sm"
|
||||
role="region"
|
||||
aria-label="Notifications"
|
||||
aria-live="polite"
|
||||
>
|
||||
<For each={toasts()}>
|
||||
{(toast) => (
|
||||
|
||||
@@ -3,4 +3,13 @@ export { default as Card } from "./Card";
|
||||
export { default as Input } from "./Input";
|
||||
export { default as Badge } from "./Badge";
|
||||
export { default as Modal } from "./Modal";
|
||||
export { default as ErrorBoundary } from "./ErrorBoundary";
|
||||
export { default as PageTransition } from "./PageTransition";
|
||||
export { default as EmptyState } from "./EmptyState";
|
||||
export {
|
||||
SkeletonText,
|
||||
SkeletonCard,
|
||||
SkeletonAvatar,
|
||||
SkeletonTable,
|
||||
} from "./Skeleton";
|
||||
export { ToastProvider, useToast } from "./Toast";
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render } from "solid-js/web";
|
||||
import { ErrorBoundary as SolidErrorBoundary } from "solid-js";
|
||||
import type { JSX } from "solid-js";
|
||||
|
||||
import Button from "./Button";
|
||||
@@ -8,6 +9,14 @@ import Input from "./Input";
|
||||
import Badge from "./Badge";
|
||||
import Modal from "./Modal";
|
||||
import { ToastProvider, useToast } from "./Toast";
|
||||
import ErrorBoundary from "./ErrorBoundary";
|
||||
import EmptyState from "./EmptyState";
|
||||
import {
|
||||
SkeletonText,
|
||||
SkeletonCard,
|
||||
SkeletonAvatar,
|
||||
SkeletonTable,
|
||||
} from "./Skeleton";
|
||||
|
||||
function mount(comp: () => JSX.Element): HTMLDivElement {
|
||||
const container = document.createElement("div");
|
||||
@@ -502,3 +511,101 @@ describe("Toast", () => {
|
||||
expect(toastEl.className).toContain("bg-[var(--color-error-bg)]");
|
||||
});
|
||||
});
|
||||
|
||||
describe("ErrorBoundary", () => {
|
||||
it("renders children when no error", () => {
|
||||
mount(() => (
|
||||
<ErrorBoundary>
|
||||
<p>All good</p>
|
||||
</ErrorBoundary>
|
||||
));
|
||||
expect(document.body.textContent).toContain("All good");
|
||||
});
|
||||
|
||||
it("catches errors and shows fallback UI", () => {
|
||||
const Throwing = () => {
|
||||
throw new Error("Test error");
|
||||
};
|
||||
mount(() => (
|
||||
<ErrorBoundary>
|
||||
<Throwing />
|
||||
</ErrorBoundary>
|
||||
));
|
||||
expect(document.body.textContent).toContain("Something went wrong");
|
||||
expect(document.body.textContent).toContain("Try again");
|
||||
expect(document.body.textContent).toContain("Report issue");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Skeleton", () => {
|
||||
it("SkeletonText renders with aria-label", () => {
|
||||
mount(() => <SkeletonText lines={3} />);
|
||||
const el = document.querySelector("[role='status']")!;
|
||||
expect(el).toBeTruthy();
|
||||
expect(el.getAttribute("aria-label")).toBe("Loading");
|
||||
});
|
||||
|
||||
it("SkeletonText renders correct number of lines", () => {
|
||||
mount(() => <SkeletonText lines={5} />);
|
||||
const bars = document.querySelectorAll("[role='status'] > div");
|
||||
expect(bars.length).toBe(5);
|
||||
});
|
||||
|
||||
it("SkeletonCard renders with aria-label", () => {
|
||||
mount(() => <SkeletonCard />);
|
||||
const el = document.querySelector("[role='status']")!;
|
||||
expect(el).toBeTruthy();
|
||||
expect(el.getAttribute("aria-label")).toBe("Loading");
|
||||
});
|
||||
|
||||
it("SkeletonAvatar renders with correct size", () => {
|
||||
mount(() => <SkeletonAvatar size={60} />);
|
||||
const el = document.querySelector("[role='status']")! as HTMLElement;
|
||||
expect(el.style.width).toBe("60px");
|
||||
expect(el.style.height).toBe("60px");
|
||||
});
|
||||
|
||||
it("SkeletonTable renders correct number of rows", () => {
|
||||
mount(() => <SkeletonTable rows={3} />);
|
||||
const rows = document.querySelectorAll("[role='status'] > div");
|
||||
expect(rows.length).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe("EmptyState", () => {
|
||||
it("renders title and description", () => {
|
||||
mount(() => (
|
||||
<EmptyState
|
||||
title="No items"
|
||||
description="There are no items to display."
|
||||
/>
|
||||
));
|
||||
expect(document.body.textContent).toContain("No items");
|
||||
expect(document.body.textContent).toContain("There are no items to display.");
|
||||
});
|
||||
|
||||
it("renders action button when provided", () => {
|
||||
const onClick = vi.fn();
|
||||
mount(() => (
|
||||
<EmptyState
|
||||
title="Empty"
|
||||
action={{ label: "Add item", onClick }}
|
||||
/>
|
||||
));
|
||||
const btn = document.querySelector("button")!;
|
||||
expect(btn).toBeTruthy();
|
||||
expect(btn.textContent).toContain("Add item");
|
||||
btn.click();
|
||||
expect(onClick).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("renders icon when provided", () => {
|
||||
mount(() => (
|
||||
<EmptyState
|
||||
title="Empty"
|
||||
icon={<svg data-testid="test-icon" width="24" height="24" />}
|
||||
/>
|
||||
));
|
||||
expect(document.querySelector("[data-testid='test-icon']")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user