From 577e4bdacee8e43ae8b03f083e8d899dc434ab86 Mon Sep 17 00:00:00 2001 From: Antti Kettunen Date: Tue, 7 Apr 2026 08:02:37 +0300 Subject: [PATCH] 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 --- webui/index.html | 17 - webui/src/app/router.test.tsx | 5 +- webui/src/platform/shell/globals.d.ts | 16 +- .../src/platform/workflows/album-workflows.ts | 194 +++ webui/src/routes/__root.tsx | 9 +- webui/src/routes/issues/-issues.helpers.ts | 39 + webui/src/routes/issues/-issues.types.ts | 45 +- webui/src/routes/issues/-route.test.tsx | 61 +- .../issues/-ui/issue-detail-modal.module.css | 203 ++++ .../routes/issues/-ui/issue-detail-modal.tsx | 303 ++++- .../routes/issues/-ui/issue-domain-host.tsx | 284 +++++ webui/static/script.js | 1077 +---------------- 12 files changed, 1141 insertions(+), 1112 deletions(-) create mode 100644 webui/src/platform/workflows/album-workflows.ts create mode 100644 webui/src/routes/issues/-ui/issue-domain-host.tsx 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 ? ( +
+
External Links
+
+ {externalLinks.map((link) => ( + + {link.label} + + ))} +
+
+ ) : null} + + {trackMetaItems.length > 0 ? ( +
+
Track Details
+
+ {trackMetaItems.map((item) => ( +
+ {item.icon} + {item.label} + {item.value} +
+ ))} +
+
+ ) : null} + + {snapshot.file_path ? ( +
+
File Path
+
{String(snapshot.file_path)}
+
+ ) : null} + + {trackRows.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 && ( - - )} - {addToWishlist && ( - - )} + +
)} @@ -365,6 +475,13 @@ export function IssueDetailModal({ /> )} + + {!isAdmin && issue.admin_response ? ( +
+
Admin Response
+
{issue.admin_response}
+
+ ) : null} ) : null} @@ -401,3 +518,109 @@ export function IssueDetailModal({ ); } + +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) { + 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) { + 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; +} diff --git a/webui/src/routes/issues/-ui/issue-domain-host.tsx b/webui/src/routes/issues/-ui/issue-domain-host.tsx new file mode 100644 index 00000000..8252bc60 --- /dev/null +++ b/webui/src/routes/issues/-ui/issue-domain-host.tsx @@ -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(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( + 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('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 ( +
+
event.stopPropagation()} + > +
+

+ Report Issue - {entityLabel} +

+ +
+ +
+
+
{payload.entityName}
+ {payload.artistName ? ( +
+ {payload.artistName} + {payload.albumTitle ? ` - ${payload.albumTitle}` : ''} +
+ ) : null} +
+ +
+ +
+ {categories.map(([category, meta]) => ( + + ))} +
+
+ + {selectedCategory ? ( +
+ + { + setTitle(event.target.value); + setTitleEdited(true); + }} + placeholder="Brief summary of the issue..." + value={title} + /> + + - -
- - - -
-
- `; - - _reportIssueState.selectedCategory = null; - _reportIssueState.selectedPriority = 'normal'; - const submitBtn = document.getElementById('report-issue-submit-btn'); - if (submitBtn) submitBtn.disabled = true; - - overlay.classList.remove('hidden'); -} - -function selectIssueCategory(el, category) { - document.querySelectorAll('.report-issue-category-card').forEach(c => c.classList.remove('selected')); - el.classList.add('selected'); - _reportIssueState.selectedCategory = category; - - const detailsSection = document.getElementById('report-issue-details-section'); - if (detailsSection) detailsSection.style.display = ''; - - // Auto-generate title based on category - const titleInput = document.getElementById('report-issue-input-title'); - const catMeta = ISSUE_CATEGORIES[category]; - if (titleInput && !titleInput._userEdited) { - const entityName = _reportIssueState.entityName || ''; - titleInput.value = `${catMeta.label}: ${entityName}`; - } - - const submitBtn = document.getElementById('report-issue-submit-btn'); - if (submitBtn) submitBtn.disabled = false; -} - -function selectIssuePriority(el, priority) { - document.querySelectorAll('.report-issue-priority-btn').forEach(b => b.classList.remove('selected')); - el.classList.add('selected'); - _reportIssueState.selectedPriority = priority; -} - -function closeReportIssueModal() { - const overlay = document.getElementById('report-issue-overlay'); - if (overlay) overlay.classList.add('hidden'); - _reportIssueState = {}; -} - -async function submitIssue() { - if (_reportIssueState._submitting) return; - const category = _reportIssueState.selectedCategory; - if (!category) { - showToast('Please select an issue category', 'error'); - return; - } - - const titleInput = document.getElementById('report-issue-input-title'); - const descInput = document.getElementById('report-issue-input-desc'); - const title = (titleInput?.value || '').trim(); - const description = (descInput?.value || '').trim(); - - if (!title) { - showToast('Please provide a title for the issue', 'error'); - return; - } - - _reportIssueState._submitting = true; - const submitBtn = document.getElementById('report-issue-submit-btn'); - if (submitBtn) { - submitBtn.disabled = true; - submitBtn.textContent = 'Submitting...'; - } - - try { - const resp = await fetch('/api/issues', { - method: 'POST', - headers: _issueHeaders({ 'Content-Type': 'application/json' }), - body: JSON.stringify({ - profile_id: currentProfile ? currentProfile.id : 1, - entity_type: _reportIssueState.entityType, - entity_id: String(_reportIssueState.entityId), - category: category, - title: title, - description: description, - priority: _reportIssueState.selectedPriority || 'normal', - }), - }); - const data = await resp.json(); - if (data.success) { - showToast('Issue reported successfully', 'success'); - closeReportIssueModal(); - if (currentPage === 'issues') { - loadIssuesPage(); - } else { - requestIssuesRefresh(); - } - } else { - showToast(data.error || 'Failed to submit issue', 'error'); - } - } catch (e) { - console.error('Failed to submit issue:', e); - showToast('Failed to submit issue', 'error'); - } finally { - _reportIssueState._submitting = false; - if (submitBtn) { - submitBtn.disabled = false; - submitBtn.textContent = 'Submit Issue'; - } - } -} - -// --- Issue Detail Modal --- - -async function showIssueDetailModal(issueId) { - const overlay = document.getElementById('issue-detail-overlay'); - const body = document.getElementById('issue-detail-body'); - const footer = document.getElementById('issue-detail-footer'); - const titleEl = document.getElementById('issue-detail-title'); - if (!overlay || !body) return; - - body.innerHTML = '
Loading...
'; - footer.innerHTML = ''; - overlay.classList.remove('hidden'); - - try { - const resp = await fetch(`/api/issues/${issueId}`, { headers: _issueHeaders() }); - const data = await resp.json(); - if (!data.success || !data.issue) { - body.innerHTML = '
Issue not found
'; - return; - } - renderIssueDetail(data.issue, body, footer, titleEl); - } catch (e) { - console.error('Failed to load issue:', e); - body.innerHTML = '
Failed to load issue
'; - } -} - -function renderIssueDetail(issue, body, footer, titleEl) { - const admin = isEnhancedAdmin(); - const catMeta = ISSUE_CATEGORIES[issue.category] || ISSUE_CATEGORIES.other; - const statusMeta = ISSUE_STATUS_META[issue.status] || ISSUE_STATUS_META.open; - - let snapshot = {}; - try { snapshot = typeof issue.snapshot_data === 'string' ? JSON.parse(issue.snapshot_data || '{}') : (issue.snapshot_data || {}); } catch (e) { } - - const entityLabel = issue.entity_type === 'track' ? 'Track' : (issue.entity_type === 'album' ? 'Album' : 'Artist'); - const entityName = snapshot.title || snapshot.name || `${entityLabel} #${issue.entity_id}`; - const artistName = snapshot.artist_name || (issue.entity_type === 'artist' ? snapshot.name : '') || ''; - const albumTitle = issue.entity_type === 'album' ? (snapshot.title || '') : (snapshot.album_title || ''); - const artistId = issue.entity_type === 'artist' ? snapshot.id : snapshot.artist_id; - - // Resolve image URLs — album art and artist photo - let artistThumb = ''; - let albumThumb = ''; - if (issue.entity_type === 'album') { - albumThumb = snapshot.thumb_url || ''; - artistThumb = snapshot.artist_thumb || ''; - } else if (issue.entity_type === 'track') { - albumThumb = snapshot.album_thumb || ''; - artistThumb = snapshot.artist_thumb || ''; - } else { - // Artist issue - artistThumb = snapshot.thumb_url || ''; - } - - // Determine the album-level Spotify ID for download/wishlist actions - const spotifyAlbumId = snapshot.spotify_album_id || ''; - - console.log('Issue detail snapshot:', { entityType: issue.entity_type, albumThumb, artistThumb, spotifyAlbumId, snapshotKeys: Object.keys(snapshot) }); - - const createdDate = issue.created_at ? new Date(issue.created_at).toLocaleString() : 'Unknown'; - const resolvedDate = issue.resolved_at ? new Date(issue.resolved_at).toLocaleString() : ''; - - titleEl.textContent = `Issue #${issue.id}`; - - // --- Build external links chips --- - function _extLinks(snap) { - const links = []; - if (snap.spotify_artist_id) links.push({ svc: 'Spotify', type: 'Artist', url: `https://open.spotify.com/artist/${snap.spotify_artist_id}`, cls: 'ext-spotify' }); - if (snap.spotify_album_id) links.push({ svc: 'Spotify', type: 'Album', url: `https://open.spotify.com/album/${snap.spotify_album_id}`, cls: 'ext-spotify' }); - if (snap.spotify_track_id) links.push({ svc: 'Spotify', type: 'Track', url: `https://open.spotify.com/track/${snap.spotify_track_id}`, cls: 'ext-spotify' }); - if (snap.artist_musicbrainz_id) links.push({ svc: 'MusicBrainz', type: 'Artist', url: `https://musicbrainz.org/artist/${snap.artist_musicbrainz_id}`, cls: 'ext-mb' }); - if (snap.musicbrainz_release_id) links.push({ svc: 'MusicBrainz', type: 'Release', url: `https://musicbrainz.org/release/${snap.musicbrainz_release_id}`, cls: 'ext-mb' }); - if (snap.musicbrainz_recording_id) links.push({ svc: 'MusicBrainz', type: 'Recording', url: `https://musicbrainz.org/recording/${snap.musicbrainz_recording_id}`, cls: 'ext-mb' }); - if (snap.artist_deezer_id) links.push({ svc: 'Deezer', type: 'Artist', url: `https://www.deezer.com/artist/${snap.artist_deezer_id}`, cls: 'ext-deezer' }); - if (snap.album_deezer_id) links.push({ svc: 'Deezer', type: 'Album', url: `https://www.deezer.com/album/${snap.album_deezer_id}`, cls: 'ext-deezer' }); - if (snap.track_deezer_id) links.push({ svc: 'Deezer', type: 'Track', url: `https://www.deezer.com/track/${snap.track_deezer_id}`, cls: 'ext-deezer' }); - if (snap.artist_tidal_id) links.push({ svc: 'Tidal', type: 'Artist', url: `https://listen.tidal.com/artist/${snap.artist_tidal_id}`, cls: 'ext-tidal' }); - if (snap.album_tidal_id) links.push({ svc: 'Tidal', type: 'Album', url: `https://listen.tidal.com/album/${snap.album_tidal_id}`, cls: 'ext-tidal' }); - if (snap.artist_qobuz_id) links.push({ svc: 'Qobuz', type: 'Artist', cls: 'ext-qobuz', id: snap.artist_qobuz_id }); - if (snap.album_qobuz_id) links.push({ svc: 'Qobuz', type: 'Album', cls: 'ext-qobuz', id: snap.album_qobuz_id }); - return links; - } - - const extLinks = _extLinks(snapshot); - let extLinksHtml = ''; - if (extLinks.length > 0) { - const chips = extLinks.map(l => { - if (l.url) { - return `${_esc(l.svc)} ${_esc(l.type)}`; - } - return `${_esc(l.svc)} ${_esc(l.type)}`; - }).join(''); - extLinksHtml = `
${chips}
`; - } - - // --- Build enhanced-library-style album/track widget --- - // Determine which album data to show (for album issues it's the entity, for track issues it's the parent) - const showAlbumWidget = (issue.entity_type === 'album' || issue.entity_type === 'track'); - const albumName = issue.entity_type === 'album' ? (snapshot.title || '') : (snapshot.album_title || ''); - const albumYear = snapshot.year || ''; - const albumLabel = snapshot.label || ''; - const albumType = snapshot.record_type || ''; - const albumTrackCount = issue.entity_type === 'album' ? (snapshot.track_count || '') : (snapshot.album_track_count || ''); - const albumGenres = snapshot.genres || []; - - // --- Build the hero section (artist photo + album art + info) --- - let heroHtml = ''; - if (showAlbumWidget) { - // Genre tags - let genreTagsHtml = ''; - if (Array.isArray(albumGenres) && albumGenres.length > 0) { - genreTagsHtml = `
${albumGenres.slice(0, 5).map(g => `${_esc(g)}`).join('')}
`; - } - - // Album meta line - const albumMetaParts = []; - if (albumYear) albumMetaParts.push(String(albumYear)); - if (albumType) albumMetaParts.push(albumType.charAt(0).toUpperCase() + albumType.slice(1)); - if (albumTrackCount) albumMetaParts.push(albumTrackCount + ' tracks'); - if (albumLabel) albumMetaParts.push(albumLabel); - - // For track issues, show the track title under the album - const trackNameLine = issue.entity_type === 'track' && entityName - ? `
♫ ${_esc(entityName)}
` : ''; - - heroHtml = ` -
-
- ${artistThumb ? `` : ''} - ${albumThumb ? `` : ''} -
${catMeta.icon}
-
-
- ${artistName ? `
${_esc(artistName)}
` : ''} -
${_esc(albumName)}
- ${trackNameLine} - ${albumMetaParts.length > 0 ? `
${_esc(albumMetaParts.join(' \u00B7 '))}
` : ''} - ${genreTagsHtml} - ${extLinksHtml} -
-
- `; - } else { - // Artist-level issue — simpler hero - heroHtml = ` -
-
- ${artistThumb ? `` : `
${catMeta.icon}
`} -
-
-
${_esc(entityName)}
- ${extLinksHtml} -
-
- `; - } - - // --- Issue info bar --- - let issueInfoHtml = ` -
-
- ${_esc(statusMeta.label)} - - ${catMeta.icon} ${_esc(catMeta.label)} -
-
- Reported ${_esc(createdDate)} - ${issue.reporter_name && admin ? `by ${_esc(issue.reporter_name)}` : ''} - ${resolvedDate ? `Resolved ${_esc(resolvedDate)}` : ''} -
-
- `; - - // --- Issue description --- - let descriptionHtml = ` -
-
Issue
-
${_esc(issue.title)}
- ${issue.description ? `
${_esc(issue.description)}
` : '
No additional details provided
'} -
- `; - - // --- Action buttons (Download Album / Add to Wishlist) for admin --- - let actionButtonsHtml = ''; - if (admin && (issue.entity_type === 'album' || issue.entity_type === 'track')) { - actionButtonsHtml = ` -
- - -
- `; - } - - // --- Metadata grid for track-level issues --- - let metaGridHtml = ''; - if (issue.entity_type === 'track') { - const metaItems = []; - if (snapshot.track_number) metaItems.push({ icon: '#', label: 'Track', value: String(snapshot.track_number) }); - if (snapshot.duration) metaItems.push({ icon: '◷', label: 'Duration', value: typeof snapshot.duration === 'number' ? formatDurationMs(snapshot.duration) : String(snapshot.duration) }); - if (snapshot.format) metaItems.push({ icon: '💾', label: 'Format', value: snapshot.format }); - if (snapshot.bitrate) metaItems.push({ icon: '🎶', label: 'Bitrate', value: snapshot.bitrate + ' kbps' }); - if (snapshot.bpm) metaItems.push({ icon: '♫', label: 'BPM', value: String(snapshot.bpm) }); - if (snapshot.quality) metaItems.push({ icon: '★', label: 'Quality', value: snapshot.quality }); - if (metaItems.length > 0) { - metaGridHtml = ` -
-
Track Details
-
- ${metaItems.map(m => ` -
- ${m.icon} - ${_esc(m.label)} - ${_esc(m.value)} -
- `).join('')} -
-
- `; - } - } - - // --- File path display for tracks --- - let filePathHtml = ''; - if (snapshot.file_path) { - filePathHtml = ` -
-
File Path
-
${_esc(snapshot.file_path)}
-
- `; - } - - // --- Enhanced-library-style track listing --- - let trackListHtml = ''; - if (snapshot.tracks && Array.isArray(snapshot.tracks) && snapshot.tracks.length > 0) { - let lastDisc = null; - let rows = ''; - const hasMultiDisc = snapshot.tracks.some(tr => (tr.disc_number || 1) > 1); - snapshot.tracks.forEach(t => { - const disc = t.disc_number || 1; - if (hasMultiDisc && disc !== lastDisc) { - rows += `
Disc ${disc}
`; - lastDisc = disc; - } - const fmt = t.format || (t.file_path ? t.file_path.split('.').pop().toUpperCase() : ''); - const fmtLower = fmt.toLowerCase(); - const fmtClass = fmtLower === 'flac' ? 'flac' : (fmtLower === 'mp3' ? 'mp3' : 'other'); - const br = t.bitrate ? parseInt(t.bitrate) : 0; - const brClass = br >= 320 || fmtLower === 'flac' ? 'high' : (br >= 192 ? 'medium' : 'low'); - const durStr = t.duration && typeof t.duration === 'number' ? formatDurationMs(t.duration) : ''; - - rows += ` -
- ${_esc(String(t.track_number || '-'))} - ${_esc(t.title || 'Unknown')} - ${durStr ? `${durStr}` : ''} - - ${fmt ? `${_esc(fmt)}` : ''} - ${br ? `${br}k` : ''} - -
- `; - }); - trackListHtml = ` -
-
Track Listing ${snapshot.tracks.length} tracks
-
${rows}
-
- `; - } - - // --- Admin response section --- - let adminResponseHtml = ''; - if (admin) { - adminResponseHtml = ` -
-
Admin Response
- -
- `; - } else if (issue.admin_response) { - adminResponseHtml = ` -
-
Admin Response
-
${_esc(issue.admin_response)}
-
- `; - } - - body.innerHTML = ` - ${heroHtml} - ${issueInfoHtml} - ${actionButtonsHtml} - ${descriptionHtml} - ${metaGridHtml} - ${filePathHtml} - ${trackListHtml} - ${adminResponseHtml} - `; - - // --- Footer with status action buttons --- - const safeId = parseInt(issue.id, 10); - let footerHtml = ''; - - if (admin) { - if (issue.status === 'open' || issue.status === 'in_progress') { - if (issue.status === 'open') { - footerHtml += ``; - } - footerHtml += ``; - footerHtml += ``; - } else { - footerHtml += ``; - } - footerHtml += ``; - } else { - if (issue.status === 'open') { - footerHtml += ``; - } - } - - footer.innerHTML = footerHtml; - - // --- Attach action button handlers --- - const dlBtn = document.getElementById('issue-action-download'); - if (dlBtn) { - dlBtn.onclick = () => issueDownloadAlbum(spotifyAlbumId, artistName, albumName); - } - const wlBtn = document.getElementById('issue-action-wishlist'); - if (wlBtn) { - wlBtn.onclick = () => issueAddToWishlist(spotifyAlbumId, artistName, albumName); - } + reporter.openReportIssue({ + entityType, + entityId: String(entityId), + entityName: entityName || '', + artistName: artistName || '', + albumTitle: albumTitle || '', + }); } -// --- Issue Action: Download Album --- -async function issueDownloadAlbum(spotifyAlbumId, artistName, albumName) { - const btn = document.getElementById('issue-action-download'); - if (!spotifyAlbumId && (!artistName || !albumName)) { - showToast('No album ID or artist/album info available for download', 'warning'); - return; - } - try { - if (btn) { btn.disabled = true; btn.textContent = 'Loading...'; } - - let response; - if (spotifyAlbumId) { - const albumParams = new URLSearchParams({ name: albumName || '', artist: artistName || '' }); - response = await fetch(`/api/spotify/album/${encodeURIComponent(spotifyAlbumId)}?${albumParams}`); - } else { - // No Spotify album ID — search for the album by name - const query = `${artistName} ${albumName}`; - const searchResp = await fetch('/api/enhanced-search', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ query }) - }); - if (!searchResp.ok) throw new Error('Album search failed'); - const searchData = await searchResp.json(); - const foundAlbum = searchData.spotify_albums?.[0]; - if (!foundAlbum || !foundAlbum.id) { - showToast(`Could not find "${albumName}" by ${artistName}`, 'warning'); - return; - } - const albumParams = new URLSearchParams({ name: foundAlbum.name || albumName, artist: foundAlbum.artist || artistName }); - response = await fetch(`/api/spotify/album/${encodeURIComponent(foundAlbum.id)}?${albumParams}`); - } - - if (!response.ok) { - if (response.status === 401) throw new Error('Spotify not authenticated'); - throw new Error(`Failed to load album: ${response.status}`); - } - - const albumData = await response.json(); - if (!albumData || !albumData.tracks || albumData.tracks.length === 0) { - showToast(`No tracks available for "${albumName}"`, 'warning'); - return; - } - - // Close the issue modal first - closeIssueDetailModal(); - - const resolvedAlbumId = albumData.id || spotifyAlbumId || Date.now(); - const virtualPlaylistId = `issue_download_${resolvedAlbumId}`; - - // Enrich tracks with album metadata - const enrichedTracks = albumData.tracks.map(track => ({ - ...track, - album: { - name: albumData.name, - id: albumData.id, - album_type: albumData.album_type || 'album', - images: albumData.images || [], - release_date: albumData.release_date, - total_tracks: albumData.total_tracks - } - })); - - const playlistName = `[${artistName}] ${albumData.name}`; - const artistObject = { id: `issue_${artistName}`, name: artistName, image_url: '' }; - const fullAlbumObject = { - name: albumData.name, - id: albumData.id, - album_type: albumData.album_type || 'album', - images: albumData.images || [], - image_url: albumData.images?.[0]?.url || null, - release_date: albumData.release_date, - total_tracks: albumData.total_tracks, - artists: albumData.artists || [{ name: artistName }] - }; - +window.SoulSyncWorkflowActions = { + async openDownloadMissingAlbum(input) { await openDownloadMissingModalForArtistAlbum( - virtualPlaylistId, playlistName, enrichedTracks, fullAlbumObject, artistObject, true + input.virtualPlaylistId, + input.playlistName, + input.tracks, + input.album, + input.artist, + input.forceDownload !== false, ); - - // Register download bubble so it appears on the dashboard - const albumType = fullAlbumObject.album_type || 'album'; - registerArtistDownload(artistObject, fullAlbumObject, virtualPlaylistId, albumType); - - } catch (error) { - console.error('Issue download error:', error); - showToast(`Error: ${error.message}`, 'error'); - } finally { - if (btn) { btn.disabled = false; btn.innerHTML = ' Download Album'; } - } -} - -// --- Redownload Library Album (Enhanced View) --- -async function redownloadLibraryAlbum(album, artistName, btn) { - const albumName = album.title || ''; - const spotifyAlbumId = album.spotify_album_id || ''; - - if (!spotifyAlbumId && !albumName) { - showToast('No album ID or name available for redownload', 'warning'); - return; - } - - const origText = btn ? btn.innerHTML : ''; - try { - if (btn) { btn.disabled = true; btn.textContent = 'Loading...'; } - - let response; - if (spotifyAlbumId) { - const params = new URLSearchParams({ name: albumName, artist: artistName || '' }); - response = await fetch(`/api/spotify/album/${encodeURIComponent(spotifyAlbumId)}?${params}`); - } - - // Fallback: search by name if no ID or direct fetch failed - if (!response || !response.ok) { - const query = `${artistName || ''} ${albumName}`.trim(); - const searchResp = await fetch('/api/enhanced-search', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ query }) - }); - if (!searchResp.ok) throw new Error('Album search failed'); - const searchData = await searchResp.json(); - const found = searchData.spotify_albums?.[0] || searchData.itunes_albums?.[0]; - if (!found || !found.id) { - showToast(`Could not find "${albumName}" by ${artistName || 'unknown'}`, 'warning'); - return; - } - const params = new URLSearchParams({ name: found.name || albumName, artist: found.artist || artistName || '' }); - response = await fetch(`/api/spotify/album/${encodeURIComponent(found.id)}?${params}`); - } - - if (!response.ok) throw new Error(`Failed to load album: ${response.status}`); - - const albumData = await response.json(); - if (!albumData || !albumData.tracks || albumData.tracks.length === 0) { - showToast(`No tracks found for "${albumName}"`, 'warning'); - return; + if (input.registerDownload !== false) { + registerArtistDownload( + input.artist, + input.album, + input.virtualPlaylistId, + input.albumType || input.album?.album_type || 'album', + ); } - - const resolvedId = albumData.id || spotifyAlbumId || album.id; - const virtualPlaylistId = `library_redownload_${resolvedId}`; - const playlistName = `[${artistName || 'Unknown'}] ${albumData.name}`; - - const enrichedTracks = albumData.tracks.map(track => ({ - ...track, - album: { - name: albumData.name, - id: albumData.id, - album_type: albumData.album_type || 'album', - images: albumData.images || [], - release_date: albumData.release_date, - total_tracks: albumData.total_tracks - } - })); - - const enhancedArtist = artistDetailPageState.enhancedData?.artist; - const artistObject = { - id: artistDetailPageState.currentArtistId || `library_${artistName || album.id}`, - name: artistName || '', - image_url: enhancedArtist?.thumb_url || '' - }; - const fullAlbumObject = { - name: albumData.name, - id: albumData.id, - album_type: albumData.album_type || 'album', - images: albumData.images || [], - image_url: albumData.images?.[0]?.url || null, - release_date: albumData.release_date, - total_tracks: albumData.total_tracks, - artists: albumData.artists || [{ name: artistName || '' }] - }; - - await openDownloadMissingModalForArtistAlbum( - virtualPlaylistId, playlistName, enrichedTracks, fullAlbumObject, artistObject, true + }, + async openAddToWishlistAlbum(input) { + await openAddToWishlistModal( + input.album, + input.artist, + input.tracks, + input.albumType || input.album?.album_type || 'album', ); - - // Register download bubble so it appears on the dashboard - const albumType = fullAlbumObject.album_type || 'album'; - registerArtistDownload(artistObject, fullAlbumObject, virtualPlaylistId, albumType); - - } catch (error) { - console.error('Redownload album error:', error); - showToast(`Error: ${error.message}`, 'error'); - } finally { - if (btn) { btn.disabled = false; btn.innerHTML = origText; } - } -} - -// --- Issue Action: Add to Wishlist --- -async function issueAddToWishlist(spotifyAlbumId, artistName, albumName) { - const btn = document.getElementById('issue-action-wishlist'); - if (!spotifyAlbumId && (!artistName || !albumName)) { - showToast('No album ID or artist/album info available', 'warning'); - return; - } - try { - if (btn) { btn.disabled = true; btn.textContent = 'Loading...'; } - - let response; - if (spotifyAlbumId) { - const albumParams = new URLSearchParams({ name: albumName || '', artist: artistName || '' }); - response = await fetch(`/api/spotify/album/${encodeURIComponent(spotifyAlbumId)}?${albumParams}`); - } else { - // No Spotify album ID — search for the album by name - const query = `${artistName} ${albumName}`; - const searchResp = await fetch('/api/enhanced-search', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ query }) - }); - if (!searchResp.ok) throw new Error('Album search failed'); - const searchData = await searchResp.json(); - const foundAlbum = searchData.spotify_albums?.[0]; - if (!foundAlbum || !foundAlbum.id) { - showToast(`Could not find "${albumName}" by ${artistName}`, 'warning'); - return; - } - const albumParams = new URLSearchParams({ name: foundAlbum.name || albumName, artist: foundAlbum.artist || artistName }); - response = await fetch(`/api/spotify/album/${encodeURIComponent(foundAlbum.id)}?${albumParams}`); - } - - if (!response.ok) throw new Error(`Failed to load album: ${response.status}`); - - const albumData = await response.json(); - if (!albumData || !albumData.tracks || albumData.tracks.length === 0) { - showToast(`No tracks available for "${albumName}"`, 'warning'); - return; - } - - // Close issue modal and open wishlist modal - closeIssueDetailModal(); - - const albumArtists = albumData.artists || [{ name: artistName }]; - const album = { - name: albumData.name, - id: albumData.id, - album_type: albumData.album_type || 'album', - images: albumData.images || [], - release_date: albumData.release_date, - total_tracks: albumData.total_tracks, - artists: albumArtists - }; - const artist = { id: null, name: artistName }; - - // Enrich tracks with album metadata — use album artist for wishlist grouping - // (Spotify returns per-track artists which can differ on compilations/soundtracks) - const tracks = albumData.tracks.map(t => ({ - ...t, - artists: albumArtists, - album: album - })); - - await openAddToWishlistModal(album, artist, tracks, albumData.album_type || 'album'); - - } catch (error) { - console.error('Issue wishlist error:', error); - showToast(`Error: ${error.message}`, 'error'); - } finally { - if (btn) { btn.disabled = false; btn.innerHTML = ' Add to Wishlist'; } - } -} - -async function updateIssueStatus(issueId, newStatus) { - const payload = { status: newStatus }; - - // Include admin response if present - const responseInput = document.getElementById('issue-detail-response-input'); - if (responseInput) { - payload.admin_response = responseInput.value.trim(); - } - - try { - const resp = await fetch(`/api/issues/${issueId}`, { - method: 'PUT', - headers: _issueHeaders({ 'Content-Type': 'application/json' }), - body: JSON.stringify(payload), - }); - const data = await resp.json(); - if (data.success) { - showToast(`Issue ${newStatus === 'resolved' ? 'resolved' : newStatus === 'dismissed' ? 'dismissed' : newStatus === 'in_progress' ? 'marked in progress' : 'reopened'}`, 'success'); - closeIssueDetailModal(); - if (currentPage === 'issues') { - loadIssuesPage(); - } else { - requestIssuesRefresh(); - } - } else { - showToast(data.error || 'Failed to update issue', 'error'); - } - } catch (e) { - console.error('Failed to update issue:', e); - showToast('Failed to update issue', 'error'); - } -} - -async function deleteIssue(issueId) { - if (!confirm('Are you sure you want to delete this issue?')) return; - try { - const resp = await fetch(`/api/issues/${issueId}`, { method: 'DELETE', headers: _issueHeaders() }); - const data = await resp.json(); - if (data.success) { - showToast('Issue deleted', 'success'); - closeIssueDetailModal(); - if (currentPage === 'issues') { - loadIssuesPage(); - } else { - requestIssuesRefresh(); - } - } else { - showToast(data.error || 'Failed to delete issue', 'error'); - } - } catch (e) { - console.error('Failed to delete issue:', e); - showToast('Failed to delete issue', 'error'); - } -} - -function closeIssueDetailModal() { - const overlay = document.getElementById('issue-detail-overlay'); - if (overlay) overlay.classList.add('hidden'); - window.dispatchEvent(new CustomEvent('ss:issues-close-detail')); -} - -async function loadIssuesBadge() { - try { - const resp = await fetch('/api/issues/counts', { headers: _issueHeaders() }); - const data = await resp.json(); - if (!data.success) return; - const badge = document.getElementById('issues-nav-badge'); - if (badge) { - const openCount = data.counts.open || 0; - badge.textContent = openCount; - badge.classList.toggle('hidden', openCount === 0); - } - } catch (e) { } -} - -window.addEventListener('ss:issues-refresh', () => { - loadIssuesBadge(); -}); - -window.SoulSyncIssueActions = { - downloadAlbum: issueDownloadAlbum, - addToWishlist: issueAddToWishlist, + }, + notify(message, type) { + showToast(message, type || 'info'); + }, }; // ===== END ISSUES PAGE =====