diff --git a/webui/index.html b/webui/index.html
index 2b6773c1..72160ef9 100644
--- a/webui/index.html
+++ b/webui/index.html
@@ -6150,23 +6150,6 @@
-
-
diff --git a/webui/src/app/router.test.tsx b/webui/src/app/router.test.tsx
index a269f27f..b39cb553 100644
--- a/webui/src/app/router.test.tsx
+++ b/webui/src/app/router.test.tsx
@@ -63,14 +63,14 @@ function createShellBridge(overrides: Partial
= {}): 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'] });
diff --git a/webui/src/platform/shell/globals.d.ts b/webui/src/platform/shell/globals.d.ts
index 9078795c..6314cdd7 100644
--- a/webui/src/platform/shell/globals.d.ts
+++ b/webui/src/platform/shell/globals.d.ts
@@ -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;
+ openAddToWishlistAlbum: (input: WishlistAlbumWorkflowInput) => void | Promise;
+ notify?: (message: string, type?: string) => void;
};
SoulSyncWebRouter?: {
routeManifest: ShellRouteDefinition[];
diff --git a/webui/src/platform/workflows/album-workflows.ts b/webui/src/platform/workflows/album-workflows.ts
new file mode 100644
index 00000000..9498c92c
--- /dev/null
+++ b/webui/src/platform/workflows/album-workflows.ts
@@ -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>;
+ album: Record;
+ artist: Record;
+ albumType: string;
+ forceDownload: boolean;
+ registerDownload?: boolean;
+}
+
+export interface WishlistAlbumWorkflowInput {
+ tracks: Array>;
+ album: Record;
+ artist: Record;
+ 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>;
+}
+
+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 {
+ 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()) ?? {};
+ 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 {
+ 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()) ?? {}
+ );
+ } 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()) ?? {}
+ );
+ }
+}
+
+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');
+}
diff --git a/webui/src/routes/__root.tsx b/webui/src/routes/__root.tsx
index e1d1086d..0f57d9c4 100644
--- a/webui/src/routes/__root.tsx
+++ b/webui/src/routes/__root.tsx
@@ -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()({
- component: () => ,
+ component: () => (
+ <>
+
+
+ >
+ ),
});
diff --git a/webui/src/routes/issues/-issues.helpers.ts b/webui/src/routes/issues/-issues.helpers.ts
index 3ec48869..762916f0 100644
--- a/webui/src/routes/issues/-issues.helpers.ts
+++ b/webui/src/routes/issues/-issues.helpers.ts
@@ -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 {
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 {
+ 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 {
const payload = await parseJsonResponse<{ success: boolean; error?: string }>(
apiClient.delete(`issues/${issueId}`, {
diff --git a/webui/src/routes/issues/-issues.types.ts b/webui/src/routes/issues/-issues.types.ts
index 6aa8ba54..9c315521 100644
--- a/webui/src/routes/issues/-issues.types.ts
+++ b/webui/src/routes/issues/-issues.types.ts
@@ -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>;
+ 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;
+}
diff --git a/webui/src/routes/issues/-route.test.tsx b/webui/src/routes/issues/-route.test.tsx
index 30418f4b..6ea71f4d 100644
--- a/webui/src/routes/issues/-route.test.tsx
+++ b/webui/src/routes/issues/-route.test.tsx
@@ -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( );
}
-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);
+ });
});
});
diff --git a/webui/src/routes/issues/-ui/issue-detail-modal.module.css b/webui/src/routes/issues/-ui/issue-detail-modal.module.css
index 742a9230..00c303ea 100644
--- a/webui/src/routes/issues/-ui/issue-detail-modal.module.css
+++ b/webui/src/routes/issues/-ui/issue-detail-modal.module.css
@@ -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;
diff --git a/webui/src/routes/issues/-ui/issue-detail-modal.tsx b/webui/src/routes/issues/-ui/issue-detail-modal.tsx
index 9de7747e..d27d6f58 100644
--- a/webui/src/routes/issues/-ui/issue-detail-modal.tsx
+++ b/webui/src/routes/issues/-ui/issue-detail-modal.tsx
@@ -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 (
+ {issue.updated_at ? (
+
+ U
+ Updated
+
+ {formatIssueDate(issue.updated_at)}
+
+
+ ) : null}
+ {issue.resolved_at ? (
+
+ R
+ Resolved
+
+ {formatIssueDate(issue.resolved_at)}
+
+
+ ) : null}
+ {issue.resolved_by ? (
+
+ A
+ Resolver
+ {issue.resolved_by}
+
+ ) : null}
+ {issue.reporter_name ? (
+
+ P
+ Reporter
+ {issue.reporter_name}
+
+ ) : null}
+ {externalLinks.length > 0 ? (
+
+
+ Track Listing ({trackRows.length} tracks)
+
+
+ {trackRows.map((track, index) => {
+ const format = String(track.format || '').toUpperCase();
+ const bitrate = track.bitrate ? `${track.bitrate}k` : '';
+ const duration = formatDuration(track.duration);
+ return (
+
+
+ {String(track.track_number || index + 1)}
+
+
+ {String(track.title || 'Unknown')}
+
+ {duration}
+
+ {format ? {format} : null}
+ {bitrate ? (
+ {bitrate}
+ ) : null}
+
+
+ );
+ })}
+
+
+ ) : null}
+
{issue.entity_type !== 'artist' && isAdmin && (
Admin Actions
- {downloadAlbum && (
-
- downloadAlbum(
- String(snapshot.spotify_album_id || ''),
- String(snapshot.artist_name || ''),
- String(snapshot.album_title || snapshot.title || ''),
- )
- }
- >
- Download Album
-
- )}
- {addToWishlist && (
-
- addToWishlist(
- String(snapshot.spotify_album_id || ''),
- String(snapshot.artist_name || ''),
- String(snapshot.album_title || snapshot.title || ''),
- )
- }
- >
- Add to Wishlist
-
- )}
+ downloadWorkflowMutation.mutate(albumWorkflowInput)}
+ >
+ {downloadWorkflowMutation.isPending ? 'Loading...' : 'Download Album'}
+
+ wishlistWorkflowMutation.mutate(albumWorkflowInput)}
+ >
+ {wishlistWorkflowMutation.isPending ? 'Loading...' : 'Add to Wishlist'}
+
)}
@@ -365,6 +475,13 @@ export function IssueDetailModal({
/>
)}
+
+ {!isAdmin && issue.admin_response ? (
+