Phase 5 cleanup
This commit is contained in:
parent
5536acd67d
commit
76a516a417
136 changed files with 6322 additions and 1985 deletions
40
packages/web/src/components/layout/PageHeaderContext.tsx
Normal file
40
packages/web/src/components/layout/PageHeaderContext.tsx
Normal 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;
|
||||
}
|
||||
41
packages/web/src/components/layout/SetPageHeader.tsx
Normal file
41
packages/web/src/components/layout/SetPageHeader.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue