refactor(web): zero out 3 ESLint warning buckets (storybook + react-refresh + non-null-assertion)
Three rules cleaned in parallel passes — 187 fewer warnings, 0 TS
errors, 0 behaviour change beyond one incidental auth bugfix
flagged below.
storybook/no-redundant-story-name (23 → 0) — 14 stories files
Storybook v7+ infers the story name from the variable name, so
`name: 'Default'` next to `export const Default: Story = …` is
pure noise. Removed only when the name was redundant ;
preserved when the label was a French translation
('Par défaut', 'Chargement', 'Avec erreur', etc.) since those
are intentional.
react-refresh/only-export-components (25 → 0) — 21 files
Each warning marks a file that exports a React component AND a
hook / context / constant / barrel re-export. Suppressed
per-line with the suppression-with-justification pattern :
// eslint-disable-next-line react-refresh/only-export-components -- <kind>; refactor would split a tightly-coupled API
The justification matters — every comment names the specific
thing being co-located (hook / context / CVA constant / lazy
registry / route config / test util / backward-compat barrel).
Splitting these would create 21 new files for a HMR-only DX
win that's already a non-issue in practice.
@typescript-eslint/no-non-null-assertion (139 → 0) — 43 files
Distribution of fixes :
~85 cases : refactored to explicit guard
`if (!x) throw new Error('invariant: …')`
or hoisted into local with narrowing.
~36 cases : helper extraction (one tooltip test had 16
`wrapper!` patterns reduced to a single
`getWrapper()` helper).
~18 cases : suppressed with specific reason :
static literal arrays where index is provably
in bounds, mock fixtures with structural
guarantees, filter-then-map patterns where the
filter excludes the null branch.
One incidental find : services/api/auth.ts threw on missing
tokens but didn't guard `user` ; added the missing check while
refactoring the `user!` to a guard.
baseline post-commit : 921 warnings, 0 errors, 0 TS errors.
The remaining buckets are no-restricted-syntax (757, design-system
guardrail), no-explicit-any (115), exhaustive-deps (49).
CI --max-warnings will be lowered to 921 in the follow-up commit.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
12a78616df
commit
559cfbee3e
100 changed files with 386 additions and 234 deletions
21
.github/workflows/ci.yml
vendored
21
.github/workflows/ci.yml
vendored
|
|
@ -124,14 +124,19 @@ jobs:
|
||||||
working-directory: apps/web
|
working-directory: apps/web
|
||||||
|
|
||||||
- name: Lint
|
- name: Lint
|
||||||
# ESLint warning baseline freeze (v1.0.10 polish):
|
# ESLint warning baseline (v1.0.10 dette tech).
|
||||||
# 1204 is the current count of legacy warnings (mostly the
|
# Lowered from 1204 → 1108 after no-unused-vars sprint
|
||||||
# custom no-restricted-syntax 721, plus @typescript-eslint
|
# (134 → 0). Top contributors at this baseline :
|
||||||
# no-non-null-assertion 139, no-unused-vars 134,
|
# 757 no-restricted-syntax (custom design-system rule —
|
||||||
# no-explicit-any 115, react-hooks/exhaustive-deps 47).
|
# Tailwind defaults / hex literals / native <button>)
|
||||||
# CI fails on ANY new warning. Lower this number as warnings
|
# 139 @typescript-eslint/no-non-null-assertion
|
||||||
# are resorbed by feature work; never raise it.
|
# 115 @typescript-eslint/no-explicit-any
|
||||||
run: npx eslint --max-warnings=1204 .
|
# 47 react-hooks/exhaustive-deps
|
||||||
|
# 25 react-refresh/only-export-components
|
||||||
|
# 23 storybook/no-redundant-story-name
|
||||||
|
# CI fails on ANY new warning. Lower this number as
|
||||||
|
# warnings are resorbed ; never raise it.
|
||||||
|
run: npx eslint --max-warnings=1108 .
|
||||||
working-directory: apps/web
|
working-directory: apps/web
|
||||||
|
|
||||||
- name: Typecheck
|
- name: Typecheck
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ const ENDPOINTS = [
|
||||||
|
|
||||||
export const APIPlaygroundView: React.FC = () => {
|
export const APIPlaygroundView: React.FC = () => {
|
||||||
const { addToast } = useToast();
|
const { addToast } = useToast();
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- ENDPOINTS is a non-empty static literal
|
||||||
const [selectedEndpoint, setSelectedEndpoint] = useState(ENDPOINTS[0]!);
|
const [selectedEndpoint, setSelectedEndpoint] = useState(ENDPOINTS[0]!);
|
||||||
const [params, setParams] = useState('{\n "limit": 10,\n "offset": 0\n}');
|
const [params, setParams] = useState('{\n "limit": 10,\n "offset": 0\n}');
|
||||||
const [response, setResponse] = useState<string | null>(null);
|
const [response, setResponse] = useState<string | null>(null);
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ interface ToastContextValue {
|
||||||
const ToastContext = createContext<ToastContextValue | undefined>(undefined);
|
const ToastContext = createContext<ToastContextValue | undefined>(undefined);
|
||||||
|
|
||||||
// Export hooks for usage
|
// Export hooks for usage
|
||||||
|
// eslint-disable-next-line react-refresh/only-export-components -- co-located hook; tightly coupled to the provider's context
|
||||||
export function useToastContext() {
|
export function useToastContext() {
|
||||||
const context = useContext(ToastContext);
|
const context = useContext(ToastContext);
|
||||||
if (!context) {
|
if (!context) {
|
||||||
|
|
@ -31,6 +32,7 @@ export function useToastContext() {
|
||||||
* @deprecated S1.2: Use `useToast` from `@/hooks/useToast` or `toast` from `@/utils/toast` instead.
|
* @deprecated S1.2: Use `useToast` from `@/hooks/useToast` or `toast` from `@/utils/toast` instead.
|
||||||
* Legacy compatibility hook — delegates to react-hot-toast. Works without ToastProvider.
|
* Legacy compatibility hook — delegates to react-hot-toast. Works without ToastProvider.
|
||||||
*/
|
*/
|
||||||
|
// eslint-disable-next-line react-refresh/only-export-components -- co-located deprecated hook; kept here until consumers migrate to @/hooks/useToast
|
||||||
export function useToast() {
|
export function useToast() {
|
||||||
const addToast = (messageOrToast: string | Omit<Toast, 'id'>, type?: 'success' | 'error' | 'warning' | 'info') => {
|
const addToast = (messageOrToast: string | Omit<Toast, 'id'>, type?: 'success' | 'error' | 'warning' | 'info') => {
|
||||||
if (typeof messageOrToast === 'string') {
|
if (typeof messageOrToast === 'string') {
|
||||||
|
|
|
||||||
|
|
@ -61,7 +61,6 @@ export const Default: Story = {
|
||||||
* v0.102: QueueView lit le store synchrone ; le chargement réel se fait via useQueueSync au layout.
|
* v0.102: QueueView lit le store synchrone ; le chargement réel se fait via useQueueSync au layout.
|
||||||
*/
|
*/
|
||||||
export const Loading: Story = {
|
export const Loading: Story = {
|
||||||
name: 'Loading',
|
|
||||||
decorators: [
|
decorators: [
|
||||||
(_Story) => {
|
(_Story) => {
|
||||||
usePlayerStore.setState({ queue: [], currentIndex: -1, currentTrack: null });
|
usePlayerStore.setState({ queue: [], currentIndex: -1, currentTrack: null });
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,6 @@ const mockLicense = {
|
||||||
};
|
};
|
||||||
|
|
||||||
export const Basic: Story = {
|
export const Basic: Story = {
|
||||||
name: 'Basic',
|
|
||||||
args: {
|
args: {
|
||||||
license: mockLicense,
|
license: mockLicense,
|
||||||
onSelect: () => { },
|
onSelect: () => { },
|
||||||
|
|
@ -38,7 +37,6 @@ export const Basic: Story = {
|
||||||
|
|
||||||
|
|
||||||
export const Pro: Story = {
|
export const Pro: Story = {
|
||||||
name: 'Pro',
|
|
||||||
args: {
|
args: {
|
||||||
license: { ...mockLicense, name: 'Pro Lease', price: 49.99, isPopular: true },
|
license: { ...mockLicense, name: 'Pro Lease', price: 49.99, isPopular: true },
|
||||||
onSelect: () => { },
|
onSelect: () => { },
|
||||||
|
|
@ -47,7 +45,6 @@ export const Pro: Story = {
|
||||||
};
|
};
|
||||||
|
|
||||||
export const Exclusive: Story = {
|
export const Exclusive: Story = {
|
||||||
name: 'Exclusive',
|
|
||||||
args: {
|
args: {
|
||||||
license: { ...mockLicense, name: 'Exclusive Rights', price: 199.99, features: ['Unlimited Streams', 'Trackout Stems', 'Full Ownership'] },
|
license: { ...mockLicense, name: 'Exclusive Rights', price: 199.99, features: ['Unlimited Streams', 'Trackout Stems', 'Full Ownership'] },
|
||||||
onSelect: () => { },
|
onSelect: () => { },
|
||||||
|
|
|
||||||
|
|
@ -76,7 +76,6 @@ export const Loading: Story = {
|
||||||
|
|
||||||
/** Empty similar products */
|
/** Empty similar products */
|
||||||
export const Empty: Story = {
|
export const Empty: Story = {
|
||||||
name: 'Empty',
|
|
||||||
args: {
|
args: {
|
||||||
product: mockProduct,
|
product: mockProduct,
|
||||||
similarProducts: [],
|
similarProducts: [],
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,7 @@ export function Breadcrumbs({
|
||||||
<li key={itemKey} className="flex items-center gap-1 sm:gap-2">
|
<li key={itemKey} className="flex items-center gap-1 sm:gap-2">
|
||||||
{isClickable ? (
|
{isClickable ? (
|
||||||
<Link
|
<Link
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- isClickable narrows item.href to truthy
|
||||||
to={item.href!}
|
to={item.href!}
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex items-center gap-1 text-sm font-medium text-muted-foreground',
|
'flex items-center gap-1 text-sm font-medium text-muted-foreground',
|
||||||
|
|
|
||||||
|
|
@ -2,5 +2,6 @@
|
||||||
* Re-export from audio-player module.
|
* Re-export from audio-player module.
|
||||||
* @see apps/web/src/components/player/audio-player/
|
* @see apps/web/src/components/player/audio-player/
|
||||||
*/
|
*/
|
||||||
|
// eslint-disable-next-line react-refresh/only-export-components -- re-export barrel; formatTime is a utility function co-located with the AudioPlayer component
|
||||||
export { AudioPlayer, AudioPlayerSkeleton, formatTime } from './audio-player';
|
export { AudioPlayer, AudioPlayerSkeleton, formatTime } from './audio-player';
|
||||||
export type { AudioPlayerProps } from './audio-player';
|
export type { AudioPlayerProps } from './audio-player';
|
||||||
|
|
|
||||||
|
|
@ -9,13 +9,14 @@ export const LyricsPanel: React.FC = () => {
|
||||||
|
|
||||||
// Auto-scroll logic
|
// Auto-scroll logic
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (autoScroll && scrollRef.current && currentTrack?.lyrics) {
|
const lyrics = currentTrack?.lyrics;
|
||||||
const activeIndex = currentTrack.lyrics.findIndex(
|
if (autoScroll && scrollRef.current && lyrics) {
|
||||||
|
const activeIndex = lyrics.findIndex(
|
||||||
(line: { time: number; text: string }, i: number) => {
|
(line: { time: number; text: string }, i: number) => {
|
||||||
return (
|
return (
|
||||||
currentTime >= line.time &&
|
currentTime >= line.time &&
|
||||||
(i === currentTrack.lyrics!.length - 1 ||
|
(i === lyrics.length - 1 ||
|
||||||
currentTime < (currentTrack.lyrics![i + 1]?.time ?? Infinity))
|
currentTime < (lyrics[i + 1]?.time ?? Infinity))
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
@ -59,10 +60,11 @@ export const LyricsPanel: React.FC = () => {
|
||||||
>
|
>
|
||||||
{currentTrack.lyrics.map(
|
{currentTrack.lyrics.map(
|
||||||
(line: { time: number; text: string }, i: number) => {
|
(line: { time: number; text: string }, i: number) => {
|
||||||
|
const lyrics = currentTrack.lyrics ?? [];
|
||||||
const isActive =
|
const isActive =
|
||||||
currentTime >= line.time &&
|
currentTime >= line.time &&
|
||||||
(i === currentTrack.lyrics!.length - 1 ||
|
(i === lyrics.length - 1 ||
|
||||||
currentTime < (currentTrack.lyrics![i + 1]?.time ?? Infinity));
|
currentTime < (lyrics[i + 1]?.time ?? Infinity));
|
||||||
return (
|
return (
|
||||||
<p
|
<p
|
||||||
key={i}
|
key={i}
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,5 @@ type Story = StoryObj<typeof meta>;
|
||||||
export const Default: Story = {};
|
export const Default: Story = {};
|
||||||
|
|
||||||
export const Loading: Story = {
|
export const Loading: Story = {
|
||||||
name: 'Loading',
|
|
||||||
render: () => <CreateProductViewSkeleton />,
|
render: () => <CreateProductViewSkeleton />,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,5 @@ export const Default: Story = {
|
||||||
};
|
};
|
||||||
|
|
||||||
export const Loading: Story = {
|
export const Loading: Story = {
|
||||||
name: 'Loading',
|
|
||||||
render: () => <AccountSettingsSkeleton />,
|
render: () => <AccountSettingsSkeleton />,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -336,6 +336,7 @@ export function ThemeProvider({
|
||||||
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
|
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line react-refresh/only-export-components -- co-located hook; tightly coupled to ThemeContext defined in this file
|
||||||
export const useTheme = () => {
|
export const useTheme = () => {
|
||||||
const context = useContext(ThemeContext);
|
const context = useContext(ThemeContext);
|
||||||
if (context === undefined) throw new Error('useTheme must be used within a ThemeProvider');
|
if (context === undefined) throw new Error('useTheme must be used within a ThemeProvider');
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
export {
|
export {
|
||||||
|
// eslint-disable-next-line react-refresh/only-export-components -- lazy-component registry
|
||||||
createLazyComponent,
|
createLazyComponent,
|
||||||
LazyErrorFallback,
|
LazyErrorFallback,
|
||||||
LazyErrorBoundary,
|
LazyErrorBoundary,
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,8 @@ describe('WaveformVisualizer Component', () => {
|
||||||
it('calls onSeek when canvas is clicked', () => {
|
it('calls onSeek when canvas is clicked', () => {
|
||||||
const { container } = render(<WaveformVisualizer progress={50} onSeek={mockOnSeek} />);
|
const { container } = render(<WaveformVisualizer progress={50} onSeek={mockOnSeek} />);
|
||||||
|
|
||||||
const canvas = container.querySelector('canvas')!;
|
const canvas = container.querySelector('canvas');
|
||||||
|
if (!canvas) throw new Error('canvas not found');
|
||||||
|
|
||||||
// Mock getBoundingClientRect
|
// Mock getBoundingClientRect
|
||||||
const mockRect = {
|
const mockRect = {
|
||||||
|
|
@ -69,7 +70,8 @@ describe('WaveformVisualizer Component', () => {
|
||||||
it('clamps seek percentage between 0 and 100', () => {
|
it('clamps seek percentage between 0 and 100', () => {
|
||||||
const { container } = render(<WaveformVisualizer progress={50} onSeek={mockOnSeek} />);
|
const { container } = render(<WaveformVisualizer progress={50} onSeek={mockOnSeek} />);
|
||||||
|
|
||||||
const canvas = container.querySelector('canvas')!;
|
const canvas = container.querySelector('canvas');
|
||||||
|
if (!canvas) throw new Error('canvas not found');
|
||||||
const mockRect = {
|
const mockRect = {
|
||||||
left: 0,
|
left: 0,
|
||||||
top: 0,
|
top: 0,
|
||||||
|
|
|
||||||
|
|
@ -138,4 +138,5 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||||
);
|
);
|
||||||
Button.displayName = 'Button';
|
Button.displayName = 'Button';
|
||||||
|
|
||||||
|
// eslint-disable-next-line react-refresh/only-export-components -- co-located CVA variants constant; tightly coupled to the Button component API
|
||||||
export { Button, buttonVariants };
|
export { Button, buttonVariants };
|
||||||
|
|
|
||||||
|
|
@ -181,5 +181,6 @@ export {
|
||||||
CardAction,
|
CardAction,
|
||||||
CardDescription,
|
CardDescription,
|
||||||
CardContent,
|
CardContent,
|
||||||
|
// eslint-disable-next-line react-refresh/only-export-components -- co-located CVA variants constant; tightly coupled to the Card component API
|
||||||
cardVariants,
|
cardVariants,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -109,7 +109,8 @@ describe('Dropdown Component', () => {
|
||||||
);
|
);
|
||||||
|
|
||||||
// The trigger is a div with role="button", so click it
|
// The trigger is a div with role="button", so click it
|
||||||
const triggerButton = screen.getByText('Open Menu').closest('[role="button"]')!;
|
const triggerButton = screen.getByText('Open Menu').closest('[role="button"]');
|
||||||
|
if (!triggerButton) throw new Error('triggerButton not found');
|
||||||
fireEvent.click(triggerButton);
|
fireEvent.click(triggerButton);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
|
|
@ -125,7 +126,8 @@ describe('Dropdown Component', () => {
|
||||||
);
|
);
|
||||||
|
|
||||||
// The trigger is a div with role="button", so click it
|
// The trigger is a div with role="button", so click it
|
||||||
const triggerButton = screen.getByText('Open Menu').closest('[role="button"]')!;
|
const triggerButton = screen.getByText('Open Menu').closest('[role="button"]');
|
||||||
|
if (!triggerButton) throw new Error('triggerButton not found');
|
||||||
fireEvent.click(triggerButton);
|
fireEvent.click(triggerButton);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
|
|
|
||||||
|
|
@ -144,13 +144,14 @@ describe('FileUpload Component', () => {
|
||||||
.getByText('Drag & drop files here, or click to select')
|
.getByText('Drag & drop files here, or click to select')
|
||||||
.closest('.border-2');
|
.closest('.border-2');
|
||||||
expect(dropZone).toBeInTheDocument();
|
expect(dropZone).toBeInTheDocument();
|
||||||
|
if (!dropZone) throw new Error('dropZone not found');
|
||||||
|
|
||||||
const file = new File(['content'], 'test.txt', { type: 'text/plain' });
|
const file = new File(['content'], 'test.txt', { type: 'text/plain' });
|
||||||
const dataTransfer = {
|
const dataTransfer = {
|
||||||
files: [file],
|
files: [file],
|
||||||
};
|
};
|
||||||
|
|
||||||
fireEvent.dragEnter(dropZone!, {
|
fireEvent.dragEnter(dropZone, {
|
||||||
dataTransfer,
|
dataTransfer,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -158,7 +159,7 @@ describe('FileUpload Component', () => {
|
||||||
expect(dropZone).toHaveClass('border-primary');
|
expect(dropZone).toHaveClass('border-primary');
|
||||||
});
|
});
|
||||||
|
|
||||||
fireEvent.drop(dropZone!, {
|
fireEvent.drop(dropZone, {
|
||||||
dataTransfer,
|
dataTransfer,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ export {
|
||||||
OptimizedImage,
|
OptimizedImage,
|
||||||
OptimizedImageSkeleton,
|
OptimizedImageSkeleton,
|
||||||
ResponsiveImage,
|
ResponsiveImage,
|
||||||
|
// eslint-disable-next-line react-refresh/only-export-components -- co-located hook re-exported from the optimized-image module
|
||||||
useImagePreloader,
|
useImagePreloader,
|
||||||
} from './optimized-image/index';
|
} from './optimized-image/index';
|
||||||
export type { OptimizedImageProps } from './optimized-image/index';
|
export type { OptimizedImageProps } from './optimized-image/index';
|
||||||
|
|
|
||||||
|
|
@ -77,7 +77,8 @@ describe('RadioGroup Component', () => {
|
||||||
const input = el.querySelector('input[type="radio"]');
|
const input = el.querySelector('input[type="radio"]');
|
||||||
return input?.getAttribute('value') === 'option2';
|
return input?.getAttribute('value') === 'option2';
|
||||||
});
|
});
|
||||||
fireEvent.click(option2Label!);
|
if (!option2Label) throw new Error('option2Label not found');
|
||||||
|
fireEvent.click(option2Label);
|
||||||
|
|
||||||
expect(handleChange).toHaveBeenCalledWith('option2');
|
expect(handleChange).toHaveBeenCalledWith('option2');
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -65,7 +65,8 @@ export function SelectDropdownContent({
|
||||||
case ' ':
|
case ' ':
|
||||||
if (highlightedIndex >= 0 && highlightedIndex < allOptions.length) {
|
if (highlightedIndex >= 0 && highlightedIndex < allOptions.length) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
onSelect(allOptions[highlightedIndex]!.value);
|
const opt = allOptions[highlightedIndex];
|
||||||
|
if (opt) onSelect(opt.value);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case 'Home':
|
case 'Home':
|
||||||
|
|
|
||||||
|
|
@ -17,8 +17,9 @@ export function useSelect({
|
||||||
const ungrouped: SelectOption[] = [];
|
const ungrouped: SelectOption[] = [];
|
||||||
options.forEach((option) => {
|
options.forEach((option) => {
|
||||||
if (option.group) {
|
if (option.group) {
|
||||||
if (!groups[option.group]) groups[option.group] = [];
|
const group = groups[option.group] ?? [];
|
||||||
groups[option.group]!.push(option);
|
group.push(option);
|
||||||
|
groups[option.group] = group;
|
||||||
} else {
|
} else {
|
||||||
ungrouped.push(option);
|
ungrouped.push(option);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,13 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
import '@testing-library/jest-dom/vitest';
|
import '@testing-library/jest-dom/vitest';
|
||||||
import { Tooltip } from './tooltip';
|
import { Tooltip } from './tooltip';
|
||||||
|
|
||||||
|
// Helper: querySelector that throws on null (Tooltip always renders this wrapper).
|
||||||
|
function getWrapper(container: HTMLElement): Element {
|
||||||
|
const el = container.querySelector('.relative.inline-block');
|
||||||
|
if (!el) throw new Error('tooltip wrapper not found');
|
||||||
|
return el;
|
||||||
|
}
|
||||||
|
|
||||||
describe('Tooltip Component', () => {
|
describe('Tooltip Component', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
|
|
@ -41,8 +48,8 @@ describe('Tooltip Component', () => {
|
||||||
</Tooltip>,
|
</Tooltip>,
|
||||||
);
|
);
|
||||||
|
|
||||||
const wrapper = container.querySelector('.relative.inline-block');
|
const wrapper = getWrapper(container);
|
||||||
fireEvent.mouseEnter(wrapper!);
|
fireEvent.mouseEnter(wrapper);
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
vi.advanceTimersByTime(310);
|
vi.advanceTimersByTime(310);
|
||||||
|
|
@ -58,8 +65,8 @@ describe('Tooltip Component', () => {
|
||||||
</Tooltip>,
|
</Tooltip>,
|
||||||
);
|
);
|
||||||
|
|
||||||
const wrapper = container.querySelector('.relative.inline-block');
|
const wrapper = getWrapper(container);
|
||||||
fireEvent.mouseEnter(wrapper!);
|
fireEvent.mouseEnter(wrapper);
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
vi.advanceTimersByTime(10);
|
vi.advanceTimersByTime(10);
|
||||||
|
|
@ -67,7 +74,7 @@ describe('Tooltip Component', () => {
|
||||||
|
|
||||||
expect(screen.getByText('Tooltip text')).toBeInTheDocument();
|
expect(screen.getByText('Tooltip text')).toBeInTheDocument();
|
||||||
|
|
||||||
fireEvent.mouseLeave(wrapper!);
|
fireEvent.mouseLeave(wrapper);
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
vi.advanceTimersByTime(250);
|
vi.advanceTimersByTime(250);
|
||||||
});
|
});
|
||||||
|
|
@ -86,8 +93,8 @@ describe('Tooltip Component', () => {
|
||||||
</Tooltip>,
|
</Tooltip>,
|
||||||
);
|
);
|
||||||
|
|
||||||
const wrapper = container.querySelector('.relative.inline-block');
|
const wrapper = getWrapper(container);
|
||||||
fireEvent.mouseEnter(wrapper!);
|
fireEvent.mouseEnter(wrapper);
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
vi.advanceTimersByTime(10);
|
vi.advanceTimersByTime(10);
|
||||||
|
|
@ -103,8 +110,8 @@ describe('Tooltip Component', () => {
|
||||||
</Tooltip>,
|
</Tooltip>,
|
||||||
);
|
);
|
||||||
|
|
||||||
const wrapper = container.querySelector('.relative.inline-block');
|
const wrapper = getWrapper(container);
|
||||||
fireEvent.mouseEnter(wrapper!);
|
fireEvent.mouseEnter(wrapper);
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
vi.advanceTimersByTime(10);
|
vi.advanceTimersByTime(10);
|
||||||
|
|
@ -121,8 +128,8 @@ describe('Tooltip Component', () => {
|
||||||
</Tooltip>,
|
</Tooltip>,
|
||||||
);
|
);
|
||||||
|
|
||||||
const wrapper = container.querySelector('.relative.inline-block');
|
const wrapper = getWrapper(container);
|
||||||
fireEvent.mouseEnter(wrapper!);
|
fireEvent.mouseEnter(wrapper);
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
vi.advanceTimersByTime(10);
|
vi.advanceTimersByTime(10);
|
||||||
|
|
@ -139,8 +146,8 @@ describe('Tooltip Component', () => {
|
||||||
</Tooltip>,
|
</Tooltip>,
|
||||||
);
|
);
|
||||||
|
|
||||||
const wrapper = container.querySelector('.relative.inline-block');
|
const wrapper = getWrapper(container);
|
||||||
fireEvent.mouseEnter(wrapper!);
|
fireEvent.mouseEnter(wrapper);
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
vi.advanceTimersByTime(10);
|
vi.advanceTimersByTime(10);
|
||||||
|
|
@ -157,8 +164,8 @@ describe('Tooltip Component', () => {
|
||||||
</Tooltip>,
|
</Tooltip>,
|
||||||
);
|
);
|
||||||
|
|
||||||
const wrapper = container.querySelector('.relative.inline-block');
|
const wrapper = getWrapper(container);
|
||||||
fireEvent.mouseEnter(wrapper!);
|
fireEvent.mouseEnter(wrapper);
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
vi.advanceTimersByTime(10);
|
vi.advanceTimersByTime(10);
|
||||||
|
|
@ -192,8 +199,8 @@ describe('Tooltip Component', () => {
|
||||||
</Tooltip>,
|
</Tooltip>,
|
||||||
);
|
);
|
||||||
|
|
||||||
const wrapper = container.querySelector('.relative.inline-block');
|
const wrapper = getWrapper(container);
|
||||||
fireEvent.mouseEnter(wrapper!);
|
fireEvent.mouseEnter(wrapper);
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
vi.advanceTimersByTime(10);
|
vi.advanceTimersByTime(10);
|
||||||
|
|
@ -209,8 +216,8 @@ describe('Tooltip Component', () => {
|
||||||
</Tooltip>,
|
</Tooltip>,
|
||||||
);
|
);
|
||||||
|
|
||||||
const wrapper = container.querySelector('.relative.inline-block');
|
const wrapper = getWrapper(container);
|
||||||
fireEvent.mouseEnter(wrapper!);
|
fireEvent.mouseEnter(wrapper);
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
vi.advanceTimersByTime(10);
|
vi.advanceTimersByTime(10);
|
||||||
|
|
@ -227,8 +234,8 @@ describe('Tooltip Component', () => {
|
||||||
</Tooltip>,
|
</Tooltip>,
|
||||||
);
|
);
|
||||||
|
|
||||||
const wrapper = container.querySelector('.relative.inline-block');
|
const wrapper = getWrapper(container);
|
||||||
fireEvent.mouseEnter(wrapper!);
|
fireEvent.mouseEnter(wrapper);
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
vi.advanceTimersByTime(10);
|
vi.advanceTimersByTime(10);
|
||||||
|
|
@ -245,14 +252,14 @@ describe('Tooltip Component', () => {
|
||||||
</Tooltip>,
|
</Tooltip>,
|
||||||
);
|
);
|
||||||
|
|
||||||
const wrapper = container.querySelector('.relative.inline-block');
|
const wrapper = getWrapper(container);
|
||||||
fireEvent.mouseEnter(wrapper!);
|
fireEvent.mouseEnter(wrapper);
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
vi.advanceTimersByTime(200);
|
vi.advanceTimersByTime(200);
|
||||||
});
|
});
|
||||||
|
|
||||||
fireEvent.mouseLeave(wrapper!);
|
fireEvent.mouseLeave(wrapper);
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
vi.advanceTimersByTime(200);
|
vi.advanceTimersByTime(200);
|
||||||
|
|
@ -362,8 +369,8 @@ describe('Tooltip Component', () => {
|
||||||
</Tooltip>,
|
</Tooltip>,
|
||||||
);
|
);
|
||||||
|
|
||||||
const wrapper = container.querySelector('.relative.inline-block');
|
const wrapper = getWrapper(container);
|
||||||
fireEvent.mouseEnter(wrapper!);
|
fireEvent.mouseEnter(wrapper);
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
vi.advanceTimersByTime(10);
|
vi.advanceTimersByTime(10);
|
||||||
});
|
});
|
||||||
|
|
@ -380,8 +387,8 @@ describe('Tooltip Component', () => {
|
||||||
</Tooltip>,
|
</Tooltip>,
|
||||||
);
|
);
|
||||||
|
|
||||||
const wrapper = container.querySelector('.relative.inline-block');
|
const wrapper = getWrapper(container);
|
||||||
fireEvent.mouseEnter(wrapper!);
|
fireEvent.mouseEnter(wrapper);
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
vi.advanceTimersByTime(10);
|
vi.advanceTimersByTime(10);
|
||||||
});
|
});
|
||||||
|
|
@ -423,8 +430,8 @@ describe('Tooltip Component', () => {
|
||||||
</Tooltip>,
|
</Tooltip>,
|
||||||
);
|
);
|
||||||
|
|
||||||
const wrapper = container.querySelector('.relative.inline-block');
|
const wrapper = getWrapper(container);
|
||||||
fireEvent.mouseEnter(wrapper!);
|
fireEvent.mouseEnter(wrapper);
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
vi.advanceTimersByTime(10);
|
vi.advanceTimersByTime(10);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
export type { VirtualizedListProps } from './virtualized-list/index';
|
export type { VirtualizedListProps } from './virtualized-list/index';
|
||||||
export {
|
export {
|
||||||
VirtualizedList,
|
VirtualizedList,
|
||||||
|
// eslint-disable-next-line react-refresh/only-export-components -- co-located hook re-exported from the virtualized-list module
|
||||||
useInfiniteScroll,
|
useInfiniteScroll,
|
||||||
|
// eslint-disable-next-line react-refresh/only-export-components -- co-located hook re-exported from the virtualized-list module
|
||||||
useScrollPosition,
|
useScrollPosition,
|
||||||
} from './virtualized-list/index';
|
} from './virtualized-list/index';
|
||||||
|
|
|
||||||
|
|
@ -56,8 +56,8 @@ export const VirtualizedList = React.forwardRef<
|
||||||
0,
|
0,
|
||||||
Math.floor(scrollTop / itemHeight) - overscan,
|
Math.floor(scrollTop / itemHeight) - overscan,
|
||||||
);
|
);
|
||||||
const endIndex = virtualItems[virtualItems.length - 1]!.index;
|
const last = virtualItems[virtualItems.length - 1];
|
||||||
onItemsRendered(startIndex, endIndex);
|
if (last) onItemsRendered(startIndex, last.index);
|
||||||
}
|
}
|
||||||
}, [onScroll, onItemsRendered, virtualItems, itemHeight, overscan]);
|
}, [onScroll, onItemsRendered, virtualItems, itemHeight, overscan]);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -42,7 +42,8 @@ export function useBatchUpload<T>(
|
||||||
|
|
||||||
const processNext = () => {
|
const processNext = () => {
|
||||||
while (active < MAX_PARALLEL && queue.length > 0) {
|
while (active < MAX_PARALLEL && queue.length > 0) {
|
||||||
const item = queue.shift()!;
|
const item = queue.shift();
|
||||||
|
if (!item) continue;
|
||||||
if (cancelRef.current.has(item.id)) continue;
|
if (cancelRef.current.has(item.id)) continue;
|
||||||
|
|
||||||
active++;
|
active++;
|
||||||
|
|
|
||||||
|
|
@ -1,2 +1,3 @@
|
||||||
export type { VisualizerSettings } from './audio-context';
|
export type { VisualizerSettings } from './audio-context';
|
||||||
|
// eslint-disable-next-line react-refresh/only-export-components -- re-export barrel; useAudio hook is tightly coupled to the AudioProvider context
|
||||||
export { useAudio, AudioProvider } from './audio-context';
|
export { useAudio, AudioProvider } from './audio-context';
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import { useAudioContextValue } from './useAudioContextValue';
|
||||||
|
|
||||||
const AudioContext = createContext<AudioContextType | undefined>(undefined);
|
const AudioContext = createContext<AudioContextType | undefined>(undefined);
|
||||||
|
|
||||||
|
// eslint-disable-next-line react-refresh/only-export-components -- co-located hook; tightly coupled to AudioContext defined in this file
|
||||||
export const useAudio = () => {
|
export const useAudio = () => {
|
||||||
const context = useContext(AudioContext);
|
const context = useContext(AudioContext);
|
||||||
if (!context) throw new Error('useAudio must be used within AudioProvider');
|
if (!context) throw new Error('useAudio must be used within AudioProvider');
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,6 @@ export default meta;
|
||||||
type Story = StoryObj<typeof meta>;
|
type Story = StoryObj<typeof meta>;
|
||||||
|
|
||||||
export const Loading: Story = {
|
export const Loading: Story = {
|
||||||
name: 'Loading',
|
|
||||||
render: () => <AnalyticsViewSkeleton />,
|
render: () => <AnalyticsViewSkeleton />,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -62,14 +62,16 @@ export function AnalyticsViewAudience({ data }: AnalyticsViewAudienceProps) {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{data.top_listening_times && data.top_listening_times.length > 0 && (
|
{data.top_listening_times && data.top_listening_times.length > 0 && (() => {
|
||||||
|
const times = data.top_listening_times;
|
||||||
|
return (
|
||||||
<div>
|
<div>
|
||||||
<p className="text-muted-foreground text-xs uppercase tracking-wider mb-3 flex items-center gap-1">
|
<p className="text-muted-foreground text-xs uppercase tracking-wider mb-3 flex items-center gap-1">
|
||||||
<Clock className="w-3 h-3" aria-hidden="true" /> Peak listening hours
|
<Clock className="w-3 h-3" aria-hidden="true" /> Peak listening hours
|
||||||
</p>
|
</p>
|
||||||
<div className="grid grid-cols-6 gap-1" role="list" aria-label="Listening hours distribution">
|
<div className="grid grid-cols-6 gap-1" role="list" aria-label="Listening hours distribution">
|
||||||
{data.top_listening_times.map((slot) => {
|
{times.map((slot) => {
|
||||||
const maxPlays = Math.max(...data.top_listening_times!.map((s) => s.play_count));
|
const maxPlays = Math.max(...times.map((s) => s.play_count));
|
||||||
const intensity = maxPlays > 0 ? slot.play_count / maxPlays : 0;
|
const intensity = maxPlays > 0 ? slot.play_count / maxPlays : 0;
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|
@ -90,7 +92,8 @@ export function AnalyticsViewAudience({ data }: AnalyticsViewAudienceProps) {
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
);
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
|
|
|
||||||
|
|
@ -74,14 +74,16 @@ export function AnalyticsViewMarketplace({ data }: Props) {
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Revenue timeline */}
|
{/* Revenue timeline */}
|
||||||
{data.revenue_timeline && data.revenue_timeline.length > 0 && (
|
{data.revenue_timeline && data.revenue_timeline.length > 0 && (() => {
|
||||||
|
const timeline = data.revenue_timeline;
|
||||||
|
const maxRevenue = Math.max(...timeline.map((d) => d.revenue), 1);
|
||||||
|
return (
|
||||||
<div className="rounded-xl border border-white/5 bg-card p-6">
|
<div className="rounded-xl border border-white/5 bg-card p-6">
|
||||||
<h3 className="text-sm font-medium text-muted-foreground uppercase tracking-widest mb-4">
|
<h3 className="text-sm font-medium text-muted-foreground uppercase tracking-widest mb-4">
|
||||||
Revenue Timeline
|
Revenue Timeline
|
||||||
</h3>
|
</h3>
|
||||||
<div className="flex gap-1 items-end h-32" role="img" aria-label="Revenue timeline chart">
|
<div className="flex gap-1 items-end h-32" role="img" aria-label="Revenue timeline chart">
|
||||||
{data.revenue_timeline.map((day, i) => {
|
{timeline.map((day, i) => {
|
||||||
const maxRevenue = Math.max(...data.revenue_timeline!.map((d) => d.revenue), 1);
|
|
||||||
const height = (day.revenue / maxRevenue) * 100;
|
const height = (day.revenue / maxRevenue) * 100;
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|
@ -102,7 +104,8 @@ export function AnalyticsViewMarketplace({ data }: Props) {
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
);
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -45,7 +45,10 @@ export function AnalyticsViewSales({ data, onExportSales }: AnalyticsViewSalesPr
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Revenue over time */}
|
{/* Revenue over time */}
|
||||||
{data.revenue_by_period && data.revenue_by_period.length > 0 && (
|
{data.revenue_by_period && data.revenue_by_period.length > 0 && (() => {
|
||||||
|
const periods = data.revenue_by_period;
|
||||||
|
const maxRev = Math.max(...periods.map((p) => p.revenue));
|
||||||
|
return (
|
||||||
<Card variant="glass" className="p-6 bg-black/40 border-white/5">
|
<Card variant="glass" className="p-6 bg-black/40 border-white/5">
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<h3 className="font-bold text-foreground text-sm uppercase tracking-widest flex items-center gap-2">
|
<h3 className="font-bold text-foreground text-sm uppercase tracking-widest flex items-center gap-2">
|
||||||
|
|
@ -62,8 +65,7 @@ export function AnalyticsViewSales({ data, onExportSales }: AnalyticsViewSalesPr
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2" role="list" aria-label="Daily revenue">
|
<div className="space-y-2" role="list" aria-label="Daily revenue">
|
||||||
{data.revenue_by_period.slice(-14).map((period) => {
|
{periods.slice(-14).map((period) => {
|
||||||
const maxRev = Math.max(...data.revenue_by_period!.map((p) => p.revenue));
|
|
||||||
const width = maxRev > 0 ? (period.revenue / maxRev) * 100 : 0;
|
const width = maxRev > 0 ? (period.revenue / maxRev) * 100 : 0;
|
||||||
return (
|
return (
|
||||||
<div key={period.date} className="flex items-center gap-3 text-xs" role="listitem">
|
<div key={period.date} className="flex items-center gap-3 text-xs" role="listitem">
|
||||||
|
|
@ -82,7 +84,8 @@ export function AnalyticsViewSales({ data, onExportSales }: AnalyticsViewSalesPr
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
);
|
||||||
|
})()}
|
||||||
|
|
||||||
{/* Top selling tracks */}
|
{/* Top selling tracks */}
|
||||||
{hasRevenue && data.top_selling_tracks && data.top_selling_tracks.length > 0 && (
|
{hasRevenue && data.top_selling_tracks && data.top_selling_tracks.length > 0 && (
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,6 @@ export const Default: Story = { name: 'Par défaut' };
|
||||||
|
|
||||||
/** Skeleton pendant chargement (layout aligné). */
|
/** Skeleton pendant chargement (layout aligné). */
|
||||||
export const Loading: Story = {
|
export const Loading: Story = {
|
||||||
name: 'Loading',
|
|
||||||
render: () => <RegisterPageSkeleton />,
|
render: () => <RegisterPageSkeleton />,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -37,13 +37,11 @@ export const Default: Story = {
|
||||||
|
|
||||||
/** État de chargement (skeleton). */
|
/** État de chargement (skeleton). */
|
||||||
export const Loading: Story = {
|
export const Loading: Story = {
|
||||||
name: 'Loading',
|
|
||||||
render: () => <SessionsPageSkeleton />,
|
render: () => <SessionsPageSkeleton />,
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Liste vide (override via MSW ou props). */
|
/** Liste vide (override via MSW ou props). */
|
||||||
export const Empty: Story = {
|
export const Empty: Story = {
|
||||||
name: 'Empty',
|
|
||||||
args: {
|
args: {
|
||||||
initialSessions: [],
|
initialSessions: [],
|
||||||
},
|
},
|
||||||
|
|
@ -51,7 +49,6 @@ export const Empty: Story = {
|
||||||
|
|
||||||
/** Erreur chargement sessions */
|
/** Erreur chargement sessions */
|
||||||
export const Error: Story = {
|
export const Error: Story = {
|
||||||
name: 'Error',
|
|
||||||
parameters: {
|
parameters: {
|
||||||
msw: {
|
msw: {
|
||||||
handlers: [
|
handlers: [
|
||||||
|
|
|
||||||
|
|
@ -67,7 +67,9 @@ describe('ChatInput Component', () => {
|
||||||
render(<ChatInput />);
|
render(<ChatInput />);
|
||||||
const input = screen.getByPlaceholderText(/Broadcast message/);
|
const input = screen.getByPlaceholderText(/Broadcast message/);
|
||||||
fireEvent.change(input, { target: { value: 'Hello' } });
|
fireEvent.change(input, { target: { value: 'Hello' } });
|
||||||
fireEvent.submit(input.closest('form')!);
|
const form = input.closest('form');
|
||||||
|
if (!form) throw new Error('form not found');
|
||||||
|
fireEvent.submit(form);
|
||||||
expect(mockSendMessage).toHaveBeenCalledWith('Hello', undefined, null);
|
expect(mockSendMessage).toHaveBeenCalledWith('Hello', undefined, null);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -75,14 +77,17 @@ describe('ChatInput Component', () => {
|
||||||
render(<ChatInput />);
|
render(<ChatInput />);
|
||||||
const input = screen.getByPlaceholderText(/Broadcast message/);
|
const input = screen.getByPlaceholderText(/Broadcast message/);
|
||||||
fireEvent.change(input, { target: { value: 'Hello' } });
|
fireEvent.change(input, { target: { value: 'Hello' } });
|
||||||
fireEvent.submit(input.closest('form')!);
|
const form = input.closest('form');
|
||||||
|
if (!form) throw new Error('form not found');
|
||||||
|
fireEvent.submit(form);
|
||||||
expect(input).toHaveValue('');
|
expect(input).toHaveValue('');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not submit when input is empty and no attachments', () => {
|
it('does not submit when input is empty and no attachments', () => {
|
||||||
render(<ChatInput />);
|
render(<ChatInput />);
|
||||||
const form = screen.getByPlaceholderText(/Broadcast message/).closest('form');
|
const form = screen.getByPlaceholderText(/Broadcast message/).closest('form');
|
||||||
fireEvent.submit(form!);
|
if (!form) throw new Error('form not found');
|
||||||
|
fireEvent.submit(form);
|
||||||
expect(mockSendMessage).not.toHaveBeenCalled();
|
expect(mockSendMessage).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,5 @@ export const ProductionRoom: Story = {
|
||||||
};
|
};
|
||||||
|
|
||||||
export const Loading: Story = {
|
export const Loading: Story = {
|
||||||
name: 'Loading',
|
|
||||||
render: () => <ChatInterfaceSkeleton />,
|
render: () => <ChatInterfaceSkeleton />,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -90,7 +90,8 @@ export function MentionAutocomplete({
|
||||||
setSelectedIndex((i) => (i - 1 + filtered.length) % filtered.length);
|
setSelectedIndex((i) => (i - 1 + filtered.length) % filtered.length);
|
||||||
} else if (e.key === 'Enter' && show) {
|
} else if (e.key === 'Enter' && show) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
handleSelect(filtered[selectedIndex]!.username);
|
const target = filtered[selectedIndex];
|
||||||
|
if (target) handleSelect(target.username);
|
||||||
} else if (e.key === 'Escape') {
|
} else if (e.key === 'Escape') {
|
||||||
setShow(false);
|
setShow(false);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
export {
|
export {
|
||||||
VirtualizedChatMessages,
|
VirtualizedChatMessages,
|
||||||
|
// eslint-disable-next-line react-refresh/only-export-components -- co-located hook re-exported from the virtualized-chat-messages module
|
||||||
useChatMessages,
|
useChatMessages,
|
||||||
VirtualizedChatMessagesSkeleton,
|
VirtualizedChatMessagesSkeleton,
|
||||||
} from './virtualized-chat-messages';
|
} from './virtualized-chat-messages';
|
||||||
|
|
|
||||||
|
|
@ -183,10 +183,9 @@ export const useChatStore = create<ChatState>()(
|
||||||
}),
|
}),
|
||||||
addMessage: (message) =>
|
addMessage: (message) =>
|
||||||
set((state) => {
|
set((state) => {
|
||||||
if (!state.messages[message.conversation_id]) {
|
const list = state.messages[message.conversation_id] ?? [];
|
||||||
state.messages[message.conversation_id] = [];
|
list.push(message);
|
||||||
}
|
state.messages[message.conversation_id] = list;
|
||||||
state.messages[message.conversation_id]!.push(message);
|
|
||||||
}),
|
}),
|
||||||
loadMessages: (conversationId, newMessages) =>
|
loadMessages: (conversationId, newMessages) =>
|
||||||
set((state) => {
|
set((state) => {
|
||||||
|
|
@ -224,21 +223,19 @@ export const useChatStore = create<ChatState>()(
|
||||||
if (messages) {
|
if (messages) {
|
||||||
const message = messages.find((m) => m.id === messageId);
|
const message = messages.find((m) => m.id === messageId);
|
||||||
if (message) {
|
if (message) {
|
||||||
if (!message.reactions) message.reactions = {};
|
const reactions = message.reactions ?? {};
|
||||||
|
message.reactions = reactions;
|
||||||
// Remove existing reaction from this user if any
|
// Remove existing reaction from this user if any
|
||||||
Object.keys(message.reactions).forEach((e) => {
|
Object.keys(reactions).forEach((e) => {
|
||||||
const users = message.reactions![e];
|
const users = reactions[e];
|
||||||
if (!users) return;
|
if (!users) return;
|
||||||
message.reactions![e] = users.filter(
|
reactions[e] = users.filter((id) => id !== userId);
|
||||||
(id) => id !== userId,
|
if (reactions[e]?.length === 0) delete reactions[e];
|
||||||
);
|
|
||||||
if (message.reactions![e]?.length === 0)
|
|
||||||
delete message.reactions![e];
|
|
||||||
});
|
});
|
||||||
// Add new reaction
|
// Add new reaction
|
||||||
if (!message.reactions[emoji]) message.reactions[emoji] = [];
|
if (!reactions[emoji]) reactions[emoji] = [];
|
||||||
if (!message.reactions[emoji].includes(userId)) {
|
if (!reactions[emoji].includes(userId)) {
|
||||||
message.reactions[emoji].push(userId);
|
reactions[emoji].push(userId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -249,12 +246,13 @@ export const useChatStore = create<ChatState>()(
|
||||||
if (messages) {
|
if (messages) {
|
||||||
const message = messages.find((m) => m.id === messageId);
|
const message = messages.find((m) => m.id === messageId);
|
||||||
if (message && message.reactions) {
|
if (message && message.reactions) {
|
||||||
const emojisToRemove = emoji ? [emoji] : Object.keys(message.reactions);
|
const reactions = message.reactions;
|
||||||
|
const emojisToRemove = emoji ? [emoji] : Object.keys(reactions);
|
||||||
emojisToRemove.forEach((e) => {
|
emojisToRemove.forEach((e) => {
|
||||||
const users = message.reactions![e];
|
const users = reactions[e];
|
||||||
if (!users) return;
|
if (!users) return;
|
||||||
message.reactions![e] = users.filter((id) => id !== userId);
|
reactions[e] = users.filter((id) => id !== userId);
|
||||||
if (message.reactions![e]?.length === 0) delete message.reactions![e];
|
if (reactions[e]?.length === 0) delete reactions[e];
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,10 @@ export function CheckoutCompletePage() {
|
||||||
|
|
||||||
const { data: order, isLoading, error } = useQuery({
|
const { data: order, isLoading, error } = useQuery({
|
||||||
queryKey: ['order', orderId],
|
queryKey: ['order', orderId],
|
||||||
queryFn: () => marketplaceService.getOrder(orderId!),
|
queryFn: () => {
|
||||||
|
if (!orderId) throw new Error('orderId required');
|
||||||
|
return marketplaceService.getOrder(orderId);
|
||||||
|
},
|
||||||
enabled: !!orderId,
|
enabled: !!orderId,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -70,11 +70,13 @@ export function DiscoverPage() {
|
||||||
refetch: refetchGenre,
|
refetch: refetchGenre,
|
||||||
} = useInfiniteQuery({
|
} = useInfiniteQuery({
|
||||||
queryKey: ['discoverGenre', browseGenre],
|
queryKey: ['discoverGenre', browseGenre],
|
||||||
queryFn: ({ pageParam }) =>
|
queryFn: ({ pageParam }) => {
|
||||||
discoverService.getTracksByGenre(browseGenre!, {
|
if (!browseGenre) throw new Error('browseGenre required');
|
||||||
|
return discoverService.getTracksByGenre(browseGenre, {
|
||||||
cursor: pageParam,
|
cursor: pageParam,
|
||||||
limit: 20,
|
limit: 20,
|
||||||
}),
|
});
|
||||||
|
},
|
||||||
getNextPageParam: (last) => last.next_cursor ?? undefined,
|
getNextPageParam: (last) => last.next_cursor ?? undefined,
|
||||||
initialPageParam: undefined as string | undefined,
|
initialPageParam: undefined as string | undefined,
|
||||||
enabled: !!browseGenre,
|
enabled: !!browseGenre,
|
||||||
|
|
@ -90,11 +92,13 @@ export function DiscoverPage() {
|
||||||
refetch: refetchTag,
|
refetch: refetchTag,
|
||||||
} = useInfiniteQuery({
|
} = useInfiniteQuery({
|
||||||
queryKey: ['discoverTag', browseTag],
|
queryKey: ['discoverTag', browseTag],
|
||||||
queryFn: ({ pageParam }) =>
|
queryFn: ({ pageParam }) => {
|
||||||
discoverService.getTracksByTag(browseTag!, {
|
if (!browseTag) throw new Error('browseTag required');
|
||||||
|
return discoverService.getTracksByTag(browseTag, {
|
||||||
cursor: pageParam,
|
cursor: pageParam,
|
||||||
limit: 20,
|
limit: 20,
|
||||||
}),
|
});
|
||||||
|
},
|
||||||
getNextPageParam: (last) => last.next_cursor ?? undefined,
|
getNextPageParam: (last) => last.next_cursor ?? undefined,
|
||||||
initialPageParam: undefined as string | undefined,
|
initialPageParam: undefined as string | undefined,
|
||||||
enabled: !!browseTag,
|
enabled: !!browseTag,
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,7 @@ export const WithImages: Story = {
|
||||||
};
|
};
|
||||||
|
|
||||||
export const SingleImage: Story = {
|
export const SingleImage: Story = {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- mock fixture: mockImages declared above with entries
|
||||||
args: { images: [mockImages[0]!] },
|
args: { images: [mockImages[0]!] },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -49,7 +49,10 @@ export function GearImageGallery({
|
||||||
{onRemoveImage && sorted[selected] && (
|
{onRemoveImage && sorted[selected] && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onRemoveImage(sorted[selected]!.id)}
|
onClick={() => {
|
||||||
|
const img = sorted[selected];
|
||||||
|
if (img) onRemoveImage(img.id);
|
||||||
|
}}
|
||||||
className="absolute top-2 right-2 p-1 bg-black/60 rounded-full text-white hover:bg-black/80 transition-colors"
|
className="absolute top-2 right-2 p-1 bg-black/60 rounded-full text-white hover:bg-black/80 transition-colors"
|
||||||
aria-label="Remove image"
|
aria-label="Remove image"
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ export const Default: Story = {
|
||||||
export const WarrantyExpiringSoon: Story = {
|
export const WarrantyExpiringSoon: Story = {
|
||||||
args: {
|
args: {
|
||||||
item: {
|
item: {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- mock fixture: mockGearInventory has entries
|
||||||
...mockGearInventory[0]!,
|
...mockGearInventory[0]!,
|
||||||
warrantyExpire: new Date(Date.now() + 15 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10),
|
warrantyExpire: new Date(Date.now() + 15 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10),
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,6 @@ export default meta;
|
||||||
type Story = StoryObj<typeof meta>;
|
type Story = StoryObj<typeof meta>;
|
||||||
|
|
||||||
export const Loading: Story = {
|
export const Loading: Story = {
|
||||||
name: 'Loading',
|
|
||||||
render: () => <LiveViewSkeleton />,
|
render: () => <LiveViewSkeleton />,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -26,13 +26,11 @@ export const Default: Story = { name: 'Par défaut' };
|
||||||
|
|
||||||
/** Loading state — skeleton to avoid layout shift */
|
/** Loading state — skeleton to avoid layout shift */
|
||||||
export const Loading: Story = {
|
export const Loading: Story = {
|
||||||
name: 'Loading',
|
|
||||||
render: () => <NotificationsPageSkeleton />,
|
render: () => <NotificationsPageSkeleton />,
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Empty list */
|
/** Empty list */
|
||||||
export const Empty: Story = {
|
export const Empty: Story = {
|
||||||
name: 'Empty',
|
|
||||||
parameters: {
|
parameters: {
|
||||||
msw: {
|
msw: {
|
||||||
handlers: [
|
handlers: [
|
||||||
|
|
@ -53,7 +51,6 @@ export const Empty: Story = {
|
||||||
|
|
||||||
/** Error loading notifications */
|
/** Error loading notifications */
|
||||||
export const Error: Story = {
|
export const Error: Story = {
|
||||||
name: 'Error',
|
|
||||||
parameters: {
|
parameters: {
|
||||||
msw: {
|
msw: {
|
||||||
handlers: [
|
handlers: [
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,7 @@ function groupNotificationsByDate(
|
||||||
// Return in stable order defined by DATE_GROUP_ORDER
|
// Return in stable order defined by DATE_GROUP_ORDER
|
||||||
return DATE_GROUP_ORDER.filter((g) => groups.has(g)).map((g) => [
|
return DATE_GROUP_ORDER.filter((g) => groups.has(g)).map((g) => [
|
||||||
g,
|
g,
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- guarded by groups.has(g) in the filter above
|
||||||
groups.get(g)!,
|
groups.get(g)!,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -83,7 +83,10 @@ export function PlayerQueue({ isOpen, onClose, onPlay }: PlayerQueueProps) {
|
||||||
// Poll session when active (v0.203 Lot D1)
|
// Poll session when active (v0.203 Lot D1)
|
||||||
const { data: sessionData } = useQuery({
|
const { data: sessionData } = useQuery({
|
||||||
queryKey: ['queue-session', activeSessionToken],
|
queryKey: ['queue-session', activeSessionToken],
|
||||||
queryFn: () => queueApi.getQueueSession(activeSessionToken!),
|
queryFn: () => {
|
||||||
|
if (!activeSessionToken) throw new Error('activeSessionToken required');
|
||||||
|
return queueApi.getQueueSession(activeSessionToken);
|
||||||
|
},
|
||||||
enabled: !!activeSessionToken && isOpen,
|
enabled: !!activeSessionToken && isOpen,
|
||||||
refetchInterval: 8000,
|
refetchInterval: 8000,
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -55,9 +55,12 @@ export function shuffleTracks(tracks: Track[]): Track[] {
|
||||||
const shuffled = [...tracks];
|
const shuffled = [...tracks];
|
||||||
for (let i = shuffled.length - 1; i > 0; i--) {
|
for (let i = shuffled.length - 1; i > 0; i--) {
|
||||||
const j = Math.floor(Math.random() * (i + 1));
|
const j = Math.floor(Math.random() * (i + 1));
|
||||||
const temp = shuffled[i]!;
|
const a = shuffled[i];
|
||||||
shuffled[i] = shuffled[j]!;
|
const b = shuffled[j];
|
||||||
shuffled[j] = temp;
|
if (a !== undefined && b !== undefined) {
|
||||||
|
shuffled[i] = b;
|
||||||
|
shuffled[j] = a;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return shuffled;
|
return shuffled;
|
||||||
}
|
}
|
||||||
|
|
@ -358,7 +361,7 @@ export class AudioPlayerService {
|
||||||
step++;
|
step++;
|
||||||
if (step >= steps) {
|
if (step >= steps) {
|
||||||
clearInterval(id);
|
clearInterval(id);
|
||||||
this.audioElement!.volume = 0;
|
if (this.audioElement) this.audioElement.volume = 0;
|
||||||
onComplete();
|
onComplete();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
export {
|
export {
|
||||||
PlaylistAnalytics,
|
PlaylistAnalytics,
|
||||||
PlaylistAnalyticsSkeleton,
|
PlaylistAnalyticsSkeleton,
|
||||||
|
// eslint-disable-next-line react-refresh/only-export-components -- co-located hook re-exported from the playlist-analytics module
|
||||||
usePlaylistAnalytics,
|
usePlaylistAnalytics,
|
||||||
} from './playlist-analytics';
|
} from './playlist-analytics';
|
||||||
export type { PlaylistAnalyticsData, PlaylistAnalyticsProps } from './playlist-analytics';
|
export type { PlaylistAnalyticsData, PlaylistAnalyticsProps } from './playlist-analytics';
|
||||||
|
|
|
||||||
|
|
@ -64,6 +64,7 @@ export const Default: Story = {
|
||||||
export const SingleSelection: Story = {
|
export const SingleSelection: Story = {
|
||||||
name: 'Une playlist sélectionnée',
|
name: 'Une playlist sélectionnée',
|
||||||
args: {
|
args: {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- mock fixture: mockPlaylists has entries
|
||||||
selectedPlaylists: [mockPlaylists[0]!],
|
selectedPlaylists: [mockPlaylists[0]!],
|
||||||
onSelectionClear: () => {},
|
onSelectionClear: () => {},
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -90,6 +90,7 @@ export class PlaylistErrorBoundary extends Component<Props, State> {
|
||||||
* T0502: Create Playlist Error Handling Improvements
|
* T0502: Create Playlist Error Handling Improvements
|
||||||
* Uses ErrorDisplay component for consistent error presentation
|
* Uses ErrorDisplay component for consistent error presentation
|
||||||
*/
|
*/
|
||||||
|
// eslint-disable-next-line react-refresh/only-export-components -- private fallback component co-located with the error-boundary class; refactor would require duplicating its onReset contract
|
||||||
function ErrorFallback({
|
function ErrorFallback({
|
||||||
error,
|
error,
|
||||||
onReset,
|
onReset,
|
||||||
|
|
|
||||||
|
|
@ -2,5 +2,6 @@
|
||||||
* Re-export from playlist-list module for backward compatibility.
|
* Re-export from playlist-list module for backward compatibility.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
// eslint-disable-next-line react-refresh/only-export-components -- backward-compat re-export barrel; named + default exports point to the same component
|
||||||
export { PlaylistList, default } from './playlist-list';
|
export { PlaylistList, default } from './playlist-list';
|
||||||
export type { PlaylistListProps, SortField, SortOrder } from './playlist-list';
|
export type { PlaylistListProps, SortField, SortOrder } from './playlist-list';
|
||||||
|
|
|
||||||
|
|
@ -199,12 +199,15 @@ function getToastConfig(notification: PlaylistNotification): {
|
||||||
description: notification.content,
|
description: notification.content,
|
||||||
options: {
|
options: {
|
||||||
action: notification.link
|
action: notification.link
|
||||||
? {
|
? (() => {
|
||||||
label: 'Voir',
|
const link = notification.link;
|
||||||
onClick: () => {
|
return {
|
||||||
safeNavigate(notification.link!);
|
label: 'Voir',
|
||||||
},
|
onClick: () => {
|
||||||
}
|
if (link) safeNavigate(link);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
})()
|
||||||
: undefined,
|
: undefined,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
@ -215,12 +218,15 @@ function getToastConfig(notification: PlaylistNotification): {
|
||||||
description: notification.content,
|
description: notification.content,
|
||||||
options: {
|
options: {
|
||||||
action: notification.link
|
action: notification.link
|
||||||
? {
|
? (() => {
|
||||||
label: 'Voir',
|
const link = notification.link;
|
||||||
onClick: () => {
|
return {
|
||||||
safeNavigate(notification.link!);
|
label: 'Voir',
|
||||||
},
|
onClick: () => {
|
||||||
}
|
if (link) safeNavigate(link);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
})()
|
||||||
: undefined,
|
: undefined,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
@ -231,12 +237,15 @@ function getToastConfig(notification: PlaylistNotification): {
|
||||||
description: notification.content,
|
description: notification.content,
|
||||||
options: {
|
options: {
|
||||||
action: notification.link
|
action: notification.link
|
||||||
? {
|
? (() => {
|
||||||
label: 'Voir',
|
const link = notification.link;
|
||||||
onClick: () => {
|
return {
|
||||||
safeNavigate(notification.link!);
|
label: 'Voir',
|
||||||
},
|
onClick: () => {
|
||||||
}
|
if (link) safeNavigate(link);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
})()
|
||||||
: undefined,
|
: undefined,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
@ -247,12 +256,15 @@ function getToastConfig(notification: PlaylistNotification): {
|
||||||
description: notification.content,
|
description: notification.content,
|
||||||
options: {
|
options: {
|
||||||
action: notification.link
|
action: notification.link
|
||||||
? {
|
? (() => {
|
||||||
label: 'Voir',
|
const link = notification.link;
|
||||||
onClick: () => {
|
return {
|
||||||
safeNavigate(notification.link!);
|
label: 'Voir',
|
||||||
},
|
onClick: () => {
|
||||||
}
|
if (link) safeNavigate(link);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
})()
|
||||||
: undefined,
|
: undefined,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -104,6 +104,7 @@ const defaultHookReturn = {
|
||||||
},
|
},
|
||||||
collaborators: [],
|
collaborators: [],
|
||||||
tracks: [mockTrack],
|
tracks: [mockTrack],
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- mock fixture: mockPlaylist.tracks declared above
|
||||||
playlistTracks: mockPlaylist.tracks!,
|
playlistTracks: mockPlaylist.tracks!,
|
||||||
isAddTrackModalOpen: false,
|
isAddTrackModalOpen: false,
|
||||||
setIsAddTrackModalOpen: vi.fn(),
|
setIsAddTrackModalOpen: vi.fn(),
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,10 @@ import { getPresence } from '../services/presenceService';
|
||||||
export function usePresence(userId: string | null | undefined) {
|
export function usePresence(userId: string | null | undefined) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['presence', userId],
|
queryKey: ['presence', userId],
|
||||||
queryFn: () => getPresence(userId!),
|
queryFn: () => {
|
||||||
|
if (!userId) throw new Error('userId required');
|
||||||
|
return getPresence(userId);
|
||||||
|
},
|
||||||
enabled: !!userId,
|
enabled: !!userId,
|
||||||
staleTime: 30_000, // 30s - presence doesn't change that often
|
staleTime: 30_000, // 30s - presence doesn't change that often
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -153,11 +153,12 @@ describe('AvatarUpload', () => {
|
||||||
|
|
||||||
const dropZone = container.querySelector('.rounded-full');
|
const dropZone = container.querySelector('.rounded-full');
|
||||||
expect(dropZone).toBeInTheDocument();
|
expect(dropZone).toBeInTheDocument();
|
||||||
|
if (!dropZone) throw new Error('dropZone not found');
|
||||||
|
|
||||||
const validFile = new File(['content'], 'test.jpg', { type: 'image/jpeg' });
|
const validFile = new File(['content'], 'test.jpg', { type: 'image/jpeg' });
|
||||||
const dataTransfer = { files: [validFile] };
|
const dataTransfer = { files: [validFile] };
|
||||||
|
|
||||||
fireEvent.drop(dropZone!, {
|
fireEvent.drop(dropZone, {
|
||||||
dataTransfer,
|
dataTransfer,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,7 @@ export function ProfileSocialLinksSection({ socialLinks }: ProfileSocialLinksSec
|
||||||
key,
|
key,
|
||||||
label,
|
label,
|
||||||
Icon,
|
Icon,
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- guarded by socialLinks?.[key] != null in filter above
|
||||||
href: String(socialLinks![key]),
|
href: String(socialLinks![key]),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -39,7 +39,10 @@ export function useUserProfilePage() {
|
||||||
|
|
||||||
const { data: postsData, isLoading: isPostsLoading } = useQuery({
|
const { data: postsData, isLoading: isPostsLoading } = useQuery({
|
||||||
queryKey: ['userPosts', profile?.id],
|
queryKey: ['userPosts', profile?.id],
|
||||||
queryFn: () => socialService.getPostsByUser(profile!.id, 1, profile ?? undefined),
|
queryFn: () => {
|
||||||
|
if (!profile?.id) throw new Error('profile.id required');
|
||||||
|
return socialService.getPostsByUser(profile.id, 1, profile);
|
||||||
|
},
|
||||||
enabled: !!profile?.id,
|
enabled: !!profile?.id,
|
||||||
retry: false,
|
retry: false,
|
||||||
});
|
});
|
||||||
|
|
@ -53,7 +56,10 @@ export function useUserProfilePage() {
|
||||||
|
|
||||||
const { data: repostsData } = useQuery({
|
const { data: repostsData } = useQuery({
|
||||||
queryKey: ['userReposts', profile?.id],
|
queryKey: ['userReposts', profile?.id],
|
||||||
queryFn: () => usersApi.getUserReposts(profile!.id, 20, 0),
|
queryFn: () => {
|
||||||
|
if (!profile?.id) throw new Error('profile.id required');
|
||||||
|
return usersApi.getUserReposts(profile.id, 20, 0);
|
||||||
|
},
|
||||||
enabled: !!profile?.id,
|
enabled: !!profile?.id,
|
||||||
retry: false,
|
retry: false,
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,6 @@ export default meta;
|
||||||
type Story = StoryObj<typeof meta>;
|
type Story = StoryObj<typeof meta>;
|
||||||
|
|
||||||
export const Loading: Story = {
|
export const Loading: Story = {
|
||||||
name: 'Loading',
|
|
||||||
render: () => <PurchasesViewSkeleton />,
|
render: () => <PurchasesViewSkeleton />,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -94,7 +94,9 @@ describe('AssignRoleModal', () => {
|
||||||
// Open the Select dropdown to see role options
|
// Open the Select dropdown to see role options
|
||||||
const dialog = screen.getByRole('dialog');
|
const dialog = screen.getByRole('dialog');
|
||||||
const selectTriggers = within(dialog).getAllByRole('button', { name: /select a role/i });
|
const selectTriggers = within(dialog).getAllByRole('button', { name: /select a role/i });
|
||||||
await user.click(selectTriggers[0]!);
|
const trigger = selectTriggers[0];
|
||||||
|
if (!trigger) throw new Error('select trigger not found');
|
||||||
|
await user.click(trigger);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByRole('option', { name: /administrator/i })).toBeInTheDocument();
|
expect(screen.getByRole('option', { name: /administrator/i })).toBeInTheDocument();
|
||||||
|
|
@ -121,7 +123,9 @@ describe('AssignRoleModal', () => {
|
||||||
|
|
||||||
const dialog = screen.getByRole('dialog');
|
const dialog = screen.getByRole('dialog');
|
||||||
const selectTriggers = within(dialog).getAllByRole('button', { name: /select a role/i });
|
const selectTriggers = within(dialog).getAllByRole('button', { name: /select a role/i });
|
||||||
await user.click(selectTriggers[0]!);
|
const trigger = selectTriggers[0];
|
||||||
|
if (!trigger) throw new Error('select trigger not found');
|
||||||
|
await user.click(trigger);
|
||||||
const option = await screen.findByRole('option', { name: /administrator/i });
|
const option = await screen.findByRole('option', { name: /administrator/i });
|
||||||
await user.click(option);
|
await user.click(option);
|
||||||
|
|
||||||
|
|
@ -178,7 +182,9 @@ describe('AssignRoleModal', () => {
|
||||||
|
|
||||||
const dialog = screen.getByRole('dialog');
|
const dialog = screen.getByRole('dialog');
|
||||||
const selectTriggers = within(dialog).getAllByRole('button', { name: /select a role/i });
|
const selectTriggers = within(dialog).getAllByRole('button', { name: /select a role/i });
|
||||||
await user.click(selectTriggers[0]!);
|
const trigger = selectTriggers[0];
|
||||||
|
if (!trigger) throw new Error('select trigger not found');
|
||||||
|
await user.click(trigger);
|
||||||
const option = await screen.findByRole('option', { name: /administrator/i });
|
const option = await screen.findByRole('option', { name: /administrator/i });
|
||||||
await user.click(option);
|
await user.click(option);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -26,13 +26,11 @@ export const Default: Story = { name: 'Par défaut' };
|
||||||
|
|
||||||
/** Loading state — skeleton to avoid layout shift. */
|
/** Loading state — skeleton to avoid layout shift. */
|
||||||
export const Loading: Story = {
|
export const Loading: Story = {
|
||||||
name: 'Loading',
|
|
||||||
render: () => <SearchPageSkeleton />,
|
render: () => <SearchPageSkeleton />,
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Empty results (no tracks, artists, playlists). */
|
/** Empty results (no tracks, artists, playlists). */
|
||||||
export const Empty: Story = {
|
export const Empty: Story = {
|
||||||
name: 'Empty',
|
|
||||||
parameters: {
|
parameters: {
|
||||||
msw: {
|
msw: {
|
||||||
handlers: [
|
handlers: [
|
||||||
|
|
@ -49,7 +47,6 @@ export const Empty: Story = {
|
||||||
|
|
||||||
/** Error loading search. */
|
/** Error loading search. */
|
||||||
export const Error: Story = {
|
export const Error: Story = {
|
||||||
name: 'Error',
|
|
||||||
parameters: {
|
parameters: {
|
||||||
msw: {
|
msw: {
|
||||||
handlers: [
|
handlers: [
|
||||||
|
|
|
||||||
|
|
@ -2,4 +2,5 @@
|
||||||
* Re-export from account-settings module for backward compatibility.
|
* Re-export from account-settings module for backward compatibility.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
// eslint-disable-next-line react-refresh/only-export-components -- backward-compat re-export barrel; named + default exports point to the same component
|
||||||
export { AccountSettings, AccountSettingsSkeleton, default } from './account-settings';
|
export { AccountSettings, AccountSettingsSkeleton, default } from './account-settings';
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,10 @@ export function PresenceInvisibleToggle() {
|
||||||
|
|
||||||
const { data: presence } = useQuery({
|
const { data: presence } = useQuery({
|
||||||
queryKey: ['presence', user?.id],
|
queryKey: ['presence', user?.id],
|
||||||
queryFn: () => getPresence(user!.id),
|
queryFn: () => {
|
||||||
|
if (!user?.id) throw new Error('user.id required');
|
||||||
|
return getPresence(user.id);
|
||||||
|
},
|
||||||
enabled: !!user?.id,
|
enabled: !!user?.id,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -17,13 +17,18 @@ export function ProfileVisibilityCard() {
|
||||||
|
|
||||||
const { data: profile, isLoading } = useQuery({
|
const { data: profile, isLoading } = useQuery({
|
||||||
queryKey: ['profile', user?.id],
|
queryKey: ['profile', user?.id],
|
||||||
queryFn: () => usersApi.getProfile(user!.id),
|
queryFn: () => {
|
||||||
|
if (!user?.id) throw new Error('user.id required');
|
||||||
|
return usersApi.getProfile(user.id);
|
||||||
|
},
|
||||||
enabled: !!user?.id,
|
enabled: !!user?.id,
|
||||||
});
|
});
|
||||||
|
|
||||||
const mutation = useMutation({
|
const mutation = useMutation({
|
||||||
mutationFn: (isPublic: boolean) =>
|
mutationFn: (isPublic: boolean) => {
|
||||||
usersApi.updateProfile(user!.id, { is_public: isPublic }),
|
if (!user?.id) throw new Error('user.id required');
|
||||||
|
return usersApi.updateProfile(user.id, { is_public: isPublic });
|
||||||
|
},
|
||||||
onSuccess: (updatedProfile) => {
|
onSuccess: (updatedProfile) => {
|
||||||
queryClient.setQueryData(['profile', user?.id], updatedProfile);
|
queryClient.setQueryData(['profile', user?.id], updatedProfile);
|
||||||
queryClient.invalidateQueries({ queryKey: ['userProfile'] });
|
queryClient.invalidateQueries({ queryKey: ['userProfile'] });
|
||||||
|
|
|
||||||
|
|
@ -136,7 +136,9 @@ export function SocialViewFeedItem({ item, onPlay }: SocialViewFeedItemProps) {
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="appearance-none bg-transparent border-0 p-0 text-left w-full bg-card p-4 rounded-xl flex items-center gap-4 shadow-[0_0_8px_rgba(26,26,30,0.05)] hover:shadow-[0_0_12px_rgba(26,26,30,0.08)] group cursor-pointer transition-shadow focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
|
className="appearance-none bg-transparent border-0 p-0 text-left w-full bg-card p-4 rounded-xl flex items-center gap-4 shadow-[0_0_8px_rgba(26,26,30,0.05)] hover:shadow-[0_0_12px_rgba(26,26,30,0.08)] group cursor-pointer transition-shadow focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
|
||||||
onClick={() => onPlay(item.track!)}
|
onClick={() => {
|
||||||
|
if (item.track) onPlay(item.track);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<div className="w-16 h-16 rounded-lg overflow-hidden relative">
|
<div className="w-16 h-16 rounded-lg overflow-hidden relative">
|
||||||
<img
|
<img
|
||||||
|
|
|
||||||
|
|
@ -49,7 +49,6 @@ export const Loading: Story = {
|
||||||
|
|
||||||
/** Données vides (aucun segment) */
|
/** Données vides (aucun segment) */
|
||||||
export const Empty: Story = {
|
export const Empty: Story = {
|
||||||
name: 'Empty',
|
|
||||||
args: {
|
args: {
|
||||||
trackId: '123',
|
trackId: '123',
|
||||||
initialHeatmap: {
|
initialHeatmap: {
|
||||||
|
|
@ -62,7 +61,6 @@ export const Empty: Story = {
|
||||||
|
|
||||||
/** Erreur chargement */
|
/** Erreur chargement */
|
||||||
export const Error: Story = {
|
export const Error: Story = {
|
||||||
name: 'Error',
|
|
||||||
parameters: {
|
parameters: {
|
||||||
msw: {
|
msw: {
|
||||||
handlers: [
|
handlers: [
|
||||||
|
|
|
||||||
|
|
@ -36,16 +36,17 @@ export function getSkipBorderColor(skipCount: number, listenCount: number): stri
|
||||||
}
|
}
|
||||||
|
|
||||||
function computeStats(heatmap: PlaybackHeatmapData): HeatmapStats | null {
|
function computeStats(heatmap: PlaybackHeatmapData): HeatmapStats | null {
|
||||||
if (!heatmap.segments.length) return null;
|
const first = heatmap.segments[0];
|
||||||
|
if (!first) return null;
|
||||||
const totalListens = heatmap.segments.reduce((sum, seg) => sum + seg.listen_count, 0);
|
const totalListens = heatmap.segments.reduce((sum, seg) => sum + seg.listen_count, 0);
|
||||||
const totalSkips = heatmap.segments.reduce((sum, seg) => sum + seg.skip_count, 0);
|
const totalSkips = heatmap.segments.reduce((sum, seg) => sum + seg.skip_count, 0);
|
||||||
const maxIntensitySegment = heatmap.segments.reduce(
|
const maxIntensitySegment = heatmap.segments.reduce(
|
||||||
(max, seg) => (seg.intensity > max.intensity ? seg : max),
|
(max, seg) => (seg.intensity > max.intensity ? seg : max),
|
||||||
heatmap.segments[0]!,
|
first,
|
||||||
);
|
);
|
||||||
const maxSkipSegment = heatmap.segments.reduce(
|
const maxSkipSegment = heatmap.segments.reduce(
|
||||||
(max, seg) => (seg.skip_count > max.skip_count ? seg : max),
|
(max, seg) => (seg.skip_count > max.skip_count ? seg : max),
|
||||||
heatmap.segments[0]!,
|
first,
|
||||||
);
|
);
|
||||||
return { totalListens, totalSkips, maxIntensitySegment, maxSkipSegment };
|
return { totalListens, totalSkips, maxIntensitySegment, maxSkipSegment };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -94,6 +94,7 @@ describe('useBitrateAdaptation', () => {
|
||||||
expect(result.current.isAdapting).toBe(true);
|
expect(result.current.isAdapting).toBe(true);
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- assigned synchronously in Promise executor above
|
||||||
resolvePromise!({
|
resolvePromise!({
|
||||||
recommended_bitrate: 320,
|
recommended_bitrate: 320,
|
||||||
});
|
});
|
||||||
|
|
@ -217,6 +218,7 @@ describe('useBitrateAdaptation', () => {
|
||||||
|
|
||||||
// Resolve the promise after unmount
|
// Resolve the promise after unmount
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- assigned synchronously in Promise executor above
|
||||||
resolvePromise!({
|
resolvePromise!({
|
||||||
recommended_bitrate: 320,
|
recommended_bitrate: 320,
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -184,6 +184,7 @@ describe('useHLSStream', () => {
|
||||||
unmount();
|
unmount();
|
||||||
|
|
||||||
// Resolve the promise after unmount
|
// Resolve the promise after unmount
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- assigned synchronously in Promise executor above
|
||||||
resolvePromise!({
|
resolvePromise!({
|
||||||
status: 'ready',
|
status: 'ready',
|
||||||
bitrates: [128],
|
bitrates: [128],
|
||||||
|
|
|
||||||
|
|
@ -185,7 +185,7 @@ export function SubscriptionPage(): React.ReactElement {
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{currentSub.status === 'trialing'
|
{currentSub.status === 'trialing'
|
||||||
? `Trial (ends ${formatDate(currentSub.trial_end!)})`
|
? `Trial (ends ${formatDate(currentSub.trial_end ?? '')})`
|
||||||
: currentSub.status}
|
: currentSub.status}
|
||||||
</span>
|
</span>
|
||||||
{currentSub.cancel_at_period_end && (
|
{currentSub.cancel_at_period_end && (
|
||||||
|
|
|
||||||
|
|
@ -95,9 +95,11 @@ export const DeeplyNested: Story = {
|
||||||
...mockComment,
|
...mockComment,
|
||||||
replies: [
|
replies: [
|
||||||
{
|
{
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- mock fixture: replies declared above with 2 entries
|
||||||
...mockCommentWithReplies.replies![0],
|
...mockCommentWithReplies.replies![0],
|
||||||
replies: [
|
replies: [
|
||||||
{
|
{
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- mock fixture: replies declared above with 2 entries
|
||||||
...mockCommentWithReplies.replies![1],
|
...mockCommentWithReplies.replies![1],
|
||||||
content: 'Nested reply',
|
content: 'Nested reply',
|
||||||
id: 'r3'
|
id: 'r3'
|
||||||
|
|
@ -182,10 +184,12 @@ export const VisualStressTest: Story = {
|
||||||
'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.\n\nDuis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur.',
|
'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.\n\nDuis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur.',
|
||||||
replies: [
|
replies: [
|
||||||
{
|
{
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- mock fixture: replies declared above with 2 entries
|
||||||
...mockCommentWithReplies.replies![0],
|
...mockCommentWithReplies.replies![0],
|
||||||
content: 'Short reply.',
|
content: 'Short reply.',
|
||||||
} as TrackComment,
|
} as TrackComment,
|
||||||
{
|
{
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- mock fixture: replies declared above with 2 entries
|
||||||
...mockCommentWithReplies.replies![1],
|
...mockCommentWithReplies.replies![1],
|
||||||
content:
|
content:
|
||||||
'A much longer reply that wraps multiple lines and tests tracking-tight, radius tokens, and hover states without breaking the thread layout or overflowing.',
|
'A much longer reply that wraps multiple lines and tests tracking-tight, radius tokens, and hover states without breaking the thread layout or overflowing.',
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@
|
||||||
export {
|
export {
|
||||||
TrackFilters,
|
TrackFilters,
|
||||||
TrackFiltersSkeleton,
|
TrackFiltersSkeleton,
|
||||||
|
// eslint-disable-next-line react-refresh/only-export-components -- backward-compat re-export barrel; named + default exports point to the same component
|
||||||
default,
|
default,
|
||||||
} from './track-filters';
|
} from './track-filters';
|
||||||
export type { TrackFiltersProps, TrackFilterOptions } from './track-filters';
|
export type { TrackFiltersProps, TrackFilterOptions } from './track-filters';
|
||||||
|
|
|
||||||
|
|
@ -71,7 +71,9 @@ describe('TrackGrid', () => {
|
||||||
} else {
|
} else {
|
||||||
// Si pas de role="button", chercher dans le conteneur parent
|
// Si pas de role="button", chercher dans le conteneur parent
|
||||||
const track1Text = screen.getByText('Track 1');
|
const track1Text = screen.getByText('Track 1');
|
||||||
await user.click(track1Text.closest('div')!);
|
const div = track1Text.closest('div');
|
||||||
|
if (!div) throw new Error('parent div not found');
|
||||||
|
await user.click(div);
|
||||||
expect(mockOnTrackClick).toHaveBeenCalledWith(mockTracks[0]);
|
expect(mockOnTrackClick).toHaveBeenCalledWith(mockTracks[0]);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -59,7 +59,8 @@ describe('TrackList', () => {
|
||||||
render(<TrackList tracks={mockTracks} onTrackClick={mockOnTrackClick} />);
|
render(<TrackList tracks={mockTracks} onTrackClick={mockOnTrackClick} />);
|
||||||
|
|
||||||
const track1 = screen.getByText('Track 1').closest('[role="listitem"]');
|
const track1 = screen.getByText('Track 1').closest('[role="listitem"]');
|
||||||
await user.click(track1!);
|
if (!track1) throw new Error('track1 listitem not found');
|
||||||
|
await user.click(track1);
|
||||||
|
|
||||||
expect(mockOnTrackClick).toHaveBeenCalledWith(mockTracks[0]);
|
expect(mockOnTrackClick).toHaveBeenCalledWith(mockTracks[0]);
|
||||||
});
|
});
|
||||||
|
|
@ -71,7 +72,8 @@ describe('TrackList', () => {
|
||||||
|
|
||||||
// Hover to show play button
|
// Hover to show play button
|
||||||
const track1 = screen.getByText('Track 1').closest('[role="listitem"]');
|
const track1 = screen.getByText('Track 1').closest('[role="listitem"]');
|
||||||
await user.hover(track1!);
|
if (!track1) throw new Error('track1 listitem not found');
|
||||||
|
await user.hover(track1);
|
||||||
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||||
|
|
||||||
|
|
@ -215,7 +217,8 @@ describe('TrackList', () => {
|
||||||
|
|
||||||
// Hover to show like button
|
// Hover to show like button
|
||||||
const track1 = screen.getByText('Track 1').closest('[role="listitem"]');
|
const track1 = screen.getByText('Track 1').closest('[role="listitem"]');
|
||||||
await user.hover(track1!);
|
if (!track1) throw new Error('track1 listitem not found');
|
||||||
|
await user.hover(track1);
|
||||||
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||||
|
|
||||||
|
|
@ -233,7 +236,8 @@ describe('TrackList', () => {
|
||||||
|
|
||||||
// Hover to show more button
|
// Hover to show more button
|
||||||
const track1 = screen.getByText('Track 1').closest('[role="listitem"]');
|
const track1 = screen.getByText('Track 1').closest('[role="listitem"]');
|
||||||
await user.hover(track1!);
|
if (!track1) throw new Error('track1 listitem not found');
|
||||||
|
await user.hover(track1);
|
||||||
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||||
|
|
||||||
|
|
@ -285,7 +289,8 @@ describe('TrackList', () => {
|
||||||
|
|
||||||
// Hover to show like button
|
// Hover to show like button
|
||||||
const track1 = screen.getByText('Track 1').closest('[role="listitem"]');
|
const track1 = screen.getByText('Track 1').closest('[role="listitem"]');
|
||||||
await user.hover(track1!);
|
if (!track1) throw new Error('track1 listitem not found');
|
||||||
|
await user.hover(track1);
|
||||||
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@
|
||||||
export {
|
export {
|
||||||
TrackSearchFilters,
|
TrackSearchFilters,
|
||||||
TrackSearchFiltersSkeleton,
|
TrackSearchFiltersSkeleton,
|
||||||
|
// eslint-disable-next-line react-refresh/only-export-components -- backward-compat re-export barrel; named + default exports point to the same component
|
||||||
default,
|
default,
|
||||||
} from './track-search-filters';
|
} from './track-search-filters';
|
||||||
export type {
|
export type {
|
||||||
|
|
|
||||||
|
|
@ -103,7 +103,7 @@ export function TrackStemsSection({ track, isCreator }: TrackStemsSectionProps)
|
||||||
<p className="text-muted-foreground">{t('tracks.stemsSection.uploadHelp')}</p>
|
<p className="text-muted-foreground">{t('tracks.stemsSection.uploadHelp')}</p>
|
||||||
) : (
|
) : (
|
||||||
<ul className="space-y-2">
|
<ul className="space-y-2">
|
||||||
{stems!.map((stem) => (
|
{(stems ?? []).map((stem) => (
|
||||||
<li
|
<li
|
||||||
key={stem.id}
|
key={stem.id}
|
||||||
className="flex items-center justify-between gap-4 py-2 px-3 rounded-lg bg-muted/30 hover:bg-muted/50"
|
className="flex items-center justify-between gap-4 py-2 px-3 rounded-lg bg-muted/30 hover:bg-muted/50"
|
||||||
|
|
|
||||||
|
|
@ -82,7 +82,9 @@ export function CommentThreadContent({
|
||||||
{onSeek ? (
|
{onSeek ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onSeek(comment.timestamp!)}
|
onClick={() => {
|
||||||
|
if (comment.timestamp != null) onSeek(comment.timestamp);
|
||||||
|
}}
|
||||||
className="text-xs text-primary hover:underline font-medium tabular-nums"
|
className="text-xs text-primary hover:underline font-medium tabular-nums"
|
||||||
aria-label={`Aller à ${formatTime(comment.timestamp)}`}
|
aria-label={`Aller à ${formatTime(comment.timestamp)}`}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,7 @@ export function useCommentReplies({
|
||||||
});
|
});
|
||||||
|
|
||||||
const replies = hasInitialReplies
|
const replies = hasInitialReplies
|
||||||
? initialReplies!
|
? (initialReplies ?? [])
|
||||||
: (repliesData?.replies ?? []);
|
: (repliesData?.replies ?? []);
|
||||||
|
|
||||||
const isLoadingReplies =
|
const isLoadingReplies =
|
||||||
|
|
|
||||||
|
|
@ -94,9 +94,10 @@ describe('useInfiniteScroll', () => {
|
||||||
|
|
||||||
// Simuler l'intersection si l'observer existe
|
// Simuler l'intersection si l'observer existe
|
||||||
if (observerCallback && mockObserverInstance) {
|
if (observerCallback && mockObserverInstance) {
|
||||||
|
const cb = observerCallback;
|
||||||
const sentinel = document.createElement('div');
|
const sentinel = document.createElement('div');
|
||||||
act(() => {
|
act(() => {
|
||||||
observerCallback!(
|
cb(
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
isIntersecting: true,
|
isIntersecting: true,
|
||||||
|
|
@ -140,9 +141,10 @@ describe('useInfiniteScroll', () => {
|
||||||
|
|
||||||
// Simuler l'intersection si l'observer existe
|
// Simuler l'intersection si l'observer existe
|
||||||
if (observerCallback && mockObserverInstance) {
|
if (observerCallback && mockObserverInstance) {
|
||||||
|
const cb = observerCallback;
|
||||||
const sentinel = document.createElement('div');
|
const sentinel = document.createElement('div');
|
||||||
act(() => {
|
act(() => {
|
||||||
observerCallback!(
|
cb(
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
isIntersecting: true,
|
isIntersecting: true,
|
||||||
|
|
@ -288,9 +290,10 @@ describe('useInfiniteScroll', () => {
|
||||||
|
|
||||||
// Simuler l'intersection si l'observer existe
|
// Simuler l'intersection si l'observer existe
|
||||||
if (observerCallback && mockObserverInstance) {
|
if (observerCallback && mockObserverInstance) {
|
||||||
|
const cb = observerCallback;
|
||||||
const sentinel = document.createElement('div');
|
const sentinel = document.createElement('div');
|
||||||
act(() => {
|
act(() => {
|
||||||
observerCallback!(
|
cb(
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
isIntersecting: true,
|
isIntersecting: true,
|
||||||
|
|
@ -336,10 +339,11 @@ describe('useInfiniteScroll', () => {
|
||||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||||
|
|
||||||
if (observerCallback && mockObserverInstance) {
|
if (observerCallback && mockObserverInstance) {
|
||||||
|
const cb = observerCallback;
|
||||||
const sentinel = document.createElement('div');
|
const sentinel = document.createElement('div');
|
||||||
// Simuler plusieurs intersections rapides
|
// Simuler plusieurs intersections rapides
|
||||||
act(() => {
|
act(() => {
|
||||||
observerCallback!(
|
cb(
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
isIntersecting: true,
|
isIntersecting: true,
|
||||||
|
|
@ -355,7 +359,7 @@ describe('useInfiniteScroll', () => {
|
||||||
);
|
);
|
||||||
|
|
||||||
// Simuler une deuxième intersection immédiatement
|
// Simuler une deuxième intersection immédiatement
|
||||||
observerCallback!(
|
cb(
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
isIntersecting: true,
|
isIntersecting: true,
|
||||||
|
|
|
||||||
|
|
@ -305,6 +305,7 @@ describe('useTrackList', () => {
|
||||||
expect(result.current.isLoading).toBe(true);
|
expect(result.current.isLoading).toBe(true);
|
||||||
|
|
||||||
act(() => {
|
act(() => {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- assigned synchronously in Promise executor above
|
||||||
resolvePromise!({
|
resolvePromise!({
|
||||||
data: mockTracks,
|
data: mockTracks,
|
||||||
total: mockTracks.length,
|
total: mockTracks.length,
|
||||||
|
|
|
||||||
|
|
@ -74,12 +74,14 @@ export function useTrackList(
|
||||||
const getInitialFilters = () => {
|
const getInitialFilters = () => {
|
||||||
if (syncUrlParams) {
|
if (syncUrlParams) {
|
||||||
const filters: TrackFilters = {};
|
const filters: TrackFilters = {};
|
||||||
if (searchParams.get('genre')) filters.genre = searchParams.get('genre')!;
|
const genre = searchParams.get('genre');
|
||||||
if (searchParams.get('artist'))
|
if (genre) filters.genre = genre;
|
||||||
filters.artist = searchParams.get('artist')!;
|
const artist = searchParams.get('artist');
|
||||||
if (searchParams.get('album')) filters.album = searchParams.get('album')!;
|
if (artist) filters.artist = artist;
|
||||||
if (searchParams.get('year'))
|
const album = searchParams.get('album');
|
||||||
filters.year = parseInt(searchParams.get('year')!);
|
if (album) filters.album = album;
|
||||||
|
const year = searchParams.get('year');
|
||||||
|
if (year) filters.year = parseInt(year);
|
||||||
// Basic mapping
|
// Basic mapping
|
||||||
return { ...initialFilterOptions, ...filters };
|
return { ...initialFilterOptions, ...filters };
|
||||||
}
|
}
|
||||||
|
|
@ -92,9 +94,10 @@ export function useTrackList(
|
||||||
|
|
||||||
const getInitialSort = () => {
|
const getInitialSort = () => {
|
||||||
if (syncUrlParams) {
|
if (syncUrlParams) {
|
||||||
if (searchParams.get('sortField')) {
|
const sortField = searchParams.get('sortField');
|
||||||
|
if (sortField) {
|
||||||
return {
|
return {
|
||||||
field: searchParams.get('sortField')!,
|
field: sortField,
|
||||||
order: (searchParams.get('sortOrder') as 'asc' | 'desc') || 'asc',
|
order: (searchParams.get('sortOrder') as 'asc' | 'desc') || 'asc',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -115,6 +115,10 @@ export class ChunkedUploadManager {
|
||||||
);
|
);
|
||||||
this.state.uploadId = uploadId;
|
this.state.uploadId = uploadId;
|
||||||
}
|
}
|
||||||
|
const uploadId = this.state.uploadId;
|
||||||
|
if (!uploadId) {
|
||||||
|
throw new Error('uploadId must be set after initiateChunkedUpload');
|
||||||
|
}
|
||||||
|
|
||||||
// 2. Uploader chaque chunk séquentiellement
|
// 2. Uploader chaque chunk séquentiellement
|
||||||
for (let i = this.currentChunkIndex; i < this.chunks.length; i++) {
|
for (let i = this.currentChunkIndex; i < this.chunks.length; i++) {
|
||||||
|
|
@ -134,7 +138,7 @@ export class ChunkedUploadManager {
|
||||||
for (let retry = 0; retry <= this.MAX_RETRIES; retry++) {
|
for (let retry = 0; retry <= this.MAX_RETRIES; retry++) {
|
||||||
try {
|
try {
|
||||||
const response = await trackService.uploadChunk(
|
const response = await trackService.uploadChunk(
|
||||||
this.state.uploadId!,
|
uploadId,
|
||||||
chunkNumber,
|
chunkNumber,
|
||||||
this.state.totalChunks,
|
this.state.totalChunks,
|
||||||
this.file.size,
|
this.file.size,
|
||||||
|
|
@ -184,9 +188,7 @@ export class ChunkedUploadManager {
|
||||||
|
|
||||||
// 4. Compléter l'upload
|
// 4. Compléter l'upload
|
||||||
this.updateProgress(95);
|
this.updateProgress(95);
|
||||||
const track = await trackService.completeChunkedUpload(
|
const track = await trackService.completeChunkedUpload(uploadId);
|
||||||
this.state.uploadId!,
|
|
||||||
);
|
|
||||||
|
|
||||||
this.state.track = track;
|
this.state.track = track;
|
||||||
this.state.isComplete = true;
|
this.state.isComplete = true;
|
||||||
|
|
|
||||||
|
|
@ -101,6 +101,7 @@ function getSeasonForDate(date: Date): { current: SeasonConfig; dayOfSeason: num
|
||||||
|
|
||||||
// Build array of season start dates for this year and next
|
// Build array of season start dates for this year and next
|
||||||
const starts = SEASONS.map((s, i) => {
|
const starts = SEASONS.map((s, i) => {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- SEASONS is a static array of 4 elements; modulo guarantees in-bounds
|
||||||
const nextSeason = SEASONS[(i + 1) % SEASONS.length]!;
|
const nextSeason = SEASONS[(i + 1) % SEASONS.length]!;
|
||||||
return {
|
return {
|
||||||
config: s,
|
config: s,
|
||||||
|
|
@ -125,6 +126,7 @@ function getSeasonForDate(date: Date): { current: SeasonConfig; dayOfSeason: num
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback: winter (SEASONS[3] is always 'winter')
|
// Fallback: winter (SEASONS[3] is always 'winter')
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- SEASONS literal has 4 elements, index 3 always present
|
||||||
return { current: SEASONS[3]!, dayOfSeason: 1, progress: 0 };
|
return { current: SEASONS[3]!, dayOfSeason: 1, progress: 0 };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -133,6 +135,7 @@ const TRANSITION_DAYS = 7;
|
||||||
function computeSeasonalContext(date: Date): SeasonalContext {
|
function computeSeasonalContext(date: Date): SeasonalContext {
|
||||||
const { current, dayOfSeason, progress } = getSeasonForDate(date);
|
const { current, dayOfSeason, progress } = getSeasonForDate(date);
|
||||||
const currentIndex = SEASONS.indexOf(current);
|
const currentIndex = SEASONS.indexOf(current);
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- modulo on 4-elem static SEASONS guarantees in-bounds
|
||||||
const previousSeason = SEASONS[(currentIndex - 1 + SEASONS.length) % SEASONS.length]!;
|
const previousSeason = SEASONS[(currentIndex - 1 + SEASONS.length) % SEASONS.length]!;
|
||||||
|
|
||||||
const isTransitioning = dayOfSeason <= TRANSITION_DAYS;
|
const isTransitioning = dayOfSeason <= TRANSITION_DAYS;
|
||||||
|
|
@ -170,6 +173,7 @@ export function useSeason(overrideSeason?: Season): SeasonalContext {
|
||||||
|
|
||||||
return useMemo(() => {
|
return useMemo(() => {
|
||||||
if (overrideSeason) {
|
if (overrideSeason) {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- SEASONS[0] exists in static 4-elem array
|
||||||
const config = SEASONS.find((s) => s.season === overrideSeason) ?? SEASONS[0]!;
|
const config = SEASONS.find((s) => s.season === overrideSeason) ?? SEASONS[0]!;
|
||||||
return {
|
return {
|
||||||
season: config.season,
|
season: config.season,
|
||||||
|
|
|
||||||
|
|
@ -142,6 +142,7 @@ function getPeriodForHour(hour: number): PeriodConfig {
|
||||||
if (hour >= p.startHour || hour < p.endHour) return p;
|
if (hour >= p.startHour || hour < p.endHour) return p;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- PERIODS literal has 6 entries, index 5 always present
|
||||||
return PERIODS[5]!; // fallback to night
|
return PERIODS[5]!; // fallback to night
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -174,6 +175,7 @@ function computePalette(now: Date): TimeOfDayPalette {
|
||||||
|
|
||||||
// Find next period for smooth interpolation
|
// Find next period for smooth interpolation
|
||||||
const nextPeriodIndex = (PERIODS.indexOf(current) + 1) % PERIODS.length;
|
const nextPeriodIndex = (PERIODS.indexOf(current) + 1) % PERIODS.length;
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- modulo on static PERIODS array guarantees in-bounds
|
||||||
const next = PERIODS[nextPeriodIndex]!;
|
const next = PERIODS[nextPeriodIndex]!;
|
||||||
|
|
||||||
// Smooth transition in the last 30% of each period
|
// Smooth transition in the last 30% of each period
|
||||||
|
|
@ -228,6 +230,7 @@ export function useTimeOfDay(enabled = true): TimeOfDayPalette {
|
||||||
|
|
||||||
/** Force a specific period (for Storybook / testing) */
|
/** Force a specific period (for Storybook / testing) */
|
||||||
export function getTimeOfDayPalette(period: TimeOfDayPeriod): TimeOfDayPalette {
|
export function getTimeOfDayPalette(period: TimeOfDayPeriod): TimeOfDayPalette {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- PERIODS[2] is midday, present in static array
|
||||||
const config = PERIODS.find((p) => p.period === period) ?? PERIODS[2]!;
|
const config = PERIODS.find((p) => p.period === period) ?? PERIODS[2]!;
|
||||||
return {
|
return {
|
||||||
period: config.period,
|
period: config.period,
|
||||||
|
|
|
||||||
|
|
@ -213,7 +213,9 @@ const waitForStylesheets = (): Promise<void> => {
|
||||||
import { ThemeProvider } from './components/theme/ThemeProvider';
|
import { ThemeProvider } from './components/theme/ThemeProvider';
|
||||||
|
|
||||||
const renderApp = () => {
|
const renderApp = () => {
|
||||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
const root = document.getElementById('root');
|
||||||
|
if (!root) throw new Error('root element not found in index.html');
|
||||||
|
ReactDOM.createRoot(root).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<ThemeProvider defaultTheme="dark" storageKey="vite-ui-theme">
|
<ThemeProvider defaultTheme="dark" storageKey="vite-ui-theme">
|
||||||
|
|
|
||||||
|
|
@ -56,6 +56,7 @@ export const AuthProvider = ({ children, loadingComponent }: AuthProviderProps)
|
||||||
* Hook to check if auth is currently initializing
|
* Hook to check if auth is currently initializing
|
||||||
* Can be used by components that need to know auth initialization status
|
* Can be used by components that need to know auth initialization status
|
||||||
*/
|
*/
|
||||||
|
// eslint-disable-next-line react-refresh/only-export-components -- co-located hook; tightly coupled to the AuthProvider lifecycle
|
||||||
export const useAuthInitialization = () => {
|
export const useAuthInitialization = () => {
|
||||||
const [isInitializing, setIsInitializing] = useState(true);
|
const [isInitializing, setIsInitializing] = useState(true);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -61,6 +61,7 @@ import { useUser } from '@/features/auth/hooks/useUser';
|
||||||
import type { RouteEntry } from './types';
|
import type { RouteEntry } from './types';
|
||||||
|
|
||||||
/** Redirects /profile to /u/<current_username> */
|
/** Redirects /profile to /u/<current_username> */
|
||||||
|
// eslint-disable-next-line react-refresh/only-export-components -- route config co-located with lazy components
|
||||||
function ProfileRedirect() {
|
function ProfileRedirect() {
|
||||||
const { data: user } = useUser();
|
const { data: user } = useUser();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
|
||||||
|
|
@ -204,8 +204,11 @@ export async function login(data: LoginRequest): Promise<LoginResponse> {
|
||||||
|
|
||||||
// INT-TYPE-008: Return format aligned with backend LoginResponse
|
// INT-TYPE-008: Return format aligned with backend LoginResponse
|
||||||
// Backend format: { user: UserResponse, token: TokenResponse, requires_2fa?: boolean }
|
// Backend format: { user: UserResponse, token: TokenResponse, requires_2fa?: boolean }
|
||||||
|
if (!user) {
|
||||||
|
throw new Error('Login response missing user data');
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
user: user!,
|
user,
|
||||||
token: {
|
token: {
|
||||||
access_token: accessToken,
|
access_token: accessToken,
|
||||||
refresh_token: refreshToken || '', // Ensure string
|
refresh_token: refreshToken || '', // Ensure string
|
||||||
|
|
|
||||||
|
|
@ -100,9 +100,10 @@ export function createResponseSuccessHandler(apiClient: AxiosInstance) {
|
||||||
statusText: response.statusText,
|
statusText: response.statusText,
|
||||||
headers: sanitizeForLogging(response.headers),
|
headers: sanitizeForLogging(response.headers),
|
||||||
data: sanitizeForLogging(response.data),
|
data: sanitizeForLogging(response.data),
|
||||||
duration: (response.config as AxiosResponse['config'] & { _requestStartTime?: number })?._requestStartTime
|
duration: ((): number | undefined => {
|
||||||
? Date.now() - (response.config as AxiosResponse['config'] & { _requestStartTime?: number })._requestStartTime!
|
const cfg = response.config as AxiosResponse['config'] & { _requestStartTime?: number };
|
||||||
: undefined,
|
return cfg._requestStartTime ? Date.now() - cfg._requestStartTime : undefined;
|
||||||
|
})(),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -97,7 +97,7 @@ async function searchWithFacets(
|
||||||
if (opts.cursor) params.cursor = opts.cursor;
|
if (opts.cursor) params.cursor = opts.cursor;
|
||||||
if (opts.limit != null && opts.limit > 0) params.limit = Math.min(opts.limit, 50);
|
if (opts.limit != null && opts.limit > 0) params.limit = Math.min(opts.limit, 50);
|
||||||
|
|
||||||
const f = opts.facets!;
|
const f = opts.facets ?? {};
|
||||||
if (f.genre) params.genre = f.genre;
|
if (f.genre) params.genre = f.genre;
|
||||||
if (f.musicalKey) params.musical_key = f.musicalKey;
|
if (f.musicalKey) params.musical_key = f.musicalKey;
|
||||||
if (f.bpmMin && f.bpmMin > 0) params.bpm_min = f.bpmMin;
|
if (f.bpmMin && f.bpmMin > 0) params.bpm_min = f.bpmMin;
|
||||||
|
|
|
||||||
|
|
@ -423,8 +423,9 @@ describe('OfflineQueueService', () => {
|
||||||
// Check localStorage
|
// Check localStorage
|
||||||
const stored = localStorageMock.getItem('veza_offline_queue');
|
const stored = localStorageMock.getItem('veza_offline_queue');
|
||||||
expect(stored).not.toBeNull();
|
expect(stored).not.toBeNull();
|
||||||
|
if (stored === null) throw new Error('stored is null');
|
||||||
|
|
||||||
const parsed = JSON.parse(stored!);
|
const parsed = JSON.parse(stored);
|
||||||
expect(parsed.length).toBe(1);
|
expect(parsed.length).toBe(1);
|
||||||
expect(parsed[0].config.url).toBe('/api/v1/tracks');
|
expect(parsed[0].config.url).toBe('/api/v1/tracks');
|
||||||
});
|
});
|
||||||
|
|
@ -485,7 +486,8 @@ describe('OfflineQueueService', () => {
|
||||||
// The service should filter out old requests on load
|
// The service should filter out old requests on load
|
||||||
// Since we can't easily test singleton initialization, we'll verify the logic
|
// Since we can't easily test singleton initialization, we'll verify the logic
|
||||||
const stored = localStorageMock.getItem('veza_offline_queue');
|
const stored = localStorageMock.getItem('veza_offline_queue');
|
||||||
const parsed = JSON.parse(stored!);
|
if (stored === null) throw new Error('stored is null');
|
||||||
|
const parsed = JSON.parse(stored);
|
||||||
const oneDayAgo = Date.now() - 24 * 60 * 60 * 1000;
|
const oneDayAgo = Date.now() - 24 * 60 * 60 * 1000;
|
||||||
const filtered = parsed.filter(
|
const filtered = parsed.filter(
|
||||||
(req: QueuedRequest) => req.timestamp > oneDayAgo,
|
(req: QueuedRequest) => req.timestamp > oneDayAgo,
|
||||||
|
|
|
||||||
|
|
@ -53,14 +53,15 @@ class PWAService {
|
||||||
|
|
||||||
if ('serviceWorker' in navigator) {
|
if ('serviceWorker' in navigator) {
|
||||||
try {
|
try {
|
||||||
this.registration = await navigator.serviceWorker.register('/sw.js', {
|
const registration = await navigator.serviceWorker.register('/sw.js', {
|
||||||
scope: '/',
|
scope: '/',
|
||||||
});
|
});
|
||||||
|
this.registration = registration;
|
||||||
|
|
||||||
logger.info('[PWA] Service Worker registered successfully');
|
logger.info('[PWA] Service Worker registered successfully');
|
||||||
|
|
||||||
this.registration.addEventListener('updatefound', () => {
|
registration.addEventListener('updatefound', () => {
|
||||||
const newWorker = this.registration!.installing;
|
const newWorker = registration.installing;
|
||||||
if (newWorker) {
|
if (newWorker) {
|
||||||
newWorker.addEventListener('statechange', () => {
|
newWorker.addEventListener('statechange', () => {
|
||||||
if (
|
if (
|
||||||
|
|
@ -223,7 +224,8 @@ class PWAService {
|
||||||
* Clear all caches
|
* Clear all caches
|
||||||
*/
|
*/
|
||||||
public async clearCaches(): Promise<void> {
|
public async clearCaches(): Promise<void> {
|
||||||
if (this.registration) {
|
const registration = this.registration;
|
||||||
|
if (registration) {
|
||||||
const messageChannel = new MessageChannel();
|
const messageChannel = new MessageChannel();
|
||||||
|
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
|
|
@ -233,7 +235,7 @@ class PWAService {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
this.registration!.active?.postMessage({ type: 'CLEAR_CACHE' }, [
|
registration.active?.postMessage({ type: 'CLEAR_CACHE' }, [
|
||||||
messageChannel.port2,
|
messageChannel.port2,
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
@ -244,7 +246,9 @@ class PWAService {
|
||||||
* Get service worker version
|
* Get service worker version
|
||||||
*/
|
*/
|
||||||
public async getVersion(): Promise<string> {
|
public async getVersion(): Promise<string> {
|
||||||
if (this.registration && this.registration.active) {
|
const registration = this.registration;
|
||||||
|
const active = registration?.active;
|
||||||
|
if (registration && active) {
|
||||||
const messageChannel = new MessageChannel();
|
const messageChannel = new MessageChannel();
|
||||||
|
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
|
|
@ -254,9 +258,7 @@ class PWAService {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
this.registration!.active!.postMessage({ type: 'GET_VERSION' }, [
|
active.postMessage({ type: 'GET_VERSION' }, [messageChannel.port2]);
|
||||||
messageChannel.port2,
|
|
||||||
]);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -47,10 +47,13 @@ class RequestDeduplicationService {
|
||||||
|
|
||||||
// Sort params for consistent key generation
|
// Sort params for consistent key generation
|
||||||
const params = config.params
|
const params = config.params
|
||||||
? Object.keys(config.params)
|
? (() => {
|
||||||
.sort()
|
const cp = config.params;
|
||||||
.map((key) => `${key}=${JSON.stringify(config.params![key])}`)
|
return Object.keys(cp)
|
||||||
.join('&')
|
.sort()
|
||||||
|
.map((key) => `${key}=${JSON.stringify(cp[key])}`)
|
||||||
|
.join('&');
|
||||||
|
})()
|
||||||
: '';
|
: '';
|
||||||
|
|
||||||
// Serialize body for consistent key generation
|
// Serialize body for consistent key generation
|
||||||
|
|
|
||||||
|
|
@ -69,10 +69,13 @@ class ResponseCacheService {
|
||||||
|
|
||||||
// Sort params for consistent key generation
|
// Sort params for consistent key generation
|
||||||
const params = config.params
|
const params = config.params
|
||||||
? Object.keys(config.params)
|
? (() => {
|
||||||
.sort()
|
const cp = config.params;
|
||||||
.map((key) => `${key}=${JSON.stringify(config.params![key])}`)
|
return Object.keys(cp)
|
||||||
.join('&')
|
.sort()
|
||||||
|
.map((key) => `${key}=${JSON.stringify(cp[key])}`)
|
||||||
|
.join('&');
|
||||||
|
})()
|
||||||
: '';
|
: '';
|
||||||
|
|
||||||
// Include Authorization header in key for user-specific cache
|
// Include Authorization header in key for user-specific cache
|
||||||
|
|
|
||||||
|
|
@ -81,7 +81,8 @@ function decodeJWT(token: string): JWTPayload | null {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
// Décoder le payload (base64url)
|
// Décoder le payload (base64url)
|
||||||
const payload = parts[1]!;
|
const payload = parts[1];
|
||||||
|
if (!payload) return null;
|
||||||
const decoded = atob(payload.replace(/-/g, '+').replace(/_/g, '/'));
|
const decoded = atob(payload.replace(/-/g, '+').replace(/_/g, '/'));
|
||||||
return JSON.parse(decoded) as JWTPayload;
|
return JSON.parse(decoded) as JWTPayload;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ const queryClient = new QueryClient({
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// eslint-disable-next-line react-refresh/only-export-components -- test util, no HMR concern
|
||||||
function AllTheProviders({ children }: AllTheProvidersProps) {
|
function AllTheProviders({ children }: AllTheProvidersProps) {
|
||||||
return (
|
return (
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
|
|
@ -32,5 +33,6 @@ const customRender = (
|
||||||
) => render(ui, { wrapper: AllTheProviders, ...options });
|
) => render(ui, { wrapper: AllTheProviders, ...options });
|
||||||
|
|
||||||
// Re-export everything
|
// Re-export everything
|
||||||
|
// eslint-disable-next-line react-refresh/only-export-components -- test util, no HMR concern
|
||||||
export * from '@testing-library/react';
|
export * from '@testing-library/react';
|
||||||
export { customRender as render };
|
export { customRender as render };
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,7 @@ interface AllProvidersProps {
|
||||||
initialEntries?: string[];
|
initialEntries?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line react-refresh/only-export-components -- test util, no HMR concern
|
||||||
const AllProviders = ({ children, initialEntries }: AllProvidersProps) => {
|
const AllProviders = ({ children, initialEntries }: AllProvidersProps) => {
|
||||||
const queryClient = createTestQueryClient();
|
const queryClient = createTestQueryClient();
|
||||||
const Router = initialEntries ? MemoryRouter : BrowserRouter;
|
const Router = initialEntries ? MemoryRouter : BrowserRouter;
|
||||||
|
|
@ -58,6 +59,7 @@ const customRender = (ui: ReactElement, options?: CustomRenderOptions) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
// Re-export everything from @testing-library/react
|
// Re-export everything from @testing-library/react
|
||||||
|
// eslint-disable-next-line react-refresh/only-export-components -- test util, no HMR concern
|
||||||
export * from '@testing-library/react';
|
export * from '@testing-library/react';
|
||||||
|
|
||||||
// Override render with our custom render
|
// Override render with our custom render
|
||||||
|
|
|
||||||
|
|
@ -77,21 +77,19 @@ export function parseRGB(rgbString: string): [number, number, number] | null {
|
||||||
// Handle CSS variable format: "255 255 255"
|
// Handle CSS variable format: "255 255 255"
|
||||||
const spaceSeparated = rgbString.trim().match(/^(\d+)\s+(\d+)\s+(\d+)$/);
|
const spaceSeparated = rgbString.trim().match(/^(\d+)\s+(\d+)\s+(\d+)$/);
|
||||||
if (spaceSeparated) {
|
if (spaceSeparated) {
|
||||||
return [
|
const [, r, g, b] = spaceSeparated;
|
||||||
parseInt(spaceSeparated[1]!, 10),
|
if (r && g && b) {
|
||||||
parseInt(spaceSeparated[2]!, 10),
|
return [parseInt(r, 10), parseInt(g, 10), parseInt(b, 10)];
|
||||||
parseInt(spaceSeparated[3]!, 10),
|
}
|
||||||
];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle rgb() format: "rgb(255, 255, 255)"
|
// Handle rgb() format: "rgb(255, 255, 255)"
|
||||||
const rgbMatch = rgbString.match(/rgb\((\d+),\s*(\d+),\s*(\d+)\)/);
|
const rgbMatch = rgbString.match(/rgb\((\d+),\s*(\d+),\s*(\d+)\)/);
|
||||||
if (rgbMatch) {
|
if (rgbMatch) {
|
||||||
return [
|
const [, r, g, b] = rgbMatch;
|
||||||
parseInt(rgbMatch[1]!, 10),
|
if (r && g && b) {
|
||||||
parseInt(rgbMatch[2]!, 10),
|
return [parseInt(r, 10), parseInt(g, 10), parseInt(b, 10)];
|
||||||
parseInt(rgbMatch[3]!, 10),
|
}
|
||||||
];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
|
|
|
||||||
|
|
@ -121,7 +121,10 @@ export function sanitizeHTML(
|
||||||
content: string,
|
content: string,
|
||||||
options: SanitizeOptions = {},
|
options: SanitizeOptions = {},
|
||||||
): string {
|
): string {
|
||||||
const config = { ...DEFAULT_OPTIONS, ...options };
|
const config: Required<SanitizeOptions> = {
|
||||||
|
...(DEFAULT_OPTIONS as Required<SanitizeOptions>),
|
||||||
|
...options,
|
||||||
|
};
|
||||||
let sanitized = content;
|
let sanitized = content;
|
||||||
|
|
||||||
// Supprimer les patterns dangereux
|
// Supprimer les patterns dangereux
|
||||||
|
|
@ -131,14 +134,14 @@ export function sanitizeHTML(
|
||||||
|
|
||||||
// Supprimer les tags non autorisés
|
// Supprimer les tags non autorisés
|
||||||
if (config.stripUnknownTags) {
|
if (config.stripUnknownTags) {
|
||||||
sanitized = stripUnknownTags(sanitized, config.allowedTags!);
|
sanitized = stripUnknownTags(sanitized, config.allowedTags);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Nettoyer les attributs
|
// Nettoyer les attributs
|
||||||
sanitized = sanitizeAttributes(sanitized, config.allowedAttributes!);
|
sanitized = sanitizeAttributes(sanitized, config.allowedAttributes);
|
||||||
|
|
||||||
// Valider les URLs
|
// Valider les URLs
|
||||||
sanitized = validateURLs(sanitized, config.allowedSchemes!);
|
sanitized = validateURLs(sanitized, config.allowedSchemes);
|
||||||
|
|
||||||
// Supprimer les tags vides
|
// Supprimer les tags vides
|
||||||
if (config.stripEmptyTags) {
|
if (config.stripEmptyTags) {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue