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.
|
// Serve a matching build file (assets, favicons, etc.) if present.
|
||||||
const file = resolveStaticFile(path);
|
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.
|
// SPA fallback — hand any other route to the React app.
|
||||||
if (hasUiIndex) return ctr.status(200, "OK").printFile(uiIndexPath, { addTypes: true });
|
if (hasUiIndex) return ctr.status(200, "OK").printFile(uiIndexPath, { addTypes: true });
|
||||||
|
|||||||
+20
-2
@@ -2,9 +2,27 @@
|
|||||||
<html lang="en" class="dark">
|
<html lang="en" class="dark">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<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" />
|
<!-- viewport-fit=cover lets the app paint under the notch/home indicator; we
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
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." />
|
<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>
|
<title>PatchPass</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<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,
|
icon_url: iconUrl.trim() || null,
|
||||||
max_pending_requests: maxPending,
|
max_pending_requests: maxPending,
|
||||||
};
|
};
|
||||||
const saved = editing
|
const saved = editing ? await agentsApi.update(agent!.id, payload) : await agentsApi.create(payload);
|
||||||
? await agentsApi.update(agent!.id, payload)
|
|
||||||
: await agentsApi.create(payload);
|
|
||||||
toast.success(editing ? "Agent updated" : "Agent created");
|
toast.success(editing ? "Agent updated" : "Agent created");
|
||||||
onSaved(saved, !editing);
|
onSaved(saved, !editing);
|
||||||
onClose();
|
onClose();
|
||||||
@@ -68,17 +66,17 @@ export function AgentFormModal({
|
|||||||
title={editing ? "Edit agent" : "New agent"}
|
title={editing ? "Edit agent" : "New agent"}
|
||||||
footer={
|
footer={
|
||||||
<>
|
<>
|
||||||
<Button variant="ghost" onClick={onClose}>
|
<Button variant="ghost" className="flex-1 sm:flex-none" onClick={onClose}>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={submit} loading={loading}>
|
<Button className="flex-1 sm:flex-none" onClick={submit} loading={loading}>
|
||||||
{editing ? "Save" : "Create agent"}
|
{editing ? "Save" : "Create agent"}
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div className="space-y-4">
|
<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
|
<Textarea
|
||||||
label="Description"
|
label="Description"
|
||||||
value={description}
|
value={description}
|
||||||
@@ -90,18 +88,26 @@ export function AgentFormModal({
|
|||||||
value={website}
|
value={website}
|
||||||
onChange={(e) => setWebsite(e.target.value)}
|
onChange={(e) => setWebsite(e.target.value)}
|
||||||
placeholder="https://…"
|
placeholder="https://…"
|
||||||
|
type="url"
|
||||||
|
inputMode="url"
|
||||||
|
autoCapitalize="none"
|
||||||
|
autoCorrect="off"
|
||||||
/>
|
/>
|
||||||
<Input
|
<Input
|
||||||
label="Icon URL (optional)"
|
label="Icon URL (optional)"
|
||||||
value={iconUrl}
|
value={iconUrl}
|
||||||
onChange={(e) => setIconUrl(e.target.value)}
|
onChange={(e) => setIconUrl(e.target.value)}
|
||||||
placeholder="https://…/icon.png"
|
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."
|
hint="Verified on save. Must be a JPG, PNG, or GIF under 1 MB."
|
||||||
/>
|
/>
|
||||||
<div>
|
<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="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>
|
</div>
|
||||||
<input
|
<input
|
||||||
type="range"
|
type="range"
|
||||||
@@ -110,8 +116,9 @@ export function AgentFormModal({
|
|||||||
value={maxPending}
|
value={maxPending}
|
||||||
onChange={(e) => setMaxPending(Number(e.target.value))}
|
onChange={(e) => setMaxPending(Number(e.target.value))}
|
||||||
className="w-full accent-[var(--color-primary)]"
|
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).
|
How many requests this agent may have awaiting review at once (1–10).
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -132,7 +139,7 @@ export function ConnectModal({
|
|||||||
}: {
|
}: {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
agent: Agent;
|
agent: Agent | null;
|
||||||
revealedKey?: string | null;
|
revealedKey?: string | null;
|
||||||
}) {
|
}) {
|
||||||
const [tab, setTab] = useState<Tab>("openclaw");
|
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 (
|
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">
|
<div className="space-y-4">
|
||||||
{hasKey ? (
|
{hasKey ? (
|
||||||
<div className="rounded-lg border border-pending/40 bg-pending/10 p-3">
|
<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">
|
<div className="mt-2">
|
||||||
<CodeBlock code={revealedKey!} />
|
<CodeBlock code={revealedKey!} />
|
||||||
</div>
|
</div>
|
||||||
@@ -210,12 +229,12 @@ curl -X POST ${origin}/v1/change-requests/{request_id}/consume -H "x-api-key: ${
|
|||||||
</p>
|
</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) => (
|
{tabs.map((t) => (
|
||||||
<button
|
<button
|
||||||
key={t.id}
|
key={t.id}
|
||||||
onClick={() => setTab(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"
|
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 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");
|
const lines = content.split("\n");
|
||||||
return (
|
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>
|
<code>
|
||||||
{lines.map((line, i) => {
|
{lines.map((line, i) => {
|
||||||
let cls = "";
|
let cls = "";
|
||||||
@@ -13,7 +19,10 @@ function DiffView({ content }: { content: string }) {
|
|||||||
else if (line.startsWith("-")) cls = "diff-del";
|
else if (line.startsWith("-")) cls = "diff-del";
|
||||||
else if (line.startsWith("diff ") || line.startsWith("index ")) cls = "diff-meta";
|
else if (line.startsWith("diff ") || line.startsWith("index ")) cls = "diff-meta";
|
||||||
return (
|
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 || " "}
|
{line || " "}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -38,14 +47,21 @@ function percentDelta(before: unknown, after: unknown): string | null {
|
|||||||
return `${pct > 0 ? "+" : ""}${pct}%`;
|
return `${pct > 0 ? "+" : ""}${pct}%`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function BeforeAfter({ before, after, contentType }: { before: unknown; after: unknown; contentType?: string | null }) {
|
function BeforeAfter({
|
||||||
const delta =
|
before,
|
||||||
contentType === "integer" || contentType === "number" ? percentDelta(before, after) : null;
|
after,
|
||||||
|
contentType,
|
||||||
|
}: {
|
||||||
|
before: unknown;
|
||||||
|
after: unknown;
|
||||||
|
contentType?: string | null;
|
||||||
|
}) {
|
||||||
|
const delta = contentType === "integer" || contentType === "number" ? percentDelta(before, after) : null;
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-wrap items-center gap-2 text-sm">
|
<div className="flex flex-wrap items-center gap-x-2 gap-y-1.5 text-sm">
|
||||||
<code className="diff-del rounded px-2 py-0.5 font-mono">{fmtValue(before)}</code>
|
<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>
|
<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>}
|
{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>}
|
{contentType && <span className="text-xs text-faint">({contentType})</span>}
|
||||||
</div>
|
</div>
|
||||||
@@ -53,34 +69,54 @@ function BeforeAfter({ before, after, contentType }: { before: unknown; after: u
|
|||||||
}
|
}
|
||||||
|
|
||||||
function typeChip(label: string, color: string) {
|
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 }) {
|
export function ChangeCard({ change, index }: { change: Change; index: number }) {
|
||||||
|
const [wrap, setWrap] = useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-xl border border-border bg-surface-raised/50 p-4">
|
<div className="rounded-xl border border-border bg-surface-raised/50 p-3 sm:p-4">
|
||||||
<div className="mb-3 flex flex-wrap items-center gap-2">
|
<div className="mb-3 flex items-start gap-2">
|
||||||
<span className="text-xs font-mono text-faint">#{index + 1}</span>
|
<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="min-w-0 break-all text-sm text-text">{change.path}</code>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{change.type === "config" && (
|
||||||
|
<>
|
||||||
|
{typeChip("config", "bg-accent/15 text-accent")}
|
||||||
|
<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="min-w-0 break-words text-sm text-text">{change.label}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Wrapping beats horizontal scrolling for long lines on a phone. */}
|
||||||
{change.type === "unified_diff" && (
|
{change.type === "unified_diff" && (
|
||||||
<>
|
<button
|
||||||
{typeChip("diff", "bg-primary-dim/40 text-primary")}
|
onClick={() => setWrap((w) => !w)}
|
||||||
<code className="break-all text-sm text-text">{change.path}</code>
|
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 ${
|
||||||
{change.type === "config" && (
|
wrap ? "bg-primary/15 text-primary" : "text-faint hover:bg-surface-raised hover:text-muted"
|
||||||
<>
|
}`}
|
||||||
{typeChip("config", "bg-accent/15 text-accent")}
|
>
|
||||||
<code className="break-all text-sm text-text">{change.path}</code>
|
<WrapIcon className="h-[18px] w-[18px]" />
|
||||||
</>
|
</button>
|
||||||
)}
|
|
||||||
{change.type === "custom" && (
|
|
||||||
<>
|
|
||||||
{typeChip("custom", "bg-changes/15 text-changes")}
|
|
||||||
<span className="text-sm text-text">{change.label}</span>
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{change.type === "unified_diff" && <DiffView content={change.content} />}
|
|
||||||
|
{change.type === "unified_diff" && <DiffView content={change.content} wrap={wrap} />}
|
||||||
{change.type === "config" && (
|
{change.type === "config" && (
|
||||||
<BeforeAfter before={change.before} after={change.after} contentType={change.content_type} />
|
<BeforeAfter before={change.before} after={change.after} contentType={change.content_type} />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,30 +1,41 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import { CheckIcon, CopyIcon } from "./icons";
|
||||||
|
|
||||||
export function CodeBlock({ code, language }: { code: string; language?: string }) {
|
export function CodeBlock({ code, language }: { code: string; language?: string }) {
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
const copy = async () => {
|
const copy = async () => {
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(code);
|
await navigator.clipboard.writeText(code);
|
||||||
setCopied(true);
|
setCopied(true);
|
||||||
setTimeout(() => setCopied(false), 1500);
|
setTimeout(() => setCopied(false), 1500);
|
||||||
} catch {
|
} catch {
|
||||||
/* ignore */
|
/* clipboard unavailable (insecure origin) — nothing useful to do */
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
{language && (
|
{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}
|
{language}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={copy}
|
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>
|
</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>
|
<code>{code}</code>
|
||||||
</pre>
|
</pre>
|
||||||
</div>
|
</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 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: {
|
APPROVE: {
|
||||||
title: "Approve request",
|
title: "Approve request",
|
||||||
verb: "Approve",
|
verb: "Approve",
|
||||||
@@ -46,18 +49,24 @@ export function DecisionModal({
|
|||||||
}) {
|
}) {
|
||||||
const [comment, setComment] = useState("");
|
const [comment, setComment] = useState("");
|
||||||
const [loading, setLoading] = useState(false);
|
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(() => {
|
useEffect(() => {
|
||||||
setComment("");
|
if (decision) {
|
||||||
|
setShown(decision);
|
||||||
|
setComment("");
|
||||||
|
}
|
||||||
}, [decision]);
|
}, [decision]);
|
||||||
|
|
||||||
if (!decision) return null;
|
if (!shown) return null;
|
||||||
const m = meta[decision];
|
const m = meta[shown];
|
||||||
const tooLong = comment.length > MAX;
|
const tooLong = comment.length > MAX;
|
||||||
const missingRequired = m.commentRequired && comment.trim().length === 0;
|
const missingRequired = m.commentRequired && comment.trim().length === 0;
|
||||||
|
|
||||||
const submit = async () => {
|
const submit = async () => {
|
||||||
if (tooLong || missingRequired) return;
|
if (!decision || tooLong || missingRequired) return;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const updated = await requests.decide(request.request_id, decision, comment.trim() || undefined);
|
const updated = await requests.decide(request.request_id, decision, comment.trim() || undefined);
|
||||||
@@ -79,10 +88,16 @@ export function DecisionModal({
|
|||||||
title={m.title}
|
title={m.title}
|
||||||
footer={
|
footer={
|
||||||
<>
|
<>
|
||||||
<Button variant="ghost" onClick={onClose}>
|
<Button variant="ghost" className="flex-1 sm:flex-none" onClick={onClose}>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</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}
|
{m.verb}
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
@@ -90,19 +105,22 @@ export function DecisionModal({
|
|||||||
>
|
>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="rounded-lg border border-border bg-surface-raised/50 p-3">
|
<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">
|
<p className="mt-1 text-xs text-muted">
|
||||||
{request.changes.length} change{request.changes.length === 1 ? "" : "s"} ·{" "}
|
{request.changes.length} change{request.changes.length === 1 ? "" : "s"} · {request.agent?.name}
|
||||||
{request.agent?.name}
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-muted">{m.blurb}</p>
|
<p className="text-sm text-muted">{m.blurb}</p>
|
||||||
<div>
|
<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">
|
<span className="text-sm text-muted">
|
||||||
Comment {m.commentRequired ? <span className="text-changes">(required)</span> : "(optional)"}
|
Comment {m.commentRequired ? <span className="text-changes">(required)</span> : "(optional)"}
|
||||||
</span>
|
</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}
|
{comment.length}/{MAX}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -110,9 +128,11 @@ export function DecisionModal({
|
|||||||
value={comment}
|
value={comment}
|
||||||
onChange={(e) => setComment(e.target.value)}
|
onChange={(e) => setComment(e.target.value)}
|
||||||
rows={4}
|
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…"}
|
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"
|
tooLong ? "border-rejected" : "border-border-strong"
|
||||||
}`}
|
}`}
|
||||||
/>
|
/>
|
||||||
|
|||||||
+242
-83
@@ -1,7 +1,18 @@
|
|||||||
import { ReactNode, useState } from "react";
|
import { ReactNode, useEffect, useRef, useState } from "react";
|
||||||
import { Link, NavLink, useNavigate } from "react-router-dom";
|
import { Link, NavLink, useNavigate } from "react-router-dom";
|
||||||
import { useAuth } from "../context/AuthContext";
|
import { useAuth } from "../context/AuthContext";
|
||||||
|
import { useNotifications } from "../context/NotificationsContext";
|
||||||
import { NotificationBell } from "./NotificationBell";
|
import { NotificationBell } from "./NotificationBell";
|
||||||
|
import { Sheet } from "./Sheet";
|
||||||
|
import {
|
||||||
|
AgentsIcon,
|
||||||
|
BellIcon,
|
||||||
|
HomeIcon,
|
||||||
|
LogOutIcon,
|
||||||
|
RequestsIcon,
|
||||||
|
SettingsIcon,
|
||||||
|
ShieldIcon,
|
||||||
|
} from "./icons";
|
||||||
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
{ to: "/", label: "Dashboard", end: true },
|
{ to: "/", label: "Dashboard", end: true },
|
||||||
@@ -10,18 +21,27 @@ const navItems = [
|
|||||||
{ to: "/settings", label: "Settings" },
|
{ 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 }) {
|
export function Layout({ children }: { children: ReactNode }) {
|
||||||
const { user, logout } = useAuth();
|
const { user } = useAuth();
|
||||||
const navigate = useNavigate();
|
|
||||||
const [menuOpen, setMenuOpen] = useState(false);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen">
|
<div className="flex min-h-[100dvh] flex-col">
|
||||||
<header className="sticky top-0 z-30 border-b border-border bg-bg/80 backdrop-blur">
|
<header
|
||||||
<div className="mx-auto flex max-w-6xl items-center justify-between gap-2 px-3 py-3 sm:px-4">
|
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">
|
<div className="flex min-w-0 items-center gap-3 sm:gap-6">
|
||||||
<Link to="/" className="flex items-center gap-2">
|
<Link to="/" className="tap-sm flex items-center gap-2">
|
||||||
<span className="hidden text-xl xs:inline">✅</span>
|
<img src="/icon.svg" alt="" className="h-7 w-7 rounded-lg" />
|
||||||
<span className="text-base font-bold tracking-tight sm:text-lg">
|
<span className="text-base font-bold tracking-tight sm:text-lg">
|
||||||
Patch<span className="text-primary">Pass</span>
|
Patch<span className="text-primary">Pass</span>
|
||||||
</span>
|
</span>
|
||||||
@@ -57,93 +77,232 @@ export function Layout({ children }: { children: ReactNode }) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex shrink-0 items-center gap-1 sm:gap-2">
|
<div className="flex shrink-0 items-center gap-1 sm:gap-2">
|
||||||
<NotificationBell />
|
{/* Below md the bell is replaced by the Alerts tab. */}
|
||||||
<div className="relative">
|
<div className="hidden md:block">
|
||||||
<button
|
<NotificationBell />
|
||||||
onClick={() => setMenuOpen((o) => !o)}
|
|
||||||
className="flex items-center gap-2 rounded-lg px-2 py-1.5 text-sm hover:bg-surface-raised"
|
|
||||||
>
|
|
||||||
<span className="flex h-7 w-7 items-center justify-center rounded-full bg-primary-dim/50 text-xs font-semibold text-primary">
|
|
||||||
{user?.display_name?.[0]?.toUpperCase()}
|
|
||||||
</span>
|
|
||||||
<span className="hidden text-text sm:inline">{user?.display_name}</span>
|
|
||||||
</button>
|
|
||||||
{menuOpen && (
|
|
||||||
<div
|
|
||||||
className="animate-fade-in absolute right-0 mt-2 w-44 rounded-xl border border-border-strong bg-surface py-1 shadow-2xl"
|
|
||||||
onMouseLeave={() => setMenuOpen(false)}
|
|
||||||
>
|
|
||||||
<Link
|
|
||||||
to="/settings"
|
|
||||||
className="block px-4 py-2 text-sm text-muted hover:bg-surface-raised hover:text-text"
|
|
||||||
onClick={() => setMenuOpen(false)}
|
|
||||||
>
|
|
||||||
Settings
|
|
||||||
</Link>
|
|
||||||
<Link
|
|
||||||
to="/notifications"
|
|
||||||
className="block px-4 py-2 text-sm text-muted hover:bg-surface-raised hover:text-text md:hidden"
|
|
||||||
onClick={() => setMenuOpen(false)}
|
|
||||||
>
|
|
||||||
Notifications
|
|
||||||
</Link>
|
|
||||||
{user?.role === "ADMIN" && (
|
|
||||||
<Link
|
|
||||||
to="/admin"
|
|
||||||
className="block px-4 py-2 text-sm text-accent hover:bg-surface-raised"
|
|
||||||
onClick={() => setMenuOpen(false)}
|
|
||||||
>
|
|
||||||
Admin
|
|
||||||
</Link>
|
|
||||||
)}
|
|
||||||
<button
|
|
||||||
onClick={async () => {
|
|
||||||
await logout();
|
|
||||||
navigate("/login");
|
|
||||||
}}
|
|
||||||
className="block w-full px-4 py-2 text-left text-sm text-rejected hover:bg-surface-raised"
|
|
||||||
>
|
|
||||||
Log out
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
<AccountMenu />
|
||||||
</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>
|
</header>
|
||||||
|
|
||||||
<main className="mx-auto max-w-6xl px-3 py-5 sm:px-4 sm:py-8">{children}</main>
|
<main className="mx-auto w-full max-w-6xl flex-1 px-3 py-5 sm:px-4 sm:py-8">{children}</main>
|
||||||
|
|
||||||
<footer className="mx-auto max-w-6xl px-4 py-8 text-center text-xs text-faint">
|
<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">
|
<div className="flex flex-wrap items-center justify-center gap-x-3">
|
||||||
<Link to="/privacy" className="hover:text-muted">
|
<Link to="/privacy" className="inline-flex min-h-11 items-center hover:text-muted sm:min-h-0">
|
||||||
Privacy Policy
|
Privacy Policy
|
||||||
</Link>
|
</Link>
|
||||||
<span>·</span>
|
<span aria-hidden>·</span>
|
||||||
<Link to="/terms" className="hover:text-muted">
|
<Link to="/terms" className="inline-flex min-h-11 items-center hover:text-muted sm:min-h-0">
|
||||||
Terms of Service
|
Terms of Service
|
||||||
</Link>
|
</Link>
|
||||||
<span>·</span>
|
<span className="hidden sm:inline" aria-hidden>
|
||||||
<span>PatchPass — review changes, approve intent, let agents proceed.</span>
|
·
|
||||||
|
</span>
|
||||||
|
<span className="w-full sm:w-auto">
|
||||||
|
PatchPass — review changes, approve intent, let agents proceed.
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function BottomTabs() {
|
||||||
|
const { unreadCount } = useNotifications();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<nav
|
||||||
|
data-chrome
|
||||||
|
className="pb-safe px-safe fixed inset-x-0 bottom-0 z-40 border-t border-border bg-bg/90 backdrop-blur-xl md:hidden"
|
||||||
|
>
|
||||||
|
<div className="mx-auto flex h-[var(--bottom-nav-h)] max-w-md items-stretch">
|
||||||
|
{tabs.map(({ to, label, Icon, end, badge }) => (
|
||||||
|
<NavLink key={to} to={to} end={end} className="flex flex-1 items-stretch">
|
||||||
|
{({ isActive }) => (
|
||||||
|
<span
|
||||||
|
className={`tap-sm flex w-full flex-col items-center justify-center gap-1 ${
|
||||||
|
isActive ? "text-primary" : "text-faint"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="relative">
|
||||||
|
<Icon
|
||||||
|
className="h-[22px] w-[22px] transition-transform"
|
||||||
|
strokeWidth={isActive ? 2.2 : 1.7}
|
||||||
|
/>
|
||||||
|
{badge && unreadCount > 0 && (
|
||||||
|
<span className="absolute -right-2.5 -top-1 flex h-4 min-w-4 items-center justify-center rounded-full bg-rejected px-1 text-[10px] font-bold text-white">
|
||||||
|
{unreadCount > 9 ? "9+" : unreadCount}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<span className="text-[10px] font-medium leading-none">{label}</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</NavLink>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AccountMenu() {
|
||||||
|
const { user, logout } = useAuth();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const onDown = (e: MouseEvent) => {
|
||||||
|
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
|
||||||
|
};
|
||||||
|
document.addEventListener("mousedown", onDown);
|
||||||
|
return () => document.removeEventListener("mousedown", onDown);
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
const signOut = async () => {
|
||||||
|
setOpen(false);
|
||||||
|
await logout();
|
||||||
|
navigate("/login");
|
||||||
|
};
|
||||||
|
|
||||||
|
const initial = user?.display_name?.[0]?.toUpperCase() ?? "?";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative" ref={ref}>
|
||||||
|
<button
|
||||||
|
onClick={() => setOpen((o) => !o)}
|
||||||
|
className="tap-sm flex min-h-11 items-center gap-2 rounded-lg px-2 py-1.5 text-sm hover:bg-surface-raised"
|
||||||
|
aria-label="Account menu"
|
||||||
|
aria-expanded={open}
|
||||||
|
>
|
||||||
|
<span className="flex h-8 w-8 items-center justify-center rounded-full bg-primary-dim/50 text-xs font-semibold text-primary">
|
||||||
|
{initial}
|
||||||
|
</span>
|
||||||
|
<span className="hidden text-text sm:inline">{user?.display_name}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Desktop: dropdown. */}
|
||||||
|
{open && (
|
||||||
|
<div className="animate-fade-in absolute right-0 mt-2 hidden w-48 rounded-xl border border-border-strong bg-surface py-1 shadow-2xl sm:block">
|
||||||
|
<MenuLink to="/settings" onClick={() => setOpen(false)}>
|
||||||
|
Settings
|
||||||
|
</MenuLink>
|
||||||
|
{user?.role === "ADMIN" && (
|
||||||
|
<MenuLink to="/admin" onClick={() => setOpen(false)} accent>
|
||||||
|
Admin
|
||||||
|
</MenuLink>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={signOut}
|
||||||
|
className="block w-full px-4 py-2 text-left text-sm text-rejected hover:bg-surface-raised"
|
||||||
|
>
|
||||||
|
Log out
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Mobile: bottom sheet, which is reachable one-handed. */}
|
||||||
|
<div className="sm:hidden">
|
||||||
|
<Sheet open={open} onClose={() => setOpen(false)} padded={false}>
|
||||||
|
<div className="flex items-center gap-3 px-5 pb-4 pt-3">
|
||||||
|
<span className="flex h-11 w-11 items-center justify-center rounded-full bg-primary-dim/50 text-base font-semibold text-primary">
|
||||||
|
{initial}
|
||||||
|
</span>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="truncate font-semibold text-text">{user?.display_name}</p>
|
||||||
|
<p className="truncate text-xs text-muted">@{user?.username}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="border-t border-border">
|
||||||
|
<SheetRow
|
||||||
|
icon={<SettingsIcon className="h-5 w-5" />}
|
||||||
|
label="Settings"
|
||||||
|
onClick={() => {
|
||||||
|
setOpen(false);
|
||||||
|
navigate("/settings");
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{user?.role === "ADMIN" && (
|
||||||
|
<SheetRow
|
||||||
|
icon={<ShieldIcon className="h-5 w-5" />}
|
||||||
|
label="Admin"
|
||||||
|
accent
|
||||||
|
onClick={() => {
|
||||||
|
setOpen(false);
|
||||||
|
navigate("/admin");
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<SheetRow
|
||||||
|
icon={<LogOutIcon className="h-5 w-5" />}
|
||||||
|
label="Log out"
|
||||||
|
danger
|
||||||
|
onClick={signOut}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Sheet>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MenuLink({
|
||||||
|
to,
|
||||||
|
onClick,
|
||||||
|
accent,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
to: string;
|
||||||
|
onClick: () => void;
|
||||||
|
accent?: boolean;
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
to={to}
|
||||||
|
onClick={onClick}
|
||||||
|
className={`block px-4 py-2 text-sm hover:bg-surface-raised ${
|
||||||
|
accent ? "text-accent" : "text-muted hover:text-text"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetRow({
|
||||||
|
icon,
|
||||||
|
label,
|
||||||
|
onClick,
|
||||||
|
accent,
|
||||||
|
danger,
|
||||||
|
}: {
|
||||||
|
icon: ReactNode;
|
||||||
|
label: string;
|
||||||
|
onClick: () => void;
|
||||||
|
accent?: boolean;
|
||||||
|
danger?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={onClick}
|
||||||
|
className={`flex min-h-14 w-full items-center gap-3.5 border-b border-border/60 px-5 text-left text-[15px] font-medium transition active:bg-surface-raised ${
|
||||||
|
danger ? "text-rejected" : accent ? "text-accent" : "text-text"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className={danger ? "" : accent ? "" : "text-muted"}>{icon}</span>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
+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({
|
export function Modal({
|
||||||
open,
|
open,
|
||||||
onClose,
|
onClose,
|
||||||
@@ -17,43 +23,9 @@ export function Modal({
|
|||||||
footer?: ReactNode;
|
footer?: ReactNode;
|
||||||
wide?: boolean;
|
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 (
|
return (
|
||||||
<div className="fixed inset-0 z-50 flex items-end justify-center p-0 sm:items-center sm:p-4">
|
<Sheet open={open} onClose={onClose} onSubmit={onSubmit} title={title} footer={footer} wide={wide}>
|
||||||
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={onClose} />
|
{children}
|
||||||
<div
|
</Sheet>
|
||||||
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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { useNotifications } from "../context/NotificationsContext";
|
import { useNotifications } from "../context/NotificationsContext";
|
||||||
|
import { BellIcon } from "./icons";
|
||||||
import { relativeTime } from "../utils";
|
import { relativeTime } from "../utils";
|
||||||
|
|
||||||
export function NotificationBell() {
|
export function NotificationBell() {
|
||||||
@@ -21,25 +22,19 @@ export function NotificationBell() {
|
|||||||
<div className="relative" ref={ref}>
|
<div className="relative" ref={ref}>
|
||||||
<button
|
<button
|
||||||
onClick={() => setOpen((o) => !o)}
|
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"
|
aria-label="Notifications"
|
||||||
>
|
>
|
||||||
<svg className="h-5 w-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8">
|
<BellIcon className="h-5 w-5" />
|
||||||
<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>
|
|
||||||
{unreadCount > 0 && (
|
{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}
|
{unreadCount > 99 ? "99+" : unreadCount}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{open && (
|
{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">
|
<div className="flex items-center justify-between border-b border-border px-4 py-2.5">
|
||||||
<span className="text-sm font-semibold">Notifications</span>
|
<span className="text-sm font-semibold">Notifications</span>
|
||||||
<div className="flex gap-2 text-xs">
|
<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
|
<button
|
||||||
{...props}
|
{...props}
|
||||||
disabled={props.disabled || loading}
|
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" />}
|
{loading && <Spinner className="h-4 w-4" />}
|
||||||
{children}
|
{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 (
|
return (
|
||||||
<label className="block">
|
<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
|
<input
|
||||||
{...props}
|
{...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>
|
</label>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Textarea({ label, className = "", ...props }: TextareaHTMLAttributes<HTMLTextAreaElement> & { label?: string }) {
|
export function Textarea({
|
||||||
|
label,
|
||||||
|
className = "",
|
||||||
|
...props
|
||||||
|
}: TextareaHTMLAttributes<HTMLTextAreaElement> & { label?: string }) {
|
||||||
return (
|
return (
|
||||||
<label className="block">
|
<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
|
<textarea
|
||||||
{...props}
|
{...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>
|
</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 (
|
return (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
role="switch"
|
||||||
|
aria-checked={checked}
|
||||||
onClick={() => onChange(!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
|
<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
|
<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>
|
</span>
|
||||||
{label && <span className="text-sm text-text">{label}</span>}
|
{label && <span className="text-left text-sm text-text">{label}</span>}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function EmptyState({ title, subtitle, icon }: { title: string; subtitle?: string; icon?: string }) {
|
export function EmptyState({ title, subtitle, icon }: { title: string; subtitle?: string; icon?: string }) {
|
||||||
return (
|
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>}
|
{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>}
|
{subtitle && <p className="mt-1 max-w-md text-sm text-muted">{subtitle}</p>}
|
||||||
</div>
|
</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 (
|
return (
|
||||||
<div className="mb-5 flex flex-wrap items-end justify-between gap-3 sm:mb-6 sm:gap-4">
|
<div className="mb-5 flex flex-wrap items-end justify-between gap-3 sm:mb-6 sm:gap-4">
|
||||||
<div>
|
<div className="min-w-0">
|
||||||
<h1 className="text-xl font-bold tracking-tight text-text sm:text-2xl">{title}</h1>
|
<h1 className="text-2xl font-bold tracking-tight text-text">{title}</h1>
|
||||||
{subtitle && <p className="mt-1 text-sm text-muted">{subtitle}</p>}
|
{subtitle && <p className="mt-1 text-sm text-muted">{subtitle}</p>}
|
||||||
</div>
|
</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>
|
</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]);
|
||||||
|
}
|
||||||
+241
-7
@@ -2,6 +2,9 @@
|
|||||||
|
|
||||||
/* PatchPass design tokens (Tailwind v4 @theme) */
|
/* PatchPass design tokens (Tailwind v4 @theme) */
|
||||||
@theme {
|
@theme {
|
||||||
|
/* Small-phone breakpoint (iPhone SE and friends sit below it). */
|
||||||
|
--breakpoint-xs: 24rem;
|
||||||
|
|
||||||
--color-bg: #0a0e17;
|
--color-bg: #0a0e17;
|
||||||
--color-surface: #111726;
|
--color-surface: #111726;
|
||||||
--color-surface-raised: #1a2234;
|
--color-surface-raised: #1a2234;
|
||||||
@@ -24,6 +27,15 @@
|
|||||||
--color-cancelled: #7a8296;
|
--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,
|
html,
|
||||||
body,
|
body,
|
||||||
#root {
|
#root {
|
||||||
@@ -35,21 +47,158 @@ body,
|
|||||||
overflow-x: clip;
|
overflow-x: clip;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
/* Landscape notch insets, applied once for all in-flow content. Fixed-position
|
||||||
font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial,
|
chrome escapes this and re-applies the insets itself via `px-safe`. */
|
||||||
sans-serif;
|
#root {
|
||||||
-webkit-font-smoothing: antialiased;
|
padding-left: var(--safe-left);
|
||||||
|
padding-right: var(--safe-right);
|
||||||
}
|
}
|
||||||
|
|
||||||
button:hover,
|
html {
|
||||||
a:hover {
|
/* Stop iOS from re-flowing type when the device rotates. */
|
||||||
cursor: pointer;
|
-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;
|
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 syntax coloring */
|
||||||
.diff-add {
|
.diff-add {
|
||||||
background-color: rgba(62, 207, 142, 0.13);
|
background-color: rgba(62, 207, 142, 0.13);
|
||||||
@@ -66,6 +215,8 @@ a:hover {
|
|||||||
color: var(--color-faint);
|
color: var(--color-faint);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Motion ─────────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
@keyframes fadeIn {
|
@keyframes fadeIn {
|
||||||
from {
|
from {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
@@ -94,3 +245,86 @@ a:hover {
|
|||||||
.animate-pulse-ring {
|
.animate-pulse-ring {
|
||||||
animation: pulseRing 1.8s ease-out infinite;
|
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() {
|
function FullScreenLoader() {
|
||||||
return (
|
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" />
|
<Spinner className="h-8 w-8" />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -66,7 +66,19 @@ ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
|
|||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
<App />
|
<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>
|
</AuthProvider>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
</React.StrictMode>,
|
</React.StrictMode>,
|
||||||
|
|||||||
+115
-68
@@ -4,6 +4,7 @@ import { admin } from "../api/client";
|
|||||||
import type { AdminUser, AuditLog, Agent, GlobalSettings } from "../api/types";
|
import type { AdminUser, AuditLog, Agent, GlobalSettings } from "../api/types";
|
||||||
import { useAuth } from "../context/AuthContext";
|
import { useAuth } from "../context/AuthContext";
|
||||||
import { Button, Card, PageHeader, Spinner, Toggle } from "../components/ui";
|
import { Button, Card, PageHeader, Spinner, Toggle } from "../components/ui";
|
||||||
|
import { ConfirmSheet } from "../components/ConfirmSheet";
|
||||||
import { relativeTime } from "../utils";
|
import { relativeTime } from "../utils";
|
||||||
|
|
||||||
type Tab = "users" | "agents" | "settings" | "audit";
|
type Tab = "users" | "agents" | "settings" | "audit";
|
||||||
@@ -20,12 +21,12 @@ export function AdminPage() {
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<PageHeader title="Admin" subtitle="Platform administration." />
|
<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) => (
|
{tabs.map((t) => (
|
||||||
<button
|
<button
|
||||||
key={t.id}
|
key={t.id}
|
||||||
onClick={() => setTab(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"
|
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 { user: me } = useAuth();
|
||||||
const [users, setUsers] = useState<AdminUser[]>([]);
|
const [users, setUsers] = useState<AdminUser[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [pendingDelete, setPendingDelete] = useState<AdminUser | null>(null);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -75,13 +78,16 @@ function UsersTab() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
const remove = async (u: AdminUser) => {
|
const remove = async (u: AdminUser) => {
|
||||||
if (!confirm(`Delete user "${u.username}" and all their data?`)) return;
|
setBusy(true);
|
||||||
try {
|
try {
|
||||||
await admin.deleteUser(u.id);
|
await admin.deleteUser(u.id);
|
||||||
toast.success("User deleted");
|
toast.success("User deleted");
|
||||||
|
setPendingDelete(null);
|
||||||
load();
|
load();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.error(e instanceof Error ? e.message : "Failed");
|
toast.error(e instanceof Error ? e.message : "Failed");
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -89,41 +95,54 @@ function UsersTab() {
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{users.map((u) => (
|
{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="min-w-0 flex-1">
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
<div className="flex items-center gap-2">
|
<div className="min-w-0 flex-1">
|
||||||
<span className="font-medium text-text">{u.display_name}</span>
|
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||||
<span className="text-xs text-faint">@{u.username}</span>
|
<span className="font-medium text-text">{u.display_name}</span>
|
||||||
{u.role === "ADMIN" && (
|
<span className="text-xs text-faint">@{u.username}</span>
|
||||||
<span className="rounded-full bg-accent/15 px-2 py-0.5 text-[10px] font-semibold text-accent">
|
{u.role === "ADMIN" && (
|
||||||
ADMIN
|
<span className="rounded-full bg-accent/15 px-2 py-0.5 text-[10px] font-semibold text-accent">
|
||||||
</span>
|
ADMIN
|
||||||
)}
|
</span>
|
||||||
{u.disabled && (
|
)}
|
||||||
<span className="rounded-full bg-rejected/15 px-2 py-0.5 text-[10px] font-semibold text-rejected">
|
{u.disabled && (
|
||||||
DISABLED
|
<span className="rounded-full bg-rejected/15 px-2 py-0.5 text-[10px] font-semibold text-rejected">
|
||||||
</span>
|
DISABLED
|
||||||
)}
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="mt-0.5 text-xs text-muted">
|
||||||
|
{u.agent_count} agents · {u.request_count} requests · joined {relativeTime(u.created_at)}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-muted">
|
{u.id !== me?.id && (
|
||||||
{u.agent_count} agents · {u.request_count} requests · joined {relativeTime(u.created_at)}
|
<div className="flex w-full gap-2 *:flex-1 sm:w-auto sm:flex-nowrap sm:*:flex-none">
|
||||||
</p>
|
<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={() => setPendingDelete(u)}>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{u.id !== me?.id && (
|
|
||||||
<div className="flex w-full flex-wrap gap-2 sm:w-auto sm:flex-nowrap">
|
|
||||||
<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)}>
|
|
||||||
Delete
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Card>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -131,6 +150,9 @@ function UsersTab() {
|
|||||||
function AgentsTab() {
|
function AgentsTab() {
|
||||||
const [agents, setAgents] = useState<(Agent & { owner: { id: number; username: string } })[]>([]);
|
const [agents, setAgents] = useState<(Agent & { owner: { id: number; username: string } })[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [pendingDelete, setPendingDelete] = useState<Agent | null>(null);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
setAgents(await admin.agents());
|
setAgents(await admin.agents());
|
||||||
@@ -151,13 +173,16 @@ function AgentsTab() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
const remove = async (a: Agent) => {
|
const remove = async (a: Agent) => {
|
||||||
if (!confirm(`Delete agent "${a.name}"?`)) return;
|
setBusy(true);
|
||||||
try {
|
try {
|
||||||
await admin.deleteAgent(a.id);
|
await admin.deleteAgent(a.id);
|
||||||
toast.success("Agent deleted");
|
toast.success("Agent deleted");
|
||||||
|
setPendingDelete(null);
|
||||||
load();
|
load();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.error(e instanceof Error ? e.message : "Failed");
|
toast.error(e instanceof Error ? e.message : "Failed");
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -165,29 +190,42 @@ function AgentsTab() {
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{agents.map((a) => (
|
{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="min-w-0 flex-1">
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
<div className="flex items-center gap-2">
|
<div className="min-w-0 flex-1">
|
||||||
<span className="font-medium text-text">{a.name}</span>
|
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||||
<span className="text-xs text-faint">by @{a.owner.username}</span>
|
<span className="font-medium text-text">{a.name}</span>
|
||||||
{a.disabled && (
|
<span className="text-xs text-faint">by @{a.owner.username}</span>
|
||||||
<span className="rounded-full bg-rejected/15 px-2 py-0.5 text-[10px] font-semibold text-rejected">
|
{a.disabled && (
|
||||||
DISABLED
|
<span className="rounded-full bg-rejected/15 px-2 py-0.5 text-[10px] font-semibold text-rejected">
|
||||||
</span>
|
DISABLED
|
||||||
)}
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{a.description && <p className="mt-0.5 text-xs text-muted">{a.description}</p>}
|
||||||
|
</div>
|
||||||
|
<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={() => setPendingDelete(a)}>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
{a.description && <p className="text-xs text-muted">{a.description}</p>}
|
|
||||||
</div>
|
|
||||||
<div className="flex w-full flex-wrap gap-2 sm:w-auto sm:flex-nowrap">
|
|
||||||
<Button variant="ghost" onClick={() => toggle(a)}>
|
|
||||||
{a.disabled ? "Enable" : "Disable"}
|
|
||||||
</Button>
|
|
||||||
<Button variant="ghost" className="text-rejected" onClick={() => remove(a)}>
|
|
||||||
Delete
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -195,7 +233,10 @@ function AgentsTab() {
|
|||||||
function SettingsTab() {
|
function SettingsTab() {
|
||||||
const [settings, setSettings] = useState<GlobalSettings | null>(null);
|
const [settings, setSettings] = useState<GlobalSettings | null>(null);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
admin.settings().then(setSettings).catch(() => {});
|
admin
|
||||||
|
.settings()
|
||||||
|
.then(setSettings)
|
||||||
|
.catch(() => {});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const update = async (patch: Partial<GlobalSettings>) => {
|
const update = async (patch: Partial<GlobalSettings>) => {
|
||||||
@@ -211,15 +252,15 @@ function SettingsTab() {
|
|||||||
if (!settings) return <Loader />;
|
if (!settings) return <Loader />;
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<Card className="flex flex-wrap items-center justify-between gap-4 p-4 sm:p-5">
|
<Card className="flex items-center justify-between gap-4 p-4 sm:p-5">
|
||||||
<div>
|
<div className="min-w-0">
|
||||||
<p className="font-medium text-text">Enable registration</p>
|
<p className="font-medium text-text">Enable registration</p>
|
||||||
<p className="text-sm text-muted">Allow new humans to create accounts.</p>
|
<p className="text-sm text-muted">Allow new humans to create accounts.</p>
|
||||||
</div>
|
</div>
|
||||||
<Toggle checked={settings.registration_enabled} onChange={(v) => update({ registration_enabled: v })} />
|
<Toggle checked={settings.registration_enabled} onChange={(v) => update({ registration_enabled: v })} />
|
||||||
</Card>
|
</Card>
|
||||||
<Card className="flex flex-wrap items-center justify-between gap-4 p-4 sm:p-5">
|
<Card className="flex items-center justify-between gap-4 p-4 sm:p-5">
|
||||||
<div>
|
<div className="min-w-0">
|
||||||
<p className="font-medium text-text">Enable requests</p>
|
<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>
|
<p className="text-sm text-muted">Allow agents to submit new change requests platform-wide.</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -251,14 +292,20 @@ function AuditTab() {
|
|||||||
<div>
|
<div>
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
{logs.map((l) => (
|
{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">
|
<Card key={l.id} className="p-3 text-sm">
|
||||||
<code className="rounded bg-surface-raised px-2 py-0.5 text-xs text-accent">{l.action}</code>
|
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 sm:flex-nowrap sm:gap-3">
|
||||||
<span className="min-w-0 flex-1 truncate text-muted">
|
<code className="shrink-0 rounded bg-surface-raised px-2 py-0.5 text-xs text-accent">
|
||||||
{l.actor ? `@${l.actor.username}` : "system"}
|
{l.action}
|
||||||
{l.target_type && ` → ${l.target_type}:${l.target_id}`}
|
</code>
|
||||||
{l.detail && ` · ${l.detail}`}
|
<span className="min-w-0 flex-1 break-words text-muted sm:truncate">
|
||||||
</span>
|
{l.actor ? `@${l.actor.username}` : "system"}
|
||||||
<span className="w-full text-xs text-faint sm:w-auto sm:shrink-0">{relativeTime(l.created_at)}</span>
|
{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>
|
||||||
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -267,7 +314,7 @@ function AuditTab() {
|
|||||||
<Button variant="secondary" disabled={page <= 1} onClick={() => setPage((p) => p - 1)}>
|
<Button variant="secondary" disabled={page <= 1} onClick={() => setPage((p) => p - 1)}>
|
||||||
Previous
|
Previous
|
||||||
</Button>
|
</Button>
|
||||||
<span className="text-sm text-muted">
|
<span className="text-sm text-muted tabular-nums">
|
||||||
Page {page} of {totalPages}
|
Page {page} of {totalPages}
|
||||||
</span>
|
</span>
|
||||||
<Button variant="secondary" disabled={page >= totalPages} onClick={() => setPage((p) => p + 1)}>
|
<Button variant="secondary" disabled={page >= totalPages} onClick={() => setPage((p) => p + 1)}>
|
||||||
|
|||||||
+148
-28
@@ -5,6 +5,11 @@ import type { Agent } from "../api/types";
|
|||||||
import { Button, Card, EmptyState, PageHeader, Spinner } from "../components/ui";
|
import { Button, Card, EmptyState, PageHeader, Spinner } from "../components/ui";
|
||||||
import { AgentAvatar } from "../components/AgentAvatar";
|
import { AgentAvatar } from "../components/AgentAvatar";
|
||||||
import { AgentFormModal, ConnectModal } from "../components/AgentModals";
|
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() {
|
export function AgentsPage() {
|
||||||
const [agents, setAgents] = useState<Agent[]>([]);
|
const [agents, setAgents] = useState<Agent[]>([]);
|
||||||
@@ -13,6 +18,9 @@ export function AgentsPage() {
|
|||||||
const [editing, setEditing] = useState<Agent | null>(null);
|
const [editing, setEditing] = useState<Agent | null>(null);
|
||||||
const [connectAgent, setConnectAgent] = useState<Agent | null>(null);
|
const [connectAgent, setConnectAgent] = useState<Agent | null>(null);
|
||||||
const [revealedKey, setRevealedKey] = useState<string | 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 () => {
|
const load = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -35,15 +43,18 @@ export function AgentsPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const regenerate = async (agent: Agent) => {
|
const regenerate = async (agent: Agent) => {
|
||||||
if (!confirm(`Regenerate the API key for "${agent.name}"? The old key stops working immediately.`)) return;
|
setBusy(true);
|
||||||
try {
|
try {
|
||||||
const updated = await agentsApi.regenerateKey(agent.id);
|
const updated = await agentsApi.regenerateKey(agent.id);
|
||||||
toast.success("API key regenerated");
|
toast.success("API key regenerated");
|
||||||
setRevealedKey(updated.api_key ?? null);
|
setRevealedKey(updated.api_key ?? null);
|
||||||
setConnectAgent(updated);
|
setConnectAgent(updated);
|
||||||
|
setConfirm(null);
|
||||||
load();
|
load();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(err instanceof Error ? err.message : "Failed");
|
toast.error(err instanceof Error ? err.message : "Failed");
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -58,16 +69,24 @@ export function AgentsPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const remove = async (agent: Agent) => {
|
const remove = async (agent: Agent) => {
|
||||||
if (!confirm(`Delete "${agent.name}"? All its requests will be permanently deleted.`)) return;
|
setBusy(true);
|
||||||
try {
|
try {
|
||||||
await agentsApi.remove(agent.id);
|
await agentsApi.remove(agent.id);
|
||||||
toast.success("Agent deleted");
|
toast.success("Agent deleted");
|
||||||
|
setConfirm(null);
|
||||||
load();
|
load();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(err instanceof Error ? err.message : "Failed");
|
toast.error(err instanceof Error ? err.message : "Failed");
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const openEdit = (a: Agent) => {
|
||||||
|
setEditing(a);
|
||||||
|
setFormOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
@@ -97,47 +116,65 @@ export function AgentsPage() {
|
|||||||
subtitle="Create an agent, then connect it via OpenClaw, MCP, or the REST API."
|
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) => (
|
{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">
|
<div className="flex items-start gap-3">
|
||||||
<AgentAvatar name={a.name} iconUrl={a.icon_url} size={44} />
|
<AgentAvatar name={a.name} iconUrl={a.icon_url} size={44} />
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<h3 className="truncate font-semibold text-text">{a.name}</h3>
|
<h3 className="truncate font-semibold text-text">{a.name}</h3>
|
||||||
{a.disabled && (
|
{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
|
DISABLED
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{a.description && <p className="mt-0.5 line-clamp-2 text-xs text-muted">{a.description}</p>}
|
{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 ·{" "}
|
{a.pending_count ?? 0}/{a.max_pending_requests} pending ·{" "}
|
||||||
<code>{a.api_key_masked}</code>
|
<code>{a.api_key_masked}</code>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</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
|
Connect
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button variant="ghost" className="flex-1" onClick={() => openEdit(a)}>
|
||||||
|
Edit
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
onClick={() => {
|
className="w-11 shrink-0 px-0"
|
||||||
setEditing(a);
|
aria-label={`More actions for ${a.name}`}
|
||||||
setFormOpen(true);
|
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
|
Edit
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="ghost" onClick={() => regenerate(a)}>
|
<Button variant="ghost" onClick={() => setConfirm({ agent: a, kind: "regenerate" })}>
|
||||||
Regenerate key
|
Regenerate key
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="ghost" onClick={() => toggleDisabled(a)}>
|
<Button variant="ghost" onClick={() => toggleDisabled(a)}>
|
||||||
{a.disabled ? "Enable" : "Disable"}
|
{a.disabled ? "Enable" : "Disable"}
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="ghost" className="text-rejected" onClick={() => remove(a)}>
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="text-rejected"
|
||||||
|
onClick={() => setConfirm({ agent: a, kind: "delete" })}
|
||||||
|
>
|
||||||
Delete
|
Delete
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -146,23 +183,106 @@ export function AgentsPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<AgentFormModal
|
<AgentFormModal open={formOpen} onClose={() => setFormOpen(false)} agent={editing} onSaved={onSaved} />
|
||||||
open={formOpen}
|
|
||||||
onClose={() => setFormOpen(false)}
|
<ConnectModal
|
||||||
agent={editing}
|
open={!!connectAgent}
|
||||||
onSaved={onSaved}
|
onClose={() => {
|
||||||
|
setConnectAgent(null);
|
||||||
|
setRevealedKey(null);
|
||||||
|
}}
|
||||||
|
agent={connectAgent}
|
||||||
|
revealedKey={revealedKey}
|
||||||
/>
|
/>
|
||||||
{connectAgent && (
|
|
||||||
<ConnectModal
|
{/* Overflow actions (mobile) */}
|
||||||
open={!!connectAgent}
|
<Sheet
|
||||||
onClose={() => {
|
open={!!menuAgent}
|
||||||
setConnectAgent(null);
|
onClose={() => setMenuAgent(null)}
|
||||||
setRevealedKey(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);
|
||||||
}}
|
}}
|
||||||
agent={connectAgent}
|
|
||||||
revealedKey={revealedKey}
|
|
||||||
/>
|
/>
|
||||||
)}
|
<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>
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
+50
-32
@@ -3,9 +3,10 @@ import { Link } from "react-router-dom";
|
|||||||
import { requests as requestsApi, agents as agentsApi } from "../api/client";
|
import { requests as requestsApi, agents as agentsApi } from "../api/client";
|
||||||
import type { Agent, ChangeRequest, RequestState } from "../api/types";
|
import type { Agent, ChangeRequest, RequestState } from "../api/types";
|
||||||
import { useNotifications } from "../context/NotificationsContext";
|
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 { StateBadge } from "../components/StateBadge";
|
||||||
import { AgentAvatar } from "../components/AgentAvatar";
|
import { AgentAvatar } from "../components/AgentAvatar";
|
||||||
|
import { ChevronRightIcon } from "../components/icons";
|
||||||
import { expiresIn, relativeTime } from "../utils";
|
import { expiresIn, relativeTime } from "../utils";
|
||||||
|
|
||||||
const summaryTiles: { state: RequestState; label: string }[] = [
|
const summaryTiles: { state: RequestState; label: string }[] = [
|
||||||
@@ -56,20 +57,25 @@ export function DashboardPage() {
|
|||||||
subtitle="Requests awaiting your review, and your connected agents."
|
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) => (
|
{summaryTiles.map((t) => (
|
||||||
<Card key={t.state} className="p-4">
|
<Card key={t.state} className="p-3.5 sm:p-4">
|
||||||
<p className="text-3xl font-bold text-text">{counts[t.state] ?? 0}</p>
|
<p className="text-2xl font-bold text-text tabular-nums sm:text-3xl">
|
||||||
<p className="mt-1 text-xs text-muted">{t.label}</p>
|
{counts[t.state] ?? 0}
|
||||||
|
</p>
|
||||||
|
<p className="mt-0.5 text-xs text-muted sm:mt-1">{t.label}</p>
|
||||||
</Card>
|
</Card>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-8 lg:grid-cols-3">
|
{/* min-w-0 on both tracks: grid items default to min-width:auto, so the
|
||||||
<div className="lg:col-span-2">
|
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">
|
<div className="mb-3 flex items-center justify-between gap-3">
|
||||||
<h2 className="text-lg font-semibold">Awaiting review</h2>
|
<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 →
|
View all →
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
@@ -80,7 +86,7 @@ export function DashboardPage() {
|
|||||||
subtitle="No requests are waiting for your review right now."
|
subtitle="No requests are waiting for your review right now."
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-3">
|
<div className="space-y-2.5">
|
||||||
{pending.map((r) => (
|
{pending.map((r) => (
|
||||||
<PendingRow key={r.request_id} request={r} />
|
<PendingRow key={r.request_id} request={r} />
|
||||||
))}
|
))}
|
||||||
@@ -88,15 +94,19 @@ export function DashboardPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div className="min-w-0">
|
||||||
<div className="mb-3 flex items-center justify-between gap-3">
|
<div className="mb-3 flex items-center justify-between gap-3">
|
||||||
<h2 className="text-lg font-semibold">Agents</h2>
|
<h2 className="text-lg font-semibold">Agents</h2>
|
||||||
<Link to="/agents" className="text-sm text-primary hover:underline">
|
<Link to="/agents" className={textLinkClass}>
|
||||||
Manage →
|
Manage →
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
{agents.length === 0 ? (
|
{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">
|
<div className="space-y-2">
|
||||||
{agents.map((a) => (
|
{agents.map((a) => (
|
||||||
@@ -123,30 +133,38 @@ function PendingRow({ request }: { request: ChangeRequest }) {
|
|||||||
const exp = expiresIn(request.expires_at);
|
const exp = expiresIn(request.expires_at);
|
||||||
return (
|
return (
|
||||||
<Link to={`/requests/${request.request_id}`} className="block">
|
<Link to={`/requests/${request.request_id}`} className="block">
|
||||||
<Card className="p-4 transition hover:border-border-strong hover:bg-surface-raised/40">
|
<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">
|
||||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between sm:gap-3">
|
<AgentAvatar name={request.agent?.name ?? "?"} iconUrl={request.agent?.icon_url} size={32} />
|
||||||
<div className="flex min-w-0 items-start gap-3">
|
|
||||||
<AgentAvatar name={request.agent?.name ?? "?"} iconUrl={request.agent?.icon_url} size={32} />
|
<div className="min-w-0 flex-1">
|
||||||
<div className="min-w-0">
|
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<p className="min-w-0 break-words font-medium leading-snug text-text">{request.title}</p>
|
||||||
<p className="break-words font-medium text-text">{request.title}</p>
|
{request.resubmitted && (
|
||||||
{request.resubmitted && (
|
<span className="animate-pulse-ring rounded-full bg-changes/20 px-2 py-0.5 text-[10px] font-semibold text-changes">
|
||||||
<span className="animate-pulse-ring rounded-full bg-changes/20 px-2 py-0.5 text-[10px] font-semibold text-changes">
|
UPDATED
|
||||||
UPDATED
|
</span>
|
||||||
</span>
|
)}
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<p className="mt-0.5 truncate text-xs text-muted">
|
|
||||||
{request.agent?.name} · {request.changes.length} change
|
|
||||||
{request.changes.length === 1 ? "" : "s"} · {relativeTime(request.created_at)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex self-start sm:shrink-0 sm:flex-col sm:items-end sm:gap-1">
|
<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} />
|
<StateBadge state={request.state} />
|
||||||
<span className={`text-[11px] ${exp.urgent ? "text-pending" : "text-faint"}`}>{exp.text}</span>
|
<span className={`text-[11px] ${exp.urgent ? "text-pending" : "text-faint"}`}>
|
||||||
|
{exp.text}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<ChevronRightIcon className="mt-1 h-5 w-5 shrink-0 text-faint sm:hidden" />
|
||||||
</Card>
|
</Card>
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
|
|||||||
+37
-32
@@ -2,23 +2,28 @@ import { Link } from "react-router-dom";
|
|||||||
|
|
||||||
function LegalShell({ title, children }: { title: string; children: React.ReactNode }) {
|
function LegalShell({ title, children }: { title: string; children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-3xl px-4 py-12">
|
<div className="pt-safe pb-safe mx-auto max-w-3xl px-4">
|
||||||
<Link to="/" className="mb-6 inline-block text-sm text-muted hover:text-text">
|
<div className="py-10 sm:py-12">
|
||||||
← Back
|
<Link
|
||||||
</Link>
|
to="/"
|
||||||
<h1 className="mb-6 text-3xl font-bold tracking-tight">{title}</h1>
|
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"
|
||||||
<div className="space-y-5 text-sm leading-relaxed text-muted [&_h2]:mt-6 [&_h2]:text-lg [&_h2]:font-semibold [&_h2]:text-text">
|
>
|
||||||
{children}
|
← Back
|
||||||
|
</Link>
|
||||||
|
<h1 className="mb-6 text-3xl font-bold tracking-tight">{title}</h1>
|
||||||
|
<div className="space-y-5 text-sm leading-relaxed text-muted [&_h2]:mt-6 [&_h2]:text-lg [&_h2]:font-semibold [&_h2]:text-text">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
<footer className="mt-12 border-t border-border pt-6 text-xs text-faint">
|
||||||
|
<Link to="/privacy" className="hover:text-muted">
|
||||||
|
Privacy Policy
|
||||||
|
</Link>
|
||||||
|
<span className="mx-2">·</span>
|
||||||
|
<Link to="/terms" className="hover:text-muted">
|
||||||
|
Terms of Service
|
||||||
|
</Link>
|
||||||
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
<footer className="mt-12 border-t border-border pt-6 text-xs text-faint">
|
|
||||||
<Link to="/privacy" className="hover:text-muted">
|
|
||||||
Privacy Policy
|
|
||||||
</Link>
|
|
||||||
<span className="mx-2">·</span>
|
|
||||||
<Link to="/terms" className="hover:text-muted">
|
|
||||||
Terms of Service
|
|
||||||
</Link>
|
|
||||||
</footer>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -27,28 +32,28 @@ export function PrivacyPage() {
|
|||||||
return (
|
return (
|
||||||
<LegalShell title="Privacy Policy">
|
<LegalShell title="Privacy Policy">
|
||||||
<p>
|
<p>
|
||||||
PatchPass is a self-hostable human approval layer for AI agents. This policy explains what data
|
PatchPass is a self-hostable human approval layer for AI agents. This policy explains what data the
|
||||||
the platform stores and the controls you have over it.
|
platform stores and the controls you have over it.
|
||||||
</p>
|
</p>
|
||||||
<h2>Data we store</h2>
|
<h2>Data we store</h2>
|
||||||
<p>
|
<p>
|
||||||
We store the account data you provide (username, display name, a bcrypt-hashed password, and an
|
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,
|
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
|
description, website, icon URL, and an API key), the change requests your agents submit, the decisions
|
||||||
decisions you make, and your in-app notifications.
|
you make, and your in-app notifications.
|
||||||
</p>
|
</p>
|
||||||
<h2>Agent icons</h2>
|
<h2>Agent icons</h2>
|
||||||
<p>
|
<p>
|
||||||
When you set an agent icon URL, the backend fetches it once to verify it points to a valid image
|
When you set an agent icon URL, the backend fetches it once to verify it points to a valid image (JPG,
|
||||||
(JPG, PNG, or GIF, under 1 MB). The image itself is <strong>not</strong> stored or cached —
|
PNG, or GIF, under 1 MB). The image itself is <strong>not</strong> stored or cached — only the
|
||||||
only the URL you provided is kept.
|
URL you provided is kept.
|
||||||
</p>
|
</p>
|
||||||
<h2>How your data is used</h2>
|
<h2>How your data is used</h2>
|
||||||
<p>
|
<p>
|
||||||
Data is used solely to operate the approval workflow: routing agent requests to you for review,
|
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
|
recording decisions, and delivering notifications. We do not sell data or share it with third parties.
|
||||||
parties. Administrators of your instance can view platform data for moderation; access to another
|
Administrators of your instance can view platform data for moderation; access to another user's
|
||||||
user's request payloads is explicitly audited.
|
request payloads is explicitly audited.
|
||||||
</p>
|
</p>
|
||||||
<h2>Retention</h2>
|
<h2>Retention</h2>
|
||||||
<p>
|
<p>
|
||||||
@@ -58,8 +63,8 @@ export function PrivacyPage() {
|
|||||||
<h2>Your rights (GDPR)</h2>
|
<h2>Your rights (GDPR)</h2>
|
||||||
<p>
|
<p>
|
||||||
You can export all of your data as machine-readable JSON at any time from Settings. You can also
|
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,
|
delete your account, which permanently removes your account and cascades to all your agents, change
|
||||||
change requests, and notifications. These actions are self-service and take effect immediately.
|
requests, and notifications. These actions are self-service and take effect immediately.
|
||||||
</p>
|
</p>
|
||||||
<h2>Security</h2>
|
<h2>Security</h2>
|
||||||
<p>
|
<p>
|
||||||
@@ -92,13 +97,13 @@ export function TermsPage() {
|
|||||||
</p>
|
</p>
|
||||||
<h2>Rate limits</h2>
|
<h2>Rate limits</h2>
|
||||||
<p>
|
<p>
|
||||||
To keep the platform usable, agents are limited to 15 requests per hour and a configurable number
|
To keep the platform usable, agents are limited to 15 requests per hour and a configurable number of
|
||||||
of simultaneous pending requests, and each human may own up to 5 agents.
|
simultaneous pending requests, and each human may own up to 5 agents.
|
||||||
</p>
|
</p>
|
||||||
<h2>Availability</h2>
|
<h2>Availability</h2>
|
||||||
<p>
|
<p>
|
||||||
This is self-hosted software. Availability, backups, and data durability are the responsibility of
|
This is self-hosted software. Availability, backups, and data durability are the responsibility of the
|
||||||
the operator of this instance.
|
operator of this instance.
|
||||||
</p>
|
</p>
|
||||||
<h2>Changes</h2>
|
<h2>Changes</h2>
|
||||||
<p>These terms may be updated by the operator of your instance.</p>
|
<p>These terms may be updated by the operator of your instance.</p>
|
||||||
|
|||||||
+10
-4
@@ -44,7 +44,10 @@ export function LoginPage() {
|
|||||||
label="Username"
|
label="Username"
|
||||||
value={username}
|
value={username}
|
||||||
onChange={(e) => setUsername(e.target.value)}
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
autoFocus
|
autoComplete="username"
|
||||||
|
autoCapitalize="none"
|
||||||
|
autoCorrect="off"
|
||||||
|
spellCheck={false}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
<Input
|
<Input
|
||||||
@@ -52,6 +55,7 @@ export function LoginPage() {
|
|||||||
type="password"
|
type="password"
|
||||||
value={password}
|
value={password}
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
autoComplete="current-password"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
{needsTotp && (
|
{needsTotp && (
|
||||||
@@ -61,6 +65,8 @@ export function LoginPage() {
|
|||||||
onChange={(e) => setTotp(e.target.value.replace(/\D/g, "").slice(0, 6))}
|
onChange={(e) => setTotp(e.target.value.replace(/\D/g, "").slice(0, 6))}
|
||||||
placeholder="123456"
|
placeholder="123456"
|
||||||
inputMode="numeric"
|
inputMode="numeric"
|
||||||
|
autoComplete="one-time-code"
|
||||||
|
pattern="[0-9]*"
|
||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -90,10 +96,10 @@ export function AuthShell({
|
|||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-[100dvh] items-center justify-center p-3 sm:p-4">
|
<div className="pt-safe pb-safe flex min-h-[100dvh] items-center justify-center">
|
||||||
<div className="w-full max-w-md">
|
<div className="w-full max-w-md px-3 py-6 sm:px-4">
|
||||||
<div className="mb-8 text-center">
|
<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">
|
<h1 className="text-3xl font-bold tracking-tight">
|
||||||
Patch<span className="text-primary">Pass</span>
|
Patch<span className="text-primary">Pass</span>
|
||||||
</h1>
|
</h1>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { useNotifications } from "../context/NotificationsContext";
|
import { useNotifications } from "../context/NotificationsContext";
|
||||||
import { Button, Card, EmptyState, PageHeader } from "../components/ui";
|
import { Button, Card, EmptyState, PageHeader } from "../components/ui";
|
||||||
|
import { ChevronRightIcon } from "../components/icons";
|
||||||
import { relativeTime } from "../utils";
|
import { relativeTime } from "../utils";
|
||||||
|
|
||||||
export function NotificationsPage() {
|
export function NotificationsPage() {
|
||||||
@@ -33,7 +34,7 @@ export function NotificationsPage() {
|
|||||||
{items.map((n) => (
|
{items.map((n) => (
|
||||||
<Card
|
<Card
|
||||||
key={n.id}
|
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" : ""
|
n.read ? "opacity-60" : ""
|
||||||
}`}
|
}`}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -41,12 +42,20 @@ export function NotificationsPage() {
|
|||||||
if (n.request_id) navigate(`/requests/${n.request_id}`);
|
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">
|
<div className="min-w-0 flex-1">
|
||||||
<p className="font-medium text-text">{n.title}</p>
|
<p className="font-medium leading-snug text-text">{n.title}</p>
|
||||||
<p className="text-sm text-muted">{n.message}</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>
|
</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>
|
</Card>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -55,19 +55,24 @@ export function RegisterPage() {
|
|||||||
value={username}
|
value={username}
|
||||||
onChange={(e) => setUsername(e.target.value)}
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
hint="Letters, numbers, dots, dashes, underscores. Case-insensitive."
|
hint="Letters, numbers, dots, dashes, underscores. Case-insensitive."
|
||||||
autoFocus
|
autoComplete="username"
|
||||||
|
autoCapitalize="none"
|
||||||
|
autoCorrect="off"
|
||||||
|
spellCheck={false}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
<Input
|
<Input
|
||||||
label="Display name (optional)"
|
label="Display name (optional)"
|
||||||
value={displayName}
|
value={displayName}
|
||||||
onChange={(e) => setDisplayName(e.target.value)}
|
onChange={(e) => setDisplayName(e.target.value)}
|
||||||
|
autoComplete="name"
|
||||||
/>
|
/>
|
||||||
<Input
|
<Input
|
||||||
label="Password"
|
label="Password"
|
||||||
type="password"
|
type="password"
|
||||||
value={password}
|
value={password}
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
autoComplete="new-password"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
<Input
|
<Input
|
||||||
@@ -75,6 +80,7 @@ export function RegisterPage() {
|
|||||||
type="password"
|
type="password"
|
||||||
value={confirm}
|
value={confirm}
|
||||||
onChange={(e) => setConfirm(e.target.value)}
|
onChange={(e) => setConfirm(e.target.value)}
|
||||||
|
autoComplete="new-password"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
<Button type="submit" loading={loading} className="w-full">
|
<Button type="submit" loading={loading} className="w-full">
|
||||||
|
|||||||
+169
-119
@@ -8,6 +8,7 @@ import { StateBadge } from "../components/StateBadge";
|
|||||||
import { ChangeList } from "../components/ChangeRenderer";
|
import { ChangeList } from "../components/ChangeRenderer";
|
||||||
import { AgentAvatar } from "../components/AgentAvatar";
|
import { AgentAvatar } from "../components/AgentAvatar";
|
||||||
import { DecisionModal } from "../components/DecisionModal";
|
import { DecisionModal } from "../components/DecisionModal";
|
||||||
|
import { ChevronLeftIcon } from "../components/icons";
|
||||||
import { expiresIn, formatDateTime, relativeTime } from "../utils";
|
import { expiresIn, formatDateTime, relativeTime } from "../utils";
|
||||||
|
|
||||||
type Decision = "APPROVE" | "REJECT" | "REQUEST_CHANGES";
|
type Decision = "APPROVE" | "REJECT" | "REQUEST_CHANGES";
|
||||||
@@ -58,146 +59,195 @@ export function RequestDetailPage() {
|
|||||||
const canDecide = request.state === "PENDING";
|
const canDecide = request.state === "PENDING";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="animate-fade-in">
|
<div>
|
||||||
<Link to="/requests" className="mb-4 inline-block text-sm text-muted hover:text-text">
|
{/* The fade-in wrapper is kept inside: its transform would otherwise make
|
||||||
← Back to requests
|
it the containing block for the fixed decision bar below. */}
|
||||||
</Link>
|
<div className="animate-fade-in">
|
||||||
|
<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" && (
|
{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">
|
<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 rounded-full bg-changes/25 px-2 py-0.5 text-xs font-bold text-changes">
|
<span className="animate-pulse-ring w-fit rounded-full bg-changes/25 px-2 py-0.5 text-xs font-bold text-changes">
|
||||||
UPDATED
|
UPDATED
|
||||||
</span>
|
</span>
|
||||||
<p className="text-sm text-text">
|
<p className="text-sm text-text">
|
||||||
This request was revised by the agent after you requested changes (update #{request.update_count}).
|
This request was revised by the agent after you requested changes (update #
|
||||||
Please re-review the changes below.
|
{request.update_count}). Please re-review the changes below.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="grid gap-6 lg:grid-cols-3">
|
<div className="grid gap-6 lg:grid-cols-3">
|
||||||
<div className="order-2 lg:order-1 lg:col-span-2">
|
<div className="min-w-0 lg:col-span-2">
|
||||||
<div className="mb-4 flex items-start justify-between gap-4">
|
<div className="mb-4">
|
||||||
<div>
|
<div className="flex flex-wrap items-start gap-x-3 gap-y-2">
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<h1 className="min-w-0 break-words text-xl font-bold leading-tight tracking-tight sm:text-2xl">
|
||||||
<h1 className="break-words text-xl font-bold tracking-tight sm:text-2xl">{request.title}</h1>
|
{request.title}
|
||||||
<StateBadge state={request.state} />
|
</h1>
|
||||||
|
<StateBadge state={request.state} className="mt-0.5 shrink-0" />
|
||||||
</div>
|
</div>
|
||||||
{request.description && <p className="mt-2 text-muted">{request.description}</p>}
|
{request.description && (
|
||||||
|
<p className="mt-2 text-[15px] leading-relaxed text-muted sm:text-base">
|
||||||
|
{request.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{request.comment && (
|
||||||
|
<div className="mb-5 rounded-xl border border-border bg-surface-raised/40 p-4">
|
||||||
|
<p className="mb-1 text-xs font-semibold uppercase tracking-wide text-faint">
|
||||||
|
{request.state === "CHANGES_REQUESTED" ? "Changes requested" : "Reviewer note"}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-text">{request.comment}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wide text-faint">
|
||||||
|
Proposed changes ({request.changes.length})
|
||||||
|
</h2>
|
||||||
|
<ChangeList changes={request.changes} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{request.comment && (
|
<div className="min-w-0 space-y-4">
|
||||||
<div className="mb-5 rounded-xl border border-border bg-surface-raised/40 p-4">
|
{/* On phones this lives in the sticky bar at the bottom instead. */}
|
||||||
<p className="mb-1 text-xs font-semibold uppercase tracking-wide text-faint">
|
{canDecide && (
|
||||||
{request.state === "CHANGES_REQUESTED" ? "Changes requested" : "Reviewer note"}
|
<Card className="hidden space-y-2 p-4 lg:block">
|
||||||
</p>
|
<p className="mb-1 text-sm font-semibold">Your decision</p>
|
||||||
<p className="text-sm text-text">{request.comment}</p>
|
<Button variant="success" className="w-full" onClick={() => setDecision("APPROVE")}>
|
||||||
</div>
|
Approve
|
||||||
)}
|
</Button>
|
||||||
|
<Button variant="primary" className="w-full" onClick={() => setDecision("REQUEST_CHANGES")}>
|
||||||
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wide text-faint">
|
Request changes
|
||||||
Proposed changes ({request.changes.length})
|
</Button>
|
||||||
</h2>
|
<Button variant="danger" className="w-full" onClick={() => setDecision("REJECT")}>
|
||||||
<ChangeList changes={request.changes} />
|
Reject
|
||||||
</div>
|
</Button>
|
||||||
|
<p className={`pt-1 text-center text-xs ${exp.urgent ? "text-pending" : "text-faint"}`}>
|
||||||
<div className="order-1 space-y-4 lg:order-2">
|
{exp.text}
|
||||||
{canDecide && (
|
</p>
|
||||||
<Card className="space-y-2 p-4">
|
</Card>
|
||||||
<p className="mb-1 text-sm font-semibold">Your decision</p>
|
|
||||||
<Button variant="success" className="w-full" onClick={() => setDecision("APPROVE")}>
|
|
||||||
Approve
|
|
||||||
</Button>
|
|
||||||
<Button variant="primary" className="w-full" onClick={() => setDecision("REQUEST_CHANGES")}>
|
|
||||||
Request changes
|
|
||||||
</Button>
|
|
||||||
<Button variant="danger" className="w-full" onClick={() => setDecision("REJECT")}>
|
|
||||||
Reject
|
|
||||||
</Button>
|
|
||||||
<p className={`pt-1 text-center text-xs ${exp.urgent ? "text-pending" : "text-faint"}`}>
|
|
||||||
{exp.text}
|
|
||||||
</p>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Card className="p-4">
|
|
||||||
<p className="mb-3 text-sm font-semibold">Agent</p>
|
|
||||||
{request.agent ? (
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<AgentAvatar name={request.agent.name} iconUrl={request.agent.icon_url} />
|
|
||||||
<div className="min-w-0">
|
|
||||||
<p className="truncate text-sm font-medium">{request.agent.name}</p>
|
|
||||||
{request.agent.website && (
|
|
||||||
<a
|
|
||||||
href={request.agent.website}
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
className="truncate text-xs text-primary hover:underline"
|
|
||||||
>
|
|
||||||
{request.agent.website}
|
|
||||||
</a>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<p className="text-sm text-muted">Unknown</p>
|
|
||||||
)}
|
)}
|
||||||
{request.agent?.description && (
|
|
||||||
<p className="mt-2 text-xs text-muted">{request.agent.description}</p>
|
|
||||||
)}
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card className="space-y-2 p-4 text-xs">
|
|
||||||
<Row label="Request ID" value={<code className="text-[11px]">{request.request_id}</code>} />
|
|
||||||
<Row
|
|
||||||
label="Content hash"
|
|
||||||
value={<code className="text-[11px] break-all text-muted">{request.content_hash}</code>}
|
|
||||||
/>
|
|
||||||
<Row label="Created" value={relativeTime(request.created_at)} />
|
|
||||||
<Row label="Expires" value={formatDateTime(request.expires_at)} />
|
|
||||||
{request.decided_at && <Row label="Decided" value={formatDateTime(request.decided_at)} />}
|
|
||||||
{request.consumed_at && <Row label="Consumed" value={formatDateTime(request.consumed_at)} />}
|
|
||||||
{request.update_count > 0 && <Row label="Updates" value={String(request.update_count)} />}
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{request.metadata && Object.keys(request.metadata).length > 0 && (
|
|
||||||
<Card className="p-4">
|
<Card className="p-4">
|
||||||
<p className="mb-2 text-sm font-semibold">Metadata</p>
|
<p className="mb-3 text-sm font-semibold">Agent</p>
|
||||||
<div className="space-y-1 text-xs">
|
{request.agent ? (
|
||||||
{Object.entries(request.metadata).map(([k, v]) => (
|
<div className="flex items-center gap-3">
|
||||||
<Row key={k} label={k} value={<span className="text-muted">{String(v)}</span>} />
|
<AgentAvatar name={request.agent.name} iconUrl={request.agent.icon_url} />
|
||||||
))}
|
<div className="min-w-0">
|
||||||
</div>
|
<p className="truncate text-sm font-medium">{request.agent.name}</p>
|
||||||
|
{request.agent.website && (
|
||||||
|
<a
|
||||||
|
href={request.agent.website}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="block truncate text-xs text-primary hover:underline"
|
||||||
|
>
|
||||||
|
{request.agent.website}
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted">Unknown</p>
|
||||||
|
)}
|
||||||
|
{request.agent?.description && (
|
||||||
|
<p className="mt-2 text-xs text-muted">{request.agent.description}</p>
|
||||||
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
|
||||||
|
|
||||||
{request.receipt && (
|
<Card className="space-y-2 p-4 text-xs">
|
||||||
<Card className="border-approved/30 bg-approved/5 p-4">
|
<Row label="Request ID" value={<code className="text-[11px]">{request.request_id}</code>} />
|
||||||
<p className="mb-2 flex items-center gap-2 text-sm font-semibold text-approved">
|
<Row
|
||||||
<span>🔏</span> Signed receipt
|
label="Content hash"
|
||||||
</p>
|
value={<code className="text-[11px] break-all text-muted">{request.content_hash}</code>}
|
||||||
<div className="space-y-1 text-xs">
|
/>
|
||||||
<Row label="Decision" value={request.receipt.payload.decision} />
|
<Row label="Created" value={relativeTime(request.created_at)} />
|
||||||
<Row label="Algorithm" value={request.receipt.algorithm} />
|
<Row label="Expires" value={formatDateTime(request.expires_at)} />
|
||||||
<Row
|
{request.decided_at && <Row label="Decided" value={formatDateTime(request.decided_at)} />}
|
||||||
label="Signature"
|
{request.consumed_at && <Row label="Consumed" value={formatDateTime(request.consumed_at)} />}
|
||||||
value={<code className="text-[11px] break-all text-muted">{request.receipt.signature}</code>}
|
{request.update_count > 0 && <Row label="Updates" value={String(request.update_count)} />}
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
|
||||||
|
{request.metadata && Object.keys(request.metadata).length > 0 && (
|
||||||
|
<Card className="p-4">
|
||||||
|
<p className="mb-2 text-sm font-semibold">Metadata</p>
|
||||||
|
<div className="space-y-1 text-xs">
|
||||||
|
{Object.entries(request.metadata).map(([k, v]) => (
|
||||||
|
<Row key={k} label={k} value={<span className="text-muted">{String(v)}</span>} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{request.receipt && (
|
||||||
|
<Card className="border-approved/30 bg-approved/5 p-4">
|
||||||
|
<p className="mb-2 flex items-center gap-2 text-sm font-semibold text-approved">
|
||||||
|
<span>🔏</span> Signed receipt
|
||||||
|
</p>
|
||||||
|
<div className="space-y-1 text-xs">
|
||||||
|
<Row label="Decision" value={request.receipt.payload.decision} />
|
||||||
|
<Row label="Algorithm" value={request.receipt.algorithm} />
|
||||||
|
<Row
|
||||||
|
label="Signature"
|
||||||
|
value={
|
||||||
|
<code className="text-[11px] break-all text-muted">{request.receipt.signature}</code>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function Row({ label, value }: { label: string; value: React.ReactNode }) {
|
function Row({ label, value }: { label: string; value: React.ReactNode }) {
|
||||||
return (
|
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="shrink-0 text-faint">{label}</span>
|
||||||
<span className="break-all text-text sm:text-right">{value}</span>
|
<span className="break-all text-text sm:text-right">{value}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+48
-39
@@ -6,6 +6,7 @@ import { useNotifications } from "../context/NotificationsContext";
|
|||||||
import { Card, EmptyState, PageHeader, Spinner, Button } from "../components/ui";
|
import { Card, EmptyState, PageHeader, Spinner, Button } from "../components/ui";
|
||||||
import { StateBadge } from "../components/StateBadge";
|
import { StateBadge } from "../components/StateBadge";
|
||||||
import { AgentAvatar } from "../components/AgentAvatar";
|
import { AgentAvatar } from "../components/AgentAvatar";
|
||||||
|
import { ChevronRightIcon } from "../components/icons";
|
||||||
import { relativeTime } from "../utils";
|
import { relativeTime } from "../utils";
|
||||||
|
|
||||||
const STATES: RequestState[] = [
|
const STATES: RequestState[] = [
|
||||||
@@ -31,7 +32,10 @@ export function RequestsPage() {
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
agentsApi.list().then(setAgents).catch(() => {});
|
agentsApi
|
||||||
|
.list()
|
||||||
|
.then(setAgents)
|
||||||
|
.catch(() => {});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
@@ -56,54 +60,51 @@ export function RequestsPage() {
|
|||||||
|
|
||||||
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
|
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 (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<PageHeader title="Requests" subtitle="Full history of change requests submitted by your agents." />
|
<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">
|
<div className="mb-4 space-y-2.5">
|
||||||
<select
|
{/* Swipeable filter pills — bleeds to the screen edges on phones so the
|
||||||
value={stateFilter}
|
row reads as scrollable. */}
|
||||||
onChange={(e) => {
|
<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">
|
||||||
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">
|
|
||||||
<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"}`}
|
|
||||||
>
|
|
||||||
All
|
|
||||||
</button>
|
|
||||||
{STATES.map((s) => (
|
|
||||||
<button
|
<button
|
||||||
key={s}
|
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setStateFilter(s);
|
setStateFilter("");
|
||||||
setPage(1);
|
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.replace("_", " ").toLowerCase()}
|
All
|
||||||
</button>
|
</button>
|
||||||
))}
|
{STATES.map((s) => (
|
||||||
|
<button
|
||||||
|
key={s}
|
||||||
|
onClick={() => {
|
||||||
|
setStateFilter(s);
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
className={chip(stateFilter === s)}
|
||||||
|
>
|
||||||
|
{s.replace("_", " ").toLowerCase()}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<select
|
<select
|
||||||
value={agentFilter}
|
value={agentFilter}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setAgentFilter(e.target.value ? Number(e.target.value) : "");
|
setAgentFilter(e.target.value ? Number(e.target.value) : "");
|
||||||
setPage(1);
|
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>
|
<option value="">All agents</option>
|
||||||
{agents.map((a) => (
|
{agents.map((a) => (
|
||||||
@@ -124,25 +125,33 @@ export function RequestsPage() {
|
|||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{items.map((r) => (
|
{items.map((r) => (
|
||||||
<Link key={r.request_id} to={`/requests/${r.request_id}`} className="block">
|
<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">
|
<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">
|
||||||
<div className="flex min-w-0 items-start gap-3">
|
|
||||||
<AgentAvatar name={r.agent?.name ?? "?"} iconUrl={r.agent?.icon_url} size={32} />
|
<AgentAvatar name={r.agent?.name ?? "?"} iconUrl={r.agent?.icon_url} size={32} />
|
||||||
|
|
||||||
<div className="min-w-0 flex-1">
|
<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">
|
||||||
<p className="break-words font-medium text-text">{r.title}</p>
|
<p className="min-w-0 break-words font-medium leading-snug text-text">
|
||||||
|
{r.title}
|
||||||
|
</p>
|
||||||
{r.resubmitted && r.state === "PENDING" && (
|
{r.resubmitted && r.state === "PENDING" && (
|
||||||
<span className="rounded-full bg-changes/20 px-1.5 py-0.5 text-[10px] font-semibold text-changes">
|
<span className="rounded-full bg-changes/20 px-1.5 py-0.5 text-[10px] font-semibold text-changes">
|
||||||
UPDATED
|
UPDATED
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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"} ·{" "}
|
{r.agent?.name} · {r.changes.length} change{r.changes.length === 1 ? "" : "s"} ·{" "}
|
||||||
{relativeTime(r.created_at)}
|
{relativeTime(r.created_at)}
|
||||||
</p>
|
</p>
|
||||||
|
<div className="mt-2 sm:hidden">
|
||||||
|
<StateBadge state={r.state} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="hidden shrink-0 sm:block">
|
||||||
|
<StateBadge state={r.state} />
|
||||||
</div>
|
</div>
|
||||||
<div className="self-start sm:shrink-0"><StateBadge state={r.state} /></div>
|
<ChevronRightIcon className="mt-1 h-5 w-5 shrink-0 text-faint sm:hidden" />
|
||||||
</Card>
|
</Card>
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
@@ -154,7 +163,7 @@ export function RequestsPage() {
|
|||||||
<Button variant="secondary" disabled={page <= 1} onClick={() => setPage((p) => p - 1)}>
|
<Button variant="secondary" disabled={page <= 1} onClick={() => setPage((p) => p - 1)}>
|
||||||
Previous
|
Previous
|
||||||
</Button>
|
</Button>
|
||||||
<span className="text-sm text-muted">
|
<span className="text-sm text-muted tabular-nums">
|
||||||
Page {page} of {totalPages}
|
Page {page} of {totalPages}
|
||||||
</span>
|
</span>
|
||||||
<Button variant="secondary" disabled={page >= totalPages} onClick={() => setPage((p) => p + 1)}>
|
<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 }) {
|
function Section({ title, description, children }: { title: string; description?: string; children: React.ReactNode }) {
|
||||||
return (
|
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>
|
<h2 className="text-base font-semibold text-text">{title}</h2>
|
||||||
{description && <p className="mb-4 mt-0.5 text-sm text-muted">{description}</p>}
|
{description && <p className="mb-4 mt-0.5 text-sm text-muted">{description}</p>}
|
||||||
<div className={description ? "" : "mt-4"}>{children}</div>
|
<div className={description ? "" : "mt-4"}>{children}</div>
|
||||||
@@ -118,9 +118,11 @@ export function SettingsPage() {
|
|||||||
<div className="grid gap-5 lg:grid-cols-2">
|
<div className="grid gap-5 lg:grid-cols-2">
|
||||||
<Section title="Profile">
|
<Section title="Profile">
|
||||||
<div className="space-y-3">
|
<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)} />
|
<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>
|
</div>
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
@@ -129,17 +131,23 @@ export function SettingsPage() {
|
|||||||
<Input
|
<Input
|
||||||
label="Current password"
|
label="Current password"
|
||||||
type="password"
|
type="password"
|
||||||
|
autoComplete="current-password"
|
||||||
value={curPw}
|
value={curPw}
|
||||||
onChange={(e) => setCurPw(e.target.value)}
|
onChange={(e) => setCurPw(e.target.value)}
|
||||||
/>
|
/>
|
||||||
<Input
|
<Input
|
||||||
label="New password"
|
label="New password"
|
||||||
type="password"
|
type="password"
|
||||||
|
autoComplete="new-password"
|
||||||
value={newPw}
|
value={newPw}
|
||||||
onChange={(e) => setNewPw(e.target.value)}
|
onChange={(e) => setNewPw(e.target.value)}
|
||||||
hint="At least 8 characters."
|
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
|
Change password
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -165,8 +173,10 @@ export function SettingsPage() {
|
|||||||
onChange={(e) => setTotp(e.target.value.replace(/\D/g, "").slice(0, 6))}
|
onChange={(e) => setTotp(e.target.value.replace(/\D/g, "").slice(0, 6))}
|
||||||
placeholder="123456"
|
placeholder="123456"
|
||||||
inputMode="numeric"
|
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}>
|
<Button onClick={confirm2fa} disabled={totp.length !== 6}>
|
||||||
Enable 2FA
|
Enable 2FA
|
||||||
</Button>
|
</Button>
|
||||||
@@ -198,9 +208,11 @@ export function SettingsPage() {
|
|||||||
max={90}
|
max={90}
|
||||||
value={autoDeleteDays}
|
value={autoDeleteDays}
|
||||||
onChange={(e) => setAutoDeleteDays(Number(e.target.value))}
|
onChange={(e) => setAutoDeleteDays(Number(e.target.value))}
|
||||||
onMouseUp={() => saveAutoDelete(true, autoDeleteDays)}
|
// pointerup covers mouse, touch and pen in one handler.
|
||||||
onTouchEnd={() => saveAutoDelete(true, autoDeleteDays)}
|
onPointerUp={() => saveAutoDelete(true, autoDeleteDays)}
|
||||||
|
onKeyUp={() => saveAutoDelete(true, autoDeleteDays)}
|
||||||
className="w-full accent-[var(--color-primary)]"
|
className="w-full accent-[var(--color-primary)]"
|
||||||
|
aria-label="Retention window in days"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -208,13 +220,15 @@ export function SettingsPage() {
|
|||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
<Section title="Export your data" description="Download all your data (account, agents, requests, notifications) as JSON.">
|
<Section title="Export your data" description="Download all your data (account, agents, requests, notifications) as JSON.">
|
||||||
<a href={account.exportUrl} download>
|
<a href={account.exportUrl} download className="block sm:inline-block">
|
||||||
<Button variant="secondary">Download export</Button>
|
<Button variant="secondary" className="w-full sm:w-auto">
|
||||||
|
Download export
|
||||||
|
</Button>
|
||||||
</a>
|
</a>
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
<Section title="Danger zone" description="Permanently delete your account and all associated data. This cannot be undone.">
|
<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
|
Delete account
|
||||||
</Button>
|
</Button>
|
||||||
</Section>
|
</Section>
|
||||||
@@ -227,10 +241,10 @@ export function SettingsPage() {
|
|||||||
title="Disable 2FA"
|
title="Disable 2FA"
|
||||||
footer={
|
footer={
|
||||||
<>
|
<>
|
||||||
<Button variant="ghost" onClick={() => setDisable2faOpen(false)}>
|
<Button variant="ghost" className="flex-1 sm:flex-none" onClick={() => setDisable2faOpen(false)}>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="danger" onClick={disable2fa}>
|
<Button variant="danger" className="flex-1 sm:flex-none" onClick={disable2fa}>
|
||||||
Disable
|
Disable
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
@@ -239,6 +253,7 @@ export function SettingsPage() {
|
|||||||
<Input
|
<Input
|
||||||
label="Confirm your password"
|
label="Confirm your password"
|
||||||
type="password"
|
type="password"
|
||||||
|
autoComplete="current-password"
|
||||||
value={disablePw}
|
value={disablePw}
|
||||||
onChange={(e) => setDisablePw(e.target.value)}
|
onChange={(e) => setDisablePw(e.target.value)}
|
||||||
/>
|
/>
|
||||||
@@ -251,10 +266,15 @@ export function SettingsPage() {
|
|||||||
title="Delete account"
|
title="Delete account"
|
||||||
footer={
|
footer={
|
||||||
<>
|
<>
|
||||||
<Button variant="ghost" onClick={() => setDeleteOpen(false)}>
|
<Button variant="ghost" className="flex-1 sm:flex-none" onClick={() => setDeleteOpen(false)}>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="danger" onClick={deleteAccount} disabled={!deletePw}>
|
<Button
|
||||||
|
variant="danger"
|
||||||
|
className="flex-1 sm:flex-none"
|
||||||
|
onClick={deleteAccount}
|
||||||
|
disabled={!deletePw}
|
||||||
|
>
|
||||||
Permanently delete
|
Permanently delete
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
@@ -268,6 +288,7 @@ export function SettingsPage() {
|
|||||||
<Input
|
<Input
|
||||||
label="Confirm your password"
|
label="Confirm your password"
|
||||||
type="password"
|
type="password"
|
||||||
|
autoComplete="current-password"
|
||||||
value={deletePw}
|
value={deletePw}
|
||||||
onChange={(e) => setDeletePw(e.target.value)}
|
onChange={(e) => setDeletePw(e.target.value)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
Reference in New Issue
Block a user