327 lines
10 KiB
TypeScript
327 lines
10 KiB
TypeScript
/**
|
|
* 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 {
|
|
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(','),
|
|
...rows.map((row) =>
|
|
row.map((cell) => `"${cell.replace(/"/g, '""')}"`).join(','),
|
|
),
|
|
].join('\n');
|
|
|
|
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);
|
|
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) {
|
|
showSuccess(
|
|
`${successCount} playlist${successCount > 1 ? 's' : ''} supprimée${successCount > 1 ? 's' : ''} avec succès.`,
|
|
);
|
|
onSelectionClear();
|
|
onPlaylistsDeleted?.();
|
|
}
|
|
|
|
if (errorCount > 0) {
|
|
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) {
|
|
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);
|
|
showSuccess(
|
|
`${shareLinks.length} lien${shareLinks.length > 1 ? 's' : ''} copié${shareLinks.length > 1 ? 's' : ''} dans le presse-papiers.`,
|
|
);
|
|
onSelectionClear();
|
|
} else {
|
|
showError('Impossible de créer les liens de partage.');
|
|
}
|
|
} finally {
|
|
setIsSharing(false);
|
|
}
|
|
};
|
|
|
|
const handleExportJSON = () => {
|
|
try {
|
|
exportPlaylistsToJSON(selectedPlaylists);
|
|
showSuccess(
|
|
`${selectedCount} playlist${selectedCount > 1 ? 's' : ''} exportée${selectedCount > 1 ? 's' : ''} en JSON.`,
|
|
);
|
|
onSelectionClear();
|
|
} catch (error) {
|
|
showError("Impossible d'exporter les playlists.");
|
|
}
|
|
};
|
|
|
|
const handleExportCSV = () => {
|
|
try {
|
|
exportPlaylistsToCSV(selectedPlaylists);
|
|
showSuccess(
|
|
`${selectedCount} playlist${selectedCount > 1 ? 's' : ''} exportée${selectedCount > 1 ? 's' : ''} en CSV.`,
|
|
);
|
|
onSelectionClear();
|
|
} catch (error) {
|
|
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',
|
|
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">
|
|
{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 ? (
|
|
<>
|
|
<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 ? (
|
|
<>
|
|
<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">
|
|
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>
|
|
);
|
|
}
|