Refactor shared dialog pieces

- split the modal shell into smaller shared components
- move default dialog styling into the shared dialog module
- simplify the issues modals to use the shared frame/header/body/footer pieces
- keep the issues route search navigation typed against the route
This commit is contained in:
Antti Kettunen 2026-05-02 20:20:46 +03:00
parent bd6be61b77
commit a14d397bea
No known key found for this signature in database
GPG key ID: C6B2A3D250359BD7
8 changed files with 460 additions and 530 deletions

View file

@ -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%;
}
}

View file

@ -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 (
<Dialog.Root open={open} onOpenChange={onOpenChange}>
<Dialog.Portal>
<Dialog.Backdrop className={styles.backdrop} />
<Dialog.Viewport className={styles.viewport}>
<Dialog.Popup className={clsx(styles.popup, className)}>{children}</Dialog.Popup>
</Dialog.Viewport>
</Dialog.Portal>
</Dialog.Root>
);
}
export function DialogHeader({
children,
closeLabel = 'Close dialog',
title,
}: {
children?: ReactNode;
closeLabel?: string;
title: ReactNode;
}) {
return (
<div className={styles.header}>
<div className={styles.headerContent}>
<Dialog.Title className={styles.title}>{title}</Dialog.Title>
{children ? <div className={styles.headerMeta}>{children}</div> : null}
</div>
<Dialog.Close className={styles.close} aria-label={closeLabel}>
×
</Dialog.Close>
</div>
);
}
export function DialogBody({ children }: { children: ReactNode }) {
return <div className={styles.body}>{children}</div>;
}
export function DialogFooter({ children }: { children: ReactNode }) {
return <div className={styles.footer}>{children}</div>;
}

View file

@ -0,0 +1 @@
export { DialogBody, DialogFooter, DialogFrame, DialogHeader } from './dialog';

View file

@ -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 () => {

View file

@ -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%;
}

View file

