feat(ui): layout refinements — sidebar, nav pill, command palette, brand block
This commit is contained in:
parent
f71b433323
commit
23f79c31e0
|
|
@ -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>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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" />
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue