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