@ -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<HTMLDivElement | null>(null);
const previouslyFocusedElementRef = useRef<HTMLElement | null>(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 (
<div
className={styles.modalOverlay}
role="dialog"
aria-modal="true"
aria-labelledby="issue-detail-title"
onClick={onClose}
<DialogFrame
open={isOpen}
onOpenChange={(nextOpen) => {
if (!nextOpen) {
onClose();
}
}}
className={styles.issueDetailDialog}
>
<div
className={`${styles.modal} ${styles.issueDetailModal}`}
ref={modalRef}
tabIndex={-1}
onClick={(event) => event.stopPropagation()}
>
<div className={styles.modalHeader}>
<h3 className={styles.modalHeaderTitle} id="issue-detail-title">
{issue ? `Issue #${issue.id}` : 'Issue details'}
</h3>
<button
className={styles.modalClose}
type="button"
onClick={onClose}
aria-label="Close issue detail"
>
&times;
</button>
</div>
<div className={styles.modalBody}>
{isLoading ? (
<div className={styles.issuesLoading}>
<div className={styles.issuesSpinner} />
Loading issue details...
<DialogHeader
title={issue ? `Issue #${issue.id}` : 'Issue details'}
closeLabel="Close issue detail"
/>
<DialogBody>
{isLoading ? (
<div className={styles.issuesLoading}>
<div className={styles.issuesSpinner} />
Loading issue details...
</div>
) : error ? (
<div className={styles.issuesEmpty}>
<div className={styles.issuesEmptyTitle}>Failed to load issue</div>
<div className={styles.issuesEmptyText}>
{error instanceof Error ? error.message : 'Unknown error'}
</div>
) : error ? (
<div className={styles.issuesEmpty}>
<div className={styles.issuesEmptyTitle}>Failed to load issue</div>
<div className={styles.issuesEmptyText}>
{error instanceof Error ? error.message : 'Unknown error'}
</div>
) : issue ? (
<>
<div className={styles.issueHero}>
<div className={styles.issueHeroArtGroup}>
{issue.entity_type === 'artist' && issueArtwork ? (
<img className={styles.issueHeroArtistThumb} src={issueArtwork} alt="" />
) : null}
{issueArtwork ? (
<img className={styles.issueHeroAlbumArt} src={issueArtwork} alt="" />
) : (
<div className={styles.issueHeroAlbumPlaceholder}>
{ISSUE_CATEGORY_META[issue.category]?.icon || 'OT'}
</div>
)}
</div>
</div>
) : issue ? (
<>
<div className={styles.issueHero}>
<div className={styles.issueHeroArtGroup}>
{issue.entity_type === 'artist' && issueArtwork ? (
<img className={styles.issueHeroArtistThumb} src={issueArtwork} alt="" />
) : null}
{issueArtwork ? (
<img className={styles.issueHeroAlbumArt} src={issueArtwork} alt="" />
) : (
<div className={styles.issueHeroAlbumPlaceholder}>
{ISSUE_CATEGORY_META[issue.category]?.icon || 'OT'}
</div>
<div className={styles.issueHeroInfo}>
{issue.entity_type !== 'artist' && snapshot.artist_name ? (
<div className={styles.issueHeroArtist}>{String(snapshot.artist_name)}</div>
) : null}
<div className={styles.issueHeroAlbum}>
{String(
issue.entity_type === 'artist'
? snapshot.name || issue.title
: snapshot.album_title || snapshot.title || issue.title,
)}
</div>
<div className={styles.issueHeroInfo}>
{issue.entity_type !== 'artist' && snapshot.artist_name ? (
<div className={styles.issueHeroArtist}>{String(snapshot.artist_name)}</div>
) : null}
<div className={styles.issueHeroAlbum}>
{String(
issue.entity_type === 'artist'
? snapshot.name || issue.title
: snapshot.album_title || snapshot.title || issue.title,
)}
</div>
{issue.entity_type === 'track' ? (
<div className={styles.issueHeroTrackName}> {issue.title}</div>
) : null}
{issue.entity_type !== 'artist' && albumMetaParts.length > 0 ? (
<div className={styles.issueHeroMeta}>{albumMetaParts.join(' - ')}</div>
) : null}
{genreTags.length > 0 ? (
<div className={styles.issueHeroGenres}>
{genreTags.map((genre) => (
<span className={styles.issueHeroGenreTag} key={String(genre)}>
{String(genre)}
</span>
))}
</div>
) : null}
{externalLinks.length > 0 ? (
<div className={styles.issueExternalLinks}>
{externalLinks.map((link) =>
link.url ? (
<a
key={`${link.service}-${link.type}-${link.label}`}
className={`${styles.issueExternalLink} ${styles[link.className]}`}
href={link.url}
target="_blank"
rel="noreferrer"
title={`${link.service} ${link.type}`}
>
<span className={styles.issueExternalLinkService}>{link.service}</span>
<span className={styles.issueExternalLinkType}>{link.type}</span>
</a>
) : (
<span
key={`${link.service}-${link.type}-${link.label}`}
className={`${styles.issueExternalLink} ${styles[link.className]}`}
title={`${link.service} ${link.type}: ${link.id}`}
>
<span className={styles.issueExternalLinkService}>{link.service}</span>
<span className={styles.issueExternalLinkType}>{link.type}</span>
</span>
),
)}
</div>
) : null}
</div>
</div>
<div className={styles.issueDetailInfoBar}>
<div className={styles.issueDetailInfoLeft}>
<span
className={`${styles.issuePriorityDot} ${getPriorityDotClassName(priorityClassName)}`}
/>
<span
className={`${styles.issueStatusBadge} ${getStatusClassName(issue.status)}`}
>
{formatStatusLabel(issue.status)}
</span>
<span className={styles.issueDetailCategory}>{issueCategoryLabel}</span>
</div>
<div className={styles.issueDetailInfoRight}>
<span className={styles.issueDetailDate}>
Reported {formatIssueDate(issue.created_at)}
</span>
{issue.resolved_at ? (
<span className={styles.issueDetailDate}>
Resolved {formatIssueDate(issue.resolved_at)}
</span>
) : null}
{issue.reporter_name && isAdmin ? (
<span className={styles.issueDetailProfile}>by {issue.reporter_name}</span>
) : null}
</div>
</div>
{issue.entity_type !== 'artist' && isAdmin && (
<div className={styles.issueDetailSection}>
<div className={styles.issueDetailSectionTitle}>Admin Actions</div>
<div className={styles.issueActionButtons}>
<Button
className={styles.issueActionDownload}
type="button"
disabled={downloadWorkflowMutation.isPending}
onClick={() => downloadWorkflowMutation.mutate(albumWorkflowInput)}
>
{downloadWorkflowMutation.isPending ? 'Loading...' : 'Download Album'}
</Button>
<Button
className={styles.issueActionWishlist}
type="button"
disabled={wishlistWorkflowMutation.isPending}
onClick={() => wishlistWorkflowMutation.mutate(albumWorkflowInput)}
>
{wishlistWorkflowMutation.isPending ? 'Loading...' : 'Add to Wishlist'}
</Button>
</div>
</div>
)}
<div className={styles.issueDetailSection}>
<div className={styles.issueDetailSectionTitle}>Issue</div>
<div className={styles.issueDetailTitleText}>{issue.title}</div>
<div
className={
issue.description ? styles.issueDetailDescription : styles.issueDetailNoDesc
}
>
{issue.description || 'No additional details provided'}
</div>
</div>
{issue.entity_type === 'track' && trackMetaItems.length > 0 ? (
<div className={styles.issueDetailSection}>
<div className={styles.issueDetailSectionTitle}>Track Details</div>
<div className={styles.issueDetailMetaGrid}>
{trackMetaItems.map((item) => (
<div className={styles.issueMetaItem} key={item.label}>
<span className={styles.issueMetaIcon}>{item.icon}</span>
<span className={styles.issueMetaLabel}>{item.label}</span>
<span className={styles.issueMetaValue}>{item.value}</span>
</div>
{issue.entity_type === 'track' ? (
<div className={styles.issueHeroTrackName}> {issue.title}</div>
) : null}
{issue.entity_type !== 'artist' && albumMetaParts.length > 0 ? (
<div className={styles.issueHeroMeta}>{albumMetaParts.join(' - ')}</div>
) : null}
{genreTags.length > 0 ? (
<div className={styles.issueHeroGenres}>
{genreTags.map((genre) => (
<span className={styles.issueHeroGenreTag} key={String(genre)}>
{String(genre)}
</span>
))}
</div>
</div>
) : null}
{snapshot.file_path ? (
<div className={styles.issueDetailSection}>
<div className={styles.issueDetailSectionTitle}>File Path</div>
<div className={styles.issueDetailFilepath}>{String(snapshot.file_path)}</div>
</div>
) : null}
{trackRows.length > 0 ? (
<div className={styles.issueDetailSection}>
<div className={styles.issueDetailSectionTitle}>
Track Listing{' '}
<span className={styles.issueDetailSectionCount}>
{trackRows.length} tracks
</span>
) : null}
{externalLinks.length > 0 ? (
<div className={styles.issueExternalLinks}>
{externalLinks.map((link) =>
link.url ? (
<a
key={`${link.service}-${link.type}-${link.label}`}
className={`${styles.issueExternalLink} ${styles[link.className]}`}
href={link.url}
target="_blank"
rel="noreferrer"
title={`${link.service} ${link.type}`}
>
<span className={styles.issueExternalLinkService}>{link.service}</span>
<span className={styles.issueExternalLinkType}>{link.type}</span>
</a>
) : (
<span
key={`${link.service}-${link.type}-${link.label}`}
className={`${styles.issueExternalLink} ${styles[link.className]}`}
title={`${link.service} ${link.type}: ${link.id}`}
>
<span className={styles.issueExternalLinkService}>{link.service}</span>
<span className={styles.issueExternalLinkType}>{link.type}</span>
</span>
),
)}
</div>
<div className={styles.issueDetailTracklist}>{renderTrackListing(trackRows)}</div>
</div>
) : null}
) : null}
</div>
</div>
{isAdmin && (
<div className={styles.issueDetailSection}>
<div className={styles.issueDetailSectionTitle}>Admin Response</div>
<textarea
className={styles.issueDetailResponseTextarea}
id="issue-detail-response-input"
value={adminResponse}
onChange={(event) => setAdminResponse(event.target.value)}
placeholder="Write a response to the reporter..."
rows={3}
/>
</div>
)}
<div className={styles.issueDetailInfoBar}>
<div className={styles.issueDetailInfoLeft}>
<span
className={`${styles.issuePriorityDot} ${getPriorityDotClassName(priorityClassName)}`}
/>
<span className={`${styles.issueStatusBadge} ${getStatusClassName(issue.status)}`}>
{formatStatusLabel(issue.status)}
</span>
<span className={styles.issueDetailCategory}>{issueCategoryLabel}</span>
</div>
<div className={styles.issueDetailInfoRight}>
<span className={styles.issueDetailDate}>
Reported {formatIssueDate(issue.created_at)}
</span>
{issue.resolved_at ? (
<span className={styles.issueDetailDate}>
Resolved {formatIssueDate(issue.resolved_at)}
</span>
) : null}
{issue.reporter_name && isAdmin ? (
<span className={styles.issueDetailProfile}>by {issue.reporter_name}</span>
) : null}
</div>
</div>
{!isAdmin && issue.admin_response ? (
<div className={styles.issueDetailSection}>
<div className={styles.issueDetailSectionTitle}>Admin Response</div>
<div className={styles.issueDetailAdminResponse}>{issue.admin_response}</div>
{issue.entity_type !== 'artist' && isAdmin && (
<div className={styles.issueDetailSection}>
<div className={styles.issueDetailSectionTitle}>Admin Actions</div>
<div className={styles.issueActionButtons}>
<Button
className={styles.issueActionDownload}
type="button"
disabled={downloadWorkflowMutation.isPending}
onClick={() => downloadWorkflowMutation.mutate(albumWorkflowInput)}
>
{downloadWorkflowMutation.isPending ? 'Loading...' : 'Download Album'}
</Button>
<Button
className={styles.issueActionWishlist}
type="button"
disabled={wishlistWorkflowMutation.isPending}
onClick={() => wishlistWorkflowMutation.mutate(albumWorkflowInput)}
>
{wishlistWorkflowMutation.isPending ? 'Loading...' : 'Add to Wishlist'}
</Button>
</div>
) : null}
</>
) : null}
</div>
</div>
)}
<div className={styles.modalFooter}>
<Button className={styles.modalButtonSecondary} type="button" onClick={onClose}>
Close
</Button>
{!isLoading && !error && issue && (
<>
{statusButtons}
{isAdmin && (
<Button
className={styles.modalButtonDelete}
type="button"
onClick={() => {
if (window.confirm('Delete this issue?')) {
deleteMutation.mutate(issue.id);
}
}}
disabled={deleteMutation.isPending}
>
Delete
</Button>
)}
</>
)}
</div>
</div>
</div>
<div className={styles.issueDetailSection}>
<div className={styles.issueDetailSectionTitle}>Issue</div>
<div className={styles.issueDetailTitleText}>{issue.title}</div>
<div
className={
issue.description ? styles.issueDetailDescription : styles.issueDetailNoDesc
}
>
{issue.description || 'No additional details provided'}
</div>
</div>
{issue.entity_type === 'track' && trackMetaItems.length > 0 ? (
<div className={styles.issueDetailSection}>
<div className={styles.issueDetailSectionTitle}>Track Details</div>
<div className={styles.issueDetailMetaGrid}>
{trackMetaItems.map((item) => (
<div className={styles.issueMetaItem} key={item.label}>
<span className={styles.issueMetaIcon}>{item.icon}</span>
<span className={styles.issueMetaLabel}>{item.label}</span>
<span className={styles.issueMetaValue}>{item.value}</span>
</div>
))}
</div>
</div>
) : null}
{snapshot.file_path ? (
<div className={styles.issueDetailSection}>
<div className={styles.issueDetailSectionTitle}>File Path</div>
<div className={styles.issueDetailFilepath}>{String(snapshot.file_path)}</div>
</div>
) : null}
{trackRows.length > 0 ? (
<div className={styles.issueDetailSection}>
<div className={styles.issueDetailSectionTitle}>
Track Listing{' '}
<span className={styles.issueDetailSectionCount}>{trackRows.length} tracks</span>
</div>
<div className={styles.issueDetailTracklist}>{renderTrackListing(trackRows)}</div>
</div>
) : null}
{isAdmin && (
<div className={styles.issueDetailSection}>
<div className={styles.issueDetailSectionTitle}>Admin Response</div>
<textarea
className={styles.issueDetailResponseTextarea}
id="issue-detail-response-input"
value={adminResponse}
onChange={(event) => setAdminResponse(event.target.value)}
placeholder="Write a response to the reporter..."
rows={3}
/>
</div>
)}
{!isAdmin && issue.admin_response ? (
<div className={styles.issueDetailSection}>
<div className={styles.issueDetailSectionTitle}>Admin Response</div>
<div className={styles.issueDetailAdminResponse}>{issue.admin_response}</div>
</div>
) : null}
</>
) : null}
</DialogBody>
<DialogFooter>
<Button className={styles.modalButtonSecondary} type="button" onClick={onClose}>
Close
</Button>
{!isLoading && !error && issue && (
<>
{statusButtons}
{isAdmin && (
<Button
className={styles.modalButtonDelete}
type="button"
onClick={() => {
if (window.confirm('Delete this issue?')) {
deleteMutation.mutate(issue.id);
}
}}
disabled={deleteMutation.isPending}
>
Delete
</Button>
)}
</>
)}
</DialogFooter>
</DialogFrame>
);
}
function getFocusableElements(container: HTMLElement) {
return Array.from(
container.querySelectorAll<HTMLElement>(
[
'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<Record<string, unknown>>) {
const nodes: ReactNode[] = [];
let lastDisc: number | null = null;

View file

@ -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 (
<ReportIssueModal
key={`${reportPayload.entityType}:${reportPayload.entityId}`}
payload={reportPayload}
@ -105,8 +104,7 @@ export function IssueDomainHost() {
setReportPayload(null);
void queryClient.invalidateQueries({ queryKey: ISSUE_DOMAIN_QUERY_KEY });
}}
/>,
document.body,
/>
);
}
@ -169,37 +167,26 @@ function ReportIssueModal({
});
return (
<div
className={styles.modalOverlay}
role="dialog"
aria-modal="true"
aria-labelledby="report-issue-title"
onClick={onClose}
<DialogFrame
open={true}
onOpenChange={(nextOpen) => {
if (!nextOpen) {
onClose();
}
}}
className={styles.reportIssueDialog}
closeLabel="Close report issue modal"
>
<form
className={`${styles.modal} ${styles.reportIssueModal}`}
onClick={(event) => event.stopPropagation()}
onSubmit={(event) => {
event.preventDefault();
event.stopPropagation();
void form.handleSubmit().catch(() => undefined);
}}
>
<div className={styles.modalHeader}>
<h3 className={styles.modalHeaderTitle} id="report-issue-title">
Report Issue - {entityLabel}
</h3>
<button
className={styles.modalClose}
type="button"
onClick={onClose}
aria-label="Close report issue modal"
>
&times;
</button>
</div>
<div className={styles.modalBody}>
<DialogHeader title={`Report Issue - ${entityLabel}`} closeLabel="Close report issue modal" />
<DialogBody>
<form
className={styles.reportIssueForm}
onSubmit={(event) => {
event.preventDefault();
event.stopPropagation();
void form.handleSubmit().catch(() => undefined);
}}
>
<div className={styles.reportIssueEntityInfo}>
<div className={styles.reportIssueEntityName}>{payload.entityName}</div>
{payload.artistName ? (
@ -322,35 +309,35 @@ function ReportIssueModal({
return <FormError message={error} />;
}}
</form.Subscribe>
</div>
<FormActions className={styles.modalFooter}>
<Button className={styles.modalButtonSecondary} type="button" onClick={onClose}>
Cancel
</Button>
<form.Subscribe
selector={(state) => ({
category: state.values.category,
isSubmitting: state.isSubmitting,
title: state.values.title,
})}
>
{(state) => {
const isSubmitting = state.isSubmitting || createMutation.isPending;
return (
<Button
className={styles.modalButtonPrimary}
type="submit"
disabled={!state.category || !state.title.trim() || isSubmitting}
>
{isSubmitting ? 'Submitting...' : 'Submit Issue'}
</Button>
);
}}
</form.Subscribe>
</FormActions>
</form>
</div>
<DialogFooter>
<Button className={styles.modalButtonSecondary} type="button" onClick={onClose}>
Cancel
</Button>
<form.Subscribe
selector={(state) => ({
category: state.values.category,
isSubmitting: state.isSubmitting,
title: state.values.title,
})}
>
{(state) => {
const isSubmitting = state.isSubmitting || createMutation.isPending;
return (
<Button
className={styles.modalButtonPrimary}
type="submit"
disabled={!state.category || !state.title.trim() || isSubmitting}
>
{isSubmitting ? 'Submitting...' : 'Submit Issue'}
</Button>
);
}}
</form.Subscribe>
</DialogFooter>
</form>
</DialogBody>
</DialogFrame>
);
}

View file

@ -36,6 +36,7 @@ type NavigateFunction = ReturnType<typeof useNavigate>;
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,