Match the legacy issue modal

- Restore the shell-era issue detail layout and hero ordering.
- Keep external links color-coded by service.
- Hide track details for album issues and keep the track list compact.
- Restore legacy track-list badge colors for format and bitrate.
- Match the neutral dismiss button styling from the old modal.
- Add regression coverage for the album issue modal state.
This commit is contained in:
Antti Kettunen 2026-05-02 16:58:33 +03:00
parent d8f8c6b95c
commit 3df5e4b76d
No known key found for this signature in database
GPG key ID: C6B2A3D250359BD7
3 changed files with 553 additions and 226 deletions

View file

@ -80,6 +80,10 @@ describe('issues route', () => {
artist_name: 'Artist', artist_name: 'Artist',
thumb_url: 'https://example.com/thumb.jpg', thumb_url: 'https://example.com/thumb.jpg',
spotify_album_id: 'abc123', spotify_album_id: 'abc123',
track_number: 1,
duration: 245,
format: 'FLAC',
bitrate: 1411,
}, },
created_at: '2026-04-03 10:30:00', created_at: '2026-04-03 10:30:00',
reporter_name: 'Ada', reporter_name: 'Ada',
@ -101,14 +105,18 @@ describe('issues route', () => {
status: 'open', status: 'open',
priority: 'normal', priority: 'normal',
snapshot_data: { snapshot_data: {
title: 'Album Name', title: 'Album Name',
artist_name: 'Artist', artist_name: 'Artist',
thumb_url: 'https://example.com/thumb.jpg', thumb_url: 'https://example.com/thumb.jpg',
spotify_album_id: 'abc123', spotify_album_id: 'abc123',
track_number: 1,
duration: 245,
format: 'FLAC',
bitrate: 1411,
},
created_at: '2026-04-03 10:30:00',
reporter_name: 'Ada',
}, },
created_at: '2026-04-03 10:30:00',
reporter_name: 'Ada',
},
}); });
} }
if (url.includes('/api/spotify/album/abc123')) { if (url.includes('/api/spotify/album/abc123')) {
@ -248,4 +256,11 @@ describe('issues route', () => {
).toBe(true); ).toBe(true);
}); });
}); });
it('does not render track details for album issues', async () => {
renderIssuesRoute(['/issues?issueId=7']);
await waitFor(() => expect(screen.getByRole('dialog')).toHaveTextContent('Issue #7'));
expect(screen.queryByText('Track Details')).not.toBeInTheDocument();
});
}); });

View file

