0c49b132ee
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>
309 lines
8.8 KiB
TypeScript
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>
|
|
);
|
|
}
|