mirror of https://github.com/Nezreka/SoulSync.git
- 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 bridgepull/388/head
parent
43db30608d
commit
577e4bdace
@ -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');
|
||||
}
|
||||
@ -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 new issue