@ -415,6 +415,44 @@
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 {
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 { .reportIssueModal {
@ -536,6 +574,22 @@
margin-top: 2px; margin-top: 2px;
} }
.issueHeroGenres {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin-top: 4px;
}
.issueHeroGenreTag {
font-size: 10px;
padding: 2px 8px;
border-radius: 10px;
background: rgba(255, 255, 255, 0.06);
color: rgba(255, 255, 255, 0.55);
border: 1px solid rgba(255, 255, 255, 0.08);
}
.issueDetailInfoBar { .issueDetailInfoBar {
display: flex; display: flex;
align-items: center; align-items: center;
@ -555,6 +609,10 @@
flex-wrap: wrap; flex-wrap: wrap;
} }
.issueDetailInfoRight {
font-size: 12px;
}
.issueDetailCategory { .issueDetailCategory {
font-size: 13px; font-size: 13px;
color: rgba(255, 255, 255, 0.7); color: rgba(255, 255, 255, 0.7);
@ -585,6 +643,14 @@
font-weight: 500; font-weight: 500;
} }
.issueDetailSectionCount {
font-weight: 400;
color: rgba(255, 255, 255, 0.3);
font-size: 10px;
text-transform: none;
margin-left: 4px;
}
.issueDetailTitleText { .issueDetailTitleText {
font-size: 15px; font-size: 15px;
font-weight: 500; font-weight: 500;
@ -666,30 +732,61 @@
margin-bottom: 14px; margin-bottom: 14px;
} }
.issueActionDownload,
.issueActionWishlist,
.modalButtonSecondary,
.modalButtonPrimary,
.modalButtonProgress,
.modalButtonResolve,
.modalButtonDismiss,
.modalButtonReopen,
.modalButtonDelete {
min-height: auto;
padding: 8px 16px;
border: none;
border-radius: 8px;
font-size: 12px;
font-weight: 600;
line-height: 1.2;
}
.issueActionDownload { .issueActionDownload {
background: rgba(var(--accent-light-rgb), 0.12); background: rgba(var(--accent-light-rgb), 0.12);
color: rgb(var(--accent-light-rgb)); color: rgb(var(--accent-light-rgb));
border-color: rgba(var(--accent-light-rgb), 0.25);
} }
.issueActionDownload:hover:not(:disabled) { .issueActionDownload:hover:not(:disabled) {
background: rgba(var(--accent-light-rgb), 0.22); background: rgba(var(--accent-light-rgb), 0.22);
border-color: rgba(var(--accent-light-rgb), 0.4);
} }
.issueActionWishlist { .issueActionWishlist {
background: rgba(255, 165, 0, 0.1); background: rgba(255, 165, 0, 0.1);
color: #ffa500; color: #ffa500;
border-color: rgba(255, 165, 0, 0.2);
} }
.issueActionWishlist:hover:not(:disabled) { .issueActionWishlist:hover:not(:disabled) {
background: rgba(255, 165, 0, 0.2); background: rgba(255, 165, 0, 0.2);
border-color: rgba(255, 165, 0, 0.35);
} }
.issueDetailResponseTextarea { .issueDetailResponseTextarea {
width: 100%;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
color: #fff;
padding: 10px 12px;
border-radius: 8px;
font-size: 13px;
outline: none;
resize: vertical;
min-height: 70px; min-height: 70px;
font-family: inherit;
transition: border-color 0.2s;
box-sizing: border-box;
line-height: 1.5;
}
.issueDetailResponseTextarea:focus {
border-color: rgba(var(--accent-light-rgb), 0.5);
} }
.issueDetailAdminResponse { .issueDetailAdminResponse {
@ -706,12 +803,14 @@
.issueExternalLinks { .issueExternalLinks {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
gap: 8px; gap: 4px;
margin-top: 8px;
} }
.issueExternalLink { .issueExternalLink {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 6px;
padding: 6px 10px; padding: 6px 10px;
border-radius: 999px; border-radius: 999px;
background: rgba(255, 255, 255, 0.06); background: rgba(255, 255, 255, 0.06);
@ -725,29 +824,97 @@
background: rgba(255, 255, 255, 0.1); background: rgba(255, 255, 255, 0.1);
} }
.issueDetailTracklist { .issueExternalLinkService {
display: grid; font-weight: 600;
gap: 6px;
} }
.issueDetailTracklistRow, .issueExternalLinkType {
.issueDetailTracklistDisc { font-size: 10px;
font-weight: 400;
opacity: 0.85;
}
.issueExternalLinkSpotify {
background: rgba(30, 215, 96, 0.1);
color: #1ed760;
}
.issueExternalLinkSpotify:hover {
background: rgba(30, 215, 96, 0.2);
}
.issueExternalLinkMusicBrainz {
background: rgba(186, 81, 163, 0.1);
color: #d4a0cb;
}
.issueExternalLinkMusicBrainz:hover {
background: rgba(186, 81, 163, 0.2);
}
.issueExternalLinkDeezer {
background: rgba(160, 54, 255, 0.1);
color: #c88eff;
}
.issueExternalLinkDeezer:hover {
background: rgba(160, 54, 255, 0.2);
}
.issueExternalLinkTidal {
background: rgba(0, 255, 255, 0.08);
color: #66ffff;
}
.issueExternalLinkTidal:hover {
background: rgba(0, 255, 255, 0.15);
}
.issueExternalLinkQobuz {
background: rgba(0, 130, 200, 0.1);
color: #5bb8e8;
}
.issueExternalLinkQobuz:hover {
background: rgba(0, 130, 200, 0.2);
}
.issueDetailTracklist {
display: grid; display: grid;
grid-template-columns: 42px minmax(0, 1fr) auto auto; gap: 0;
gap: 10px; background: rgba(255, 255, 255, 0.02);
align-items: center;
padding: 8px 10px;
border-radius: 8px;
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.05); border: 1px solid rgba(255, 255, 255, 0.05);
font-size: 12px; border-radius: 10px;
padding: 6px 0;
max-height: 240px;
overflow-y: auto;
} }
.issueDetailTracklistDisc { .issueDetailTracklistDisc {
display: block; display: block;
color: rgba(255, 255, 255, 0.45); font-size: 10px;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.5px; letter-spacing: 0.5px;
color: rgba(255, 255, 255, 0.35);
padding: 8px 14px 4px;
border-top: 1px solid rgba(255, 255, 255, 0.04);
}
.issueDetailTracklistDisc:first-child {
border-top: none;
padding-top: 4px;
}
.issueDetailTracklistRow {
display: flex;
align-items: center;
padding: 4px 14px;
gap: 10px;
transition: background 0.15s;
}
.issueDetailTracklistRow:hover {
background: rgba(255, 255, 255, 0.03);
} }
.issueDetailTracklistNum, .issueDetailTracklistNum,
@ -756,12 +923,36 @@
color: rgba(255, 255, 255, 0.42); color: rgba(255, 255, 255, 0.42);
} }
.issueDetailTracklistNum {
font-size: 12px;
min-width: 22px;
text-align: right;
flex-shrink: 0;
}
.issueDetailTracklistTitle { .issueDetailTracklistTitle {
font-size: 13px;
color: rgba(255, 255, 255, 0.75);
flex: 1;
min-width: 0; min-width: 0;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
color: rgba(255, 255, 255, 0.82); }
.issueDetailTracklistDur {
font-size: 11px;
color: rgba(255, 255, 255, 0.3);
flex-shrink: 0;
min-width: 36px;
text-align: right;
}
.issueDetailTracklistMeta {
display: flex;
align-items: center;
gap: 6px;
flex-shrink: 0;
} }
.issueTrackBadge { .issueTrackBadge {
@ -772,7 +963,34 @@
background: rgba(255, 255, 255, 0.08); background: rgba(255, 255, 255, 0.08);
color: rgba(255, 255, 255, 0.65); color: rgba(255, 255, 255, 0.65);
font-size: 10px; font-size: 10px;
margin-left: 4px; margin-left: 0;
}
.issueTrackBadgeFlac {
background: rgba(255, 184, 77, 0.15);
color: #ffb84d;
}
.issueTrackBadgeMp3 {
background: rgba(var(--accent-rgb), 0.15);
color: rgb(var(--accent-light-rgb));
}
.issueTrackBadgeOther {
background: rgba(255, 255, 255, 0.08);
color: rgba(255, 255, 255, 0.5);
}
.issueTrackBadgeHigh {
color: rgb(var(--accent-light-rgb));
}
.issueTrackBadgeMedium {
color: #ffb84d;
}
.issueTrackBadgeLow {
color: #ff6b6b;
} }
.reportIssueEntityInfo { .reportIssueEntityInfo {
@ -802,7 +1020,12 @@
} }
.modalButtonSecondary { .modalButtonSecondary {
background: rgba(255, 255, 255, 0.04); background: rgba(255, 255, 255, 0.1);
color: rgba(255, 255, 255, 0.8);
}
.modalButtonSecondary:hover:not(:disabled) {
background: rgba(255, 255, 255, 0.15);
} }
.modalButtonPrimary { .modalButtonPrimary {
@ -819,31 +1042,31 @@
.modalButtonProgress { .modalButtonProgress {
background: rgba(77, 166, 255, 0.15); background: rgba(77, 166, 255, 0.15);
color: #4da6ff; color: #4da6ff;
border-color: rgba(77, 166, 255, 0.25);
} }
.modalButtonResolve { .modalButtonResolve {
background: rgba(74, 222, 128, 0.15); background: rgba(74, 222, 128, 0.15);
color: #4ade80; color: #4ade80;
border-color: rgba(74, 222, 128, 0.25);
} }
.modalButtonDismiss { .modalButtonDismiss {
background: rgba(239, 68, 68, 0.14); background: rgba(136, 136, 136, 0.15);
color: #ffd0d0; color: #888;
border-color: rgba(239, 68, 68, 0.28); border-color: rgba(136, 136, 136, 0.3);
}
.modalButtonDismiss:hover:not(:disabled) {
background: rgba(136, 136, 136, 0.25);
} }
.modalButtonReopen { .modalButtonReopen {
background: rgba(var(--accent-light-rgb), 0.15); background: rgba(var(--accent-light-rgb), 0.15);
color: #fff; color: #fff;
border-color: rgba(var(--accent-light-rgb), 0.3);
} }
.modalButtonDelete { .modalButtonDelete {
background: rgba(239, 68, 68, 0.14); background: rgba(239, 68, 68, 0.14);
color: #ffd0d0; color: #ffd0d0;
border-color: rgba(239, 68, 68, 0.28);
} }
.modalButtonDelete:hover:not(:disabled), .modalButtonDelete:hover:not(:disabled),
@ -934,6 +1157,18 @@
padding: 0 18px 18px; padding: 0 18px 18px;
} }
.issueDetailModal {
max-height: calc(100vh - 24px);
}
.issueDetailModal .modalBody {
padding: 18px;
}
.issueDetailModal .modalFooter {
padding: 0 18px 18px;
}
.issueDetailMetaGrid { .issueDetailMetaGrid {
grid-template-columns: repeat(1, minmax(0, 1fr)); grid-template-columns: repeat(1, minmax(0, 1fr));
} }

