refactor(playlists): découper AddTrackToPlaylistModal en module

- Module add-track-to-playlist-modal/ : useAddTrackToPlaylistModal, Search,
  List, TrackRow, Footer, Skeleton
- Liste max-h-96 (layout), Spinner pour chargement
- Stories : Default (useArgs), Loading (Skeleton)
- Re-export depuis AddTrackToPlaylistModal.tsx

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
senke 2026-02-06 00:22:09 +01:00
parent 7ed4a8decd
commit 157bda45e2
11 changed files with 538 additions and 323 deletions

View file

@ -1,5 +1,5 @@
import type { Meta, StoryObj } from '@storybook/react';
import { AddTrackToPlaylistModal } from './AddTrackToPlaylistModal';
import { AddTrackToPlaylistModal, AddTrackToPlaylistModalSkeleton } from './AddTrackToPlaylistModal';
import { Button } from '@/components/ui/button';
import { useArgs } from '@storybook/preview-api';
@ -31,3 +31,12 @@ export const Default: Story = {
playlistId: 'pl1',
},
};
export const Loading: Story = {
name: 'Chargement',
render: () => (
<div className="p-4 max-w-2xl">
<AddTrackToPlaylistModalSkeleton />
</div>
),
};

View file

@ -1,324 +1,8 @@
/**
* Composant modal pour ajouter des tracks à une playlist
* T0471: Create Add Track to Playlist Component
* AddTrackToPlaylistModal re-export from feature module.
*/
import { useState, useEffect, useCallback } from 'react';
import { useDebounce } from '@/hooks/useDebounce';
import { Modal } from '@/components/ui/modal';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Checkbox } from '@/components/ui/checkbox';
import { useToast } from '@/hooks/useToast';
import { useAddTrackToPlaylist } from '../hooks/usePlaylist';
import {
searchTracks,
TrackSearchError,
} from '@/features/tracks/services/trackSearchService';
import type { Track } from '@/features/tracks/types/track';
import { Search, Plus } from 'lucide-react';
import { Spinner } from '@/components/ui/Spinner';
import { cn } from '@/lib/utils';
import { logger } from '@/utils/logger';
interface AddTrackToPlaylistModalProps {
open: boolean;
onClose: () => void;
playlistId: string;
onTracksAdded?: () => void;
}
export function AddTrackToPlaylistModal({
open,
onClose,
playlistId,
onTracksAdded,
}: AddTrackToPlaylistModalProps) {
const [searchQuery, setSearchQuery] = useState('');
// Action 2.4.1.3: Use useDebounce hook instead of manual setTimeout for consistency
const debouncedSearchQuery = useDebounce(searchQuery, 500);
const [tracks, setTracks] = useState<Track[]>([]);
const [selectedTracks, setSelectedTracks] = useState<Set<string>>(new Set());
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [page, setPage] = useState(1);
const [total, setTotal] = useState(0);
const [addingTracks, setAddingTracks] = useState(false);
const { success: showSuccess, error: showError } = useToast();
const addTrackMutation = useAddTrackToPlaylist();
const performSearch = useCallback(async () => {
setLoading(true);
setError(null);
try {
const response = await searchTracks({
query: debouncedSearchQuery.trim() || undefined,
page,
limit: 20,
});
setTracks(response.tracks);
setTotal(response.pagination.total);
} catch (err) {
let errorMessage = 'Erreur lors de la recherche';
if (err instanceof TrackSearchError) {
errorMessage = err.message;
} else if (err instanceof Error) {
errorMessage = err.message;
}
setError(errorMessage);
setTracks([]);
setTotal(0);
} finally {
setLoading(false);
}
}, [debouncedSearchQuery, page]);
// Effectuer la recherche quand la query debounced change
useEffect(() => {
if (debouncedSearchQuery.trim() || open) {
performSearch();
} else {
setTracks([]);
setTotal(0);
}
}, [debouncedSearchQuery, open, performSearch]);
// Réinitialiser quand le modal s'ouvre
useEffect(() => {
if (open) {
setSearchQuery('');
setSelectedTracks(new Set());
setPage(1);
setError(null);
}
}, [open]);
const handleTrackToggle = (trackId: string) => {
setSelectedTracks((prev) => {
const newSet = new Set(prev);
if (newSet.has(trackId)) {
newSet.delete(trackId);
} else {
newSet.add(trackId);
}
return newSet;
});
};
const handleSelectAll = () => {
if (selectedTracks.size === tracks.length) {
setSelectedTracks(new Set());
} else {
setSelectedTracks(new Set(tracks.map((t) => t.id)));
}
};
const handleAddTracks = async () => {
if (selectedTracks.size === 0) {
showError('Aucun track sélectionné');
return;
}
setAddingTracks(true);
const trackIds = Array.from(selectedTracks);
let successCount = 0;
let errorCount = 0;
try {
// Ajouter les tracks un par un
for (const trackId of trackIds) {
try {
await addTrackMutation.mutateAsync({
playlistId, // playlistId is already string
trackId,
});
successCount++;
} catch (err) {
errorCount++;
logger.error(`Failed to add track ${trackId}:`, { error: err });
}
}
if (successCount > 0) {
showSuccess(
`${successCount} track${successCount > 1 ? 's' : ''} ajouté${successCount > 1 ? 's' : ''} à la playlist.`,
);
setSelectedTracks(new Set());
onTracksAdded?.();
onClose();
}
if (errorCount > 0) {
showError(
`${errorCount} track${errorCount > 1 ? 's' : ''} n'a${errorCount > 1 ? 'ont' : ''} pas pu être ajouté${errorCount > 1 ? 's' : ''}.`,
);
}
} finally {
setAddingTracks(false);
}
};
const handleClose = () => {
setSearchQuery('');
setSelectedTracks(new Set());
setPage(1);
setError(null);
onClose();
};
return (
<Modal
open={open}
onClose={handleClose}
title="Ajouter des tracks à la playlist"
size="xl"
>
<div className="space-y-4">
{/* Recherche */}
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
type="text"
placeholder="Rechercher des tracks..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-10"
/>
</div>
{/* Liste des tracks avec sélection */}
<div className="border rounded-lg max-h-[400px] overflow-y-auto">
{loading && tracks.length === 0 ? (
<div className="flex items-center justify-center p-8">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
<span className="ml-2 text-muted-foreground">
Recherche en cours...
</span>
</div>
) : error ? (
<div className="p-8 text-center text-destructive">
<p>{error}</p>
</div>
) : tracks.length === 0 ? (
<div className="p-8 text-center text-muted-foreground">
<p>Aucun track trouvé</p>
</div>
) : (
<>
{/* En-tête avec sélection multiple */}
<div className="sticky top-0 bg-background border-b p-4 flex items-center justify-between">
<div className="flex items-center space-x-2">
<Checkbox
checked={
tracks.length > 0 && selectedTracks.size === tracks.length
}
onCheckedChange={handleSelectAll}
disabled={loading}
/>
<span className="text-sm font-medium">
{selectedTracks.size > 0
? `${selectedTracks.size} track${selectedTracks.size > 1 ? 's' : ''} sélectionné${selectedTracks.size > 1 ? 's' : ''}`
: 'Sélectionner tout'}
</span>
</div>
{total > 0 && (
<span className="text-sm text-muted-foreground">
{total} track{total > 1 ? 's' : ''} trouvé
{total > 1 ? 's' : ''}
</span>
)}
</div>
{/* Liste des tracks */}
<div className="divide-y">
{tracks.map((track) => {
const isSelected = selectedTracks.has(track.id);
return (
<div
key={track.id}
className={cn(
'p-4 flex items-center space-x-4 hover:bg-muted/50 transition-colors',
isSelected && 'bg-muted/30',
)}
>
<Checkbox
checked={isSelected}
onCheckedChange={() => handleTrackToggle(track.id)}
/>
<div className="flex-1 min-w-0">
<p className="font-medium truncate">{track.title}</p>
<p className="text-sm text-muted-foreground truncate">
{track.artist}
{track.album && `${track.album}`}
</p>
</div>
<div className="text-sm text-muted-foreground">
{Math.floor(track.duration / 60)}:
{String(track.duration % 60).padStart(2, '0')}
</div>
</div>
);
})}
</div>
{/* Pagination simple */}
{total > 20 && (
<div className="p-4 border-t flex items-center justify-between">
<span className="text-sm text-muted-foreground">
Page {page} sur {Math.ceil(total / 20)}
</span>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page === 1 || loading}
>
Précédent
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setPage((p) => p + 1)}
disabled={page >= Math.ceil(total / 20) || loading}
>
Suivant
</Button>
</div>
</div>
)}
</>
)}
</div>
{/* Actions */}
<div className="flex items-center justify-between pt-4 border-t">
<span className="text-sm text-muted-foreground">
{selectedTracks.size > 0
? `${selectedTracks.size} track${selectedTracks.size > 1 ? 's' : ''} sélectionné${selectedTracks.size > 1 ? 's' : ''}`
: 'Sélectionnez des tracks à ajouter'}
</span>
<div className="flex gap-2">
<Button
variant="outline"
onClick={handleClose}
disabled={addingTracks}
>
Annuler
</Button>
<Button
onClick={handleAddTracks}
disabled={selectedTracks.size === 0 || addingTracks}
>
{addingTracks && <Spinner size="sm" className="mr-2" />}
<Plus className="w-4 h-4 mr-2" />
Ajouter {selectedTracks.size > 0 && `(${selectedTracks.size})`}
</Button>
</div>
</div>
</div>
</Modal>
);
}
export {
AddTrackToPlaylistModal,
AddTrackToPlaylistModalSkeleton,
} from './add-track-to-playlist-modal';
export type { AddTrackToPlaylistModalProps } from './add-track-to-playlist-modal';

