feat: initialize project structure with core components and pages
CI / Lint, typecheck, test, build (push) Canceled after 0s
CI / End-to-end tests (push) Canceled after 0s

- Added shared styles for UI components in `src/components/styles.ts`.
- Created configuration constants in `src/lib/config.ts`.
- Implemented date formatting and greeting functions in `src/lib/dates.ts` and corresponding tests in `src/lib/dates.test.ts`.
- Developed incident management functions in `src/lib/incidents.ts` with tests in `src/lib/incidents.test.ts`.
- Established navigation structure in `src/lib/navigation.ts`.
- Seeded demo data for incidents and users in `src/lib/seed.ts`.
- Defined tone classes for UI elements in `src/lib/tones.ts`.
- Created type definitions for incidents and related entities in `src/lib/types.ts`.
- Set up main application entry point in `src/main.tsx`.
- Developed various pages including About, Analytics, Incidents, Locations, Overview, and Report Incident.
- Configured testing setup in `src/test/setup.ts` and TypeScript configurations.
- Integrated Vite for development and testing with appropriate plugins.
This commit is contained in:
jayjennings committed 2026-10-06 10:07:57 -07:00
1 parent 732d4d8931
commit a382fa904a
53 files changed
+3523 -31

No files matched your search

+17
View File
@@ -0,0 +1,17 @@
import { useState } from 'react'
import { Outlet } from 'react-router-dom'
import { Header } from './Header'
import { Sidebar } from './Sidebar'
export function AppLayout({ openIncidentCount }: { openIncidentCount: number }) {
const [menuOpen, setMenuOpen] = useState(false)
return (
<div className="flex min-h-screen bg-canvas">
<Sidebar open={menuOpen} onClose={() => setMenuOpen(false)} openIncidentCount={openIncidentCount} />
<main className="min-w-0 flex-1">
<Header onOpenMenu={() => setMenuOpen(true)} />
<Outlet />
</main>
</div>
)
}
+5
View File
@@ -0,0 +1,5 @@
import type { ReactNode } from 'react'
export function Eyebrow({ children, className = 'text-[#8da29b]' }: { children: ReactNode; className?: string }) {
return <span className={`block font-mono text-3xs font-medium tracking-[0.09em] uppercase ${className}`}>{children}</span>
}
+33
View File
@@ -0,0 +1,33 @@
import type { ReactNode } from 'react'
interface FormSectionProps {
number: string
title: string
copy: string
children: ReactNode
}
export function FormSection({ number, title, copy, children }: FormSectionProps) {
return (
<div className="border-b border-line px-5 py-6 sm:px-9 sm:py-7">
<div className="mb-6 flex gap-[13px]">
<span className="pt-[3px] font-mono text-xs text-coral">{number}</span>
<div>
<h2 className="mb-1 font-display text-[19px] font-semibold">{title}</h2>
<p className="text-xs text-muted">{copy}</p>
</div>
</div>
{children}
</div>
)
}
export function Field({ label, optional = false, children }: { label: string; optional?: boolean; children: ReactNode }) {
return (
<label className="mb-[17px] block text-2xs font-semibold text-[#586762]">
{label}
{optional && <em className="float-right font-normal text-[#9ba7a2] not-italic">Optional</em>}
{children}
</label>
)
}
+31
View File
@@ -0,0 +1,31 @@
import { Bell, Menu, Search } from 'lucide-react'
import { useLocation } from 'react-router-dom'
import { pageTitles } from '../lib/navigation'
import { currentUser } from '../lib/seed'
import { iconButton } from './styles'
export function Header({ onOpenMenu }: { onOpenMenu: () => void }) {
const { pathname } = useLocation()
return (
<header className="flex h-[62px] items-center justify-between border-b border-line bg-white/60 px-[17px] sm:h-[72px] sm:px-6 lg:px-[42px]">
<button type="button" className={`${iconButton} mr-2.5 sm:hidden`} onClick={onOpenMenu} aria-label="Open menu">
<Menu size={21} />
</button>
<div className="flex items-center gap-2.5 text-xs text-[#94a09b]">
<span className="max-sm:hidden">Safety workspace</span>
<span className="max-sm:hidden">/</span>
<strong className="text-ink">{pageTitles[pathname] ?? 'Not found'}</strong>
</div>
<div className="flex items-center gap-[15px]">
<button type="button" className={iconButton} aria-label="Search">
<Search size={19} />
</button>
<button type="button" className={`${iconButton} relative`} aria-label="Notifications">
<Bell size={19} />
<i className="absolute top-1 right-[3px] size-1.5 rounded-full border border-canvas bg-coral" />
</button>
<div className="grid size-8 place-items-center rounded-full bg-[#d2e0d8] font-mono text-2xs text-teal">{currentUser.initials}</div>
</div>
</header>
)
}
+80
View File
@@ -0,0 +1,80 @@
import { AlertTriangle, MapPin } from 'lucide-react'
import { formatDate } from '../lib/dates'
import { severityDotClass, statusClass, typeTone } from '../lib/incidents'
import { toneClasses } from '../lib/tones'
import type { Incident } from '../lib/types'
import { iconButton } from './styles'
const th = 'border-b border-line px-2.5 py-3 font-mono text-3xs font-medium tracking-[0.06em] text-faint uppercase first:pl-6 last:pr-[18px]'
const td = 'border-b border-line-soft px-2.5 py-3.5 text-2xs whitespace-nowrap text-body first:pl-6 last:pr-[18px]'
interface IncidentTableProps {
incidents: Incident[]
limit?: number
wide?: boolean
}
export function IncidentTable({ incidents, limit, wide = false }: IncidentTableProps) {
const rows = limit ? incidents.slice(0, limit) : incidents
return (
<div className="overflow-auto">
<table className={`w-full border-collapse text-left ${wide ? 'min-w-[900px]' : 'min-w-[720px]'}`}>
<thead>
<tr>
<th className={th}>Incident</th>
<th className={th}>Type</th>
<th className={th}>Severity</th>
<th className={th}>Location</th>
<th className={th}>Status</th>
<th className={th}>Date</th>
<th className={th}>
<span className="sr-only">Actions</span>
</th>
</tr>
</thead>
<tbody>
{rows.map((incident) => (
<tr key={incident.id} className="[&:last-child>td]:border-b-0">
<td className={td}>
<div className="flex min-w-[250px] items-center gap-2.5">
<span className={`grid size-7 place-items-center rounded-[7px] ${toneClasses[typeTone[incident.type]]}`}>
<AlertTriangle size={14} />
</span>
<div>
<strong className="block text-xs font-semibold text-ink">{incident.title}</strong>
<small className="mt-1 block text-3xs text-faint">
{incident.id} · {incident.reporter}
</small>
</div>
</div>
</td>
<td className={td}>{incident.type}</td>
<td className={td}>
<span className="inline-flex items-center gap-[5px]">
<i className={`size-1.5 rounded-full ${severityDotClass[incident.severity]}`} />
{incident.severity}
</span>
</td>
<td className={td}>
<div className="flex max-w-[120px] flex-wrap items-center gap-1">
<MapPin size={14} />
{incident.location}
<small className="mt-1 ml-[18px] block w-full text-3xs text-faint">{incident.building}</small>
</div>
</td>
<td className={td}>
<span className={`rounded px-2 py-[5px] text-3xs ${statusClass(incident.status)}`}>{incident.status}</span>
</td>
<td className={`${td} text-[#87938f]`}>{formatDate(incident.date)}</td>
<td className={td}>
<button type="button" className={iconButton} aria-label={`More actions for ${incident.id}`}>
<span className="tracking-[2px] text-[#91aaa0]">•••</span>
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)
}
+25
View File
@@ -0,0 +1,25 @@
import type { LucideIcon } from 'lucide-react'
import { toneClasses, type Tone } from '../lib/tones'
interface MetricCardProps {
label: string
value: string
delta: string
icon: LucideIcon
tone: Tone
}
export function MetricCard({ label, value, delta, icon: Icon, tone }: MetricCardProps) {
return (
<div className="rounded-lg border border-line bg-white p-[13px] sm:p-[18px]">
<div className={`grid size-[35px] place-items-center rounded-[7px] ${toneClasses[tone]}`}>
<Icon size={19} />
</div>
<span className="mt-2.5 mb-1.5 block text-xs text-muted sm:mt-[15px]">{label}</span>
<strong className="block font-display text-[25px] font-semibold tracking-[-1px] text-ink sm:text-[30px]">{value}</strong>
<small className="mt-[9px] block text-[9px] text-[#7d8b86] sm:text-2xs">
<b className="text-positive">↗</b> {delta}
</small>
</div>
)
}
+9
View File
@@ -0,0 +1,9 @@
import type { ReactNode } from 'react'
export function Page({ children, narrow = false }: { children: ReactNode; narrow?: boolean }) {
return (
<div className={`mx-auto px-[17px] pt-7 pb-10 sm:px-6 sm:pt-10 sm:pb-[60px] lg:px-[42px] ${narrow ? 'max-w-[900px]' : 'max-w-[1360px]'}`}>
{children}
</div>
)
}
+22
View File
@@ -0,0 +1,22 @@
import type { ReactNode } from 'react'
import { Eyebrow } from './Eyebrow'
interface PageIntroProps {
eyebrow: string
title: string
copy?: string
action?: ReactNode
}
export function PageIntro({ eyebrow, title, copy, action }: PageIntroProps) {
return (
<div className="mb-[31px] flex flex-col items-start gap-5 sm:flex-row sm:items-center sm:justify-between">
<div>
<Eyebrow>{eyebrow}</Eyebrow>
<h1 className="mt-[7px] mb-[5px] font-display text-[29px] font-semibold tracking-[-1px] text-ink sm:text-[34px]">{title}</h1>
{copy && <p className="text-sm text-muted">{copy}</p>}
</div>
{action}
</div>
)
}
+25
View File
@@ -0,0 +1,25 @@
import { ArrowUpRight } from 'lucide-react'
import { NavLink } from 'react-router-dom'
import { Eyebrow } from './Eyebrow'
interface PanelHeadingProps {
eyebrow: string
title: string
link?: { label: string; to: string }
}
export function PanelHeading({ eyebrow, title, link }: PanelHeadingProps) {
return (
<div className="flex items-center justify-between px-[17px] pt-[19px] pb-3.5 sm:px-6 sm:pt-6 sm:pb-[18px]">
<div>
<Eyebrow>{eyebrow}</Eyebrow>
<h2 className="mt-1.5 font-display text-[19px] font-semibold text-ink">{title}</h2>
</div>
{link && (
<NavLink to={link.to} className="inline-flex items-center gap-[5px] text-xs font-bold text-coral">
{link.label} <ArrowUpRight size={15} />
</NavLink>
)}
</div>
)
}
+34
View File
@@ -0,0 +1,34 @@
interface ProgressBarProps {
/** 0–100 */
percent: number
barClassName?: string
className?: string
}
export function ProgressBar({ percent, barClassName = 'bg-coral', className = '' }: ProgressBarProps) {
return (
<div className={`mt-2 h-1.5 overflow-hidden rounded-full bg-[#edf1ed] ${className}`}>
<i className={`block h-full rounded-full ${barClassName}`} style={{ width: `${percent}%` }} />
</div>
)
}
interface ProgressRowProps {
label: string
value: string
percent: number
variant?: 'action' | 'location'
}
export function ProgressRow({ label, value, percent, variant = 'action' }: ProgressRowProps) {
const isLocation = variant === 'location'
return (
<div className={isLocation ? 'px-6 py-[7px]' : 'px-6 pt-3'}>
<div className={`flex justify-between text-2xs ${isLocation ? 'text-[#63726d]' : 'text-muted'}`}>
<span>{label}</span>
<strong className="font-mono font-normal text-ink">{value}</strong>
</div>
<ProgressBar percent={percent} barClassName={isLocation ? 'bg-sage' : 'bg-coral'} />
</div>
)
}
+89
View File
@@ -0,0 +1,89 @@
import { ChevronDown, FileText, Info, Plus, Settings, ShieldCheck, X } from 'lucide-react'
import { NavLink } from 'react-router-dom'
import { primaryNav } from '../lib/navigation'
import { currentUser, workspace } from '../lib/seed'
import { Eyebrow } from './Eyebrow'
import { iconButton } from './styles'
const navLink = ({ isActive }: { isActive: boolean }) =>
`flex h-[42px] items-center gap-3 rounded-md px-3 text-[13px] hover:bg-forest-800 hover:text-white ${isActive ? 'bg-forest-800 text-white' : 'text-forest-200'}`
const navSection = 'mb-6 border-b border-forest-700 pb-[23px]'
interface SidebarProps {
open: boolean
onClose: () => void
openIncidentCount: number
}
export function Sidebar({ open, onClose, openIncidentCount }: SidebarProps) {
return (
<aside
className={`fixed inset-y-0 z-10 flex w-[260px] flex-none flex-col overflow-y-auto bg-forest-950 px-[15px] pt-7 pb-[18px] text-forest-100 shadow-[8px_0_30px_#163c3740] transition-[left] duration-200 sm:static sm:min-h-screen sm:w-[230px] sm:shadow-none lg:w-[246px] ${open ? 'left-0' : '-left-[270px]'}`}
>
<div className="flex items-center gap-2.5 px-3 pb-[31px] text-white">
<div className="grid size-[34px] flex-none place-items-center rounded-[9px] bg-coral text-white">
<ShieldCheck size={20} />
</div>
<div>
<strong className="block font-display text-[19px] leading-tight sm:text-[17px] lg:text-[19px] font-bold tracking-[-0.5px] whitespace-nowrap">Open SafetyLog</strong>
<span className="mt-0.5 block text-3xs text-forest-300">EHS · Incident intelligence</span>
</div>
<button type="button" className={`${iconButton} absolute top-2 right-2 sm:hidden`} onClick={onClose} aria-label="Close menu">
<X size={20} />
</button>
</div>
<div className="mb-[27px] flex items-center gap-2.5 rounded-[7px] border border-[#316158] p-[11px]">
<div className="grid size-[29px] place-items-center rounded-full bg-[#f1c865] font-mono text-2xs font-bold text-[#143d37]">{workspace.initials}</div>
<div>
<Eyebrow>Workspace</Eyebrow>
<strong className="block text-[13px] text-white">{workspace.name}</strong>
</div>
<ChevronDown size={16} className="ml-auto" />
</div>
<nav className={navSection}>
{primaryNav.map(({ label, to, icon: Icon }) => (
<NavLink key={to} to={to} end onClick={onClose} className={navLink}>
<Icon size={18} />
<span>{label}</span>
{to === '/incidents' && openIncidentCount > 0 && (
<em className="ml-auto rounded-[10px] bg-coral px-[7px] py-0.5 font-mono text-2xs text-white not-italic">{openIncidentCount}</em>
)}
</NavLink>
))}
</nav>
<div className={navSection}>
<Eyebrow className="px-3 pb-2 text-[#8da29b]">Manage</Eyebrow>
<NavLink to="/report" onClick={onClose} className={navLink}>
<Plus size={18} />
<span>Report an incident</span>
</NavLink>
<a href="#resources" className={navLink({ isActive: false })}>
<FileText size={18} />
<span>Resources</span>
</a>
</div>
<div className="mt-auto">
<NavLink to="/about" onClick={onClose} className={navLink}>
<Info size={18} />
<span>About</span>
</NavLink>
<a href="#settings" className={navLink({ isActive: false })}>
<Settings size={18} />
<span>Settings</span>
</a>
<div className="mt-4 flex items-center gap-2.5 border-t border-forest-700 px-2 pt-5">
<div className="grid size-[31px] place-items-center rounded-full bg-[#5c8274] font-mono text-2xs text-white">{currentUser.initials}</div>
<div>
<strong className="block text-xs text-white">{currentUser.name}</strong>
<span className="mt-0.5 block text-3xs text-forest-300">{currentUser.role}</span>
</div>
<span className="ml-auto tracking-[2px] text-[#91aaa0]">•••</span>
</div>
</div>
</aside>
)
}
+15
View File
@@ -0,0 +1,15 @@
// Shared Tailwind class strings for elements reused across pages.
export const panel = 'min-w-0 rounded-lg border border-line bg-white shadow-[0_5px_20px_#1b252305]'
const buttonBase = 'inline-flex cursor-pointer items-center justify-center gap-2 rounded-[5px] px-3.5 py-[11px] text-xs font-bold no-underline'
export const primaryButton = `${buttonBase} bg-coral text-white shadow-[0_4px_12px_#ee725833]`
export const secondaryButton = `${buttonBase} border border-line bg-white text-ink`
export const lightButton = `${buttonBase} bg-[#edf6ef] text-teal`
export const iconButton = 'grid cursor-pointer place-items-center bg-transparent p-1.5 text-[#72817d]'
export const field =
'mt-[7px] block w-full rounded-[5px] border border-line bg-field px-3 py-[11px] text-xs text-ink outline-none focus:border-[#83b3a5] focus:shadow-[0_0_0_3px_#83b3a522]'
export const filterBox = 'flex h-[38px] items-center gap-2 rounded-[5px] border border-line px-[11px] text-[#85938e]'