Phase 5 cleanup

This commit is contained in:
Aerilyn Weber 2026-04-26 18:44:59 +09:00
parent 5536acd67d
commit 76a516a417
136 changed files with 6322 additions and 1985 deletions

View file

@ -0,0 +1,40 @@
'use client';
import { createContext, useContext, useState } from 'react';
import type { ReactNode } from 'react';
interface PageHeader {
title: string;
subtitle?: string;
crumbs?: string[];
actions?: ReactNode;
}
interface PageHeaderContextValue {
header: PageHeader;
setHeader: (h: PageHeader) => void;
}
const PageHeaderContext = createContext<PageHeaderContextValue | null>(null);
export function PageHeaderProvider({ children }: { children: ReactNode }) {
const [header, setHeader] = useState<PageHeader>({ title: 'MeshiTrack' });
return (
<PageHeaderContext.Provider value={{ header, setHeader }}>
{children}
</PageHeaderContext.Provider>
);
}
export function usePageHeader(): PageHeaderContextValue {
const ctx = useContext(PageHeaderContext);
// Return a no-op when rendered outside the provider (e.g. in unit tests).
if (!ctx) {
return {
header: { title: '' },
setHeader: () => {},
};
}
return ctx;
}

View file

@ -0,0 +1,41 @@
'use client';
import { useEffect, type ReactNode } from 'react';
import { usePageHeader } from './PageHeaderContext';
interface SetPageHeaderProps {
title: string;
subtitle?: string;
crumbs?: string[];
actions?: ReactNode;
}
/**
* Call inside a page component to set the TopBar's title/subtitle/crumbs.
* Renders a visually-hidden heading for accessibility and tests.
*/
export function SetPageHeader({ title, subtitle, crumbs, actions }: SetPageHeaderProps) {
const { setHeader } = usePageHeader();
useEffect(() => {
setHeader({ title, subtitle, crumbs, actions });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [title, subtitle, crumbs?.join(',')]);
return (
<h1
aria-hidden="false"
style={{
position: 'absolute',
width: 1,
height: 1,
padding: 0,
margin: -1,
overflow: 'hidden',
clip: 'rect(0,0,0,0)',
whiteSpace: 'nowrap',
border: 0,
}}
>
{title}
</h1>
);
}

View file

@ -1,33 +1,259 @@
import Link from 'next/link';
'use client';
const navItems = [
{ label: 'Dashboard', href: '/dashboard' },
{ label: 'Medicines', href: '/medicines' },
{ label: 'Settings', href: '/settings' },
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { Icon } from '@/components/ui/Icon';
import type { IconName } from '@/components/ui/Icon';
import { Avatar } from '@/components/ui/Avatar';
import { useApi } from '@/lib/useApi';
interface NavItem {
id: string;
label: string;
href: string;
icon: IconName;
section?: string;
badge?: number;
}
const NAV: NavItem[] = [
{ id: 'dashboard', label: 'Dashboard', href: '/dashboard', icon: 'dashboard' },
{
id: 'cabinet',
label: 'Cabinet',
href: '/medicines/cabinet',
icon: 'cabinet',
section: 'Medicines',
},
{
id: 'schedule',
label: 'Schedule & Log',
href: '/medicines/schedule',
icon: 'clock',
section: 'Medicines',
},
{
id: 'regimens',
label: 'Regimens',
href: '/medicines/regimens',
icon: 'list',
section: 'Medicines',
},
{
id: 'organizer',
label: 'Pill Organizer',
href: '/medicines/organizer',
icon: 'calendar',
section: 'Medicines',
},
{
id: 'library',
label: 'Library',
href: '/medicines/library',
icon: 'pill',
section: 'Medicines',
},
{
id: 'refills',
label: 'Shopping list',
href: '/refills',
icon: 'refresh',
section: 'Medicines',
},
{ id: 'purchases', label: 'Purchases', href: '/purchases', icon: 'truck', section: 'Medicines' },
{ id: 'prices', label: 'Prices', href: '/medicine-prices', icon: 'tag', section: 'Medicines' },
{ id: 'stores', label: 'Stores', href: '/stores', icon: 'store', section: 'Medicines' },
{
id: 'activity',
label: 'Activity & Spend',
href: '/medicines/activity',
icon: 'trend',
section: 'Medicines',
},
{ id: 'settings', label: 'Settings', href: '/settings', icon: 'settings' },
];
function groupNav(items: NavItem[]): [string, NavItem[]][] {
const map = new Map<string, NavItem[]>();
for (const item of items) {
const key = item.section ?? '__root__';
if (!map.has(key)) map.set(key, []);
map.get(key)!.push(item);
}
return Array.from(map.entries());
}
export function Sidebar() {
const pathname = usePathname();
const { profile } = useApi();
const displayName = profile?.displayName ?? 'User';
const groups = groupNav(NAV);
return (
<aside className="flex w-64 flex-col border-r bg-white">
<div className="flex h-16 items-center border-b px-6">
<Link href="/dashboard" className="text-xl font-bold text-primary-700">
MeshiTrack
</Link>
<aside
style={{
background: 'var(--bg-elev)',
borderRight: '1px solid var(--border)',
display: 'flex',
flexDirection: 'column',
position: 'sticky',
top: 0,
height: '100vh',
width: 248,
flexShrink: 0,
}}
>
{/* Brand */}
<div
style={{
display: 'flex',
gap: 10,
alignItems: 'center',
padding: '18px 18px 14px',
borderBottom: '1px solid var(--border)',
}}
>
<div
style={{
width: 34,
height: 34,
borderRadius: 10,
display: 'grid',
placeItems: 'center',
background: 'var(--brand-soft)',
flexShrink: 0,
}}
>
<svg width="22" height="22" viewBox="0 0 24 24" aria-hidden="true">
<rect x="3" y="3" width="18" height="18" rx="5" fill="var(--brand)" />
<path
d="M7 15l3-6 2 4 2-3 3 5"
stroke="var(--brand-ink)"
strokeWidth="1.8"
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</div>
<div>
<div
style={{
fontFamily: 'var(--font-display)',
fontSize: 17,
fontWeight: 600,
letterSpacing: '-0.02em',
color: 'var(--ink-strong)',
}}
>
MeshiTrack
</div>
</div>
</div>
<nav className="flex-1 overflow-y-auto p-4">
<ul className="space-y-1">
{navItems.map((item) => (
<li key={item.href}>
<Link
href={item.href}
className="flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium text-gray-700 hover:bg-gray-100 transition-colors"
{/* Navigation */}
<nav
style={{ flex: 1, overflowY: 'auto', padding: '10px 10px 16px' }}
aria-label="Main navigation"
>
{groups.map(([section, items]) => (
<div key={section} style={{ marginBottom: 14 }}>
{section !== '__root__' && (
<div
style={{
fontSize: 10,
fontWeight: 600,
letterSpacing: '0.08em',
textTransform: 'uppercase',
color: 'var(--ink-faint)',
padding: '10px 10px 6px',
}}
>
{item.label}
</Link>
</li>
))}
</ul>
{section}
</div>
)}
{items.map((item) => {
const isActive = pathname === item.href || pathname.startsWith(item.href + '/');
return (
<Link
key={item.id}
href={item.href}
style={{
display: 'flex',
alignItems: 'center',
gap: 10,
width: '100%',
padding: '7px 10px',
borderRadius: 'var(--r-sm)',
fontSize: 13,
color: isActive ? 'var(--brand-soft-ink)' : 'var(--ink-muted)',
background: isActive ? 'var(--brand-soft)' : 'transparent',
fontWeight: isActive ? 500 : 400,
transition: 'background 0.1s, color 0.1s',
whiteSpace: 'nowrap',
overflow: 'hidden',
textDecoration: 'none',
}}
>
<Icon
name={item.icon}
size={16}
style={{ opacity: isActive ? 1 : 0.8, flexShrink: 0 }}
/>
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis' }}>
{item.label}
</span>
{item.badge != null && (
<span
style={{
background: 'var(--danger-soft)',
color: 'var(--danger)',
fontSize: 10,
fontWeight: 600,
padding: '1px 6px',
borderRadius: 8,
minWidth: 18,
textAlign: 'center',
fontVariantNumeric: 'tabular-nums',
}}
>
{item.badge}
</span>
)}
</Link>
);
})}
</div>
))}
</nav>
{/* Footer */}
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 10,
padding: '12px 14px',
borderTop: '1px solid var(--border)',
}}
>
<Avatar name={displayName} size={32} />
<div style={{ minWidth: 0 }}>
<div
style={{
fontSize: 13,
fontWeight: 500,
color: 'var(--ink-strong)',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{displayName}
</div>
<div style={{ fontSize: 11, color: 'var(--ink-muted)' }}>Household owner</div>
</div>
</div>
</aside>
);
}

View file

@ -1,49 +1,130 @@
'use client';
import Link from 'next/link';
import useSWR from 'swr';
import { useApi } from '@/lib/useApi';
import { getHousehold } from '@/services/households';
import { Icon } from '@/components/ui/Icon';
import { IconButton } from '@/components/ui/IconButton';
import { useTheme } from '@/components/ThemeProvider';
import { usePageHeader } from './PageHeaderContext';
export function TopBar() {
const { householdId, profile, isLoading } = useApi();
const name = profile?.displayName ?? 'Unknown';
const initial = name.charAt(0).toUpperCase();
const { header } = usePageHeader();
const { theme, toggleTheme } = useTheme();
const { data: household } = useSWR(householdId ? `household-${householdId}` : null, () =>
getHousehold(householdId!),
);
if (isLoading) {
return (
<header className="flex h-16 items-center justify-between border-b bg-white px-6">
<div className="h-6 w-32 animate-pulse rounded bg-gray-200" />
<div className="h-8 w-8 animate-pulse rounded-full bg-gray-200" />
</header>
);
}
const { title, subtitle, crumbs, actions } = header;
return (
<header className="flex h-16 items-center justify-between border-b bg-white px-6">
<div className="text-sm text-gray-500">
{householdId ? (
<span className="rounded-md border px-3 py-1 font-medium text-gray-700">
{household?.name ?? householdId}
</span>
) : (
<Link
href="/settings"
className="rounded-md border border-amber-300 bg-amber-50 px-3 py-1 text-amber-700 hover:bg-amber-100 transition-colors"
<header
style={{
display: 'flex',
alignItems: 'flex-end',
justifyContent: 'space-between',
padding: '22px 32px 18px',
borderBottom: '1px solid var(--border)',
background: 'var(--bg)',
position: 'sticky',
top: 0,
zIndex: 5,
backdropFilter: 'blur(8px)',
}}
>
<div>
{crumbs && crumbs.length > 1 && (
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 6,
fontSize: 11,
color: 'var(--ink-muted)',
marginBottom: 6,
textTransform: 'uppercase',
letterSpacing: '0.06em',
fontWeight: 500,
}}
>
No household - Create one
</Link>
{crumbs.map((c, i) => (
<span key={i} style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
{i > 0 && <Icon name="chev" size={12} style={{ opacity: 0.4 }} />}
<span style={i === crumbs.length - 1 ? { color: 'var(--ink)' } : undefined}>
{c}
</span>
</span>
))}
</div>
)}
<h1
style={{
fontFamily: 'var(--font-display)',
fontSize: 28,
fontWeight: 500,
letterSpacing: '-0.02em',
color: 'var(--ink-strong)',
margin: 0,
lineHeight: 1.1,
}}
>
{title}
</h1>
{subtitle && (
<div style={{ fontSize: 13, color: 'var(--ink-muted)', marginTop: 4 }}>{subtitle}</div>
)}
</div>
<div className="flex items-center gap-4">
<span className="text-sm text-gray-600">{name}</span>
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-primary-200 text-sm font-medium text-primary-800">
{initial}
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
{/* Search */}
<div
style={{
position: 'relative',
display: 'flex',
alignItems: 'center',
gap: 8,
background: 'var(--bg-elev)',
border: '1px solid var(--border)',
borderRadius: 'var(--r-sm)',
padding: '6px 10px',
minWidth: 280,
color: 'var(--ink-muted)',
}}
>
<Icon name="search" size={14} />
<input
placeholder="Search medicines, regimens, stores…"
style={{
border: 0,
outline: 0,
background: 'transparent',
fontSize: 13,
flex: 1,
color: 'var(--ink)',
}}
aria-label="Search"
/>
<kbd
style={{
fontFamily: 'var(--font-mono)',
fontSize: 10,
padding: '2px 5px',
background: 'var(--bg-inset)',
borderRadius: 4,
border: '1px solid var(--border)',
color: 'var(--ink-muted)',
}}
>
K
</kbd>
</div>
{/* Notifications */}
<IconButton icon="bell" label="Notifications" dot />
{/* Theme toggle */}
<IconButton
icon={theme === 'dark' ? 'sun' : 'moon'}
label="Toggle theme"
onClick={toggleTheme}
/>
{/* Page actions slot */}
{actions}
</div>
</header>
);