Migrate issue domain to React

- Mount a React-owned issue domain host and bridge report issue actions through it
- Add typed issue creation helpers, report payload types, and shared album workflow launchers
- Expand issue detail UI parity with metadata, links, track details, and admin actions
- Remove legacy static issue modal/list/detail code and update tests for the React bridge
pull/388/head
Antti Kettunen 2 months ago
parent 43db30608d
commit 577e4bdace
No known key found for this signature in database
GPG Key ID: C6B2A3D250359BD7

@ -6150,23 +6150,6 @@
</div>
</div>
<!-- Report Issue Modal -->
<div class="modal-overlay hidden" id="report-issue-overlay">
<div class="enhanced-bulk-modal report-issue-modal">
<div class="enhanced-bulk-modal-header">
<h3 id="report-issue-title">Report an Issue</h3>
<button class="enhanced-bulk-modal-close" onclick="closeReportIssueModal()">&times;</button>
</div>
<div class="enhanced-bulk-modal-body" id="report-issue-body">
<!-- Populated dynamically -->
</div>
<div class="enhanced-bulk-modal-footer">
<button class="enhanced-bulk-btn secondary" onclick="closeReportIssueModal()">Cancel</button>
<button class="enhanced-bulk-btn primary" id="report-issue-submit-btn" onclick="submitIssue()">Submit Issue</button>
</div>
</div>
</div>
<!-- Help & Docs Page -->
<div class="page{% if initial_client_page == 'help' %} active{% endif %}" id="help-page">
<div class="docs-layout">

