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>
This commit is contained in:
+242
-83
@@ -1,7 +1,18 @@
|
||||
import { ReactNode, useState } from "react";
|
||||
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 },
|
||||
@@ -10,18 +21,27 @@ const navItems = [
|
||||
{ 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, logout } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const { user } = useAuth();
|
||||
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
<header className="sticky top-0 z-30 border-b border-border bg-bg/80 backdrop-blur">
|
||||
<div className="mx-auto flex max-w-6xl items-center justify-between gap-2 px-3 py-3 sm:px-4">
|
||||
<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="flex items-center gap-2">
|
||||
<span className="hidden text-xl xs:inline">✅</span>
|
||||
<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>
|
||||
@@ -57,93 +77,232 @@ export function Layout({ children }: { children: ReactNode }) {
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 items-center gap-1 sm:gap-2">
|
||||
<NotificationBell />
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setMenuOpen((o) => !o)}
|
||||
className="flex items-center gap-2 rounded-lg px-2 py-1.5 text-sm hover:bg-surface-raised"
|
||||
>
|
||||
<span className="flex h-7 w-7 items-center justify-center rounded-full bg-primary-dim/50 text-xs font-semibold text-primary">
|
||||
{user?.display_name?.[0]?.toUpperCase()}
|
||||
</span>
|
||||
<span className="hidden text-text sm:inline">{user?.display_name}</span>
|
||||
</button>
|
||||
{menuOpen && (
|
||||
<div
|
||||
className="animate-fade-in absolute right-0 mt-2 w-44 rounded-xl border border-border-strong bg-surface py-1 shadow-2xl"
|
||||
onMouseLeave={() => setMenuOpen(false)}
|
||||
>
|
||||
<Link
|
||||
to="/settings"
|
||||
className="block px-4 py-2 text-sm text-muted hover:bg-surface-raised hover:text-text"
|
||||
onClick={() => setMenuOpen(false)}
|
||||
>
|
||||
Settings
|
||||
</Link>
|
||||
<Link
|
||||
to="/notifications"
|
||||
className="block px-4 py-2 text-sm text-muted hover:bg-surface-raised hover:text-text md:hidden"
|
||||
onClick={() => setMenuOpen(false)}
|
||||
>
|
||||
Notifications
|
||||
</Link>
|
||||
{user?.role === "ADMIN" && (
|
||||
<Link
|
||||
to="/admin"
|
||||
className="block px-4 py-2 text-sm text-accent hover:bg-surface-raised"
|
||||
onClick={() => setMenuOpen(false)}
|
||||
>
|
||||
Admin
|
||||
</Link>
|
||||
)}
|
||||
<button
|
||||
onClick={async () => {
|
||||
await logout();
|
||||
navigate("/login");
|
||||
}}
|
||||
className="block w-full px-4 py-2 text-left text-sm text-rejected hover:bg-surface-raised"
|
||||
>
|
||||
Log out
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{/* Below md the bell is replaced by the Alerts tab. */}
|
||||
<div className="hidden md:block">
|
||||
<NotificationBell />
|
||||
</div>
|
||||
<AccountMenu />
|
||||
</div>
|
||||
</div>
|
||||
{/* mobile nav */}
|
||||
<nav className="flex items-center justify-between gap-1 border-t border-border px-3 py-2 md:hidden sm:px-4">
|
||||
{navItems.map((item) => (
|
||||
<NavLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
end={item.end}
|
||||
className={({ isActive }) =>
|
||||
`whitespace-nowrap rounded-lg px-2 py-1.5 text-sm font-medium ${
|
||||
isActive ? "bg-surface-raised text-text" : "text-muted"
|
||||
}`
|
||||
}
|
||||
>
|
||||
{item.label}
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<main className="mx-auto max-w-6xl px-3 py-5 sm:px-4 sm:py-8">{children}</main>
|
||||
<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-3">
|
||||
<Link to="/privacy" className="hover:text-muted">
|
||||
<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>·</span>
|
||||
<Link to="/terms" className="hover:text-muted">
|
||||
<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>·</span>
|
||||
<span>PatchPass — review changes, approve intent, let agents proceed.</span>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user