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:
@@ -3,6 +3,10 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<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>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -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 |
@@ -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" }
|
||||
]
|
||||
}
|
||||
@@ -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;
|
||||
});
|
||||
}),
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -9,12 +9,16 @@ import {
|
||||
Menu,
|
||||
X,
|
||||
SlidersHorizontal,
|
||||
Keyboard,
|
||||
} from 'lucide-react';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import NewTicketModal from '../pages/NewTicket';
|
||||
import NotificationsBell from './NotificationsBell';
|
||||
import GlobalSearch from './GlobalSearch';
|
||||
import Avatar from './Avatar';
|
||||
import CommandPalette from './CommandPalette';
|
||||
import KeyboardHelp from './KeyboardHelp';
|
||||
import { useShortcut, useLeaderShortcut } from '../hooks/useShortcuts';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -39,6 +43,8 @@ export default function Layout({ children, title, action, subheader, wide }: Lay
|
||||
const navigate = useNavigate();
|
||||
const [showNewTicket, setShowNewTicket] = useState(false);
|
||||
const [mobileOpen, setMobileOpen] = useState(false);
|
||||
const [paletteOpen, setPaletteOpen] = useState(false);
|
||||
const [helpOpen, setHelpOpen] = useState(false);
|
||||
|
||||
const canCreateTicket = user?.role !== 'USER';
|
||||
const isAdmin = user?.role === 'ADMIN';
|
||||
@@ -48,6 +54,31 @@ export default function Layout({ children, title, action, subheader, wide }: Lay
|
||||
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 = (
|
||||
<>
|
||||
<NavLink
|
||||
@@ -156,6 +187,10 @@ export default function Layout({ children, title, action, subheader, wide }: Lay
|
||||
</>
|
||||
)}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onSelect={() => setHelpOpen(true)}>
|
||||
<Keyboard size={14} />
|
||||
Keyboard shortcuts
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={handleLogout} className="text-destructive">
|
||||
<LogOut size={14} />
|
||||
Log out
|
||||
@@ -216,6 +251,13 @@ export default function Layout({ children, title, action, subheader, wide }: Lay
|
||||
</main>
|
||||
|
||||
{showNewTicket && <NewTicketModal onClose={() => setShowNewTicket(false)} />}
|
||||
<CommandPalette
|
||||
open={paletteOpen}
|
||||
onOpenChange={setPaletteOpen}
|
||||
onNewTicket={canCreateTicket ? () => setShowNewTicket(true) : undefined}
|
||||
onShowShortcuts={() => setHelpOpen(true)}
|
||||
/>
|
||||
<KeyboardHelp open={helpOpen} onOpenChange={setHelpOpen} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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))]);
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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})`;
|
||||
});
|
||||
}
|
||||
@@ -17,3 +17,11 @@ createRoot(document.getElementById('root')!).render(
|
||||
</ThemeProvider>
|
||||
</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
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -85,7 +85,7 @@ export default function NewTicketModal({ onClose }: NewTicketModalProps) {
|
||||
{errors.overview && <p className={errorClass}>{errors.overview.message}</p>}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className={labelClass}>Severity</label>
|
||||
<select
|
||||
|
||||
@@ -59,7 +59,7 @@ export default function Settings() {
|
||||
{/* 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">
|
||||
<div className="grid grid-cols-1 sm: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 ?? ''} />
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { useShortcut } from '../hooks/useShortcuts';
|
||||
import { format, formatDistanceToNow } from 'date-fns';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
@@ -23,6 +24,8 @@ import SeverityBadge from '../components/SeverityBadge';
|
||||
import StatusBadge from '../components/StatusBadge';
|
||||
import CTISelect from '../components/CTISelect';
|
||||
import Avatar from '../components/Avatar';
|
||||
import MentionTextarea from '../components/MentionTextarea';
|
||||
import { injectMentionLinks } from '../lib/mentions';
|
||||
import { TicketStatus } from '../types';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import {
|
||||
@@ -99,6 +102,7 @@ export default function TicketDetail() {
|
||||
const [expandedLogs, setExpandedLogs] = useState<Set<string>>(new Set());
|
||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||
|
||||
const commentTextareaRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
const [editForm, setEditForm] = useState({ title: '', overview: '' });
|
||||
const [pendingCTI, setPendingCTI] = useState({ categoryId: '', typeId: '', itemId: '' });
|
||||
const [statusOpen, setStatusOpen] = useState(false);
|
||||
@@ -195,6 +199,20 @@ export default function TicketDetail() {
|
||||
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) => {
|
||||
setExpandedLogs((prev) => {
|
||||
const next = new Set(prev);
|
||||
@@ -245,7 +263,7 @@ export default function TicketDetail() {
|
||||
All tickets
|
||||
</button>
|
||||
|
||||
<div className="flex gap-6 items-start">
|
||||
<div className="flex flex-col-reverse md:flex-row gap-6 md:items-start">
|
||||
{/* ── Main content ── */}
|
||||
<div className="flex-1 min-w-0">
|
||||
{/* Title card */}
|
||||
@@ -386,7 +404,9 @@ export default function TicketDetail() {
|
||||
)}
|
||||
</div>
|
||||
<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>
|
||||
@@ -418,25 +438,30 @@ export default function TicketDetail() {
|
||||
{preview ? (
|
||||
<div className="prose prose-sm prose-invert text-gray-300 min-h-[80px] mb-3 px-1 max-w-none">
|
||||
{commentBody.trim() ? (
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{commentBody}</ReactMarkdown>
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>
|
||||
{injectMentionLinks(commentBody, users)}
|
||||
</ReactMarkdown>
|
||||
) : (
|
||||
<span className="text-gray-600 italic">Nothing to preview</span>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<textarea
|
||||
<div className="mb-3">
|
||||
<MentionTextarea
|
||||
ref={commentTextareaRef}
|
||||
value={commentBody}
|
||||
onChange={(e) => setCommentBody(e.target.value)}
|
||||
placeholder="Leave a comment… Markdown supported"
|
||||
rows={4}
|
||||
className="w-full bg-transparent text-gray-100 placeholder-gray-600 text-sm focus:outline-none resize-none mb-3"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
|
||||
e.preventDefault();
|
||||
submitComment(e as unknown as React.FormEvent);
|
||||
onChange={setCommentBody}
|
||||
users={users}
|
||||
onSubmit={() =>
|
||||
submitComment({
|
||||
preventDefault: () => {},
|
||||
} 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">
|
||||
<span className="text-xs text-gray-600">Markdown · Ctrl+Enter</span>
|
||||
@@ -513,7 +538,7 @@ export default function TicketDetail() {
|
||||
{isComment ? (
|
||||
<div className="prose text-sm text-gray-300">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>
|
||||
{log.detail!}
|
||||
{injectMentionLinks(log.detail!, users)}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
) : (
|
||||
@@ -533,7 +558,7 @@ export default function TicketDetail() {
|
||||
</div>
|
||||
|
||||
{/* ── 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 */}
|
||||
<div className="bg-gray-900 border border-gray-800 rounded-xl divide-y divide-gray-800">
|
||||
<div className="px-4 py-3">
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
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 { formatDistanceToNow } from 'date-fns';
|
||||
import { toast } from 'sonner';
|
||||
@@ -69,6 +70,7 @@ function sevColor(severity: number) {
|
||||
|
||||
export default function Tickets() {
|
||||
const [params, setParams] = useSearchParams();
|
||||
const navigate = useNavigate();
|
||||
const { user: authUser } = useAuth();
|
||||
const { data: users = [] } = useUsers();
|
||||
|
||||
@@ -83,6 +85,7 @@ export default function Tickets() {
|
||||
|
||||
const [searchInput, setSearchInput] = useState(search);
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
const [cursor, setCursor] = useState<number>(-1);
|
||||
const [saveOpen, setSaveOpen] = useState(false);
|
||||
const [newViewName, setNewViewName] = useState('');
|
||||
const [confirmBulk, setConfirmBulk] = useState<
|
||||
@@ -213,6 +216,35 @@ export default function Tickets() {
|
||||
|
||||
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 (
|
||||
<Layout wide>
|
||||
{/* Status tabs */}
|
||||
@@ -453,10 +485,14 @@ export default function Tickets() {
|
||||
</div>
|
||||
|
||||
<ul className="divide-y divide-border">
|
||||
{tickets.map((ticket) => (
|
||||
{tickets.map((ticket, idx) => (
|
||||
<li
|
||||
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
|
||||
type="checkbox"
|
||||
|
||||
@@ -137,7 +137,7 @@ export default function AdminCTI() {
|
||||
|
||||
return (
|
||||
<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 */}
|
||||
<div className={panelClass}>
|
||||
<div className={panelHeaderClass}>
|
||||
|
||||
@@ -193,8 +193,8 @@ export default function AdminUsers() {
|
||||
</button>
|
||||
}
|
||||
>
|
||||
<div className="bg-gray-900 border border-gray-800 rounded-xl overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<div className="bg-gray-900 border border-gray-800 rounded-xl overflow-x-auto">
|
||||
<table className="w-full text-sm min-w-[640px]">
|
||||
<thead className="border-b border-gray-800">
|
||||
<tr>
|
||||
<th className="text-left px-5 py-3 text-xs font-semibold text-gray-500 uppercase tracking-wide">
|
||||
|
||||
@@ -116,8 +116,8 @@ export default function AdminWebhooks() {
|
||||
No webhooks configured. Add one to push events to n8n, Slack, or any HTTP receiver.
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-md border border-border overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<div className="rounded-md border border-border overflow-x-auto">
|
||||
<table className="w-full text-sm min-w-[640px]">
|
||||
<thead className="border-b border-border bg-card text-xs uppercase text-muted-foreground">
|
||||
<tr>
|
||||
<th className="text-left px-4 py-2">Name</th>
|
||||
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
Reference in New Issue
Block a user