Use Base UI and clsx in form primitives

- Adopt Base UI for the shared form field, input, button, and toggle wrappers
- Replace the local class-name helper with clsx to keep the primitives simpler
- Keep native textarea and select controls where they still fit the existing styling pattern
pull/388/head
Antti Kettunen 2 weeks ago
parent f06ccd643e
commit bd6be61b77
No known key found for this signature in database
GPG Key ID: C6B2A3D250359BD7

@ -318,7 +318,7 @@ def watch_and_run_backend() -> None:
def main() -> int:
if not (ROOT_DIR / 'webui' / 'node_modules').is_dir():
print('webui/node_modules is missing.')
print('Run: cd webui && npm install')
print('Run: cd webui && npm ci')
return 1
vite_proc = start_vite()

@ -6,9 +6,11 @@
"": {
"name": "soulsync-webui",
"dependencies": {
"@base-ui/react": "^1.4.1",
"@tanstack/react-form": "^1.29.1",
"@tanstack/react-query": "^5.100.5",
"@tanstack/react-router": "^1.168.24",
"clsx": "^2.1.1",
"ky": "^2.0.2",
"react": "^19.2.5",
"react-dom": "^19.2.5"
@ -338,7 +340,6 @@
"version": "7.29.2",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz",
"integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.9.0"
@ -392,6 +393,66 @@
"node": ">=6.9.0"
}
},
"node_modules/@base-ui/react": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/@base-ui/react/-/react-1.4.1.tgz",
"integrity": "sha512-Ab5/LIhcmL8BQcsBUYiOfkSDRdLpvgUBzMK30cu684JPcLclYlztharvCZyNNgzJtbAiREzI9q0pI5erHCMgCw==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.29.2",
"@base-ui/utils": "0.2.8",
"@floating-ui/react-dom": "^2.1.8",
"@floating-ui/utils": "^0.2.11",
"use-sync-external-store": "^1.6.0"
},
"engines": {
"node": ">=14.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/mui-org"
},
"peerDependencies": {
"@date-fns/tz": "^1.2.0",
"@types/react": "^17 || ^18 || ^19",
"date-fns": "^4.0.0",
"react": "^17 || ^18 || ^19",
"react-dom": "^17 || ^18 || ^19"
},
"peerDependenciesMeta": {
"@date-fns/tz": {
"optional": true
},
"@types/react": {
"optional": true
},
"date-fns": {
"optional": true
}
}
},
"node_modules/@base-ui/utils": {
"version": "0.2.8",
"resolved": "https://registry.npmjs.org/@base-ui/utils/-/utils-0.2.8.tgz",
"integrity": "sha512-jvOi+c+ftGlGotNcKnzPVg2IhCaDTB6/6R3JeqdjdXktuAJi3wKH9T7+svuaKh1mmfVU11UWzUZVH74JDfi/wQ==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.29.2",
"@floating-ui/utils": "^0.2.11",
"reselect": "^5.1.1",
"use-sync-external-store": "^1.6.0"
},
"peerDependencies": {
"@types/react": "^17 || ^18 || ^19",
"react": "^17 || ^18 || ^19",
"react-dom": "^17 || ^18 || ^19"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@bramus/specificity": {
"version": "2.4.2",
"resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz",
@ -563,6 +624,44 @@
}
}
},
"node_modules/@floating-ui/core": {
"version": "1.7.5",
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz",
"integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==",
"license": "MIT",
"dependencies": {
"@floating-ui/utils": "^0.2.11"
}
},
"node_modules/@floating-ui/dom": {
"version": "1.7.6",
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz",
"integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==",
"license": "MIT",
"dependencies": {
"@floating-ui/core": "^1.7.5",
"@floating-ui/utils": "^0.2.11"
}
},
"node_modules/@floating-ui/react-dom": {
"version": "2.1.8",
"resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.8.tgz",
"integrity": "sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A==",
"license": "MIT",
"dependencies": {
"@floating-ui/dom": "^1.7.6"
},
"peerDependencies": {
"react": ">=16.8.0",
"react-dom": ">=16.8.0"
}
},
"node_modules/@floating-ui/utils": {
"version": "0.2.11",
"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz",
"integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==",
"license": "MIT"
},
"node_modules/@inquirer/ansi": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-2.0.5.tgz",
@ -2001,7 +2100,7 @@
"version": "19.2.14",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
"dev": true,
"devOptional": true,
"license": "MIT",
"dependencies": {
"csstype": "^3.2.2"
@ -2631,6 +2730,15 @@
"node": ">=12"
}
},
"node_modules/clsx": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@ -2703,7 +2811,7 @@
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"dev": true,
"devOptional": true,
"license": "MIT"
},
"node_modules/data-urls": {
@ -3952,6 +4060,12 @@
"node": ">=0.10.0"
}
},
"node_modules/reselect": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz",
"integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==",
"license": "MIT"
},
"node_modules/rettime": {
"version": "0.11.8",
"resolved": "https://registry.npmjs.org/rettime/-/rettime-0.11.8.tgz",

@ -12,9 +12,11 @@
"test:e2e": "playwright test"
},
"dependencies": {
"@base-ui/react": "^1.4.1",
"@tanstack/react-form": "^1.29.1",
"@tanstack/react-query": "^5.100.5",
"@tanstack/react-router": "^1.168.24",
"clsx": "^2.1.1",
"ky": "^2.0.2",
"react": "^19.2.5",
"react-dom": "^19.2.5"

@ -1,8 +1,7 @@
import { describe, expect, it, vi } from 'vite-plus/test';
import type { ResponsePromise } from 'ky';
import { HTTPError } from 'ky';
import type { ResponsePromise } from 'ky';
import { describe, expect, it, vi } from 'vite-plus/test';
import { readJson } from './api-client';
@ -46,9 +45,6 @@ describe('readJson', () => {
const result = readJson(promise);
await expect(result).rejects.toBe(error);
await expect(result).rejects.toHaveProperty(
'message',
error.message,
);
await expect(result).rejects.toHaveProperty('message', error.message);
});
});

