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:
parent
bd6be61b77
commit
a14d397bea
8 changed files with 460 additions and 530 deletions
110
webui/src/components/dialog/dialog.module.css
Normal file
110
webui/src/components/dialog/dialog.module.css
Normal 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%;
|
||||
}
|
||||
}
|
||||
59
webui/src/components/dialog/dialog.tsx
Normal file
59
webui/src/components/dialog/dialog.tsx
Normal 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>;
|
||||
}
|
||||
1
webui/src/components/dialog/index.ts
Normal file
1
webui/src/components/dialog/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { DialogBody, DialogFooter, DialogFrame, DialogHeader } from './dialog';
|
||||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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%;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
>
|
||||
×
|
||||
</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;
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
>
|
||||
×
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in a new issue