feat(ui): layout refinements — sidebar, nav pill, command palette, brand block

This commit is contained in:
null 2026-07-06 12:28:25 -05:00
parent f71b433323
commit 23f79c31e0
5 changed files with 118 additions and 55 deletions

View File

@ -12,6 +12,7 @@ import { scheduleLabel, scheduleValue } from '@/lib/billingSchedule';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'; import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { BrandGlyph } from '@/components/brand/Brand';
import type { Bill } from '@/types'; import type { Bill } from '@/types';
// ─── Navigation commands ────────────────────────────────────────────────────── // ─── Navigation commands ──────────────────────────────────────────────────────
@ -281,38 +282,47 @@ export default function CommandPalette() {
return ( return (
<Dialog open={open} onOpenChange={setOpen}> <Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="gap-0 overflow-hidden p-0 sm:max-w-2xl"> <DialogContent className="gap-0 overflow-hidden border-border/70 bg-card/95 p-0 shadow-2xl shadow-black/20 backdrop-blur-xl sm:max-w-2xl">
<DialogHeader className="sr-only"> <DialogHeader className="sr-only">
<DialogTitle>Command palette</DialogTitle> <DialogTitle>Command palette</DialogTitle>
</DialogHeader> </DialogHeader>
<div className="flex items-center gap-2 border-b border-border/70 px-4 py-3"> <div className="border-b border-border/70 bg-muted/25 px-4 py-3">
<Search className="h-4 w-4 shrink-0 text-muted-foreground" /> <div className="mb-3 flex items-center gap-3">
<Input <BrandGlyph name="tracker" className="h-10 w-10 rounded-2xl" />
ref={inputRef} <div className="min-w-0">
value={query} <p className="text-sm font-semibold text-foreground">Find your next move</p>
onChange={event => setQuery(event.target.value)} <p className="mt-0.5 text-xs text-muted-foreground">Search bills, jump to a month, or open a workspace.</p>
onKeyDown={event => { </div>
if (event.key === 'Enter') { </div>
if (billResults[0]) openBills(billResults[0]); <div className="flex items-center gap-2 rounded-xl border border-border/70 bg-card/75 px-3 py-1.5 shadow-sm shadow-black/5">
else if (commandResults[0]) runCommand(commandResults[0]); <Search className="h-4 w-4 shrink-0 text-muted-foreground" />
} <Input
}} ref={inputRef}
placeholder="Search bills or type a command…" value={query}
className="h-9 border-0 bg-transparent px-0 text-base shadow-none focus-visible:ring-0" onChange={event => setQuery(event.target.value)}
/> onKeyDown={event => {
{query && ( if (event.key === 'Enter') {
<Button if (billResults[0]) openBills(billResults[0]);
type="button" else if (commandResults[0]) runCommand(commandResults[0]);
size="icon" }
variant="ghost" }}
className="h-7 w-7 shrink-0" placeholder="Search bills, months, pages..."
onClick={() => setQuery('')} className="h-9 border-0 bg-transparent px-0 text-base shadow-none focus-visible:ring-0"
aria-label="Clear search" />
> {query && (
<X className="h-4 w-4" /> <Button
</Button> type="button"
)} size="icon"
variant="ghost"
className="h-7 w-7 shrink-0 rounded-full"
onClick={() => setQuery('')}
aria-label="Clear search"
>
<X className="h-4 w-4" />
</Button>
)}
</div>
</div> </div>
<div className="max-h-[min(32rem,75svh)] overflow-y-auto p-2 space-y-1"> <div className="max-h-[min(32rem,75svh)] overflow-y-auto p-2 space-y-1">
@ -322,8 +332,12 @@ export default function CommandPalette() {
Loading Loading
</div> </div>
) : !hasResults ? ( ) : !hasResults ? (
<div className="px-4 py-12 text-center text-sm text-muted-foreground"> <div className="px-4 py-12 text-center">
No results. <div className="mx-auto grid h-11 w-11 place-items-center rounded-2xl border border-border/70 bg-muted/45 text-muted-foreground">
<Search className="h-5 w-5" aria-hidden="true" />
</div>
<p className="mt-3 text-sm font-medium text-foreground">Nothing matched that search.</p>
<p className="mt-1 text-xs text-muted-foreground">Try a bill name, amount, category, or month.</p>
</div> </div>
) : ( ) : (
<> <>

View File

@ -8,12 +8,20 @@ export const BrandBlock = React.memo(function BrandBlock({ adminMode = false }:
<NavLink <NavLink
to={adminMode ? '/admin' : '/'} to={adminMode ? '/admin' : '/'}
aria-label={BRAND.name} aria-label={BRAND.name}
className="group flex items-center gap-2 rounded-xl focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50" className="group flex min-w-0 items-center gap-3 rounded-2xl focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50"
> >
<BrandMark className="h-12 w-auto max-w-[6.75rem] drop-shadow-[0_1px_2px_rgba(0,0,0,0.24)] transition-transform duration-200 group-hover:scale-[1.015]" /> <BrandMark className="h-12 w-auto max-w-[6.75rem] drop-shadow-[0_1px_2px_rgba(0,0,0,0.24)] transition-transform duration-200 group-hover:scale-[1.015]" />
<BrandWordmark className="sr-only" /> <BrandWordmark className="sr-only" />
<span className="hidden min-w-0 flex-col leading-none xl:flex">
<span className="text-[11px] font-semibold uppercase tracking-[0.18em] text-primary/80">
{adminMode ? 'Admin workspace' : 'Home workspace'}
</span>
<span className="mt-1 max-w-[12rem] truncate text-xs text-muted-foreground">
{adminMode ? 'System care and access' : 'Bills, timing, and clarity'}
</span>
</span>
{adminMode && ( {adminMode && (
<span className="hidden sm:inline-flex rounded-full border border-destructive/25 bg-destructive/10 px-2 py-0.5 text-[10px] font-semibold uppercase text-destructive"> <span className="hidden rounded-full border border-destructive/25 bg-destructive/10 px-2 py-0.5 text-[10px] font-semibold uppercase text-destructive sm:inline-flex xl:hidden">
Admin Admin
</span> </span>
)} )}

View File

@ -2,6 +2,8 @@ import { useState, useEffect } from 'react';
import { Link, Outlet, useLocation } from 'react-router-dom'; import { Link, Outlet, useLocation } from 'react-router-dom';
import AppNavigation from './Sidebar'; import AppNavigation from './Sidebar';
import { api } from '@/api'; import { api } from '@/api';
import { APP_VERSION } from '@/lib/version';
import { BRAND } from '@/lib/brand';
import PageTransition from '@/components/PageTransition'; import PageTransition from '@/components/PageTransition';
import { FormatSync } from '@/components/FormatSync'; import { FormatSync } from '@/components/FormatSync';
@ -45,12 +47,18 @@ export default function Layout({ mainContentId }: { mainContentId?: string }) {
</PageTransition> </PageTransition>
</div> </div>
</main> </main>
<footer className="mx-auto flex w-full max-w-[1500px] flex-wrap items-center justify-center gap-x-4 gap-y-2 px-4 pb-6 text-xs text-muted-foreground sm:px-6 lg:px-8" <footer className="mx-auto flex w-full max-w-[1500px] flex-wrap items-center justify-between gap-x-4 gap-y-3 px-4 pb-6 text-xs text-muted-foreground sm:px-6 lg:px-8"
aria-label="Footer" aria-label="Footer"
> >
<Link to="/about" className="underline-offset-4 hover:text-foreground hover:underline">About</Link> <span className="inline-flex items-center gap-2">
<Link to="/release-notes" className="underline-offset-4 hover:text-foreground hover:underline">Release Notes</Link> <span className="h-1.5 w-1.5 rounded-full bg-primary" aria-hidden="true" />
<SimplefinBadge /> <span>{BRAND.name} v{APP_VERSION}</span>
</span>
<span className="flex flex-wrap items-center gap-x-4 gap-y-2">
<Link to="/about" className="underline-offset-4 hover:text-foreground hover:underline">About</Link>
<Link to="/release-notes" className="underline-offset-4 hover:text-foreground hover:underline">Release Notes</Link>
<SimplefinBadge />
</span>
</footer> </footer>
</div> </div>
); );

View File

@ -30,11 +30,11 @@ export const NavPill = React.memo(function NavPill({ item, onNavigate, badge = 0
end={end} end={end}
onClick={onNavigate} onClick={onNavigate}
className={({ isActive }) => cn( className={({ isActive }) => cn(
'inline-flex items-center gap-2 rounded-full px-3 py-2 text-sm font-medium transition-all', 'inline-flex items-center gap-2 rounded-full border px-3 py-2 text-sm font-medium transition-all',
'focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50', 'focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50',
isActive isActive
? 'bg-primary text-primary-foreground shadow-sm shadow-primary/20' ? 'border-primary/25 bg-primary text-primary-foreground shadow-sm shadow-primary/20'
: 'text-muted-foreground hover:bg-accent/85 hover:text-accent-foreground hover:shadow-sm' : 'border-transparent text-muted-foreground hover:border-border/60 hover:bg-accent/85 hover:text-accent-foreground hover:shadow-sm'
)} )}
> >
<Icon className="h-4 w-4" /> <Icon className="h-4 w-4" />

View File

@ -7,6 +7,7 @@ import {
} from 'lucide-react'; } from 'lucide-react';
import { api } from '@/api'; import { api } from '@/api';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { BRAND } from '@/lib/brand';
import { useAuth } from '@/hooks/useAuth'; import { useAuth } from '@/hooks/useAuth';
import { useOverdueCount } from '@/hooks/useQueries'; import { useOverdueCount } from '@/hooks/useQueries';
import { ThemeToggle } from '@/components/ui/theme-toggle'; import { ThemeToggle } from '@/components/ui/theme-toggle';
@ -77,11 +78,11 @@ function TrackerMenu({ onNavigate, badge = 0, badgeNames = [], items = trackerIt
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<button <button
className={cn( className={cn(
'inline-flex items-center gap-2 rounded-full px-3 py-2 text-sm font-medium transition-all', 'inline-flex items-center gap-2 rounded-full border px-3 py-2 text-sm font-medium transition-all',
'focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50', 'focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50',
isTrackerActive isTrackerActive
? 'bg-primary text-primary-foreground shadow-sm shadow-primary/20' ? 'border-primary/25 bg-primary text-primary-foreground shadow-sm shadow-primary/20'
: 'text-muted-foreground hover:bg-accent/85 hover:text-accent-foreground hover:shadow-sm', : 'border-transparent text-muted-foreground hover:border-border/60 hover:bg-accent/85 hover:text-accent-foreground hover:shadow-sm',
)} )}
aria-expanded={isTrackerActive} aria-expanded={isTrackerActive}
aria-haspopup="menu" aria-haspopup="menu"
@ -135,6 +136,10 @@ function UserMenu({ adminMode = false }: { adminMode?: boolean }) {
); );
const accountToolsAllowed = useMemo(() => !user?.is_default_admin, [user]); const accountToolsAllowed = useMemo(() => !user?.is_default_admin, [user]);
const userRole = useMemo(() => user?.role, [user]); const userRole = useMemo(() => user?.role, [user]);
const initials = useMemo(() => {
const source = name.trim() || (adminMode ? 'Admin' : 'User');
return source.split(/\s+/).slice(0, 2).map(part => part[0]?.toUpperCase()).join('');
}, [adminMode, name]);
const handleLogout = async () => { const handleLogout = async () => {
try { await logout(); } catch { /* ignore */ } try { await logout(); } catch { /* ignore */ }
@ -145,18 +150,23 @@ function UserMenu({ adminMode = false }: { adminMode?: boolean }) {
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<button <button
className="inline-flex h-9 items-center gap-2 rounded-full border border-border/80 bg-muted px-2.5 text-sm font-medium text-foreground shadow-sm transition-all hover:bg-accent hover:text-accent-foreground hover:shadow focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50" className="inline-flex h-9 items-center gap-2 rounded-full border border-border/75 bg-card/75 px-2.5 text-sm font-medium text-foreground shadow-sm shadow-black/5 transition-all hover:bg-accent hover:text-accent-foreground hover:shadow focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50"
aria-label="Open user menu" aria-label="Open user menu"
> >
<span className="flex h-6 w-6 items-center justify-center rounded-full bg-primary/10 text-primary"> <span className="flex h-6 w-6 items-center justify-center rounded-full bg-primary/10 text-[10px] font-bold text-primary">
<User className="h-3.5 w-3.5" /> {initials || <User className="h-3.5 w-3.5" />}
</span> </span>
<span className="hidden max-w-[140px] truncate sm:inline">{name}</span> <span className="hidden max-w-[140px] truncate sm:inline">{name}</span>
<ChevronDown className="h-3.5 w-3.5 text-muted-foreground" /> <ChevronDown className="h-3.5 w-3.5 text-muted-foreground" />
</button> </button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-52"> <DropdownMenuContent align="end" className="w-60">
<DropdownMenuLabel className="truncate">{name}</DropdownMenuLabel> <DropdownMenuLabel>
<span className="block truncate text-sm font-semibold">{name}</span>
<span className="mt-0.5 block text-xs font-normal text-muted-foreground">
{adminMode ? 'Admin workspace' : 'Welcome back'}
</span>
</DropdownMenuLabel>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
{userRole === 'admin' && !adminMode && ( {userRole === 'admin' && !adminMode && (
<> <>
@ -237,11 +247,11 @@ export default function Sidebar({ adminMode = false }: { adminMode?: boolean; ma
}, [adminMode]); }, [adminMode]);
return ( return (
<header className="sticky top-0 z-40 border-b border-border/70 bg-card/95 shadow-sm shadow-black/10 backdrop-blur-xl supports-[backdrop-filter]:bg-card/90"> <header className="sticky top-0 z-40 border-b border-border/65 bg-card/90 shadow-sm shadow-black/10 backdrop-blur-xl supports-[backdrop-filter]:bg-card/82">
<div className="mx-auto flex h-16 w-full max-w-[1500px] items-center gap-4 px-4 sm:px-6 lg:px-8"> <div className="mx-auto flex h-[4.5rem] w-full max-w-[1500px] items-center gap-4 px-4 sm:px-6 lg:px-8">
<BrandBlock adminMode={adminMode} /> <BrandBlock adminMode={adminMode} />
<nav className="hidden items-center gap-1 lg:flex"> <nav className="hidden items-center gap-1.5 lg:flex" aria-label={adminMode ? 'Admin navigation' : 'Primary navigation'}>
{!adminMode && <TrackerMenu badge={overdueCount} badgeNames={overdueNames} items={trackerMenuItems} />} {!adminMode && <TrackerMenu badge={overdueCount} badgeNames={overdueNames} items={trackerMenuItems} />}
{items.map(item => ( {items.map(item => (
<NavPill key={item.to} item={item} /> <NavPill key={item.to} item={item} />
@ -253,18 +263,18 @@ export default function Sidebar({ adminMode = false }: { adminMode?: boolean; ma
<Button <Button
type="button" type="button"
variant="outline" variant="outline"
className="hidden h-9 gap-2 rounded-full bg-card/70 px-3 text-muted-foreground shadow-sm shadow-black/5 md:inline-flex" className="hidden h-9 gap-2 rounded-full border-border/75 bg-card/75 px-3 text-muted-foreground shadow-sm shadow-black/5 md:inline-flex"
onClick={() => window.dispatchEvent(new Event('command-palette:open'))} onClick={() => window.dispatchEvent(new Event('command-palette:open'))}
title="Find a bill" title="Find a bill"
> >
<Search className="h-4 w-4" /> <Search className="h-4 w-4" />
<span className="text-sm">Find</span> <span className="text-sm">Search bills</span>
<kbd className="rounded border border-border bg-background px-1.5 py-0.5 text-[10px] font-semibold text-muted-foreground"> <kbd className="rounded border border-border bg-background px-1.5 py-0.5 text-[10px] font-semibold text-muted-foreground">
Ctrl K Ctrl K
</kbd> </kbd>
</Button> </Button>
)} )}
<ThemeToggle className="rounded-full border border-border/75 bg-card/70 shadow-sm shadow-black/5" /> <ThemeToggle className="rounded-full border border-border/75 bg-card/75 shadow-sm shadow-black/5" />
<UserMenu adminMode={adminMode} /> <UserMenu adminMode={adminMode} />
<Button <Button
type="button" type="button"
@ -281,8 +291,31 @@ export default function Sidebar({ adminMode = false }: { adminMode?: boolean; ma
</div> </div>
{mobileOpen && ( {mobileOpen && (
<div className="max-h-[70vh] overflow-y-auto border-t border-border/70 bg-card/95 px-4 py-3 shadow-lg shadow-black/15 lg:hidden"> <div className="max-h-[76vh] overflow-y-auto border-t border-border/70 bg-card/95 px-4 py-3 shadow-lg shadow-black/15 lg:hidden">
<nav className="mx-auto grid max-w-[1500px] gap-1"> <div className="mx-auto mb-3 flex max-w-[1500px] items-center justify-between gap-3 rounded-2xl border border-border/65 bg-muted/35 px-3 py-3">
<div className="min-w-0">
<p className="text-sm font-semibold text-foreground">{adminMode ? 'Admin workspace' : BRAND.name}</p>
<p className="mt-0.5 truncate text-xs text-muted-foreground">
{adminMode ? 'System care and access' : 'Choose where you want to go next'}
</p>
</div>
{!adminMode && (
<Button
type="button"
variant="outline"
size="sm"
className="shrink-0 rounded-full bg-card/80"
onClick={() => {
setMobileOpen(false);
window.dispatchEvent(new Event('command-palette:open'));
}}
>
<Search className="h-4 w-4" />
Search
</Button>
)}
</div>
<nav className="mx-auto grid max-w-[1500px] gap-1" aria-label={adminMode ? 'Admin mobile navigation' : 'Mobile navigation'}>
{!adminMode && trackerMenuItems.map(item => ( {!adminMode && trackerMenuItems.map(item => (
<NavPill <NavPill
key={item.to} key={item.to}