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:
@@ -86,7 +86,12 @@ server.notFound(async (ctr) => {
|
||||
|
||||
// Serve a matching build file (assets, favicons, etc.) if present.
|
||||
const file = resolveStaticFile(path);
|
||||
if (file) return ctr.status(200, "OK").printFile(file, { addTypes: true });
|
||||
if (file) {
|
||||
// rjweb's type map has no entry for .webmanifest, and browsers want the
|
||||
// PWA manifest served as application/manifest+json.
|
||||
if (file.endsWith(".webmanifest")) ctr.headers.set("Content-Type", "application/manifest+json");
|
||||
return ctr.status(200, "OK").printFile(file, { addTypes: !file.endsWith(".webmanifest") });
|
||||
}
|
||||
|
||||
// SPA fallback — hand any other route to the React app.
|
||||
if (hasUiIndex) return ctr.status(200, "OK").printFile(uiIndexPath, { addTypes: true });
|
||||
|
||||
+20
-2
@@ -2,9 +2,27 @@
|
||||
<html lang="en" class="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'%3E%3Ctext y='.9em' font-size='90'%3E%E2%9C%85%3C/text%3E%3C/svg%3E" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<!-- viewport-fit=cover lets the app paint under the notch/home indicator; we
|
||||
re-inset content with env(safe-area-inset-*) in index.css.
|
||||
interactive-widget keeps fixed chrome above the on-screen keyboard. -->
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1.0, viewport-fit=cover, interactive-widget=resizes-content"
|
||||
/>
|
||||
<meta name="description" content="PatchPass — a human approval layer for AI agents." />
|
||||
|
||||
<link rel="icon" href="/icon.svg" type="image/svg+xml" />
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||
<link rel="manifest" href="/manifest.webmanifest" />
|
||||
|
||||
<meta name="theme-color" content="#0a0e17" />
|
||||
<meta name="color-scheme" content="dark" />
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-title" content="PatchPass" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<meta name="format-detection" content="telephone=no" />
|
||||
|
||||
<title>PatchPass</title>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 5.8 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 6.4 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 18 KiB |
@@ -0,0 +1,22 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0" stop-color="#172038" />
|
||||
<stop offset="1" stop-color="#0a0e17" />
|
||||
</linearGradient>
|
||||
<radialGradient id="glow" cx="0.5" cy="0.5" r="0.5">
|
||||
<stop offset="0" stop-color="#6d8bff" stop-opacity="0.28" />
|
||||
<stop offset="1" stop-color="#6d8bff" stop-opacity="0" />
|
||||
</radialGradient>
|
||||
</defs>
|
||||
<rect width="512" height="512" rx="113" fill="url(#bg)" />
|
||||
<rect width="512" height="512" rx="113" fill="url(#glow)" />
|
||||
<path
|
||||
d="M141 264 L223 344 L376 172"
|
||||
fill="none"
|
||||
stroke="#8aa4ff"
|
||||
stroke-width="100"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 721 B |
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "PatchPass",
|
||||
"short_name": "PatchPass",
|
||||
"description": "A human approval layer for AI agents. Review changes, approve intent, let agents proceed.",
|
||||
"start_url": "/",
|
||||
"scope": "/",
|
||||
"display": "standalone",
|
||||
"display_override": ["standalone", "minimal-ui"],
|
||||
"orientation": "any",
|
||||
"background_color": "#0a0e17",
|
||||
"theme_color": "#0a0e17",
|
||||
"categories": ["developer", "productivity", "utilities"],
|
||||
"icons": [
|
||||
{ "src": "/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" },
|
||||
{ "src": "/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" },
|
||||
{ "src": "/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" },
|
||||
{ "src": "/icon.svg", "sizes": "any", "type": "image/svg+xml", "purpose": "any" }
|
||||
],
|
||||
"shortcuts": [
|
||||
{ "name": "Awaiting review", "url": "/", "description": "Requests waiting for your review" },
|
||||
{ "name": "All requests", "url": "/requests", "description": "Full change request history" },
|
||||
{ "name": "Agents", "url": "/agents", "description": "Manage your agents and API keys" }
|
||||
]
|
||||
}
|
||||
@@ -47,9 +47,7 @@ export function AgentFormModal({
|
||||
icon_url: iconUrl.trim() || null,
|
||||
max_pending_requests: maxPending,
|
||||
};
|
||||
const saved = editing
|
||||
? await agentsApi.update(agent!.id, payload)
|
||||
: await agentsApi.create(payload);
|
||||
const saved = editing ? await agentsApi.update(agent!.id, payload) : await agentsApi.create(payload);
|
||||
toast.success(editing ? "Agent updated" : "Agent created");
|
||||
onSaved(saved, !editing);
|
||||
onClose();
|
||||
@@ -68,17 +66,17 @@ export function AgentFormModal({
|
||||
title={editing ? "Edit agent" : "New agent"}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="ghost" onClick={onClose}>
|
||||
<Button variant="ghost" className="flex-1 sm:flex-none" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={submit} loading={loading}>
|
||||
<Button className="flex-1 sm:flex-none" onClick={submit} loading={loading}>
|
||||
{editing ? "Save" : "Create agent"}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<Input label="Name" value={name} onChange={(e) => setName(e.target.value)} autoFocus />
|
||||
<Input label="Name" value={name} onChange={(e) => setName(e.target.value)} />
|
||||
<Textarea
|
||||
label="Description"
|
||||
value={description}
|
||||
@@ -90,18 +88,26 @@ export function AgentFormModal({
|
||||
value={website}
|
||||
onChange={(e) => setWebsite(e.target.value)}
|
||||
placeholder="https://…"
|
||||
type="url"
|
||||
inputMode="url"
|
||||
autoCapitalize="none"
|
||||
autoCorrect="off"
|
||||
/>
|
||||
<Input
|
||||
label="Icon URL (optional)"
|
||||
value={iconUrl}
|
||||
onChange={(e) => setIconUrl(e.target.value)}
|
||||
placeholder="https://…/icon.png"
|
||||
type="url"
|
||||
inputMode="url"
|
||||
autoCapitalize="none"
|
||||
autoCorrect="off"
|
||||
hint="Verified on save. Must be a JPG, PNG, or GIF under 1 MB."
|
||||
/>
|
||||
<div>
|
||||
<div className="mb-1 flex items-center justify-between text-sm">
|
||||
<div className="mb-1.5 flex items-center justify-between text-sm">
|
||||
<span className="text-muted">Max pending requests</span>
|
||||
<span className="font-medium text-text">{maxPending}</span>
|
||||
<span className="font-medium text-text tabular-nums">{maxPending}</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
@@ -110,8 +116,9 @@ export function AgentFormModal({
|
||||
value={maxPending}
|
||||
onChange={(e) => setMaxPending(Number(e.target.value))}
|
||||
className="w-full accent-[var(--color-primary)]"
|
||||
aria-label="Max pending requests"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-faint">
|
||||
<p className="mt-1.5 text-xs text-faint">
|
||||
How many requests this agent may have awaiting review at once (1–10).
|
||||
</p>
|
||||
</div>
|
||||
@@ -132,7 +139,7 @@ export function ConnectModal({
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
agent: Agent;
|
||||
agent: Agent | null;
|
||||
revealedKey?: string | null;
|
||||
}) {
|
||||
const [tab, setTab] = useState<Tab>("openclaw");
|
||||
@@ -194,11 +201,23 @@ curl -X POST ${origin}/v1/change-requests/{request_id}/consume -H "x-api-key: ${
|
||||
];
|
||||
|
||||
return (
|
||||
<Modal open={open} onClose={onClose} title={`Connect "${agent.name}"`} wide footer={<Button onClick={onClose}>Done</Button>}>
|
||||
<Modal
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
title={agent ? `Connect "${agent.name}"` : "Connect"}
|
||||
wide
|
||||
footer={
|
||||
<Button className="w-full sm:w-auto" onClick={onClose}>
|
||||
Done
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
{hasKey ? (
|
||||
<div className="rounded-lg border border-pending/40 bg-pending/10 p-3">
|
||||
<p className="text-xs font-semibold text-pending">Save this API key now — it won't be shown again.</p>
|
||||
<p className="text-xs font-semibold text-pending">
|
||||
Save this API key now — it won't be shown again.
|
||||
</p>
|
||||
<div className="mt-2">
|
||||
<CodeBlock code={revealedKey!} />
|
||||
</div>
|
||||
@@ -210,12 +229,12 @@ curl -X POST ${origin}/v1/change-requests/{request_id}/consume -H "x-api-key: ${
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex gap-1 overflow-x-auto border-b border-border">
|
||||
<div className="no-scrollbar -mx-4 flex gap-1 overflow-x-auto overscroll-x-contain border-b border-border px-4 sm:mx-0 sm:px-0">
|
||||
{tabs.map((t) => (
|
||||
<button
|
||||
key={t.id}
|
||||
onClick={() => setTab(t.id)}
|
||||
className={`shrink-0 px-3 py-2 text-sm font-medium transition ${
|
||||
className={`min-h-11 shrink-0 px-3 text-sm font-medium transition ${
|
||||
tab === t.id ? "border-b-2 border-primary text-text" : "text-muted hover:text-text"
|
||||
}`}
|
||||
>
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
import { useState } from "react";
|
||||
import type { Change } from "../api/types";
|
||||
import { WrapIcon } from "./icons";
|
||||
|
||||
function DiffView({ content }: { content: string }) {
|
||||
function DiffView({ content, wrap }: { content: string; wrap: boolean }) {
|
||||
const lines = content.split("\n");
|
||||
return (
|
||||
<pre className="overflow-x-auto rounded-lg border border-border bg-bg p-3 text-xs leading-relaxed">
|
||||
<pre
|
||||
className={`scroll-x rounded-lg border border-border bg-bg p-2.5 text-[11px] leading-relaxed sm:p-3 sm:text-xs ${
|
||||
wrap ? "overflow-x-hidden" : ""
|
||||
}`}
|
||||
>
|
||||
<code>
|
||||
{lines.map((line, i) => {
|
||||
let cls = "";
|
||||
@@ -13,7 +19,10 @@ function DiffView({ content }: { content: string }) {
|
||||
else if (line.startsWith("-")) cls = "diff-del";
|
||||
else if (line.startsWith("diff ") || line.startsWith("index ")) cls = "diff-meta";
|
||||
return (
|
||||
<div key={i} className={`whitespace-pre px-1 ${cls}`}>
|
||||
<div
|
||||
key={i}
|
||||
className={`px-1 ${wrap ? "whitespace-pre-wrap break-words" : "whitespace-pre"} ${cls}`}
|
||||
>
|
||||
{line || " "}
|
||||
</div>
|
||||
);
|
||||
@@ -38,14 +47,21 @@ function percentDelta(before: unknown, after: unknown): string | null {
|
||||
return `${pct > 0 ? "+" : ""}${pct}%`;
|
||||
}
|
||||
|
||||
function BeforeAfter({ before, after, contentType }: { before: unknown; after: unknown; contentType?: string | null }) {
|
||||
const delta =
|
||||
contentType === "integer" || contentType === "number" ? percentDelta(before, after) : null;
|
||||
function BeforeAfter({
|
||||
before,
|
||||
after,
|
||||
contentType,
|
||||
}: {
|
||||
before: unknown;
|
||||
after: unknown;
|
||||
contentType?: string | null;
|
||||
}) {
|
||||
const delta = contentType === "integer" || contentType === "number" ? percentDelta(before, after) : null;
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-2 text-sm">
|
||||
<code className="diff-del rounded px-2 py-0.5 font-mono">{fmtValue(before)}</code>
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-1.5 text-sm">
|
||||
<code className="diff-del max-w-full break-all rounded px-2 py-0.5 font-mono">{fmtValue(before)}</code>
|
||||
<span className="text-faint">→</span>
|
||||
<code className="diff-add rounded px-2 py-0.5 font-mono">{fmtValue(after)}</code>
|
||||
<code className="diff-add max-w-full break-all rounded px-2 py-0.5 font-mono">{fmtValue(after)}</code>
|
||||
{delta && <span className="rounded bg-primary-dim/40 px-2 py-0.5 text-xs text-primary">{delta}</span>}
|
||||
{contentType && <span className="text-xs text-faint">({contentType})</span>}
|
||||
</div>
|
||||
@@ -53,34 +69,54 @@ function BeforeAfter({ before, after, contentType }: { before: unknown; after: u
|
||||
}
|
||||
|
||||
function typeChip(label: string, color: string) {
|
||||
return <span className={`rounded px-2 py-0.5 text-xs font-medium ${color}`}>{label}</span>;
|
||||
return <span className={`shrink-0 rounded px-2 py-0.5 text-xs font-medium ${color}`}>{label}</span>;
|
||||
}
|
||||
|
||||
export function ChangeCard({ change, index }: { change: Change; index: number }) {
|
||||
const [wrap, setWrap] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-border bg-surface-raised/50 p-4">
|
||||
<div className="mb-3 flex flex-wrap items-center gap-2">
|
||||
<span className="text-xs font-mono text-faint">#{index + 1}</span>
|
||||
<div className="rounded-xl border border-border bg-surface-raised/50 p-3 sm:p-4">
|
||||
<div className="mb-3 flex items-start gap-2">
|
||||
<span className="mt-0.5 shrink-0 font-mono text-xs text-faint">#{index + 1}</span>
|
||||
|
||||
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-x-2 gap-y-1.5">
|
||||
{change.type === "unified_diff" && (
|
||||
<>
|
||||
{typeChip("diff", "bg-primary-dim/40 text-primary")}
|
||||
<code className="break-all text-sm text-text">{change.path}</code>
|
||||
<code className="min-w-0 break-all text-sm text-text">{change.path}</code>
|
||||
</>
|
||||
)}
|
||||
{change.type === "config" && (
|
||||
<>
|
||||
{typeChip("config", "bg-accent/15 text-accent")}
|
||||
<code className="break-all text-sm text-text">{change.path}</code>
|
||||
<code className="min-w-0 break-all text-sm text-text">{change.path}</code>
|
||||
</>
|
||||
)}
|
||||
{change.type === "custom" && (
|
||||
<>
|
||||
{typeChip("custom", "bg-changes/15 text-changes")}
|
||||
<span className="text-sm text-text">{change.label}</span>
|
||||
<span className="min-w-0 break-words text-sm text-text">{change.label}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{change.type === "unified_diff" && <DiffView content={change.content} />}
|
||||
|
||||
{/* Wrapping beats horizontal scrolling for long lines on a phone. */}
|
||||
{change.type === "unified_diff" && (
|
||||
<button
|
||||
onClick={() => setWrap((w) => !w)}
|
||||
aria-pressed={wrap}
|
||||
title={wrap ? "Disable line wrapping" : "Wrap long lines"}
|
||||
className={`tap-sm -mr-1 -mt-1 flex h-9 w-9 shrink-0 items-center justify-center rounded-lg transition ${
|
||||
wrap ? "bg-primary/15 text-primary" : "text-faint hover:bg-surface-raised hover:text-muted"
|
||||
}`}
|
||||
>
|
||||
<WrapIcon className="h-[18px] w-[18px]" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{change.type === "unified_diff" && <DiffView content={change.content} wrap={wrap} />}
|
||||
{change.type === "config" && (
|
||||
<BeforeAfter before={change.before} after={change.after} contentType={change.content_type} />
|
||||
)}
|
||||
|
||||
@@ -1,30 +1,41 @@
|
||||
import { useState } from "react";
|
||||
import { CheckIcon, CopyIcon } from "./icons";
|
||||
|
||||
export function CodeBlock({ code, language }: { code: string; language?: string }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const copy = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(code);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
} catch {
|
||||
/* ignore */
|
||||
/* clipboard unavailable (insecure origin) — nothing useful to do */
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
{language && (
|
||||
<span className="absolute left-3 top-2 text-[10px] uppercase tracking-wide text-faint">
|
||||
<span className="absolute left-3 top-2.5 text-[10px] uppercase tracking-wide text-faint">
|
||||
{language}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
onClick={copy}
|
||||
className="absolute right-2 top-2 rounded-md border border-border-strong bg-surface px-2 py-1 text-xs text-muted hover:text-text"
|
||||
aria-label={copied ? "Copied" : "Copy to clipboard"}
|
||||
className={`tap-sm absolute right-1.5 top-1.5 z-10 flex h-9 items-center gap-1.5 rounded-lg border border-border-strong px-2.5 text-xs transition ${
|
||||
copied ? "bg-approved/15 text-approved" : "bg-surface text-muted hover:text-text"
|
||||
}`}
|
||||
>
|
||||
{copied ? "Copied!" : "Copy"}
|
||||
{copied ? <CheckIcon className="h-4 w-4" /> : <CopyIcon className="h-4 w-4" />}
|
||||
<span className="hidden xs:inline">{copied ? "Copied" : "Copy"}</span>
|
||||
</button>
|
||||
<pre className={`overflow-x-auto rounded-lg border border-border bg-bg p-3 ${language ? "pt-7" : ""} text-xs leading-relaxed text-text`}>
|
||||
<pre
|
||||
className={`scroll-x rounded-lg border border-border bg-bg p-3 pr-16 ${
|
||||
language ? "pt-8" : ""
|
||||
} text-[11px] leading-relaxed text-text sm:text-xs`}
|
||||
>
|
||||
<code>{code}</code>
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { Sheet } from "./Sheet";
|
||||
import { Button } from "./ui";
|
||||
|
||||
/**
|
||||
* Replaces `window.confirm` for destructive actions — the native dialog is a
|
||||
* hard stop that looks nothing like the app on a phone.
|
||||
*/
|
||||
export function ConfirmSheet({
|
||||
open,
|
||||
onClose,
|
||||
onConfirm,
|
||||
title,
|
||||
message,
|
||||
confirmLabel = "Confirm",
|
||||
danger,
|
||||
loading,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onConfirm: () => void;
|
||||
title: string;
|
||||
message: string;
|
||||
confirmLabel?: string;
|
||||
danger?: boolean;
|
||||
loading?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<Sheet
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
onSubmit={onConfirm}
|
||||
title={title}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="ghost" className="flex-1 sm:flex-none" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant={danger ? "danger" : "primary"}
|
||||
className="flex-1 sm:flex-none"
|
||||
onClick={onConfirm}
|
||||
loading={loading}
|
||||
>
|
||||
{confirmLabel}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<p className="text-sm leading-relaxed text-muted">{message}</p>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
@@ -9,7 +9,10 @@ type Decision = "APPROVE" | "REJECT" | "REQUEST_CHANGES";
|
||||
|
||||
const MAX = 500;
|
||||
|
||||
const meta: Record<Decision, { title: string; verb: string; variant: "success" | "danger" | "primary"; blurb: string; commentRequired: boolean }> = {
|
||||
const meta: Record<
|
||||
Decision,
|
||||
{ title: string; verb: string; variant: "success" | "danger" | "primary"; blurb: string; commentRequired: boolean }
|
||||
> = {
|
||||
APPROVE: {
|
||||
title: "Approve request",
|
||||
verb: "Approve",
|
||||
@@ -46,18 +49,24 @@ export function DecisionModal({
|
||||
}) {
|
||||
const [comment, setComment] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
// Held past `decision` going null so the sheet can animate out with its
|
||||
// copy intact instead of blanking mid-dismiss.
|
||||
const [shown, setShown] = useState<Decision | null>(decision);
|
||||
|
||||
useEffect(() => {
|
||||
if (decision) {
|
||||
setShown(decision);
|
||||
setComment("");
|
||||
}
|
||||
}, [decision]);
|
||||
|
||||
if (!decision) return null;
|
||||
const m = meta[decision];
|
||||
if (!shown) return null;
|
||||
const m = meta[shown];
|
||||
const tooLong = comment.length > MAX;
|
||||
const missingRequired = m.commentRequired && comment.trim().length === 0;
|
||||
|
||||
const submit = async () => {
|
||||
if (tooLong || missingRequired) return;
|
||||
if (!decision || tooLong || missingRequired) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const updated = await requests.decide(request.request_id, decision, comment.trim() || undefined);
|
||||
@@ -79,10 +88,16 @@ export function DecisionModal({
|
||||
title={m.title}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="ghost" onClick={onClose}>
|
||||
<Button variant="ghost" className="flex-1 sm:flex-none" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant={m.variant} onClick={submit} loading={loading} disabled={tooLong || missingRequired}>
|
||||
<Button
|
||||
variant={m.variant}
|
||||
className="flex-1 sm:flex-none"
|
||||
onClick={submit}
|
||||
loading={loading}
|
||||
disabled={tooLong || missingRequired}
|
||||
>
|
||||
{m.verb}
|
||||
</Button>
|
||||
</>
|
||||
@@ -90,19 +105,22 @@ export function DecisionModal({
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-lg border border-border bg-surface-raised/50 p-3">
|
||||
<p className="text-sm font-medium text-text">{request.title}</p>
|
||||
<p className="text-sm font-medium leading-snug text-text">{request.title}</p>
|
||||
<p className="mt-1 text-xs text-muted">
|
||||
{request.changes.length} change{request.changes.length === 1 ? "" : "s"} ·{" "}
|
||||
{request.agent?.name}
|
||||
{request.changes.length} change{request.changes.length === 1 ? "" : "s"} · {request.agent?.name}
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-sm text-muted">{m.blurb}</p>
|
||||
<div>
|
||||
<div className="mb-1 flex items-center justify-between">
|
||||
<div className="mb-1.5 flex items-center justify-between">
|
||||
<span className="text-sm text-muted">
|
||||
Comment {m.commentRequired ? <span className="text-changes">(required)</span> : "(optional)"}
|
||||
</span>
|
||||
<span className={`text-xs ${tooLong ? "text-rejected" : comment.length > MAX * 0.8 ? "text-pending" : "text-faint"}`}>
|
||||
<span
|
||||
className={`text-xs tabular-nums ${
|
||||
tooLong ? "text-rejected" : comment.length > MAX * 0.8 ? "text-pending" : "text-faint"
|
||||
}`}
|
||||
>
|
||||
{comment.length}/{MAX}
|
||||
</span>
|
||||
</div>
|
||||
@@ -110,9 +128,11 @@ export function DecisionModal({
|
||||
value={comment}
|
||||
onChange={(e) => setComment(e.target.value)}
|
||||
rows={4}
|
||||
autoFocus
|
||||
// Only steal focus (and raise the keyboard) when a comment is
|
||||
// actually needed to submit.
|
||||
autoFocus={m.commentRequired}
|
||||
placeholder={m.commentRequired ? "Explain what needs to change…" : "Add an optional note…"}
|
||||
className={`w-full rounded-lg border bg-bg px-3 py-2 text-sm text-text outline-none focus:border-primary ${
|
||||
className={`w-full rounded-lg border bg-bg px-3 py-2 text-base text-text outline-none focus:border-primary sm:text-sm ${
|
||||
tooLong ? "border-rejected" : "border-border-strong"
|
||||
}`}
|
||||
/>
|
||||
|
||||
+237
-78
@@ -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">
|
||||
{/* Below md the bell is replaced by the Alerts tab. */}
|
||||
<div className="hidden md:block">
|
||||
<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"
|
||||
</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"
|
||||
>
|
||||
<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()}
|
||||
<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>
|
||||
{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)}
|
||||
>
|
||||
|
||||
{/* 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
|
||||
</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>
|
||||
</MenuLink>
|
||||
{user?.role === "ADMIN" && (
|
||||
<Link
|
||||
to="/admin"
|
||||
className="block px-4 py-2 text-sm text-accent hover:bg-surface-raised"
|
||||
onClick={() => setMenuOpen(false)}
|
||||
>
|
||||
<MenuLink to="/admin" onClick={() => setOpen(false)} accent>
|
||||
Admin
|
||||
</Link>
|
||||
</MenuLink>
|
||||
)}
|
||||
<button
|
||||
onClick={async () => {
|
||||
await logout();
|
||||
navigate("/login");
|
||||
}}
|
||||
onClick={signOut}
|
||||
className="block w-full px-4 py-2 text-left text-sm text-rejected hover:bg-surface-raised"
|
||||
>
|
||||
Log out
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</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>
|
||||
|
||||
<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">
|
||||
Privacy Policy
|
||||
</Link>
|
||||
<span>·</span>
|
||||
<Link to="/terms" className="hover:text-muted">
|
||||
Terms of Service
|
||||
</Link>
|
||||
<span>·</span>
|
||||
<span>PatchPass — review changes, approve intent, let agents proceed.</span>
|
||||
{/* 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>
|
||||
</footer>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
+10
-38
@@ -1,5 +1,11 @@
|
||||
import { ReactNode, useEffect } from "react";
|
||||
import { ReactNode } from "react";
|
||||
import { Sheet } from "./Sheet";
|
||||
|
||||
/**
|
||||
* Kept as the app-wide dialog entry point. Rendering delegates to {@link Sheet},
|
||||
* which is a drag-dismissable bottom sheet on phones and a centred dialog above
|
||||
* the `sm` breakpoint.
|
||||
*/
|
||||
export function Modal({
|
||||
open,
|
||||
onClose,
|
||||
@@ -17,43 +23,9 @@ export function Modal({
|
||||
footer?: ReactNode;
|
||||
wide?: boolean;
|
||||
}) {
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
if (e.key === "Enter" && (e.ctrlKey || e.metaKey) && onSubmit) onSubmit();
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
document.body.style.overflow = "hidden";
|
||||
return () => {
|
||||
window.removeEventListener("keydown", onKey);
|
||||
document.body.style.overflow = "";
|
||||
};
|
||||
}, [open, onClose, onSubmit]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-end justify-center p-0 sm:items-center sm:p-4">
|
||||
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={onClose} />
|
||||
<div
|
||||
className={`animate-fade-in relative z-10 flex max-h-[92dvh] w-full flex-col ${wide ? "max-w-3xl" : "max-w-lg"} rounded-t-2xl border border-border-strong bg-surface shadow-2xl sm:max-h-[90vh] sm:rounded-2xl`}
|
||||
>
|
||||
<div className="flex items-center justify-between border-b border-border px-5 py-4">
|
||||
<h2 className="text-lg font-semibold text-text">{title}</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="rounded-lg p-1 text-muted hover:bg-surface-raised hover:text-text"
|
||||
aria-label="Close"
|
||||
>
|
||||
<svg className="h-5 w-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M6 6l12 12M18 6L6 18" strokeLinecap="round" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-4 py-4 sm:px-5">{children}</div>
|
||||
{footer && <div className="flex flex-wrap justify-end gap-2 border-t border-border px-4 py-3 sm:px-5 sm:py-4">{footer}</div>}
|
||||
</div>
|
||||
</div>
|
||||
<Sheet open={open} onClose={onClose} onSubmit={onSubmit} title={title} footer={footer} wide={wide}>
|
||||
{children}
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useNotifications } from "../context/NotificationsContext";
|
||||
import { BellIcon } from "./icons";
|
||||
import { relativeTime } from "../utils";
|
||||
|
||||
export function NotificationBell() {
|
||||
@@ -21,25 +22,19 @@ export function NotificationBell() {
|
||||
<div className="relative" ref={ref}>
|
||||
<button
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
className="relative rounded-lg p-2 text-muted transition hover:bg-surface-raised hover:text-text"
|
||||
className="relative flex h-10 w-10 items-center justify-center rounded-lg text-muted transition hover:bg-surface-raised hover:text-text"
|
||||
aria-label="Notifications"
|
||||
>
|
||||
<svg className="h-5 w-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8">
|
||||
<path
|
||||
d="M18 8a6 6 0 10-12 0c0 7-3 9-3 9h18s-3-2-3-9M13.7 21a2 2 0 01-3.4 0"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
<BellIcon className="h-5 w-5" />
|
||||
{unreadCount > 0 && (
|
||||
<span className="absolute -right-0.5 -top-0.5 flex h-4 min-w-4 items-center justify-center rounded-full bg-rejected px-1 text-[10px] font-bold text-white">
|
||||
<span className="absolute right-1 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 > 99 ? "99+" : unreadCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="animate-fade-in fixed inset-x-3 top-14 z-40 mt-2 rounded-xl border border-border-strong bg-surface shadow-2xl sm:absolute sm:inset-x-auto sm:top-auto sm:w-80">
|
||||
<div className="animate-fade-in absolute right-0 z-40 mt-2 w-80 rounded-xl border border-border-strong bg-surface shadow-2xl">
|
||||
<div className="flex items-center justify-between border-b border-border px-4 py-2.5">
|
||||
<span className="text-sm font-semibold">Notifications</span>
|
||||
<div className="flex gap-2 text-xs">
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
import { ReactNode, useEffect, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useIsMobile } from "../hooks/useMediaQuery";
|
||||
import { useScrollLock } from "../hooks/useScrollLock";
|
||||
import { CloseIcon } from "./icons";
|
||||
|
||||
const EXIT_MS = 240;
|
||||
|
||||
/**
|
||||
* Responsive overlay primitive: a drag-dismissable bottom sheet on phones, a
|
||||
* centred dialog from `sm` up. Everything modal in the app renders through this
|
||||
* so the dismiss gesture, scroll lock and safe-area handling stay consistent.
|
||||
*/
|
||||
export function Sheet({
|
||||
open,
|
||||
onClose,
|
||||
onSubmit,
|
||||
title,
|
||||
children,
|
||||
footer,
|
||||
wide,
|
||||
padded = true,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onSubmit?: () => void;
|
||||
title?: string;
|
||||
children: ReactNode;
|
||||
footer?: ReactNode;
|
||||
wide?: boolean;
|
||||
padded?: boolean;
|
||||
}) {
|
||||
const isMobile = useIsMobile();
|
||||
const [mounted, setMounted] = useState(open);
|
||||
const [exiting, setExiting] = useState(false);
|
||||
const [dragY, setDragY] = useState(0);
|
||||
const [dragging, setDragging] = useState(false);
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
const dragStart = useRef<{ y: number; t: number } | null>(null);
|
||||
|
||||
// Keep the panel mounted through its exit transition.
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setMounted(true);
|
||||
setExiting(false);
|
||||
setDragY(0);
|
||||
return;
|
||||
}
|
||||
if (!mounted) return;
|
||||
setExiting(true);
|
||||
const t = window.setTimeout(() => {
|
||||
setMounted(false);
|
||||
setExiting(false);
|
||||
setDragY(0);
|
||||
}, EXIT_MS);
|
||||
return () => window.clearTimeout(t);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open]);
|
||||
|
||||
useScrollLock(mounted);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
if (e.key === "Enter" && (e.ctrlKey || e.metaKey) && onSubmit) onSubmit();
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [open, onClose, onSubmit]);
|
||||
|
||||
if (!mounted) return null;
|
||||
|
||||
// ── drag-to-dismiss (mobile, from the grabber/header only, so it never
|
||||
// fights the scroll container underneath) ────────────────────────────────
|
||||
const onPointerDown = (e: React.PointerEvent) => {
|
||||
if (!isMobile || e.pointerType === "mouse") return;
|
||||
dragStart.current = { y: e.clientY, t: e.timeStamp };
|
||||
setDragging(true);
|
||||
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
|
||||
};
|
||||
|
||||
const onPointerMove = (e: React.PointerEvent) => {
|
||||
if (!dragStart.current) return;
|
||||
// Resist upward drags so the sheet feels anchored.
|
||||
const dy = e.clientY - dragStart.current.y;
|
||||
setDragY(dy > 0 ? dy : dy / 4);
|
||||
};
|
||||
|
||||
const endDrag = (e: React.PointerEvent) => {
|
||||
const start = dragStart.current;
|
||||
dragStart.current = null;
|
||||
setDragging(false);
|
||||
if (!start) return;
|
||||
const dy = e.clientY - start.y;
|
||||
const dt = Math.max(1, e.timeStamp - start.t);
|
||||
const velocity = dy / dt; // px/ms
|
||||
const height = panelRef.current?.offsetHeight ?? 400;
|
||||
if (dy > height * 0.28 || velocity > 0.5) onClose();
|
||||
else setDragY(0);
|
||||
};
|
||||
|
||||
const dismissProgress = Math.min(1, Math.max(0, dragY) / 320);
|
||||
|
||||
const panelTransform = exiting
|
||||
? isMobile
|
||||
? "translateY(100%)"
|
||||
: "scale(0.96)"
|
||||
: dragY
|
||||
? `translateY(${dragY}px)`
|
||||
: undefined;
|
||||
|
||||
// Portalled to <body>: any ancestor with a transform (page fade-ins, the
|
||||
// blurred header) would otherwise become the containing block and knock the
|
||||
// fixed overlay out of place.
|
||||
return createPortal(
|
||||
<div className="fixed inset-0 z-50 flex items-end justify-center sm:items-center sm:p-4" role="dialog" aria-modal="true">
|
||||
<div
|
||||
className={`absolute inset-0 bg-black/60 backdrop-blur-sm transition-opacity duration-200 ${
|
||||
exiting ? "opacity-0" : "animate-backdrop-in"
|
||||
}`}
|
||||
style={{ opacity: exiting ? 0 : 1 - dismissProgress * 0.7 }}
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
<div
|
||||
ref={panelRef}
|
||||
className={`relative z-10 flex max-h-[92dvh] w-full flex-col border border-border-strong bg-surface shadow-2xl ${
|
||||
wide ? "sm:max-w-3xl" : "sm:max-w-lg"
|
||||
} rounded-t-2xl sm:max-h-[85vh] sm:rounded-2xl ${
|
||||
exiting
|
||||
? isMobile
|
||||
? "" // slides out; fading as well reads as a glitch on phones
|
||||
: "opacity-0"
|
||||
: isMobile
|
||||
? "animate-sheet-up"
|
||||
: "animate-scale-in"
|
||||
}`}
|
||||
style={{
|
||||
transform: panelTransform,
|
||||
transition: dragging ? "none" : `transform ${EXIT_MS}ms cubic-bezier(0.32,0.72,0,1), opacity 180ms ease`,
|
||||
}}
|
||||
>
|
||||
{/* Grab area — the whole header is draggable, like iOS sheets. */}
|
||||
<div
|
||||
className="shrink-0 touch-none"
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={endDrag}
|
||||
onPointerCancel={endDrag}
|
||||
>
|
||||
<div className="flex justify-center pt-2 sm:hidden">
|
||||
<span className="h-1 w-9 rounded-full bg-border-strong" />
|
||||
</div>
|
||||
{title && (
|
||||
<div className="flex items-center justify-between gap-3 border-b border-border px-5 py-3.5 sm:py-4">
|
||||
<h2 className="min-w-0 truncate text-base font-semibold text-text sm:text-lg">{title}</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="tap-sm -mr-2 flex h-10 w-10 shrink-0 items-center justify-center rounded-full text-muted hover:bg-surface-raised hover:text-text"
|
||||
aria-label="Close"
|
||||
>
|
||||
<CloseIcon className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* The home-indicator inset is folded into the padding with calc so it
|
||||
adds to the base spacing instead of replacing it. */}
|
||||
<div
|
||||
className={`min-h-0 flex-1 overflow-y-auto overscroll-contain ${
|
||||
padded ? "px-4 py-4 sm:px-5" : ""
|
||||
} ${footer ? "" : padded ? "pb-[calc(1rem_+_var(--safe-bottom))]" : "pb-safe"}`}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
|
||||
{footer && (
|
||||
<div className="flex shrink-0 flex-wrap justify-end gap-2 border-t border-border px-4 pb-[calc(0.75rem_+_var(--safe-bottom))] pt-3 sm:px-5 sm:pb-[calc(1rem_+_var(--safe-bottom))] sm:pt-4">
|
||||
{footer}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import type { SVGProps } from "react";
|
||||
|
||||
// 24px stroke icons, sized by the consumer via className.
|
||||
|
||||
type Props = SVGProps<SVGSVGElement>;
|
||||
|
||||
function Base({ children, className = "h-6 w-6", ...props }: Props) {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.8}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={className}
|
||||
aria-hidden="true"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export const HomeIcon = (p: Props) => (
|
||||
<Base {...p}>
|
||||
<path d="M3 10.2 12 3l9 7.2V20a1 1 0 0 1-1 1h-5v-6H9v6H4a1 1 0 0 1-1-1z" />
|
||||
</Base>
|
||||
);
|
||||
|
||||
export const RequestsIcon = (p: Props) => (
|
||||
<Base {...p}>
|
||||
<path d="M4 5.5A1.5 1.5 0 0 1 5.5 4h13A1.5 1.5 0 0 1 20 5.5v13a1.5 1.5 0 0 1-1.5 1.5h-13A1.5 1.5 0 0 1 4 18.5z" />
|
||||
<path d="m8 11.5 2.2 2.2L16 8" />
|
||||
</Base>
|
||||
);
|
||||
|
||||
export const AgentsIcon = (p: Props) => (
|
||||
<Base {...p}>
|
||||
<rect x="4" y="7" width="16" height="12" rx="3" />
|
||||
<path d="M12 3v4M9 13h.01M15 13h.01M9.5 16.5h5" />
|
||||
</Base>
|
||||
);
|
||||
|
||||
export const BellIcon = (p: Props) => (
|
||||
<Base {...p}>
|
||||
<path d="M18 8a6 6 0 1 0-12 0c0 7-3 9-3 9h18s-3-2-3-9" />
|
||||
<path d="M13.7 21a2 2 0 0 1-3.4 0" />
|
||||
</Base>
|
||||
);
|
||||
|
||||
export const SettingsIcon = (p: Props) => (
|
||||
<Base {...p}>
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
<path d="M19.4 15a1.7 1.7 0 0 0 .34 1.87l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.7 1.7 0 0 0-1.87-.34 1.7 1.7 0 0 0-1 1.55V21a2 2 0 1 1-4 0v-.09a1.7 1.7 0 0 0-1.11-1.55 1.7 1.7 0 0 0-1.87.34l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06A1.7 1.7 0 0 0 4.6 15a1.7 1.7 0 0 0-1.55-1H3a2 2 0 1 1 0-4h.09A1.7 1.7 0 0 0 4.6 8.9a1.7 1.7 0 0 0-.34-1.87l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.7 1.7 0 0 0 1.87.34H9a1.7 1.7 0 0 0 1-1.55V3a2 2 0 1 1 4 0v.09a1.7 1.7 0 0 0 1 1.55 1.7 1.7 0 0 0 1.87-.34l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.7 1.7 0 0 0-.34 1.87V9a1.7 1.7 0 0 0 1.55 1H21a2 2 0 1 1 0 4h-.09a1.7 1.7 0 0 0-1.51 1" />
|
||||
</Base>
|
||||
);
|
||||
|
||||
export const ShieldIcon = (p: Props) => (
|
||||
<Base {...p}>
|
||||
<path d="M12 3l7 3v5.5c0 4.4-2.9 7.9-7 9.5-4.1-1.6-7-5.1-7-9.5V6z" />
|
||||
<path d="m9 12 2 2 4-4" />
|
||||
</Base>
|
||||
);
|
||||
|
||||
export const LogOutIcon = (p: Props) => (
|
||||
<Base {...p}>
|
||||
<path d="M9 21H6a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h3" />
|
||||
<path d="m16 17 5-5-5-5M21 12H9" />
|
||||
</Base>
|
||||
);
|
||||
|
||||
export const ChevronLeftIcon = (p: Props) => (
|
||||
<Base {...p}>
|
||||
<path d="m15 18-6-6 6-6" />
|
||||
</Base>
|
||||
);
|
||||
|
||||
export const ChevronRightIcon = (p: Props) => (
|
||||
<Base {...p}>
|
||||
<path d="m9 18 6-6-6-6" />
|
||||
</Base>
|
||||
);
|
||||
|
||||
export const CloseIcon = (p: Props) => (
|
||||
<Base {...p}>
|
||||
<path d="M6 6l12 12M18 6 6 18" />
|
||||
</Base>
|
||||
);
|
||||
|
||||
export const CheckIcon = (p: Props) => (
|
||||
<Base {...p}>
|
||||
<path d="m5 13 4.5 4.5L19 7" />
|
||||
</Base>
|
||||
);
|
||||
|
||||
export const CopyIcon = (p: Props) => (
|
||||
<Base {...p}>
|
||||
<rect x="9" y="9" width="11" height="11" rx="2" />
|
||||
<path d="M5 15a2 2 0 0 1-1-1.7V6a2 2 0 0 1 2-2h7.3A2 2 0 0 1 15 5" />
|
||||
</Base>
|
||||
);
|
||||
|
||||
export const FilterIcon = (p: Props) => (
|
||||
<Base {...p}>
|
||||
<path d="M4 5h16M7 12h10M10 19h4" />
|
||||
</Base>
|
||||
);
|
||||
|
||||
export const MoreIcon = (p: Props) => (
|
||||
<Base {...p}>
|
||||
<circle cx="5" cy="12" r="1.4" fill="currentColor" stroke="none" />
|
||||
<circle cx="12" cy="12" r="1.4" fill="currentColor" stroke="none" />
|
||||
<circle cx="19" cy="12" r="1.4" fill="currentColor" stroke="none" />
|
||||
</Base>
|
||||
);
|
||||
|
||||
export const KeyIcon = (p: Props) => (
|
||||
<Base {...p}>
|
||||
<circle cx="8" cy="14" r="4" />
|
||||
<path d="m11 11 8-8M17 5l2 2M14.5 7.5l2 2" />
|
||||
</Base>
|
||||
);
|
||||
|
||||
export const PowerIcon = (p: Props) => (
|
||||
<Base {...p}>
|
||||
<path d="M12 4v8M7.5 7a7 7 0 1 0 9 0" />
|
||||
</Base>
|
||||
);
|
||||
|
||||
export const TrashIcon = (p: Props) => (
|
||||
<Base {...p}>
|
||||
<path d="M4 7h16M10 4h4M6 7l1 12a2 2 0 0 0 2 2h6a2 2 0 0 0 2-2l1-12M10 11v6M14 11v6" />
|
||||
</Base>
|
||||
);
|
||||
|
||||
export const PencilIcon = (p: Props) => (
|
||||
<Base {...p}>
|
||||
<path d="M4 20h4L19.5 8.5a2.1 2.1 0 0 0-3-3L5 17v3z" />
|
||||
</Base>
|
||||
);
|
||||
|
||||
export const PlugIcon = (p: Props) => (
|
||||
<Base {...p}>
|
||||
<path d="M9 3v5M15 3v5M6 8h12v3a6 6 0 0 1-6 6 6 6 0 0 1-6-6zM12 17v4" />
|
||||
</Base>
|
||||
);
|
||||
|
||||
export const WrapIcon = (p: Props) => (
|
||||
<Base {...p}>
|
||||
<path d="M4 6h16M4 18h6" />
|
||||
<path d="M4 12h13a3 3 0 1 1 0 6h-3" />
|
||||
<path d="m12 15-2 3 2 3" />
|
||||
</Base>
|
||||
);
|
||||
+60
-19
@@ -21,7 +21,9 @@ export function Button({
|
||||
<button
|
||||
{...props}
|
||||
disabled={props.disabled || loading}
|
||||
className={`inline-flex min-h-10 items-center justify-center gap-2 rounded-lg px-4 py-2 text-sm font-medium transition disabled:cursor-not-allowed disabled:opacity-50 ${variants[variant]} ${className}`}
|
||||
// min-h-11 is the 44px touch target Apple and Google both recommend;
|
||||
// desktop gets the tighter 40px.
|
||||
className={`inline-flex min-h-11 items-center justify-center gap-2 rounded-lg px-4 py-2 text-sm font-medium transition duration-100 active:scale-[0.98] disabled:cursor-not-allowed disabled:opacity-50 disabled:active:scale-100 sm:min-h-10 ${variants[variant]} ${className}`}
|
||||
>
|
||||
{loading && <Spinner className="h-4 w-4" />}
|
||||
{children}
|
||||
@@ -54,68 +56,107 @@ export function Card({
|
||||
);
|
||||
}
|
||||
|
||||
export function Input({ label, hint, className = "", ...props }: InputHTMLAttributes<HTMLInputElement> & { label?: string; hint?: string }) {
|
||||
export function Input({
|
||||
label,
|
||||
hint,
|
||||
className = "",
|
||||
...props
|
||||
}: InputHTMLAttributes<HTMLInputElement> & { label?: string; hint?: string }) {
|
||||
return (
|
||||
<label className="block">
|
||||
{label && <span className="mb-1 block text-sm text-muted">{label}</span>}
|
||||
{label && <span className="mb-1.5 block text-sm text-muted">{label}</span>}
|
||||
<input
|
||||
{...props}
|
||||
className={`w-full rounded-lg border border-border-strong bg-bg px-3 py-2 text-sm text-text outline-none focus:border-primary ${className}`}
|
||||
className={`min-h-11 w-full rounded-lg border border-border-strong bg-bg px-3 py-2 text-base text-text outline-none transition-colors focus:border-primary sm:min-h-10 sm:text-sm ${className}`}
|
||||
/>
|
||||
{hint && <span className="mt-1 block text-xs text-faint">{hint}</span>}
|
||||
{hint && <span className="mt-1.5 block text-xs text-faint">{hint}</span>}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
export function Textarea({ label, className = "", ...props }: TextareaHTMLAttributes<HTMLTextAreaElement> & { label?: string }) {
|
||||
export function Textarea({
|
||||
label,
|
||||
className = "",
|
||||
...props
|
||||
}: TextareaHTMLAttributes<HTMLTextAreaElement> & { label?: string }) {
|
||||
return (
|
||||
<label className="block">
|
||||
{label && <span className="mb-1 block text-sm text-muted">{label}</span>}
|
||||
{label && <span className="mb-1.5 block text-sm text-muted">{label}</span>}
|
||||
<textarea
|
||||
{...props}
|
||||
className={`w-full rounded-lg border border-border-strong bg-bg px-3 py-2 text-sm text-text outline-none focus:border-primary ${className}`}
|
||||
className={`w-full rounded-lg border border-border-strong bg-bg px-3 py-2 text-base text-text outline-none transition-colors focus:border-primary sm:text-sm ${className}`}
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
export function Toggle({ checked, onChange, label }: { checked: boolean; onChange: (v: boolean) => void; label?: string }) {
|
||||
export function Toggle({
|
||||
checked,
|
||||
onChange,
|
||||
label,
|
||||
}: {
|
||||
checked: boolean;
|
||||
onChange: (v: boolean) => void;
|
||||
label?: string;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={checked}
|
||||
onClick={() => onChange(!checked)}
|
||||
className="inline-flex items-center gap-3"
|
||||
// Padded out to a full-height touch target without moving the track.
|
||||
className="-my-2 inline-flex min-h-11 items-center gap-3 py-2"
|
||||
>
|
||||
<span
|
||||
className={`relative h-6 w-11 rounded-full transition ${checked ? "bg-primary" : "bg-border-strong"}`}
|
||||
className={`relative h-7 w-12 shrink-0 rounded-full transition-colors sm:h-6 sm:w-11 ${
|
||||
checked ? "bg-primary" : "bg-border-strong"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`absolute top-0.5 h-5 w-5 rounded-full bg-white transition-all ${checked ? "left-[22px]" : "left-0.5"}`}
|
||||
className={`absolute top-0.5 h-6 w-6 rounded-full bg-white shadow-sm transition-all sm:h-5 sm:w-5 ${
|
||||
checked ? "left-[22px] sm:left-[22px]" : "left-0.5"
|
||||
}`}
|
||||
/>
|
||||
</span>
|
||||
{label && <span className="text-sm text-text">{label}</span>}
|
||||
{label && <span className="text-left text-sm text-text">{label}</span>}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function EmptyState({ title, subtitle, icon }: { title: string; subtitle?: string; icon?: string }) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center rounded-xl border border-dashed border-border py-16 text-center">
|
||||
<div className="flex flex-col items-center justify-center rounded-xl border border-dashed border-border px-5 py-12 text-center sm:py-16">
|
||||
{icon && <div className="mb-3 text-4xl opacity-70">{icon}</div>}
|
||||
<p className="text-text font-medium">{title}</p>
|
||||
<p className="font-medium text-text">{title}</p>
|
||||
{subtitle && <p className="mt-1 max-w-md text-sm text-muted">{subtitle}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function PageHeader({ title, subtitle, actions }: { title: string; subtitle?: string; actions?: ReactNode }) {
|
||||
export function PageHeader({
|
||||
title,
|
||||
subtitle,
|
||||
actions,
|
||||
}: {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
actions?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="mb-5 flex flex-wrap items-end justify-between gap-3 sm:mb-6 sm:gap-4">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold tracking-tight text-text sm:text-2xl">{title}</h1>
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-2xl font-bold tracking-tight text-text">{title}</h1>
|
||||
{subtitle && <p className="mt-1 text-sm text-muted">{subtitle}</p>}
|
||||
</div>
|
||||
{actions && <div className="flex w-full gap-2 sm:w-auto">{actions}</div>}
|
||||
{/* Actions span the width on phones so they stay thumb-sized. */}
|
||||
{actions && (
|
||||
<div className="flex w-full gap-2 *:flex-1 sm:w-auto sm:*:flex-none">{actions}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Shared classes for inline "see more" links — pads them to a real touch target. */
|
||||
export const textLinkClass =
|
||||
"-my-2 inline-flex min-h-11 items-center py-2 text-sm text-primary hover:underline sm:min-h-0 sm:py-0";
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export function useMediaQuery(query: string): boolean {
|
||||
const [matches, setMatches] = useState(() =>
|
||||
typeof window === "undefined" ? false : window.matchMedia(query).matches,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const mql = window.matchMedia(query);
|
||||
const onChange = () => setMatches(mql.matches);
|
||||
onChange();
|
||||
mql.addEventListener("change", onChange);
|
||||
return () => mql.removeEventListener("change", onChange);
|
||||
}, [query]);
|
||||
|
||||
return matches;
|
||||
}
|
||||
|
||||
/** Matches Tailwind's `sm` breakpoint — below it we switch to sheets + tab bar. */
|
||||
export function useIsMobile(): boolean {
|
||||
return useMediaQuery("(max-width: 639px)");
|
||||
}
|
||||
|
||||
/** True on touch-primary devices, where hover affordances don't exist. */
|
||||
export function useIsTouch(): boolean {
|
||||
return useMediaQuery("(pointer: coarse)");
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { useEffect } from "react";
|
||||
|
||||
// `overflow: hidden` on <body> is not enough on iOS Safari — the page still
|
||||
// rubber-bands behind the overlay. Pinning the body and restoring scroll on
|
||||
// release is the only approach that holds across engines.
|
||||
|
||||
let locks = 0;
|
||||
let savedScrollY = 0;
|
||||
let saved: Partial<CSSStyleDeclaration> = {};
|
||||
|
||||
function lock() {
|
||||
if (locks++ > 0) return;
|
||||
const body = document.body;
|
||||
savedScrollY = window.scrollY;
|
||||
saved = {
|
||||
position: body.style.position,
|
||||
top: body.style.top,
|
||||
left: body.style.left,
|
||||
right: body.style.right,
|
||||
width: body.style.width,
|
||||
overflow: body.style.overflow,
|
||||
};
|
||||
body.style.position = "fixed";
|
||||
body.style.top = `-${savedScrollY}px`;
|
||||
body.style.left = "0";
|
||||
body.style.right = "0";
|
||||
body.style.width = "100%";
|
||||
body.style.overflow = "hidden";
|
||||
}
|
||||
|
||||
function unlock() {
|
||||
if (--locks > 0) return;
|
||||
locks = 0;
|
||||
Object.assign(document.body.style, saved);
|
||||
window.scrollTo(0, savedScrollY);
|
||||
}
|
||||
|
||||
export function useScrollLock(active: boolean) {
|
||||
useEffect(() => {
|
||||
if (!active) return;
|
||||
lock();
|
||||
return unlock;
|
||||
}, [active]);
|
||||
}
|
||||
+238
-4
@@ -2,6 +2,9 @@
|
||||
|
||||
/* PatchPass design tokens (Tailwind v4 @theme) */
|
||||
@theme {
|
||||
/* Small-phone breakpoint (iPhone SE and friends sit below it). */
|
||||
--breakpoint-xs: 24rem;
|
||||
|
||||
--color-bg: #0a0e17;
|
||||
--color-surface: #111726;
|
||||
--color-surface-raised: #1a2234;
|
||||
@@ -24,6 +27,15 @@
|
||||
--color-cancelled: #7a8296;
|
||||
}
|
||||
|
||||
/* Layout metrics shared between the shell and the pages that dodge it. */
|
||||
:root {
|
||||
--bottom-nav-h: 3.75rem;
|
||||
--safe-top: env(safe-area-inset-top, 0px);
|
||||
--safe-bottom: env(safe-area-inset-bottom, 0px);
|
||||
--safe-left: env(safe-area-inset-left, 0px);
|
||||
--safe-right: env(safe-area-inset-right, 0px);
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
@@ -35,21 +47,158 @@ body,
|
||||
overflow-x: clip;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial,
|
||||
sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
/* Landscape notch insets, applied once for all in-flow content. Fixed-position
|
||||
chrome escapes this and re-applies the insets itself via `px-safe`. */
|
||||
#root {
|
||||
padding-left: var(--safe-left);
|
||||
padding-right: var(--safe-right);
|
||||
}
|
||||
|
||||
html {
|
||||
/* Stop iOS from re-flowing type when the device rotates. */
|
||||
-webkit-text-size-adjust: 100%;
|
||||
text-size-adjust: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, ui-sans-serif, system-ui, "Segoe UI", Roboto,
|
||||
Helvetica, Arial, sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
/* No rubber-band / pull-to-refresh: the single biggest "this is a web page" tell. */
|
||||
overscroll-behavior-y: none;
|
||||
}
|
||||
|
||||
/* Kill the grey flash on tap; every interactive element supplies its own
|
||||
:active feedback instead (see .tap / Button). */
|
||||
* {
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
button,
|
||||
a,
|
||||
[role="button"],
|
||||
label,
|
||||
summary {
|
||||
-webkit-touch-callout: none;
|
||||
/* Removes the 300ms click delay without disabling pinch-zoom on the page. */
|
||||
touch-action: manipulation;
|
||||
}
|
||||
|
||||
/* App chrome shouldn't be text-selectable — content still is. */
|
||||
button,
|
||||
nav,
|
||||
[data-chrome] {
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* Hover styles are already gated to hover-capable devices by Tailwind v4, but
|
||||
cursor rules are not. */
|
||||
@media (hover: hover) {
|
||||
button:hover,
|
||||
a:hover {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
* {
|
||||
scrollbar-color: var(--color-border-strong) transparent;
|
||||
}
|
||||
|
||||
/* Touch devices get no visible scrollbar gutters. */
|
||||
@media (pointer: coarse) {
|
||||
* {
|
||||
scrollbar-width: none;
|
||||
}
|
||||
*::-webkit-scrollbar {
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* iOS zooms the viewport when a focused input's font-size is under 16px. */
|
||||
@media (pointer: coarse) {
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
input,
|
||||
textarea,
|
||||
select {
|
||||
/* Keep the caret and selection on-brand across engines. */
|
||||
caret-color: var(--color-primary);
|
||||
}
|
||||
|
||||
/* Give sliders a full-height hit area (the native thumb stays centred) and let
|
||||
vertical drags still scroll the page. */
|
||||
input[type="range"] {
|
||||
height: 2.75rem;
|
||||
touch-action: pan-y;
|
||||
}
|
||||
|
||||
::selection {
|
||||
background-color: color-mix(in srgb, var(--color-primary) 35%, transparent);
|
||||
}
|
||||
|
||||
/* ── Utilities ──────────────────────────────────────────────────────────────── */
|
||||
|
||||
@utility pt-safe {
|
||||
padding-top: var(--safe-top);
|
||||
}
|
||||
@utility pb-safe {
|
||||
padding-bottom: var(--safe-bottom);
|
||||
}
|
||||
@utility px-safe {
|
||||
padding-left: var(--safe-left);
|
||||
padding-right: var(--safe-right);
|
||||
}
|
||||
@utility mb-safe {
|
||||
margin-bottom: var(--safe-bottom);
|
||||
}
|
||||
|
||||
/* Native-feeling press feedback for cards and rows. */
|
||||
@utility tap {
|
||||
transition:
|
||||
transform 0.12s ease,
|
||||
background-color 0.12s ease,
|
||||
opacity 0.12s ease;
|
||||
|
||||
&:active {
|
||||
transform: scale(0.985);
|
||||
}
|
||||
}
|
||||
|
||||
@utility tap-sm {
|
||||
transition:
|
||||
transform 0.1s ease,
|
||||
background-color 0.1s ease,
|
||||
opacity 0.1s ease;
|
||||
|
||||
&:active {
|
||||
transform: scale(0.94);
|
||||
opacity: 0.75;
|
||||
}
|
||||
}
|
||||
|
||||
/* Horizontal scrollers (diffs, code, filter chips) must not hand the gesture
|
||||
back to the browser's back-swipe. */
|
||||
@utility scroll-x {
|
||||
overflow-x: auto;
|
||||
overscroll-behavior-x: contain;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
@utility no-scrollbar {
|
||||
scrollbar-width: none;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Diff syntax coloring */
|
||||
.diff-add {
|
||||
background-color: rgba(62, 207, 142, 0.13);
|
||||
@@ -66,6 +215,8 @@ a:hover {
|
||||
color: var(--color-faint);
|
||||
}
|
||||
|
||||
/* ── Motion ─────────────────────────────────────────────────────────────────── */
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
@@ -94,3 +245,86 @@ a:hover {
|
||||
.animate-pulse-ring {
|
||||
animation: pulseRing 1.8s ease-out infinite;
|
||||
}
|
||||
|
||||
/* Sheet + backdrop transitions, tuned to iOS's ease-out curve. */
|
||||
@keyframes sheetUp {
|
||||
from {
|
||||
transform: translateY(100%);
|
||||
}
|
||||
to {
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
.animate-sheet-up {
|
||||
animation: sheetUp 0.28s cubic-bezier(0.32, 0.72, 0, 1);
|
||||
}
|
||||
|
||||
@keyframes scaleIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.96);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
.animate-scale-in {
|
||||
animation: scaleIn 0.16s ease-out;
|
||||
}
|
||||
|
||||
@keyframes backdropIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
.animate-backdrop-in {
|
||||
animation: backdropIn 0.22s ease-out;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
.tap:active,
|
||||
.tap-sm:active {
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── react-toastify, restyled to the design tokens and lifted above the tabs ── */
|
||||
|
||||
.Toastify__toast-container {
|
||||
padding: 0;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.Toastify__toast {
|
||||
border-radius: 0.75rem;
|
||||
border: 1px solid var(--color-border-strong);
|
||||
background-color: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
font-family: inherit;
|
||||
font-size: 0.875rem;
|
||||
min-height: 3rem;
|
||||
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.Toastify__toast-container {
|
||||
left: 0.75rem;
|
||||
right: 0.75rem;
|
||||
width: auto;
|
||||
bottom: calc(var(--bottom-nav-h) + var(--safe-bottom) + 0.75rem);
|
||||
}
|
||||
.Toastify__toast {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
+14
-2
@@ -23,7 +23,7 @@ import { PrivacyPage, TermsPage } from "./pages/Legal";
|
||||
|
||||
function FullScreenLoader() {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center text-primary">
|
||||
<div className="flex min-h-[100dvh] items-center justify-center text-primary">
|
||||
<Spinner className="h-8 w-8" />
|
||||
</div>
|
||||
);
|
||||
@@ -66,7 +66,19 @@ ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
|
||||
<BrowserRouter>
|
||||
<AuthProvider>
|
||||
<App />
|
||||
<ToastContainer position="bottom-right" theme="dark" newestOnTop />
|
||||
<ToastContainer
|
||||
position="bottom-right"
|
||||
theme="dark"
|
||||
newestOnTop
|
||||
// Swipe-to-dismiss and a short auto-close read as native on a phone.
|
||||
draggable
|
||||
draggablePercent={35}
|
||||
closeOnClick
|
||||
closeButton={false}
|
||||
hideProgressBar
|
||||
autoClose={3200}
|
||||
limit={3}
|
||||
/>
|
||||
</AuthProvider>
|
||||
</BrowserRouter>
|
||||
</React.StrictMode>,
|
||||
|
||||
+71
-24
@@ -4,6 +4,7 @@ import { admin } from "../api/client";
|
||||
import type { AdminUser, AuditLog, Agent, GlobalSettings } from "../api/types";
|
||||
import { useAuth } from "../context/AuthContext";
|
||||
import { Button, Card, PageHeader, Spinner, Toggle } from "../components/ui";
|
||||
import { ConfirmSheet } from "../components/ConfirmSheet";
|
||||
import { relativeTime } from "../utils";
|
||||
|
||||
type Tab = "users" | "agents" | "settings" | "audit";
|
||||
@@ -20,12 +21,12 @@ export function AdminPage() {
|
||||
return (
|
||||
<div>
|
||||
<PageHeader title="Admin" subtitle="Platform administration." />
|
||||
<div className="mb-6 flex gap-1 overflow-x-auto border-b border-border">
|
||||
<div className="no-scrollbar -mx-3 mb-6 flex gap-1 overflow-x-auto overscroll-x-contain border-b border-border px-3 sm:mx-0 sm:px-0">
|
||||
{tabs.map((t) => (
|
||||
<button
|
||||
key={t.id}
|
||||
onClick={() => setTab(t.id)}
|
||||
className={`shrink-0 px-4 py-2 text-sm font-medium transition ${
|
||||
className={`min-h-11 shrink-0 px-4 text-sm font-medium transition ${
|
||||
tab === t.id ? "border-b-2 border-accent text-text" : "text-muted hover:text-text"
|
||||
}`}
|
||||
>
|
||||
@@ -45,6 +46,8 @@ function UsersTab() {
|
||||
const { user: me } = useAuth();
|
||||
const [users, setUsers] = useState<AdminUser[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [pendingDelete, setPendingDelete] = useState<AdminUser | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
@@ -75,13 +78,16 @@ function UsersTab() {
|
||||
}
|
||||
};
|
||||
const remove = async (u: AdminUser) => {
|
||||
if (!confirm(`Delete user "${u.username}" and all their data?`)) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
await admin.deleteUser(u.id);
|
||||
toast.success("User deleted");
|
||||
setPendingDelete(null);
|
||||
load();
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "Failed");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -89,9 +95,10 @@ function UsersTab() {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{users.map((u) => (
|
||||
<Card key={u.id} className="flex flex-wrap items-center gap-3 p-4">
|
||||
<Card key={u.id} className="p-3.5 sm:p-4">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||
<span className="font-medium text-text">{u.display_name}</span>
|
||||
<span className="text-xs text-faint">@{u.username}</span>
|
||||
{u.role === "ADMIN" && (
|
||||
@@ -105,25 +112,37 @@ function UsersTab() {
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted">
|
||||
<p className="mt-0.5 text-xs text-muted">
|
||||
{u.agent_count} agents · {u.request_count} requests · joined {relativeTime(u.created_at)}
|
||||
</p>
|
||||
</div>
|
||||
{u.id !== me?.id && (
|
||||
<div className="flex w-full flex-wrap gap-2 sm:w-auto sm:flex-nowrap">
|
||||
<div className="flex w-full gap-2 *:flex-1 sm:w-auto sm:flex-nowrap sm:*:flex-none">
|
||||
<Button variant="ghost" onClick={() => setRole(u, u.role === "ADMIN" ? "USER" : "ADMIN")}>
|
||||
{u.role === "ADMIN" ? "Demote" : "Promote"}
|
||||
</Button>
|
||||
<Button variant="ghost" onClick={() => toggleDisabled(u)}>
|
||||
{u.disabled ? "Enable" : "Disable"}
|
||||
</Button>
|
||||
<Button variant="ghost" className="text-rejected" onClick={() => remove(u)}>
|
||||
<Button variant="ghost" className="text-rejected" onClick={() => setPendingDelete(u)}>
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
<ConfirmSheet
|
||||
open={!!pendingDelete}
|
||||
onClose={() => setPendingDelete(null)}
|
||||
onConfirm={() => pendingDelete && remove(pendingDelete)}
|
||||
loading={busy}
|
||||
danger
|
||||
title="Delete user"
|
||||
message={`Delete "${pendingDelete?.username}" and all of their agents, change requests and notifications? This cannot be undone.`}
|
||||
confirmLabel="Delete"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -131,6 +150,9 @@ function UsersTab() {
|
||||
function AgentsTab() {
|
||||
const [agents, setAgents] = useState<(Agent & { owner: { id: number; username: string } })[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [pendingDelete, setPendingDelete] = useState<Agent | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
setAgents(await admin.agents());
|
||||
@@ -151,13 +173,16 @@ function AgentsTab() {
|
||||
}
|
||||
};
|
||||
const remove = async (a: Agent) => {
|
||||
if (!confirm(`Delete agent "${a.name}"?`)) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
await admin.deleteAgent(a.id);
|
||||
toast.success("Agent deleted");
|
||||
setPendingDelete(null);
|
||||
load();
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "Failed");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -165,9 +190,10 @@ function AgentsTab() {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{agents.map((a) => (
|
||||
<Card key={a.id} className="flex flex-wrap items-center gap-3 p-4">
|
||||
<Card key={a.id} className="p-3.5 sm:p-4">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||
<span className="font-medium text-text">{a.name}</span>
|
||||
<span className="text-xs text-faint">by @{a.owner.username}</span>
|
||||
{a.disabled && (
|
||||
@@ -176,18 +202,30 @@ function AgentsTab() {
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{a.description && <p className="text-xs text-muted">{a.description}</p>}
|
||||
{a.description && <p className="mt-0.5 text-xs text-muted">{a.description}</p>}
|
||||
</div>
|
||||
<div className="flex w-full flex-wrap gap-2 sm:w-auto sm:flex-nowrap">
|
||||
<div className="flex w-full gap-2 *:flex-1 sm:w-auto sm:flex-nowrap sm:*:flex-none">
|
||||
<Button variant="ghost" onClick={() => toggle(a)}>
|
||||
{a.disabled ? "Enable" : "Disable"}
|
||||
</Button>
|
||||
<Button variant="ghost" className="text-rejected" onClick={() => remove(a)}>
|
||||
<Button variant="ghost" className="text-rejected" onClick={() => setPendingDelete(a)}>
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
<ConfirmSheet
|
||||
open={!!pendingDelete}
|
||||
onClose={() => setPendingDelete(null)}
|
||||
onConfirm={() => pendingDelete && remove(pendingDelete)}
|
||||
loading={busy}
|
||||
danger
|
||||
title="Delete agent"
|
||||
message={`Delete "${pendingDelete?.name}" and all of its change requests? This cannot be undone.`}
|
||||
confirmLabel="Delete"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -195,7 +233,10 @@ function AgentsTab() {
|
||||
function SettingsTab() {
|
||||
const [settings, setSettings] = useState<GlobalSettings | null>(null);
|
||||
useEffect(() => {
|
||||
admin.settings().then(setSettings).catch(() => {});
|
||||
admin
|
||||
.settings()
|
||||
.then(setSettings)
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
const update = async (patch: Partial<GlobalSettings>) => {
|
||||
@@ -211,15 +252,15 @@ function SettingsTab() {
|
||||
if (!settings) return <Loader />;
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<Card className="flex flex-wrap items-center justify-between gap-4 p-4 sm:p-5">
|
||||
<div>
|
||||
<Card className="flex items-center justify-between gap-4 p-4 sm:p-5">
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium text-text">Enable registration</p>
|
||||
<p className="text-sm text-muted">Allow new humans to create accounts.</p>
|
||||
</div>
|
||||
<Toggle checked={settings.registration_enabled} onChange={(v) => update({ registration_enabled: v })} />
|
||||
</Card>
|
||||
<Card className="flex flex-wrap items-center justify-between gap-4 p-4 sm:p-5">
|
||||
<div>
|
||||
<Card className="flex items-center justify-between gap-4 p-4 sm:p-5">
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium text-text">Enable requests</p>
|
||||
<p className="text-sm text-muted">Allow agents to submit new change requests platform-wide.</p>
|
||||
</div>
|
||||
@@ -251,14 +292,20 @@ function AuditTab() {
|
||||
<div>
|
||||
<div className="space-y-1.5">
|
||||
{logs.map((l) => (
|
||||
<Card key={l.id} className="flex flex-wrap items-center gap-2 p-3 text-sm sm:flex-nowrap sm:gap-3">
|
||||
<code className="rounded bg-surface-raised px-2 py-0.5 text-xs text-accent">{l.action}</code>
|
||||
<span className="min-w-0 flex-1 truncate text-muted">
|
||||
<Card key={l.id} className="p-3 text-sm">
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 sm:flex-nowrap sm:gap-3">
|
||||
<code className="shrink-0 rounded bg-surface-raised px-2 py-0.5 text-xs text-accent">
|
||||
{l.action}
|
||||
</code>
|
||||
<span className="min-w-0 flex-1 break-words text-muted sm:truncate">
|
||||
{l.actor ? `@${l.actor.username}` : "system"}
|
||||
{l.target_type && ` → ${l.target_type}:${l.target_id}`}
|
||||
{l.detail && ` · ${l.detail}`}
|
||||
</span>
|
||||
<span className="w-full text-xs text-faint sm:w-auto sm:shrink-0">{relativeTime(l.created_at)}</span>
|
||||
<span className="w-full text-xs text-faint sm:w-auto sm:shrink-0">
|
||||
{relativeTime(l.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
@@ -267,7 +314,7 @@ function AuditTab() {
|
||||
<Button variant="secondary" disabled={page <= 1} onClick={() => setPage((p) => p - 1)}>
|
||||
Previous
|
||||
</Button>
|
||||
<span className="text-sm text-muted">
|
||||
<span className="text-sm text-muted tabular-nums">
|
||||
Page {page} of {totalPages}
|
||||
</span>
|
||||
<Button variant="secondary" disabled={page >= totalPages} onClick={() => setPage((p) => p + 1)}>
|
||||
|
||||
+142
-22
@@ -5,6 +5,11 @@ import type { Agent } from "../api/types";
|
||||
import { Button, Card, EmptyState, PageHeader, Spinner } from "../components/ui";
|
||||
import { AgentAvatar } from "../components/AgentAvatar";
|
||||
import { AgentFormModal, ConnectModal } from "../components/AgentModals";
|
||||
import { ConfirmSheet } from "../components/ConfirmSheet";
|
||||
import { Sheet } from "../components/Sheet";
|
||||
import { KeyIcon, MoreIcon, PencilIcon, PowerIcon, TrashIcon } from "../components/icons";
|
||||
|
||||
type Confirm = { agent: Agent; kind: "regenerate" | "delete" };
|
||||
|
||||
export function AgentsPage() {
|
||||
const [agents, setAgents] = useState<Agent[]>([]);
|
||||
@@ -13,6 +18,9 @@ export function AgentsPage() {
|
||||
const [editing, setEditing] = useState<Agent | null>(null);
|
||||
const [connectAgent, setConnectAgent] = useState<Agent | null>(null);
|
||||
const [revealedKey, setRevealedKey] = useState<string | null>(null);
|
||||
const [menuAgent, setMenuAgent] = useState<Agent | null>(null);
|
||||
const [confirm, setConfirm] = useState<Confirm | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
@@ -35,15 +43,18 @@ export function AgentsPage() {
|
||||
};
|
||||
|
||||
const regenerate = async (agent: Agent) => {
|
||||
if (!confirm(`Regenerate the API key for "${agent.name}"? The old key stops working immediately.`)) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const updated = await agentsApi.regenerateKey(agent.id);
|
||||
toast.success("API key regenerated");
|
||||
setRevealedKey(updated.api_key ?? null);
|
||||
setConnectAgent(updated);
|
||||
setConfirm(null);
|
||||
load();
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "Failed");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -58,16 +69,24 @@ export function AgentsPage() {
|
||||
};
|
||||
|
||||
const remove = async (agent: Agent) => {
|
||||
if (!confirm(`Delete "${agent.name}"? All its requests will be permanently deleted.`)) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
await agentsApi.remove(agent.id);
|
||||
toast.success("Agent deleted");
|
||||
setConfirm(null);
|
||||
load();
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "Failed");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const openEdit = (a: Agent) => {
|
||||
setEditing(a);
|
||||
setFormOpen(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHeader
|
||||
@@ -97,47 +116,65 @@ export function AgentsPage() {
|
||||
subtitle="Create an agent, then connect it via OpenClaw, MCP, or the REST API."
|
||||
/>
|
||||
) : (
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="grid gap-3 sm:gap-4 md:grid-cols-2">
|
||||
{agents.map((a) => (
|
||||
<Card key={a.id} className="p-4">
|
||||
<Card key={a.id} className="min-w-0 p-3.5 sm:p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<AgentAvatar name={a.name} iconUrl={a.icon_url} size={44} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="truncate font-semibold text-text">{a.name}</h3>
|
||||
{a.disabled && (
|
||||
<span className="rounded-full bg-rejected/15 px-2 py-0.5 text-[10px] font-semibold text-rejected">
|
||||
<span className="shrink-0 rounded-full bg-rejected/15 px-2 py-0.5 text-[10px] font-semibold text-rejected">
|
||||
DISABLED
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{a.description && <p className="mt-0.5 line-clamp-2 text-xs text-muted">{a.description}</p>}
|
||||
<p className="mt-1 text-xs text-faint">
|
||||
<p className="mt-1 truncate text-xs text-faint">
|
||||
{a.pending_count ?? 0}/{a.max_pending_requests} pending ·{" "}
|
||||
<code>{a.api_key_masked}</code>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
<Button variant="secondary" onClick={() => setConnectAgent(a)}>
|
||||
|
||||
{/* Phones: two primary actions plus an overflow sheet, rather
|
||||
than five wrapped ghost buttons. */}
|
||||
<div className="mt-3.5 flex gap-2 sm:hidden">
|
||||
<Button variant="secondary" className="flex-1" onClick={() => setConnectAgent(a)}>
|
||||
Connect
|
||||
</Button>
|
||||
<Button variant="ghost" className="flex-1" onClick={() => openEdit(a)}>
|
||||
Edit
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setEditing(a);
|
||||
setFormOpen(true);
|
||||
}}
|
||||
className="w-11 shrink-0 px-0"
|
||||
aria-label={`More actions for ${a.name}`}
|
||||
onClick={() => setMenuAgent(a)}
|
||||
>
|
||||
<MoreIcon className="h-5 w-5" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 hidden flex-wrap gap-2 sm:flex">
|
||||
<Button variant="secondary" onClick={() => setConnectAgent(a)}>
|
||||
Connect
|
||||
</Button>
|
||||
<Button variant="ghost" onClick={() => openEdit(a)}>
|
||||
Edit
|
||||
</Button>
|
||||
<Button variant="ghost" onClick={() => regenerate(a)}>
|
||||
<Button variant="ghost" onClick={() => setConfirm({ agent: a, kind: "regenerate" })}>
|
||||
Regenerate key
|
||||
</Button>
|
||||
<Button variant="ghost" onClick={() => toggleDisabled(a)}>
|
||||
{a.disabled ? "Enable" : "Disable"}
|
||||
</Button>
|
||||
<Button variant="ghost" className="text-rejected" onClick={() => remove(a)}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="text-rejected"
|
||||
onClick={() => setConfirm({ agent: a, kind: "delete" })}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
@@ -146,13 +183,8 @@ export function AgentsPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<AgentFormModal
|
||||
open={formOpen}
|
||||
onClose={() => setFormOpen(false)}
|
||||
agent={editing}
|
||||
onSaved={onSaved}
|
||||
/>
|
||||
{connectAgent && (
|
||||
<AgentFormModal open={formOpen} onClose={() => setFormOpen(false)} agent={editing} onSaved={onSaved} />
|
||||
|
||||
<ConnectModal
|
||||
open={!!connectAgent}
|
||||
onClose={() => {
|
||||
@@ -162,7 +194,95 @@ export function AgentsPage() {
|
||||
agent={connectAgent}
|
||||
revealedKey={revealedKey}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Overflow actions (mobile) */}
|
||||
<Sheet
|
||||
open={!!menuAgent}
|
||||
onClose={() => setMenuAgent(null)}
|
||||
title={menuAgent?.name ?? ""}
|
||||
padded={false}
|
||||
>
|
||||
<ActionRow
|
||||
icon={<PencilIcon className="h-5 w-5" />}
|
||||
label="Edit agent"
|
||||
onClick={() => {
|
||||
const a = menuAgent!;
|
||||
setMenuAgent(null);
|
||||
openEdit(a);
|
||||
}}
|
||||
/>
|
||||
<ActionRow
|
||||
icon={<KeyIcon className="h-5 w-5" />}
|
||||
label="Regenerate key"
|
||||
onClick={() => {
|
||||
const a = menuAgent!;
|
||||
setMenuAgent(null);
|
||||
setConfirm({ agent: a, kind: "regenerate" });
|
||||
}}
|
||||
/>
|
||||
<ActionRow
|
||||
icon={<PowerIcon className="h-5 w-5" />}
|
||||
label={menuAgent?.disabled ? "Enable agent" : "Disable agent"}
|
||||
onClick={() => {
|
||||
const a = menuAgent!;
|
||||
setMenuAgent(null);
|
||||
toggleDisabled(a);
|
||||
}}
|
||||
/>
|
||||
<ActionRow
|
||||
icon={<TrashIcon className="h-5 w-5" />}
|
||||
label="Delete agent"
|
||||
danger
|
||||
onClick={() => {
|
||||
const a = menuAgent!;
|
||||
setMenuAgent(null);
|
||||
setConfirm({ agent: a, kind: "delete" });
|
||||
}}
|
||||
/>
|
||||
</Sheet>
|
||||
|
||||
<ConfirmSheet
|
||||
open={!!confirm}
|
||||
onClose={() => setConfirm(null)}
|
||||
loading={busy}
|
||||
danger
|
||||
title={confirm?.kind === "delete" ? "Delete agent" : "Regenerate API key"}
|
||||
message={
|
||||
confirm?.kind === "delete"
|
||||
? `Delete "${confirm.agent.name}"? All of its change requests will be permanently deleted.`
|
||||
: `Regenerate the API key for "${confirm?.agent.name}"? The old key stops working immediately.`
|
||||
}
|
||||
confirmLabel={confirm?.kind === "delete" ? "Delete" : "Regenerate"}
|
||||
onConfirm={() => {
|
||||
if (!confirm) return;
|
||||
if (confirm.kind === "delete") remove(confirm.agent);
|
||||
else regenerate(confirm.agent);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ActionRow({
|
||||
icon,
|
||||
label,
|
||||
onClick,
|
||||
danger,
|
||||
}: {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
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" : "text-text"
|
||||
}`}
|
||||
>
|
||||
<span className={danger ? "" : "text-muted"}>{icon}</span>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
+39
-21
@@ -3,9 +3,10 @@ import { Link } from "react-router-dom";
|
||||
import { requests as requestsApi, agents as agentsApi } from "../api/client";
|
||||
import type { Agent, ChangeRequest, RequestState } from "../api/types";
|
||||
import { useNotifications } from "../context/NotificationsContext";
|
||||
import { Card, EmptyState, PageHeader, Spinner } from "../components/ui";
|
||||
import { Card, EmptyState, PageHeader, Spinner, textLinkClass } from "../components/ui";
|
||||
import { StateBadge } from "../components/StateBadge";
|
||||
import { AgentAvatar } from "../components/AgentAvatar";
|
||||
import { ChevronRightIcon } from "../components/icons";
|
||||
import { expiresIn, relativeTime } from "../utils";
|
||||
|
||||
const summaryTiles: { state: RequestState; label: string }[] = [
|
||||
@@ -56,20 +57,25 @@ export function DashboardPage() {
|
||||
subtitle="Requests awaiting your review, and your connected agents."
|
||||
/>
|
||||
|
||||
<div className="mb-8 grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
<div className="mb-7 grid grid-cols-2 gap-2.5 sm:mb-8 sm:grid-cols-4 sm:gap-3">
|
||||
{summaryTiles.map((t) => (
|
||||
<Card key={t.state} className="p-4">
|
||||
<p className="text-3xl font-bold text-text">{counts[t.state] ?? 0}</p>
|
||||
<p className="mt-1 text-xs text-muted">{t.label}</p>
|
||||
<Card key={t.state} className="p-3.5 sm:p-4">
|
||||
<p className="text-2xl font-bold text-text tabular-nums sm:text-3xl">
|
||||
{counts[t.state] ?? 0}
|
||||
</p>
|
||||
<p className="mt-0.5 text-xs text-muted sm:mt-1">{t.label}</p>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-8 lg:grid-cols-3">
|
||||
<div className="lg:col-span-2">
|
||||
{/* min-w-0 on both tracks: grid items default to min-width:auto, so the
|
||||
truncated meta lines below would otherwise widen the column past the
|
||||
viewport on narrow phones. */}
|
||||
<div className="grid gap-7 lg:grid-cols-3 lg:gap-8">
|
||||
<div className="min-w-0 lg:col-span-2">
|
||||
<div className="mb-3 flex items-center justify-between gap-3">
|
||||
<h2 className="text-lg font-semibold">Awaiting review</h2>
|
||||
<Link to="/requests" className="text-sm text-primary hover:underline">
|
||||
<Link to="/requests" className={textLinkClass}>
|
||||
View all →
|
||||
</Link>
|
||||
</div>
|
||||
@@ -80,7 +86,7 @@ export function DashboardPage() {
|
||||
subtitle="No requests are waiting for your review right now."
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-2.5">
|
||||
{pending.map((r) => (
|
||||
<PendingRow key={r.request_id} request={r} />
|
||||
))}
|
||||
@@ -88,15 +94,19 @@ export function DashboardPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="min-w-0">
|
||||
<div className="mb-3 flex items-center justify-between gap-3">
|
||||
<h2 className="text-lg font-semibold">Agents</h2>
|
||||
<Link to="/agents" className="text-sm text-primary hover:underline">
|
||||
<Link to="/agents" className={textLinkClass}>
|
||||
Manage →
|
||||
</Link>
|
||||
</div>
|
||||
{agents.length === 0 ? (
|
||||
<EmptyState icon="🤖" title="No agents yet" subtitle="Create an agent to start receiving requests." />
|
||||
<EmptyState
|
||||
icon="🤖"
|
||||
title="No agents yet"
|
||||
subtitle="Create an agent to start receiving requests."
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{agents.map((a) => (
|
||||
@@ -123,30 +133,38 @@ function PendingRow({ request }: { request: ChangeRequest }) {
|
||||
const exp = expiresIn(request.expires_at);
|
||||
return (
|
||||
<Link to={`/requests/${request.request_id}`} className="block">
|
||||
<Card className="p-4 transition hover:border-border-strong hover:bg-surface-raised/40">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between sm:gap-3">
|
||||
<div className="flex min-w-0 items-start gap-3">
|
||||
<Card className="tap flex items-start gap-3 p-3.5 hover:border-border-strong hover:bg-surface-raised/40 active:bg-surface-raised/60 sm:p-4">
|
||||
<AgentAvatar name={request.agent?.name ?? "?"} iconUrl={request.agent?.icon_url} size={32} />
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<p className="break-words font-medium text-text">{request.title}</p>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||
<p className="min-w-0 break-words font-medium leading-snug text-text">{request.title}</p>
|
||||
{request.resubmitted && (
|
||||
<span className="animate-pulse-ring rounded-full bg-changes/20 px-2 py-0.5 text-[10px] font-semibold text-changes">
|
||||
UPDATED
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-0.5 truncate text-xs text-muted">
|
||||
<p className="mt-1 truncate text-xs text-muted">
|
||||
{request.agent?.name} · {request.changes.length} change
|
||||
{request.changes.length === 1 ? "" : "s"} · {relativeTime(request.created_at)}
|
||||
</p>
|
||||
{/* Stacked under the title on phones, where a side column would
|
||||
squeeze the text to a couple of words per line. */}
|
||||
<div className="mt-2 flex flex-wrap items-center gap-2 sm:hidden">
|
||||
<StateBadge state={request.state} />
|
||||
<span className={`text-[11px] ${exp.urgent ? "text-pending" : "text-faint"}`}>
|
||||
{exp.text}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex self-start sm:shrink-0 sm:flex-col sm:items-end sm:gap-1">
|
||||
|
||||
<div className="hidden shrink-0 flex-col items-end gap-1 sm:flex">
|
||||
<StateBadge state={request.state} />
|
||||
<span className={`text-[11px] ${exp.urgent ? "text-pending" : "text-faint"}`}>{exp.text}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ChevronRightIcon className="mt-1 h-5 w-5 shrink-0 text-faint sm:hidden" />
|
||||
</Card>
|
||||
</Link>
|
||||
);
|
||||
|
||||
+23
-18
@@ -2,8 +2,12 @@ import { Link } from "react-router-dom";
|
||||
|
||||
function LegalShell({ title, children }: { title: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="mx-auto max-w-3xl px-4 py-12">
|
||||
<Link to="/" className="mb-6 inline-block text-sm text-muted hover:text-text">
|
||||
<div className="pt-safe pb-safe mx-auto max-w-3xl px-4">
|
||||
<div className="py-10 sm:py-12">
|
||||
<Link
|
||||
to="/"
|
||||
className="tap-sm -ml-1 mb-4 inline-flex min-h-11 items-center pr-3 text-sm text-muted hover:text-text sm:mb-6"
|
||||
>
|
||||
← Back
|
||||
</Link>
|
||||
<h1 className="mb-6 text-3xl font-bold tracking-tight">{title}</h1>
|
||||
@@ -20,6 +24,7 @@ function LegalShell({ title, children }: { title: string; children: React.ReactN
|
||||
</Link>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -27,28 +32,28 @@ export function PrivacyPage() {
|
||||
return (
|
||||
<LegalShell title="Privacy Policy">
|
||||
<p>
|
||||
PatchPass is a self-hostable human approval layer for AI agents. This policy explains what data
|
||||
the platform stores and the controls you have over it.
|
||||
PatchPass is a self-hostable human approval layer for AI agents. This policy explains what data the
|
||||
platform stores and the controls you have over it.
|
||||
</p>
|
||||
<h2>Data we store</h2>
|
||||
<p>
|
||||
We store the account data you provide (username, display name, a bcrypt-hashed password, and an
|
||||
optional TOTP secret if you enable two-factor authentication), the agents you create (name,
|
||||
description, website, icon URL, and an API key), the change requests your agents submit, the
|
||||
decisions you make, and your in-app notifications.
|
||||
description, website, icon URL, and an API key), the change requests your agents submit, the decisions
|
||||
you make, and your in-app notifications.
|
||||
</p>
|
||||
<h2>Agent icons</h2>
|
||||
<p>
|
||||
When you set an agent icon URL, the backend fetches it once to verify it points to a valid image
|
||||
(JPG, PNG, or GIF, under 1 MB). The image itself is <strong>not</strong> stored or cached —
|
||||
only the URL you provided is kept.
|
||||
When you set an agent icon URL, the backend fetches it once to verify it points to a valid image (JPG,
|
||||
PNG, or GIF, under 1 MB). The image itself is <strong>not</strong> stored or cached — only the
|
||||
URL you provided is kept.
|
||||
</p>
|
||||
<h2>How your data is used</h2>
|
||||
<p>
|
||||
Data is used solely to operate the approval workflow: routing agent requests to you for review,
|
||||
recording decisions, and delivering notifications. We do not sell data or share it with third
|
||||
parties. Administrators of your instance can view platform data for moderation; access to another
|
||||
user's request payloads is explicitly audited.
|
||||
recording decisions, and delivering notifications. We do not sell data or share it with third parties.
|
||||
Administrators of your instance can view platform data for moderation; access to another user's
|
||||
request payloads is explicitly audited.
|
||||
</p>
|
||||
<h2>Retention</h2>
|
||||
<p>
|
||||
@@ -58,8 +63,8 @@ export function PrivacyPage() {
|
||||
<h2>Your rights (GDPR)</h2>
|
||||
<p>
|
||||
You can export all of your data as machine-readable JSON at any time from Settings. You can also
|
||||
delete your account, which permanently removes your account and cascades to all your agents,
|
||||
change requests, and notifications. These actions are self-service and take effect immediately.
|
||||
delete your account, which permanently removes your account and cascades to all your agents, change
|
||||
requests, and notifications. These actions are self-service and take effect immediately.
|
||||
</p>
|
||||
<h2>Security</h2>
|
||||
<p>
|
||||
@@ -92,13 +97,13 @@ export function TermsPage() {
|
||||
</p>
|
||||
<h2>Rate limits</h2>
|
||||
<p>
|
||||
To keep the platform usable, agents are limited to 15 requests per hour and a configurable number
|
||||
of simultaneous pending requests, and each human may own up to 5 agents.
|
||||
To keep the platform usable, agents are limited to 15 requests per hour and a configurable number of
|
||||
simultaneous pending requests, and each human may own up to 5 agents.
|
||||
</p>
|
||||
<h2>Availability</h2>
|
||||
<p>
|
||||
This is self-hosted software. Availability, backups, and data durability are the responsibility of
|
||||
the operator of this instance.
|
||||
This is self-hosted software. Availability, backups, and data durability are the responsibility of the
|
||||
operator of this instance.
|
||||
</p>
|
||||
<h2>Changes</h2>
|
||||
<p>These terms may be updated by the operator of your instance.</p>
|
||||
|
||||
+10
-4
@@ -44,7 +44,10 @@ export function LoginPage() {
|
||||
label="Username"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
autoFocus
|
||||
autoComplete="username"
|
||||
autoCapitalize="none"
|
||||
autoCorrect="off"
|
||||
spellCheck={false}
|
||||
required
|
||||
/>
|
||||
<Input
|
||||
@@ -52,6 +55,7 @@ export function LoginPage() {
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
autoComplete="current-password"
|
||||
required
|
||||
/>
|
||||
{needsTotp && (
|
||||
@@ -61,6 +65,8 @@ export function LoginPage() {
|
||||
onChange={(e) => setTotp(e.target.value.replace(/\D/g, "").slice(0, 6))}
|
||||
placeholder="123456"
|
||||
inputMode="numeric"
|
||||
autoComplete="one-time-code"
|
||||
pattern="[0-9]*"
|
||||
autoFocus
|
||||
/>
|
||||
)}
|
||||
@@ -90,10 +96,10 @@ export function AuthShell({
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex min-h-[100dvh] items-center justify-center p-3 sm:p-4">
|
||||
<div className="w-full max-w-md">
|
||||
<div className="pt-safe pb-safe flex min-h-[100dvh] items-center justify-center">
|
||||
<div className="w-full max-w-md px-3 py-6 sm:px-4">
|
||||
<div className="mb-8 text-center">
|
||||
<div className="mb-3 text-4xl">✅</div>
|
||||
<img src="/icon.svg" alt="" className="mx-auto mb-3 h-14 w-14 rounded-2xl" />
|
||||
<h1 className="text-3xl font-bold tracking-tight">
|
||||
Patch<span className="text-primary">Pass</span>
|
||||
</h1>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useNotifications } from "../context/NotificationsContext";
|
||||
import { Button, Card, EmptyState, PageHeader } from "../components/ui";
|
||||
import { ChevronRightIcon } from "../components/icons";
|
||||
import { relativeTime } from "../utils";
|
||||
|
||||
export function NotificationsPage() {
|
||||
@@ -33,7 +34,7 @@ export function NotificationsPage() {
|
||||
{items.map((n) => (
|
||||
<Card
|
||||
key={n.id}
|
||||
className={`flex cursor-pointer items-start gap-3 p-4 transition hover:bg-surface-raised/40 ${
|
||||
className={`tap flex cursor-pointer items-start gap-3 p-3.5 hover:bg-surface-raised/40 active:bg-surface-raised/60 sm:p-4 ${
|
||||
n.read ? "opacity-60" : ""
|
||||
}`}
|
||||
onClick={() => {
|
||||
@@ -41,12 +42,20 @@ export function NotificationsPage() {
|
||||
if (n.request_id) navigate(`/requests/${n.request_id}`);
|
||||
}}
|
||||
>
|
||||
{!n.read && <span className="mt-1.5 h-2 w-2 shrink-0 rounded-full bg-primary" />}
|
||||
<span
|
||||
className={`mt-1.5 h-2 w-2 shrink-0 rounded-full ${n.read ? "bg-transparent" : "bg-primary"}`}
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-medium text-text">{n.title}</p>
|
||||
<p className="text-sm text-muted">{n.message}</p>
|
||||
<p className="font-medium leading-snug text-text">{n.title}</p>
|
||||
<p className="mt-0.5 text-sm text-muted">{n.message}</p>
|
||||
<p className="mt-1 text-xs text-faint sm:hidden">{relativeTime(n.created_at)}</p>
|
||||
</div>
|
||||
<span className="shrink-0 text-xs text-faint">{relativeTime(n.created_at)}</span>
|
||||
<span className="hidden shrink-0 text-xs text-faint sm:block">
|
||||
{relativeTime(n.created_at)}
|
||||
</span>
|
||||
{n.request_id && (
|
||||
<ChevronRightIcon className="mt-0.5 h-5 w-5 shrink-0 text-faint sm:hidden" />
|
||||
)}
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -55,19 +55,24 @@ export function RegisterPage() {
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
hint="Letters, numbers, dots, dashes, underscores. Case-insensitive."
|
||||
autoFocus
|
||||
autoComplete="username"
|
||||
autoCapitalize="none"
|
||||
autoCorrect="off"
|
||||
spellCheck={false}
|
||||
required
|
||||
/>
|
||||
<Input
|
||||
label="Display name (optional)"
|
||||
value={displayName}
|
||||
onChange={(e) => setDisplayName(e.target.value)}
|
||||
autoComplete="name"
|
||||
/>
|
||||
<Input
|
||||
label="Password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
autoComplete="new-password"
|
||||
required
|
||||
/>
|
||||
<Input
|
||||
@@ -75,6 +80,7 @@ export function RegisterPage() {
|
||||
type="password"
|
||||
value={confirm}
|
||||
onChange={(e) => setConfirm(e.target.value)}
|
||||
autoComplete="new-password"
|
||||
required
|
||||
/>
|
||||
<Button type="submit" loading={loading} className="w-full">
|
||||
|
||||
@@ -8,6 +8,7 @@ import { StateBadge } from "../components/StateBadge";
|
||||
import { ChangeList } from "../components/ChangeRenderer";
|
||||
import { AgentAvatar } from "../components/AgentAvatar";
|
||||
import { DecisionModal } from "../components/DecisionModal";
|
||||
import { ChevronLeftIcon } from "../components/icons";
|
||||
import { expiresIn, formatDateTime, relativeTime } from "../utils";
|
||||
|
||||
type Decision = "APPROVE" | "REJECT" | "REQUEST_CHANGES";
|
||||
@@ -58,33 +59,44 @@ export function RequestDetailPage() {
|
||||
const canDecide = request.state === "PENDING";
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* The fade-in wrapper is kept inside: its transform would otherwise make
|
||||
it the containing block for the fixed decision bar below. */}
|
||||
<div className="animate-fade-in">
|
||||
<Link to="/requests" className="mb-4 inline-block text-sm text-muted hover:text-text">
|
||||
← Back to requests
|
||||
<Link
|
||||
to="/requests"
|
||||
className="tap-sm -ml-2 mb-3 inline-flex min-h-11 items-center gap-0.5 pr-3 text-sm text-muted hover:text-text sm:mb-4"
|
||||
>
|
||||
<ChevronLeftIcon className="h-5 w-5" />
|
||||
Requests
|
||||
</Link>
|
||||
|
||||
{request.resubmitted && request.state === "PENDING" && (
|
||||
<div className="mb-4 flex items-center gap-3 rounded-xl border border-changes/40 bg-changes/10 px-4 py-3">
|
||||
<span className="animate-pulse-ring rounded-full bg-changes/25 px-2 py-0.5 text-xs font-bold text-changes">
|
||||
<div className="mb-4 flex flex-col gap-2 rounded-xl border border-changes/40 bg-changes/10 p-3.5 sm:flex-row sm:items-center sm:gap-3 sm:px-4 sm:py-3">
|
||||
<span className="animate-pulse-ring w-fit rounded-full bg-changes/25 px-2 py-0.5 text-xs font-bold text-changes">
|
||||
UPDATED
|
||||
</span>
|
||||
<p className="text-sm text-text">
|
||||
This request was revised by the agent after you requested changes (update #{request.update_count}).
|
||||
Please re-review the changes below.
|
||||
This request was revised by the agent after you requested changes (update #
|
||||
{request.update_count}). Please re-review the changes below.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-3">
|
||||
<div className="order-2 lg:order-1 lg:col-span-2">
|
||||
<div className="mb-4 flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h1 className="break-words text-xl font-bold tracking-tight sm:text-2xl">{request.title}</h1>
|
||||
<StateBadge state={request.state} />
|
||||
</div>
|
||||
{request.description && <p className="mt-2 text-muted">{request.description}</p>}
|
||||
<div className="min-w-0 lg:col-span-2">
|
||||
<div className="mb-4">
|
||||
<div className="flex flex-wrap items-start gap-x-3 gap-y-2">
|
||||
<h1 className="min-w-0 break-words text-xl font-bold leading-tight tracking-tight sm:text-2xl">
|
||||
{request.title}
|
||||
</h1>
|
||||
<StateBadge state={request.state} className="mt-0.5 shrink-0" />
|
||||
</div>
|
||||
{request.description && (
|
||||
<p className="mt-2 text-[15px] leading-relaxed text-muted sm:text-base">
|
||||
{request.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{request.comment && (
|
||||
@@ -102,9 +114,10 @@ export function RequestDetailPage() {
|
||||
<ChangeList changes={request.changes} />
|
||||
</div>
|
||||
|
||||
<div className="order-1 space-y-4 lg:order-2">
|
||||
<div className="min-w-0 space-y-4">
|
||||
{/* On phones this lives in the sticky bar at the bottom instead. */}
|
||||
{canDecide && (
|
||||
<Card className="space-y-2 p-4">
|
||||
<Card className="hidden space-y-2 p-4 lg:block">
|
||||
<p className="mb-1 text-sm font-semibold">Your decision</p>
|
||||
<Button variant="success" className="w-full" onClick={() => setDecision("APPROVE")}>
|
||||
Approve
|
||||
@@ -133,7 +146,7 @@ export function RequestDetailPage() {
|
||||
href={request.agent.website}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="truncate text-xs text-primary hover:underline"
|
||||
className="block truncate text-xs text-primary hover:underline"
|
||||
>
|
||||
{request.agent.website}
|
||||
</a>
|
||||
@@ -182,22 +195,59 @@ export function RequestDetailPage() {
|
||||
<Row label="Algorithm" value={request.receipt.algorithm} />
|
||||
<Row
|
||||
label="Signature"
|
||||
value={<code className="text-[11px] break-all text-muted">{request.receipt.signature}</code>}
|
||||
value={
|
||||
<code className="text-[11px] break-all text-muted">{request.receipt.signature}</code>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DecisionModal request={request} decision={decision} onClose={() => setDecision(null)} onDone={setRequest} />
|
||||
{canDecide && (
|
||||
<>
|
||||
{/* Reserve room so the last card is never trapped under the bar. */}
|
||||
<div className="h-28 lg:hidden" aria-hidden />
|
||||
|
||||
<div
|
||||
data-chrome
|
||||
className="pb-safe px-safe fixed inset-x-0 bottom-[calc(var(--bottom-nav-h)_+_var(--safe-bottom))] z-30 border-t border-border bg-bg/95 backdrop-blur-xl md:bottom-0 lg:hidden"
|
||||
>
|
||||
<div className="mx-auto max-w-2xl px-3 pb-3 pt-2.5 sm:px-4">
|
||||
<p className={`mb-2 text-center text-[11px] ${exp.urgent ? "text-pending" : "text-faint"}`}>
|
||||
{exp.text}
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="danger" className="flex-1" onClick={() => setDecision("REJECT")}>
|
||||
Reject
|
||||
</Button>
|
||||
<Button variant="secondary" className="flex-1" onClick={() => setDecision("REQUEST_CHANGES")}>
|
||||
Changes
|
||||
</Button>
|
||||
<Button variant="success" className="flex-[1.3]" onClick={() => setDecision("APPROVE")}>
|
||||
Approve
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<DecisionModal
|
||||
request={request}
|
||||
decision={decision}
|
||||
onClose={() => setDecision(null)}
|
||||
onDone={setRequest}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Row({ label, value }: { label: string; value: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex flex-col gap-1 sm:flex-row sm:items-start sm:justify-between sm:gap-3">
|
||||
<div className="flex flex-col gap-0.5 sm:flex-row sm:items-start sm:justify-between sm:gap-3">
|
||||
<span className="shrink-0 text-faint">{label}</span>
|
||||
<span className="break-all text-text sm:text-right">{value}</span>
|
||||
</div>
|
||||
|
||||
+35
-26
@@ -6,6 +6,7 @@ import { useNotifications } from "../context/NotificationsContext";
|
||||
import { Card, EmptyState, PageHeader, Spinner, Button } from "../components/ui";
|
||||
import { StateBadge } from "../components/StateBadge";
|
||||
import { AgentAvatar } from "../components/AgentAvatar";
|
||||
import { ChevronRightIcon } from "../components/icons";
|
||||
import { relativeTime } from "../utils";
|
||||
|
||||
const STATES: RequestState[] = [
|
||||
@@ -31,7 +32,10 @@ export function RequestsPage() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
agentsApi.list().then(setAgents).catch(() => {});
|
||||
agentsApi
|
||||
.list()
|
||||
.then(setAgents)
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
@@ -56,31 +60,27 @@ export function RequestsPage() {
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
|
||||
|
||||
const chip = (active: boolean) =>
|
||||
`tap-sm min-h-9 shrink-0 snap-start rounded-full border px-3.5 text-sm font-medium transition ${
|
||||
active
|
||||
? "border-primary/40 bg-primary/15 text-primary"
|
||||
: "border-border-strong bg-surface text-muted hover:text-text"
|
||||
}`;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHeader title="Requests" subtitle="Full history of change requests submitted by your agents." />
|
||||
|
||||
<div className="mb-4 flex flex-col gap-2 sm:flex-row sm:flex-wrap sm:items-center">
|
||||
<select
|
||||
value={stateFilter}
|
||||
onChange={(e) => {
|
||||
setStateFilter(e.target.value as RequestState | "");
|
||||
setPage(1);
|
||||
}}
|
||||
className="w-full rounded-lg border border-border-strong bg-bg px-3 py-2 text-sm text-text outline-none sm:hidden"
|
||||
>
|
||||
<option value="">All states</option>
|
||||
{STATES.map((s) => (
|
||||
<option key={s} value={s}>{s.replace("_", " ").toLowerCase()}</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="hidden sm:contents">
|
||||
<div className="mb-4 space-y-2.5">
|
||||
{/* Swipeable filter pills — bleeds to the screen edges on phones so the
|
||||
row reads as scrollable. */}
|
||||
<div className="no-scrollbar -mx-3 flex snap-x gap-2 overflow-x-auto overscroll-x-contain px-3 pb-0.5 sm:mx-0 sm:flex-wrap sm:px-0">
|
||||
<button
|
||||
onClick={() => {
|
||||
setStateFilter("");
|
||||
setPage(1);
|
||||
}}
|
||||
className={`shrink-0 rounded-lg px-3 py-2 text-sm ${stateFilter === "" ? "bg-surface-raised text-text" : "text-muted hover:text-text"}`}
|
||||
className={chip(stateFilter === "")}
|
||||
>
|
||||
All
|
||||
</button>
|
||||
@@ -91,19 +91,20 @@ export function RequestsPage() {
|
||||
setStateFilter(s);
|
||||
setPage(1);
|
||||
}}
|
||||
className={`shrink-0 rounded-lg px-3 py-2 text-sm ${stateFilter === s ? "bg-surface-raised text-text" : "text-muted hover:text-text"}`}
|
||||
className={chip(stateFilter === s)}
|
||||
>
|
||||
{s.replace("_", " ").toLowerCase()}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<select
|
||||
value={agentFilter}
|
||||
onChange={(e) => {
|
||||
setAgentFilter(e.target.value ? Number(e.target.value) : "");
|
||||
setPage(1);
|
||||
}}
|
||||
className="w-full rounded-lg border border-border-strong bg-bg px-3 py-2 text-sm text-text outline-none sm:ml-auto sm:w-auto"
|
||||
className="min-h-11 w-full rounded-lg border border-border-strong bg-bg px-3 text-text outline-none sm:min-h-10 sm:w-auto"
|
||||
>
|
||||
<option value="">All agents</option>
|
||||
{agents.map((a) => (
|
||||
@@ -124,25 +125,33 @@ export function RequestsPage() {
|
||||
<div className="space-y-2">
|
||||
{items.map((r) => (
|
||||
<Link key={r.request_id} to={`/requests/${r.request_id}`} className="block">
|
||||
<Card className="flex flex-col gap-2 p-3.5 transition hover:border-border-strong hover:bg-surface-raised/40 sm:flex-row sm:items-center sm:gap-3">
|
||||
<div className="flex min-w-0 items-start gap-3">
|
||||
<Card className="tap flex items-start gap-3 p-3.5 hover:border-border-strong hover:bg-surface-raised/40 active:bg-surface-raised/60 sm:items-center">
|
||||
<AgentAvatar name={r.agent?.name ?? "?"} iconUrl={r.agent?.icon_url} size={32} />
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="break-words font-medium text-text">{r.title}</p>
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||
<p className="min-w-0 break-words font-medium leading-snug text-text">
|
||||
{r.title}
|
||||
</p>
|
||||
{r.resubmitted && r.state === "PENDING" && (
|
||||
<span className="rounded-full bg-changes/20 px-1.5 py-0.5 text-[10px] font-semibold text-changes">
|
||||
UPDATED
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="truncate text-xs text-muted">
|
||||
<p className="mt-1 truncate text-xs text-muted">
|
||||
{r.agent?.name} · {r.changes.length} change{r.changes.length === 1 ? "" : "s"} ·{" "}
|
||||
{relativeTime(r.created_at)}
|
||||
</p>
|
||||
<div className="mt-2 sm:hidden">
|
||||
<StateBadge state={r.state} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="self-start sm:shrink-0"><StateBadge state={r.state} /></div>
|
||||
|
||||
<div className="hidden shrink-0 sm:block">
|
||||
<StateBadge state={r.state} />
|
||||
</div>
|
||||
<ChevronRightIcon className="mt-1 h-5 w-5 shrink-0 text-faint sm:hidden" />
|
||||
</Card>
|
||||
</Link>
|
||||
))}
|
||||
@@ -154,7 +163,7 @@ export function RequestsPage() {
|
||||
<Button variant="secondary" disabled={page <= 1} onClick={() => setPage((p) => p - 1)}>
|
||||
Previous
|
||||
</Button>
|
||||
<span className="text-sm text-muted">
|
||||
<span className="text-sm text-muted tabular-nums">
|
||||
Page {page} of {totalPages}
|
||||
</span>
|
||||
<Button variant="secondary" disabled={page >= totalPages} onClick={() => setPage((p) => p + 1)}>
|
||||
|
||||
+35
-14
@@ -9,7 +9,7 @@ import { CodeBlock } from "../components/CodeBlock";
|
||||
|
||||
function Section({ title, description, children }: { title: string; description?: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<Card className="p-4 sm:p-5">
|
||||
<Card className="min-w-0 p-4 sm:p-5">
|
||||
<h2 className="text-base font-semibold text-text">{title}</h2>
|
||||
{description && <p className="mb-4 mt-0.5 text-sm text-muted">{description}</p>}
|
||||
<div className={description ? "" : "mt-4"}>{children}</div>
|
||||
@@ -118,9 +118,11 @@ export function SettingsPage() {
|
||||
<div className="grid gap-5 lg:grid-cols-2">
|
||||
<Section title="Profile">
|
||||
<div className="space-y-3">
|
||||
<Input label="Username" value={user?.username ?? ""} disabled />
|
||||
<Input label="Username" value={user?.username ?? ""} disabled autoComplete="username" />
|
||||
<Input label="Display name" value={displayName} onChange={(e) => setDisplayName(e.target.value)} />
|
||||
<Button onClick={saveProfile}>Save profile</Button>
|
||||
<Button className="w-full sm:w-auto" onClick={saveProfile}>
|
||||
Save profile
|
||||
</Button>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
@@ -129,17 +131,23 @@ export function SettingsPage() {
|
||||
<Input
|
||||
label="Current password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
value={curPw}
|
||||
onChange={(e) => setCurPw(e.target.value)}
|
||||
/>
|
||||
<Input
|
||||
label="New password"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
value={newPw}
|
||||
onChange={(e) => setNewPw(e.target.value)}
|
||||
hint="At least 8 characters."
|
||||
/>
|
||||
<Button onClick={savePassword} disabled={!curPw || newPw.length < 8}>
|
||||
<Button
|
||||
className="w-full sm:w-auto"
|
||||
onClick={savePassword}
|
||||
disabled={!curPw || newPw.length < 8}
|
||||
>
|
||||
Change password
|
||||
</Button>
|
||||
</div>
|
||||
@@ -165,8 +173,10 @@ export function SettingsPage() {
|
||||
onChange={(e) => setTotp(e.target.value.replace(/\D/g, "").slice(0, 6))}
|
||||
placeholder="123456"
|
||||
inputMode="numeric"
|
||||
autoComplete="one-time-code"
|
||||
pattern="[0-9]*"
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<div className="flex gap-2 *:flex-1 sm:*:flex-none">
|
||||
<Button onClick={confirm2fa} disabled={totp.length !== 6}>
|
||||
Enable 2FA
|
||||
</Button>
|
||||
@@ -198,9 +208,11 @@ export function SettingsPage() {
|
||||
max={90}
|
||||
value={autoDeleteDays}
|
||||
onChange={(e) => setAutoDeleteDays(Number(e.target.value))}
|
||||
onMouseUp={() => saveAutoDelete(true, autoDeleteDays)}
|
||||
onTouchEnd={() => saveAutoDelete(true, autoDeleteDays)}
|
||||
// pointerup covers mouse, touch and pen in one handler.
|
||||
onPointerUp={() => saveAutoDelete(true, autoDeleteDays)}
|
||||
onKeyUp={() => saveAutoDelete(true, autoDeleteDays)}
|
||||
className="w-full accent-[var(--color-primary)]"
|
||||
aria-label="Retention window in days"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -208,13 +220,15 @@ export function SettingsPage() {
|
||||
</Section>
|
||||
|
||||
<Section title="Export your data" description="Download all your data (account, agents, requests, notifications) as JSON.">
|
||||
<a href={account.exportUrl} download>
|
||||
<Button variant="secondary">Download export</Button>
|
||||
<a href={account.exportUrl} download className="block sm:inline-block">
|
||||
<Button variant="secondary" className="w-full sm:w-auto">
|
||||
Download export
|
||||
</Button>
|
||||
</a>
|
||||
</Section>
|
||||
|
||||
<Section title="Danger zone" description="Permanently delete your account and all associated data. This cannot be undone.">
|
||||
<Button variant="danger" onClick={() => setDeleteOpen(true)}>
|
||||
<Button variant="danger" className="w-full sm:w-auto" onClick={() => setDeleteOpen(true)}>
|
||||
Delete account
|
||||
</Button>
|
||||
</Section>
|
||||
@@ -227,10 +241,10 @@ export function SettingsPage() {
|
||||
title="Disable 2FA"
|
||||
footer={
|
||||
<>
|
||||
<Button variant="ghost" onClick={() => setDisable2faOpen(false)}>
|
||||
<Button variant="ghost" className="flex-1 sm:flex-none" onClick={() => setDisable2faOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="danger" onClick={disable2fa}>
|
||||
<Button variant="danger" className="flex-1 sm:flex-none" onClick={disable2fa}>
|
||||
Disable
|
||||
</Button>
|
||||
</>
|
||||
@@ -239,6 +253,7 @@ export function SettingsPage() {
|
||||
<Input
|
||||
label="Confirm your password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
value={disablePw}
|
||||
onChange={(e) => setDisablePw(e.target.value)}
|
||||
/>
|
||||
@@ -251,10 +266,15 @@ export function SettingsPage() {
|
||||
title="Delete account"
|
||||
footer={
|
||||
<>
|
||||
<Button variant="ghost" onClick={() => setDeleteOpen(false)}>
|
||||
<Button variant="ghost" className="flex-1 sm:flex-none" onClick={() => setDeleteOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="danger" onClick={deleteAccount} disabled={!deletePw}>
|
||||
<Button
|
||||
variant="danger"
|
||||
className="flex-1 sm:flex-none"
|
||||
onClick={deleteAccount}
|
||||
disabled={!deletePw}
|
||||
>
|
||||
Permanently delete
|
||||
</Button>
|
||||
</>
|
||||
@@ -268,6 +288,7 @@ export function SettingsPage() {
|
||||
<Input
|
||||
label="Confirm your password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
value={deletePw}
|
||||
onChange={(e) => setDeletePw(e.target.value)}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user