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:
@@ -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
|
||||
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);
|
||||
<div className="mb-3">
|
||||
<MentionTextarea
|
||||
ref={commentTextareaRef}
|
||||
value={commentBody}
|
||||
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">
|
||||
|
||||
Reference in New Issue
Block a user