Files
patchpass/UI/src/components/Layout.tsx
T
space 0c49b132ee
Deploy / Build (pull_request) Successful in 49s
Deploy / Build and Push Docker Image (pull_request) Has been skipped
Deploy / Test & Lint (pull_request) Successful in 34s
feat(ui): make the UI feel native on mobile
Reworks the frontend around phone-first interaction patterns and makes the
app installable to a home screen.

Shell
- Bottom tab bar (Home / Requests / Agents / Alerts / Settings) below `md`,
  with the unread badge moved onto the Alerts tab; the bell stays on desktop.
- Account menu opens as a bottom sheet on phones, dropdown on desktop.
- Safe-area insets throughout: `viewport-fit=cover` plus `env(safe-area-inset-*)`
  on the sticky header, tab bar and sheet footers.

Sheets
- New `Sheet` primitive: drag-to-dismiss bottom sheet on phones, centred dialog
  from `sm` up. `Modal` now delegates to it, so every dialog inherits the
  gesture, the scroll lock and the safe-area padding.
- Portalled to `<body>` — an ancestor with a transform (the page fade-in) was
  otherwise becoming the containing block and displacing the fixed overlay.
- Scroll lock pins `<body>` and restores position, which iOS needs; plain
  `overflow: hidden` still rubber-bands there.
- `ConfirmSheet` replaces `window.confirm` for destructive actions.

Screens
- Request detail gets a sticky decision bar above the tab bar; the sidebar
  decision card is now desktop-only.
- Requests state filter becomes a swipeable pill row instead of a select.
- Agent cards show two primary actions plus an overflow sheet on phones.
- Diffs and code blocks contain their horizontal overscroll so a sideways swipe
  no longer triggers browser back; diffs gain a line-wrap toggle.
- `min-w-0` on grid tracks — items default to `min-width: auto`, so truncated
  meta lines were widening columns past the viewport at 320px.

Touch and input
- 44px minimum touch targets, `:active` press feedback, no tap highlight.
- 16px inputs on coarse pointers so iOS stops zooming on focus.
- autocomplete/inputmode hints so password managers and keyboards behave.
- `overscroll-behavior-y: none` disables pull-to-refresh; motion respects
  `prefers-reduced-motion`.

PWA
- Manifest, generated app icons (192/512/apple-touch) and standalone display
  metadata, so the app installs to a home screen without browser chrome.
- Backend serves `.webmanifest` as `application/manifest+json`; rjweb's type
  map has no entry for it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 00:43:48 +02:00

309 lines
8.8 KiB
TypeScript

