soulsync/webui/src/routes/issues/-ui/issues-page.tsx
Antti Kettunen fac4e1ba1a
Sync issues routing and shell URLs
- Move issue detail selection into route search so the modal is deep-linkable and back-button friendly.
- Normalize issue category and detail params before they reach the loader.
- Keep the legacy shell URL in sync for React-owned home pages.
- Preserve the legacy issues-tour hooks on the React issues page.
- Add Escape handling, focus trapping, and focus restore to the issue detail modal.
- Add route and helper coverage for the new search-state behavior.
2026-05-13 22:26:23 +03:00

362 lines
13 KiB
TypeScript

import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useNavigate } from '@tanstack/react-router';
import { useEffect } from 'react';
import { Select } from '@/components/form';
import { getShellProfileContext } from '@/platform/shell/bridge';
import { useReactPageShell } from '@/platform/shell/route-controllers';
import type { IssueCounts, IssueRecord, IssueStatus } from '../-issues.types';
import {
CLOSE_EVENT,
REFRESH_EVENT,
dispatchIssuesRefreshEvent,
getEntityDetails,
getEntityLabel,
getEntityName,
getIssueArtwork,
getPriorityClassName,
issueCountsQueryOptions,
issueDetailQueryOptions,
issueListQueryOptions,
ISSUE_CATEGORY_META,
ISSUE_STATUS_META,
normalizeIssuesSearch,
parseSnapshot,
formatIssueDate,
} from '../-issues.helpers';
import { Route } from '../route';
import { IssueDetailModal } from './issue-detail-modal';
import styles from './issues-page.module.css';
type NavigateFunction = ReturnType<typeof useNavigate>;
function clearIssueSelection(navigate: NavigateFunction) {
void navigate({
search: (prev) => normalizeIssuesSearch({ ...prev, issueId: undefined }),
replace: true,
});
}
export function IssuesPage() {
const bridge = useReactPageShell('issues');
const queryClient = useQueryClient();
const navigate = useNavigate({ from: Route.fullPath });
const search = Route.useSearch();
const normalizedSearch = normalizeIssuesSearch(search);
const selectedIssueId = normalizedSearch.issueId ? Number(normalizedSearch.issueId) : null;
const profile = getShellProfileContext(bridge);
const profileId = profile?.profileId ?? 0;
const openIssue = (issueId: number) => {
void navigate({
search: (prev) => normalizeIssuesSearch({ ...prev, issueId }),
});
};
useEffect(() => {
const handleRefresh = () => {
void queryClient.invalidateQueries({ queryKey: ['issues'] });
};
const handleClose = () => {
clearIssueSelection(navigate);
};
window.addEventListener(REFRESH_EVENT, handleRefresh);
window.addEventListener(CLOSE_EVENT, handleClose);
return () => {
window.removeEventListener(REFRESH_EVENT, handleRefresh);
window.removeEventListener(CLOSE_EVENT, handleClose);
};
}, [navigate, queryClient]);
const countsQuery = useQuery({
...issueCountsQueryOptions(profileId),
enabled: profileId > 0,
});
const issuesQuery = useQuery({
...issueListQueryOptions(profileId, normalizedSearch),
enabled: profileId > 0,
});
const selectedIssueQuery = useQuery({
...issueDetailQueryOptions(profileId, selectedIssueId ?? 0),
enabled: profileId > 0 && selectedIssueId !== null,
});
if (!bridge || !profile || !bridge.isPageAllowed('issues')) {
return null;
}
return (
<>
<IssueBoard
categoryFilter={normalizedSearch.category}
counts={countsQuery.data}
isAdmin={profile.isAdmin}
issues={issuesQuery.data?.issues ?? []}
issuesError={issuesQuery.error}
issuesLoading={issuesQuery.isLoading}
onCategoryChange={(category) =>
void navigate({
search: (prev) =>
normalizeIssuesSearch({
...prev,
category,
}),
replace: true,
})
}
onIssueSelect={openIssue}
onStatusChange={(status) =>
void navigate({
search: (prev) =>
normalizeIssuesSearch({
...prev,
status,
}),
replace: true,
})
}
statusFilter={normalizedSearch.status}
/>
<IssueDetailModal
isAdmin={profile.isAdmin}
issue={selectedIssueQuery.data ?? null}
isLoading={selectedIssueQuery.isLoading}
error={selectedIssueQuery.error}
onClose={() => clearIssueSelection(navigate)}
onMutationSuccess={() => {
clearIssueSelection(navigate);
dispatchIssuesRefreshEvent();
}}
profileId={profile.profileId}
/>
</>
);
}
function IssueBoard({
categoryFilter,
counts,
isAdmin,
issues,
issuesError,
issuesLoading,
onCategoryChange,
onIssueSelect,
onStatusChange,
statusFilter,
}: {
categoryFilter: string;
counts: IssueCounts | undefined;
isAdmin: boolean;
issues: IssueRecord[];
issuesError: unknown;
issuesLoading: boolean;
onCategoryChange: (category: string) => void;
onIssueSelect: (issueId: number) => void;
onStatusChange: (status: IssueStatus | 'all') => void;
statusFilter: IssueStatus | 'all';
}) {
const safeCounts = counts ?? {
open: 0,
in_progress: 0,
resolved: 0,
dismissed: 0,
total: 0,
};
return (
<div className={styles.issuesContainer} data-testid="issues-board">
<div className={styles.issuesHeader} id="issues-header">
<div className={styles.issuesHeaderLeft}>
<h2 className={styles.issuesTitle}>Issues</h2>
<p className={styles.issuesSubtitle} id="issues-subtitle">
{isAdmin
? 'Manage and resolve reported library problems'
: 'Track and resolve library problems'}
</p>
</div>
<div className={styles.issuesHeaderRight}>
<div className={styles.issuesFilters} id="issues-filters">
<Select
id="issues-filter-status"
className={styles.issuesFilterSelect}
aria-label="Status"
value={statusFilter}
onChange={(event) => onStatusChange(event.target.value as IssueStatus | 'all')}
>
<option value="open">Open</option>
<option value="all">All Statuses</option>
<option value="in_progress">In Progress</option>
<option value="resolved">Resolved</option>
<option value="dismissed">Dismissed</option>
</Select>
<Select
id="issues-filter-category"
className={styles.issuesFilterSelect}
aria-label="Category"
value={categoryFilter}
onChange={(event) => onCategoryChange(event.target.value)}
>
<option value="all">All Categories</option>
<optgroup label="Track Issues">
<option value="wrong_track">Wrong Track</option>
<option value="wrong_artist">Wrong Artist</option>
<option value="wrong_album">Wrong Album</option>
<option value="audio_quality">Audio Quality</option>
</optgroup>
<optgroup label="Album Issues">
<option value="wrong_cover">Wrong Cover Art</option>
<option value="duplicate_tracks">Duplicate Tracks</option>
<option value="missing_tracks">Missing Tracks</option>
<option value="incomplete_album">Incomplete Album</option>
</optgroup>
<optgroup label="Both">
<option value="wrong_metadata">Wrong Metadata</option>
<option value="other">Other</option>
</optgroup>
</Select>
</div>
</div>
</div>
<div className={styles.issuesStats} id="issues-stats" data-testid="issue-counts">
<div className={`${styles.issuesStatCard} ${styles.issuesStatOpen}`}>
<div className={styles.issuesStatNumber}>{safeCounts.open}</div>
<div className={styles.issuesStatLabel}>Open</div>
</div>
<div className={`${styles.issuesStatCard} ${styles.issuesStatProgress}`}>
<div className={styles.issuesStatNumber}>{safeCounts.in_progress}</div>
<div className={styles.issuesStatLabel}>In Progress</div>
</div>
<div className={`${styles.issuesStatCard} ${styles.issuesStatResolved}`}>
<div className={styles.issuesStatNumber}>{safeCounts.resolved}</div>
<div className={styles.issuesStatLabel}>Resolved</div>
</div>
<div className={`${styles.issuesStatCard} ${styles.issuesStatDismissed}`}>
<div className={styles.issuesStatNumber}>{safeCounts.dismissed}</div>
<div className={styles.issuesStatLabel}>Dismissed</div>
</div>
<div className={`${styles.issuesStatCard} ${styles.issuesStatTotal}`}>
<div className={styles.issuesStatNumber}>{safeCounts.total}</div>
<div className={styles.issuesStatLabel}>Total</div>
</div>
</div>
<div className={styles.issuesList} id="issues-list" data-testid="issue-list">
{issuesLoading ? (
<div className={styles.issuesLoading}>
<div className={styles.issuesSpinner} />
Loading issues...
</div>
) : issuesError ? (
<div className={styles.issuesEmpty}>
<div className={styles.issuesEmptyTitle}>Failed to load issues</div>
<div className={styles.issuesEmptyText}>
{issuesError instanceof Error ? issuesError.message : 'Unknown error'}
</div>
</div>
) : issues.length === 0 ? (
<div className={styles.issuesEmpty}>
<div className={styles.issuesEmptyIcon} aria-hidden="true">
🔍
</div>
<div className={styles.issuesEmptyTitle}>No issues found</div>
<div className={styles.issuesEmptyText}>
{statusFilter !== 'open' || categoryFilter !== 'all'
? 'Try adjusting your filters'
: 'No issues have been reported yet'}
</div>
</div>
) : (
issues.map((issue) => {
const snapshot = parseSnapshot(issue.snapshot_data);
const artwork = getIssueArtwork(snapshot);
const entityName = getEntityName(issue, snapshot);
const details = getEntityDetails(issue, snapshot);
const statusMeta = ISSUE_STATUS_META[issue.status] || ISSUE_STATUS_META.open;
const catMeta = ISSUE_CATEGORY_META[issue.category] || ISSUE_CATEGORY_META.other;
const priorityVariant = getPriorityClassName(issue.priority);
const statusClassName =
issue.status === 'in_progress'
? styles.issueStatusProgress
: issue.status === 'resolved'
? styles.issueStatusResolved
: issue.status === 'dismissed'
? styles.issueStatusDismissed
: styles.issueStatusOpen;
const priorityClass =
priorityVariant === 'high'
? styles.issuePriorityHigh
: priorityVariant === 'low'
? styles.issuePriorityLow
: styles.issuePriorityNormal;
const createdDate = formatIssueDate(issue.created_at);
return (
<button
key={issue.id}
className={styles.issueCard}
type="button"
data-testid={`issue-card-${issue.id}`}
onClick={() => onIssueSelect(issue.id)}
>
<div className={styles.issueCardLeft}>
{artwork ? (
<img className={styles.issueCardThumb} src={artwork} alt="" />
) : (
<div className={styles.issueCardThumbPlaceholder}>{catMeta.icon}</div>
)}
</div>
<div className={styles.issueCardCenter}>
<div className={styles.issueCardTitleRow}>
<span className={styles.issueCardCategoryIcon} title={catMeta.label}>
{catMeta.icon}
</span>
<span className={styles.issueCardTitle}>{issue.title}</span>
{issue.admin_response ? (
<span className={styles.issueCardResponded} title="Admin has responded">
💬
</span>
) : null}
</div>
<div className={styles.issueCardEntity}>
<span className={styles.issueCardEntityType}>
{getEntityLabel(issue.entity_type)}
</span>
<span className={styles.issueCardEntityName}>{entityName}</span>
{details.length > 0 ? (
<span className={styles.issueCardMetaLine}>{details.join(' - ')}</span>
) : null}
</div>
{issue.description ? (
<div className={styles.issueCardDescription}>{issue.description}</div>
) : null}
<div className={styles.issueCardFooter}>
<span className={styles.issueCardDate}>{createdDate}</span>
{isAdmin && issue.reporter_name ? (
<span className={styles.issueCardProfile}>by {issue.reporter_name}</span>
) : null}
</div>
</div>
<div className={styles.issueCardRight}>
<span className={`${styles.issueStatusBadge} ${statusClassName}`}>
{statusMeta.label}
</span>
<span
className={`${styles.issuePriorityDot} ${priorityClass}`}
title={`${issue.priority} priority`}
/>
</div>
</button>
);
})
)}
</div>
</div>
);
}