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>
|
||||
|
||||
<!-- 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 -->
|
||||
<div class="page{% if initial_client_page == 'help' %} active{% endif %}" id="help-page">
|
||||
<div class="docs-layout">
|
||||
|
|
|
|||
|
|
@ -63,14 +63,14 @@ function createShellBridge(overrides: Partial<ShellBridge> = {}): ShellBridge {
|
|||
|
||||
describe('createAppRouter', () => {
|
||||
beforeEach(() => {
|
||||
window.SoulSyncIssueActions = {};
|
||||
vi.stubGlobal('fetch', mockIssuesFetch());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
window.SoulSyncWebShellBridge = undefined;
|
||||
window.SoulSyncIssueActions = undefined;
|
||||
window.SoulSyncIssueDomain = undefined;
|
||||
});
|
||||
|
||||
it('creates one shared query client and applies router defaults', () => {
|
||||
|
|
@ -87,7 +87,6 @@ describe('createAppRouter', () => {
|
|||
|
||||
it('renders migrated React routes directly and updates shell chrome', async () => {
|
||||
window.SoulSyncWebShellBridge = createShellBridge();
|
||||
vi.stubGlobal('fetch', mockIssuesFetch());
|
||||
|
||||
const queryClient = createAppQueryClient();
|
||||
const history = createMemoryHistory({ initialEntries: ['/issues'] });
|
||||
|
|
|
|||
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 {
|
||||
DownloadMissingAlbumWorkflowInput,
|
||||
WishlistAlbumWorkflowInput,
|
||||
} from '@/platform/workflows/album-workflows';
|
||||
import type { IssueDomainBridge } from '@/routes/issues/-issues.types';
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
SoulSyncIssueActions?: {
|
||||
addToWishlist?: (albumId: string, artistName: string, albumName: string) => void;
|
||||
downloadAlbum?: (albumId: string, artistName: string, albumName: string) => void;
|
||||
showToast?: (message: string, type?: string, durationOrContext?: number | string) => void;
|
||||
SoulSyncIssueDomain?: IssueDomainBridge;
|
||||
SoulSyncWorkflowActions?: {
|
||||
openDownloadMissingAlbum: (
|
||||
input: DownloadMissingAlbumWorkflowInput,
|
||||
) => void | Promise<void>;
|
||||
openAddToWishlistAlbum: (input: WishlistAlbumWorkflowInput) => void | Promise<void>;
|
||||
notify?: (message: string, type?: string) => void;
|
||||
};
|
||||
SoulSyncWebRouter?: {
|
||||
routeManifest: ShellRouteDefinition[];
|
||||
|
|
|
|||
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 { IssueDomainHost } from './issues/-ui/issue-domain-host';
|
||||
|
||||
export const Route = createRootRouteWithContext<AppRouterContext>()({
|
||||
component: () => <Outlet />,
|
||||
component: () => (
|
||||
<>
|
||||
<Outlet />
|
||||
<IssueDomainHost />
|
||||
</>
|
||||
),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import type {
|
|||
IssueDetailResponse,
|
||||
IssueListResponse,
|
||||
IssueRecord,
|
||||
CreateIssuePayload,
|
||||
IssuesSearch,
|
||||
IssueSnapshot,
|
||||
} from './-issues.types';
|
||||
|
|
@ -101,6 +102,17 @@ function createIssueHeaders(profileId: number, extra?: HeadersInit): Headers {
|
|||
return headers;
|
||||
}
|
||||
|
||||
export function getIssueCategoriesForEntity(entityType: IssueRecord['entity_type']) {
|
||||
return Object.entries(ISSUE_CATEGORY_META).filter(([, category]) =>
|
||||
category.applies.includes(entityType),
|
||||
);
|
||||
}
|
||||
|
||||
export function createDefaultIssueTitle(category: string, entityName: string): string {
|
||||
const label = ISSUE_CATEGORY_META[category]?.label || 'Issue';
|
||||
return `${label}: ${entityName || 'Unknown'}`;
|
||||
}
|
||||
|
||||
export function normalizeIssuesSearch(search: IssuesSearch | undefined): Required<IssuesSearch> {
|
||||
const status = search?.status;
|
||||
const category = search?.category;
|
||||
|
|
@ -183,6 +195,33 @@ export async function updateIssue(
|
|||
}
|
||||
}
|
||||
|
||||
export async function createIssue(
|
||||
profileId: number,
|
||||
payload: CreateIssuePayload,
|
||||
): Promise<IssueRecord | null> {
|
||||
const response = await parseJsonResponse<{
|
||||
success: boolean;
|
||||
issue?: IssueRecord;
|
||||
error?: string;
|
||||
}>(
|
||||
apiClient.post('issues', {
|
||||
headers: createIssueHeaders(profileId, { 'Content-Type': 'application/json' }),
|
||||
json: {
|
||||
entity_type: payload.entity_type,
|
||||
entity_id: String(payload.entity_id),
|
||||
category: payload.category,
|
||||
title: payload.title,
|
||||
description: payload.description || '',
|
||||
priority: payload.priority || 'normal',
|
||||
},
|
||||
}),
|
||||
);
|
||||
if (!response.success) {
|
||||
throw new Error(response.error || 'Failed to submit issue');
|
||||
}
|
||||
return response.issue ?? null;
|
||||
}
|
||||
|
||||
export async function deleteIssue(profileId: number, issueId: number): Promise<void> {
|
||||
const payload = await parseJsonResponse<{ success: boolean; error?: string }>(
|
||||
apiClient.delete(`issues/${issueId}`, {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
export type IssueStatus = 'open' | 'in_progress' | 'resolved' | 'dismissed';
|
||||
export type IssueEntityType = 'track' | 'album' | 'artist';
|
||||
export type IssuePriority = 'low' | 'normal' | 'high';
|
||||
|
||||
export interface IssueSnapshot {
|
||||
[key: string]: unknown;
|
||||
|
|
@ -12,12 +14,30 @@ export interface IssueSnapshot {
|
|||
spotify_album_id?: string;
|
||||
spotify_artist_id?: string;
|
||||
spotify_track_id?: string;
|
||||
artist_id?: string | number;
|
||||
album_id?: string | number;
|
||||
track_number?: string | number;
|
||||
duration?: string | number;
|
||||
format?: string;
|
||||
bitrate?: string | number;
|
||||
bpm?: string | number;
|
||||
quality?: string;
|
||||
file_path?: string;
|
||||
tracks?: Array<Record<string, unknown>>;
|
||||
artist_musicbrainz_id?: string;
|
||||
musicbrainz_release_id?: string;
|
||||
musicbrainz_recording_id?: string;
|
||||
artist_deezer_id?: string;
|
||||
album_deezer_id?: string;
|
||||
track_deezer_id?: string;
|
||||
artist_tidal_id?: string;
|
||||
album_tidal_id?: string;
|
||||
}
|
||||
|
||||
export interface IssueRecord {
|
||||
id: number;
|
||||
profile_id: number;
|
||||
entity_type: 'track' | 'album' | 'artist';
|
||||
entity_type: IssueEntityType;
|
||||
entity_id: string;
|
||||
category: string;
|
||||
title: string;
|
||||
|
|
@ -66,3 +86,26 @@ export interface IssuesSearch {
|
|||
category?: string;
|
||||
status?: IssueStatus | 'all';
|
||||
}
|
||||
|
||||
export interface CreateIssuePayload {
|
||||
entity_type: IssueEntityType;
|
||||
entity_id: string;
|
||||
category: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
priority?: IssuePriority;
|
||||
}
|
||||
|
||||
export interface IssueReportPayload {
|
||||
entityType: IssueEntityType;
|
||||
entityId: string | number;
|
||||
entityName: string;
|
||||
artistName?: string;
|
||||
albumTitle?: string;
|
||||
}
|
||||
|
||||
export interface IssueDomainBridge {
|
||||
openReportIssue: (payload: IssueReportPayload) => void;
|
||||
refresh: () => void;
|
||||
closeReportIssue?: () => void;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { createMemoryHistory } from '@tanstack/react-router';
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vite-plus/test';
|
||||
|
||||
import type { ShellBridge, ShellPageId } from '@/platform/shell/bridge';
|
||||
|
|
@ -36,15 +36,15 @@ function renderIssuesRoute() {
|
|||
return render(<AppRouterProvider router={router} queryClient={queryClient} />);
|
||||
}
|
||||
|
||||
const shellActions = {
|
||||
downloadAlbum: vi.fn(),
|
||||
addToWishlist: vi.fn(),
|
||||
const workflowActions = {
|
||||
openDownloadMissingAlbum: vi.fn(),
|
||||
openAddToWishlistAlbum: vi.fn(),
|
||||
};
|
||||
|
||||
describe('issues route', () => {
|
||||
beforeEach(() => {
|
||||
shellActions.downloadAlbum.mockReset();
|
||||
shellActions.addToWishlist.mockReset();
|
||||
workflowActions.openDownloadMissingAlbum.mockReset();
|
||||
workflowActions.openAddToWishlistAlbum.mockReset();
|
||||
window.SoulSyncWebShellBridge = createShellBridge();
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
|
|
@ -107,10 +107,22 @@ describe('issues route', () => {
|
|||
},
|
||||
});
|
||||
}
|
||||
if (url.includes('/api/spotify/album/abc123')) {
|
||||
return createResponse({
|
||||
id: 'abc123',
|
||||
name: 'Album Name',
|
||||
album_type: 'album',
|
||||
images: [{ url: 'https://example.com/thumb.jpg' }],
|
||||
total_tracks: 1,
|
||||
artists: [{ name: 'Artist' }],
|
||||
tracks: [{ id: 'track-1', name: 'Track 1' }],
|
||||
});
|
||||
}
|
||||
return createResponse({ success: true });
|
||||
}) as unknown as typeof fetch,
|
||||
);
|
||||
vi.stubGlobal('SoulSyncIssueActions', shellActions);
|
||||
vi.stubGlobal('SoulSyncWorkflowActions', workflowActions);
|
||||
vi.stubGlobal('showToast', vi.fn());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
|
|
@ -139,10 +151,41 @@ describe('issues route', () => {
|
|||
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('invokes the legacy adapter for admin downloads', async () => {
|
||||
it('invokes the shared workflow adapter for admin downloads', async () => {
|
||||
renderIssuesRoute();
|
||||
fireEvent.click(await screen.findByTestId('issue-card-7'));
|
||||
fireEvent.click(await screen.findByRole('button', { name: /download album/i }));
|
||||
expect(shellActions.downloadAlbum).toHaveBeenCalledWith('abc123', 'Artist', 'Album Name');
|
||||
await waitFor(() => expect(workflowActions.openDownloadMissingAlbum).toHaveBeenCalled());
|
||||
expect(workflowActions.openDownloadMissingAlbum).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
virtualPlaylistId: 'issue_download_abc123',
|
||||
playlistName: '[Artist] Album Name',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('opens the global React issue composer through the domain bridge', async () => {
|
||||
const fetchMock = vi.mocked(fetch);
|
||||
renderIssuesRoute();
|
||||
await waitFor(() => expect(window.SoulSyncIssueDomain).toBeDefined());
|
||||
|
||||
act(() => {
|
||||
window.SoulSyncIssueDomain?.openReportIssue({
|
||||
entityType: 'album',
|
||||
entityId: 15,
|
||||
entityName: 'Album Name',
|
||||
artistName: 'Artist',
|
||||
});
|
||||
});
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: /wrong cover art/i }));
|
||||
expect(screen.getByLabelText(/title/i)).toHaveValue('Wrong Cover Art: Album Name');
|
||||
fireEvent.click(screen.getByRole('button', { name: /submit issue/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
fetchMock.mock.calls.some(([request]) => request instanceof Request && request.method === 'POST'),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -417,6 +417,11 @@
|
|||
max-height: 85vh;
|
||||
}
|
||||
|
||||
.reportIssueModal {
|
||||
max-width: 680px;
|
||||
width: 95vw;
|
||||
}
|
||||
|
||||
.modalHeader {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
|
|
@ -594,6 +599,17 @@
|
|||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.issueDetailFilepath {
|
||||
padding: 10px 12px;
|
||||
border-radius: 8px;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
color: rgba(255, 255, 255, 0.72);
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
font-size: 12px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.issueDetailNoDesc {
|
||||
font-size: 13px;
|
||||
color: rgba(255, 255, 255, 0.25);
|
||||
|
|
@ -711,6 +727,193 @@
|
|||
border-color: rgba(var(--accent-light-rgb), 0.5);
|
||||
}
|
||||
|
||||
.issueDetailAdminResponse {
|
||||
padding: 12px 14px;
|
||||
border-radius: 10px;
|
||||
background: rgba(var(--accent-light-rgb), 0.08);
|
||||
border: 1px solid rgba(var(--accent-light-rgb), 0.18);
|
||||
color: rgba(255, 255, 255, 0.78);
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.issueExternalLinks {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.issueExternalLink {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 6px 10px;
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
color: rgba(255, 255, 255, 0.78);
|
||||
text-decoration: none;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.issueExternalLink:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.issueDetailTracklist {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.issueDetailTracklistRow,
|
||||
.issueDetailTracklistDisc {
|
||||
display: grid;
|
||||
grid-template-columns: 42px minmax(0, 1fr) auto auto;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
padding: 8px 10px;
|
||||
border-radius: 8px;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.issueDetailTracklistDisc {
|
||||
display: block;
|
||||
color: rgba(255, 255, 255, 0.45);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.issueDetailTracklistNum,
|
||||
.issueDetailTracklistDur,
|
||||
.issueDetailTracklistMeta {
|
||||
color: rgba(255, 255, 255, 0.42);
|
||||
}
|
||||
|
||||
.issueDetailTracklistTitle {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: rgba(255, 255, 255, 0.82);
|
||||
}
|
||||
|
||||
.issueTrackBadge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
border-radius: 999px;
|
||||
padding: 2px 6px;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: rgba(255, 255, 255, 0.65);
|
||||
font-size: 10px;
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.reportIssueEntityInfo {
|
||||
padding: 14px 16px;
|
||||
border-radius: 12px;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.reportIssueEntityName {
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.reportIssueEntityArtist {
|
||||
margin-top: 4px;
|
||||
color: rgba(255, 255, 255, 0.48);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.reportIssueCategoryGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.reportIssueCategoryCard {
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
min-height: 122px;
|
||||
text-align: left;
|
||||
padding: 12px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.reportIssueCategoryCard:hover,
|
||||
.reportIssueCategoryCardSelected {
|
||||
border-color: rgba(var(--accent-light-rgb), 0.45);
|
||||
background: rgba(var(--accent-light-rgb), 0.1);
|
||||
}
|
||||
|
||||
.reportIssueCategoryIcon {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.reportIssueCategoryLabel {
|
||||
font-weight: 700;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.reportIssueCategoryDesc {
|
||||
color: rgba(255, 255, 255, 0.48);
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.reportIssueInput {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
margin-bottom: 12px;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
color: #fff;
|
||||
padding: 10px 12px;
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
outline: none;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.reportIssuePriorityRow {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.reportIssuePriorityButton {
|
||||
padding: 8px 12px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
color: rgba(255, 255, 255, 0.72);
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.reportIssuePriorityButtonSelected {
|
||||
border-color: rgba(var(--accent-light-rgb), 0.45);
|
||||
background: rgba(var(--accent-light-rgb), 0.14);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.reportIssueError {
|
||||
color: #ffd0d0;
|
||||
border: 1px solid rgba(239, 68, 68, 0.25);
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
border-radius: 10px;
|
||||
padding: 10px 12px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.modalFooter {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,11 @@
|
|||
import { useMutation } from '@tanstack/react-query';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import {
|
||||
launchAlbumDownloadWorkflow,
|
||||
launchAlbumWishlistWorkflow,
|
||||
} from '@/platform/workflows/album-workflows';
|
||||
|
||||
import type { IssueRecord } from '../-issues.types';
|
||||
|
||||
import {
|
||||
|
|
@ -16,13 +21,6 @@ import {
|
|||
} from '../-issues.helpers';
|
||||
import styles from './issue-detail-modal.module.css';
|
||||
|
||||
function getStatusClassName(status: string) {
|
||||
if (status === 'in_progress') return styles.issueStatusProgress;
|
||||
if (status === 'resolved') return styles.issueStatusResolved;
|
||||
if (status === 'dismissed') return styles.issueStatusDismissed;
|
||||
return styles.issueStatusOpen;
|
||||
}
|
||||
|
||||
export function IssueDetailModal({
|
||||
error,
|
||||
isAdmin,
|
||||
|
|
@ -67,6 +65,18 @@ export function IssueDetailModal({
|
|||
},
|
||||
});
|
||||
|
||||
const downloadWorkflowMutation = useMutation({
|
||||
mutationFn: launchAlbumDownloadWorkflow,
|
||||
onError: notifyWorkflowError,
|
||||
onSuccess: onClose,
|
||||
});
|
||||
|
||||
const wishlistWorkflowMutation = useMutation({
|
||||
mutationFn: launchAlbumWishlistWorkflow,
|
||||
onError: notifyWorkflowError,
|
||||
onSuccess: onClose,
|
||||
});
|
||||
|
||||
const statusButtons = useMemo(() => {
|
||||
if (!issue) return null;
|
||||
|
||||
|
|
@ -166,9 +176,15 @@ export function IssueDetailModal({
|
|||
const issueCategoryLabel = issue
|
||||
? ISSUE_CATEGORY_META[issue.category]?.label || issue.category
|
||||
: '';
|
||||
|
||||
const downloadAlbum = window.SoulSyncIssueActions?.downloadAlbum;
|
||||
const addToWishlist = window.SoulSyncIssueActions?.addToWishlist;
|
||||
const externalLinks = getExternalLinks(snapshot);
|
||||
const trackMetaItems = getTrackMetaItems(snapshot);
|
||||
const trackRows = Array.isArray(snapshot.tracks) ? snapshot.tracks : [];
|
||||
const albumWorkflowInput = {
|
||||
spotifyAlbumId: String(snapshot.spotify_album_id || ''),
|
||||
artistName: String(snapshot.artist_name || ''),
|
||||
albumName: String(snapshot.album_title || snapshot.title || ''),
|
||||
source: 'issue',
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
|
|
@ -313,43 +329,137 @@ export function IssueDetailModal({
|
|||
{formatIssueDate(issue.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
{issue.updated_at ? (
|
||||
<div className={styles.issueMetaItem}>
|
||||
<span className={styles.issueMetaIcon}>U</span>
|
||||
<span className={styles.issueMetaLabel}>Updated</span>
|
||||
<span className={styles.issueMetaValue}>
|
||||
{formatIssueDate(issue.updated_at)}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
{issue.resolved_at ? (
|
||||
<div className={styles.issueMetaItem}>
|
||||
<span className={styles.issueMetaIcon}>R</span>
|
||||
<span className={styles.issueMetaLabel}>Resolved</span>
|
||||
<span className={styles.issueMetaValue}>
|
||||
{formatIssueDate(issue.resolved_at)}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
{issue.resolved_by ? (
|
||||
<div className={styles.issueMetaItem}>
|
||||
<span className={styles.issueMetaIcon}>A</span>
|
||||
<span className={styles.issueMetaLabel}>Resolver</span>
|
||||
<span className={styles.issueMetaValue}>{issue.resolved_by}</span>
|
||||
</div>
|
||||
) : null}
|
||||
{issue.reporter_name ? (
|
||||
<div className={styles.issueMetaItem}>
|
||||
<span className={styles.issueMetaIcon}>P</span>
|
||||
<span className={styles.issueMetaLabel}>Reporter</span>
|
||||
<span className={styles.issueMetaValue}>{issue.reporter_name}</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{externalLinks.length > 0 ? (
|
||||
<div className={styles.issueDetailSection}>
|
||||
<div className={styles.issueDetailSectionTitle}>External Links</div>
|
||||
<div className={styles.issueExternalLinks}>
|
||||
{externalLinks.map((link) => (
|
||||
<a
|
||||
key={link.url}
|
||||
className={styles.issueExternalLink}
|
||||
href={link.url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
{link.label}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{trackMetaItems.length > 0 ? (
|
||||
<div className={styles.issueDetailSection}>
|
||||
<div className={styles.issueDetailSectionTitle}>Track Details</div>
|
||||
<div className={styles.issueDetailMetaGrid}>
|
||||
{trackMetaItems.map((item) => (
|
||||
<div className={styles.issueMetaItem} key={item.label}>
|
||||
<span className={styles.issueMetaIcon}>{item.icon}</span>
|
||||
<span className={styles.issueMetaLabel}>{item.label}</span>
|
||||
<span className={styles.issueMetaValue}>{item.value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{snapshot.file_path ? (
|
||||
<div className={styles.issueDetailSection}>
|
||||
<div className={styles.issueDetailSectionTitle}>File Path</div>
|
||||
<div className={styles.issueDetailFilepath}>{String(snapshot.file_path)}</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{trackRows.length > 0 ? (
|
||||
<div className={styles.issueDetailSection}>
|
||||
<div className={styles.issueDetailSectionTitle}>
|
||||
Track Listing ({trackRows.length} tracks)
|
||||
</div>
|
||||
<div className={styles.issueDetailTracklist}>
|
||||
{trackRows.map((track, index) => {
|
||||
const format = String(track.format || '').toUpperCase();
|
||||
const bitrate = track.bitrate ? `${track.bitrate}k` : '';
|
||||
const duration = formatDuration(track.duration);
|
||||
return (
|
||||
<div
|
||||
className={styles.issueDetailTracklistRow}
|
||||
key={String(track.id || `${track.title}-${index}`)}
|
||||
>
|
||||
<span className={styles.issueDetailTracklistNum}>
|
||||
{String(track.track_number || index + 1)}
|
||||
</span>
|
||||
<span className={styles.issueDetailTracklistTitle}>
|
||||
{String(track.title || 'Unknown')}
|
||||
</span>
|
||||
<span className={styles.issueDetailTracklistDur}>{duration}</span>
|
||||
<span className={styles.issueDetailTracklistMeta}>
|
||||
{format ? <span className={styles.issueTrackBadge}>{format}</span> : null}
|
||||
{bitrate ? (
|
||||
<span className={styles.issueTrackBadge}>{bitrate}</span>
|
||||
) : null}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{issue.entity_type !== 'artist' && isAdmin && (
|
||||
<div className={styles.issueDetailSection}>
|
||||
<div className={styles.issueDetailSectionTitle}>Admin Actions</div>
|
||||
<div className={styles.issueActionButtons}>
|
||||
{downloadAlbum && (
|
||||
<button
|
||||
className={`${styles.issueActionButton} ${styles.issueActionDownload}`}
|
||||
type="button"
|
||||
onClick={() =>
|
||||
downloadAlbum(
|
||||
String(snapshot.spotify_album_id || ''),
|
||||
String(snapshot.artist_name || ''),
|
||||
String(snapshot.album_title || snapshot.title || ''),
|
||||
)
|
||||
}
|
||||
>
|
||||
Download Album
|
||||
</button>
|
||||
)}
|
||||
{addToWishlist && (
|
||||
<button
|
||||
className={`${styles.issueActionButton} ${styles.issueActionWishlist}`}
|
||||
type="button"
|
||||
onClick={() =>
|
||||
addToWishlist(
|
||||
String(snapshot.spotify_album_id || ''),
|
||||
String(snapshot.artist_name || ''),
|
||||
String(snapshot.album_title || snapshot.title || ''),
|
||||
)
|
||||
}
|
||||
>
|
||||
Add to Wishlist
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className={`${styles.issueActionButton} ${styles.issueActionDownload}`}
|
||||
type="button"
|
||||
disabled={downloadWorkflowMutation.isPending}
|
||||
onClick={() => downloadWorkflowMutation.mutate(albumWorkflowInput)}
|
||||
>
|
||||
{downloadWorkflowMutation.isPending ? 'Loading...' : 'Download Album'}
|
||||
</button>
|
||||
<button
|
||||
className={`${styles.issueActionButton} ${styles.issueActionWishlist}`}
|
||||
type="button"
|
||||
disabled={wishlistWorkflowMutation.isPending}
|
||||
onClick={() => wishlistWorkflowMutation.mutate(albumWorkflowInput)}
|
||||
>
|
||||
{wishlistWorkflowMutation.isPending ? 'Loading...' : 'Add to Wishlist'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -365,6 +475,13 @@ export function IssueDetailModal({
|
|||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isAdmin && issue.admin_response ? (
|
||||
<div className={styles.issueDetailSection}>
|
||||
<div className={styles.issueDetailSectionTitle}>Admin Response</div>
|
||||
<div className={styles.issueDetailAdminResponse}>{issue.admin_response}</div>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
|
|
@ -401,3 +518,109 @@ export function IssueDetailModal({
|
|||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getStatusClassName(status: string) {
|
||||
if (status === 'in_progress') return styles.issueStatusProgress;
|
||||
if (status === 'resolved') return styles.issueStatusResolved;
|
||||
if (status === 'dismissed') return styles.issueStatusDismissed;
|
||||
return styles.issueStatusOpen;
|
||||
}
|
||||
|
||||
function notifyWorkflowError(error: unknown) {
|
||||
const message = error instanceof Error ? error.message : 'Workflow failed';
|
||||
window.showToast?.(message, 'error');
|
||||
}
|
||||
|
||||
function formatDuration(value: unknown): string {
|
||||
const duration = typeof value === 'number' ? value : Number(value);
|
||||
if (!Number.isFinite(duration) || duration <= 0) return '';
|
||||
const seconds = duration > 10000 ? Math.floor(duration / 1000) : Math.floor(duration);
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const remaining = seconds % 60;
|
||||
return `${minutes}:${String(remaining).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function getExternalLinks(snapshot: ReturnType<typeof parseSnapshot>) {
|
||||
const links: Array<{ label: string; url: string }> = [];
|
||||
if (snapshot.spotify_artist_id) {
|
||||
links.push({
|
||||
label: 'Spotify Artist',
|
||||
url: `https://open.spotify.com/artist/${snapshot.spotify_artist_id}`,
|
||||
});
|
||||
}
|
||||
if (snapshot.spotify_album_id) {
|
||||
links.push({
|
||||
label: 'Spotify Album',
|
||||
url: `https://open.spotify.com/album/${snapshot.spotify_album_id}`,
|
||||
});
|
||||
}
|
||||
if (snapshot.spotify_track_id) {
|
||||
links.push({
|
||||
label: 'Spotify Track',
|
||||
url: `https://open.spotify.com/track/${snapshot.spotify_track_id}`,
|
||||
});
|
||||
}
|
||||
if (snapshot.artist_musicbrainz_id) {
|
||||
links.push({
|
||||
label: 'MusicBrainz Artist',
|
||||
url: `https://musicbrainz.org/artist/${snapshot.artist_musicbrainz_id}`,
|
||||
});
|
||||
}
|
||||
if (snapshot.musicbrainz_release_id) {
|
||||
links.push({
|
||||
label: 'MusicBrainz Release',
|
||||
url: `https://musicbrainz.org/release/${snapshot.musicbrainz_release_id}`,
|
||||
});
|
||||
}
|
||||
if (snapshot.musicbrainz_recording_id) {
|
||||
links.push({
|
||||
label: 'MusicBrainz Recording',
|
||||
url: `https://musicbrainz.org/recording/${snapshot.musicbrainz_recording_id}`,
|
||||
});
|
||||
}
|
||||
if (snapshot.artist_deezer_id) {
|
||||
links.push({
|
||||
label: 'Deezer Artist',
|
||||
url: `https://www.deezer.com/artist/${snapshot.artist_deezer_id}`,
|
||||
});
|
||||
}
|
||||
if (snapshot.album_deezer_id) {
|
||||
links.push({
|
||||
label: 'Deezer Album',
|
||||
url: `https://www.deezer.com/album/${snapshot.album_deezer_id}`,
|
||||
});
|
||||
}
|
||||
if (snapshot.track_deezer_id) {
|
||||
links.push({
|
||||
label: 'Deezer Track',
|
||||
url: `https://www.deezer.com/track/${snapshot.track_deezer_id}`,
|
||||
});
|
||||
}
|
||||
if (snapshot.artist_tidal_id) {
|
||||
links.push({
|
||||
label: 'Tidal Artist',
|
||||
url: `https://listen.tidal.com/artist/${snapshot.artist_tidal_id}`,
|
||||
});
|
||||
}
|
||||
if (snapshot.album_tidal_id) {
|
||||
links.push({
|
||||
label: 'Tidal Album',
|
||||
url: `https://listen.tidal.com/album/${snapshot.album_tidal_id}`,
|
||||
});
|
||||
}
|
||||
return links;
|
||||
}
|
||||
|
||||
function getTrackMetaItems(snapshot: ReturnType<typeof parseSnapshot>) {
|
||||
const items: Array<{ icon: string; label: string; value: string }> = [];
|
||||
if (snapshot.track_number) {
|
||||
items.push({ icon: '#', label: 'Track', value: String(snapshot.track_number) });
|
||||
}
|
||||
const duration = formatDuration(snapshot.duration);
|
||||
if (duration) items.push({ icon: 'T', label: 'Duration', value: duration });
|
||||
if (snapshot.format) items.push({ icon: 'F', label: 'Format', value: String(snapshot.format) });
|
||||
if (snapshot.bitrate) items.push({ icon: 'B', label: 'Bitrate', value: `${snapshot.bitrate} kbps` });
|
||||
if (snapshot.bpm) items.push({ icon: 'M', label: 'BPM', value: String(snapshot.bpm) });
|
||||
if (snapshot.quality) items.push({ icon: 'Q', label: 'Quality', value: String(snapshot.quality) });
|
||||
return items;
|
||||
}
|
||||
|
|
|
|||
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