Rebuild the frontend as a product rather than an internal tool
The UI worked but read as an operator console: no public face, no way to describe or sell the thing, and no account surface beyond the node list. This adds the missing halves and reorganises what was already there around the incident, which is the unit of value the rest of the pipeline is built to produce. A shared design system replaces per-page styling: components/ui (Button, Card, Badge, EmptyState, Skeleton, PageHeader), a type scale and shadow set in the Tailwind config, and light-mode tokens in globals.css. The existing html:not(.dark) remap mechanism is extended rather than replaced -- a parallel theming system would have been two sources of truth for the same colours. Public marketing pages (/, /features, /pricing, /faq) load without a session. middleware.ts gained a PUBLIC_PATHS allowlist to permit that; it remains a UX redirect and is still NOT an authorisation boundary, which the comment there says explicitly. Real enforcement is unchanged and still lives server-side in c2-core's auth.py. Chrome switching is done by pathname in ChromeSwitcher instead of by route group, because a route group would have collided on / and forced most of app/ to move for no behavioural gain. Billing and API keys ship as typed stubs, not integrations. lib/billing.ts and lib/apiKeys.ts define the data model and the screens consume it, but every mutating call throws with a message naming the backend route that has to exist first, and the sample data is labelled as sample. Nothing here can charge anyone or mint a real credential -- picking a payment processor and holding its keys is a decision for a human, and a half-wired checkout is worse than an obviously absent one. The severity work from the c2-core change lands here too. severity is now a filter and sort dimension on the incident list rather than decoration, since a busy dispatch channel is only readable if you can collapse it to moderate and above. routine gets a muted treatment because it is the majority of traffic, legacy "unknown" still renders nothing, and TypeBadge handles the new "other" incident type. Severity rendering moved into lib/severity.tsx so the incident list, incident detail and call rows cannot drift apart. Deliberately not touched: calls, map, alerts, nodes, systems, tokens, trips and admin. They already share the palette and stay coherent, and rewriting them would have buried the parts that actually needed to change. No colour tokens were renamed, so nothing regressed there. Verified with tsc --noEmit (npm run typecheck), clean. No runtime verification was possible and none was done. No new environment variables.
This commit is contained in:
@@ -0,0 +1,28 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
type Tone = "neutral" | "brand" | "success" | "warning" | "danger" | "info";
|
||||
|
||||
const TONE_CLASSES: Record<Tone, string> = {
|
||||
neutral: "bg-gray-800 text-gray-300",
|
||||
brand: "bg-indigo-900 text-indigo-300",
|
||||
success: "bg-green-900 text-green-300",
|
||||
warning: "bg-yellow-900 text-yellow-300",
|
||||
danger: "bg-red-900 text-red-300",
|
||||
info: "bg-blue-900 text-blue-300",
|
||||
};
|
||||
|
||||
export function Badge({ children, tone = "neutral", className }: { children: ReactNode; tone?: Tone; className?: string }) {
|
||||
return (
|
||||
<span
|
||||
className={[
|
||||
"inline-flex items-center gap-1 text-xs font-mono px-2 py-0.5 rounded-full whitespace-nowrap",
|
||||
TONE_CLASSES[tone],
|
||||
className ?? "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import Link from "next/link";
|
||||
import type { ButtonHTMLAttributes, ReactNode } from "react";
|
||||
|
||||
type Variant = "primary" | "secondary" | "ghost" | "danger";
|
||||
type Size = "sm" | "md" | "lg";
|
||||
|
||||
const VARIANT_CLASSES: Record<Variant, string> = {
|
||||
primary:
|
||||
"bg-indigo-600 hover:bg-indigo-500 active:bg-indigo-700 text-white shadow-card disabled:hover:bg-indigo-600",
|
||||
secondary:
|
||||
"bg-gray-800 hover:bg-gray-700 active:bg-gray-700 text-gray-100 border border-gray-700 disabled:hover:bg-gray-800",
|
||||
ghost:
|
||||
"bg-transparent hover:bg-gray-800 active:bg-gray-800 text-gray-300 hover:text-white disabled:hover:bg-transparent",
|
||||
danger:
|
||||
"bg-red-700 hover:bg-red-600 active:bg-red-700 text-white disabled:hover:bg-red-700",
|
||||
};
|
||||
|
||||
const SIZE_CLASSES: Record<Size, string> = {
|
||||
sm: "text-xs px-3 py-1.5 rounded-lg gap-1.5",
|
||||
md: "text-sm px-4 py-2 rounded-lg gap-2",
|
||||
lg: "text-sm px-5 py-2.5 rounded-xl gap-2",
|
||||
};
|
||||
|
||||
const BASE =
|
||||
"inline-flex items-center justify-center font-semibold font-mono transition-colors " +
|
||||
"disabled:opacity-50 disabled:cursor-not-allowed " +
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-gray-950";
|
||||
|
||||
interface CommonProps {
|
||||
variant?: Variant;
|
||||
size?: Size;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
fullWidth?: boolean;
|
||||
}
|
||||
|
||||
type ButtonProps = CommonProps &
|
||||
ButtonHTMLAttributes<HTMLButtonElement> & {
|
||||
href?: undefined;
|
||||
};
|
||||
|
||||
interface LinkButtonProps extends CommonProps {
|
||||
href: string;
|
||||
external?: boolean;
|
||||
}
|
||||
|
||||
function classes(variant: Variant, size: Size, fullWidth: boolean | undefined, extra?: string) {
|
||||
return [BASE, VARIANT_CLASSES[variant], SIZE_CLASSES[size], fullWidth ? "w-full" : "", extra ?? ""]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
/** Button — use for in-page actions. Pass `href` instead to render a Link (see LinkButton export). */
|
||||
export function Button({ variant = "primary", size = "md", children, className, fullWidth, ...rest }: ButtonProps) {
|
||||
return (
|
||||
<button className={classes(variant, size, fullWidth, className)} {...rest}>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/** Same visual language as Button, but renders a Next.js Link — for navigation, not actions. */
|
||||
export function LinkButton({ variant = "primary", size = "md", children, className, fullWidth, href, external }: LinkButtonProps) {
|
||||
if (external) {
|
||||
return (
|
||||
<a href={href} target="_blank" rel="noopener noreferrer" className={classes(variant, size, fullWidth, className)}>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Link href={href} className={classes(variant, size, fullWidth, className)}>
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { HTMLAttributes, ReactNode } from "react";
|
||||
|
||||
interface CardProps extends HTMLAttributes<HTMLDivElement> {
|
||||
children: ReactNode;
|
||||
hover?: boolean;
|
||||
padding?: "none" | "sm" | "md" | "lg";
|
||||
highlighted?: boolean;
|
||||
}
|
||||
|
||||
const PADDING: Record<NonNullable<CardProps["padding"]>, string> = {
|
||||
none: "",
|
||||
sm: "p-4",
|
||||
md: "p-5",
|
||||
lg: "p-8",
|
||||
};
|
||||
|
||||
/** Standard surface card — the base container used across app + settings + marketing. */
|
||||
export function Card({ children, hover, padding = "md", highlighted, className, ...rest }: CardProps) {
|
||||
return (
|
||||
<div
|
||||
className={[
|
||||
"bg-gray-900 border rounded-xl",
|
||||
highlighted ? "border-indigo-600/40 shadow-glow" : "border-gray-800",
|
||||
hover ? "transition-colors hover:border-gray-600" : "",
|
||||
PADDING[padding],
|
||||
className ?? "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CardHeader({ title, subtitle, action }: { title: ReactNode; subtitle?: ReactNode; action?: ReactNode }) {
|
||||
return (
|
||||
<div className="flex items-start justify-between gap-4 mb-4">
|
||||
<div className="min-w-0">
|
||||
<h3 className="text-white font-semibold text-sm">{title}</h3>
|
||||
{subtitle && <p className="text-gray-500 text-xs mt-0.5 leading-snug">{subtitle}</p>}
|
||||
</div>
|
||||
{action && <div className="shrink-0">{action}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
interface EmptyStateProps {
|
||||
icon?: ReactNode;
|
||||
title: string;
|
||||
description?: string;
|
||||
action?: ReactNode;
|
||||
}
|
||||
|
||||
/** Consistent "nothing here yet" panel — replaces the ad-hoc `<p className="text-gray-600">` scattered across pages. */
|
||||
export function EmptyState({ icon, title, description, action }: EmptyStateProps) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center text-center py-12 px-6 border border-dashed border-gray-800 rounded-xl">
|
||||
{icon && <div className="text-gray-700 mb-3">{icon}</div>}
|
||||
<p className="text-gray-300 text-sm font-semibold font-mono">{title}</p>
|
||||
{description && <p className="text-gray-600 text-xs font-mono mt-1 max-w-sm">{description}</p>}
|
||||
{action && <div className="mt-4">{action}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Inline error banner — for API/Firestore errors surfaced within a page section. */
|
||||
export function ErrorBanner({ message }: { message: string }) {
|
||||
return (
|
||||
<div className="bg-red-950 border border-red-800 rounded-lg p-4">
|
||||
<p className="text-red-400 text-sm font-mono">{message}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
interface PageHeaderProps {
|
||||
title: ReactNode;
|
||||
description?: ReactNode;
|
||||
badge?: ReactNode;
|
||||
action?: ReactNode;
|
||||
}
|
||||
|
||||
/** Standard page title row — title + optional badge on the left, primary action on the right. */
|
||||
export function PageHeader({ title, description, badge, action }: PageHeaderProps) {
|
||||
return (
|
||||
<div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<h1 className="text-xl font-bold text-white font-mono">{title}</h1>
|
||||
{badge}
|
||||
</div>
|
||||
{description && <p className="text-gray-500 text-sm mt-1 max-w-2xl">{description}</p>}
|
||||
</div>
|
||||
{action && <div className="shrink-0">{action}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
/** Loading placeholder block. Use instead of a bare "Loading…" string wherever the eventual
|
||||
* content has a predictable shape (cards, table rows, stat tiles). */
|
||||
export function Skeleton({ className }: { className?: string }) {
|
||||
return <div className={`skeleton bg-gray-800 rounded-md ${className ?? "h-4 w-full"}`} />;
|
||||
}
|
||||
|
||||
export function SkeletonCard() {
|
||||
return (
|
||||
<div className="bg-gray-900 border border-gray-800 rounded-xl p-4 space-y-3">
|
||||
<Skeleton className="h-4 w-1/3" />
|
||||
<Skeleton className="h-3 w-2/3" />
|
||||
<Skeleton className="h-3 w-1/2" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SkeletonRow({ cols = 5 }: { cols?: number }) {
|
||||
return (
|
||||
<tr className="border-b border-gray-800">
|
||||
{Array.from({ length: cols }).map((_, i) => (
|
||||
<td key={i} className="px-4 py-3">
|
||||
<Skeleton className="h-3 w-full max-w-[8rem]" />
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user