soulsync/webui/src/routes/issues/-ui/issue-detail-modal.tsx
Antti Kettunen 892334007d
Refine issue detail modal rendering
- use a scoped renderer for the loading, error, and success lifecycle
- keep Show for the larger conditional blocks inside the success view
- simplify small pending-label branches back to plain ternaries
2026-05-13 22:26:26 +03:00

736 lines
24 KiB
TypeScript

import { useMutation, useQuery } from '@tanstack/react-query';
import { useEffect, useMemo, useState, type ReactNode } from 'react';
import { DialogBody, DialogFooter, DialogFrame, DialogHeader } from '@/components/dialog';
import { Button } from '@/components/form';
import { Show } from '@/components/primitives';
import { useProfile } from '@/platform/shell/route-controllers';
import {
launchAlbumDownloadWorkflow,
launchAlbumWishlistWorkflow,
} from '@/platform/workflows/album-workflows';
import type { IssueRecord, IssueTrackRow } from '../-issues.types';
import { deleteIssue, issueDetailQueryOptions, updateIssue } from '../-issues.api';
import {
formatIssueDate,
formatStatusLabel,
getIssueArtwork,
getPriorityClassName,
getIssueCategoryMeta,
ISSUE_CATEGORY_META,
parseSnapshot,
} from '../-issues.helpers';
import styles from './issue-detail-modal.module.css';
export function IssueDetailModal({
issueId,
onClose,
onMutationSuccess,
}: {
issueId?: number;
onClose: () => void;
onMutationSuccess: () => void;
}) {
const { isAdmin, profileId } = useProfile();
const selectedIssueQuery = useQuery({
...issueDetailQueryOptions(profileId, issueId ?? 0),
enabled: issueId != null,
});
const issue = selectedIssueQuery.data ?? null;
const queryError = selectedIssueQuery.error;
const queryLoading = selectedIssueQuery.isLoading;
const [adminResponse, setAdminResponse] = useState('');
const isOpen = Boolean(issueId || queryLoading || queryError);
useEffect(() => {
setAdminResponse(issue?.admin_response || '');
}, [issue?.admin_response, issue?.id]);
const updateMutation = useMutation({
mutationFn: async (payload: { issueId: number; status: string; adminResponse: string }) => {
await updateIssue(profileId, payload.issueId, {
status: payload.status,
admin_response: payload.adminResponse,
});
},
onSuccess: async () => {
onMutationSuccess();
},
});
const deleteMutation = useMutation({
mutationFn: async (issueId: number) => {
await deleteIssue(profileId, issueId);
},
onSuccess: async () => {
onMutationSuccess();
},
});
const downloadWorkflowMutation = useMutation({
mutationFn: launchAlbumDownloadWorkflow,
onError: notifyWorkflowError,
onSuccess: onClose,
});
const wishlistWorkflowMutation = useMutation({
mutationFn: launchAlbumWishlistWorkflow,
onError: notifyWorkflowError,
onSuccess: onClose,
});
const statusButtons = useMemo(() => {
if (!issue) return null;
if (isAdmin) {
if (issue.status === 'open' || issue.status === 'in_progress') {
return (
<>
{issue.status === 'open' && (
<Button
className={styles.modalButtonProgress}
type="button"
onClick={() =>
updateMutation.mutate({
issueId: issue.id,
status: 'in_progress',
adminResponse,
})
}
disabled={updateMutation.isPending}
>
Mark In Progress
</Button>
)}
<Button
className={styles.modalButtonResolve}
type="button"
onClick={() =>
updateMutation.mutate({
issueId: issue.id,
status: 'resolved',
adminResponse,
})
}
disabled={updateMutation.isPending}
>
Resolve
</Button>
<Button
className={styles.modalButtonDismiss}
type="button"
onClick={() =>
updateMutation.mutate({
issueId: issue.id,
status: 'dismissed',
adminResponse,
})
}
disabled={updateMutation.isPending}
>
Dismiss
</Button>
</>
);
}
return (
<Button
className={styles.modalButtonReopen}
type="button"
onClick={() =>
updateMutation.mutate({
issueId: issue.id,
status: 'open',
adminResponse,
})
}
disabled={updateMutation.isPending}
>
Reopen
</Button>
);
}
if (issue.status === 'open') {
return (
<Button
className={styles.modalButtonDelete}
type="button"
onClick={() => {
if (window.confirm('Withdraw this issue?')) {
deleteMutation.mutate(issue.id);
}
}}
disabled={deleteMutation.isPending}
>
Withdraw
</Button>
);
}
return null;
}, [adminResponse, deleteMutation, isAdmin, issue, updateMutation]);
if (!issue && !queryLoading && !queryError) {
return null;
}
const snapshot = issue ? parseSnapshot(issue.snapshot_data) : {};
const issueArtwork = getIssueArtwork(snapshot);
const issueCategoryMeta = issue ? getIssueCategoryMeta(issue.category) : undefined;
const issueCategoryLabel = issue
? `${issueCategoryMeta?.icon || ''} ${issueCategoryMeta?.label || ISSUE_CATEGORY_META.other.label}`.trim()
: '';
const externalLinks = getExternalLinks(snapshot);
const trackMetaItems = getTrackMetaItems(snapshot);
const trackRows = Array.isArray(snapshot.tracks) ? snapshot.tracks : [];
const priorityLevel = issue ? getPriorityClassName(issue.priority) : 'normal';
const albumMetaParts = issue ? getAlbumMetaParts(issue, snapshot) : [];
const genreTags = Array.isArray(snapshot.genres) ? snapshot.genres.slice(0, 5) : [];
const albumWorkflowInput = {
spotifyAlbumId: String(snapshot.spotify_album_id || ''),
artistName: String(snapshot.artist_name || ''),
albumName: String(snapshot.album_title || snapshot.title || ''),
source: 'issue',
};
return (
<DialogFrame
open={isOpen}
onOpenChange={(nextOpen) => {
if (!nextOpen) {
onClose();
}
}}
className={styles.issueDetailDialog}
>
<DialogHeader
title={issue ? `Issue #${issue.id}` : 'Issue details'}
closeLabel="Close issue detail"
/>
<DialogBody>{renderIssueDetailContent()}</DialogBody>
<DialogFooter>
<Button className={styles.modalButtonSecondary} type="button" onClick={onClose}>
Close
</Button>
{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 renderIssueDetailContent() {
if (queryLoading) {
return (
<div className={styles.issuesLoading}>
<div className={styles.issuesSpinner} />
Loading issue details...
</div>
);
}
if (queryError) {
return (
<div className={styles.issuesEmpty}>
<div className={styles.issuesEmptyTitle}>Failed to load issue</div>
<div className={styles.issuesEmptyText}>
{queryError instanceof Error ? queryError.message : 'Unknown error'}
</div>
</div>
);
}
if (!issue) {
return null;
}
return (
<>
<div className={styles.issueHero}>
<div className={styles.issueHeroArtGroup}>
<Show when={issue.entity_type === 'artist' && issueArtwork}>
<img className={styles.issueHeroArtistThumb} src={issueArtwork} alt="" />
</Show>
<Show
when={issueArtwork}
fallback={
<div className={styles.issueHeroAlbumPlaceholder}>
{issueCategoryMeta?.icon || ISSUE_CATEGORY_META.other.icon}
</div>
}
>
<img className={styles.issueHeroAlbumArt} src={issueArtwork} alt="" />
</Show>
</div>
<div className={styles.issueHeroInfo}>
<Show when={issue.entity_type !== 'artist' && snapshot.artist_name}>
{(artistName) => <div className={styles.issueHeroArtist}>{String(artistName)}</div>}
</Show>
<div className={styles.issueHeroAlbum}>
{String(
issue.entity_type === 'artist'
? snapshot.name || issue.title
: snapshot.album_title || snapshot.title || issue.title,
)}
</div>
<Show when={issue.entity_type === 'track'}>
<div className={styles.issueHeroTrackName}> {issue.title}</div>
</Show>
<Show when={issue.entity_type !== 'artist' && albumMetaParts.length > 0}>
<div className={styles.issueHeroMeta}>{albumMetaParts.join(' - ')}</div>
</Show>
<Show when={genreTags.length}>
<div className={styles.issueHeroGenres}>
{genreTags.map((genre) => (
<span className={styles.issueHeroGenreTag} key={String(genre)}>
{String(genre)}
</span>
))}
</div>
</Show>
<Show when={externalLinks.length}>
<div className={styles.issueExternalLinks}>
{externalLinks.map((link) => (
<Show
key={`${link.service}-${link.type}-${link.label}`}
when={link.url}
fallback={
<span
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>
}
>
<a
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>
</Show>
))}
</div>
</Show>
</div>
</div>
<div className={styles.issueDetailInfoBar}>
<div className={styles.issueDetailInfoLeft}>
<span
className={`${styles.issuePriorityDot} ${getPriorityDotClassName(priorityLevel)}`}
/>
<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>
<Show when={issue.resolved_at}>
{(resolvedAt) => (
<span className={styles.issueDetailDate}>
Resolved {formatIssueDate(resolvedAt)}
</span>
)}
</Show>
<Show when={isAdmin && issue.reporter_name}>
{(reporterName) => (
<span className={styles.issueDetailProfile}>by {reporterName}</span>
)}
</Show>
</div>
</div>
<Show when={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>
</Show>
<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>
<Show when={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>
</Show>
<Show when={snapshot.file_path}>
{(filePath) => (
<div className={styles.issueDetailSection}>
<div className={styles.issueDetailSectionTitle}>File Path</div>
<div className={styles.issueDetailFilepath}>{String(filePath)}</div>
</div>
)}
</Show>
<Show when={trackRows.length}>
<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>
</Show>
<Show when={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>
</Show>
<Show when={!isAdmin && issue.admin_response}>
{(response) => (
<div className={styles.issueDetailSection}>
<div className={styles.issueDetailSectionTitle}>Admin Response</div>
<div className={styles.issueDetailAdminResponse}>{response}</div>
</div>
)}
</Show>
</>
);
}
}
function renderTrackListing(trackRows: IssueTrackRow[]) {
const nodes: ReactNode[] = [];
let lastDisc: number | null = null;
const hasMultiDisc = trackRows.some((track) => Number(track.disc_number || 1) > 1);
trackRows.forEach((track, index) => {
const disc = Number(track.disc_number || 1);
if (hasMultiDisc && disc !== lastDisc) {
nodes.push(
<div className={styles.issueDetailTracklistDisc} key={`disc-${disc}-${index}`}>
Disc {disc}
</div>,
);
lastDisc = disc;
}
const format = String(track.format || '').toUpperCase();
const bitrateValue = typeof track.bitrate === 'number' ? track.bitrate : Number(track.bitrate);
const bitrate = Number.isFinite(bitrateValue) && bitrateValue > 0 ? `${bitrateValue}k` : '';
const duration = formatDuration(track.duration);
const formatClassName = getTrackFormatClassName(format);
const bitrateClassName = getTrackBitrateClassName(bitrateValue, format);
nodes.push(
<div
className={styles.issueDetailTracklistRow}
key={String(track.id || `${track.title}-${index}`)}
>
<span className={styles.issueDetailTracklistNum}>{String(track.track_number || '-')}</span>
<span className={styles.issueDetailTracklistTitle}>{String(track.title || 'Unknown')}</span>
<span className={styles.issueDetailTracklistDur}>{duration}</span>
<span className={styles.issueDetailTracklistMeta}>
<Show when={format}>
<span className={`${styles.issueTrackBadge} ${formatClassName}`}>{format}</span>
</Show>
<Show when={bitrate}>
<span className={`${styles.issueTrackBadge} ${bitrateClassName}`}>{bitrate}</span>
</Show>
</span>
</div>,
);
});
return nodes;
}
function getPriorityDotClassName(priority: string) {
if (priority === 'high') return styles.issuePriorityHigh;
if (priority === 'low') return styles.issuePriorityLow;
return styles.issuePriorityNormal;
}
function getTrackFormatClassName(format: string) {
const lower = format.toLowerCase();
if (lower === 'flac') return styles.issueTrackBadgeFlac;
if (lower === 'mp3') return styles.issueTrackBadgeMp3;
return styles.issueTrackBadgeOther;
}
function getTrackBitrateClassName(bitrate: number, format: string) {
const lower = format.toLowerCase();
if (!Number.isFinite(bitrate) || bitrate <= 0) return styles.issueTrackBadgeOther;
if (bitrate >= 320 || lower === 'flac') return styles.issueTrackBadgeHigh;
if (bitrate >= 192) return styles.issueTrackBadgeMedium;
return styles.issueTrackBadgeLow;
}
function getStatusClassName(status: string) {
if (status === 'in_progress') return styles.issueStatusProgress;
if (status === 'resolved') return styles.issueStatusResolved;
if (status === 'dismissed') return styles.issueStatusDismissed;
return styles.issueStatusOpen;
}
function notifyWorkflowError(error: unknown) {
const message = error instanceof Error ? error.message : 'Workflow failed';
window.showToast?.(message, 'error');
}
function formatDuration(value: unknown): string {
const duration = typeof value === 'number' ? value : Number(value);
if (!Number.isFinite(duration) || duration <= 0) return '';
const seconds = duration > 10000 ? Math.floor(duration / 1000) : Math.floor(duration);
const minutes = Math.floor(seconds / 60);
const remaining = seconds % 60;
return `${minutes}:${String(remaining).padStart(2, '0')}`;
}
function getExternalLinks(snapshot: ReturnType<typeof parseSnapshot>) {
const links: Array<{
className:
| 'issueExternalLinkSpotify'
| 'issueExternalLinkMusicBrainz'
| 'issueExternalLinkDeezer'
| 'issueExternalLinkTidal'
| 'issueExternalLinkQobuz';
id?: string | number;
label: string;
service: string;
type: string;
url?: string;
}> = [];
if (snapshot.spotify_artist_id) {
links.push({
className: 'issueExternalLinkSpotify',
label: 'Spotify Artist',
service: 'Spotify',
type: 'Artist',
url: `https://open.spotify.com/artist/${snapshot.spotify_artist_id}`,
});
}
if (snapshot.spotify_album_id) {
links.push({
className: 'issueExternalLinkSpotify',
label: 'Spotify Album',
service: 'Spotify',
type: 'Album',
url: `https://open.spotify.com/album/${snapshot.spotify_album_id}`,
});
}
if (snapshot.spotify_track_id) {
links.push({
className: 'issueExternalLinkSpotify',
label: 'Spotify Track',
service: 'Spotify',
type: 'Track',
url: `https://open.spotify.com/track/${snapshot.spotify_track_id}`,
});
}
if (snapshot.artist_musicbrainz_id) {
links.push({
className: 'issueExternalLinkMusicBrainz',
label: 'MusicBrainz Artist',
service: 'MusicBrainz',
type: 'Artist',
url: `https://musicbrainz.org/artist/${snapshot.artist_musicbrainz_id}`,
});
}
if (snapshot.musicbrainz_release_id) {
links.push({
className: 'issueExternalLinkMusicBrainz',
label: 'MusicBrainz Release',
service: 'MusicBrainz',
type: 'Release',
url: `https://musicbrainz.org/release/${snapshot.musicbrainz_release_id}`,
});
}
if (snapshot.musicbrainz_recording_id) {
links.push({
className: 'issueExternalLinkMusicBrainz',
label: 'MusicBrainz Recording',
service: 'MusicBrainz',
type: 'Recording',
url: `https://musicbrainz.org/recording/${snapshot.musicbrainz_recording_id}`,
});
}
if (snapshot.artist_deezer_id) {
links.push({
className: 'issueExternalLinkDeezer',
label: 'Deezer Artist',
service: 'Deezer',
type: 'Artist',
url: `https://www.deezer.com/artist/${snapshot.artist_deezer_id}`,
});
}
if (snapshot.album_deezer_id) {
links.push({
className: 'issueExternalLinkDeezer',
label: 'Deezer Album',
service: 'Deezer',
type: 'Album',
url: `https://www.deezer.com/album/${snapshot.album_deezer_id}`,
});
}
if (snapshot.track_deezer_id) {
links.push({
className: 'issueExternalLinkDeezer',
label: 'Deezer Track',
service: 'Deezer',
type: 'Track',
url: `https://www.deezer.com/track/${snapshot.track_deezer_id}`,
});
}
if (snapshot.artist_tidal_id) {
links.push({
className: 'issueExternalLinkTidal',
label: 'Tidal Artist',
service: 'Tidal',
type: 'Artist',
url: `https://listen.tidal.com/artist/${snapshot.artist_tidal_id}`,
});
}
if (snapshot.album_tidal_id) {
links.push({
className: 'issueExternalLinkTidal',
label: 'Tidal Album',
service: 'Tidal',
type: 'Album',
url: `https://listen.tidal.com/album/${snapshot.album_tidal_id}`,
});
}
if (snapshot.artist_qobuz_id) {
links.push({
className: 'issueExternalLinkQobuz',
id: snapshot.artist_qobuz_id,
label: 'Qobuz Artist',
service: 'Qobuz',
type: 'Artist',
});
}
if (snapshot.album_qobuz_id) {
links.push({
className: 'issueExternalLinkQobuz',
id: snapshot.album_qobuz_id,
label: 'Qobuz Album',
service: 'Qobuz',
type: 'Album',
});
}
return links;
}
function getAlbumMetaParts(
issue: IssueRecord,
snapshot: ReturnType<typeof parseSnapshot>,
): string[] {
if (issue.entity_type === 'artist') return [];
const parts: string[] = [];
if (snapshot.year) parts.push(String(snapshot.year));
if (snapshot.record_type) {
const recordType = String(snapshot.record_type);
parts.push(recordType.charAt(0).toUpperCase() + recordType.slice(1));
}
const trackCount =
issue.entity_type === 'album' ? snapshot.track_count : snapshot.album_track_count;
if (trackCount) parts.push(`${trackCount} tracks`);
if (snapshot.label) parts.push(String(snapshot.label));
return parts;
}
function getTrackMetaItems(snapshot: ReturnType<typeof parseSnapshot>) {
const items: Array<{ icon: string; label: string; value: string }> = [];
if (snapshot.track_number) {
items.push({
icon: '#',
label: 'Track',
value: String(snapshot.track_number),
});
}
const duration = formatDuration(snapshot.duration);
if (duration) items.push({ icon: 'T', label: 'Duration', value: duration });
if (snapshot.format) items.push({ icon: 'F', label: 'Format', value: String(snapshot.format) });
if (snapshot.bitrate)
items.push({
icon: 'B',
label: 'Bitrate',
value: `${snapshot.bitrate} kbps`,
});
if (snapshot.bpm) items.push({ icon: 'M', label: 'BPM', value: String(snapshot.bpm) });
if (snapshot.quality)
items.push({
icon: 'Q',
label: 'Quality',
value: String(snapshot.quality),
});
return items;
}