diff --git a/webui/src/components/dialog/dialog.module.css b/webui/src/components/dialog/dialog.module.css new file mode 100644 index 00000000..ba31f0ef --- /dev/null +++ b/webui/src/components/dialog/dialog.module.css @@ -0,0 +1,110 @@ +.backdrop { + position: fixed; + inset: 0; + z-index: 10999; + background: rgba(5, 9, 16, 0.86); + backdrop-filter: blur(18px); +} + +.viewport { + position: fixed; + inset: 0; + z-index: 11000; + display: grid; + place-items: center; + padding: 18px; +} + +.popup { + width: min(1060px, 100%); + max-height: min(90vh, 980px); + overflow: hidden; + display: flex; + flex-direction: column; + border-radius: 28px; + border: 1px solid rgba(255, 255, 255, 0.1); + background: linear-gradient(180deg, rgba(16, 22, 34, 0.98), rgba(12, 16, 25, 0.98)); + box-shadow: 0 28px 90px rgba(0, 0, 0, 0.45); +} + +.header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 18px; + padding: 20px 22px; + border-bottom: 1px solid rgba(255, 255, 255, 0.08); +} + +.headerContent { + min-width: 0; +} + +.headerMeta { + margin-top: 6px; +} + +.title { + margin: 0; + font-size: 1.4rem; +} + +.close { + flex-shrink: 0; + width: 40px; + height: 40px; + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 14px; + background: rgba(255, 255, 255, 0.05); + color: #fff; + font-size: 16px; + cursor: pointer; +} + +.body { + flex: 1 1 auto; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 18px; + padding: 22px; +} + +.footer { + display: flex; + align-items: center; + justify-content: flex-end; + flex-wrap: wrap; + gap: 8px; + padding: 16px 22px; + border-top: 1px solid rgba(255, 255, 255, 0.06); +} + +@media (max-width: 640px) { + .viewport { + padding: 12px; + } + + .popup { + max-height: calc(100vh - 24px); + border-radius: 22px; + } + + .header { + padding: 18px; + } + + .body { + padding: 18px; + } + + .footer { + flex-direction: column; + align-items: stretch; + padding: 0 18px 18px; + } + + .footer > button { + width: 100%; + } +} diff --git a/webui/src/components/dialog/dialog.tsx b/webui/src/components/dialog/dialog.tsx new file mode 100644 index 00000000..3fd00656 --- /dev/null +++ b/webui/src/components/dialog/dialog.tsx @@ -0,0 +1,59 @@ +import type { ReactNode } from 'react'; + +import { Dialog } from '@base-ui/react/dialog'; +import clsx from 'clsx'; + +import styles from './dialog.module.css'; + +export function DialogFrame({ + children, + className, + onOpenChange, + open, +}: { + children: ReactNode; + className?: string; + onOpenChange: (open: boolean) => void; + open: boolean; +}) { + return ( + + + + + {children} + + + + ); +} + +export function DialogHeader({ + children, + closeLabel = 'Close dialog', + title, +}: { + children?: ReactNode; + closeLabel?: string; + title: ReactNode; +}) { + return ( +
+
+ {title} + {children ?
{children}
: null} +
+ + × + +
+ ); +} + +export function DialogBody({ children }: { children: ReactNode }) { + return
{children}
; +} + +export function DialogFooter({ children }: { children: ReactNode }) { + return
{children}
; +} diff --git a/webui/src/components/dialog/index.ts b/webui/src/components/dialog/index.ts new file mode 100644 index 00000000..0a7b35a7 --- /dev/null +++ b/webui/src/components/dialog/index.ts @@ -0,0 +1 @@ +export { DialogBody, DialogFooter, DialogFrame, DialogHeader } from './dialog'; diff --git a/webui/src/routes/issues/-route.test.tsx b/webui/src/routes/issues/-route.test.tsx index 26d3cc93..df204395 100644 --- a/webui/src/routes/issues/-route.test.tsx +++ b/webui/src/routes/issues/-route.test.tsx @@ -192,21 +192,15 @@ describe('issues route', () => { await waitFor(() => expect(history.location.search).toBe('?status=open&category=all')); }); - it('traps focus inside the detail modal', async () => { + it('focuses the detail modal close button on open', async () => { renderIssuesRoute(); fireEvent.click(await screen.findByTestId('issue-card-7')); const closeButton = await screen.findByRole('button', { name: /close issue detail/i, }); - const deleteButton = await screen.findByRole('button', { name: /delete/i }); - deleteButton.focus(); - expect(deleteButton).toHaveFocus(); - - fireEvent.keyDown(document, { key: 'Tab' }); - - expect(closeButton).toHaveFocus(); + await waitFor(() => expect(closeButton).toHaveFocus()); }); it('invokes the shared workflow adapter for admin downloads', async () => { diff --git a/webui/src/routes/issues/-ui/issue-detail-modal.module.css b/webui/src/routes/issues/-ui/issue-detail-modal.module.css index b4210d4d..d2f836ff 100644 --- a/webui/src/routes/issues/-ui/issue-detail-modal.module.css +++ b/webui/src/routes/issues/-ui/issue-detail-modal.module.css @@ -390,102 +390,18 @@ box-shadow: 0 0 6px rgba(255, 77, 77, 0.4); } -.modalOverlay { - position: fixed; - inset: 0; - z-index: 11000; - background: rgba(5, 9, 16, 0.76); - backdrop-filter: blur(18px); - display: grid; - place-items: center; - padding: 18px; -} - -.modal { - width: min(1060px, 100%); - max-height: min(90vh, 980px); - overflow: auto; - border-radius: 28px; - border: 1px solid rgba(255, 255, 255, 0.1); - background: linear-gradient(180deg, rgba(16, 22, 34, 0.98), rgba(12, 16, 25, 0.98)); - box-shadow: 0 28px 90px rgba(0, 0, 0, 0.45); -} - -.issueDetailModal { +.issueDetailDialog { max-width: 750px; width: 95vw; max-height: 85vh; - display: flex; - flex-direction: column; - overflow: hidden; - border-radius: 16px; } -.issueDetailModal .modalHeader { - padding: 20px 24px; - gap: 16px; -} - -.issueDetailModal .modalHeaderTitle { - margin: 0; - font-size: 18px; -} - -.issueDetailModal .modalClose { - width: 32px; - height: 32px; - border: none; - border-radius: 50%; - font-size: 16px; -} - -.issueDetailModal .modalBody { - flex: 1 1 auto; - overflow-y: auto; - max-height: calc(85vh - 120px); - padding: 24px; - display: flex; - flex-direction: column; - gap: 16px; -} - -.issueDetailModal .modalFooter { - padding: 16px 24px; - border-top: 1px solid rgba(255, 255, 255, 0.06); - gap: 8px; -} - -.reportIssueModal { +.reportIssueDialog { max-width: 680px; width: 95vw; } -.modalHeader { - display: flex; - align-items: flex-start; - justify-content: space-between; - gap: 18px; - padding: 20px 22px; - border-bottom: 1px solid rgba(255, 255, 255, 0.08); -} - -.modalHeaderTitle { - margin: 0 0 8px; - font-size: 1.4rem; -} - -.modalClose { - border: 1px solid rgba(255, 255, 255, 0.08); - background: rgba(255, 255, 255, 0.05); - color: #fff; - border-radius: 14px; - width: 40px; - height: 40px; - cursor: pointer; -} - -.modalBody { - padding: 22px; +.reportIssueForm { display: grid; gap: 18px; } @@ -1011,14 +927,6 @@ font-size: 13px; } -.modalFooter { - display: flex; - justify-content: flex-end; - flex-wrap: wrap; - gap: 10px; - padding: 0 22px 22px; -} - .modalButtonSecondary { background: rgba(255, 255, 255, 0.1); color: rgba(255, 255, 255, 0.8); @@ -1142,31 +1050,8 @@ width: 100%; } - .modal { + .issueDetailDialog { max-height: calc(100vh - 24px); - border-radius: 22px; - } - - .modalBody { - padding: 18px; - } - - .modalFooter { - flex-direction: column; - align-items: stretch; - padding: 0 18px 18px; - } - - .issueDetailModal { - max-height: calc(100vh - 24px); - } - - .issueDetailModal .modalBody { - padding: 18px; - } - - .issueDetailModal .modalFooter { - padding: 0 18px 18px; } .issueDetailMetaGrid { @@ -1183,8 +1068,7 @@ justify-content: center; } - .issueActionButtons > button, - .modalFooter > button { + .issueActionButtons > button { width: 100%; } diff --git a/webui/src/routes/issues/-ui/issue-detail-modal.tsx b/webui/src/routes/issues/-ui/issue-detail-modal.tsx index 7c8e34c6..6c1f5215 100644 --- a/webui/src/routes/issues/-ui/issue-detail-modal.tsx +++ b/webui/src/routes/issues/-ui/issue-detail-modal.tsx @@ -1,6 +1,7 @@ import { useMutation } from '@tanstack/react-query'; -import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react'; +import { useEffect, useMemo, useState, type ReactNode } from 'react'; +import { DialogBody, DialogFooter, DialogFrame, DialogHeader } from '@/components/dialog'; import { Button } from '@/components/form'; import { launchAlbumDownloadWorkflow, @@ -38,87 +39,12 @@ export function IssueDetailModal({ profileId: number; }) { const [adminResponse, setAdminResponse] = useState(''); - const modalRef = useRef(null); - const previouslyFocusedElementRef = useRef(null); const isOpen = Boolean(issue || isLoading || error); useEffect(() => { setAdminResponse(issue?.admin_response || ''); }, [issue?.admin_response, issue?.id]); - useEffect(() => { - if (!isOpen) { - return; - } - - previouslyFocusedElementRef.current = - document.activeElement instanceof HTMLElement ? document.activeElement : null; - - const focusModal = () => { - const modal = modalRef.current; - if (!modal) return; - - const focusable = getFocusableElements(modal); - (focusable[0] || modal).focus(); - }; - - const handleKeyDown = (event: KeyboardEvent) => { - if (event.key === 'Escape') { - event.preventDefault(); - event.stopPropagation(); - onClose(); - return; - } - - if (event.key !== 'Tab') return; - - const modal = modalRef.current; - if (!modal) return; - - const focusable = getFocusableElements(modal); - if (focusable.length === 0) { - event.preventDefault(); - modal.focus(); - return; - } - - const first = focusable[0]; - const last = focusable[focusable.length - 1]; - const activeElement = document.activeElement; - - if (event.shiftKey) { - if (activeElement === first || !modal.contains(activeElement)) { - event.preventDefault(); - last.focus(); - } - } else if (activeElement === last) { - event.preventDefault(); - first.focus(); - } - }; - - const onFocusIn = (event: FocusEvent) => { - const modal = modalRef.current; - if (!modal) return; - if (event.target instanceof Node && !modal.contains(event.target)) { - const focusable = getFocusableElements(modal); - (focusable[0] || modal).focus(); - } - }; - - const raf = requestAnimationFrame(focusModal); - document.addEventListener('keydown', handleKeyDown); - document.addEventListener('focusin', onFocusIn); - - return () => { - cancelAnimationFrame(raf); - document.removeEventListener('keydown', handleKeyDown); - document.removeEventListener('focusin', onFocusIn); - previouslyFocusedElementRef.current?.focus?.(); - previouslyFocusedElementRef.current = null; - }; - }, [isOpen, onClose]); - const updateMutation = useMutation({ mutationFn: async (payload: { issueId: number; status: string; adminResponse: string }) => { await updateIssue(profileId, payload.issueId, { @@ -270,283 +196,248 @@ export function IssueDetailModal({ }; return ( -
{ + if (!nextOpen) { + onClose(); + } + }} + className={styles.issueDetailDialog} > -
event.stopPropagation()} - > -
-

- {issue ? `Issue #${issue.id}` : 'Issue details'} -

- -
- -
- {isLoading ? ( -
-
- Loading issue details... + + + {isLoading ? ( +
+
+ Loading issue details... +
+ ) : error ? ( +
+
Failed to load issue
+
+ {error instanceof Error ? error.message : 'Unknown error'}
- ) : error ? ( -
-
Failed to load issue
-
- {error instanceof Error ? error.message : 'Unknown error'} +
+ ) : issue ? ( + <> +
+
+ {issue.entity_type === 'artist' && issueArtwork ? ( + + ) : null} + {issueArtwork ? ( + + ) : ( +
+ {ISSUE_CATEGORY_META[issue.category]?.icon || 'OT'} +
+ )}
-
- ) : issue ? ( - <> -
-
- {issue.entity_type === 'artist' && issueArtwork ? ( - - ) : null} - {issueArtwork ? ( - - ) : ( -
- {ISSUE_CATEGORY_META[issue.category]?.icon || 'OT'} -
+
+ {issue.entity_type !== 'artist' && snapshot.artist_name ? ( +
{String(snapshot.artist_name)}
+ ) : null} +
+ {String( + issue.entity_type === 'artist' + ? snapshot.name || issue.title + : snapshot.album_title || snapshot.title || issue.title, )}
-
- {issue.entity_type !== 'artist' && snapshot.artist_name ? ( -
{String(snapshot.artist_name)}
- ) : null} -
- {String( - issue.entity_type === 'artist' - ? snapshot.name || issue.title - : snapshot.album_title || snapshot.title || issue.title, - )} -
- {issue.entity_type === 'track' ? ( -
♪ {issue.title}
- ) : null} - {issue.entity_type !== 'artist' && albumMetaParts.length > 0 ? ( -
{albumMetaParts.join(' - ')}
- ) : null} - {genreTags.length > 0 ? ( -
- {genreTags.map((genre) => ( - - {String(genre)} - - ))} -
- ) : null} - {externalLinks.length > 0 ? ( -
- {externalLinks.map((link) => - link.url ? ( - - {link.service} - {link.type} - - ) : ( - - {link.service} - {link.type} - - ), - )} -
- ) : null} -
-
- -
-
- - - {formatStatusLabel(issue.status)} - - {issueCategoryLabel} -
-
- - Reported {formatIssueDate(issue.created_at)} - - {issue.resolved_at ? ( - - Resolved {formatIssueDate(issue.resolved_at)} - - ) : null} - {issue.reporter_name && isAdmin ? ( - by {issue.reporter_name} - ) : null} -
-
- - {issue.entity_type !== 'artist' && isAdmin && ( -
-
Admin Actions
-
- - -
-
- )} - -
-
Issue
-
{issue.title}
-
- {issue.description || 'No additional details provided'} -
-
- - {issue.entity_type === 'track' && trackMetaItems.length > 0 ? ( -
-
Track Details
-
- {trackMetaItems.map((item) => ( -
- {item.icon} - {item.label} - {item.value} -
+ {issue.entity_type === 'track' ? ( +
♪ {issue.title}
+ ) : null} + {issue.entity_type !== 'artist' && albumMetaParts.length > 0 ? ( +
{albumMetaParts.join(' - ')}
+ ) : null} + {genreTags.length > 0 ? ( +
+ {genreTags.map((genre) => ( + + {String(genre)} + ))}
-
- ) : null} - - {snapshot.file_path ? ( -
-
File Path
-
{String(snapshot.file_path)}
-
- ) : null} - - {trackRows.length > 0 ? ( -
-
- Track Listing{' '} - - {trackRows.length} tracks - + ) : null} + {externalLinks.length > 0 ? ( +
+ {externalLinks.map((link) => + link.url ? ( + + {link.service} + {link.type} + + ) : ( + + {link.service} + {link.type} + + ), + )}
-
{renderTrackListing(trackRows)}
-
- ) : null} + ) : null} +
+
- {isAdmin && ( -
-
Admin Response
-