@ -1,7 +1,12 @@
import { Button as BaseButton } from '@base-ui/react/button';
import { Field } from '@base-ui/react/field';
import { Input as BaseInput } from '@base-ui/react/input';
import { Toggle as BaseToggle } from '@base-ui/react/toggle';
import clsx from 'clsx';
import {
forwardRef,
type ComponentPropsWithoutRef,
type ButtonHTMLAttributes,
type InputHTMLAttributes,
type SelectHTMLAttributes,
type ReactNode,
type TextareaHTMLAttributes,
@ -9,10 +14,6 @@ import {
import styles from './form.module.css';
function mergeClassNames(...classNames: Array<string | false | null | undefined>) {
return classNames.filter(Boolean).join(' ');
}
export interface FormFieldProps {
children: ReactNode;
className?: string;
@ -31,30 +32,36 @@ export function FormField({
label,
}: FormFieldProps) {
return (
<div className={mergeClassNames(styles.field, className)}>
<Field.Root className={clsx(styles.field, className)}>
<div className={styles.fieldHeader}>
{htmlFor ? (
<label className={styles.fieldLabel} htmlFor={htmlFor}>
{label}
</label>
) : (
<div className={styles.fieldLabel}>{label}</div>
<Field.Label className={styles.fieldLabel}>{label}</Field.Label>
)}
{helperText ? <div className={styles.fieldHelper}>{helperText}</div> : null}
{helperText ? (
<Field.Description className={styles.fieldHelper}>{helperText}</Field.Description>
) : null}
</div>
<div className={styles.fieldControl}>{children}</div>
{error ? <FormError message={error} /> : null}
</div>
</Field.Root>
);
}
export type TextInputProps = InputHTMLAttributes<HTMLInputElement>;
type BaseInputProps = ComponentPropsWithoutRef<typeof BaseInput>;
export type TextInputProps = Omit<BaseInputProps, 'className'> & {
className?: string;
};
export const TextInput = forwardRef<HTMLInputElement, TextInputProps>(function TextInput(
{ className, ...props },
ref,
) {
return <input ref={ref} className={mergeClassNames(styles.textInput, className)} {...props} />;
return <BaseInput ref={ref} className={clsx(styles.textInput, className)} {...props} />;
});
export type TextAreaProps = TextareaHTMLAttributes<HTMLTextAreaElement>;
@ -63,7 +70,7 @@ export const TextArea = forwardRef<HTMLTextAreaElement, TextAreaProps>(function
{ className, ...props },
ref,
) {
return <textarea ref={ref} className={mergeClassNames(styles.textArea, className)} {...props} />;
return <textarea ref={ref} className={clsx(styles.textArea, className)} {...props} />;
});
export type SelectProps = SelectHTMLAttributes<HTMLSelectElement>;
@ -72,7 +79,7 @@ export const Select = forwardRef<HTMLSelectElement, SelectProps>(function Select
{ className, ...props },
ref,
) {
return <select ref={ref} className={mergeClassNames(styles.select, className)} {...props} />;
return <select ref={ref} className={clsx(styles.select, className)} {...props} />;
});
export function OptionCardGroup({
@ -82,14 +89,19 @@ export function OptionCardGroup({
children: ReactNode;
className?: string;
}) {
return <div className={mergeClassNames(styles.optionCardGroup, className)}>{children}</div>;
return <div className={clsx(styles.optionCardGroup, className)}>{children}</div>;
}
export interface OptionCardProps extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'title'> {
export interface OptionCardProps extends Omit<
ButtonHTMLAttributes<HTMLButtonElement>,
'title' | 'value'
> {
className?: string;
description?: ReactNode;
icon?: ReactNode;
selected?: boolean;
title?: ReactNode;
value?: string;
}
export const OptionCard = forwardRef<HTMLButtonElement, OptionCardProps>(function OptionCard(
@ -97,14 +109,10 @@ export const OptionCard = forwardRef<HTMLButtonElement, OptionCardProps>(functio
ref,
) {
return (
<button
<BaseToggle
ref={ref}
aria-pressed={selected}
className={mergeClassNames(
styles.optionCard,
selected && styles.optionCardSelected,
className,
)}
pressed={selected}
className={clsx(styles.optionCard, selected && styles.optionCardSelected, className)}
type={type}
{...props}
>
@ -117,7 +125,7 @@ export const OptionCard = forwardRef<HTMLButtonElement, OptionCardProps>(functio
</div>
</>
)}
</button>
</BaseToggle>
);
});
@ -128,11 +136,13 @@ export function OptionButtonGroup({
children: ReactNode;
className?: string;
}) {
return <div className={mergeClassNames(styles.optionButtonGroup, className)}>{children}</div>;
return <div className={clsx(styles.optionButtonGroup, className)}>{children}</div>;
}
export interface OptionButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
export interface OptionButtonProps extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'value'> {
className?: string;
selected?: boolean;
value?: string;
}
export const OptionButton = forwardRef<HTMLButtonElement, OptionButtonProps>(function OptionButton(
@ -140,48 +150,41 @@ export const OptionButton = forwardRef<HTMLButtonElement, OptionButtonProps>(fun
ref,
) {
return (
<button
<BaseToggle
ref={ref}
aria-pressed={selected}
className={mergeClassNames(
styles.optionButton,
selected && styles.optionButtonSelected,
className,
)}
pressed={selected}
className={clsx(styles.optionButton, selected && styles.optionButtonSelected, className)}
type={type}
{...props}
>
{children}
</button>
</BaseToggle>
);
});
export type ButtonProps = ButtonHTMLAttributes<HTMLButtonElement>;
type BaseButtonProps = ComponentPropsWithoutRef<typeof BaseButton>;
export type ButtonProps = Omit<BaseButtonProps, 'className'> & {
className?: string;
};
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button(
{ className, type = 'button', ...props },
ref,
) {
return (
<button
ref={ref}
className={mergeClassNames(styles.button, className)}
type={type}
{...props}
/>
);
return <BaseButton ref={ref} className={clsx(styles.button, className)} type={type} {...props} />;
});
export function FormError({ className, message }: { className?: string; message?: ReactNode }) {
if (!message) return null;
return (
<div className={mergeClassNames(styles.formError, className)} role="alert">
<div className={clsx(styles.formError, className)} role="alert">
{message}
</div>
);
}
export function FormActions({ className, children }: { children: ReactNode; className?: string }) {
return <div className={mergeClassNames(styles.formActions, className)}>{children}</div>;
return <div className={clsx(styles.formActions, className)}>{children}</div>;
}

@ -2,6 +2,7 @@ import { queryOptions } from '@tanstack/react-query';
import { apiClient, readJson } from '@/app/api-client';
import type { NormalizedIssuesSearch } from './-issues.helpers';
import type {
CreateIssuePayload,
IssueCounts,
@ -11,8 +12,6 @@ import type {
IssueRecord,
} from './-issues.types';
import type { NormalizedIssuesSearch } from './-issues.helpers';
const DEFAULT_LIMIT = 100;
function createIssueHeaders(profileId: number, extra?: HeadersInit): Headers {

@ -1,8 +1,4 @@
import type {
IssueRecord,
IssuesSearch,
IssueSnapshot,
} from './-issues.types';
import type { IssueRecord, IssuesSearch, IssueSnapshot } from './-issues.types';
export const REFRESH_EVENT = 'ss:issues-refresh';
export const CLOSE_EVENT = 'ss:issues-close-detail';
@ -116,15 +112,13 @@ export function normalizeIssuesSearch(search: IssuesSearch | undefined): Normali
status === 'dismissed'
? status
: DEFAULT_ISSUES_SEARCH.status,
category: typeof category === 'string' && ISSUE_CATEGORY_KEYS.has(category)
? category
: 'all',
category: typeof category === 'string' && ISSUE_CATEGORY_KEYS.has(category) ? category : 'all',
issueId:
(typeof issueId === 'number' && Number.isInteger(issueId) && issueId > 0)
typeof issueId === 'number' && Number.isInteger(issueId) && issueId > 0
? issueId
: (typeof issueId === 'string' && /^[1-9]\d*$/.test(issueId)
? Number(issueId)
: undefined),
: typeof issueId === 'string' && /^[1-9]\d*$/.test(issueId)
? Number(issueId)
: undefined,
};
}

@ -32,6 +32,8 @@ export interface IssueSnapshot {
track_deezer_id?: string;
artist_tidal_id?: string;
album_tidal_id?: string;
artist_qobuz_id?: string | number;
album_qobuz_id?: string | number;
}
export interface IssueRecord {

@ -57,7 +57,13 @@ describe('issues route', () => {
if (url.includes('/api/issues/counts')) {
return createResponse({
success: true,
counts: { open: 2, in_progress: 1, resolved: 0, dismissed: 0, total: 3 },
counts: {
open: 2,
in_progress: 1,
resolved: 0,
dismissed: 0,
total: 3,
},
});
}
if (url.includes('/api/issues?')) {
@ -105,18 +111,18 @@ describe('issues route', () => {
status: 'open',
priority: 'normal',
snapshot_data: {
title: 'Album Name',
artist_name: 'Artist',
thumb_url: 'https://example.com/thumb.jpg',
spotify_album_id: 'abc123',
track_number: 1,
duration: 245,
format: 'FLAC',
bitrate: 1411,
},
created_at: '2026-04-03 10:30:00',
reporter_name: 'Ada',
title: 'Album Name',
artist_name: 'Artist',
thumb_url: 'https://example.com/thumb.jpg',
spotify_album_id: 'abc123',
track_number: 1,
duration: 245,
format: 'FLAC',
bitrate: 1411,
},
created_at: '2026-04-03 10:30:00',
reporter_name: 'Ada',
},
});
}
if (url.includes('/api/spotify/album/abc123')) {
@ -151,6 +157,10 @@ describe('issues route', () => {
it('loads the detail modal from the route search state', async () => {
renderIssuesRoute(['/issues?issueId=7']);
await waitFor(() => expect(screen.getByRole('dialog')).toHaveTextContent('Issue #7'));
expect(await screen.findByTitle('Spotify Album')).toHaveAttribute(
'href',
'https://open.spotify.com/album/abc123',
);
});
it('stores filters in route search state', async () => {
@ -186,7 +196,9 @@ describe('issues route', () => {
renderIssuesRoute();
fireEvent.click(await screen.findByTestId('issue-card-7'));
const closeButton = await screen.findByRole('button', { name: /close issue detail/i });
const closeButton = await screen.findByRole('button', {
name: /close issue detail/i,
});
const deleteButton = await screen.findByRole('button', { name: /delete/i });
deleteButton.focus();
@ -240,7 +252,9 @@ describe('issues route', () => {
fireEvent.change(titleInput, { target: { value: 'Custom report title' } });
fireEvent.blur(titleInput);
fireEvent.change(descriptionInput, { target: { value: 'Detailed reproduction notes' } });
fireEvent.change(descriptionInput, {
target: { value: 'Detailed reproduction notes' },
});
fireEvent.click(screen.getByRole('button', { name: /high/i }));
fireEvent.click(screen.getByRole('button', { name: /wrong metadata/i }));
expect(titleInput).toHaveValue('Custom report title');

@ -9,6 +9,7 @@ import {
import type { IssueRecord } from '../-issues.types';
import { deleteIssue, updateIssue } from '../-issues.api';
import {
formatIssueDate,
formatStatusLabel,
@ -17,7 +18,6 @@ import {
ISSUE_CATEGORY_META,
parseSnapshot,
} from '../-issues.helpers';
import { deleteIssue, updateIssue } from '../-issues.api';
import styles from './issue-detail-modal.module.css';
export function IssueDetailModal({
@ -51,9 +51,8 @@ export function IssueDetailModal({
return;
}
previouslyFocusedElementRef.current = document.activeElement instanceof HTMLElement
? document.activeElement
: null;
previouslyFocusedElementRef.current =
document.activeElement instanceof HTMLElement ? document.activeElement : null;
const focusModal = () => {
const modal = modalRef.current;
@ -213,7 +212,11 @@ export function IssueDetailModal({
className={styles.modalButtonReopen}
type="button"
onClick={() =>
updateMutation.mutate({ issueId: issue.id, status: 'open', adminResponse })
updateMutation.mutate({
issueId: issue.id,
status: 'open',
adminResponse,
})
}
disabled={updateMutation.isPending}
>
@ -381,7 +384,9 @@ export function IssueDetailModal({
<div className={styles.issueDetailInfoBar}>
<div className={styles.issueDetailInfoLeft}>
<span className={`${styles.issuePriorityDot} ${getPriorityDotClassName(priorityClassName)}`} />
<span
className={`${styles.issuePriorityDot} ${getPriorityDotClassName(priorityClassName)}`}
/>
<span
className={`${styles.issueStatusBadge} ${getStatusClassName(issue.status)}`}
>
@ -465,11 +470,12 @@ export function IssueDetailModal({
{trackRows.length > 0 ? (
<div className={styles.issueDetailSection}>
<div className={styles.issueDetailSectionTitle}>
Track Listing <span className={styles.issueDetailSectionCount}>{trackRows.length} tracks</span>
</div>
<div className={styles.issueDetailTracklist}>
{renderTrackListing(trackRows)}
Track Listing{' '}
<span className={styles.issueDetailSectionCount}>
{trackRows.length} tracks
</span>
</div>
<div className={styles.issueDetailTracklist}>{renderTrackListing(trackRows)}</div>
</div>
) : null}
@ -564,13 +570,12 @@ function renderTrackListing(trackRows: Array<Record<string, unknown>>) {
const formatClassName = getTrackFormatClassName(format);
const bitrateClassName = getTrackBitrateClassName(bitrateValue, format);
nodes.push(
<div className={styles.issueDetailTracklistRow} key={String(track.id || `${track.title}-${index}`)}>
<span className={styles.issueDetailTracklistNum}>
{String(track.track_number || '-')}
</span>
<span className={styles.issueDetailTracklistTitle}>
{String(track.title || 'Unknown')}
</span>
<div
className={styles.issueDetailTracklistRow}
key={String(track.id || `${track.title}-${index}`)}
>
<span className={styles.issueDetailTracklistNum}>{String(track.track_number || '-')}</span>
<span className={styles.issueDetailTracklistTitle}>{String(track.title || 'Unknown')}</span>
<span className={styles.issueDetailTracklistDur}>{duration}</span>
<span className={styles.issueDetailTracklistMeta}>
{format ? (
@ -630,21 +635,19 @@ function formatDuration(value: unknown): string {
}
function getExternalLinks(snapshot: ReturnType<typeof parseSnapshot>) {
const links: Array<
| {
className:
| 'issueExternalLinkSpotify'
| 'issueExternalLinkMusicBrainz'
| 'issueExternalLinkDeezer'
| 'issueExternalLinkTidal'
| 'issueExternalLinkQobuz';
id?: string | number;
label: string;
service: string;
type: string;
url?: string;
}
> = [];
const links: Array<{
className:
| 'issueExternalLinkSpotify'
| 'issueExternalLinkMusicBrainz'
| 'issueExternalLinkDeezer'
| 'issueExternalLinkTidal'
| 'issueExternalLinkQobuz';
id?: string | number;
label: string;
service: string;
type: string;
url?: string;
}> = [];
if (snapshot.spotify_artist_id) {
links.push({
className: 'issueExternalLinkSpotify',
@ -789,15 +792,27 @@ function getAlbumMetaParts(
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) });
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` });
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) });
items.push({
icon: 'Q',
label: 'Quality',
value: String(snapshot.quality),
});
return items;
}

@ -20,15 +20,12 @@ import { useShellBridge } from '@/platform/shell/route-controllers';
import type { IssuePriority, IssueReportPayload } from '../-issues.types';
import { createIssue, issueCountsQueryOptions } from '../-issues.api';
import {
REFRESH_EVENT,
createDefaultIssueTitle,
getIssueCategoriesForEntity,
} from '../-issues.helpers';
import {
createIssue,
issueCountsQueryOptions,
} from '../-issues.api';
import styles from './issue-detail-modal.module.css';
const ISSUE_DOMAIN_QUERY_KEY = ['issues'] as const;

@ -8,6 +8,11 @@ import { useReactPageShell } from '@/platform/shell/route-controllers';
import type { IssueCounts, IssueRecord, IssueStatus } from '../-issues.types';
import {
issueCountsQueryOptions,
issueDetailQueryOptions,
issueListQueryOptions,
} from '../-issues.api';
import {
CLOSE_EVENT,
REFRESH_EVENT,
@ -23,11 +28,6 @@ import {
normalizeIssuesSearch,
parseSnapshot,
} from '../-issues.helpers';
import {
issueCountsQueryOptions,
issueDetailQueryOptions,
issueListQueryOptions,
} from '../-issues.api';
import { Route } from '../route';
import { IssueDetailModal } from './issue-detail-modal';
import styles from './issues-page.module.css';

@ -31,7 +31,9 @@ export const Route = createFileRoute('/issues')({
context.queryClient.ensureQueryData(issueCountsQueryOptions(profile.profileId)),
context.queryClient.ensureQueryData(issueListQueryOptions(profile.profileId, deps)),
deps.issueId
? context.queryClient.ensureQueryData(issueDetailQueryOptions(profile.profileId, deps.issueId))
? context.queryClient.ensureQueryData(
issueDetailQueryOptions(profile.profileId, deps.issueId),
)
: Promise.resolve(),
]);
},

Loading…
Cancel
Save