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
This commit is contained in:
parent
43db30608d
commit
577e4bdace
12 changed files with 1141 additions and 1112 deletions
|
|
@ -6150,23 +6150,6 @@
|
||||||
</div>
|
</div>
|
||||||
</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()">×</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 -->
|
<!-- Help & Docs Page -->
|
||||||
<div class="page{% if initial_client_page == 'help' %} active{% endif %}" id="help-page">
|
<div class="page{% if initial_client_page == 'help' %} active{% endif %}" id="help-page">
|
||||||
<div class="docs-layout">
|
<div class="docs-layout">
|
||||||
|
|
|
||||||
|
|
@ -63,14 +63,14 @@ function createShellBridge(overrides: Partial<ShellBridge> = {}): ShellBridge {
|
||||||
|
|
||||||
describe('createAppRouter', () => {
|
describe('createAppRouter', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
window.SoulSyncIssueActions = {};
|
vi.stubGlobal('fetch', mockIssuesFetch());
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
vi.unstubAllGlobals();
|
vi.unstubAllGlobals();
|
||||||
window.SoulSyncWebShellBridge = undefined;
|
window.SoulSyncWebShellBridge = undefined;
|
||||||
window.SoulSyncIssueActions = undefined;
|
window.SoulSyncIssueDomain = undefined;
|
||||||
});
|
});
|
||||||
|
|
||||||
it('creates one shared query client and applies router defaults', () => {
|
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 () => {
|
it('renders migrated React routes directly and updates shell chrome', async () => {
|
||||||
window.SoulSyncWebShellBridge = createShellBridge();
|
window.SoulSyncWebShellBridge = createShellBridge();
|
||||||
vi.stubGlobal('fetch', mockIssuesFetch());
|
|
||||||
|
|
||||||
const queryClient = createAppQueryClient();
|
const queryClient = createAppQueryClient();
|
||||||
const history = createMemoryHistory({ initialEntries: ['/issues'] });
|
const history = createMemoryHistory({ initialEntries: ['/issues'] });
|
||||||
|
|
|
||||||
16
webui/src/platform/shell/globals.d.ts
vendored
16
webui/src/platform/shell/globals.d.ts
vendored
|
|
@ -1,10 +1,20 @@
|
||||||
import type { ShellProfileContext, ShellRouteDefinition, ShellPageId } from './bridge';
|
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 {
|
declare global {
|
||||||
interface Window {
|
interface Window {
|
||||||
SoulSyncIssueActions?: {
|
showToast?: (message: string, type?: string, durationOrContext?: number | string) => void;
|
||||||
addToWishlist?: (albumId: string, artistName: string, albumName: string) => void;
|
SoulSyncIssueDomain?: IssueDomainBridge;
|
||||||
downloadAlbum?: (albumId: string, artistName: string, albumName: string) => void;
|
SoulSyncWorkflowActions?: {
|
||||||
|
openDownloadMissingAlbum: (
|
||||||
|
input: DownloadMissingAlbumWorkflowInput,
|
||||||
|
) => void | Promise<void>;
|
||||||
|
openAddToWishlistAlbum: (input: WishlistAlbumWorkflowInput) => void | Promise<void>;
|
||||||
|
notify?: (message: string, type?: string) => void;
|
||||||
};
|
};
|
||||||
SoulSyncWebRouter?: {
|
SoulSyncWebRouter?: {
|
||||||
routeManifest: ShellRouteDefinition[];
|
routeManifest: ShellRouteDefinition[];
|
||||||
|
|
|
||||||
194
webui/src/platform/workflows/album-workflows.ts
Normal file
194
webui/src/platform/workflows/album-workflows.ts
Normal file
|
|
@ -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 type { AppRouterContext } from '@/app/router';
|
||||||
|
|
||||||
|
import { IssueDomainHost } from './issues/-ui/issue-domain-host';
|
||||||
|
|
||||||
export const Route = createRootRouteWithContext<AppRouterContext>()({
|
export const Route = createRootRouteWithContext<AppRouterContext>()({
|
||||||
component: () => <Outlet />,
|
component: () => (
|
||||||
|
<>
|
||||||
|
<Outlet />
|
||||||
|
<IssueDomainHost />
|
||||||
|
</>
|
||||||
|
),
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import type {
|
||||||
IssueDetailResponse,
|
IssueDetailResponse,
|
||||||
IssueListResponse,
|
IssueListResponse,
|
||||||
IssueRecord,
|
IssueRecord,
|
||||||
|
CreateIssuePayload,
|
||||||
IssuesSearch,
|
IssuesSearch,
|
||||||
IssueSnapshot,
|
IssueSnapshot,
|
||||||
} from './-issues.types';
|
} from './-issues.types';
|
||||||
|
|
@ -101,6 +102,17 @@ function createIssueHeaders(profileId: number, extra?: HeadersInit): Headers {
|
||||||
return 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> {
|
export function normalizeIssuesSearch(search: IssuesSearch | undefined): Required<IssuesSearch> {
|
||||||
const status = search?.status;
|
const status = search?.status;
|
||||||
const category = search?.category;
|
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> {
|
export async function deleteIssue(profileId: number, issueId: number): Promise<void> {
|
||||||
const payload = await parseJsonResponse<{ success: boolean; error?: string }>(
|
const payload = await parseJsonResponse<{ success: boolean; error?: string }>(
|
||||||
apiClient.delete(`issues/${issueId}`, {
|
apiClient.delete(`issues/${issueId}`, {
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,6 @@
|
||||||
export type IssueStatus = 'open' | 'in_progress' | 'resolved' | 'dismissed';
|
export type IssueStatus = 'open' | 'in_progress' | 'resolved' | 'dismissed';
|
||||||
|
export type IssueEntityType = 'track' | 'album' | 'artist';
|
||||||
|
export type IssuePriority = 'low' | 'normal' | 'high';
|
||||||
|
|
||||||
export interface IssueSnapshot {
|
export interface IssueSnapshot {
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
|
|
@ -12,12 +14,30 @@ export interface IssueSnapshot {
|
||||||
spotify_album_id?: string;
|
spotify_album_id?: string;
|
||||||
spotify_artist_id?: string;
|
spotify_artist_id?: string;
|
||||||
spotify_track_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 {
|
export interface IssueRecord {
|
||||||
id: number;
|
id: number;
|
||||||
profile_id: number;
|
profile_id: number;
|
||||||
entity_type: 'track' | 'album' | 'artist';
|
entity_type: IssueEntityType;
|
||||||
entity_id: string;
|
entity_id: string;
|
||||||
category: string;
|
category: string;
|
||||||
title: string;
|
title: string;
|
||||||
|
|
@ -66,3 +86,26 @@ export interface IssuesSearch {
|
||||||
category?: string;
|
category?: string;
|
||||||
status?: IssueStatus | 'all';
|
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 { 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 { afterEach, beforeEach, describe, expect, it, vi } from 'vite-plus/test';
|
||||||
|
|
||||||
import type { ShellBridge, ShellPageId } from '@/platform/shell/bridge';
|
import type { ShellBridge, ShellPageId } from '@/platform/shell/bridge';
|
||||||
|
|
@ -36,15 +36,15 @@ function renderIssuesRoute() {
|
||||||
return render(<AppRouterProvider router={router} queryClient={queryClient} />);
|
return render(<AppRouterProvider router={router} queryClient={queryClient} />);
|
||||||
}
|
}
|
||||||
|
|
||||||
const shellActions = {
|
const workflowActions = {
|
||||||
downloadAlbum: vi.fn(),
|
openDownloadMissingAlbum: vi.fn(),
|
||||||
addToWishlist: vi.fn(),
|
openAddToWishlistAlbum: vi.fn(),
|
||||||
};
|
};
|
||||||
|
|
||||||
describe('issues route', () => {
|
describe('issues route', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
shellActions.downloadAlbum.mockReset();
|
workflowActions.openDownloadMissingAlbum.mockReset();
|
||||||
shellActions.addToWishlist.mockReset();
|
workflowActions.openAddToWishlistAlbum.mockReset();
|
||||||
window.SoulSyncWebShellBridge = createShellBridge();
|
window.SoulSyncWebShellBridge = createShellBridge();
|
||||||
vi.stubGlobal(
|
vi.stubGlobal(
|
||||||
'fetch',
|
'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 });
|
return createResponse({ success: true });
|
||||||
}) as unknown as typeof fetch,
|
}) as unknown as typeof fetch,
|
||||||
);
|
);
|
||||||
vi.stubGlobal('SoulSyncIssueActions', shellActions);
|
vi.stubGlobal('SoulSyncWorkflowActions', workflowActions);
|
||||||
|
vi.stubGlobal('showToast', vi.fn());
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
|
|
@ -139,10 +151,41 @@ describe('issues route', () => {
|
||||||
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
|
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();
|
renderIssuesRoute();
|
||||||
fireEvent.click(await screen.findByTestId('issue-card-7'));
|
fireEvent.click(await screen.findByTestId('issue-card-7'));
|
||||||
fireEvent.click(await screen.findByRole('button', { name: /download album/i }));
|
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;
|
max-height: 85vh;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.reportIssueModal {
|
||||||
|
max-width: 680px;
|
||||||
|
width: 95vw;
|
||||||
|
}
|
||||||
|
|
||||||
.modalHeader {
|
.modalHeader {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
|
|
@ -594,6 +599,17 @@
|
||||||
white-space: pre-wrap;
|
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 {
|
.issueDetailNoDesc {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: rgba(255, 255, 255, 0.25);
|
color: rgba(255, 255, 255, 0.25);
|
||||||
|
|
@ -711,6 +727,193 @@
|
||||||
border-color: rgba(var(--accent-light-rgb), 0.5);
|
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 {
|
.modalFooter {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,11 @@
|
||||||
import { useMutation } from '@tanstack/react-query';
|
import { useMutation } from '@tanstack/react-query';
|
||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
|
||||||
|
import {
|
||||||
|
launchAlbumDownloadWorkflow,
|
||||||
|
launchAlbumWishlistWorkflow,
|
||||||
|
} from '@/platform/workflows/album-workflows';
|
||||||
|
|
||||||
import type { IssueRecord } from '../-issues.types';
|
import type { IssueRecord } from '../-issues.types';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
|
@ -16,13 +21,6 @@ import {
|
||||||
} from '../-issues.helpers';
|
} from '../-issues.helpers';
|
||||||
import styles from './issue-detail-modal.module.css';
|
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({
|
export function IssueDetailModal({
|
||||||
error,
|
error,
|
||||||
isAdmin,
|
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(() => {
|
const statusButtons = useMemo(() => {
|
||||||
if (!issue) return null;
|
if (!issue) return null;
|
||||||
|
|
||||||
|
|
@ -166,9 +176,15 @@ export function IssueDetailModal({
|
||||||
const issueCategoryLabel = issue
|
const issueCategoryLabel = issue
|
||||||
? ISSUE_CATEGORY_META[issue.category]?.label || issue.category
|
? ISSUE_CATEGORY_META[issue.category]?.label || issue.category
|
||||||
: '';
|
: '';
|
||||||
|
const externalLinks = getExternalLinks(snapshot);
|
||||||
const downloadAlbum = window.SoulSyncIssueActions?.downloadAlbum;
|
const trackMetaItems = getTrackMetaItems(snapshot);
|
||||||
const addToWishlist = window.SoulSyncIssueActions?.addToWishlist;
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|
@ -313,43 +329,137 @@ export function IssueDetailModal({
|
||||||
{formatIssueDate(issue.created_at)}
|
{formatIssueDate(issue.created_at)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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>
|
||||||
</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 && (
|
{issue.entity_type !== 'artist' && isAdmin && (
|
||||||
<div className={styles.issueDetailSection}>
|
<div className={styles.issueDetailSection}>
|
||||||
<div className={styles.issueDetailSectionTitle}>Admin Actions</div>
|
<div className={styles.issueDetailSectionTitle}>Admin Actions</div>
|
||||||
<div className={styles.issueActionButtons}>
|
<div className={styles.issueActionButtons}>
|
||||||
{downloadAlbum && (
|
<button
|
||||||
<button
|
className={`${styles.issueActionButton} ${styles.issueActionDownload}`}
|
||||||
className={`${styles.issueActionButton} ${styles.issueActionDownload}`}
|
type="button"
|
||||||
type="button"
|
disabled={downloadWorkflowMutation.isPending}
|
||||||
onClick={() =>
|
onClick={() => downloadWorkflowMutation.mutate(albumWorkflowInput)}
|
||||||
downloadAlbum(
|
>
|
||||||
String(snapshot.spotify_album_id || ''),
|
{downloadWorkflowMutation.isPending ? 'Loading...' : 'Download Album'}
|
||||||
String(snapshot.artist_name || ''),
|
</button>
|
||||||
String(snapshot.album_title || snapshot.title || ''),
|
<button
|
||||||
)
|
className={`${styles.issueActionButton} ${styles.issueActionWishlist}`}
|
||||||
}
|
type="button"
|
||||||
>
|
disabled={wishlistWorkflowMutation.isPending}
|
||||||
Download Album
|
onClick={() => wishlistWorkflowMutation.mutate(albumWorkflowInput)}
|
||||||
</button>
|
>
|
||||||
)}
|
{wishlistWorkflowMutation.isPending ? 'Loading...' : 'Add to Wishlist'}
|
||||||
{addToWishlist && (
|
</button>
|
||||||
<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>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -365,6 +475,13 @@ export function IssueDetailModal({
|
||||||
/>
|
/>
|
||||||
</div>
|
</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}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -401,3 +518,109 @@ export function IssueDetailModal({
|
||||||
</div>
|
</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;
|
||||||
|
}
|
||||||
|
|
|
||||||
284
webui/src/routes/issues/-ui/issue-domain-host.tsx
Normal file
284
webui/src/routes/issues/-ui/issue-domain-host.tsx
Normal file
|
|
@ -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"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</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…
Reference in a new issue