diff --git a/client/index.html b/client/index.html index f4acf26..ced1668 100644 --- a/client/index.html +++ b/client/index.html @@ -3,6 +3,10 @@ + + + + Ticketing System diff --git a/client/public/icon.svg b/client/public/icon.svg new file mode 100644 index 0000000..f83b565 --- /dev/null +++ b/client/public/icon.svg @@ -0,0 +1,4 @@ + + + + diff --git a/client/public/manifest.webmanifest b/client/public/manifest.webmanifest new file mode 100644 index 0000000..866d1da --- /dev/null +++ b/client/public/manifest.webmanifest @@ -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" } + ] +} diff --git a/client/public/sw.js b/client/public/sw.js new file mode 100644 index 0000000..320eefd --- /dev/null +++ b/client/public/sw.js @@ -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; + }); + }), + ); + } +}); diff --git a/client/src/components/CommandPalette.tsx b/client/src/components/CommandPalette.tsx new file mode 100644 index 0000000..0402093 --- /dev/null +++ b/client/src/components/CommandPalette.tsx @@ -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([]); + 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 ( + + + + + + + {query.trim().length < 2 ? 'Keep typing to search…' : 'No results.'} + + + {tickets.length > 0 && ( + + {tickets.map((t) => ( + go(`/${t.displayId}`)} + > + + + {t.displayId} + + {t.title} + + ))} + + )} + + {userMatches.length > 0 && ( + <> + {tickets.length > 0 && } + + {userMatches.map((u) => ( + + go(`/tickets?assigneeId=${encodeURIComponent(u.id)}`) + } + > + + {u.displayName} + + tickets assigned + + + ))} + + + )} + + {(tickets.length > 0 || userMatches.length > 0) && } + + + {onNewTicket && ( + run(() => onNewTicket())} + > + + New ticket + c + + )} + go('/dashboard')}> + + Go to dashboard + g d + + go('/tickets')}> + + Go to tickets + g t + + go('/my-tickets')}> + + My tickets + g m + + go('/notifications')}> + + Notifications + g n + + go('/settings')}> + + Settings + + + + {isAdmin && ( + <> + + + go('/admin/users')}> + + Manage users + + go('/admin/cti')}> + + Manage CTI + + go('/admin/webhooks')}> + + Manage webhooks + + + + )} + + + + {onShowShortcuts && ( + run(() => onShowShortcuts())} + > + + Keyboard shortcuts + ? + + )} + run(logout)}> + + Log out + + + + + + + ); +} diff --git a/client/src/components/KeyboardHelp.tsx b/client/src/components/KeyboardHelp.tsx new file mode 100644 index 0000000..95cb066 --- /dev/null +++ b/client/src/components/KeyboardHelp.tsx @@ -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 ( + + + + Keyboard shortcuts + +
+ {GROUPS.map((g) => ( +
+

+ {g.title} +

+
+ {g.items.map(([keys, label]) => ( +
+
{label}
+
+ + {keys} + +
+
+ ))} +
+
+ ))} +
+
+
+ ); +} diff --git a/client/src/components/Layout.tsx b/client/src/components/Layout.tsx index 48a616e..e612365 100644 --- a/client/src/components/Layout.tsx +++ b/client/src/components/Layout.tsx @@ -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 = ( <> )} + setHelpOpen(true)}> + + Keyboard shortcuts + Log out @@ -216,6 +251,13 @@ export default function Layout({ children, title, action, subheader, wide }: Lay {showNewTicket && setShowNewTicket(false)} />} + setShowNewTicket(true) : undefined} + onShowShortcuts={() => setHelpOpen(true)} + /> + ); } diff --git a/client/src/components/MentionTextarea.tsx b/client/src/components/MentionTextarea.tsx new file mode 100644 index 0000000..c79b709 --- /dev/null +++ b/client/src/components/MentionTextarea.tsx @@ -0,0 +1,158 @@ +import { + forwardRef, + useEffect, + useImperativeHandle, + useMemo, + useRef, + useState, +} from 'react'; +import type { User } from '../types'; + +interface Props extends Omit, 'onChange'> { + value: string; + onChange: (value: string) => void; + users: Pick[]; + /** 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(function MentionTextarea( + { value, onChange, users, onSubmit, onKeyDown, ...rest }, + ref, +) { + const innerRef = useRef(null); + useImperativeHandle(ref, () => innerRef.current as HTMLTextAreaElement); + + const [trigger, setTrigger] = useState(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) => { + 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 ( +
+