View file

@ -0,0 +1,69 @@
/**
* AddTrackToPlaylistModal orchestration: Modal + hook + Search + List + Footer.
*/
import { Modal } from '@/components/ui/modal';
import { useAddTrackToPlaylistModal } from './useAddTrackToPlaylistModal';
import { AddTrackToPlaylistModalSearch } from './AddTrackToPlaylistModalSearch';
import { AddTrackToPlaylistModalList } from './AddTrackToPlaylistModalList';
import { AddTrackToPlaylistModalFooter } from './AddTrackToPlaylistModalFooter';
import type { AddTrackToPlaylistModalProps } from './types';
export function AddTrackToPlaylistModal({
open,
onClose,
playlistId,
onTracksAdded,
}: AddTrackToPlaylistModalProps) {
const {
searchQuery,
setSearchQuery,
tracks,
selectedTracks,
loading,
error,
page,
total,
totalPages,
addingTracks,
handleTrackToggle,
handleSelectAll,
handleAddTracks,
handleClose,
setPage,
} = useAddTrackToPlaylistModal(open, playlistId, onClose, onTracksAdded);
return (
<Modal
open={open}
onClose={handleClose}
title="Ajouter des tracks à la playlist"
size="xl"
>
<div className="space-y-4">
<AddTrackToPlaylistModalSearch
value={searchQuery}
onChange={setSearchQuery}
/>
<AddTrackToPlaylistModalList
loading={loading}
error={error}
tracks={tracks}
selectedTracks={selectedTracks}
total={total}
page={page}
totalPages={totalPages}
onTrackToggle={handleTrackToggle}
onSelectAll={handleSelectAll}
onPagePrev={() => setPage((p) => Math.max(1, p - 1))}
onPageNext={() => setPage((p) => p + 1)}
/>
<AddTrackToPlaylistModalFooter
selectedCount={selectedTracks.size}
addingTracks={addingTracks}
onCancel={handleClose}
onAdd={handleAddTracks}
/>
</div>
</Modal>
);
}

