Phase 3: UI redesign (Gitea-issues aesthetic)
Top-nav Layout replaces side-nav: brand, primary nav, global search (debounced /search), notifications bell (Popover + unread badge), user avatar DropdownMenu. Mobile hamburger collapse. New pages: - /dashboard: analytics home (open-by-severity, age buckets, queue load, median resolution) - /tickets: Gitea-style list with status tabs, severity/assignee/CTI filters, server pagination (25/page), multi-select bulk bar (reassign/close/severity), saved views CRUD - /notifications: full list with mark-all-read - /settings: profile, notification prefs grid, API key (SERVICE role) - /admin/webhooks: CRUD + rotate-secret + active toggle, reveal-once secret dialog TicketDetail: inline Popover editing for Status/Severity/Assignee (replaces modal chain), AlertDialog delete confirmation, comment draft autosave to localStorage per ticket. Admin Users: window.confirm swapped for AlertDialog on delete + rotate API key with toast feedback. React Query hooks added for paged tickets, bulk actions, notifications, webhooks, saved views, analytics, notification prefs. ThemeProvider wired (v1.0 ships dark-only; toggle deferred). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,151 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Copy } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import Layout from '../components/Layout';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import {
|
||||
useNotificationPrefs,
|
||||
useUpdateNotificationPrefs,
|
||||
type NotificationPrefs,
|
||||
} from '../api/queries';
|
||||
|
||||
const CHANNELS = [
|
||||
{ key: 'assignment', label: 'Ticket assigned to me' },
|
||||
{ key: 'mention', label: 'I am mentioned in a comment' },
|
||||
{ key: 'resolved', label: 'A ticket I created is resolved' },
|
||||
] as const;
|
||||
|
||||
export default function Settings() {
|
||||
const { user } = useAuth();
|
||||
const prefsQ = useNotificationPrefs();
|
||||
const updatePrefs = useUpdateNotificationPrefs();
|
||||
|
||||
const [local, setLocal] = useState<NotificationPrefs | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (prefsQ.data && !local) setLocal(prefsQ.data);
|
||||
}, [prefsQ.data, local]);
|
||||
|
||||
const handleToggle = (channel: 'email' | 'inApp', key: keyof NotificationPrefs['email']) => {
|
||||
setLocal((l) =>
|
||||
l
|
||||
? {
|
||||
...l,
|
||||
[channel]: { ...l[channel], [key]: !l[channel][key] },
|
||||
}
|
||||
: l,
|
||||
);
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
if (!local) return;
|
||||
try {
|
||||
await updatePrefs.mutateAsync(local);
|
||||
toast.success('Preferences saved');
|
||||
} catch (e) {
|
||||
toast.error((e as Error).message || 'Failed to save');
|
||||
}
|
||||
};
|
||||
|
||||
const copyKey = async () => {
|
||||
if (!user?.apiKey) return;
|
||||
await navigator.clipboard.writeText(user.apiKey);
|
||||
toast.success('API key copied');
|
||||
};
|
||||
|
||||
return (
|
||||
<Layout title="Settings">
|
||||
<div className="space-y-6">
|
||||
{/* Profile */}
|
||||
<section className="rounded-md border border-border p-4">
|
||||
<h2 className="text-sm font-semibold mb-3">Profile</h2>
|
||||
<div className="grid grid-cols-2 gap-3 text-sm">
|
||||
<Field label="Display name" value={user?.displayName ?? ''} />
|
||||
<Field label="Username" value={user?.username ?? ''} mono />
|
||||
<Field label="Email" value={user?.email ?? ''} />
|
||||
<Field label="Role" value={user?.role ?? ''} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Notifications */}
|
||||
<section className="rounded-md border border-border p-4">
|
||||
<h2 className="text-sm font-semibold mb-3">Notification preferences</h2>
|
||||
{local ? (
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-[1fr_80px_80px] gap-2 text-xs uppercase text-muted-foreground pb-1 border-b border-border">
|
||||
<span />
|
||||
<span className="text-center">Email</span>
|
||||
<span className="text-center">In app</span>
|
||||
</div>
|
||||
{CHANNELS.map(({ key, label }) => (
|
||||
<div
|
||||
key={key}
|
||||
className="grid grid-cols-[1fr_80px_80px] gap-2 items-center text-sm"
|
||||
>
|
||||
<span>{label}</span>
|
||||
<div className="text-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={local.email[key]}
|
||||
onChange={() => handleToggle('email', key)}
|
||||
/>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={local.inApp[key]}
|
||||
onChange={() => handleToggle('inApp', key)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<div className="flex justify-end pt-2">
|
||||
<button
|
||||
onClick={save}
|
||||
disabled={updatePrefs.isPending}
|
||||
className="px-3 py-1.5 bg-primary text-primary-foreground rounded-md text-sm disabled:opacity-50"
|
||||
>
|
||||
{updatePrefs.isPending ? 'Saving…' : 'Save preferences'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">Loading…</p>
|
||||
)}
|
||||
<p className="mt-3 text-xs text-muted-foreground">
|
||||
Email notifications require the server's SMTP config. If SMTP is unset, only
|
||||
in-app delivery happens regardless of these settings.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{/* API key (service accounts only) */}
|
||||
{user?.role === 'SERVICE' && user?.apiKey && (
|
||||
<section className="rounded-md border border-border p-4">
|
||||
<h2 className="text-sm font-semibold mb-3">API key</h2>
|
||||
<div className="flex items-center gap-2 bg-muted rounded-md px-3 py-2 font-mono text-xs break-all">
|
||||
<span className="flex-1">{user.apiKey}</span>
|
||||
<button
|
||||
onClick={copyKey}
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<Copy size={14} />
|
||||
</button>
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
Pass as <code>x-api-key</code> header on any server-to-server request.
|
||||
</p>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({ label, value, mono }: { label: string; value: string; mono?: boolean }) {
|
||||
return (
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground mb-0.5">{label}</p>
|
||||
<p className={`text-sm ${mono ? 'font-mono' : ''}`}>{value || '—'}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user