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')); 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(); renderIssuesRoute();
fireEvent.click(await screen.findByTestId('issue-card-7')); fireEvent.click(await screen.findByTestId('issue-card-7'));
const closeButton = await screen.findByRole('button', { const closeButton = await screen.findByRole('button', {
name: /close issue detail/i, name: /close issue detail/i,
}); });
const deleteButton = await screen.findByRole('button', { name: /delete/i });
deleteButton.focus(); await waitFor(() => expect(closeButton).toHaveFocus());
expect(deleteButton).toHaveFocus();
fireEvent.keyDown(document, { key: 'Tab' });
expect(closeButton).toHaveFocus();
}); });
it('invokes the shared workflow adapter for admin downloads', async () => { 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); box-shadow: 0 0 6px rgba(255, 77, 77, 0.4);
} }
.modalOverlay { .issueDetailDialog {
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 {
max-width: 750px; max-width: 750px;
width: 95vw; width: 95vw;
max-height: 85vh; max-height: 85vh;
display: flex;
flex-direction: column;
overflow: hidden;
border-radius: 16px;
} }
.issueDetailModal .modalHeader { .reportIssueDialog {
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 {
max-width: 680px; max-width: 680px;
width: 95vw; width: 95vw;
} }
.modalHeader { .reportIssueForm {
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;
display: grid; display: grid;
gap: 18px; gap: 18px;
} }
@ -1011,14 +927,6 @@
font-size: 13px; font-size: 13px;
} }
.modalFooter {
display: flex;
justify-content: flex-end;
flex-wrap: wrap;
gap: 10px;
padding: 0 22px 22px;
}
.modalButtonSecondary { .modalButtonSecondary {
background: rgba(255, 255, 255, 0.1); background: rgba(255, 255, 255, 0.1);
color: rgba(255, 255, 255, 0.8); color: rgba(255, 255, 255, 0.8);
@ -1142,31 +1050,8 @@
width: 100%; width: 100%;
} }
.modal { .issueDetailDialog {
max-height: calc(100vh - 24px); 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 { .issueDetailMetaGrid {
@ -1183,8 +1068,7 @@
justify-content: center; justify-content: center;
} }
.issueActionButtons > button, .issueActionButtons > button {
.modalFooter > button {
width: 100%; width: 100%;
} }

View file

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

View file

@ -1,11 +1,10 @@
import { useForm } from '@tanstack/react-form'; import { useForm } from '@tanstack/react-form';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { createPortal } from 'react-dom';
import { DialogBody, DialogFooter, DialogFrame, DialogHeader } from '@/components/dialog';
import { import {
Button, Button,
FormActions,
FormError, FormError,
FormField, FormField,
OptionButton, OptionButton,
@ -95,7 +94,7 @@ export function IssueDomainHost() {
if (!reportPayload) return null; if (!reportPayload) return null;
return createPortal( return (
<ReportIssueModal <ReportIssueModal
key={`${reportPayload.entityType}:${reportPayload.entityId}`} key={`${reportPayload.entityType}:${reportPayload.entityId}`}
payload={reportPayload} payload={reportPayload}
@ -105,8 +104,7 @@ export function IssueDomainHost() {
setReportPayload(null); setReportPayload(null);
void queryClient.invalidateQueries({ queryKey: ISSUE_DOMAIN_QUERY_KEY }); void queryClient.invalidateQueries({ queryKey: ISSUE_DOMAIN_QUERY_KEY });
}} }}
/>, />
document.body,
); );
} }
@ -169,37 +167,26 @@ function ReportIssueModal({
}); });
return ( return (
<div <DialogFrame
className={styles.modalOverlay} open={true}
role="dialog" onOpenChange={(nextOpen) => {
aria-modal="true" if (!nextOpen) {
aria-labelledby="report-issue-title" onClose();
onClick={onClose} }
}}
className={styles.reportIssueDialog}
closeLabel="Close report issue modal"
> >
<form <DialogHeader title={`Report Issue - ${entityLabel}`} closeLabel="Close report issue modal" />
className={`${styles.modal} ${styles.reportIssueModal}`} <DialogBody>
onClick={(event) => event.stopPropagation()} <form
onSubmit={(event) => { className={styles.reportIssueForm}
event.preventDefault(); onSubmit={(event) => {
event.stopPropagation(); event.preventDefault();
void form.handleSubmit().catch(() => undefined); 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}>
<div className={styles.reportIssueEntityInfo}> <div className={styles.reportIssueEntityInfo}>
<div className={styles.reportIssueEntityName}>{payload.entityName}</div> <div className={styles.reportIssueEntityName}>{payload.entityName}</div>
{payload.artistName ? ( {payload.artistName ? (
@ -322,35 +309,35 @@ function ReportIssueModal({
return <FormError message={error} />; return <FormError message={error} />;
}} }}
</form.Subscribe> </form.Subscribe>
</div>
<FormActions className={styles.modalFooter}> <DialogFooter>
<Button className={styles.modalButtonSecondary} type="button" onClick={onClose}> <Button className={styles.modalButtonSecondary} type="button" onClick={onClose}>
Cancel Cancel
</Button> </Button>
<form.Subscribe <form.Subscribe
selector={(state) => ({ selector={(state) => ({
category: state.values.category, category: state.values.category,
isSubmitting: state.isSubmitting, isSubmitting: state.isSubmitting,
title: state.values.title, title: state.values.title,
})} })}
> >
{(state) => { {(state) => {
const isSubmitting = state.isSubmitting || createMutation.isPending; const isSubmitting = state.isSubmitting || createMutation.isPending;
return ( return (
<Button <Button
className={styles.modalButtonPrimary} className={styles.modalButtonPrimary}
type="submit" type="submit"
disabled={!state.category || !state.title.trim() || isSubmitting} disabled={!state.category || !state.title.trim() || isSubmitting}
> >
{isSubmitting ? 'Submitting...' : 'Submit Issue'} {isSubmitting ? 'Submitting...' : 'Submit Issue'}
</Button> </Button>
); );
}} }}
</form.Subscribe> </form.Subscribe>
</FormActions> </DialogFooter>
</form> </form>
</div> </DialogBody>
</DialogFrame>
); );
} }

View file

@ -36,6 +36,7 @@ type NavigateFunction = ReturnType<typeof useNavigate>;
function clearIssueSelection(navigate: NavigateFunction) { function clearIssueSelection(navigate: NavigateFunction) {
void navigate({ void navigate({
to: Route.fullPath,
search: (prev) => normalizeIssuesSearch({ ...prev, issueId: undefined }), search: (prev) => normalizeIssuesSearch({ ...prev, issueId: undefined }),
replace: true, replace: true,
}); });
@ -54,6 +55,7 @@ export function IssuesPage() {
const openIssue = (issueId: number) => { const openIssue = (issueId: number) => {
void navigate({ void navigate({
to: Route.fullPath,
search: (prev) => normalizeIssuesSearch({ ...prev, issueId }), search: (prev) => normalizeIssuesSearch({ ...prev, issueId }),
}); });
}; };
@ -103,6 +105,7 @@ export function IssuesPage() {
issuesLoading={issuesQuery.isLoading} issuesLoading={issuesQuery.isLoading}
onCategoryChange={(category) => onCategoryChange={(category) =>
void navigate({ void navigate({
to: Route.fullPath,
search: (prev) => search: (prev) =>
normalizeIssuesSearch({ normalizeIssuesSearch({
...prev, ...prev,
@ -114,6 +117,7 @@ export function IssuesPage() {
onIssueSelect={openIssue} onIssueSelect={openIssue}
onStatusChange={(status) => onStatusChange={(status) =>
void navigate({ void navigate({
to: Route.fullPath,
search: (prev) => search: (prev) =>
normalizeIssuesSearch({ normalizeIssuesSearch({
...prev, ...prev,