View file

@ -0,0 +1,43 @@
/**
* AddTrackToPlaylistModal footer: selected count + Cancel + Add.
*/
import { Button } from '@/components/ui/button';
import { Plus } from 'lucide-react';
import { Spinner } from '@/components/ui/Spinner';
interface AddTrackToPlaylistModalFooterProps {
selectedCount: number;
addingTracks: boolean;
onCancel: () => void;
onAdd: () => void;
}
export function AddTrackToPlaylistModalFooter({
selectedCount,
addingTracks,
onCancel,
onAdd,
}: AddTrackToPlaylistModalFooterProps) {
return (
<div className="flex items-center justify-between pt-4 border-t">
<span className="text-sm text-muted-foreground">
{selectedCount > 0
? `${selectedCount} track${selectedCount > 1 ? 's' : ''} sélectionné${selectedCount > 1 ? 's' : ''}`
: 'Sélectionnez des tracks à ajouter'}
</span>
<div className="flex gap-2">
<Button variant="outline" onClick={onCancel} disabled={addingTracks}>
Annuler
</Button>
<Button
onClick={onAdd}
disabled={selectedCount === 0 || addingTracks}
>
{addingTracks && <Spinner size="sm" className="mr-2" />}
<Plus className="w-4 h-4 mr-2" />
Ajouter {selectedCount > 0 && `(${selectedCount})`}
</Button>
</div>
</div>
);
}

