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.
pull/388/head
Antti Kettunen 2 weeks ago
parent d8f8c6b95c
commit 3df5e4b76d
No known key found for this signature in database
GPG Key ID: C6B2A3D250359BD7

@ -80,6 +80,10 @@ describe('issues route', () => {
artist_name: 'Artist',
thumb_url: 'https://example.com/thumb.jpg',
spotify_album_id: 'abc123',
track_number: 1,
duration: 245,
format: 'FLAC',
bitrate: 1411,
},
created_at: '2026-04-03 10:30:00',
reporter_name: 'Ada',
@ -101,14 +105,18 @@ describe('issues route', () => {
status: 'open',
priority: 'normal',
snapshot_data: {
title: 'Album Name',
artist_name: 'Artist',
thumb_url: 'https://example.com/thumb.jpg',
spotify_album_id: 'abc123',
title: 'Album Name',
artist_name: 'Artist',
thumb_url: 'https://example.com/thumb.jpg',
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')) {
@ -248,4 +256,11 @@ describe('issues route', () => {
).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();
});
});

@ -415,6 +415,44 @@
max-width: 750px;
width: 95vw;
max-height: 85vh;
display: flex;
flex-direction: column;
overflow: hidden;
border-radius: 16px;
}
.issueDetailModal .modalHeader {
padding: 20px 24px;
gap: 16px;
}
.issueDetailModal .modalHeaderTitle {
margin: 0;
font-size: 18px;
}
.issueDetailModal .modalClose {
width: 32px;
height: 32px;
border: none;
border-radius: 50%;
font-size: 16px;
}
.issueDetailModal .modalBody {
flex: 1 1 auto;
overflow-y: auto;
max-height: calc(85vh - 120px);
padding: 24px;
display: flex;
flex-direction: column;
gap: 16px;
}
.issueDetailModal .modalFooter {
padding: 16px 24px;
border-top: 1px solid rgba(255, 255, 255, 0.06);
gap: 8px;
}
.reportIssueModal {
@ -536,6 +574,22 @@
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 {
display: flex;
align-items: center;
@ -555,6 +609,10 @@
flex-wrap: wrap;
}
.issueDetailInfoRight {
font-size: 12px;
}
.issueDetailCategory {
font-size: 13px;
color: rgba(255, 255, 255, 0.7);
@ -585,6 +643,14 @@
font-weight: 500;
}
.issueDetailSectionCount {
font-weight: 400;
color: rgba(255, 255, 255, 0.3);
font-size: 10px;
text-transform: none;
margin-left: 4px;
}
.issueDetailTitleText {
font-size: 15px;
font-weight: 500;
@ -666,30 +732,61 @@
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 {
background: rgba(var(--accent-light-rgb), 0.12);
color: rgb(var(--accent-light-rgb));
border-color: rgba(var(--accent-light-rgb), 0.25);
}
.issueActionDownload:hover:not(:disabled) {
background: rgba(var(--accent-light-rgb), 0.22);
border-color: rgba(var(--accent-light-rgb), 0.4);
}
.issueActionWishlist {
background: rgba(255, 165, 0, 0.1);
color: #ffa500;
border-color: rgba(255, 165, 0, 0.2);
}
.issueActionWishlist:hover:not(:disabled) {
background: rgba(255, 165, 0, 0.2);
border-color: rgba(255, 165, 0, 0.35);
}
.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;
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 {
@ -706,12 +803,14 @@
.issueExternalLinks {
display: flex;
flex-wrap: wrap;
gap: 8px;
gap: 4px;
margin-top: 8px;
}
.issueExternalLink {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 10px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.06);
@ -725,29 +824,97 @@
background: rgba(255, 255, 255, 0.1);
}
.issueDetailTracklist {
display: grid;
gap: 6px;
.issueExternalLinkService {
font-weight: 600;
}
.issueDetailTracklistRow,
.issueDetailTracklistDisc {
.issueExternalLinkType {
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;
grid-template-columns: 42px minmax(0, 1fr) auto auto;
gap: 10px;
align-items: center;
padding: 8px 10px;
border-radius: 8px;
background: rgba(255, 255, 255, 0.03);
gap: 0;
background: rgba(255, 255, 255, 0.02);
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 {
display: block;
color: rgba(255, 255, 255, 0.45);
font-size: 10px;
text-transform: uppercase;
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,
@ -756,12 +923,36 @@
color: rgba(255, 255, 255, 0.42);
}
.issueDetailTracklistNum {
font-size: 12px;
min-width: 22px;
text-align: right;
flex-shrink: 0;
}
.issueDetailTracklistTitle {
font-size: 13px;
color: rgba(255, 255, 255, 0.75);
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
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 {
@ -772,7 +963,34 @@
background: rgba(255, 255, 255, 0.08);
color: rgba(255, 255, 255, 0.65);
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 {
@ -802,7 +1020,12 @@
}
.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 {
@ -819,31 +1042,31 @@
.modalButtonProgress {
background: rgba(77, 166, 255, 0.15);
color: #4da6ff;
border-color: rgba(77, 166, 255, 0.25);
}
.modalButtonResolve {
background: rgba(74, 222, 128, 0.15);
color: #4ade80;
border-color: rgba(74, 222, 128, 0.25);
}
.modalButtonDismiss {
background: rgba(239, 68, 68, 0.14);
color: #ffd0d0;
border-color: rgba(239, 68, 68, 0.28);
background: rgba(136, 136, 136, 0.15);
color: #888;
border-color: rgba(136, 136, 136, 0.3);
}
.modalButtonDismiss:hover:not(:disabled) {
background: rgba(136, 136, 136, 0.25);
}
.modalButtonReopen {
background: rgba(var(--accent-light-rgb), 0.15);
color: #fff;
border-color: rgba(var(--accent-light-rgb), 0.3);
}
.modalButtonDelete {
background: rgba(239, 68, 68, 0.14);
color: #ffd0d0;
border-color: rgba(239, 68, 68, 0.28);
}
.modalButtonDelete:hover:not(:disabled),
@ -934,6 +1157,18 @@
padding: 0 18px 18px;
}
.issueDetailModal {
max-height: calc(100vh - 24px);
}
.issueDetailModal .modalBody {
padding: 18px;
}
.issueDetailModal .modalFooter {
padding: 0 18px 18px;
}
.issueDetailMetaGrid {
grid-template-columns: repeat(1, minmax(0, 1fr));
}

@ -1,7 +1,7 @@
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 {
launchAlbumDownloadWorkflow,
launchAlbumWishlistWorkflow,
@ -13,9 +13,8 @@ import {
deleteIssue,
formatIssueDate,
formatStatusLabel,
getEntityDetails,
getEntityLabel,
getIssueArtwork,
getPriorityClassName,
ISSUE_CATEGORY_META,
parseSnapshot,
updateIssue,
@ -249,14 +248,18 @@ export function IssueDetailModal({
}
const snapshot = issue ? parseSnapshot(issue.snapshot_data) : {};
const issueDetails = issue ? getEntityDetails(issue, snapshot) : [];
const issueArtwork = getIssueArtwork(snapshot);
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 trackMetaItems = getTrackMetaItems(snapshot);
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 = {
spotifyAlbumId: String(snapshot.spotify_album_id || ''),
artistName: String(snapshot.artist_name || ''),
@ -325,22 +328,61 @@ export function IssueDetailModal({
<div className={styles.issueHeroArtist}>{String(snapshot.artist_name)}</div>
) : null}
<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>
{issue.entity_type === 'track' ? (
<div className={styles.issueHeroTrackName}> {issue.title}</div>
) : null}
{issue.entity_type === 'artist' && snapshot.name ? (
<div className={styles.issueHeroTrackName}>{String(snapshot.name)}</div>
{issue.entity_type !== 'artist' && albumMetaParts.length > 0 ? (
<div className={styles.issueHeroMeta}>{albumMetaParts.join(' - ')}</div>
) : null}
{issueDetails.length > 0 ? (
<div className={styles.issueHeroMeta}>{issueDetails.join(' · ')}</div>
{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)}`}
>
@ -350,16 +392,45 @@ export function IssueDetailModal({
</div>
<div className={styles.issueDetailInfoRight}>
<span className={styles.issueDetailDate}>
Created {formatIssueDate(issue.created_at)}
Reported {formatIssueDate(issue.created_at)}
</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>
) : 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 Details</div>
<div className={styles.issueDetailSectionTitle}>Issue</div>
<div className={styles.issueDetailTitleText}>{issue.title}</div>
<div
className={
@ -370,100 +441,7 @@ export function IssueDetailModal({
</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 ? (
{issue.entity_type === 'track' && trackMetaItems.length > 0 ? (
<div className={styles.issueDetailSection}>
<div className={styles.issueDetailSectionTitle}>Track Details</div>
<div className={styles.issueDetailMetaGrid}>
@ -488,79 +466,25 @@ export function IssueDetailModal({
{trackRows.length > 0 ? (
<div className={styles.issueDetailSection}>
<div className={styles.issueDetailSectionTitle}>
Track Listing ({trackRows.length} tracks)
Track Listing <span className={styles.issueDetailSectionCount}>{trackRows.length} tracks</span>
</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>
);
})}
{renderTrackListing(trackRows)}
</div>
</div>
) : null}
{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>
)}
{isAdmin && (
<div className={styles.issueDetailSection}>
<FormField
label="Admin Response"
htmlFor="issue-detail-response-input"
helperText="Write a response to the reporter."
>
<TextArea
className={styles.issueDetailResponseTextarea}
id="issue-detail-response-input"
value={adminResponse}
onChange={(event) => setAdminResponse(event.target.value)}
placeholder="Write a response to the reporter..."
/>
</FormField>
<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>
)}
@ -618,6 +542,73 @@ function getFocusableElements(container: HTMLElement) {
).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) {
if (status === 'in_progress') return styles.issueStatusProgress;
if (status === 'resolved') return styles.issueStatusResolved;
@ -640,76 +631,162 @@ function formatDuration(value: unknown): string {
}
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) {
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) {

Loading…
Cancel
Save