import { ReactNode, useEffect, useRef, useState } from "react";
import { Link, NavLink, useNavigate } from "react-router-dom";
import { useAuth } from "../context/AuthContext";
import { useNotifications } from "../context/NotificationsContext";
import { NotificationBell } from "./NotificationBell";
import { Sheet } from "./Sheet";
import {
AgentsIcon,
BellIcon,
HomeIcon,
LogOutIcon,
RequestsIcon,
SettingsIcon,
ShieldIcon,
} from "./icons";
const navItems = [
{ to: "/", label: "Dashboard", end: true },
{ to: "/requests", label: "Requests" },
{ to: "/agents", label: "Agents" },
{ to: "/settings", label: "Settings" },
];
const tabs = [
{ to: "/", label: "Home", Icon: HomeIcon, end: true },
{ to: "/requests", label: "Requests", Icon: RequestsIcon },
{ to: "/agents", label: "Agents", Icon: AgentsIcon },
{ to: "/notifications", label: "Alerts", Icon: BellIcon, badge: true },
{ to: "/settings", label: "Settings", Icon: SettingsIcon },
];
export function Layout({ children }: { children: ReactNode }) {
const { user } = useAuth();
return (
<div className="flex min-h-[100dvh] flex-col">
<header
data-chrome
className="pt-safe px-safe sticky top-0 z-30 border-b border-border bg-bg/85 backdrop-blur-xl"
>
<div className="mx-auto flex h-14 max-w-6xl items-center justify-between gap-2 px-3 sm:h-16 sm:px-4">
<div className="flex min-w-0 items-center gap-3 sm:gap-6">
<Link to="/" className="tap-sm flex items-center gap-2">
<img src="/icon.svg" alt="" className="h-7 w-7 rounded-lg" />
<span className="text-base font-bold tracking-tight sm:text-lg">
Patch<span className="text-primary">Pass</span>
</span>
</Link>
<nav className="hidden items-center gap-1 md:flex">
{navItems.map((item) => (
<NavLink
key={item.to}
to={item.to}
end={item.end}
className={({ isActive }) =>
`rounded-lg px-3 py-1.5 text-sm font-medium transition ${
isActive ? "bg-surface-raised text-text" : "text-muted hover:text-text"
}`
}
>
{item.label}
</NavLink>
))}
{user?.role === "ADMIN" && (
<NavLink
to="/admin"
className={({ isActive }) =>
`rounded-lg px-3 py-1.5 text-sm font-medium transition ${
isActive ? "bg-surface-raised text-accent" : "text-accent/70 hover:text-accent"
}`
}
>
Admin
</NavLink>
)}
</nav>
</div>
<div className="flex shrink-0 items-center gap-1 sm:gap-2">
{/* Below md the bell is replaced by the Alerts tab. */}
<div className="hidden md:block">
<NotificationBell />
</div>
<AccountMenu />
</div>
</div>
</header>
<main className="mx-auto w-full max-w-6xl flex-1 px-3 py-5 sm:px-4 sm:py-8">{children}</main>
<footer className="mx-auto max-w-6xl px-4 py-8 text-center text-xs text-faint">
<div className="flex flex-wrap items-center justify-center gap-x-3">
<Link to="/privacy" className="inline-flex min-h-11 items-center hover:text-muted sm:min-h-0">
Privacy Policy
</Link>
<span aria-hidden>·</span>
<Link to="/terms" className="inline-flex min-h-11 items-center hover:text-muted sm:min-h-0">
Terms of Service
</Link>
<span className="hidden sm:inline" aria-hidden>
·
</span>
<span className="w-full sm:w-auto">
PatchPass review changes, approve intent, let agents proceed.
</span>
</div>
</footer>
{/* Keeps the last of the page clear of the fixed tab bar. */}
<div
className="h-[calc(var(--bottom-nav-h)_+_var(--safe-bottom))] shrink-0 md:hidden"
aria-hidden
/>
<BottomTabs />
</div>
);
}
function BottomTabs() {
const { unreadCount } = useNotifications();
return (
<nav
data-chrome
className="pb-safe px-safe fixed inset-x-0 bottom-0 z-40 border-t border-border bg-bg/90 backdrop-blur-xl md:hidden"
>
<div className="mx-auto flex h-[var(--bottom-nav-h)] max-w-md items-stretch">
{tabs.map(({ to, label, Icon, end, badge }) => (
<NavLink key={to} to={to} end={end} className="flex flex-1 items-stretch">
{({ isActive }) => (
<span
className={`tap-sm flex w-full flex-col items-center justify-center gap-1 ${
isActive ? "text-primary" : "text-faint"
}`}
>
<span className="relative">
<Icon
className="h-[22px] w-[22px] transition-transform"
strokeWidth={isActive ? 2.2 : 1.7}
/>
{badge && unreadCount > 0 && (
<span className="absolute -right-2.5 -top-1 flex h-4 min-w-4 items-center justify-center rounded-full bg-rejected px-1 text-[10px] font-bold text-white">
{unreadCount > 9 ? "9+" : unreadCount}
</span>
)}
</span>
<span className="text-[10px] font-medium leading-none">{label}</span>
</span>
)}
</NavLink>
))}
</div>
</nav>
);
}
function AccountMenu() {
const { user, logout } = useAuth();
const navigate = useNavigate();
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
const onDown = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
};
document.addEventListener("mousedown", onDown);
return () => document.removeEventListener("mousedown", onDown);
}, [open]);
const signOut = async () => {
setOpen(false);
await logout();
navigate("/login");
};
const initial = user?.display_name?.[0]?.toUpperCase() ?? "?";
return (
<div className="relative" ref={ref}>
<button
onClick={() => setOpen((o) => !o)}
className="tap-sm flex min-h-11 items-center gap-2 rounded-lg px-2 py-1.5 text-sm hover:bg-surface-raised"
aria-label="Account menu"
aria-expanded={open}
>
<span className="flex h-8 w-8 items-center justify-center rounded-full bg-primary-dim/50 text-xs font-semibold text-primary">
{initial}
</span>
<span className="hidden text-text sm:inline">{user?.display_name}</span>
</button>
{/* Desktop: dropdown. */}
{open && (
<div className="animate-fade-in absolute right-0 mt-2 hidden w-48 rounded-xl border border-border-strong bg-surface py-1 shadow-2xl sm:block">
<MenuLink to="/settings" onClick={() => setOpen(false)}>
Settings
</MenuLink>
{user?.role === "ADMIN" && (
<MenuLink to="/admin" onClick={() => setOpen(false)} accent>
Admin
</MenuLink>
)}
<button
onClick={signOut}
className="block w-full px-4 py-2 text-left text-sm text-rejected hover:bg-surface-raised"
>
Log out
</button>
</div>
)}
{/* Mobile: bottom sheet, which is reachable one-handed. */}
<div className="sm:hidden">
<Sheet open={open} onClose={() => setOpen(false)} padded={false}>
<div className="flex items-center gap-3 px-5 pb-4 pt-3">
<span className="flex h-11 w-11 items-center justify-center rounded-full bg-primary-dim/50 text-base font-semibold text-primary">
{initial}
</span>
<div className="min-w-0">
<p className="truncate font-semibold text-text">{user?.display_name}</p>
<p className="truncate text-xs text-muted">@{user?.username}</p>
</div>
</div>
<div className="border-t border-border">
<SheetRow
icon={<SettingsIcon className="h-5 w-5" />}
label="Settings"
onClick={() => {
setOpen(false);
navigate("/settings");
}}
/>
{user?.role === "ADMIN" && (
<SheetRow
icon={<ShieldIcon className="h-5 w-5" />}
label="Admin"
accent
onClick={() => {
setOpen(false);
navigate("/admin");
}}
/>
)}
<SheetRow
icon={<LogOutIcon className="h-5 w-5" />}
label="Log out"
danger
onClick={signOut}
/>
</div>
</Sheet>
</div>
</div>
);
}
function MenuLink({
to,
onClick,
accent,
children,
}: {
to: string;
onClick: () => void;
accent?: boolean;
children: ReactNode;
}) {
return (
<Link
to={to}
onClick={onClick}
className={`block px-4 py-2 text-sm hover:bg-surface-raised ${
accent ? "text-accent" : "text-muted hover:text-text"
}`}
>
{children}
</Link>
);
}
function SheetRow({
icon,
label,
onClick,
accent,
danger,
}: {
icon: ReactNode;
label: string;
onClick: () => void;
accent?: boolean;
danger?: boolean;
}) {
return (
<button
onClick={onClick}
className={`flex min-h-14 w-full items-center gap-3.5 border-b border-border/60 px-5 text-left text-[15px] font-medium transition active:bg-surface-raised ${
danger ? "text-rejected" : accent ? "text-accent" : "text-text"
}`}
>
<span className={danger ? "" : accent ? "" : "text-muted"}>{icon}</span>
{label}
</button>
);
}