View file

@ -0,0 +1,118 @@
/**
* AddTrackToPlaylistModal list area: loading, error, empty, or header + rows + pagination. max-h-96.
*/
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { Spinner } from '@/components/ui/Spinner';
import { AddTrackToPlaylistModalTrackRow } from './AddTrackToPlaylistModalTrackRow';
import type { Track } from '@/features/tracks/types/track';
interface AddTrackToPlaylistModalListProps {
loading: boolean;
error: string | null;
tracks: Track[];
selectedTracks: Set<string>;
total: number;
page: number;
totalPages: number;
onTrackToggle: (trackId: string) => void;
onSelectAll: () => void;
onPagePrev: () => void;
onPageNext: () => void;
}
export function AddTrackToPlaylistModalList({
loading,
error,
tracks,
selectedTracks,
total,
page,
totalPages,
onTrackToggle,
onSelectAll,
onPagePrev,
onPageNext,
}: AddTrackToPlaylistModalListProps) {
if (loading && tracks.length === 0) {
return (
<div className="flex items-center justify-center p-8 max-h-96">
<Spinner className="h-6 w-6 text-muted-foreground" />
<span className="ml-2 text-muted-foreground">Recherche en cours...</span>
</div>
);
}
if (error) {
return (
<div className="p-8 text-center text-destructive max-h-96">
<p>{error}</p>
</div>
);
}
if (tracks.length === 0) {
return (
<div className="p-8 text-center text-muted-foreground max-h-96">
<p>Aucun track trouvé</p>
</div>
);
}
const allSelected = tracks.length > 0 && selectedTracks.size === tracks.length;
return (
<div className="border rounded-lg max-h-96 overflow-y-auto flex flex-col">
<div className="sticky top-0 bg-background border-b p-4 flex items-center justify-between shrink-0">
<div className="flex items-center space-x-2">
<Checkbox
checked={allSelected}
onCheckedChange={onSelectAll}
disabled={loading}
/>
<span className="text-sm font-medium">
{selectedTracks.size > 0
? `${selectedTracks.size} track${selectedTracks.size > 1 ? 's' : ''} sélectionné${selectedTracks.size > 1 ? 's' : ''}`
: 'Sélectionner tout'}
</span>
</div>
{total > 0 && (
<span className="text-sm text-muted-foreground">
{total} track{total > 1 ? 's' : ''} trouvé{total > 1 ? 's' : ''}
</span>
)}
</div>
<div className="divide-y overflow-y-auto flex-1 min-h-0">
{tracks.map((track) => (
<AddTrackToPlaylistModalTrackRow
key={track.id}
track={track}
isSelected={selectedTracks.has(track.id)}
onToggle={() => onTrackToggle(track.id)}
/>
))}
</div>
{totalPages > 1 && (
<div className="p-4 border-t flex items-center justify-between shrink-0">
<span className="text-sm text-muted-foreground">
Page {page} sur {totalPages}
</span>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={onPagePrev}
disabled={page <= 1 || loading}
>
Précédent
</Button>
<Button
variant="outline"
size="sm"
onClick={onPageNext}
disabled={page >= totalPages || loading}
>
Suivant
</Button>
</div>
</div>
)}
</div>
);
}

View file

@ -0,0 +1,28 @@
/**
* AddTrackToPlaylistModal search input.
*/
import { Input } from '@/components/ui/input';
import { Search } from 'lucide-react';
interface AddTrackToPlaylistModalSearchProps {
value: string;
onChange: (value: string) => void;
}
export function AddTrackToPlaylistModalSearch({
value,
onChange,
}: AddTrackToPlaylistModalSearchProps) {
return (
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
type="text"
placeholder="Rechercher des tracks..."
value={value}
onChange={(e) => onChange(e.target.value)}
className="pl-10"
/>
</div>
);
}

View file

@ -0,0 +1,39 @@
/**
* AddTrackToPlaylistModal skeleton for list area (story Loading).
*/
import { Skeleton } from '@/components/ui/skeleton';
import { cn } from '@/lib/utils';
interface AddTrackToPlaylistModalSkeletonProps {
className?: string;
}
export function AddTrackToPlaylistModalSkeleton({
className,
}: AddTrackToPlaylistModalSkeletonProps) {
return (
<div className={cn('space-y-4', className)}>
<Skeleton className="h-10 w-full" />
<div className="border rounded-lg max-h-96 overflow-hidden p-4 space-y-3">
<div className="flex items-center gap-2">
<Skeleton className="h-4 w-4" />
<Skeleton className="h-4 w-32" />
</div>
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="flex items-center gap-4">
<Skeleton className="h-4 w-4" />
<div className="flex-1 space-y-1">
<Skeleton className="h-4 w-3/4" />
<Skeleton className="h-3 w-1/2" />
</div>
<Skeleton className="h-4 w-10" />
</div>
))}
</div>
<div className="flex justify-end gap-2 pt-4 border-t">
<Skeleton className="h-9 w-20" />
<Skeleton className="h-9 w-24" />
</div>
</div>
);
}

View file

@ -0,0 +1,39 @@
/**
* AddTrackToPlaylistModal single track row (checkbox + title/artist + duration).
*/
import { Checkbox } from '@/components/ui/checkbox';
import { cn } from '@/lib/utils';
import type { Track } from '@/features/tracks/types/track';
interface AddTrackToPlaylistModalTrackRowProps {
track: Track;
isSelected: boolean;
onToggle: () => void;
}
export function AddTrackToPlaylistModalTrackRow({
track,
isSelected,
onToggle,
}: AddTrackToPlaylistModalTrackRowProps) {
const album = (track as Track & { album?: string }).album;
const durationStr = `${Math.floor(track.duration / 60)}:${String(track.duration % 60).padStart(2, '0')}`;
return (
<div
className={cn(
'p-4 flex items-center space-x-4 hover:bg-muted/50 transition-colors',
isSelected && 'bg-muted/30',
)}
>
<Checkbox checked={isSelected} onCheckedChange={onToggle} />
<div className="flex-1 min-w-0">
<p className="font-medium truncate">{track.title}</p>
<p className="text-sm text-muted-foreground truncate">
{track.artist}
{album != null && album !== '' ? `${album}` : ''}
</p>
</div>
<div className="text-sm text-muted-foreground">{durationStr}</div>
</div>
);
}

View file

@ -0,0 +1,6 @@
/**
* AddTrackToPlaylistModal public API.
*/
export { AddTrackToPlaylistModal } from './AddTrackToPlaylistModal';
export { AddTrackToPlaylistModalSkeleton } from './AddTrackToPlaylistModalSkeleton';
export type { AddTrackToPlaylistModalProps } from './types';

View file

@ -0,0 +1,9 @@
/**
* AddTrackToPlaylistModal public props.
*/
export interface AddTrackToPlaylistModalProps {
open: boolean;
onClose: () => void;
playlistId: string;
onTracksAdded?: () => void;
}

View file

@ -0,0 +1,171 @@
/**
* AddTrackToPlaylistModal search, selection, add tracks.
*/
import { useState, useEffect, useCallback } from 'react';
import { useDebounce } from '@/hooks/useDebounce';
import { useToast } from '@/hooks/useToast';
import { useAddTrackToPlaylist } from '../../hooks/usePlaylist';
import {
searchTracks,
TrackSearchError,
} from '@/features/tracks/services/trackSearchService';
import type { Track } from '@/features/tracks/types/track';
import { logger } from '@/utils/logger';
const PAGE_SIZE = 20;
export function useAddTrackToPlaylistModal(
open: boolean,
playlistId: string,
onClose: () => void,
onTracksAdded?: () => void,
) {
const [searchQuery, setSearchQuery] = useState('');
const debouncedSearchQuery = useDebounce(searchQuery, 500);
const [tracks, setTracks] = useState<Track[]>([]);
const [selectedTracks, setSelectedTracks] = useState<Set<string>>(new Set());
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [page, setPage] = useState(1);
const [total, setTotal] = useState(0);
const [addingTracks, setAddingTracks] = useState(false);
const { success: showSuccess, error: showError } = useToast();
const addTrackMutation = useAddTrackToPlaylist();
const performSearch = useCallback(async () => {
setLoading(true);
setError(null);
try {
const response = await searchTracks({
query: debouncedSearchQuery.trim() || undefined,
page,
limit: PAGE_SIZE,
});
setTracks(response.tracks);
setTotal(response.pagination.total);
} catch (err) {
let errorMessage = 'Erreur lors de la recherche';
if (err instanceof TrackSearchError) {
errorMessage = err.message;
} else if (err instanceof Error) {
errorMessage = err.message;
}
setError(errorMessage);
setTracks([]);
setTotal(0);
} finally {
setLoading(false);
}
}, [debouncedSearchQuery, page]);
useEffect(() => {
if (debouncedSearchQuery.trim() || open) {
performSearch();
} else {
setTracks([]);
setTotal(0);
}
}, [debouncedSearchQuery, open, performSearch]);
useEffect(() => {
if (open) {
setSearchQuery('');
setSelectedTracks(new Set());
setPage(1);
setError(null);
}
}, [open]);
const handleTrackToggle = useCallback((trackId: string) => {
setSelectedTracks((prev) => {
const next = new Set(prev);
if (next.has(trackId)) next.delete(trackId);
else next.add(trackId);
return next;
});
}, []);
const handleSelectAll = useCallback(() => {
setSelectedTracks((prev) => {
if (prev.size === tracks.length) return new Set();
return new Set(tracks.map((t) => t.id));
});
}, [tracks]);
const handleAddTracks = useCallback(async () => {
if (selectedTracks.size === 0) {
showError('Aucun track sélectionné');
return;
}
setAddingTracks(true);
const trackIds = Array.from(selectedTracks);
let successCount = 0;
let errorCount = 0;
try {
for (const trackId of trackIds) {
try {
await addTrackMutation.mutateAsync({ playlistId, trackId });
successCount++;
} catch (err) {
errorCount++;
logger.error(`Failed to add track ${trackId}:`, { error: err });
}
}
if (successCount > 0) {
showSuccess(
`${successCount} track${successCount > 1 ? 's' : ''} ajouté${successCount > 1 ? 's' : ''} à la playlist.`,
);
setSelectedTracks(new Set());
onTracksAdded?.();
onClose();
}
if (errorCount > 0) {
showError(
`${errorCount} track${errorCount > 1 ? 's' : ''} n'a${errorCount > 1 ? 'ont' : ''} pas pu être ajouté${errorCount > 1 ? 's' : ''}.`,
);
}
} finally {
setAddingTracks(false);
}
}, [
selectedTracks,
playlistId,
addTrackMutation,
showSuccess,
showError,
onTracksAdded,
onClose,
]);
const handleClose = useCallback(() => {
setSearchQuery('');
setSelectedTracks(new Set());
setPage(1);
setError(null);
onClose();
}, [onClose]);
const totalPages = Math.ceil(total / PAGE_SIZE);
const setPageSafe = useCallback((updater: (p: number) => number) => {
setPage(updater);
}, []);
return {
searchQuery,
setSearchQuery,
tracks,
selectedTracks,
loading,
error,
page,
total,
totalPages,
addingTracks,
handleTrackToggle,
handleSelectAll,
handleAddTracks,
handleClose,
setPage: setPageSafe,
};
}