feat(dashboard): add React foundation and overview

This commit is contained in:
jjbrunton committed 2026-08-08 14:53:53 +01:00
1 parent 59c3a61a88
commit 51e3e28a9b
20 files changed
+3347 -244

No files matched your search

+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#0b1210" />
<title>Reddit Bot Console</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+60
View File
@@ -0,0 +1,60 @@
import type { ZodType } from 'zod';
import {
botStatsSchema,
controlResponseSchema,
timersSchema,
type BotStats,
type ControlResponse,
type Timers,
} from './contracts.js';
export class ApiError extends Error {
constructor(
message: string,
readonly status: number,
) {
super(message);
this.name = 'ApiError';
}
}
interface ErrorPayload {
error?: unknown;
}
async function parseResponse<T>(response: Response, schema: ZodType<T>): Promise<T> {
const payload: unknown = await response.json();
if (!response.ok) {
const errorPayload = payload as ErrorPayload;
const message = typeof errorPayload.error === 'string' ? errorPayload.error : `Request failed (${response.status})`;
throw new ApiError(message, response.status);
}
return schema.parse(payload);
}
export interface DashboardApi {
getStats(): Promise<BotStats>;
getTimers(): Promise<Timers>;
setRunning(isRunning: boolean): Promise<ControlResponse>;
}
export function createDashboardApi(fetchImplementation?: typeof fetch): DashboardApi {
function request(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
return (fetchImplementation ?? globalThis.fetch)(input, init);
}
return {
async getStats() {
return parseResponse(await request('/api/stats'), botStatsSchema);
},
async getTimers() {
return parseResponse(await request('/api/timers'), timersSchema);
},
async setRunning(isRunning) {
const endpoint = isRunning ? '/api/start' : '/api/stop';
return parseResponse(await request(endpoint, { method: 'POST' }), controlResponseSchema);
},
};
}
export const dashboardApi = createDashboardApi();
+46
View File
@@ -0,0 +1,46 @@
import { z } from 'zod';
const activityItemSchema = z.object({
timestamp: z.number(),
type: z.enum(['sent', 'skipped', 'response', 'error', 'info', 'debug']),
message: z.string(),
recipient: z.string().optional(),
imageUrl: z.string().optional(),
});
export const botStatsSchema = z.object({
isRunning: z.boolean(),
dryRunMode: z.boolean(),
totalProcessed: z.number(),
totalMessagesSent: z.number(),
totalResponses: z.number(),
responseRate: z.number(),
messagesThisHour: z.number(),
maxMessagesPerHour: z.number(),
subreddits: z.array(z.object({ name: z.string(), type: z.enum(['image', 'chat']) })),
targetGenders: z.array(z.string()),
recentActivity: z.array(activityItemSchema),
invitationCooldown: z
.object({
until: z.number(),
reason: z.string(),
redditCode: z.string().nullable(),
})
.nullable(),
});
export const timersSchema = z.object({
nextMessageAvailableAt: z.number().nullable(),
hourlyResetAt: z.number(),
isHourlyLimitReached: z.boolean(),
messagesRemaining: z.number(),
});
export const controlResponseSchema = z.object({
success: z.literal(true),
running: z.boolean(),
});
export type BotStats = z.infer<typeof botStatsSchema>;
export type Timers = z.infer<typeof timersSchema>;
export type ControlResponse = z.infer<typeof controlResponseSchema>;
+28
View File
@@ -0,0 +1,28 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { dashboardApi } from './client.js';
import type { BotStats } from './contracts.js';
export const dashboardQueryKeys = {
stats: ['dashboard', 'stats'] as const,
timers: ['dashboard', 'timers'] as const,
};
export function useStatsQuery() {
return useQuery({ queryKey: dashboardQueryKeys.stats, queryFn: dashboardApi.getStats });
}
export function useTimersQuery() {
return useQuery({ queryKey: dashboardQueryKeys.timers, queryFn: dashboardApi.getTimers });
}
export function useSetRunningMutation() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: dashboardApi.setRunning,
onSuccess(response) {
queryClient.setQueryData<BotStats>(dashboardQueryKeys.stats, (current) =>
current ? { ...current, isRunning: response.running } : current,
);
},
});
}
+63
View File
@@ -0,0 +1,63 @@
import { useState } from 'react';
import { NavLink, Outlet } from 'react-router-dom';
import { navigationItems } from './navigation.js';
export function AppShell() {
const [isNavigationOpen, setIsNavigationOpen] = useState(false);
return (
<div className="app-shell">
<a className="skip-link" href="#main-content">Skip to content</a>
<aside className={isNavigationOpen ? 'sidebar is-open' : 'sidebar'}>
<div className="brand">
<span className="brand-mark" aria-hidden="true">R</span>
<div>
<strong>Reddit Bot</strong>
<span>Operator console</span>
</div>
</div>
<nav aria-label="Primary navigation" id="primary-navigation">
{navigationItems.map((item) => (
<NavLink
aria-label={item.label}
className={({ isActive }) => (isActive ? 'nav-link is-active' : 'nav-link')}
end={item.path === '/'}
key={item.path}
onClick={() => setIsNavigationOpen(false)}
to={item.path}
>
<span>{item.label}</span>
<small>{item.description}</small>
</NavLink>
))}
</nav>
</aside>
{isNavigationOpen ? (
<button
aria-label="Close navigation backdrop"
className="navigation-backdrop"
onClick={() => setIsNavigationOpen(false)}
type="button"
/>
) : null}
<div className="workspace">
<header className="mobile-header">
<span>Reddit Bot</span>
<button
aria-controls="primary-navigation"
aria-expanded={isNavigationOpen}
aria-label={isNavigationOpen ? 'Close navigation' : 'Open navigation'}
className="icon-button"
onClick={() => setIsNavigationOpen((current) => !current)}
type="button"
>
<span aria-hidden="true">{isNavigationOpen ? '×' : '☰'}</span>
</button>
</header>
<main id="main-content" tabIndex={-1}>
<Outlet />
</main>
</div>
</div>
);
}
+14
View File
@@ -0,0 +1,14 @@
export interface NavigationItem {
label: string;
path: string;
description: string;
}
export const navigationItems: readonly NavigationItem[] = [
{ label: 'Overview', path: '/', description: 'System health and current activity' },
{ label: 'Conversations', path: '/conversations', description: 'Inbox and message history' },
{ label: 'Media', path: '/media', description: 'Private media and sync controls' },
{ label: 'Performance', path: '/performance', description: 'Response and strategy trends' },
{ label: 'Operations', path: '/operations', description: 'Jobs, events, and diagnostics' },
{ label: 'Settings', path: '/settings', description: 'Runtime and access configuration' },
];
+17
View File
@@ -0,0 +1,17 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import type { PropsWithChildren } from 'react';
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 15_000,
retry: 1,
refetchOnWindowFocus: false,
},
mutations: { retry: false },
},
});
export function AppProviders({ children }: PropsWithChildren) {
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
}
+23
View File
@@ -0,0 +1,23 @@
import { createBrowserRouter, type RouteObject } from 'react-router-dom';
import { AppShell } from './app-shell.js';
import { OverviewPage } from '../features/overview/overview-page.js';
import { PlaceholderPage } from '../features/placeholder-page.js';
export function createDashboardRoutes(): RouteObject[] {
return [
{
path: '/',
element: <AppShell />,
children: [
{ index: true, element: <OverviewPage /> },
{ path: 'conversations', element: <PlaceholderPage title="Conversations" description="Review conversations, drafts, and replies." /> },
{ path: 'media', element: <PlaceholderPage title="Media" description="Browse private media and monitor syncs." /> },
{ path: 'performance', element: <PlaceholderPage title="Performance" description="Understand response and strategy performance." /> },
{ path: 'operations', element: <PlaceholderPage title="Operations" description="Inspect jobs, events, logs, and diagnostics." /> },
{ path: 'settings', element: <PlaceholderPage title="Settings" description="Manage runtime behavior and Reddit access." /> },
],
},
];
}
export const dashboardRouter = createBrowserRouter(createDashboardRoutes());
@@ -0,0 +1,39 @@
import { useEffect, useRef } from 'react';
interface ConfirmationDialogProps {
action: 'start' | 'stop';
isPending: boolean;
onCancel(): void;
onConfirm(): void;
}
export function ConfirmationDialog({ action, isPending, onCancel, onConfirm }: ConfirmationDialogProps) {
const cancelButtonRef = useRef<HTMLButtonElement>(null);
const isStarting = action === 'start';
useEffect(() => {
cancelButtonRef.current?.focus();
}, []);
return (
<div aria-labelledby="confirmation-title" aria-modal="true" className="dialog-backdrop" role="dialog">
<div className="dialog-card">
<span className="eyebrow">Confirm action</span>
<h2 id="confirmation-title">{isStarting ? 'Start bot?' : 'Stop bot?'}</h2>
<p>
{isStarting
? 'The bot will resume automated processing.'
: 'The bot will pause after its current operation.'}
</p>
<div className="dialog-actions">
<button className="button secondary" disabled={isPending} onClick={onCancel} ref={cancelButtonRef} type="button">
Cancel
</button>
<button className={isStarting ? 'button primary' : 'button danger'} disabled={isPending} onClick={onConfirm} type="button">
{isPending ? 'Applying…' : `Confirm ${action}`}
</button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,199 @@
import { useState } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import { dashboardQueryKeys, useSetRunningMutation, useStatsQuery, useTimersQuery } from '../../api/queries.js';
import type { BotStats, Timers } from '../../api/contracts.js';
import { ConfirmationDialog } from '../../components/confirmation-dialog.js';
function errorMessage(error: Error | null): string {
return error?.message ?? 'Something went wrong.';
}
function formatTime(timestamp: number): string {
return new Intl.DateTimeFormat(undefined, { hour: '2-digit', minute: '2-digit' }).format(timestamp);
}
function MetricCard({ label, value, detail, metric }: { label: string; value: string | number; detail: string; metric: string }) {
return (
<article className="metric-card">
<span>{label}</span>
<strong data-metric={metric}>{value}</strong>
<small>{detail}</small>
</article>
);
}
function TimersPanel({ timers }: { timers: Timers }) {
const isReady = timers.nextMessageAvailableAt === null || timers.nextMessageAvailableAt <= Date.now();
return (
<section aria-labelledby="timers-heading" className="panel">
<div className="panel-heading">
<div>
<span className="eyebrow">Rate limits</span>
<h2 id="timers-heading">Sending window</h2>
</div>
<span className={isReady ? 'pill success' : 'pill warning'}>{isReady ? 'Ready' : 'Cooling down'}</span>
</div>
<dl className="definition-list">
<div>
<dt>Next message</dt>
<dd>{isReady ? 'Available now' : formatTime(timers.nextMessageAvailableAt ?? Date.now())}</dd>
</div>
<div>
<dt>Hourly allowance</dt>
<dd>{timers.messagesRemaining} messages remaining</dd>
</div>
<div>
<dt>Allowance resets</dt>
<dd>{formatTime(timers.hourlyResetAt)}</dd>
</div>
</dl>
</section>
);
}
function ActivityPanel({ stats }: { stats: BotStats }) {
return (
<section aria-labelledby="activity-heading" className="panel activity-panel">
<div className="panel-heading">
<div>
<span className="eyebrow">Latest signals</span>
<h2 id="activity-heading">Recent activity</h2>
</div>
</div>
{stats.recentActivity.length === 0 ? (
<div className="empty-state">
<strong>No recent activity yet.</strong>
<span>New bot events will appear here.</span>
</div>
) : (
<ol className="activity-list">
{stats.recentActivity.map((item) => (
<li key={`${item.timestamp}-${item.message}`}>
<span className={`activity-marker ${item.type}`} aria-hidden="true" />
<div><strong>{item.message}</strong><small>{formatTime(item.timestamp)}</small></div>
</li>
))}
</ol>
)}
</section>
);
}
export function OverviewPage() {
const queryClient = useQueryClient();
const statsQuery = useStatsQuery();
const timersQuery = useTimersQuery();
const controlMutation = useSetRunningMutation();
const [confirmationAction, setConfirmationAction] = useState<'start' | 'stop' | null>(null);
const isRefreshing = (statsQuery.isFetching || timersQuery.isFetching) && !statsQuery.isPending;
async function refreshOverview() {
await Promise.all([
queryClient.invalidateQueries({ queryKey: dashboardQueryKeys.stats }),
queryClient.invalidateQueries({ queryKey: dashboardQueryKeys.timers }),
]);
}
function confirmControl() {
if (!confirmationAction) return;
controlMutation.mutate(confirmationAction === 'start', {
onSuccess: () => setConfirmationAction(null),
});
}
if (statsQuery.isPending) {
return <div aria-label="Loading overview" className="page-state" role="status"><span className="spinner" />Loading overview…</div>;
}
if (statsQuery.isError) {
return (
<div className="page-state error-state" role="alert">
<strong>Overview unavailable</strong>
<span>{errorMessage(statsQuery.error)}</span>
<button className="button secondary" onClick={() => void statsQuery.refetch()} type="button">Retry overview</button>
</div>
);
}
const stats = statsQuery.data;
const action = stats.isRunning ? 'stop' : 'start';
return (
<div className="page">
<header className="page-header">
<div>
<span className="eyebrow">Control room</span>
<h1>Overview</h1>
<p>System health, sending limits, and the signals that need attention.</p>
</div>
<div className="header-actions">
<button
aria-label={isRefreshing ? 'Refreshing overview' : 'Refresh overview'}
className="button secondary"
disabled={isRefreshing}
onClick={() => void refreshOverview()}
type="button"
>
{isRefreshing ? 'Refreshing…' : 'Refresh'}
</button>
<button
aria-label={`${action === 'start' ? 'Start' : 'Stop'} bot`}
className={action === 'start' ? 'button primary' : 'button danger'}
onClick={() => setConfirmationAction(action)}
type="button"
>
{action === 'start' ? 'Start bot' : 'Stop bot'}
</button>
</div>
</header>
<section aria-label="Bot state" className="status-strip">
<div>
<span className={`status-dot ${stats.isRunning ? 'running' : 'stopped'}`} aria-hidden="true" />
<div><small>Bot status</small><strong data-bot-status>{stats.isRunning ? 'Running' : 'Stopped'}</strong></div>
</div>
{stats.dryRunMode ? <span className="pill neutral">Dry run</span> : null}
<span className="status-note">{stats.messagesThisHour} of {stats.maxMessagesPerHour} messages used this hour</span>
</section>
{stats.invitationCooldown ? (
<div className="cooldown-banner" role="status">
<strong>Reddit invitation cooldown</strong>
<span>{stats.invitationCooldown.reason}</span>
</div>
) : null}
<section aria-label="Key metrics" className="metrics-grid">
<MetricCard detail="All recorded sends" label="Messages sent" metric="messages-sent" value={stats.totalMessagesSent} />
<MetricCard detail={`${stats.responseRate}% response rate`} label="Responses" metric="responses" value={stats.totalResponses} />
<MetricCard detail="Posts evaluated" label="Processed" metric="processed" value={stats.totalProcessed} />
<MetricCard detail={`Limit ${stats.maxMessagesPerHour}`} label="This hour" metric="messages-hour" value={stats.messagesThisHour} />
</section>
<div className="overview-grid">
{timersQuery.isPending ? (
<section aria-label="Loading timers" className="panel panel-loading" role="status"><span className="spinner" />Loading sending window…</section>
) : timersQuery.isError ? (
<section className="panel error-panel" role="alert">
<strong>Sending window unavailable</strong>
<span>{errorMessage(timersQuery.error)}</span>
<button className="button secondary" onClick={() => void timersQuery.refetch()} type="button">Retry timers</button>
</section>
) : (
<TimersPanel timers={timersQuery.data} />
)}
<ActivityPanel stats={stats} />
</div>
{controlMutation.isError ? <div className="toast error" role="alert">{errorMessage(controlMutation.error)}</div> : null}
{confirmationAction ? (
<ConfirmationDialog
action={confirmationAction}
isPending={controlMutation.isPending}
onCancel={() => setConfirmationAction(null)}
onConfirm={confirmControl}
/>
) : null}
</div>
);
}
@@ -0,0 +1,15 @@
interface PlaceholderPageProps {
title: string;
description: string;
}
export function PlaceholderPage({ title, description }: PlaceholderPageProps) {
return (
<div className="page">
<header className="page-header">
<div><span className="eyebrow">Workspace</span><h1>{title}</h1><p>{description}</p></div>
</header>
<section className="panel empty-state"><strong>{title} workspace</strong><span>This route is ready for its feature module.</span></section>
</div>
);
}
+17
View File
@@ -0,0 +1,17 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { RouterProvider } from 'react-router-dom';
import { AppProviders } from './app/providers.js';
import { dashboardRouter } from './app/router.js';
import './styles.css';
const rootElement = document.getElementById('root');
if (!rootElement) throw new Error('Dashboard root element is missing');
createRoot(rootElement).render(
<StrictMode>
<AppProviders>
<RouterProvider router={dashboardRouter} />
</AppProviders>
</StrictMode>,
);
+113
View File
@@ -0,0 +1,113 @@
:root {
color: #e9f1ec;
background: #09110e;
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
font-synthesis: none;
text-rendering: optimizeLegibility;
--surface: #101a16;
--surface-raised: #17231e;
--border: #2a3a33;
--muted: #9aaca3;
--accent: #75d69d;
--accent-dark: #153c28;
--danger: #ff8a84;
--warning: #f3c76b;
}
* { box-sizing: border-box; }
body { margin: 0; min-width: 320px; min-height: 100vh; }
button, input { font: inherit; }
button { cursor: pointer; }
button:disabled { cursor: wait; opacity: .6; }
a { color: inherit; }
.skip-link { position: fixed; left: 1rem; top: -5rem; z-index: 100; background: white; color: black; padding: .75rem 1rem; border-radius: .5rem; }
.skip-link:focus { top: 1rem; }
.app-shell { min-height: 100vh; }
.sidebar { position: fixed; inset: 0 auto 0 0; z-index: 30; width: 17.5rem; padding: 1.5rem 1rem; visibility: hidden; background: #0c1511; border-right: 1px solid var(--border); transform: translateX(-100%); transition: transform 180ms ease, visibility 180ms; }
.sidebar.is-open { visibility: visible; transform: translateX(0); }
.brand { display: flex; align-items: center; gap: .75rem; padding: 0 .5rem 1.75rem; }
.brand-mark { display: grid; place-items: center; width: 2.4rem; height: 2.4rem; border-radius: .75rem; color: #07130c; background: var(--accent); font-weight: 900; }
.brand div { display: grid; gap: .1rem; }
.brand span:last-child, .brand div span { color: var(--muted); font-size: .75rem; }
nav { display: grid; gap: .35rem; }
.nav-link { display: grid; gap: .2rem; padding: .8rem .75rem; color: var(--muted); text-decoration: none; border-radius: .75rem; border: 1px solid transparent; }
.nav-link span { color: #dfe9e3; font-weight: 650; }
.nav-link small { font-size: .72rem; line-height: 1.3; }
.nav-link:hover { background: var(--surface); }
.nav-link.is-active { background: var(--accent-dark); border-color: #28593d; }
.nav-link.is-active span { color: #b8f1cd; }
.navigation-backdrop { position: fixed; inset: 0; z-index: 20; width: 100%; border: 0; background: rgba(0, 0, 0, .6); }
.workspace { min-height: 100vh; }
.mobile-header { position: sticky; top: 0; z-index: 15; display: flex; align-items: center; justify-content: space-between; height: 4rem; padding: 0 1rem; background: rgba(9, 17, 14, .92); border-bottom: 1px solid var(--border); backdrop-filter: blur(12px); font-weight: 700; }
.icon-button { width: 2.5rem; height: 2.5rem; border: 1px solid var(--border); border-radius: .6rem; color: white; background: var(--surface); font-size: 1.25rem; }
main { min-height: calc(100vh - 4rem); }
.page { width: min(78rem, 100%); margin: 0 auto; padding: 1.5rem 1rem 4rem; }
.page-header { display: flex; flex-wrap: wrap; align-items: flex-end; justify-content: space-between; gap: 1.5rem; margin-bottom: 1.5rem; }
h1, h2, p { margin-top: 0; }
h1 { margin-bottom: .4rem; font-size: clamp(2rem, 4vw, 3rem); line-height: 1; letter-spacing: -.045em; }
h2 { margin-bottom: 0; font-size: 1.1rem; }
.page-header p { max-width: 42rem; margin-bottom: 0; color: var(--muted); }
.eyebrow { display: block; margin-bottom: .45rem; color: var(--accent); font-size: .72rem; font-weight: 800; letter-spacing: .11em; text-transform: uppercase; }
.header-actions, .dialog-actions { display: flex; gap: .75rem; }
.button { min-height: 2.6rem; padding: .55rem .9rem; border-radius: .65rem; border: 1px solid transparent; color: white; font-weight: 700; }
.button.primary { color: #06140b; background: var(--accent); }
.button.secondary { border-color: var(--border); background: var(--surface-raised); }
.button.danger { color: #2d0907; background: var(--danger); }
.status-strip { display: flex; flex-wrap: wrap; align-items: center; gap: 1rem; padding: 1rem; margin-bottom: 1rem; background: linear-gradient(110deg, #13221b, #0f1915); border: 1px solid var(--border); border-radius: .9rem; }
.status-strip > div { display: flex; align-items: center; gap: .75rem; }
.status-strip > div div { display: grid; }
.status-strip small { color: var(--muted); }
.status-dot { width: .7rem; height: .7rem; border-radius: 50%; }
.status-dot.running { background: var(--accent); box-shadow: 0 0 0 .35rem rgba(117, 214, 157, .12); }
.status-dot.stopped { background: var(--danger); box-shadow: 0 0 0 .35rem rgba(255, 138, 132, .1); }
.pill { display: inline-flex; align-items: center; padding: .3rem .55rem; border-radius: 999px; font-size: .75rem; font-weight: 800; }
.pill.neutral { color: #d4dfda; background: #2b3832; }
.pill.success { color: #aaf0c4; background: #163a26; }
.pill.warning { color: #ffe1a0; background: #42351b; }
.status-note { margin-left: auto; color: var(--muted); font-size: .85rem; }
.cooldown-banner { display: grid; gap: .25rem; padding: .9rem 1rem; margin-bottom: 1rem; color: #ffe1a0; background: #2a2111; border: 1px solid #59451e; border-radius: .8rem; }
.cooldown-banner span { font-size: .85rem; }
.metrics-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: .75rem; margin-bottom: .75rem; }
.metric-card, .panel { background: var(--surface); border: 1px solid var(--border); border-radius: .9rem; }
.metric-card { display: grid; gap: .3rem; padding: 1rem; }
.metric-card > span, .metric-card small { color: var(--muted); }
.metric-card strong { font-size: 1.75rem; letter-spacing: -.04em; }
.overview-grid { display: grid; gap: .75rem; }
.panel { padding: 1rem; }
.panel-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 1rem; margin-bottom: 1rem; }
.definition-list { display: grid; gap: .75rem; margin: 0; }
.definition-list > div { display: flex; justify-content: space-between; gap: 1rem; padding-top: .75rem; border-top: 1px solid var(--border); }
.definition-list dt { color: var(--muted); }
.definition-list dd { margin: 0; text-align: right; font-weight: 700; }
.empty-state { display: grid; place-items: center; gap: .4rem; min-height: 10rem; text-align: center; color: var(--muted); }
.empty-state strong { color: #e9f1ec; }
.activity-list { display: grid; gap: .75rem; padding: 0; margin: 0; list-style: none; }
.activity-list li { display: flex; align-items: flex-start; gap: .75rem; }
.activity-list li div { display: grid; gap: .15rem; }
.activity-list small { color: var(--muted); }
.activity-marker { flex: 0 0 auto; width: .55rem; height: .55rem; margin-top: .3rem; border-radius: 50%; background: var(--muted); }
.activity-marker.sent, .activity-marker.response { background: var(--accent); }
.activity-marker.error { background: var(--danger); }
.page-state { display: grid; place-items: center; align-content: center; gap: 1rem; min-height: 60vh; color: var(--muted); }
.error-state strong, .error-panel strong { color: var(--danger); }
.error-panel, .panel-loading { display: grid; place-items: center; align-content: center; gap: .75rem; min-height: 14rem; text-align: center; color: var(--muted); }
.spinner { width: 1.4rem; height: 1.4rem; border: 2px solid var(--border); border-top-color: var(--accent); border-radius: 50%; animation: spin .8s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
.dialog-backdrop { position: fixed; inset: 0; z-index: 60; display: grid; place-items: center; padding: 1rem; background: rgba(0, 0, 0, .72); }
.dialog-card { width: min(28rem, 100%); padding: 1.4rem; background: var(--surface-raised); border: 1px solid var(--border); border-radius: 1rem; box-shadow: 0 2rem 5rem rgba(0, 0, 0, .4); }
.dialog-card p { color: var(--muted); }
.dialog-actions { justify-content: flex-end; margin-top: 1.5rem; }
.toast { position: fixed; right: 1rem; bottom: 1rem; max-width: 25rem; padding: .8rem 1rem; border-radius: .7rem; }
.toast.error { color: #ffd1ce; background: #461b18; border: 1px solid #80352f; }
@media (min-width: 48rem) {
.sidebar { visibility: visible; transform: none; }
.workspace { margin-left: 17.5rem; }
.mobile-header { display: none; }
main { min-height: 100vh; }
.page { padding: 3rem 2rem 5rem; }
.navigation-backdrop { display: none; }
.metrics-grid { grid-template-columns: repeat(4, minmax(0, 1fr)); }
.overview-grid { grid-template-columns: minmax(18rem, .8fr) minmax(22rem, 1.2fr); }
}
+21
View File
@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"allowJs": false,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"types": ["vite/client"]
},
"include": ["src", "vite.config.ts"]
}
+17
View File
@@ -0,0 +1,17 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import { fileURLToPath } from 'node:url';
export default defineConfig({
root: fileURLToPath(new URL('.', import.meta.url)),
plugins: [react()],
build: {
outDir: '../../dist-dashboard',
emptyOutDir: true,
},
server: {
proxy: {
'/api': 'http://localhost:3000',
},
},
});