veza/apps/web/src/features/playlists/components/PlaylistBatchActions.tsx

328 lines
10 KiB
TypeScript
Raw Normal View History

/**
* Composant pour les actions batch sur les playlists
* T0506: Create Playlist Batch Operations
*/
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import { Dialog } from '@/components/ui/dialog';
import { Trash2, Share2, Download, X, Loader2 } from 'lucide-react';
import { useDeletePlaylist, useCreateShareLink } from '../hooks/usePlaylist';
import { useToast } from '@/hooks/useToast';
import { cn } from '@/lib/utils';
import type { Playlist } from '../types';
interface PlaylistBatchActionsProps {
selectedPlaylists: Playlist[];
onSelectionClear: () => void;
onPlaylistsDeleted?: () => void;
className?: string;
}
/**
* Exporte les playlists sélectionnées au format JSON
*/
function exportPlaylistsToJSON(playlists: Playlist[]): void {
const dataStr = JSON.stringify(playlists, null, 2);
const dataBlob = new Blob([dataStr], { type: 'application/json' });
const url = URL.createObjectURL(dataBlob);
const link = document.createElement('a');
link.href = url;
link.download = `playlists-${new Date().toISOString().split('T')[0]}.json`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
}
/**
* Exporte les playlists sélectionnées au format CSV
*/
function exportPlaylistsToCSV(playlists: Playlist[]): void {
2025-12-13 02:34:34 +00:00
const headers = [
'ID',
'Titre',
'Description',
'Publique',
'Nombre de tracks',
'Créée le',
];
const rows = playlists.map((p) => [
p.id.toString(),
p.title,
p.description || '',
p.is_public ? 'Oui' : 'Non',
p.track_count.toString(),
new Date(p.created_at).toLocaleDateString('fr-FR'),
]);
const csvContent = [
headers.join(','),
2025-12-13 02:34:34 +00:00
...rows.map((row) =>
row.map((cell) => `"${cell.replace(/"/g, '""')}"`).join(','),
),
].join('\n');
2025-12-13 02:34:34 +00:00
const dataBlob = new Blob([`\ufeff${csvContent}`], {
type: 'text/csv;charset=utf-8;',
});
const url = URL.createObjectURL(dataBlob);
const link = document.createElement('a');
link.href = url;
link.download = `playlists-${new Date().toISOString().split('T')[0]}.csv`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
}
export function PlaylistBatchActions({
selectedPlaylists,
onSelectionClear,
onPlaylistsDeleted,
className,
}: PlaylistBatchActionsProps) {
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const [isSharing, setIsSharing] = useState(false);
2025-12-13 02:34:34 +00:00
const { success: showSuccess, error: showError } = useToast();
const deleteMutation = useDeletePlaylist();
const createShareLinkMutation = useCreateShareLink();
const selectedCount = selectedPlaylists.length;
if (selectedCount === 0) {
return null;
}
const handleDelete = async () => {
setIsDeleting(true);
let successCount = 0;
let errorCount = 0;
try {
for (const playlist of selectedPlaylists) {
try {
await deleteMutation.mutateAsync(playlist.id);
successCount++;
} catch (error) {
errorCount++;
console.error(`Failed to delete playlist ${playlist.id}:`, error);
}
}
if (successCount > 0) {
2025-12-13 02:34:34 +00:00
showSuccess(
`${successCount} playlist${successCount > 1 ? 's' : ''} supprimée${successCount > 1 ? 's' : ''} avec succès.`,
);
onSelectionClear();
onPlaylistsDeleted?.();
}
if (errorCount > 0) {
2025-12-13 02:34:34 +00:00
showError(
`${errorCount} playlist${errorCount > 1 ? 's' : ''} n'a${errorCount > 1 ? 'ont' : ''} pas pu être supprimée${errorCount > 1 ? 's' : ''}.`,
);
}
} finally {
setIsDeleting(false);
setShowDeleteDialog(false);
}
};
const handleShare = async () => {
setIsSharing(true);
const shareLinks: string[] = [];
try {
for (const playlist of selectedPlaylists) {
try {
const result = await createShareLinkMutation.mutateAsync(playlist.id);
// Le résultat est un PlaylistShareLink avec share_token
if (result && typeof result === 'object' && 'share_token' in result) {
const shareUrl = `${window.location.origin}/playlists/shared/${result.share_token}`;
shareLinks.push(shareUrl);
}
} catch (error) {
2025-12-13 02:34:34 +00:00
console.error(
`Failed to create share link for playlist ${playlist.id}:`,
error,
);
}
}
if (shareLinks.length > 0) {
// Copier les liens dans le presse-papiers
const linksText = shareLinks.join('\n');
await navigator.clipboard.writeText(linksText);
2025-12-13 02:34:34 +00:00
showSuccess(
`${shareLinks.length} lien${shareLinks.length > 1 ? 's' : ''} copié${shareLinks.length > 1 ? 's' : ''} dans le presse-papiers.`,
);
onSelectionClear();
} else {
2025-12-13 02:34:34 +00:00
showError('Impossible de créer les liens de partage.');
}
} finally {
setIsSharing(false);
}
};
const handleExportJSON = () => {
try {
exportPlaylistsToJSON(selectedPlaylists);
2025-12-13 02:34:34 +00:00
showSuccess(
`${selectedCount} playlist${selectedCount > 1 ? 's' : ''} exportée${selectedCount > 1 ? 's' : ''} en JSON.`,
);
onSelectionClear();
} catch (error) {
2025-12-13 02:34:34 +00:00
showError("Impossible d'exporter les playlists.");
}
};
const handleExportCSV = () => {
try {
exportPlaylistsToCSV(selectedPlaylists);
2025-12-13 02:34:34 +00:00
showSuccess(
`${selectedCount} playlist${selectedCount > 1 ? 's' : ''} exportée${selectedCount > 1 ? 's' : ''} en CSV.`,
);
onSelectionClear();
} catch (error) {
2025-12-13 02:34:34 +00:00
showError("Impossible d'exporter les playlists.");
}
};
return (
<div
className={cn(
'flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3 sm:gap-4',
'p-4 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg',
'sticky top-0 z-10 backdrop-blur-sm',
2025-12-13 02:34:34 +00:00
className,
)}
role="region"
aria-label={`Actions batch pour ${selectedCount} playlist${selectedCount > 1 ? 's' : ''} sélectionnée${selectedCount > 1 ? 's' : ''}`}
>
<div className="flex items-center gap-3">
<span className="text-sm font-medium text-blue-900 dark:text-blue-100">
2025-12-13 02:34:34 +00:00
{selectedCount} playlist{selectedCount > 1 ? 's' : ''} sélectionnée
{selectedCount > 1 ? 's' : ''}
</span>
<Button
variant="ghost"
size="sm"
onClick={onSelectionClear}
className="h-8 w-8 p-0 touch-manipulation"
aria-label="Désélectionner toutes les playlists"
>
<X className="h-4 w-4" aria-hidden="true" />
</Button>
</div>
<div className="flex flex-wrap items-center gap-2 w-full sm:w-auto">
<Button
variant="outline"
size="sm"
onClick={handleShare}
disabled={isSharing || isDeleting}
className="touch-manipulation min-h-[44px] sm:min-h-0 flex-1 sm:flex-initial"
aria-label="Partager les playlists sélectionnées"
>
{isSharing ? (
<>
2025-12-13 02:34:34 +00:00
<Loader2
className="h-4 w-4 sm:mr-2 animate-spin"
aria-hidden="true"
/>
<span className="hidden sm:inline">Partage...</span>
</>
) : (
<>
<Share2 className="h-4 w-4 sm:mr-2" aria-hidden="true" />
<span className="hidden sm:inline">Partager</span>
</>
)}
</Button>
<Button
variant="outline"
size="sm"
onClick={handleExportJSON}
disabled={isSharing || isDeleting}
className="touch-manipulation min-h-[44px] sm:min-h-0 flex-1 sm:flex-initial"
aria-label="Exporter en JSON"
>
<Download className="h-4 w-4 sm:mr-2" aria-hidden="true" />
<span className="hidden sm:inline">JSON</span>
</Button>
<Button
variant="outline"
size="sm"
onClick={handleExportCSV}
disabled={isSharing || isDeleting}
className="touch-manipulation min-h-[44px] sm:min-h-0 flex-1 sm:flex-initial"
aria-label="Exporter en CSV"
>
<Download className="h-4 w-4 sm:mr-2" aria-hidden="true" />
<span className="hidden sm:inline">CSV</span>
</Button>
<Button
variant="destructive"
size="sm"
onClick={() => setShowDeleteDialog(true)}
disabled={isSharing || isDeleting}
className="touch-manipulation min-h-[44px] sm:min-h-0 flex-1 sm:flex-initial"
aria-label="Supprimer les playlists sélectionnées"
>
{isDeleting ? (
<>
2025-12-13 02:34:34 +00:00
<Loader2
className="h-4 w-4 sm:mr-2 animate-spin"
aria-hidden="true"
/>
<span className="hidden sm:inline">Suppression...</span>
</>
) : (
<>
<Trash2 className="h-4 w-4 sm:mr-2" aria-hidden="true" />
<span className="hidden sm:inline">Supprimer</span>
</>
)}
</Button>
</div>
{/* Delete Confirmation Dialog */}
<Dialog
open={showDeleteDialog}
onClose={() => setShowDeleteDialog(false)}
title="Supprimer les playlists ?"
variant="alert"
onConfirm={handleDelete}
onCancel={() => setShowDeleteDialog(false)}
confirmLabel={isDeleting ? 'Suppression...' : 'Supprimer'}
cancelLabel="Annuler"
showCancel={true}
size="md"
aria-label="Dialogue de confirmation de suppression batch"
>
<div className="space-y-4">
<p className="text-sm text-muted-foreground">
2025-12-13 02:34:34 +00:00
Vous êtes sur le point de supprimer <strong>{selectedCount}</strong>{' '}
playlist{selectedCount > 1 ? 's' : ''}. Cette action est
irréversible.
</p>
<div className="p-3 bg-muted rounded-md max-h-48 overflow-y-auto">
<p className="text-sm font-medium mb-2">Playlists à supprimer :</p>
<ul className="text-sm text-muted-foreground space-y-1">
{selectedPlaylists.map((playlist) => (
<li key={playlist.id}> {playlist.title}</li>
))}
</ul>
</div>
</div>
</Dialog>
</div>
);
}