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'));
|
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 () => {
|
||||||
|
|
|
||||||
|
|
@ -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%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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'}
|
||||||
>
|
|
||||||
×
|
|
||||||
</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;
|
||||||
|
|
|
||||||
|
|
@ -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"
|
|
||||||
>
|
|
||||||
×
|
|
||||||
</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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue