feat(dashboard): add React foundation and overview
This commit is contained in:
1 parent
59c3a61a88
commit
51e3e28a9b
20 files changed
+3347
-244
No files matched your search
@@ -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();
|
||||
@@ -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>;
|
||||
@@ -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,
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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' },
|
||||
];
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>,
|
||||
);
|
||||
@@ -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); }
|
||||
}
|
||||
Reference in new issue
Block a user