@ -63,14 +63,14 @@ function createShellBridge(overrides: Partial<ShellBridge> = {}): ShellBridge {
describe('createAppRouter', () => {
beforeEach(() => {
window.SoulSyncIssueActions = {};
vi.stubGlobal('fetch', mockIssuesFetch());
});
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
window.SoulSyncWebShellBridge = undefined;
window.SoulSyncIssueActions = undefined;
window.SoulSyncIssueDomain = undefined;
});
it('creates one shared query client and applies router defaults', () => {
@ -87,7 +87,6 @@ describe('createAppRouter', () => {
it('renders migrated React routes directly and updates shell chrome', async () => {
window.SoulSyncWebShellBridge = createShellBridge();
vi.stubGlobal('fetch', mockIssuesFetch());
const queryClient = createAppQueryClient();
const history = createMemoryHistory({ initialEntries: ['/issues'] });

@ -1,10 +1,20 @@
import type { ShellProfileContext, ShellRouteDefinition, ShellPageId } from './bridge';
import type {
DownloadMissingAlbumWorkflowInput,
WishlistAlbumWorkflowInput,
} from '@/platform/workflows/album-workflows';
import type { IssueDomainBridge } from '@/routes/issues/-issues.types';
declare global {
interface Window {
SoulSyncIssueActions?: {
addToWishlist?: (albumId: string, artistName: string, albumName: string) => void;
downloadAlbum?: (albumId: string, artistName: string, albumName: string) => void;
showToast?: (message: string, type?: string, durationOrContext?: number | string) => void;
SoulSyncIssueDomain?: IssueDomainBridge;
SoulSyncWorkflowActions?: {
openDownloadMissingAlbum: (
input: DownloadMissingAlbumWorkflowInput,
) => void | Promise<void>;
openAddToWishlistAlbum: (input: WishlistAlbumWorkflowInput) => void | Promise<void>;
notify?: (message: string, type?: string) => void;
};
SoulSyncWebRouter?: {
routeManifest: ShellRouteDefinition[];

@ -0,0 +1,194 @@
import { apiClient } from '@/app/api-client';
export interface AlbumWorkflowLaunchInput {
spotifyAlbumId?: string;
artistName?: string;
albumName?: string;
source?: string;
}
export interface DownloadMissingAlbumWorkflowInput {
virtualPlaylistId: string;
playlistName: string;
tracks: Array<Record<string, unknown>>;
album: Record<string, unknown>;
artist: Record<string, unknown>;
albumType: string;
forceDownload: boolean;
registerDownload?: boolean;
}
export interface WishlistAlbumWorkflowInput {
tracks: Array<Record<string, unknown>>;
album: Record<string, unknown>;
artist: Record<string, unknown>;
albumType: string;
}
interface AlbumSearchResult {
id?: string;
name?: string;
title?: string;
artist?: string;
}
interface AlbumApiResponse {
id?: string;
name?: string;
album_type?: string;
images?: Array<{ url?: string }>;
image_url?: string | null;
release_date?: string;
total_tracks?: number;
artists?: Array<{ id?: string | null; name?: string }>;
tracks?: Array<Record<string, unknown>>;
}
interface EnhancedSearchResponse {
spotify_albums?: AlbumSearchResult[];
itunes_albums?: AlbumSearchResult[];
}
function getWorkflowBridge() {
const bridge = window.SoulSyncWorkflowActions;
if (!bridge) {
throw new Error('Album workflow host is not ready yet');
}
return bridge;
}
function notify(message: string, type: 'success' | 'error' | 'warning' | 'info' = 'info') {
if (window.SoulSyncWorkflowActions?.notify) {
window.SoulSyncWorkflowActions.notify(message, type);
return;
}
window.showToast?.(message, type);
}
async function searchAlbum(input: AlbumWorkflowLaunchInput): Promise<AlbumSearchResult> {
const query = `${input.artistName || ''} ${input.albumName || ''}`.trim();
if (!query) {
throw new Error('No album ID or artist/album info available');
}
const searchData =
(await apiClient
.post('enhanced-search', {
json: { query },
})
.json<EnhancedSearchResponse>()) ?? {};
const foundAlbum = searchData.spotify_albums?.[0] ?? searchData.itunes_albums?.[0];
if (!foundAlbum?.id) {
throw new Error(
`Could not find "${input.albumName || 'album'}" by ${input.artistName || 'unknown artist'}`,
);
}
return foundAlbum;
}
async function fetchAlbum(input: AlbumWorkflowLaunchInput): Promise<AlbumApiResponse> {
let albumId = input.spotifyAlbumId || '';
let albumName = input.albumName || '';
let artistName = input.artistName || '';
if (!albumId) {
const foundAlbum = await searchAlbum(input);
albumId = foundAlbum.id || '';
albumName = foundAlbum.name || foundAlbum.title || albumName;
artistName = foundAlbum.artist || artistName;
}
const searchParams = new URLSearchParams({ name: albumName, artist: artistName });
try {
return (
(await apiClient
.get(`spotify/album/${encodeURIComponent(albumId)}`, { searchParams })
.json<AlbumApiResponse>()) ?? {}
);
} catch (error) {
if (!input.spotifyAlbumId || (!input.artistName && !input.albumName)) {
throw error;
}
const foundAlbum = await searchAlbum(input);
const fallbackParams = new URLSearchParams({
name: foundAlbum.name || foundAlbum.title || albumName,
artist: foundAlbum.artist || artistName,
});
return (
(await apiClient
.get(`spotify/album/${encodeURIComponent(foundAlbum.id || '')}`, {
searchParams: fallbackParams,
})
.json<AlbumApiResponse>()) ?? {}
);
}
}
async function resolveAlbumWorkflowData(input: AlbumWorkflowLaunchInput) {
const albumData = await fetchAlbum(input);
if (!albumData.tracks?.length) {
throw new Error(`No tracks available for "${input.albumName || albumData.name || 'album'}"`);
}
const albumArtists = albumData.artists?.length
? albumData.artists
: [{ name: input.artistName || 'Unknown Artist' }];
const artistName = input.artistName || albumArtists[0]?.name || 'Unknown Artist';
const albumType = albumData.album_type || 'album';
const album = {
name: albumData.name || input.albumName || 'Unknown Album',
id: albumData.id || input.spotifyAlbumId || '',
album_type: albumType,
images: albumData.images || [],
image_url: albumData.image_url || albumData.images?.[0]?.url || null,
release_date: albumData.release_date,
total_tracks: albumData.total_tracks,
artists: albumArtists,
};
const tracks = albumData.tracks.map((track) => ({
...track,
artists: albumArtists,
album,
}));
return {
album,
albumType,
artist: { id: `workflow_${artistName}`, name: artistName, image_url: '' },
artistName,
tracks,
};
}
export async function launchAlbumDownloadWorkflow(input: AlbumWorkflowLaunchInput) {
const bridge = getWorkflowBridge();
const { album, albumType, artist, artistName, tracks } = await resolveAlbumWorkflowData(input);
const resolvedAlbumId = String(album.id || input.spotifyAlbumId || Date.now());
const source = input.source || 'album';
await bridge.openDownloadMissingAlbum({
virtualPlaylistId: `${source}_download_${resolvedAlbumId}`,
playlistName: `[${artistName}] ${String(album.name || 'Unknown Album')}`,
tracks,
album,
artist,
albumType,
forceDownload: true,
registerDownload: true,
});
}
export async function launchAlbumWishlistWorkflow(input: AlbumWorkflowLaunchInput) {
const bridge = getWorkflowBridge();
const { album, albumType, artist, tracks } = await resolveAlbumWorkflowData(input);
await bridge.openAddToWishlistAlbum({
album,
artist,
tracks,
albumType,
});
notify('Wishlist workflow opened', 'success');
}

@ -2,6 +2,13 @@ import { Outlet, createRootRouteWithContext } from '@tanstack/react-router';
import type { AppRouterContext } from '@/app/router';
import { IssueDomainHost } from './issues/-ui/issue-domain-host';
export const Route = createRootRouteWithContext<AppRouterContext>()({
component: () => <Outlet />,
component: () => (
<>
<Outlet />
<IssueDomainHost />
</>
),
});

@ -8,6 +8,7 @@ import type {
IssueDetailResponse,
IssueListResponse,
IssueRecord,
CreateIssuePayload,
IssuesSearch,
IssueSnapshot,
} from './-issues.types';
@ -101,6 +102,17 @@ function createIssueHeaders(profileId: number, extra?: HeadersInit): Headers {
return headers;
}
export function getIssueCategoriesForEntity(entityType: IssueRecord['entity_type']) {
return Object.entries(ISSUE_CATEGORY_META).filter(([, category]) =>
category.applies.includes(entityType),
);
}
export function createDefaultIssueTitle(category: string, entityName: string): string {
const label = ISSUE_CATEGORY_META[category]?.label || 'Issue';
return `${label}: ${entityName || 'Unknown'}`;
}
export function normalizeIssuesSearch(search: IssuesSearch | undefined): Required<IssuesSearch> {
const status = search?.status;
const category = search?.category;
@ -183,6 +195,33 @@ export async function updateIssue(
}
}
export async function createIssue(
profileId: number,
payload: CreateIssuePayload,
): Promise<IssueRecord | null> {
const response = await parseJsonResponse<{
success: boolean;
issue?: IssueRecord;
error?: string;
}>(
apiClient.post('issues', {
headers: createIssueHeaders(profileId, { 'Content-Type': 'application/json' }),
json: {
entity_type: payload.entity_type,
entity_id: String(payload.entity_id),
category: payload.category,
title: payload.title,
description: payload.description || '',
priority: payload.priority || 'normal',
},
}),
);
if (!response.success) {
throw new Error(response.error || 'Failed to submit issue');
}
return response.issue ?? null;
}
export async function deleteIssue(profileId: number, issueId: number): Promise<void> {
const payload = await parseJsonResponse<{ success: boolean; error?: string }>(
apiClient.delete(`issues/${issueId}`, {

@ -1,4 +1,6 @@
export type IssueStatus = 'open' | 'in_progress' | 'resolved' | 'dismissed';
export type IssueEntityType = 'track' | 'album' | 'artist';
export type IssuePriority = 'low' | 'normal' | 'high';
export interface IssueSnapshot {
[key: string]: unknown;
@ -12,12 +14,30 @@ export interface IssueSnapshot {
spotify_album_id?: string;
spotify_artist_id?: string;
spotify_track_id?: string;
artist_id?: string | number;
album_id?: string | number;
track_number?: string | number;
duration?: string | number;
format?: string;
bitrate?: string | number;
bpm?: string | number;
quality?: string;
file_path?: string;
tracks?: Array<Record<string, unknown>>;
artist_musicbrainz_id?: string;
musicbrainz_release_id?: string;
musicbrainz_recording_id?: string;
artist_deezer_id?: string;
album_deezer_id?: string;
track_deezer_id?: string;
artist_tidal_id?: string;
album_tidal_id?: string;
}
export interface IssueRecord {
id: number;
profile_id: number;
entity_type: 'track' | 'album' | 'artist';
entity_type: IssueEntityType;
entity_id: string;
category: string;
title: string;
@ -66,3 +86,26 @@ export interface IssuesSearch {
category?: string;
status?: IssueStatus | 'all';
}
export interface CreateIssuePayload {
entity_type: IssueEntityType;
entity_id: string;
category: string;
title: string;
description?: string;
priority?: IssuePriority;
}
export interface IssueReportPayload {
entityType: IssueEntityType;
entityId: string | number;
entityName: string;
artistName?: string;
albumTitle?: string;
}
export interface IssueDomainBridge {
openReportIssue: (payload: IssueReportPayload) => void;
refresh: () => void;
closeReportIssue?: () => void;
}

@ -1,5 +1,5 @@
import { createMemoryHistory } from '@tanstack/react-router';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vite-plus/test';
import type { ShellBridge, ShellPageId } from '@/platform/shell/bridge';
@ -36,15 +36,15 @@ function renderIssuesRoute() {
return render(<AppRouterProvider router={router} queryClient={queryClient} />);
}
const shellActions = {
downloadAlbum: vi.fn(),
addToWishlist: vi.fn(),
const workflowActions = {
openDownloadMissingAlbum: vi.fn(),
openAddToWishlistAlbum: vi.fn(),
};
describe('issues route', () => {
beforeEach(() => {
shellActions.downloadAlbum.mockReset();
shellActions.addToWishlist.mockReset();
workflowActions.openDownloadMissingAlbum.mockReset();
workflowActions.openAddToWishlistAlbum.mockReset();
window.SoulSyncWebShellBridge = createShellBridge();
vi.stubGlobal(
'fetch',
@ -107,10 +107,22 @@ describe('issues route', () => {
},
});
}
if (url.includes('/api/spotify/album/abc123')) {
return createResponse({
id: 'abc123',
name: 'Album Name',
album_type: 'album',
images: [{ url: 'https://example.com/thumb.jpg' }],
total_tracks: 1,
artists: [{ name: 'Artist' }],
tracks: [{ id: 'track-1', name: 'Track 1' }],
});
}
return createResponse({ success: true });
}) as unknown as typeof fetch,
);
vi.stubGlobal('SoulSyncIssueActions', shellActions);
vi.stubGlobal('SoulSyncWorkflowActions', workflowActions);
vi.stubGlobal('showToast', vi.fn());
});
afterEach(() => {
@ -139,10 +151,41 @@ describe('issues route', () => {
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
});
it('invokes the legacy adapter for admin downloads', async () => {
it('invokes the shared workflow adapter for admin downloads', async () => {
renderIssuesRoute();
fireEvent.click(await screen.findByTestId('issue-card-7'));
fireEvent.click(await screen.findByRole('button', { name: /download album/i }));
expect(shellActions.downloadAlbum).toHaveBeenCalledWith('abc123', 'Artist', 'Album Name');
await waitFor(() => expect(workflowActions.openDownloadMissingAlbum).toHaveBeenCalled());
expect(workflowActions.openDownloadMissingAlbum).toHaveBeenCalledWith(
expect.objectContaining({
virtualPlaylistId: 'issue_download_abc123',
playlistName: '[Artist] Album Name',
}),
);
});
it('opens the global React issue composer through the domain bridge', async () => {
const fetchMock = vi.mocked(fetch);
renderIssuesRoute();
await waitFor(() => expect(window.SoulSyncIssueDomain).toBeDefined());
act(() => {
window.SoulSyncIssueDomain?.openReportIssue({
entityType: 'album',
entityId: 15,
entityName: 'Album Name',
artistName: 'Artist',
});
});
fireEvent.click(await screen.findByRole('button', { name: /wrong cover art/i }));
expect(screen.getByLabelText(/title/i)).toHaveValue('Wrong Cover Art: Album Name');
fireEvent.click(screen.getByRole('button', { name: /submit issue/i }));
await waitFor(() => {
expect(
fetchMock.mock.calls.some(([request]) => request instanceof Request && request.method === 'POST'),
).toBe(true);
});
});
});

@ -417,6 +417,11 @@
max-height: 85vh;
}
.reportIssueModal {
max-width: 680px;
width: 95vw;
}
.modalHeader {
display: flex;
align-items: flex-start;
@ -594,6 +599,17 @@
white-space: pre-wrap;
}
.issueDetailFilepath {
padding: 10px 12px;
border-radius: 8px;
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.06);
color: rgba(255, 255, 255, 0.72);
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 12px;
overflow-wrap: anywhere;
}
.issueDetailNoDesc {
font-size: 13px;
color: rgba(255, 255, 255, 0.25);
@ -711,6 +727,193 @@
border-color: rgba(var(--accent-light-rgb), 0.5);
}
.issueDetailAdminResponse {
padding: 12px 14px;
border-radius: 10px;
background: rgba(var(--accent-light-rgb), 0.08);
border: 1px solid rgba(var(--accent-light-rgb), 0.18);
color: rgba(255, 255, 255, 0.78);
font-size: 13px;
line-height: 1.6;
white-space: pre-wrap;
}
.issueExternalLinks {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.issueExternalLink {
display: inline-flex;
align-items: center;
padding: 6px 10px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.08);
color: rgba(255, 255, 255, 0.78);
text-decoration: none;
font-size: 12px;
}
.issueExternalLink:hover {
background: rgba(255, 255, 255, 0.1);
}
.issueDetailTracklist {
display: grid;
gap: 6px;
}
.issueDetailTracklistRow,
.issueDetailTracklistDisc {
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);
border: 1px solid rgba(255, 255, 255, 0.05);
font-size: 12px;
}
.issueDetailTracklistDisc {
display: block;
color: rgba(255, 255, 255, 0.45);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.issueDetailTracklistNum,
.issueDetailTracklistDur,
.issueDetailTracklistMeta {
color: rgba(255, 255, 255, 0.42);
}
.issueDetailTracklistTitle {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: rgba(255, 255, 255, 0.82);
}
.issueTrackBadge {
display: inline-flex;
align-items: center;
border-radius: 999px;
padding: 2px 6px;
background: rgba(255, 255, 255, 0.08);
color: rgba(255, 255, 255, 0.65);
font-size: 10px;
margin-left: 4px;
}
.reportIssueEntityInfo {
padding: 14px 16px;
border-radius: 12px;
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.06);
}
.reportIssueEntityName {
color: #fff;
font-weight: 600;
}
.reportIssueEntityArtist {
margin-top: 4px;
color: rgba(255, 255, 255, 0.48);
font-size: 13px;
}
.reportIssueCategoryGrid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: 10px;
}
.reportIssueCategoryCard {
display: grid;
gap: 5px;
min-height: 122px;
text-align: left;
padding: 12px;
border-radius: 12px;
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(255, 255, 255, 0.04);
color: #fff;
cursor: pointer;
font-family: inherit;
}
.reportIssueCategoryCard:hover,
.reportIssueCategoryCardSelected {
border-color: rgba(var(--accent-light-rgb), 0.45);
background: rgba(var(--accent-light-rgb), 0.1);
}
.reportIssueCategoryIcon {
font-size: 18px;
}
.reportIssueCategoryLabel {
font-weight: 700;
font-size: 13px;
}
.reportIssueCategoryDesc {
color: rgba(255, 255, 255, 0.48);
font-size: 12px;
line-height: 1.4;
}
.reportIssueInput {
width: 100%;
box-sizing: border-box;
margin-bottom: 12px;
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;
font-family: inherit;
}
.reportIssuePriorityRow {
display: flex;
gap: 8px;
margin-top: 12px;
}
.reportIssuePriorityButton {
padding: 8px 12px;
border-radius: 999px;
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(255, 255, 255, 0.05);
color: rgba(255, 255, 255, 0.72);
cursor: pointer;
font-family: inherit;
}
.reportIssuePriorityButtonSelected {
border-color: rgba(var(--accent-light-rgb), 0.45);
background: rgba(var(--accent-light-rgb), 0.14);
color: #fff;
}
.reportIssueError {
color: #ffd0d0;
border: 1px solid rgba(239, 68, 68, 0.25);
background: rgba(239, 68, 68, 0.1);
border-radius: 10px;
padding: 10px 12px;
font-size: 13px;
}
.modalFooter {
display: flex;
justify-content: flex-end;

@ -1,6 +1,11 @@
import { useMutation } from '@tanstack/react-query';
import { useEffect, useMemo, useState } from 'react';
import {
launchAlbumDownloadWorkflow,
launchAlbumWishlistWorkflow,
} from '@/platform/workflows/album-workflows';
import type { IssueRecord } from '../-issues.types';
import {
@ -16,13 +21,6 @@ import {
} from '../-issues.helpers';
import styles from './issue-detail-modal.module.css';
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;
}
export function IssueDetailModal({
error,
isAdmin,
@ -67,6 +65,18 @@ export function IssueDetailModal({
},
});
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;
@ -166,9 +176,15 @@ export function IssueDetailModal({
const issueCategoryLabel = issue
? ISSUE_CATEGORY_META[issue.category]?.label || issue.category
: '';
const downloadAlbum = window.SoulSyncIssueActions?.downloadAlbum;
const addToWishlist = window.SoulSyncIssueActions?.addToWishlist;
const externalLinks = getExternalLinks(snapshot);
const trackMetaItems = getTrackMetaItems(snapshot);
const trackRows = Array.isArray(snapshot.tracks) ? snapshot.tracks : [];
const albumWorkflowInput = {
spotifyAlbumId: String(snapshot.spotify_album_id || ''),
artistName: String(snapshot.artist_name || ''),
albumName: String(snapshot.album_title || snapshot.title || ''),
source: 'issue',
};
return (
<div
@ -313,43 +329,137 @@ export function IssueDetailModal({
{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>
<div className={styles.issueActionButtons}>
{downloadAlbum && (
<button
className={`${styles.issueActionButton} ${styles.issueActionDownload}`}
type="button"
onClick={() =>
downloadAlbum(
String(snapshot.spotify_album_id || ''),
String(snapshot.artist_name || ''),
String(snapshot.album_title || snapshot.title || ''),
)
}
>
Download Album
</button>
)}
{addToWishlist && (
<button
className={`${styles.issueActionButton} ${styles.issueActionWishlist}`}
type="button"
onClick={() =>
addToWishlist(
String(snapshot.spotify_album_id || ''),
String(snapshot.artist_name || ''),
String(snapshot.album_title || snapshot.title || ''),
)
}
>
Add to Wishlist
</button>
)}
<button
className={`${styles.issueActionButton} ${styles.issueActionDownload}`}
type="button"
disabled={downloadWorkflowMutation.isPending}
onClick={() => downloadWorkflowMutation.mutate(albumWorkflowInput)}
>
{downloadWorkflowMutation.isPending ? 'Loading...' : 'Download Album'}
</button>
<button
className={`${styles.issueActionButton} ${styles.issueActionWishlist}`}
type="button"
disabled={wishlistWorkflowMutation.isPending}
onClick={() => wishlistWorkflowMutation.mutate(albumWorkflowInput)}
>
{wishlistWorkflowMutation.isPending ? 'Loading...' : 'Add to Wishlist'}
</button>
</div>
</div>
)}
@ -365,6 +475,13 @@ export function IssueDetailModal({
/>
</div>
)}
{!isAdmin && issue.admin_response ? (
<div className={styles.issueDetailSection}>
<div className={styles.issueDetailSectionTitle}>Admin Response</div>
<div className={styles.issueDetailAdminResponse}>{issue.admin_response}</div>
</div>
) : null}
</>
) : null}
</div>
@ -401,3 +518,109 @@ export function IssueDetailModal({
</div>
);
}
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<{ label: string; url: string }> = [];
if (snapshot.spotify_artist_id) {
links.push({
label: 'Spotify Artist',
url: `https://open.spotify.com/artist/${snapshot.spotify_artist_id}`,
});
}
if (snapshot.spotify_album_id) {
links.push({
label: 'Spotify Album',
url: `https://open.spotify.com/album/${snapshot.spotify_album_id}`,
});
}
if (snapshot.spotify_track_id) {
links.push({
label: 'Spotify Track',
url: `https://open.spotify.com/track/${snapshot.spotify_track_id}`,
});
}
if (snapshot.artist_musicbrainz_id) {
links.push({
label: 'MusicBrainz Artist',
url: `https://musicbrainz.org/artist/${snapshot.artist_musicbrainz_id}`,
});
}
if (snapshot.musicbrainz_release_id) {
links.push({
label: 'MusicBrainz Release',
url: `https://musicbrainz.org/release/${snapshot.musicbrainz_release_id}`,
});
}
if (snapshot.musicbrainz_recording_id) {
links.push({
label: 'MusicBrainz Recording',
url: `https://musicbrainz.org/recording/${snapshot.musicbrainz_recording_id}`,
});
}
if (snapshot.artist_deezer_id) {
links.push({
label: 'Deezer Artist',
url: `https://www.deezer.com/artist/${snapshot.artist_deezer_id}`,
});
}
if (snapshot.album_deezer_id) {
links.push({
label: 'Deezer Album',
url: `https://www.deezer.com/album/${snapshot.album_deezer_id}`,
});
}
if (snapshot.track_deezer_id) {
links.push({
label: 'Deezer Track',
url: `https://www.deezer.com/track/${snapshot.track_deezer_id}`,
});
}
if (snapshot.artist_tidal_id) {
links.push({
label: 'Tidal Artist',
url: `https://listen.tidal.com/artist/${snapshot.artist_tidal_id}`,
});
}
if (snapshot.album_tidal_id) {
links.push({
label: 'Tidal Album',
url: `https://listen.tidal.com/album/${snapshot.album_tidal_id}`,
});
}
return links;
}
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;
}

@ -0,0 +1,284 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useEffect, useMemo, useState } from 'react';
import { createPortal } from 'react-dom';
import { getShellProfileContext } from '@/platform/shell/bridge';
import { useShellBridge } from '@/platform/shell/route-controllers';
import type { IssuePriority, IssueReportPayload } from '../-issues.types';
import {
REFRESH_EVENT,
createDefaultIssueTitle,
createIssue,
getIssueCategoriesForEntity,
issueCountsQueryOptions,
} from '../-issues.helpers';
import styles from './issue-detail-modal.module.css';
const ISSUE_DOMAIN_QUERY_KEY = ['issues'] as const;
export function IssueDomainHost() {
const bridge = useShellBridge();
const queryClient = useQueryClient();
const profile = getShellProfileContext(bridge);
const [reportPayload, setReportPayload] = useState<IssueReportPayload | null>(null);
const profileId = profile?.profileId ?? 0;
const countsQuery = useQuery({
...issueCountsQueryOptions(profileId),
enabled: profileId > 0,
});
useEffect(() => {
if (countsQuery.data) {
updateBadge(countsQuery.data.open || 0);
}
}, [countsQuery.data]);
useEffect(() => {
const handleRefresh = () => {
void queryClient.invalidateQueries({ queryKey: ISSUE_DOMAIN_QUERY_KEY });
};
window.addEventListener(REFRESH_EVENT, handleRefresh);
return () => {
window.removeEventListener(REFRESH_EVENT, handleRefresh);
};
}, [queryClient]);
useEffect(() => {
window.SoulSyncIssueDomain = {
openReportIssue(payload) {
setReportPayload(payload);
},
closeReportIssue() {
setReportPayload(null);
},
refresh() {
void queryClient.invalidateQueries({ queryKey: ISSUE_DOMAIN_QUERY_KEY });
},
};
return () => {
if (window.SoulSyncIssueDomain?.openReportIssue) {
window.SoulSyncIssueDomain = undefined;
}
};
}, [queryClient]);
if (!reportPayload) return null;
return createPortal(
<ReportIssueModal
payload={reportPayload}
profileId={profileId}
onClose={() => setReportPayload(null)}
onSubmitted={() => {
setReportPayload(null);
void queryClient.invalidateQueries({ queryKey: ISSUE_DOMAIN_QUERY_KEY });
}}
/>,
document.body,
);
}
function ReportIssueModal({
onClose,
onSubmitted,
payload,
profileId,
}: {
onClose: () => void;
onSubmitted: () => void;
payload: IssueReportPayload;
profileId: number;
}) {
const [selectedCategory, setSelectedCategory] = useState('');
const [selectedPriority, setSelectedPriority] = useState<IssuePriority>('normal');
const [title, setTitle] = useState('');
const [description, setDescription] = useState('');
const [titleEdited, setTitleEdited] = useState(false);
const [error, setError] = useState('');
const categories = useMemo(
() => getIssueCategoriesForEntity(payload.entityType),
[payload.entityType],
);
const entityLabel =
payload.entityType === 'track' ? 'Track' : payload.entityType === 'album' ? 'Album' : 'Artist';
const createMutation = useMutation({
mutationFn: async () => {
if (!profileId) throw new Error('Profile is still loading');
if (!selectedCategory) throw new Error('Please select an issue category');
const trimmedTitle = title.trim();
if (!trimmedTitle) throw new Error('Please provide a title for the issue');
await createIssue(profileId, {
entity_type: payload.entityType,
entity_id: String(payload.entityId),
category: selectedCategory,
title: trimmedTitle,
description: description.trim(),
priority: selectedPriority,
});
},
onError: (mutationError) => {
const message =
mutationError instanceof Error ? mutationError.message : 'Failed to submit issue';
setError(message);
notify(message, 'error');
},
onSuccess: () => {
notify('Issue reported successfully', 'success');
onSubmitted();
},
});
function selectCategory(category: string) {
setSelectedCategory(category);
setError('');
if (!titleEdited) {
setTitle(createDefaultIssueTitle(category, payload.entityName));
}
}
return (
<div
className={styles.modalOverlay}
role="dialog"
aria-modal="true"
aria-labelledby="report-issue-title"
onClick={onClose}
>
<div
className={`${styles.modal} ${styles.reportIssueModal}`}
onClick={(event) => event.stopPropagation()}
>
<div className={styles.modalHeader}>
<h3 className={styles.modalHeaderTitle} id="report-issue-title">
Report Issue - {entityLabel}
</h3>
<button
className={styles.modalClose}
type="button"
onClick={onClose}
aria-label="Close report issue modal"
>
&times;
</button>
</div>
<div className={styles.modalBody}>
<div className={styles.reportIssueEntityInfo}>
<div className={styles.reportIssueEntityName}>{payload.entityName}</div>
{payload.artistName ? (
<div className={styles.reportIssueEntityArtist}>
{payload.artistName}
{payload.albumTitle ? ` - ${payload.albumTitle}` : ''}
</div>
) : null}
</div>
<div className={styles.issueDetailSection}>
<label className={styles.issueDetailSectionTitle}>What's the problem?</label>
<div className={styles.reportIssueCategoryGrid}>
{categories.map(([category, meta]) => (
<button
key={category}
className={`${styles.reportIssueCategoryCard} ${
selectedCategory === category ? styles.reportIssueCategoryCardSelected : ''
}`}
type="button"
onClick={() => selectCategory(category)}
>
<div className={styles.reportIssueCategoryIcon}>{meta.icon}</div>
<div className={styles.reportIssueCategoryLabel}>{meta.label}</div>
<div className={styles.reportIssueCategoryDesc}>{meta.description}</div>
</button>
))}
</div>
</div>
{selectedCategory ? (
<div className={styles.issueDetailSection}>
<label className={styles.issueDetailSectionTitle} htmlFor="report-issue-title-input">
Title
</label>
<input
className={styles.reportIssueInput}
id="report-issue-title-input"
maxLength={200}
onChange={(event) => {
setTitle(event.target.value);
setTitleEdited(true);
}}
placeholder="Brief summary of the issue..."
value={title}
/>
<label className={styles.issueDetailSectionTitle} htmlFor="report-issue-desc-input">
Details
</label>
<textarea
className={styles.issueDetailResponseTextarea}
id="report-issue-desc-input"
maxLength={2000}
onChange={(event) => setDescription(event.target.value)}
placeholder="Provide more details about what's wrong..."
rows={4}
value={description}
/>
<div className={styles.reportIssuePriorityRow} aria-label="Priority">
{(['low', 'normal', 'high'] as const).map((priority) => (
<button
key={priority}
className={`${styles.reportIssuePriorityButton} ${
selectedPriority === priority ? styles.reportIssuePriorityButtonSelected : ''
}`}
type="button"
onClick={() => setSelectedPriority(priority)}
>
{priority[0].toUpperCase()}
{priority.slice(1)}
</button>
))}
</div>
</div>
) : null}
{error ? <div className={styles.reportIssueError}>{error}</div> : null}
</div>
<div className={styles.modalFooter}>
<button
className={`${styles.modalButton} ${styles.modalButtonSecondary}`}
type="button"
onClick={onClose}
>
Cancel
</button>
<button
className={`${styles.modalButton} ${styles.modalButtonPrimary}`}
type="button"
disabled={!selectedCategory || createMutation.isPending}
onClick={() => createMutation.mutate()}
>
{createMutation.isPending ? 'Submitting...' : 'Submit Issue'}
</button>
</div>
</div>
</div>
);
}
function notify(message: string, type: 'success' | 'error' | 'warning' | 'info' = 'info') {
window.showToast?.(message, type);
}
function updateBadge(openCount: number) {
const badge = document.getElementById('issues-nav-badge');
if (!badge) return;
badge.textContent = String(openCount || 0);
badge.classList.toggle('hidden', !openCount);
}

File diff suppressed because it is too large Load Diff
Loading…
Cancel
Save