@ -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 . issueHero TrackName} > { String ( snapshot . name ) } < / div >
{ issue . entity_type !== 'artist' && albumMetaParts . length > 0 ? (
< div className = { styles . issueHero Meta} > { 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 } >
Crea ted { formatIssueDate ( issue . created_at ) }
Repor ted { 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 ) {