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:
parent
7ed4a8decd
commit
157bda45e2
11 changed files with 538 additions and 323 deletions
|
|
@ -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>
|
||||
),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
/**
|
||||
* AddTrackToPlaylistModal — public API.
|
||||
*/
|
||||
export { AddTrackToPlaylistModal } from './AddTrackToPlaylistModal';
|
||||
export { AddTrackToPlaylistModalSkeleton } from './AddTrackToPlaylistModalSkeleton';
|
||||
export type { AddTrackToPlaylistModalProps } from './types';
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
/**
|
||||
* AddTrackToPlaylistModal — public props.
|
||||
*/
|
||||
export interface AddTrackToPlaylistModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
playlistId: string;
|
||||
onTracksAdded?: () => void;
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
Loading…
Reference in a new issue