View file

@ -1,7 +1,7 @@
import { useMutation } from '@tanstack/react-query'; import { useMutation } from '@tanstack/react-query';
import { useEffect, useMemo, useRef, useState } from 'react'; import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
import { Button, FormField, TextArea } from '@/components/form'; import { Button } from '@/components/form';
import { import {
launchAlbumDownloadWorkflow, launchAlbumDownloadWorkflow,
launchAlbumWishlistWorkflow, launchAlbumWishlistWorkflow,
@ -13,9 +13,8 @@ import {
deleteIssue, deleteIssue,
formatIssueDate, formatIssueDate,
formatStatusLabel, formatStatusLabel,
getEntityDetails,
getEntityLabel,
getIssueArtwork, getIssueArtwork,
getPriorityClassName,
ISSUE_CATEGORY_META, ISSUE_CATEGORY_META,
parseSnapshot, parseSnapshot,
updateIssue, updateIssue,
@ -249,14 +248,18 @@ export function IssueDetailModal({
} }
const snapshot = issue ? parseSnapshot(issue.snapshot_data) : {}; const snapshot = issue ? parseSnapshot(issue.snapshot_data) : {};
const issueDetails = issue ? getEntityDetails(issue, snapshot) : [];
const issueArtwork = getIssueArtwork(snapshot); const issueArtwork = getIssueArtwork(snapshot);
const issueCategoryLabel = issue const issueCategoryLabel = issue
? ISSUE_CATEGORY_META[issue.category]?.label || issue.category ? `${ISSUE_CATEGORY_META[issue.category]?.icon || ''} ${
ISSUE_CATEGORY_META[issue.category]?.label || issue.category
}`.trim()
: ''; : '';
const externalLinks = getExternalLinks(snapshot); const externalLinks = getExternalLinks(snapshot);
const trackMetaItems = getTrackMetaItems(snapshot); const trackMetaItems = getTrackMetaItems(snapshot);
const trackRows = Array.isArray(snapshot.tracks) ? snapshot.tracks : []; const trackRows = Array.isArray(snapshot.tracks) ? snapshot.tracks : [];
const priorityClassName = issue ? getPriorityClassName(issue.priority) : 'normal';
const albumMetaParts = issue ? getAlbumMetaParts(issue, snapshot) : [];
const genreTags = Array.isArray(snapshot.genres) ? snapshot.genres.slice(0, 5) : [];
const albumWorkflowInput = { const albumWorkflowInput = {
spotifyAlbumId: String(snapshot.spotify_album_id || ''), spotifyAlbumId: String(snapshot.spotify_album_id || ''),
artistName: String(snapshot.artist_name || ''), artistName: String(snapshot.artist_name || ''),
@ -325,22 +328,61 @@ export function IssueDetailModal({
<div className={styles.issueHeroArtist}>{String(snapshot.artist_name)}</div> <div className={styles.issueHeroArtist}>{String(snapshot.artist_name)}</div>
) : null} ) : null}
<div className={styles.issueHeroAlbum}> <div className={styles.issueHeroAlbum}>
{String(snapshot.album_title || snapshot.title || issue.title)} {String(
issue.entity_type === 'artist'
? snapshot.name || issue.title
: snapshot.album_title || snapshot.title || issue.title,
)}
</div> </div>
{issue.entity_type === 'track' ? ( {issue.entity_type === 'track' ? (
<div className={styles.issueHeroTrackName}> {issue.title}</div> <div className={styles.issueHeroTrackName}> {issue.title}</div>
) : null} ) : null}
{issue.entity_type === 'artist' && snapshot.name ? ( {issue.entity_type !== 'artist' && albumMetaParts.length > 0 ? (
<div className={styles.issueHeroTrackName}>{String(snapshot.name)}</div> <div className={styles.issueHeroMeta}>{albumMetaParts.join(' - ')}</div>
) : null} ) : null}
{issueDetails.length > 0 ? ( {genreTags.length > 0 ? (
<div className={styles.issueHeroMeta}>{issueDetails.join(' · ')}</div> <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} ) : null}
</div> </div>
</div> </div>
<div className={styles.issueDetailInfoBar}> <div className={styles.issueDetailInfoBar}>
<div className={styles.issueDetailInfoLeft}> <div className={styles.issueDetailInfoLeft}>
<span className={`${styles.issuePriorityDot} ${getPriorityDotClassName(priorityClassName)}`} />
<span <span
className={`${styles.issueStatusBadge} ${getStatusClassName(issue.status)}`} className={`${styles.issueStatusBadge} ${getStatusClassName(issue.status)}`}
> >
@ -350,178 +392,19 @@ export function IssueDetailModal({
</div> </div>
<div className={styles.issueDetailInfoRight}> <div className={styles.issueDetailInfoRight}>
<span className={styles.issueDetailDate}> <span className={styles.issueDetailDate}>
Created {formatIssueDate(issue.created_at)} Reported {formatIssueDate(issue.created_at)}
</span> </span>
{issue.reporter_name ? ( {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> <span className={styles.issueDetailProfile}>by {issue.reporter_name}</span>
) : null} ) : null}
</div> </div>
</div> </div>
<div className={styles.issueDetailSection}>
<div className={styles.issueDetailSectionTitle}>Issue Details</div>
<div className={styles.issueDetailTitleText}>{issue.title}</div>
<div
className={
issue.description ? styles.issueDetailDescription : styles.issueDetailNoDesc
}
>
{issue.description || 'No additional details provided'}
</div>
</div>
<div className={styles.issueDetailSection}>
<div className={styles.issueDetailSectionTitle}>Context</div>
<div className={styles.issueDetailMetaGrid}>
<div className={styles.issueMetaItem}>
<span className={styles.issueMetaIcon}></span>
<span className={styles.issueMetaLabel}>Entity</span>
<span className={styles.issueMetaValue}>
{getEntityLabel(issue.entity_type)}
</span>
</div>
<div className={styles.issueMetaItem}>
<span className={styles.issueMetaIcon}>#</span>
<span className={styles.issueMetaLabel}>Entity ID</span>
<span className={styles.issueMetaValue}>{issue.entity_id}</span>
</div>
<div className={styles.issueMetaItem}>
<span className={styles.issueMetaIcon}></span>
<span className={styles.issueMetaLabel}>Category</span>
<span className={styles.issueMetaValue}>
{ISSUE_CATEGORY_META[issue.category]?.label || issue.category}
</span>
</div>
<div className={styles.issueMetaItem}>
<span className={styles.issueMetaIcon}>!</span>
<span className={styles.issueMetaLabel}>Priority</span>
<span className={styles.issueMetaValue}>{issue.priority}</span>
</div>
<div className={styles.issueMetaItem}>
<span className={styles.issueMetaIcon}></span>
<span className={styles.issueMetaLabel}>Status</span>
<span className={styles.issueMetaValue}>{formatStatusLabel(issue.status)}</span>
</div>
<div className={styles.issueMetaItem}>
<span className={styles.issueMetaIcon}></span>
<span className={styles.issueMetaLabel}>Created</span>
<span className={styles.issueMetaValue}>
{formatIssueDate(issue.created_at)}
</span>
</div>
{issue.updated_at ? (
<div className={styles.issueMetaItem}>
<span className={styles.issueMetaIcon}>U</span>
<span className={styles.issueMetaLabel}>Updated</span>
<span className={styles.issueMetaValue}>
{formatIssueDate(issue.updated_at)}
</span>
</div>
) : null}
{issue.resolved_at ? (
<div className={styles.issueMetaItem}>
<span className={styles.issueMetaIcon}>R</span>
<span className={styles.issueMetaLabel}>Resolved</span>
<span className={styles.issueMetaValue}>
{formatIssueDate(issue.resolved_at)}
</span>
</div>
) : null}
{issue.resolved_by ? (
<div className={styles.issueMetaItem}>
<span className={styles.issueMetaIcon}>A</span>
<span className={styles.issueMetaLabel}>Resolver</span>
<span className={styles.issueMetaValue}>{issue.resolved_by}</span>
</div>
) : null}
{issue.reporter_name ? (
<div className={styles.issueMetaItem}>
<span className={styles.issueMetaIcon}>P</span>
<span className={styles.issueMetaLabel}>Reporter</span>
<span className={styles.issueMetaValue}>{issue.reporter_name}</span>
</div>
) : null}
</div>
</div>
{externalLinks.length > 0 ? (
<div className={styles.issueDetailSection}>
<div className={styles.issueDetailSectionTitle}>External Links</div>
<div className={styles.issueExternalLinks}>
{externalLinks.map((link) => (
<a
key={link.url}
className={styles.issueExternalLink}
href={link.url}
target="_blank"
rel="noreferrer"
>
{link.label}
</a>
))}
</div>
</div>
) : null}
{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 ({trackRows.length} tracks)
</div>
<div className={styles.issueDetailTracklist}>
{trackRows.map((track, index) => {
const format = String(track.format || '').toUpperCase();
const bitrate = track.bitrate ? `${track.bitrate}k` : '';
const duration = formatDuration(track.duration);
return (
<div
className={styles.issueDetailTracklistRow}
key={String(track.id || `${track.title}-${index}`)}
>
<span className={styles.issueDetailTracklistNum}>
{String(track.track_number || index + 1)}
</span>
<span className={styles.issueDetailTracklistTitle}>
{String(track.title || 'Unknown')}
</span>
<span className={styles.issueDetailTracklistDur}>{duration}</span>
<span className={styles.issueDetailTracklistMeta}>
{format ? (
<span className={styles.issueTrackBadge}>{format}</span>
) : null}
{bitrate ? (
<span className={styles.issueTrackBadge}>{bitrate}</span>
) : null}
</span>
</div>
);
})}
</div>
</div>
) : null}
{issue.entity_type !== 'artist' && isAdmin && ( {issue.entity_type !== 'artist' && isAdmin && (
<div className={styles.issueDetailSection}> <div className={styles.issueDetailSection}>
<div className={styles.issueDetailSectionTitle}>Admin Actions</div> <div className={styles.issueDetailSectionTitle}>Admin Actions</div>
@ -546,21 +429,62 @@ export function IssueDetailModal({
</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 && ( {isAdmin && (
<div className={styles.issueDetailSection}> <div className={styles.issueDetailSection}>
<FormField <div className={styles.issueDetailSectionTitle}>Admin Response</div>
label="Admin Response" <textarea
htmlFor="issue-detail-response-input" className={styles.issueDetailResponseTextarea}
helperText="Write a response to the reporter." id="issue-detail-response-input"
> value={adminResponse}
<TextArea onChange={(event) => setAdminResponse(event.target.value)}
className={styles.issueDetailResponseTextarea} placeholder="Write a response to the reporter..."
id="issue-detail-response-input" rows={3}
value={adminResponse} />
onChange={(event) => setAdminResponse(event.target.value)}
placeholder="Write a response to the reporter..."
/>
</FormField>
</div> </div>
)} )}
@ -618,6 +542,73 @@ function getFocusableElements(container: HTMLElement) {
).filter((element) => element.tabIndex >= 0); ).filter((element) => element.tabIndex >= 0);
} }
function renderTrackListing(trackRows: Array<Record<string, unknown>>) {
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}>
{format ? (
<span className={`${styles.issueTrackBadge} ${formatClassName}`}>{format}</span>
) : null}
{bitrate ? (
<span className={`${styles.issueTrackBadge} ${bitrateClassName}`}>{bitrate}</span>
) : null}
</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) { function getStatusClassName(status: string) {
if (status === 'in_progress') return styles.issueStatusProgress; if (status === 'in_progress') return styles.issueStatusProgress;
if (status === 'resolved') return styles.issueStatusResolved; if (status === 'resolved') return styles.issueStatusResolved;
@ -640,76 +631,162 @@ function formatDuration(value: unknown): string {
} }
function getExternalLinks(snapshot: ReturnType<typeof parseSnapshot>) { function getExternalLinks(snapshot: ReturnType<typeof parseSnapshot>) {
const links: Array<{ label: string; url: string }> = []; 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) { if (snapshot.spotify_artist_id) {
links.push({ links.push({
className: 'issueExternalLinkSpotify',
label: 'Spotify Artist', label: 'Spotify Artist',
service: 'Spotify',
type: 'Artist',
url: `https://open.spotify.com/artist/${snapshot.spotify_artist_id}`, url: `https://open.spotify.com/artist/${snapshot.spotify_artist_id}`,
}); });
} }
if (snapshot.spotify_album_id) { if (snapshot.spotify_album_id) {
links.push({ links.push({
className: 'issueExternalLinkSpotify',
label: 'Spotify Album', label: 'Spotify Album',
service: 'Spotify',
type: 'Album',
url: `https://open.spotify.com/album/${snapshot.spotify_album_id}`, url: `https://open.spotify.com/album/${snapshot.spotify_album_id}`,
}); });
} }
if (snapshot.spotify_track_id) { if (snapshot.spotify_track_id) {
links.push({ links.push({
className: 'issueExternalLinkSpotify',
label: 'Spotify Track', label: 'Spotify Track',
service: 'Spotify',
type: 'Track',
url: `https://open.spotify.com/track/${snapshot.spotify_track_id}`, url: `https://open.spotify.com/track/${snapshot.spotify_track_id}`,
}); });
} }
if (snapshot.artist_musicbrainz_id) { if (snapshot.artist_musicbrainz_id) {
links.push({ links.push({
className: 'issueExternalLinkMusicBrainz',
label: 'MusicBrainz Artist', label: 'MusicBrainz Artist',
service: 'MusicBrainz',
type: 'Artist',
url: `https://musicbrainz.org/artist/${snapshot.artist_musicbrainz_id}`, url: `https://musicbrainz.org/artist/${snapshot.artist_musicbrainz_id}`,
}); });
} }
if (snapshot.musicbrainz_release_id) { if (snapshot.musicbrainz_release_id) {
links.push({ links.push({
className: 'issueExternalLinkMusicBrainz',
label: 'MusicBrainz Release', label: 'MusicBrainz Release',
service: 'MusicBrainz',
type: 'Release',
url: `https://musicbrainz.org/release/${snapshot.musicbrainz_release_id}`, url: `https://musicbrainz.org/release/${snapshot.musicbrainz_release_id}`,
}); });
} }
if (snapshot.musicbrainz_recording_id) { if (snapshot.musicbrainz_recording_id) {
links.push({ links.push({
className: 'issueExternalLinkMusicBrainz',
label: 'MusicBrainz Recording', label: 'MusicBrainz Recording',
service: 'MusicBrainz',
type: 'Recording',
url: `https://musicbrainz.org/recording/${snapshot.musicbrainz_recording_id}`, url: `https://musicbrainz.org/recording/${snapshot.musicbrainz_recording_id}`,
}); });
} }
if (snapshot.artist_deezer_id) { if (snapshot.artist_deezer_id) {
links.push({ links.push({
className: 'issueExternalLinkDeezer',
label: 'Deezer Artist', label: 'Deezer Artist',
service: 'Deezer',
type: 'Artist',
url: `https://www.deezer.com/artist/${snapshot.artist_deezer_id}`, url: `https://www.deezer.com/artist/${snapshot.artist_deezer_id}`,
}); });
} }
if (snapshot.album_deezer_id) { if (snapshot.album_deezer_id) {
links.push({ links.push({
className: 'issueExternalLinkDeezer',
label: 'Deezer Album', label: 'Deezer Album',
service: 'Deezer',
type: 'Album',
url: `https://www.deezer.com/album/${snapshot.album_deezer_id}`, url: `https://www.deezer.com/album/${snapshot.album_deezer_id}`,
}); });
} }
if (snapshot.track_deezer_id) { if (snapshot.track_deezer_id) {
links.push({ links.push({
className: 'issueExternalLinkDeezer',
label: 'Deezer Track', label: 'Deezer Track',
service: 'Deezer',
type: 'Track',
url: `https://www.deezer.com/track/${snapshot.track_deezer_id}`, url: `https://www.deezer.com/track/${snapshot.track_deezer_id}`,
}); });
} }
if (snapshot.artist_tidal_id) { if (snapshot.artist_tidal_id) {
links.push({ links.push({
className: 'issueExternalLinkTidal',
label: 'Tidal Artist', label: 'Tidal Artist',
service: 'Tidal',
type: 'Artist',
url: `https://listen.tidal.com/artist/${snapshot.artist_tidal_id}`, url: `https://listen.tidal.com/artist/${snapshot.artist_tidal_id}`,
}); });
} }
if (snapshot.album_tidal_id) { if (snapshot.album_tidal_id) {
links.push({ links.push({
className: 'issueExternalLinkTidal',
label: 'Tidal Album', label: 'Tidal Album',
service: 'Tidal',
type: 'Album',
url: `https://listen.tidal.com/album/${snapshot.album_tidal_id}`, 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; 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>) { function getTrackMetaItems(snapshot: ReturnType<typeof parseSnapshot>) {
const items: Array<{ icon: string; label: string; value: string }> = []; const items: Array<{ icon: string; label: string; value: string }> = [];
if (snapshot.track_number) { if (snapshot.track_number) {