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:
parent
d8f8c6b95c
commit
3df5e4b76d
3 changed files with 553 additions and 226 deletions
|
|
@ -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,178 +392,19 @@ 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>
|
||||
|
||||
<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 && (
|
||||
<div className={styles.issueDetailSection}>
|
||||
<div className={styles.issueDetailSectionTitle}>Admin Actions</div>
|
||||
|
|
@ -546,21 +429,62 @@ export function IssueDetailModal({
|
|||
</div>
|
||||
)}
|
||||
|
||||
<div className={styles.issueDetailSection}>
|
||||
<div className={styles.issueDetailSectionTitle}>Issue</div>
|
||||
<div className={styles.issueDetailTitleText}>{issue.title}</div>
|
||||
<div
|
||||
className={
|
||||
issue.description ? styles.issueDetailDescription : styles.issueDetailNoDesc
|
||||
}
|
||||
>
|
||||
{issue.description || 'No additional details provided'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{issue.entity_type === 'track' && trackMetaItems.length > 0 ? (
|
||||
<div className={styles.issueDetailSection}>
|
||||
<div className={styles.issueDetailSectionTitle}>Track Details</div>
|
||||
<div className={styles.issueDetailMetaGrid}>
|
||||
{trackMetaItems.map((item) => (
|
||||
<div className={styles.issueMetaItem} key={item.label}>
|
||||
<span className={styles.issueMetaIcon}>{item.icon}</span>
|
||||
<span className={styles.issueMetaLabel}>{item.label}</span>
|
||||
<span className={styles.issueMetaValue}>{item.value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{snapshot.file_path ? (
|
||||
<div className={styles.issueDetailSection}>
|
||||
<div className={styles.issueDetailSectionTitle}>File Path</div>
|
||||
<div className={styles.issueDetailFilepath}>{String(snapshot.file_path)}</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{trackRows.length > 0 ? (
|
||||
<div className={styles.issueDetailSection}>
|
||||
<div className={styles.issueDetailSectionTitle}>
|
||||
Track Listing <span className={styles.issueDetailSectionCount}>{trackRows.length} tracks</span>
|
||||
</div>
|
||||
<div className={styles.issueDetailTracklist}>
|
||||
{renderTrackListing(trackRows)}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{isAdmin && (
|
||||
<div className={styles.issueDetailSection}>
|
||||
<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…
Reference in a new issue