refactor(webui): share import form primitives
- add shared Base UI-backed checkbox and slider primitives under the form component layer - move the singles import checkbox and auto-import confidence slider to the shared controls - keep the import route tests aligned with the new accessible component roles
This commit is contained in:
parent
aa86bedc6e
commit
0d7fb91d98
9 changed files with 298 additions and 131 deletions
|
|
@ -129,6 +129,120 @@
|
||||||
color: #fff;
|
color: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.checkbox {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: 0;
|
||||||
|
border: 2px solid rgba(255, 255, 255, 0.2);
|
||||||
|
border-radius: 4px;
|
||||||
|
background: transparent;
|
||||||
|
color: #000;
|
||||||
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
|
border-color 0.18s ease,
|
||||||
|
background 0.18s ease,
|
||||||
|
box-shadow 0.18s ease,
|
||||||
|
transform 0.18s ease;
|
||||||
|
color-scheme: dark;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox[data-checked] {
|
||||||
|
border-color: rgb(var(--accent-light-rgb));
|
||||||
|
background: rgb(var(--accent-light-rgb));
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox[data-focused] {
|
||||||
|
outline: none;
|
||||||
|
box-shadow: 0 0 0 3px rgba(var(--accent-light-rgb), 0.14);
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkboxIndicator {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.18s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox[data-checked] .checkboxIndicator {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkboxIcon {
|
||||||
|
color: #000;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1;
|
||||||
|
transform: translateY(-0.5px) scaleX(1.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rangeRoot {
|
||||||
|
display: inline-flex;
|
||||||
|
flex: 0 0 160px;
|
||||||
|
width: 160px;
|
||||||
|
min-width: 160px;
|
||||||
|
color-scheme: dark;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rangeControl {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 28px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rangeTrack {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
height: 8px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
box-shadow: inset 0 1px 1px rgba(255, 255, 255, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rangeIndicator {
|
||||||
|
height: 100%;
|
||||||
|
border-radius: inherit;
|
||||||
|
background: linear-gradient(
|
||||||
|
90deg,
|
||||||
|
rgba(var(--accent-rgb), 0.95),
|
||||||
|
rgba(var(--accent-light-rgb), 0.95)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rangeThumb {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.24);
|
||||||
|
border-radius: 50%;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 30% 28%, rgba(255, 255, 255, 0.3), transparent 46%),
|
||||||
|
rgb(var(--accent-light-rgb));
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.28);
|
||||||
|
transition:
|
||||||
|
transform 0.18s ease,
|
||||||
|
box-shadow 0.18s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rangeThumb:hover {
|
||||||
|
transform: scale(1.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rangeThumb:focus-visible {
|
||||||
|
box-shadow:
|
||||||
|
0 0 0 4px rgba(var(--accent-light-rgb), 0.14),
|
||||||
|
0 2px 8px rgba(0, 0, 0, 0.28);
|
||||||
|
}
|
||||||
|
|
||||||
.optionCardGroup {
|
.optionCardGroup {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fit, minmax(170px, 1fr));
|
grid-template-columns: repeat(auto-fit, minmax(170px, 1fr));
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
|
Checkbox,
|
||||||
FormActions,
|
FormActions,
|
||||||
FormError,
|
FormError,
|
||||||
FormField,
|
FormField,
|
||||||
|
|
@ -11,6 +12,7 @@ import {
|
||||||
OptionButtonGroup,
|
OptionButtonGroup,
|
||||||
OptionCard,
|
OptionCard,
|
||||||
OptionCardGroup,
|
OptionCardGroup,
|
||||||
|
RangeInput,
|
||||||
Select,
|
Select,
|
||||||
TextArea,
|
TextArea,
|
||||||
TextInput,
|
TextInput,
|
||||||
|
|
@ -22,6 +24,8 @@ function FormDemo() {
|
||||||
const [category, setCategory] = useState<'wrong_cover' | 'wrong_metadata'>('wrong_cover');
|
const [category, setCategory] = useState<'wrong_cover' | 'wrong_metadata'>('wrong_cover');
|
||||||
const [priority, setPriority] = useState<'low' | 'normal' | 'high'>('normal');
|
const [priority, setPriority] = useState<'low' | 'normal' | 'high'>('normal');
|
||||||
const [status, setStatus] = useState('open');
|
const [status, setStatus] = useState('open');
|
||||||
|
const [archive, setArchive] = useState(false);
|
||||||
|
const [confidence, setConfidence] = useState(90);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form>
|
<form>
|
||||||
|
|
@ -90,6 +94,20 @@ function FormDemo() {
|
||||||
</Select>
|
</Select>
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
|
<FormField label="Archive" helperText="Shared checkbox primitive">
|
||||||
|
<Checkbox checked={archive} onCheckedChange={setArchive} />
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<FormField label="Confidence" helperText="Shared range primitive">
|
||||||
|
<RangeInput
|
||||||
|
label="Confidence"
|
||||||
|
min={50}
|
||||||
|
max={100}
|
||||||
|
value={confidence}
|
||||||
|
onValueChange={setConfidence}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
<FormError message="Validation failed" />
|
<FormError message="Validation failed" />
|
||||||
|
|
||||||
<FormActions>
|
<FormActions>
|
||||||
|
|
@ -112,6 +130,16 @@ describe('form primitives', () => {
|
||||||
fireEvent.change(screen.getByLabelText('Status'), { target: { value: 'resolved' } });
|
fireEvent.change(screen.getByLabelText('Status'), { target: { value: 'resolved' } });
|
||||||
expect(screen.getByLabelText('Status')).toHaveValue('resolved');
|
expect(screen.getByLabelText('Status')).toHaveValue('resolved');
|
||||||
|
|
||||||
|
const archiveCheckbox = screen.getByRole('checkbox', { name: 'Archive' });
|
||||||
|
expect(archiveCheckbox).not.toBeChecked();
|
||||||
|
fireEvent.click(archiveCheckbox);
|
||||||
|
expect(archiveCheckbox).toBeChecked();
|
||||||
|
|
||||||
|
const confidenceSlider = screen.getByLabelText('Confidence', { selector: 'input' });
|
||||||
|
expect(confidenceSlider).toHaveValue('90');
|
||||||
|
fireEvent.change(confidenceSlider, { target: { value: '75' } });
|
||||||
|
expect(confidenceSlider).toHaveValue('75');
|
||||||
|
|
||||||
const wrongCover = screen.getByRole('button', { name: /wrong cover/i });
|
const wrongCover = screen.getByRole('button', { name: /wrong cover/i });
|
||||||
const wrongMetadata = screen.getByRole('button', { name: /wrong metadata/i });
|
const wrongMetadata = screen.getByRole('button', { name: /wrong metadata/i });
|
||||||
expect(wrongCover).toHaveAttribute('aria-pressed', 'true');
|
expect(wrongCover).toHaveAttribute('aria-pressed', 'true');
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,13 @@
|
||||||
import { Button as BaseButton } from '@base-ui/react/button';
|
import { Button as BaseButton } from '@base-ui/react/button';
|
||||||
|
import { Checkbox as BaseCheckbox } from '@base-ui/react/checkbox';
|
||||||
import { Field } from '@base-ui/react/field';
|
import { Field } from '@base-ui/react/field';
|
||||||
import { Input as BaseInput } from '@base-ui/react/input';
|
import { Input as BaseInput } from '@base-ui/react/input';
|
||||||
|
import { Slider } from '@base-ui/react/slider';
|
||||||
import { Toggle as BaseToggle } from '@base-ui/react/toggle';
|
import { Toggle as BaseToggle } from '@base-ui/react/toggle';
|
||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
import {
|
import {
|
||||||
forwardRef,
|
forwardRef,
|
||||||
|
type CSSProperties,
|
||||||
type ComponentPropsWithoutRef,
|
type ComponentPropsWithoutRef,
|
||||||
type ButtonHTMLAttributes,
|
type ButtonHTMLAttributes,
|
||||||
type SelectHTMLAttributes,
|
type SelectHTMLAttributes,
|
||||||
|
|
@ -82,6 +85,84 @@ export const Select = forwardRef<HTMLSelectElement, SelectProps>(function Select
|
||||||
return <select ref={ref} className={clsx(styles.select, className)} {...props} />;
|
return <select ref={ref} className={clsx(styles.select, className)} {...props} />;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
type BaseCheckboxProps = ComponentPropsWithoutRef<typeof BaseCheckbox.Root>;
|
||||||
|
|
||||||
|
export type CheckboxProps = Omit<BaseCheckboxProps, 'className' | 'children'> & {
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Checkbox = forwardRef<HTMLElement, CheckboxProps>(function Checkbox(
|
||||||
|
{ className, ...props },
|
||||||
|
ref,
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
<BaseCheckbox.Root ref={ref} className={clsx(styles.checkbox, className)} {...props}>
|
||||||
|
<BaseCheckbox.Indicator className={styles.checkboxIndicator}>
|
||||||
|
<span className={styles.checkboxIcon} aria-hidden="true">
|
||||||
|
✓
|
||||||
|
</span>
|
||||||
|
</BaseCheckbox.Indicator>
|
||||||
|
</BaseCheckbox.Root>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
export interface RangeInputProps {
|
||||||
|
className?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
defaultValue?: number;
|
||||||
|
label?: ReactNode;
|
||||||
|
max?: number;
|
||||||
|
min?: number;
|
||||||
|
name?: string;
|
||||||
|
step?: number;
|
||||||
|
style?: CSSProperties;
|
||||||
|
value?: number;
|
||||||
|
onValueChange?: (value: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const RangeInput = forwardRef<HTMLDivElement, RangeInputProps>(function RangeInput(
|
||||||
|
{
|
||||||
|
className,
|
||||||
|
defaultValue,
|
||||||
|
disabled,
|
||||||
|
label,
|
||||||
|
max = 100,
|
||||||
|
min = 0,
|
||||||
|
name,
|
||||||
|
onValueChange,
|
||||||
|
step = 1,
|
||||||
|
style,
|
||||||
|
value,
|
||||||
|
},
|
||||||
|
ref,
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
<Slider.Root
|
||||||
|
ref={ref}
|
||||||
|
className={clsx(styles.rangeRoot, className)}
|
||||||
|
min={min}
|
||||||
|
max={max}
|
||||||
|
thumbAlignment="edge"
|
||||||
|
style={style}
|
||||||
|
disabled={disabled}
|
||||||
|
name={name}
|
||||||
|
step={step}
|
||||||
|
value={value}
|
||||||
|
defaultValue={defaultValue}
|
||||||
|
onValueChange={(nextValue) => {
|
||||||
|
onValueChange?.(Array.isArray(nextValue) ? nextValue[0] : nextValue);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Slider.Control className={styles.rangeControl}>
|
||||||
|
<Slider.Track className={styles.rangeTrack}>
|
||||||
|
<Slider.Indicator className={styles.rangeIndicator} />
|
||||||
|
<Slider.Thumb aria-label={typeof label === 'string' ? label : undefined} className={styles.rangeThumb} />
|
||||||
|
</Slider.Track>
|
||||||
|
</Slider.Control>
|
||||||
|
</Slider.Root>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
export function OptionCardGroup({
|
export function OptionCardGroup({
|
||||||
className,
|
className,
|
||||||
children,
|
children,
|
||||||
|
|
|
||||||
|
|
@ -277,13 +277,9 @@ describe('import route', () => {
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'Refresh' }));
|
fireEvent.click(screen.getByRole('button', { name: 'Refresh' }));
|
||||||
|
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
expect((screen.getByLabelText('Select 02-track.flac') as HTMLInputElement).checked).toBe(
|
expect(screen.getByRole('checkbox', { name: 'Select 02-track.flac' })).toBeChecked(),
|
||||||
true,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
expect((screen.getByLabelText('Select 01-track.flac') as HTMLInputElement).checked).toBe(
|
|
||||||
false,
|
|
||||||
);
|
);
|
||||||
|
expect(screen.getByRole('checkbox', { name: 'Select 01-track.flac' })).not.toBeChecked();
|
||||||
expect(screen.getByText('Process Selected (1)')).toBeInTheDocument();
|
expect(screen.getByText('Process Selected (1)')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { type DragEvent, type KeyboardEvent, useState } from 'react';
|
import { type DragEvent, type KeyboardEvent, useState } from 'react';
|
||||||
|
|
||||||
|
import { Button, TextInput } from '@/components/form/form';
|
||||||
|
|
||||||
import type { ImportAlbumResult } from '../-import.types';
|
import type { ImportAlbumResult } from '../-import.types';
|
||||||
import styles from './import-page.module.css';
|
import styles from './import-page.module.css';
|
||||||
|
|
||||||
|
|
@ -285,7 +287,7 @@ function AlbumImportPanelContent({ viewModel }: { viewModel: AlbumImportViewMode
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className={styles.importPageSearchBar}>
|
<div className={styles.importPageSearchBar}>
|
||||||
<input
|
<TextInput
|
||||||
type="text"
|
type="text"
|
||||||
id="import-page-album-search-input"
|
id="import-page-album-search-input"
|
||||||
className={styles.importPageSearchInput}
|
className={styles.importPageSearchInput}
|
||||||
|
|
@ -296,10 +298,10 @@ function AlbumImportPanelContent({ viewModel }: { viewModel: AlbumImportViewMode
|
||||||
if (event.key === 'Enter') onRunSearch();
|
if (event.key === 'Enter') onRunSearch();
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<button type="button" className={styles.importPageSearchBtn} onClick={onRunSearch}>
|
<Button type="button" className={styles.importPageSearchBtn} onClick={onRunSearch}>
|
||||||
Search
|
Search
|
||||||
</button>
|
</Button>
|
||||||
<button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
className={`${styles.importPageClearBtn} ${albumResults === null ? styles.hidden : ''}`}
|
className={`${styles.importPageClearBtn} ${albumResults === null ? styles.hidden : ''}`}
|
||||||
id="import-page-album-clear-btn"
|
id="import-page-album-clear-btn"
|
||||||
|
|
@ -307,7 +309,7 @@ function AlbumImportPanelContent({ viewModel }: { viewModel: AlbumImportViewMode
|
||||||
onClick={onBackToSearch}
|
onClick={onBackToSearch}
|
||||||
>
|
>
|
||||||
x
|
x
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={styles.importPageAlbumGrid} id="import-page-album-results">
|
<div className={styles.importPageAlbumGrid} id="import-page-album-results">
|
||||||
|
|
@ -431,20 +433,20 @@ function AlbumMatchPanel({ viewModel }: { viewModel: AlbumImportViewModel }) {
|
||||||
<div className={styles.importPageMatchHeader}>
|
<div className={styles.importPageMatchHeader}>
|
||||||
<h3>Track Matching</h3>
|
<h3>Track Matching</h3>
|
||||||
<div className={styles.importPageMatchActions}>
|
<div className={styles.importPageMatchActions}>
|
||||||
<button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
className={styles.importPageSecondaryBtn}
|
className={styles.importPageSecondaryBtn}
|
||||||
onClick={onAutoRematch}
|
onClick={onAutoRematch}
|
||||||
>
|
>
|
||||||
Re-match Automatically
|
Re-match Automatically
|
||||||
</button>
|
</Button>
|
||||||
<button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
className={styles.importPageBackBtn}
|
className={styles.importPageBackBtn}
|
||||||
onClick={onBackToSearch}
|
onClick={onBackToSearch}
|
||||||
>
|
>
|
||||||
Back to Search
|
Back to Search
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -504,7 +506,7 @@ function AlbumMatchPanel({ viewModel }: { viewModel: AlbumImportViewModel }) {
|
||||||
</span>
|
</span>
|
||||||
<span>
|
<span>
|
||||||
{file ? (
|
{file ? (
|
||||||
<button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
className={styles.importPageMatchUnmatch}
|
className={styles.importPageMatchUnmatch}
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
|
|
@ -513,7 +515,7 @@ function AlbumMatchPanel({ viewModel }: { viewModel: AlbumImportViewModel }) {
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
x
|
x
|
||||||
</button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -564,7 +566,7 @@ function AlbumMatchPanel({ viewModel }: { viewModel: AlbumImportViewModel }) {
|
||||||
<div className={styles.importPageMatchStats} id="import-page-match-stats">
|
<div className={styles.importPageMatchStats} id="import-page-match-stats">
|
||||||
{matchedCount} of {albumMatch.matches?.length ?? 0} tracks matched
|
{matchedCount} of {albumMatch.matches?.length ?? 0} tracks matched
|
||||||
</div>
|
</div>
|
||||||
<button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
className={styles.importPageProcessBtn}
|
className={styles.importPageProcessBtn}
|
||||||
id="import-page-album-process-btn"
|
id="import-page-album-process-btn"
|
||||||
|
|
@ -572,7 +574,7 @@ function AlbumMatchPanel({ viewModel }: { viewModel: AlbumImportViewModel }) {
|
||||||
onClick={onProcessAlbum}
|
onClick={onProcessAlbum}
|
||||||
>
|
>
|
||||||
Process {matchedCount} Track{matchedCount === 1 ? '' : 's'}
|
Process {matchedCount} Track{matchedCount === 1 ? '' : 's'}
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
import { Button, RangeInput, Select } from '@/components/form/form';
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
ImportAutoFilter,
|
ImportAutoFilter,
|
||||||
ImportAutoImportResult,
|
ImportAutoImportResult,
|
||||||
|
|
@ -171,7 +173,7 @@ export function AutoImportPanel({
|
||||||
{getAutoImportStatusText(statusQuery.data)}
|
{getAutoImportStatusText(statusQuery.data)}
|
||||||
</span>
|
</span>
|
||||||
{statusQuery.data?.running ? (
|
{statusQuery.data?.running ? (
|
||||||
<button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
className={styles.autoImportScanNowBtn}
|
className={styles.autoImportScanNowBtn}
|
||||||
id="auto-import-scan-now"
|
id="auto-import-scan-now"
|
||||||
|
|
@ -181,26 +183,27 @@ export function AutoImportPanel({
|
||||||
>
|
>
|
||||||
<RefreshIcon />
|
<RefreshIcon />
|
||||||
Scan Now
|
Scan Now
|
||||||
</button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
{statusQuery.data?.running ? (
|
{statusQuery.data?.running ? (
|
||||||
<div className={styles.autoImportSettingsRow} id="auto-import-settings-row">
|
<div className={styles.autoImportSettingsRow} id="auto-import-settings-row">
|
||||||
<label>
|
<div className={styles.autoImportSetting}>
|
||||||
Confidence:{' '}
|
<span>Confidence:</span>
|
||||||
<input
|
<RangeInput
|
||||||
type="range"
|
label="Confidence"
|
||||||
id="auto-import-confidence"
|
min={50}
|
||||||
min="50"
|
max={100}
|
||||||
max="100"
|
|
||||||
value={confidence}
|
value={confidence}
|
||||||
onChange={(event) => setConfidence(Number(event.target.value))}
|
onValueChange={setConfidence}
|
||||||
/>{' '}
|
/>
|
||||||
<span id="auto-import-conf-val">{confidence}%</span>
|
<span className={styles.autoImportConfidenceValue} id="auto-import-conf-val">
|
||||||
</label>
|
{confidence}%
|
||||||
<label>
|
</span>
|
||||||
Interval:{' '}
|
</div>
|
||||||
<select
|
<div className={styles.autoImportSetting}>
|
||||||
|
<span>Interval:</span>
|
||||||
|
<Select
|
||||||
id="auto-import-interval"
|
id="auto-import-interval"
|
||||||
value={interval}
|
value={interval}
|
||||||
onChange={(event) => setInterval(Number(event.target.value))}
|
onChange={(event) => setInterval(Number(event.target.value))}
|
||||||
|
|
@ -209,9 +212,9 @@ export function AutoImportPanel({
|
||||||
<option value="60">60s</option>
|
<option value="60">60s</option>
|
||||||
<option value="120">2m</option>
|
<option value="120">2m</option>
|
||||||
<option value="300">5m</option>
|
<option value="300">5m</option>
|
||||||
</select>
|
</Select>
|
||||||
</label>
|
</div>
|
||||||
<button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
className={`${styles.autoImportActionBtn} ${styles.autoImportActionSecondary}`}
|
className={`${styles.autoImportActionBtn} ${styles.autoImportActionSecondary}`}
|
||||||
disabled={saveSettingsMutation.isPending}
|
disabled={saveSettingsMutation.isPending}
|
||||||
|
|
@ -223,7 +226,7 @@ export function AutoImportPanel({
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
Save
|
Save
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{activeLines.length > 0 ? (
|
{activeLines.length > 0 ? (
|
||||||
|
|
@ -264,7 +267,7 @@ export function AutoImportPanel({
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.autoImportFilters} id="auto-import-filters">
|
<div className={styles.autoImportFilters} id="auto-import-filters">
|
||||||
{(['all', 'pending', 'imported', 'failed'] as const).map((filter) => (
|
{(['all', 'pending', 'imported', 'failed'] as const).map((filter) => (
|
||||||
<button
|
<Button
|
||||||
key={filter}
|
key={filter}
|
||||||
type="button"
|
type="button"
|
||||||
className={`${styles.autoImportFilterPill} ${
|
className={`${styles.autoImportFilterPill} ${
|
||||||
|
|
@ -274,11 +277,11 @@ export function AutoImportPanel({
|
||||||
onClick={() => onFilterChange(filter)}
|
onClick={() => onFilterChange(filter)}
|
||||||
>
|
>
|
||||||
{filter === 'pending' ? 'Needs Review' : titleCase(filter)}
|
{filter === 'pending' ? 'Needs Review' : titleCase(filter)}
|
||||||
</button>
|
</Button>
|
||||||
))}
|
))}
|
||||||
<div className={styles.importPageFlexSpacer} />
|
<div className={styles.importPageFlexSpacer} />
|
||||||
{counts.review > 0 ? (
|
{counts.review > 0 ? (
|
||||||
<button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
className={styles.autoImportBatchBtn}
|
className={styles.autoImportBatchBtn}
|
||||||
id="auto-import-approve-all"
|
id="auto-import-approve-all"
|
||||||
|
|
@ -286,10 +289,10 @@ export function AutoImportPanel({
|
||||||
onClick={() => approveAllMutation.mutate()}
|
onClick={() => approveAllMutation.mutate()}
|
||||||
>
|
>
|
||||||
Approve All
|
Approve All
|
||||||
</button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
{counts.imported + counts.failed > 0 ? (
|
{counts.imported + counts.failed > 0 ? (
|
||||||
<button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
className={`${styles.autoImportBatchBtn} ${styles.autoImportClearBtn}`}
|
className={`${styles.autoImportBatchBtn} ${styles.autoImportClearBtn}`}
|
||||||
id="auto-import-clear-completed"
|
id="auto-import-clear-completed"
|
||||||
|
|
@ -297,7 +300,7 @@ export function AutoImportPanel({
|
||||||
onClick={() => clearMutation.mutate()}
|
onClick={() => clearMutation.mutate()}
|
||||||
>
|
>
|
||||||
Clear History
|
Clear History
|
||||||
</button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|
@ -445,7 +448,7 @@ function AutoImportResultCard({
|
||||||
<div className={styles.autoImportConfidenceText}>{confidencePercent}% confidence</div>
|
<div className={styles.autoImportConfidenceText}>{confidencePercent}% confidence</div>
|
||||||
{result.status === 'pending_review' ? (
|
{result.status === 'pending_review' ? (
|
||||||
<div className={styles.autoImportActions}>
|
<div className={styles.autoImportActions}>
|
||||||
<button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
className={`${styles.autoImportActionBtn} ${styles.autoImportActionPrimary}`}
|
className={`${styles.autoImportActionBtn} ${styles.autoImportActionPrimary}`}
|
||||||
disabled={approvePending}
|
disabled={approvePending}
|
||||||
|
|
@ -455,8 +458,8 @@ function AutoImportResultCard({
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Approve & Import
|
Approve & Import
|
||||||
</button>
|
</Button>
|
||||||
<button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
className={`${styles.autoImportActionBtn} ${styles.autoImportActionSecondary}`}
|
className={`${styles.autoImportActionBtn} ${styles.autoImportActionSecondary}`}
|
||||||
disabled={rejectPending}
|
disabled={rejectPending}
|
||||||
|
|
@ -466,7 +469,7 @@ function AutoImportResultCard({
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Dismiss
|
Dismiss
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -637,53 +637,6 @@
|
||||||
margin-top: 2px;
|
margin-top: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.importPageSingleCheckboxInput {
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
opacity: 0;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.importPageSingleCheckbox {
|
|
||||||
width: 18px;
|
|
||||||
height: 18px;
|
|
||||||
box-sizing: border-box;
|
|
||||||
border: 2px solid rgba(255, 255, 255, 0.2);
|
|
||||||
background: transparent;
|
|
||||||
padding: 0;
|
|
||||||
border-radius: 4px;
|
|
||||||
cursor: pointer;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
transition: all 0.2s;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.importPageSingleCheckbox::after {
|
|
||||||
content: '✓';
|
|
||||||
color: #000;
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 700;
|
|
||||||
line-height: 1;
|
|
||||||
opacity: 0;
|
|
||||||
transform: translateY(-0.5px) scaleX(1.18);
|
|
||||||
}
|
|
||||||
|
|
||||||
.importPageSingleCheckboxInput:focus-visible + .importPageSingleCheckbox {
|
|
||||||
outline: none;
|
|
||||||
box-shadow: 0 0 0 3px rgba(var(--accent-light-rgb), 0.14);
|
|
||||||
}
|
|
||||||
|
|
||||||
.importPageSingleCheckboxInput:checked + .importPageSingleCheckbox {
|
|
||||||
background: rgb(var(--accent-light-rgb));
|
|
||||||
border-color: rgb(var(--accent-light-rgb));
|
|
||||||
}
|
|
||||||
|
|
||||||
.importPageSingleCheckboxInput:checked + .importPageSingleCheckbox::after {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.importPageSingleInfo {
|
.importPageSingleInfo {
|
||||||
grid-column: 2;
|
grid-column: 2;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
|
@ -1199,23 +1152,19 @@
|
||||||
color: rgba(255, 255, 255, 0.5);
|
color: rgba(255, 255, 255, 0.5);
|
||||||
}
|
}
|
||||||
|
|
||||||
.autoImportSettingsRow label {
|
.autoImportSetting {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
}
|
}
|
||||||
.autoImportSettingsRow input[type='range'] {
|
|
||||||
width: 100px;
|
|
||||||
}
|
|
||||||
.autoImportSettingsRow select {
|
|
||||||
background: rgba(255, 255, 255, 0.05);
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
|
||||||
color: #fff;
|
|
||||||
border-radius: 6px;
|
|
||||||
padding: 3px 8px;
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
.autoImportConfidenceValue {
|
||||||
|
display: inline-block;
|
||||||
|
flex: 0 0 5ch;
|
||||||
|
width: 5ch;
|
||||||
|
text-align: right;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
.autoImportEmpty {
|
.autoImportEmpty {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 40px 20px;
|
padding: 40px 20px;
|
||||||
|
|
@ -1468,12 +1417,6 @@
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 7px;
|
gap: 7px;
|
||||||
padding: 9px 18px;
|
|
||||||
border-radius: 10px;
|
|
||||||
border: 1px solid transparent;
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 500;
|
|
||||||
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
|
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
letter-spacing: 0.1px;
|
letter-spacing: 0.1px;
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { Link, Outlet } from '@tanstack/react-router';
|
import { Link, Outlet } from '@tanstack/react-router';
|
||||||
|
|
||||||
import { Show } from '@/components/primitives';
|
import { Show } from '@/components/primitives';
|
||||||
|
import { Button } from '@/components/form/form';
|
||||||
import { useReactPageShell } from '@/platform/shell/route-controllers';
|
import { useReactPageShell } from '@/platform/shell/route-controllers';
|
||||||
|
|
||||||
import type { ImportQueueEntry } from '../-import.types';
|
import type { ImportQueueEntry } from '../-import.types';
|
||||||
|
|
@ -76,7 +77,7 @@ function ImportHeader({
|
||||||
<img src="/static/import.png" className="page-header-icon" alt="" />
|
<img src="/static/import.png" className="page-header-icon" alt="" />
|
||||||
<span>Import Music</span>
|
<span>Import Music</span>
|
||||||
</h1>
|
</h1>
|
||||||
<button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
className={`${styles.importPageRefreshBtn} ${
|
className={`${styles.importPageRefreshBtn} ${
|
||||||
refreshing ? styles.importPageRefreshBtnRefreshing : ''
|
refreshing ? styles.importPageRefreshBtnRefreshing : ''
|
||||||
|
|
@ -88,7 +89,7 @@ function ImportHeader({
|
||||||
>
|
>
|
||||||
<RefreshIcon />
|
<RefreshIcon />
|
||||||
{refreshing ? 'Refreshing...' : 'Refresh'}
|
{refreshing ? 'Refreshing...' : 'Refresh'}
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.importPageStagingBar} id="import-staging-bar">
|
<div className={styles.importPageStagingBar} id="import-staging-bar">
|
||||||
<span className={styles.importStagingPath} id="import-page-staging-path">
|
<span className={styles.importStagingPath} id="import-page-staging-path">
|
||||||
|
|
@ -118,7 +119,7 @@ function ImportProcessingQueue() {
|
||||||
>
|
>
|
||||||
<div className={styles.importPageQueueHeader}>
|
<div className={styles.importPageQueueHeader}>
|
||||||
<span className={styles.importPageQueueTitle}>Processing</span>
|
<span className={styles.importPageQueueTitle}>Processing</span>
|
||||||
<button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
className={styles.importPageQueueClear}
|
className={styles.importPageQueueClear}
|
||||||
id="import-page-queue-clear"
|
id="import-page-queue-clear"
|
||||||
|
|
@ -126,7 +127,7 @@ function ImportProcessingQueue() {
|
||||||
onClick={clearFinishedJobs}
|
onClick={clearFinishedJobs}
|
||||||
>
|
>
|
||||||
Clear finished
|
Clear finished
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.importPageQueueList} id="import-page-queue-list">
|
<div className={styles.importPageQueueList} id="import-page-queue-list">
|
||||||
{queue.map((entry) => (
|
{queue.map((entry) => (
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
|
|
||||||
|
import { Button, Checkbox, TextInput } from '@/components/form/form';
|
||||||
|
|
||||||
import type { SingleSearchState } from '../-import.store';
|
import type { SingleSearchState } from '../-import.store';
|
||||||
import type { ImportTrackResult } from '../-import.types';
|
import type { ImportTrackResult } from '../-import.types';
|
||||||
import type { ImportStagingFile } from '../-import.types';
|
import type { ImportStagingFile } from '../-import.types';
|
||||||
|
|
@ -172,12 +174,12 @@ export function SinglesImportPanel({
|
||||||
<>
|
<>
|
||||||
<div className={styles.importPageSinglesHeader}>
|
<div className={styles.importPageSinglesHeader}>
|
||||||
<div className={styles.importPageSinglesActions}>
|
<div className={styles.importPageSinglesActions}>
|
||||||
<button type="button" className={styles.importPageSecondaryBtn} onClick={onSelectAll}>
|
<Button type="button" className={styles.importPageSecondaryBtn} onClick={onSelectAll}>
|
||||||
<span id="import-page-select-all-text">
|
<span id="import-page-select-all-text">
|
||||||
{allSelected ? 'Deselect All' : 'Select All'}
|
{allSelected ? 'Deselect All' : 'Select All'}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</Button>
|
||||||
<button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
className={styles.importPageProcessBtn}
|
className={styles.importPageProcessBtn}
|
||||||
id="import-page-singles-process-btn"
|
id="import-page-singles-process-btn"
|
||||||
|
|
@ -185,7 +187,7 @@ export function SinglesImportPanel({
|
||||||
onClick={onProcessSingles}
|
onClick={onProcessSingles}
|
||||||
>
|
>
|
||||||
Process Selected ({selectedCount})
|
Process Selected ({selectedCount})
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.importPageSinglesList} id="import-page-singles-list">
|
<div className={styles.importPageSinglesList} id="import-page-singles-list">
|
||||||
|
|
@ -206,14 +208,11 @@ export function SinglesImportPanel({
|
||||||
data-single-key={fileKey}
|
data-single-key={fileKey}
|
||||||
>
|
>
|
||||||
<label className={styles.importPageSingleCheckboxWrap}>
|
<label className={styles.importPageSingleCheckboxWrap}>
|
||||||
<input
|
<Checkbox
|
||||||
type="checkbox"
|
|
||||||
aria-label={`Select ${file.filename}`}
|
|
||||||
className={styles.importPageSingleCheckboxInput}
|
|
||||||
checked={isSelected}
|
checked={isSelected}
|
||||||
onChange={() => onToggleSingle(fileKey)}
|
aria-label={`Select ${file.filename}`}
|
||||||
|
onCheckedChange={() => onToggleSingle(fileKey)}
|
||||||
/>
|
/>
|
||||||
<span className={styles.importPageSingleCheckbox} aria-hidden="true" />
|
|
||||||
</label>
|
</label>
|
||||||
<div className={styles.importPageSingleInfo}>
|
<div className={styles.importPageSingleInfo}>
|
||||||
<div className={styles.importPageSingleFilename}>{file.filename}</div>
|
<div className={styles.importPageSingleFilename}>{file.filename}</div>
|
||||||
|
|
@ -280,7 +279,7 @@ function SingleSearchPanel({
|
||||||
return (
|
return (
|
||||||
<div className={styles.importPageSingleSearchPanel}>
|
<div className={styles.importPageSingleSearchPanel}>
|
||||||
<div className={styles.importPageSingleSearchBar}>
|
<div className={styles.importPageSingleSearchBar}>
|
||||||
<input
|
<TextInput
|
||||||
type="text"
|
type="text"
|
||||||
className={styles.importPageSingleSearchInput}
|
className={styles.importPageSingleSearchInput}
|
||||||
value={query}
|
value={query}
|
||||||
|
|
@ -290,13 +289,13 @@ function SingleSearchPanel({
|
||||||
if (event.key === 'Enter') onRunSearch(fileKey, query);
|
if (event.key === 'Enter') onRunSearch(fileKey, query);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
className={styles.importPageSingleSearchGo}
|
className={styles.importPageSingleSearchGo}
|
||||||
onClick={() => onRunSearch(fileKey, query)}
|
onClick={() => onRunSearch(fileKey, query)}
|
||||||
>
|
>
|
||||||
Search
|
Search
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.importPageSingleSearchResults}>
|
<div className={styles.importPageSingleSearchResults}>
|
||||||
{searchState?.loading ? (
|
{searchState?.loading ? (
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue