diff --git a/apps/dashboard/src/app/app-shell.tsx b/apps/dashboard/src/app/app-shell.tsx index 0399ef6..3717307 100644 --- a/apps/dashboard/src/app/app-shell.tsx +++ b/apps/dashboard/src/app/app-shell.tsx @@ -1,6 +1,6 @@ import { useState } from 'react'; import { NavLink, Outlet } from 'react-router-dom'; -import { navigationItems } from './navigation.js'; +import { navigationGroups, navigationItemsByGroup } from './navigation.js'; import { ShellStatusPanel } from './shell-status-panel.js'; import { NotificationBell, NotificationProvider, NotificationViewport } from './notifications.js'; @@ -20,18 +20,23 @@ export function AppShell() { diff --git a/apps/dashboard/src/app/navigation.ts b/apps/dashboard/src/app/navigation.ts index ca7400a..a426e8d 100644 --- a/apps/dashboard/src/app/navigation.ts +++ b/apps/dashboard/src/app/navigation.ts @@ -2,16 +2,28 @@ export interface NavigationItem { label: string; path: string; description: string; + group: NavigationGroup; } +export type NavigationGroup = 'Monitor' | 'Content' | 'Analyse' | 'Configure'; + +/** Render order for the sidebar's group headings. */ +export const navigationGroups: readonly NavigationGroup[] = ['Monitor', 'Content', 'Analyse', 'Configure']; + export const navigationItems: readonly NavigationItem[] = [ - { label: 'Overview', path: '/', description: 'System health and current activity' }, - { label: 'Needs attention', path: '/attention', description: 'Handoffs, failures, and account risks' }, - { label: 'Outreach funnel', path: '/outreach', description: 'Active posts moving toward delivery' }, - { label: 'Conversations', path: '/conversations', description: 'Inbox and message history' }, - { label: 'Media', path: '/media', description: 'Private media and sync controls' }, - { label: 'Exchange library', path: '/media-library', description: 'Images the bot may send when asked' }, - { 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' }, + { label: 'Overview', path: '/', description: 'System health and current activity', group: 'Monitor' }, + { label: 'Needs attention', path: '/attention', description: 'Handoffs, failures, and account risks', group: 'Monitor' }, + { label: 'Outreach funnel', path: '/outreach', description: 'Active posts moving toward delivery', group: 'Monitor' }, + { label: 'Conversations', path: '/conversations', description: 'Inbox and message history', group: 'Monitor' }, + { label: 'Media', path: '/media', description: 'Private media and sync controls', group: 'Content' }, + { label: 'Exchange library', path: '/media-library', description: 'Images the bot may send when asked', group: 'Content' }, + { label: 'Performance', path: '/performance', description: 'Response and strategy trends', group: 'Analyse' }, + { label: 'Operations', path: '/operations', description: 'Jobs, events, and diagnostics', group: 'Analyse' }, + { label: 'Subreddits', path: '/subreddits', description: 'Where the bot posts and how it talks', group: 'Configure' }, + { label: 'Accounts', path: '/accounts', description: 'Reddit sessions and access', group: 'Configure' }, + { label: 'Settings', path: '/settings', description: 'Pacing, safety, and notifications', group: 'Configure' }, ]; + +export function navigationItemsByGroup(group: NavigationGroup): readonly NavigationItem[] { + return navigationItems.filter((item) => item.group === group); +} diff --git a/apps/dashboard/src/app/notifications.tsx b/apps/dashboard/src/app/notifications.tsx index 35c0be8..3d5860d 100644 --- a/apps/dashboard/src/app/notifications.tsx +++ b/apps/dashboard/src/app/notifications.tsx @@ -158,7 +158,7 @@ export function notificationForEvent(event: DashboardEvent): NotificationDraft | ? `Signed in as u/${event.payload.username}.` : `Session status changed to ${event.payload.status}.`), tone: event.payload.status === 'authenticated' ? 'success' : 'error', - href: '/settings', + href: '/accounts', }; case 'conversation.updated': case 'sync.started': diff --git a/apps/dashboard/src/app/router.tsx b/apps/dashboard/src/app/router.tsx index 8d32f7d..e754f08 100644 --- a/apps/dashboard/src/app/router.tsx +++ b/apps/dashboard/src/app/router.tsx @@ -10,6 +10,8 @@ import { RouteErrorPage } from './route-error-page.js'; import { AttentionPage } from '../features/attention/attention-page.js'; import { OutreachPage } from '../features/outreach/outreach-page.js'; import { MediaLibraryPage } from '../features/media-library/media-library-page.js'; +import { SubredditsPage } from '../features/subreddits/subreddits-page.js'; +import { AccountsPage } from '../features/accounts/accounts-page.js'; export function createDashboardRoutes(): RouteObject[] { return [ @@ -27,6 +29,8 @@ export function createDashboardRoutes(): RouteObject[] { { path: 'media-library', element: }, { path: 'performance', element: }, { path: 'operations', element: }, + { path: 'subreddits', element: }, + { path: 'accounts', element: }, { path: 'settings', element: }, ], }, diff --git a/apps/dashboard/src/components/form-fields.tsx b/apps/dashboard/src/components/form-fields.tsx new file mode 100644 index 0000000..70ff0d9 --- /dev/null +++ b/apps/dashboard/src/components/form-fields.tsx @@ -0,0 +1,225 @@ +import type { ReactNode } from 'react'; + +interface FieldShellProps { + error?: string; + /** Guidance shown when there is no error to show in its place. */ + hint?: ReactNode; + label: string; + name: string; +} + +function fieldIds(name: string) { + return { errorId: `${name}-error`, hintId: `${name}-hint` }; +} + +function FieldMessage({ error, hint, name }: FieldShellProps) { + const { errorId, hintId } = fieldIds(name); + if (error) return {error}; + if (hint) return {hint}; + return null; +} + +function describedBy({ error, hint, name }: FieldShellProps): string | undefined { + const { errorId, hintId } = fieldIds(name); + if (error) return errorId; + return hint ? hintId : undefined; +} + +export interface NumberFieldProps extends FieldShellProps { + max?: number; + min?: number; + step?: number | 'any'; + /** Rendered inside the input as a trailing unit, e.g. "min" or "/hr". */ + suffix?: string; + value: string; + onChange(value: string): void; +} + +export function NumberField({ error, hint, label, max, min, name, onChange, step = 1, suffix, value }: NumberFieldProps) { + const shell = { error, hint, label, name }; + return ( + + ); +} + +export interface TextFieldProps extends FieldShellProps { + placeholder?: string; + /** Rendered as a non-editable prefix inside the input, e.g. "r/". */ + prefix?: string; + type?: 'text' | 'url'; + value: string; + onChange(value: string): void; +} + +export function TextField({ error, hint, label, name, onChange, placeholder, prefix, type = 'text', value }: TextFieldProps) { + const shell = { error, hint, label, name }; + return ( + + ); +} + +export interface SelectFieldProps extends FieldShellProps { + options: ReadonlyArray<{ value: Value; label: string }>; + value: Value; + onChange(value: Value): void; +} + +export function SelectField({ error, hint, label, name, onChange, options, value }: SelectFieldProps) { + const shell = { error, hint, label, name }; + return ( + + ); +} + +export interface TextAreaFieldProps extends FieldShellProps { + monospace?: boolean; + placeholder?: string; + rows?: number; + value: string; + onChange(value: string): void; +} + +export function TextAreaField({ error, hint, label, monospace, name, onChange, placeholder, rows = 3, value }: TextAreaFieldProps) { + const shell = { error, hint, label, name }; + return ( +