Phase 4: power UX (palette, shortcuts, mentions, mobile, PWA)

Command palette (cmd+K) with fuzzy nav, ticket search, people lookup
and action entries (new ticket, logout, show shortcuts). Opens from
keyboard or user dropdown.

Global keyboard shortcuts via a small useShortcut/useLeaderShortcut
hook: `?` help overlay, `c` new ticket, `g d|t|m|n|s` leader nav.
Tickets list: j/k cursor, Enter open, x toggle select. TicketDetail:
`e` edit, `r` focus comment composer. All guarded against firing
inside text fields.

@mention autocomplete in the comment composer (MentionTextarea) with
arrow-key nav and Tab/Enter insert. Rendered comments and audit log
rewrite @username tokens to links pointing at that user's assignee
filter; unknown usernames left as plain text.

Mobile sweep: TicketDetail sidebar stacks below content on <md,
Settings profile grid collapses to one column, admin tables get
horizontal scroll with a 640px min width, CTI 3-column grid stacks
vertically on <md, New ticket severity/assignee grid same.

PWA: manifest.webmanifest, SVG icon, minimal network-first service
worker for the app shell (never caches /api/*), registered in
production builds only. Theme-color meta + manifest link in index.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-04-18 16:36:28 -04:00
parent 4bade22410
commit ef22e92ac8
21 changed files with 976 additions and 28 deletions
+4
View File
@@ -3,6 +3,10 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#2563eb" />
<link rel="manifest" href="/manifest.webmanifest" />
<link rel="icon" type="image/svg+xml" href="/icon.svg" />
<link rel="apple-touch-icon" href="/icon.svg" />
<title>Ticketing System</title> <title>Ticketing System</title>
</head> </head>
<body> <body>
+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<rect width="512" height="512" rx="96" fill="#2563eb"/>
<path fill="#ffffff" d="M128 144c0-8.8 7.2-16 16-16h224c8.8 0 16 7.2 16 16v48c0 8.8-7.2 16-16 16-8.8 0-16-7.2-16-16v-32H280v224h40c8.8 0 16 7.2 16 16s-7.2 16-16 16H192c-8.8 0-16-7.2-16-16s7.2-16 16-16h40V160h-72v32c0 8.8-7.2 16-16 16s-16-7.2-16-16v-48z"/>
</svg>

After

Width:  |  Height:  |  Size: 386 B

+13
View File
@@ -0,0 +1,13 @@
{
"name": "Ticketing System",
"short_name": "Tickets",
"description": "Homelab ticketing with CTI routing, severity triage and n8n integration.",
"start_url": "/dashboard",
"scope": "/",
"display": "standalone",
"background_color": "#0a0a0a",
"theme_color": "#2563eb",
"icons": [
{ "src": "/icon.svg", "sizes": "any", "type": "image/svg+xml", "purpose": "any maskable" }
]
}
+58
View File
@@ -0,0 +1,58 @@
// Minimal service worker: offline shell only. No data caching.
const CACHE = 'ticketing-shell-v1';
const SHELL = ['/', '/icon.svg', '/manifest.webmanifest'];
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE).then((cache) => cache.addAll(SHELL)),
);
self.skipWaiting();
});
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then((keys) =>
Promise.all(keys.filter((k) => k !== CACHE).map((k) => caches.delete(k))),
),
);
self.clients.claim();
});
self.addEventListener('fetch', (event) => {
const req = event.request;
if (req.method !== 'GET') return;
const url = new URL(req.url);
// Never cache API calls — they must always hit the server
if (url.pathname.startsWith('/api/')) return;
// Network-first for navigation, fall back to cached shell
if (req.mode === 'navigate') {
event.respondWith(
fetch(req)
.then((res) => {
const clone = res.clone();
caches.open(CACHE).then((c) => c.put('/', clone));
return res;
})
.catch(() => caches.match('/')),
);
return;
}
// Cache-first for static assets
if (url.origin === self.location.origin) {
event.respondWith(
caches.match(req).then((cached) => {
if (cached) return cached;
return fetch(req).then((res) => {
if (res.ok && res.type === 'basic') {
const clone = res.clone();
caches.open(CACHE).then((c) => c.put(req, clone));
}
return res;
});
}),
);
}
});
+236
View File
@@ -0,0 +1,236 @@
import { useEffect, useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import {
LayoutDashboard,
ListChecks,
User as UserIcon,
Bell,
Settings as SettingsIcon,
Shield,
Plus,
LogOut,
Keyboard,
Ticket as TicketIcon,
} from 'lucide-react';
import {
Command,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandSeparator,
} from '@/components/ui/command';
import { Dialog, DialogContent } from '@/components/ui/dialog';
import { useAuth } from '../contexts/AuthContext';
import { useUsers } from '../api/queries';
import api from '../api/client';
import type { Ticket } from '../types';
interface Props {
open: boolean;
onOpenChange: (open: boolean) => void;
onNewTicket?: () => void;
onShowShortcuts?: () => void;
}
export default function CommandPalette({
open,
onOpenChange,
onNewTicket,
onShowShortcuts,
}: Props) {
const navigate = useNavigate();
const { user, logout } = useAuth();
const isAdmin = user?.role === 'ADMIN';
const [query, setQuery] = useState('');
const [tickets, setTickets] = useState<Ticket[]>([]);
const { data: users = [] } = useUsers();
// Debounced ticket search
useEffect(() => {
if (!open) return;
const q = query.trim();
if (q.length < 2) {
setTickets([]);
return;
}
const t = setTimeout(async () => {
try {
const res = await api.get<{ tickets: Ticket[] }>('/search', { params: { q, limit: 8 } });
setTickets(res.data.tickets.slice(0, 8));
} catch {
setTickets([]);
}
}, 180);
return () => clearTimeout(t);
}, [query, open]);
// Reset when closed
useEffect(() => {
if (!open) setQuery('');
}, [open]);
const go = (path: string) => {
onOpenChange(false);
navigate(path);
};
const run = (fn: () => void) => {
onOpenChange(false);
setTimeout(fn, 0);
};
const userMatches = useMemo(
() =>
query.trim().length === 0
? []
: users
.filter(
(u) =>
u.displayName.toLowerCase().includes(query.toLowerCase()) ||
u.username.toLowerCase().includes(query.toLowerCase()),
)
.slice(0, 6),
[users, query],
);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="p-0 gap-0 overflow-hidden max-w-xl">
<Command shouldFilter={false}>
<CommandInput
placeholder="Search tickets, jump to pages, run commands…"
value={query}
onValueChange={setQuery}
autoFocus
/>
<CommandList>
<CommandEmpty>
{query.trim().length < 2 ? 'Keep typing to search…' : 'No results.'}
</CommandEmpty>
{tickets.length > 0 && (
<CommandGroup heading="Tickets">
{tickets.map((t) => (
<CommandItem
key={t.id}
value={`ticket-${t.id}`}
onSelect={() => go(`/${t.displayId}`)}
>
<TicketIcon size={14} className="opacity-60" />
<span className="font-mono text-xs text-muted-foreground mr-1">
{t.displayId}
</span>
<span className="truncate">{t.title}</span>
</CommandItem>
))}
</CommandGroup>
)}
{userMatches.length > 0 && (
<>
{tickets.length > 0 && <CommandSeparator />}
<CommandGroup heading="People">
{userMatches.map((u) => (
<CommandItem
key={u.id}
value={`user-${u.id}`}
onSelect={() =>
go(`/tickets?assigneeId=${encodeURIComponent(u.id)}`)
}
>
<UserIcon size={14} className="opacity-60" />
<span>{u.displayName}</span>
<span className="ml-auto text-xs text-muted-foreground">
tickets assigned
</span>
</CommandItem>
))}
</CommandGroup>
</>
)}
{(tickets.length > 0 || userMatches.length > 0) && <CommandSeparator />}
<CommandGroup heading="Actions">
{onNewTicket && (
<CommandItem
value="new-ticket"
onSelect={() => run(() => onNewTicket())}
>
<Plus size={14} className="opacity-60" />
New ticket
<span className="ml-auto text-xs text-muted-foreground">c</span>
</CommandItem>
)}
<CommandItem value="nav-dashboard" onSelect={() => go('/dashboard')}>
<LayoutDashboard size={14} className="opacity-60" />
Go to dashboard
<span className="ml-auto text-xs text-muted-foreground">g d</span>
</CommandItem>
<CommandItem value="nav-tickets" onSelect={() => go('/tickets')}>
<ListChecks size={14} className="opacity-60" />
Go to tickets
<span className="ml-auto text-xs text-muted-foreground">g t</span>
</CommandItem>
<CommandItem value="nav-my" onSelect={() => go('/my-tickets')}>
<UserIcon size={14} className="opacity-60" />
My tickets
<span className="ml-auto text-xs text-muted-foreground">g m</span>
</CommandItem>
<CommandItem value="nav-notifications" onSelect={() => go('/notifications')}>
<Bell size={14} className="opacity-60" />
Notifications
<span className="ml-auto text-xs text-muted-foreground">g n</span>
</CommandItem>
<CommandItem value="nav-settings" onSelect={() => go('/settings')}>
<SettingsIcon size={14} className="opacity-60" />
Settings
</CommandItem>
</CommandGroup>
{isAdmin && (
<>
<CommandSeparator />
<CommandGroup heading="Admin">
<CommandItem value="admin-users" onSelect={() => go('/admin/users')}>
<Shield size={14} className="opacity-60" />
Manage users
</CommandItem>
<CommandItem value="admin-cti" onSelect={() => go('/admin/cti')}>
<Shield size={14} className="opacity-60" />
Manage CTI
</CommandItem>
<CommandItem value="admin-webhooks" onSelect={() => go('/admin/webhooks')}>
<Shield size={14} className="opacity-60" />
Manage webhooks
</CommandItem>
</CommandGroup>
</>
)}
<CommandSeparator />
<CommandGroup heading="System">
{onShowShortcuts && (
<CommandItem
value="show-shortcuts"
onSelect={() => run(() => onShowShortcuts())}
>
<Keyboard size={14} className="opacity-60" />
Keyboard shortcuts
<span className="ml-auto text-xs text-muted-foreground">?</span>
</CommandItem>
)}
<CommandItem value="logout" onSelect={() => run(logout)}>
<LogOut size={14} className="opacity-60" />
Log out
</CommandItem>
</CommandGroup>
</CommandList>
</Command>
</DialogContent>
</Dialog>
);
}
+82
View File
@@ -0,0 +1,82 @@
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
interface Props {
open: boolean;
onOpenChange: (open: boolean) => void;
}
const GROUPS: { title: string; items: [string, string][] }[] = [
{
title: 'Global',
items: [
['⌘/Ctrl K', 'Open command palette'],
['?', 'Show this help'],
['c', 'Create new ticket'],
],
},
{
title: 'Navigate',
items: [
['g d', 'Dashboard'],
['g t', 'Tickets'],
['g m', 'My tickets'],
['g n', 'Notifications'],
['g s', 'Settings'],
],
},
{
title: 'Tickets list',
items: [
['j', 'Next ticket'],
['k', 'Previous ticket'],
['Enter', 'Open selected'],
['x', 'Toggle selected'],
],
},
{
title: 'Ticket detail',
items: [
['e', 'Edit title & overview'],
['r', 'Reply / new comment'],
['⌘/Ctrl Enter', 'Submit comment'],
],
},
];
export default function KeyboardHelp({ open, onOpenChange }: Props) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-xl">
<DialogHeader>
<DialogTitle>Keyboard shortcuts</DialogTitle>
</DialogHeader>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-6">
{GROUPS.map((g) => (
<div key={g.title}>
<p className="text-xs uppercase tracking-wide text-muted-foreground mb-2">
{g.title}
</p>
<dl className="space-y-1.5">
{g.items.map(([keys, label]) => (
<div key={keys} className="flex items-center justify-between gap-4 text-sm">
<dt className="text-foreground">{label}</dt>
<dd>
<kbd className="font-mono text-[11px] bg-muted border border-border rounded px-1.5 py-0.5 text-muted-foreground">
{keys}
</kbd>
</dd>
</div>
))}
</dl>
</div>
))}
</div>
</DialogContent>
</Dialog>
);
}
+42
View File
@@ -9,12 +9,16 @@ import {
Menu, Menu,
X, X,
SlidersHorizontal, SlidersHorizontal,
Keyboard,
} from 'lucide-react'; } from 'lucide-react';
import { useAuth } from '../contexts/AuthContext'; import { useAuth } from '../contexts/AuthContext';
import NewTicketModal from '../pages/NewTicket'; import NewTicketModal from '../pages/NewTicket';
import NotificationsBell from './NotificationsBell'; import NotificationsBell from './NotificationsBell';
import GlobalSearch from './GlobalSearch'; import GlobalSearch from './GlobalSearch';
import Avatar from './Avatar'; import Avatar from './Avatar';
import CommandPalette from './CommandPalette';
import KeyboardHelp from './KeyboardHelp';
import { useShortcut, useLeaderShortcut } from '../hooks/useShortcuts';
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
@@ -39,6 +43,8 @@ export default function Layout({ children, title, action, subheader, wide }: Lay
const navigate = useNavigate(); const navigate = useNavigate();
const [showNewTicket, setShowNewTicket] = useState(false); const [showNewTicket, setShowNewTicket] = useState(false);
const [mobileOpen, setMobileOpen] = useState(false); const [mobileOpen, setMobileOpen] = useState(false);
const [paletteOpen, setPaletteOpen] = useState(false);
const [helpOpen, setHelpOpen] = useState(false);
const canCreateTicket = user?.role !== 'USER'; const canCreateTicket = user?.role !== 'USER';
const isAdmin = user?.role === 'ADMIN'; const isAdmin = user?.role === 'ADMIN';
@@ -48,6 +54,31 @@ export default function Layout({ children, title, action, subheader, wide }: Lay
navigate('/login'); navigate('/login');
}; };
useShortcut('mod+k', (e) => {
e.preventDefault();
setPaletteOpen((v) => !v);
});
useShortcut('shift+/', (e) => {
// ? key — open help overlay
e.preventDefault();
setHelpOpen(true);
});
useShortcut('c', (e) => {
if (!canCreateTicket) return;
e.preventDefault();
setShowNewTicket(true);
}, [canCreateTicket]);
useLeaderShortcut('g', {
d: () => navigate('/dashboard'),
t: () => navigate('/tickets'),
m: () => navigate('/my-tickets'),
n: () => navigate('/notifications'),
s: () => navigate('/settings'),
});
const primaryNav = ( const primaryNav = (
<> <>
<NavLink <NavLink
@@ -156,6 +187,10 @@ export default function Layout({ children, title, action, subheader, wide }: Lay
</> </>
)} )}
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuItem onSelect={() => setHelpOpen(true)}>
<Keyboard size={14} />
Keyboard shortcuts
</DropdownMenuItem>
<DropdownMenuItem onSelect={handleLogout} className="text-destructive"> <DropdownMenuItem onSelect={handleLogout} className="text-destructive">
<LogOut size={14} /> <LogOut size={14} />
Log out Log out
@@ -216,6 +251,13 @@ export default function Layout({ children, title, action, subheader, wide }: Lay
</main> </main>
{showNewTicket && <NewTicketModal onClose={() => setShowNewTicket(false)} />} {showNewTicket && <NewTicketModal onClose={() => setShowNewTicket(false)} />}
<CommandPalette
open={paletteOpen}
onOpenChange={setPaletteOpen}
onNewTicket={canCreateTicket ? () => setShowNewTicket(true) : undefined}
onShowShortcuts={() => setHelpOpen(true)}
/>
<KeyboardHelp open={helpOpen} onOpenChange={setHelpOpen} />
</div> </div>
); );
} }
+158
View File
@@ -0,0 +1,158 @@
import {
forwardRef,
useEffect,
useImperativeHandle,
useMemo,
useRef,
useState,
} from 'react';
import type { User } from '../types';
interface Props extends Omit<React.TextareaHTMLAttributes<HTMLTextAreaElement>, 'onChange'> {
value: string;
onChange: (value: string) => void;
users: Pick<User, 'id' | 'username' | 'displayName'>[];
/** Called when user hits ⌘/Ctrl+Enter with no menu open. */
onSubmit?: () => void;
}
interface Trigger {
start: number;
query: string;
}
const MENTION_RE = /(^|[^\w-])@([\w-]{0,30})$/;
function detectTrigger(text: string, caret: number): Trigger | null {
const prefix = text.slice(0, caret);
const m = MENTION_RE.exec(prefix);
if (!m) return null;
// position of '@' is caret - matched[2].length - 1
return { start: caret - m[2].length - 1, query: m[2] };
}
const MentionTextarea = forwardRef<HTMLTextAreaElement, Props>(function MentionTextarea(
{ value, onChange, users, onSubmit, onKeyDown, ...rest },
ref,
) {
const innerRef = useRef<HTMLTextAreaElement | null>(null);
useImperativeHandle(ref, () => innerRef.current as HTMLTextAreaElement);
const [trigger, setTrigger] = useState<Trigger | null>(null);
const [cursor, setCursor] = useState(0);
const matches = useMemo(() => {
if (!trigger) return [];
const q = trigger.query.toLowerCase();
const filter = (u: (typeof users)[number]) =>
u.username.toLowerCase().includes(q) || u.displayName.toLowerCase().includes(q);
return (q ? users.filter(filter) : users).slice(0, 6);
}, [trigger, users]);
useEffect(() => {
setCursor(0);
}, [trigger?.query]);
const handleSelect = () => {
const el = innerRef.current;
if (!el) return;
const t = detectTrigger(value, el.selectionStart ?? 0);
setTrigger(t);
};
const insertMention = (username: string) => {
const el = innerRef.current;
if (!el || !trigger) return;
const before = value.slice(0, trigger.start);
const after = value.slice(el.selectionStart ?? trigger.start);
const insertion = `@${username} `;
const next = before + insertion + after;
onChange(next);
setTrigger(null);
// move caret after inserted mention
const newCaret = before.length + insertion.length;
requestAnimationFrame(() => {
if (!innerRef.current) return;
innerRef.current.focus();
innerRef.current.setSelectionRange(newCaret, newCaret);
});
};
const keyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (trigger && matches.length > 0) {
if (e.key === 'ArrowDown') {
e.preventDefault();
setCursor((c) => (c + 1) % matches.length);
return;
}
if (e.key === 'ArrowUp') {
e.preventDefault();
setCursor((c) => (c - 1 + matches.length) % matches.length);
return;
}
if (e.key === 'Enter' || e.key === 'Tab') {
e.preventDefault();
insertMention(matches[cursor].username);
return;
}
if (e.key === 'Escape') {
e.preventDefault();
setTrigger(null);
return;
}
}
if (onSubmit && e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
onSubmit();
return;
}
onKeyDown?.(e);
};
return (
<div className="relative">
<textarea
{...rest}
ref={innerRef}
value={value}
onChange={(e) => {
onChange(e.target.value);
// trigger recalc via select event after React updates
requestAnimationFrame(handleSelect);
}}
onKeyUp={handleSelect}
onClick={handleSelect}
onBlur={() => setTimeout(() => setTrigger(null), 120)}
onKeyDown={keyDown}
/>
{trigger && matches.length > 0 && (
<div
className="absolute left-0 top-full mt-1 z-20 w-56 rounded-md border border-border bg-popover text-popover-foreground shadow-lg overflow-hidden"
role="listbox"
>
{matches.map((u, i) => (
<button
type="button"
key={u.id}
role="option"
aria-selected={i === cursor}
onMouseDown={(e) => {
e.preventDefault();
insertMention(u.username);
}}
onMouseEnter={() => setCursor(i)}
className={`w-full text-left px-3 py-1.5 text-sm flex items-center gap-2 ${
i === cursor ? 'bg-accent text-accent-foreground' : ''
}`}
>
<span className="font-medium">{u.displayName}</span>
<span className="text-xs text-muted-foreground">@{u.username}</span>
</button>
))}
</div>
)}
</div>
);
});
export default MentionTextarea;
+113
View File
@@ -0,0 +1,113 @@
import * as React from 'react';
import { Command as CommandPrimitive } from 'cmdk';
import { Search } from 'lucide-react';
import { cn } from '@/lib/utils';
const Command = React.forwardRef<
React.ElementRef<typeof CommandPrimitive>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive>
>(({ className, ...props }, ref) => (
<CommandPrimitive
ref={ref}
className={cn(
'flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground',
className,
)}
{...props}
/>
));
Command.displayName = CommandPrimitive.displayName;
const CommandInput = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Input>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
>(({ className, ...props }, ref) => (
<div className="flex items-center border-b border-border px-3" cmdk-input-wrapper="">
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
<CommandPrimitive.Input
ref={ref}
className={cn(
'flex h-11 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50',
className,
)}
{...props}
/>
</div>
));
CommandInput.displayName = CommandPrimitive.Input.displayName;
const CommandList = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.List>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
>(({ className, ...props }, ref) => (
<CommandPrimitive.List
ref={ref}
className={cn('max-h-[320px] overflow-y-auto overflow-x-hidden', className)}
{...props}
/>
));
CommandList.displayName = CommandPrimitive.List.displayName;
const CommandEmpty = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Empty>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
>((props, ref) => (
<CommandPrimitive.Empty
ref={ref}
className="py-6 text-center text-sm text-muted-foreground"
{...props}
/>
));
CommandEmpty.displayName = CommandPrimitive.Empty.displayName;
const CommandGroup = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Group>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Group
ref={ref}
className={cn(
'overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground',
className,
)}
{...props}
/>
));
CommandGroup.displayName = CommandPrimitive.Group.displayName;
const CommandSeparator = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Separator
ref={ref}
className={cn('-mx-1 h-px bg-border', className)}
{...props}
/>
));
CommandSeparator.displayName = CommandPrimitive.Separator.displayName;
const CommandItem = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none aria-selected:bg-accent aria-selected:text-accent-foreground data-[disabled='true']:pointer-events-none data-[disabled='true']:opacity-50",
className,
)}
{...props}
/>
));
CommandItem.displayName = CommandPrimitive.Item.displayName;
export {
Command,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandSeparator,
};
+111
View File
@@ -0,0 +1,111 @@
import { useEffect, useRef } from 'react';
export function isTextField(el: EventTarget | null): boolean {
if (!(el instanceof HTMLElement)) return false;
const tag = el.tagName;
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return true;
if (el.isContentEditable) return true;
return false;
}
type Handler = (e: KeyboardEvent) => void;
/**
* Fires when the given key is pressed outside any text field.
* Pass modifiers as part of the key spec, e.g. `mod+k` for Ctrl/Meta+K.
* Single-char keys ignore shift unless explicitly `shift+x`.
*/
export function useShortcut(spec: string | string[], handler: Handler, deps: unknown[] = []) {
const handlerRef = useRef(handler);
handlerRef.current = handler;
useEffect(() => {
const specs = (Array.isArray(spec) ? spec : [spec]).map(parseSpec);
const onKey = (e: KeyboardEvent) => {
if (isTextField(e.target)) {
// Allow mod+key even inside text fields (e.g. ⌘K)
if (!specs.some((s) => s.mod && matches(s, e))) return;
}
for (const s of specs) {
if (matches(s, e)) {
handlerRef.current(e);
return;
}
}
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, deps);
}
interface ParsedSpec {
key: string;
mod: boolean;
shift: boolean;
}
function parseSpec(s: string): ParsedSpec {
const parts = s.toLowerCase().split('+');
const key = parts[parts.length - 1];
return {
key,
mod: parts.includes('mod'),
shift: parts.includes('shift'),
};
}
function matches(s: ParsedSpec, e: KeyboardEvent): boolean {
if (e.key.toLowerCase() !== s.key) return false;
if (s.mod !== (e.metaKey || e.ctrlKey)) return false;
if (s.shift && !e.shiftKey) return false;
return true;
}
/**
* Tracks a two-key "g" prefix and fires the matching handler when the second
* key is pressed within the timeout window. Any other key cancels.
*/
export function useLeaderShortcut(
leader: string,
mapping: Record<string, () => void>,
timeoutMs = 1000,
) {
const state = useRef<{ armed: boolean; timer: number | null }>({ armed: false, timer: null });
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (isTextField(e.target)) return;
if (e.metaKey || e.ctrlKey || e.altKey) {
state.current.armed = false;
return;
}
const key = e.key.toLowerCase();
if (!state.current.armed) {
if (key === leader) {
state.current.armed = true;
if (state.current.timer) window.clearTimeout(state.current.timer);
state.current.timer = window.setTimeout(() => {
state.current.armed = false;
}, timeoutMs);
e.preventDefault();
}
return;
}
// armed: expect second key
state.current.armed = false;
if (state.current.timer) {
window.clearTimeout(state.current.timer);
state.current.timer = null;
}
const fn = mapping[key];
if (fn) {
e.preventDefault();
fn();
}
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [leader, timeoutMs, JSON.stringify(Object.keys(mapping))]);
}
+40
View File
@@ -0,0 +1,40 @@
import { describe, it, expect } from 'vitest';
import { injectMentionLinks } from './mentions';
const users = [
{ id: 'u1', username: 'alice', displayName: 'Alice' },
{ id: 'u2', username: 'bob-smith', displayName: 'Bob Smith' },
];
describe('injectMentionLinks', () => {
it('rewrites known mentions to markdown links with assignee filter', () => {
const out = injectMentionLinks('hey @alice check this', users);
expect(out).toBe('hey [@alice](/tickets?assigneeId=u1) check this');
});
it('leaves unknown mentions alone', () => {
expect(injectMentionLinks('ping @ghost', users)).toBe('ping @ghost');
});
it('ignores email-like strings', () => {
expect(injectMentionLinks('mail alice@example.com', users)).toBe(
'mail alice@example.com',
);
});
it('matches at start of string', () => {
expect(injectMentionLinks('@alice hi', users)).toBe(
'[@alice](/tickets?assigneeId=u1) hi',
);
});
it('handles hyphenated usernames', () => {
expect(injectMentionLinks('cc @bob-smith', users)).toBe(
'cc [@bob-smith](/tickets?assigneeId=u2)',
);
});
it('skips processing when no @ in input', () => {
expect(injectMentionLinks('no mentions here', users)).toBe('no mentions here');
});
});
+17
View File
@@ -0,0 +1,17 @@
import type { User } from '../types';
type UserLite = Pick<User, 'id' | 'username' | 'displayName'>;
/**
* Rewrites `@username` tokens (outside word chars) into markdown links pointing
* to that user's ticket queue. Unknown usernames are left as plain text.
*/
export function injectMentionLinks(body: string, users: UserLite[]): string {
if (!body.includes('@') || users.length === 0) return body;
const byName = new Map(users.map((u) => [u.username.toLowerCase(), u]));
return body.replace(/(^|[^\w-])@([a-zA-Z0-9_-]+)/g, (_m, pre: string, name: string) => {
const user = byName.get(name.toLowerCase());
if (!user) return `${pre}@${name}`;
return `${pre}[@${user.username}](/tickets?assigneeId=${user.id})`;
});
}
+8
View File
@@ -17,3 +17,11 @@ createRoot(document.getElementById('root')!).render(
</ThemeProvider> </ThemeProvider>
</StrictMode>, </StrictMode>,
); );
if ('serviceWorker' in navigator && import.meta.env.PROD) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js').catch(() => {
// Installability is a progressive enhancement; ignore failures
});
});
}
+1 -1
View File
@@ -85,7 +85,7 @@ export default function NewTicketModal({ onClose }: NewTicketModalProps) {
{errors.overview && <p className={errorClass}>{errors.overview.message}</p>} {errors.overview && <p className={errorClass}>{errors.overview.message}</p>}
</div> </div>
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div> <div>
<label className={labelClass}>Severity</label> <label className={labelClass}>Severity</label>
<select <select
+1 -1
View File
@@ -59,7 +59,7 @@ export default function Settings() {
{/* Profile */} {/* Profile */}
<section className="rounded-md border border-border p-4"> <section className="rounded-md border border-border p-4">
<h2 className="text-sm font-semibold mb-3">Profile</h2> <h2 className="text-sm font-semibold mb-3">Profile</h2>
<div className="grid grid-cols-2 gap-3 text-sm"> <div className="grid grid-cols-1 sm:grid-cols-2 gap-3 text-sm">
<Field label="Display name" value={user?.displayName ?? ''} /> <Field label="Display name" value={user?.displayName ?? ''} />
<Field label="Username" value={user?.username ?? ''} mono /> <Field label="Username" value={user?.username ?? ''} mono />
<Field label="Email" value={user?.email ?? ''} /> <Field label="Email" value={user?.email ?? ''} />
+43 -18
View File
@@ -1,5 +1,6 @@
import { useEffect, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { useParams, useNavigate } from 'react-router-dom'; import { useParams, useNavigate } from 'react-router-dom';
import { useShortcut } from '../hooks/useShortcuts';
import { format, formatDistanceToNow } from 'date-fns'; import { format, formatDistanceToNow } from 'date-fns';
import ReactMarkdown from 'react-markdown'; import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm'; import remarkGfm from 'remark-gfm';
@@ -23,6 +24,8 @@ import SeverityBadge from '../components/SeverityBadge';
import StatusBadge from '../components/StatusBadge'; import StatusBadge from '../components/StatusBadge';
import CTISelect from '../components/CTISelect'; import CTISelect from '../components/CTISelect';
import Avatar from '../components/Avatar'; import Avatar from '../components/Avatar';
import MentionTextarea from '../components/MentionTextarea';
import { injectMentionLinks } from '../lib/mentions';
import { TicketStatus } from '../types'; import { TicketStatus } from '../types';
import { useAuth } from '../contexts/AuthContext'; import { useAuth } from '../contexts/AuthContext';
import { import {
@@ -99,6 +102,7 @@ export default function TicketDetail() {
const [expandedLogs, setExpandedLogs] = useState<Set<string>>(new Set()); const [expandedLogs, setExpandedLogs] = useState<Set<string>>(new Set());
const [deleteOpen, setDeleteOpen] = useState(false); const [deleteOpen, setDeleteOpen] = useState(false);
const commentTextareaRef = useRef<HTMLTextAreaElement | null>(null);
const [editForm, setEditForm] = useState({ title: '', overview: '' }); const [editForm, setEditForm] = useState({ title: '', overview: '' });
const [pendingCTI, setPendingCTI] = useState({ categoryId: '', typeId: '', itemId: '' }); const [pendingCTI, setPendingCTI] = useState({ categoryId: '', typeId: '', itemId: '' });
const [statusOpen, setStatusOpen] = useState(false); const [statusOpen, setStatusOpen] = useState(false);
@@ -195,6 +199,20 @@ export default function TicketDetail() {
await deleteCommentMutation.mutateAsync(commentId); await deleteCommentMutation.mutateAsync(commentId);
}; };
useShortcut('e', (e) => {
if (!ticket || editing) return;
e.preventDefault();
startEdit();
}, [ticket, editing]);
useShortcut('r', (e) => {
if (!ticket) return;
e.preventDefault();
setTab('comments');
setPreview(false);
setTimeout(() => commentTextareaRef.current?.focus(), 40);
}, [ticket]);
const toggleLog = (logId: string) => { const toggleLog = (logId: string) => {
setExpandedLogs((prev) => { setExpandedLogs((prev) => {
const next = new Set(prev); const next = new Set(prev);
@@ -245,7 +263,7 @@ export default function TicketDetail() {
All tickets All tickets
</button> </button>
<div className="flex gap-6 items-start"> <div className="flex flex-col-reverse md:flex-row gap-6 md:items-start">
{/* ── Main content ── */} {/* ── Main content ── */}
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
{/* Title card */} {/* Title card */}
@@ -386,7 +404,9 @@ export default function TicketDetail() {
)} )}
</div> </div>
<div className="px-4 py-3 prose prose-sm prose-invert text-gray-300 text-sm max-w-none"> <div className="px-4 py-3 prose prose-sm prose-invert text-gray-300 text-sm max-w-none">
<ReactMarkdown remarkPlugins={[remarkGfm]}>{comment.body}</ReactMarkdown> <ReactMarkdown remarkPlugins={[remarkGfm]}>
{injectMentionLinks(comment.body, users)}
</ReactMarkdown>
</div> </div>
</div> </div>
</div> </div>
@@ -418,25 +438,30 @@ export default function TicketDetail() {
{preview ? ( {preview ? (
<div className="prose prose-sm prose-invert text-gray-300 min-h-[80px] mb-3 px-1 max-w-none"> <div className="prose prose-sm prose-invert text-gray-300 min-h-[80px] mb-3 px-1 max-w-none">
{commentBody.trim() ? ( {commentBody.trim() ? (
<ReactMarkdown remarkPlugins={[remarkGfm]}>{commentBody}</ReactMarkdown> <ReactMarkdown remarkPlugins={[remarkGfm]}>
{injectMentionLinks(commentBody, users)}
</ReactMarkdown>
) : ( ) : (
<span className="text-gray-600 italic">Nothing to preview</span> <span className="text-gray-600 italic">Nothing to preview</span>
)} )}
</div> </div>
) : ( ) : (
<textarea <div className="mb-3">
value={commentBody} <MentionTextarea
onChange={(e) => setCommentBody(e.target.value)} ref={commentTextareaRef}
placeholder="Leave a comment… Markdown supported" value={commentBody}
rows={4} onChange={setCommentBody}
className="w-full bg-transparent text-gray-100 placeholder-gray-600 text-sm focus:outline-none resize-none mb-3" users={users}
onKeyDown={(e) => { onSubmit={() =>
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) { submitComment({
e.preventDefault(); preventDefault: () => {},
submitComment(e as unknown as React.FormEvent); } as unknown as React.FormEvent)
} }
}} placeholder="Leave a comment… Markdown & @mentions"
/> rows={4}
className="w-full bg-transparent text-gray-100 placeholder-gray-600 text-sm focus:outline-none resize-none"
/>
</div>
)} )}
<div className="flex justify-between items-center border-t border-gray-700 pt-2"> <div className="flex justify-between items-center border-t border-gray-700 pt-2">
<span className="text-xs text-gray-600">Markdown · Ctrl+Enter</span> <span className="text-xs text-gray-600">Markdown · Ctrl+Enter</span>
@@ -513,7 +538,7 @@ export default function TicketDetail() {
{isComment ? ( {isComment ? (
<div className="prose text-sm text-gray-300"> <div className="prose text-sm text-gray-300">
<ReactMarkdown remarkPlugins={[remarkGfm]}> <ReactMarkdown remarkPlugins={[remarkGfm]}>
{log.detail!} {injectMentionLinks(log.detail!, users)}
</ReactMarkdown> </ReactMarkdown>
</div> </div>
) : ( ) : (
@@ -533,7 +558,7 @@ export default function TicketDetail() {
</div> </div>
{/* ── Sidebar ── */} {/* ── Sidebar ── */}
<div className="w-64 flex-shrink-0 sticky top-0 space-y-3"> <div className="w-full md:w-64 flex-shrink-0 md:sticky md:top-0 space-y-3">
{/* Ticket Summary */} {/* Ticket Summary */}
<div className="bg-gray-900 border border-gray-800 rounded-xl divide-y divide-gray-800"> <div className="bg-gray-900 border border-gray-800 rounded-xl divide-y divide-gray-800">
<div className="px-4 py-3"> <div className="px-4 py-3">
+39 -3
View File
@@ -1,5 +1,6 @@
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { Link, useSearchParams } from 'react-router-dom'; import { Link, useNavigate, useSearchParams } from 'react-router-dom';
import { useShortcut } from '../hooks/useShortcuts';
import { ChevronLeft, ChevronRight, Trash2, Save } from 'lucide-react'; import { ChevronLeft, ChevronRight, Trash2, Save } from 'lucide-react';
import { formatDistanceToNow } from 'date-fns'; import { formatDistanceToNow } from 'date-fns';
import { toast } from 'sonner'; import { toast } from 'sonner';
@@ -69,6 +70,7 @@ function sevColor(severity: number) {
export default function Tickets() { export default function Tickets() {
const [params, setParams] = useSearchParams(); const [params, setParams] = useSearchParams();
const navigate = useNavigate();
const { user: authUser } = useAuth(); const { user: authUser } = useAuth();
const { data: users = [] } = useUsers(); const { data: users = [] } = useUsers();
@@ -83,6 +85,7 @@ export default function Tickets() {
const [searchInput, setSearchInput] = useState(search); const [searchInput, setSearchInput] = useState(search);
const [selected, setSelected] = useState<Set<string>>(new Set()); const [selected, setSelected] = useState<Set<string>>(new Set());
const [cursor, setCursor] = useState<number>(-1);
const [saveOpen, setSaveOpen] = useState(false); const [saveOpen, setSaveOpen] = useState(false);
const [newViewName, setNewViewName] = useState(''); const [newViewName, setNewViewName] = useState('');
const [confirmBulk, setConfirmBulk] = useState< const [confirmBulk, setConfirmBulk] = useState<
@@ -213,6 +216,35 @@ export default function Tickets() {
const agentUsers = users.filter((u) => u.role !== 'SERVICE'); const agentUsers = users.filter((u) => u.role !== 'SERVICE');
// Keyboard navigation
useEffect(() => {
setCursor((c) => (c >= tickets.length ? tickets.length - 1 : c));
}, [tickets.length]);
useShortcut('j', (e) => {
if (tickets.length === 0) return;
e.preventDefault();
setCursor((c) => Math.min(tickets.length - 1, c < 0 ? 0 : c + 1));
}, [tickets.length]);
useShortcut('k', (e) => {
if (tickets.length === 0) return;
e.preventDefault();
setCursor((c) => Math.max(0, c < 0 ? 0 : c - 1));
}, [tickets.length]);
useShortcut('enter', (e) => {
if (cursor < 0 || cursor >= tickets.length) return;
e.preventDefault();
navigate(`/${tickets[cursor].displayId}`);
}, [cursor, tickets]);
useShortcut('x', (e) => {
if (cursor < 0 || cursor >= tickets.length) return;
e.preventDefault();
toggleOne(tickets[cursor].id);
}, [cursor, tickets]);
return ( return (
<Layout wide> <Layout wide>
{/* Status tabs */} {/* Status tabs */}
@@ -453,10 +485,14 @@ export default function Tickets() {
</div> </div>
<ul className="divide-y divide-border"> <ul className="divide-y divide-border">
{tickets.map((ticket) => ( {tickets.map((ticket, idx) => (
<li <li
key={ticket.id} key={ticket.id}
className="flex items-center gap-3 px-4 py-3 hover:bg-accent/30 transition-colors" className={`flex items-center gap-3 px-4 py-3 transition-colors ${
idx === cursor
? 'bg-accent/50 ring-1 ring-inset ring-primary'
: 'hover:bg-accent/30'
}`}
> >
<input <input
type="checkbox" type="checkbox"
+1 -1
View File
@@ -137,7 +137,7 @@ export default function AdminCTI() {
return ( return (
<Layout title="CTI Configuration"> <Layout title="CTI Configuration">
<div className="grid grid-cols-3 gap-4 h-[calc(100vh-10rem)]"> <div className="grid grid-cols-1 md:grid-cols-3 gap-4 md:h-[calc(100vh-10rem)]">
{/* Categories */} {/* Categories */}
<div className={panelClass}> <div className={panelClass}>
<div className={panelHeaderClass}> <div className={panelHeaderClass}>
+2 -2
View File
@@ -193,8 +193,8 @@ export default function AdminUsers() {
</button> </button>
} }
> >
<div className="bg-gray-900 border border-gray-800 rounded-xl overflow-hidden"> <div className="bg-gray-900 border border-gray-800 rounded-xl overflow-x-auto">
<table className="w-full text-sm"> <table className="w-full text-sm min-w-[640px]">
<thead className="border-b border-gray-800"> <thead className="border-b border-gray-800">
<tr> <tr>
<th className="text-left px-5 py-3 text-xs font-semibold text-gray-500 uppercase tracking-wide"> <th className="text-left px-5 py-3 text-xs font-semibold text-gray-500 uppercase tracking-wide">
+2 -2
View File
@@ -116,8 +116,8 @@ export default function AdminWebhooks() {
No webhooks configured. Add one to push events to n8n, Slack, or any HTTP receiver. No webhooks configured. Add one to push events to n8n, Slack, or any HTTP receiver.
</div> </div>
) : ( ) : (
<div className="rounded-md border border-border overflow-hidden"> <div className="rounded-md border border-border overflow-x-auto">
<table className="w-full text-sm"> <table className="w-full text-sm min-w-[640px]">
<thead className="border-b border-border bg-card text-xs uppercase text-muted-foreground"> <thead className="border-b border-border bg-card text-xs uppercase text-muted-foreground">
<tr> <tr>
<th className="text-left px-4 py-2">Name</th> <th className="text-left px-4 py-2">Name</th>
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />