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
+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))]);
}