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 ? (
-
- ) : 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 ? (
+
-
{renderTrackListing(trackRows)}
-
- ) : null}
+ ) : null}
+
+
- {isAdmin && (
-
- )}
+
+
+
+
+ {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}
+
+
- {!isAdmin && issue.admin_response ? (
-
-
Admin Response
-
{issue.admin_response}
+ {issue.entity_type !== 'artist' && isAdmin && (
+
+
Admin Actions
+
+
+
- ) : null}
- >
- ) : null}
-
+
+ )}
-
-
- {!isLoading && !error && issue && (
- <>
- {statusButtons}
- {isAdmin && (
-
- )}
- >
- )}
-
-
-
+
+
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}
+
+ ))}
+
+
+ ) : null}
+
+ {snapshot.file_path ? (
+
+
File Path
+
{String(snapshot.file_path)}
+
+ ) : null}
+
+ {trackRows.length > 0 ? (
+
+
+ Track Listing{' '}
+ {trackRows.length} tracks
+
+
{renderTrackListing(trackRows)}
+
+ ) : null}
+
+ {isAdmin && (
+
+ )}
+
+ {!isAdmin && issue.admin_response ? (
+
+
Admin Response
+
{issue.admin_response}
+
+ ) : null}
+ >
+ ) : null}
+
+
+
+ {!isLoading && !error && issue && (
+ <>
+ {statusButtons}
+ {isAdmin && (
+
+ )}
+ >
+ )}
+
+
);
}
-function getFocusableElements(container: HTMLElement) {
- return Array.from(
- container.querySelectorAll
(
- [
- 'button:not([disabled])',
- '[href]',
- 'input:not([disabled])',
- 'select:not([disabled])',
- 'textarea:not([disabled])',
- '[tabindex]:not([tabindex="-1"])',
- ].join(','),
- ),
- ).filter((element) => element.tabIndex >= 0);
-}
-
function renderTrackListing(trackRows: Array>) {
const nodes: ReactNode[] = [];
let lastDisc: number | null = null;
diff --git a/webui/src/routes/issues/-ui/issue-domain-host.tsx b/webui/src/routes/issues/-ui/issue-domain-host.tsx
index e3ca6318..8b5fc609 100644
--- a/webui/src/routes/issues/-ui/issue-domain-host.tsx
+++ b/webui/src/routes/issues/-ui/issue-domain-host.tsx
@@ -1,11 +1,10 @@
import { useForm } from '@tanstack/react-form';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useEffect, useMemo, useState } from 'react';
-import { createPortal } from 'react-dom';
+import { DialogBody, DialogFooter, DialogFrame, DialogHeader } from '@/components/dialog';
import {
Button,
- FormActions,
FormError,
FormField,
OptionButton,
@@ -95,7 +94,7 @@ export function IssueDomainHost() {
if (!reportPayload) return null;
- return createPortal(
+ return (
,
- document.body,
+ />
);
}
@@ -169,37 +167,26 @@ function ReportIssueModal({
});
return (
- {
+ if (!nextOpen) {
+ onClose();
+ }
+ }}
+ className={styles.reportIssueDialog}
+ closeLabel="Close report issue modal"
>
-
+
+
);
}
diff --git a/webui/src/routes/issues/-ui/issues-page.tsx b/webui/src/routes/issues/-ui/issues-page.tsx
index f85d5c17..f480f9fc 100644
--- a/webui/src/routes/issues/-ui/issues-page.tsx
+++ b/webui/src/routes/issues/-ui/issues-page.tsx
@@ -36,6 +36,7 @@ type NavigateFunction = ReturnType
;
function clearIssueSelection(navigate: NavigateFunction) {
void navigate({
+ to: Route.fullPath,
search: (prev) => normalizeIssuesSearch({ ...prev, issueId: undefined }),
replace: true,
});
@@ -54,6 +55,7 @@ export function IssuesPage() {
const openIssue = (issueId: number) => {
void navigate({
+ to: Route.fullPath,
search: (prev) => normalizeIssuesSearch({ ...prev, issueId }),
});
};
@@ -103,6 +105,7 @@ export function IssuesPage() {
issuesLoading={issuesQuery.isLoading}
onCategoryChange={(category) =>
void navigate({
+ to: Route.fullPath,
search: (prev) =>
normalizeIssuesSearch({
...prev,
@@ -114,6 +117,7 @@ export function IssuesPage() {
onIssueSelect={openIssue}
onStatusChange={(status) =>
void navigate({
+ to: Route.fullPath,
search: (prev) =>
normalizeIssuesSearch({
...prev,