Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 3 additions & 12 deletions ui/src/app/layout/AppShell.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { NavLink, Outlet } from 'react-router-dom'

import { useHealthQuery } from '../../api/hooks/useHealthQuery'
import { MobileBottomNav, type MobileBottomNavLink } from '../../components/ui/MobileBottomNav'
import { useTheme } from '../providers/theme-context'

const DESKTOP_NAV_LINKS = [
Expand All @@ -11,7 +12,7 @@ const DESKTOP_NAV_LINKS = [
{ to: '/system', label: 'System' },
]

const MOBILE_NAV_LINKS = [
const MOBILE_NAV_LINKS: readonly MobileBottomNavLink[] = [
{ to: '/live', label: 'Live' },
{ to: '/events', label: 'Events' },
{ to: '/cameras', label: 'Cameras' },
Expand All @@ -22,10 +23,6 @@ function navLinkClassName({ isActive }: { isActive: boolean }): string {
return isActive ? 'nav-link nav-link--active' : 'nav-link'
}

function mobileNavLinkClassName({ isActive }: { isActive: boolean }): string {
return isActive ? 'mobile-nav-link mobile-nav-link--active' : 'mobile-nav-link'
}

function systemStatusText(status: string | undefined, isError: boolean): string {
if (isError) {
return 'System unavailable'
Expand Down Expand Up @@ -72,13 +69,7 @@ export function AppShell() {
</main>
</div>

<nav className="mobile-bottom-nav" aria-label="Mobile primary">
{MOBILE_NAV_LINKS.map((link) => (
<NavLink key={link.to} to={link.to} className={mobileNavLinkClassName}>
{link.label}
</NavLink>
))}
</nav>
<MobileBottomNav links={MOBILE_NAV_LINKS} />
</div>
)
}
58 changes: 58 additions & 0 deletions ui/src/components/ui/CameraCard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import type { ReactNode } from 'react'

interface CameraCardMetaItem {
label: string
value: ReactNode
}

interface CameraCardProps {
title: string
subtitle?: ReactNode
status?: ReactNode
media?: ReactNode
meta?: readonly CameraCardMetaItem[]
actions?: ReactNode
technicalDetails?: ReactNode
className?: string
}

export function CameraCard({
title,
subtitle,
status,
media,
meta,
actions,
technicalDetails,
className,
}: CameraCardProps) {
const classes = className ? `camera-card ${className}` : 'camera-card'

return (
<article className={classes}>
<header className="camera-card__header">
<div>
<h2 className="camera-card__title">{title}</h2>
{subtitle ? <p className="camera-card__subtitle">{subtitle}</p> : null}
</div>
{status ? <div className="camera-card__status">{status}</div> : null}
</header>

{media ? <div className="camera-card__media">{media}</div> : null}

{meta && meta.length > 0 ? (
<dl className="camera-card__meta">
{meta.map((item) => (
<div key={item.label} className="camera-card__meta-row">
<dt>{item.label}</dt>
<dd>{item.value}</dd>
</div>
))}
</dl>
) : null}

{technicalDetails ? <div className="camera-card__technical">{technicalDetails}</div> : null}
{actions ? <div className="camera-card__actions">{actions}</div> : null}
</article>
)
}
29 changes: 29 additions & 0 deletions ui/src/components/ui/EmptyState.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import type { ReactNode } from 'react'

type EmptyStateTone = 'neutral' | 'loading' | 'error'

interface EmptyStateProps {
title: string
description?: ReactNode
action?: ReactNode
tone?: EmptyStateTone
}

export function EmptyState({
title,
description,
action,
tone = 'neutral',
}: EmptyStateProps) {
return (
<div
className={`empty-state empty-state--${tone}`}
role={tone === 'error' ? 'alert' : undefined}
aria-live={tone === 'loading' ? 'polite' : undefined}
>
<h2 className="empty-state__title">{title}</h2>
{description ? <p className="empty-state__description">{description}</p> : null}
{action ? <div className="empty-state__action">{action}</div> : null}
</div>
)
}
73 changes: 73 additions & 0 deletions ui/src/components/ui/EventCard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import type { ReactNode } from 'react'

interface EventCardMetaItem {
label: string
value: ReactNode
}

interface EventCardProps {
camera: ReactNode
time: ReactNode
title?: ReactNode
summary?: ReactNode
media?: ReactNode
risk?: ReactNode
status?: ReactNode
meta?: readonly EventCardMetaItem[]
actions?: ReactNode
technicalDetails?: ReactNode
className?: string
}

export function EventCard({
camera,
time,
title,
summary,
media,
risk,
status,
meta,
actions,
technicalDetails,
className,
}: EventCardProps) {
const classes = className ? `event-card ${className}` : 'event-card'

return (
<article className={classes}>
{media ? <div className="event-card__media">{media}</div> : null}
<div className="event-card__content">
<header className="event-card__header">
<div>
<p className="event-card__camera">{camera}</p>
<p className="event-card__time">{time}</p>
</div>
{risk || status ? (
<div className="event-card__badges">
{risk}
{status}
</div>
) : null}
</header>

{title ? <h2 className="event-card__title">{title}</h2> : null}
{summary ? <p className="event-card__summary">{summary}</p> : null}

{meta && meta.length > 0 ? (
<dl className="event-card__meta">
{meta.map((item) => (
<div key={item.label} className="event-card__meta-row">
<dt>{item.label}</dt>
<dd>{item.value}</dd>
</div>
))}
</dl>
) : null}

{technicalDetails ? <div className="event-card__technical">{technicalDetails}</div> : null}
{actions ? <div className="event-card__actions">{actions}</div> : null}
</div>
</article>
)
}
37 changes: 37 additions & 0 deletions ui/src/components/ui/FilterChips.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
export interface FilterChip {
id: string
label: string
active?: boolean
disabled?: boolean
}

interface FilterChipsProps {
chips: readonly FilterChip[]
onSelect: (id: string) => void
ariaLabel?: string
}

export function FilterChips({
chips,
onSelect,
ariaLabel = 'Quick filters',
}: FilterChipsProps) {
return (
<div className="filter-chips" role="toolbar" aria-label={ariaLabel}>
{chips.map((chip) => (
<button
key={chip.id}
type="button"
className={chip.active ? 'filter-chip filter-chip--active' : 'filter-chip'}
aria-pressed={chip.active ?? false}
disabled={chip.disabled}
onClick={() => {
onSelect(chip.id)
}}
>
{chip.label}
</button>
))}
</div>
)
}
51 changes: 51 additions & 0 deletions ui/src/components/ui/MediaPanel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import type { PropsWithChildren, ReactNode } from 'react'

type MediaPanelAspect = 'video' | 'square' | 'auto'

interface MediaPanelProps extends PropsWithChildren {
title?: string
subtitle?: ReactNode
status?: ReactNode
actions?: ReactNode
placeholder?: ReactNode
aspect?: MediaPanelAspect
className?: string
}

export function MediaPanel({
title,
subtitle,
status,
actions,
placeholder,
aspect = 'video',
className,
children,
}: MediaPanelProps) {
const classes = className ? `media-panel ${className}` : 'media-panel'
const viewportClasses = `media-panel__viewport media-panel__viewport--${aspect}`
const hasHeader = title || subtitle || status || actions

return (
<section className={classes}>
{hasHeader ? (
<header className="media-panel__header">
<div>
{title ? <h2 className="media-panel__title">{title}</h2> : null}
{subtitle ? <p className="media-panel__subtitle">{subtitle}</p> : null}
</div>
{status || actions ? (
<div className="media-panel__header-actions">
{status}
{actions}
</div>
) : null}
</header>
) : null}

<div className={viewportClasses}>
{children ?? <div className="media-panel__placeholder">{placeholder ?? 'Media unavailable'}</div>}
</div>
</section>
)
}
36 changes: 36 additions & 0 deletions ui/src/components/ui/MobileBottomNav.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { NavLink } from 'react-router-dom'

export interface MobileBottomNavLink {
to: string
label: string
end?: boolean
}

interface MobileBottomNavProps {
links: readonly MobileBottomNavLink[]
ariaLabel?: string
}

function mobileNavLinkClassName({ isActive }: { isActive: boolean }): string {
return isActive ? 'mobile-nav-link mobile-nav-link--active' : 'mobile-nav-link'
}

export function MobileBottomNav({
links,
ariaLabel = 'Mobile primary',
}: MobileBottomNavProps) {
return (
<nav className="mobile-bottom-nav" aria-label={ariaLabel}>
{links.map((link) => (
<NavLink
key={link.to}
to={link.to}
end={link.end}
className={mobileNavLinkClassName}
>
{link.label}
</NavLink>
))}
</nav>
)
}
36 changes: 36 additions & 0 deletions ui/src/components/ui/ResponsivePageShell.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import type { PropsWithChildren, ReactNode } from 'react'

interface ResponsivePageShellProps extends PropsWithChildren {
title: string
lead?: ReactNode
eyebrow?: ReactNode
actions?: ReactNode
className?: string
}

export function ResponsivePageShell({
title,
lead,
eyebrow,
actions,
className,
children,
}: ResponsivePageShellProps) {
const classes = className
? `page responsive-page-shell fade-in-up ${className}`
: 'page responsive-page-shell fade-in-up'

return (
<section className={classes}>
<header className="page__header responsive-page-shell__header">
<div>
{eyebrow ? <p className="responsive-page-shell__eyebrow">{eyebrow}</p> : null}
<h1 className="page__title">{title}</h1>
{lead ? <p className="page__lead">{lead}</p> : null}
</div>
{actions ? <div className="responsive-page-shell__actions">{actions}</div> : null}
</header>
{children}
</section>
)
}
25 changes: 25 additions & 0 deletions ui/src/components/ui/RiskBadge.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import type { HTMLAttributes } from 'react'

import { riskToneForLevel } from './riskTone'

interface RiskBadgeProps extends HTMLAttributes<HTMLSpanElement> {
level: string | null | undefined
}

function riskLabel(level: string | null | undefined): string {
const trimmed = level?.trim()
return trimmed ? trimmed : 'Risk unavailable'
}

export function RiskBadge({ level, className, role, ...props }: RiskBadgeProps) {
const tone = riskToneForLevel(level)
const classes = className
? `risk-badge risk-badge--${tone} ${className}`
: `risk-badge risk-badge--${tone}`

return (
<span className={classes} role={role ?? 'status'} {...props}>
{riskLabel(level)}
</span>
)
}
Loading
Loading