Bloc A - Code mort: - Suppression Studio (components, views, features) - Suppression gamification + services mock (projectService, storageService, gamificationService) - Mise à jour Sidebar, Navbar, locales Bloc B - Frontend: - Suppression modal.tsx deprecated, Modal.stories (doublon Dialog) - Feature flags: PLAYLIST_SEARCH, PLAYLIST_RECOMMENDATIONS, ROLE_MANAGEMENT = true - Suppression 19 tests orphelins, retrait exclusions vitest.config Bloc C - Backend: - Extraction routes_auth.go depuis router.go Bloc D - Rust: - Suppression security_legacy.rs (code mort, patterns déjà dans security/)
254 lines
9 KiB
TypeScript
254 lines
9 KiB
TypeScript
import React, { useState } from 'react';
|
|
import { useAudio } from '../../../context/AudioContext';
|
|
import { Card } from '../../ui/card';
|
|
import { Button } from '../../ui/button';
|
|
import { EmptyState } from '../../ui/empty-state';
|
|
import { SaveQueueAsPlaylistModal } from './SaveQueueAsPlaylistModal';
|
|
import {
|
|
createPlaylist,
|
|
addTrack,
|
|
} from '../../../features/playlists/services/playlistService';
|
|
import {
|
|
Play,
|
|
Pause,
|
|
X,
|
|
GripVertical,
|
|
Trash2,
|
|
Save,
|
|
ListMusic,
|
|
} from 'lucide-react';
|
|
import { useToast } from '../../../components/feedback/ToastProvider';
|
|
|
|
export const QueueView: React.FC = () => {
|
|
const {
|
|
queue,
|
|
currentTrack,
|
|
reorderQueue,
|
|
removeFromQueue,
|
|
clearQueue,
|
|
playTrack,
|
|
isPlaying,
|
|
togglePlay,
|
|
autoplay,
|
|
toggleAutoplay,
|
|
} = useAudio();
|
|
const { addToast } = useToast();
|
|
const [showSaveModal, setShowSaveModal] = useState(false);
|
|
const [draggedIndex, setDraggedIndex] = useState<number | null>(null);
|
|
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null);
|
|
|
|
const handleDragStart = (e: React.DragEvent, index: number) => {
|
|
setDraggedIndex(index);
|
|
e.dataTransfer.effectAllowed = 'move';
|
|
// Transparent ghost image
|
|
const ghost = document.createElement('div');
|
|
ghost.style.opacity = '0';
|
|
document.body.appendChild(ghost);
|
|
e.dataTransfer.setDragImage(ghost, 0, 0);
|
|
setTimeout(() => document.body.removeChild(ghost), 0);
|
|
};
|
|
|
|
const handleDragOver = (e: React.DragEvent, index: number) => {
|
|
e.preventDefault();
|
|
setDragOverIndex(index);
|
|
if (draggedIndex === null || draggedIndex === index) return;
|
|
reorderQueue(draggedIndex, index);
|
|
setDraggedIndex(index);
|
|
};
|
|
|
|
const handleDragEnd = () => {
|
|
setDraggedIndex(null);
|
|
setDragOverIndex(null);
|
|
};
|
|
|
|
const handleSavePlaylist = async (name: string, isPublic: boolean) => {
|
|
const tracksToSave = [
|
|
...(currentTrack ? [currentTrack] : []),
|
|
...queue,
|
|
];
|
|
if (tracksToSave.length === 0) {
|
|
addToast('Queue is empty', 'error');
|
|
throw new Error('Queue is empty');
|
|
}
|
|
const playlist = await createPlaylist({
|
|
title: name,
|
|
description: '',
|
|
is_public: isPublic,
|
|
});
|
|
for (const track of tracksToSave) {
|
|
await addTrack(playlist.id, String(track.id));
|
|
}
|
|
addToast(`Queue saved as "${name}"`, 'success');
|
|
};
|
|
|
|
return (
|
|
<div className="max-w-4xl mx-auto space-y-6 animate-fadeIn pb-20">
|
|
<div className="flex flex-col md:flex-row justify-between items-end border-b border-border/50 pb-6 gap-4">
|
|
<div>
|
|
<h1 className="text-3xl font-heading font-bold text-foreground mb-2">
|
|
PLAY QUEUE
|
|
</h1>
|
|
<p className="text-muted-foreground font-mono text-sm">
|
|
{queue.length} tracks upcoming
|
|
</p>
|
|
</div>
|
|
<div className="flex gap-4">
|
|
<Button
|
|
variant="ghost"
|
|
onClick={() => setShowSaveModal(true)}
|
|
icon={<Save className="w-4 h-4" />}
|
|
>
|
|
Save Queue
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
className="text-destructive hover:bg-destructive/10"
|
|
onClick={clearQueue}
|
|
icon={<Trash2 className="w-4 h-4" />}
|
|
>
|
|
Clear
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Current Track */}
|
|
{currentTrack && (
|
|
<div>
|
|
<h3 className="text-xs font-bold text-muted-foreground uppercase tracking-widest mb-3">
|
|
Now Playing
|
|
</h3>
|
|
<Card
|
|
variant="glass"
|
|
className="flex items-center gap-4 p-4 border-l-4 border-l-primary"
|
|
>
|
|
<div
|
|
className="relative w-16 h-16 rounded overflow-hidden flex-shrink-0 group cursor-pointer"
|
|
onClick={togglePlay}
|
|
>
|
|
<img
|
|
src={currentTrack.coverUrl}
|
|
className="w-full h-full object-cover"
|
|
/>
|
|
<div className="absolute inset-0 bg-background/40 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity">
|
|
{isPlaying ? (
|
|
<Pause className="w-6 h-6 text-foreground" />
|
|
) : (
|
|
<Play className="w-6 h-6 text-foreground fill-current ml-1" />
|
|
)}
|
|
</div>
|
|
{isPlaying && (
|
|
<div className="absolute bottom-1 right-1 flex gap-0.5 items-end h-3">
|
|
<div className="w-1 bg-primary animate-[bounce_1s_infinite] h-full"></div>
|
|
<div className="w-1 bg-primary animate-[bounce_1.2s_infinite] h-2/3"></div>
|
|
<div className="w-1 bg-primary animate-[bounce_0.8s_infinite] h-full"></div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
<div className="flex-1">
|
|
<h2 className="text-xl font-bold text-foreground">
|
|
{currentTrack.title}
|
|
</h2>
|
|
<p className="text-muted-foreground">{currentTrack.artist}</p>
|
|
</div>
|
|
<div className="text-muted-foreground font-mono text-sm hidden md:block">
|
|
{currentTrack.duration}
|
|
</div>
|
|
</Card>
|
|
</div>
|
|
)}
|
|
|
|
{/* Up Next */}
|
|
<div>
|
|
<div className="flex justify-between items-center mb-3">
|
|
<h3 className="text-xs font-bold text-muted-foreground uppercase tracking-widest">
|
|
Up Next
|
|
</h3>
|
|
<div
|
|
className="flex items-center gap-2 cursor-pointer group"
|
|
onClick={toggleAutoplay}
|
|
>
|
|
<span
|
|
className={`text-xs font-bold ${autoplay ? 'text-success' : 'text-muted-foreground'}`}
|
|
>
|
|
Autoplay
|
|
</span>
|
|
<div
|
|
className={`w-8 h-4 rounded-full relative transition-colors ${autoplay ? 'bg-success' : 'bg-muted'}`}
|
|
>
|
|
<div
|
|
className={`absolute top-0.5 w-3 h-3 bg-background rounded-full transition-all ${autoplay ? 'left-4.5' : 'left-0.5'}`}
|
|
></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
{queue.length === 0 ? (
|
|
<EmptyState
|
|
variant="card"
|
|
icon={<ListMusic className="w-full h-full" />}
|
|
title="Nothing in your queue"
|
|
description={
|
|
autoplay
|
|
? 'Autoplay is on — we\u2019ll pick something for you.'
|
|
: 'Start playing music and add tracks to build your queue.'
|
|
}
|
|
size="md"
|
|
/>
|
|
) : (
|
|
queue.map((track, i) => (
|
|
<div
|
|
key={`${track.id}-${i}`}
|
|
draggable
|
|
onDragStart={(e) => handleDragStart(e, i)}
|
|
onDragOver={(e) => handleDragOver(e, i)}
|
|
onDragEnd={handleDragEnd}
|
|
className={`flex items-center gap-4 p-4 bg-card rounded-lg border border-transparent hover:border-border transition-all group ${draggedIndex === i ? 'opacity-50 border-primary shadow-lg scale-[1.02] cursor-grabbing' : ''} ${dragOverIndex === i && draggedIndex !== null && draggedIndex !== i ? 'border-t-2 border-t-primary bg-primary/5' : ''}`}
|
|
>
|
|
<div className="text-muted-foreground cursor-grab active:cursor-grabbing hover:text-foreground p-1">
|
|
<GripVertical className="w-5 h-5" />
|
|
</div>
|
|
<div className="w-10 h-10 rounded overflow-hidden flex-shrink-0 relative">
|
|
<img
|
|
src={track.coverUrl}
|
|
className="w-full h-full object-cover"
|
|
/>
|
|
<div
|
|
className="absolute inset-0 bg-background/50 hidden group-hover:flex items-center justify-center cursor-pointer"
|
|
onClick={() => playTrack(track)}
|
|
>
|
|
<Play className="w-4 h-4 text-foreground fill-current" />
|
|
</div>
|
|
</div>
|
|
<div className="flex-1 min-w-0">
|
|
<div className="text-sm font-bold text-foreground truncate">
|
|
{track.title}
|
|
</div>
|
|
<div className="text-xs text-muted-foreground truncate">
|
|
{track.artist}
|
|
</div>
|
|
</div>
|
|
<div className="text-muted-foreground font-mono text-xs hidden sm:block">
|
|
{track.duration}
|
|
</div>
|
|
<button
|
|
className="p-2 text-muted-foreground hover:text-destructive opacity-0 group-hover:opacity-100 transition-opacity"
|
|
onClick={() => removeFromQueue(track.id)}
|
|
>
|
|
<X className="w-4 h-4" />
|
|
</button>
|
|
</div>
|
|
))
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{showSaveModal && (
|
|
<SaveQueueAsPlaylistModal
|
|
onClose={() => setShowSaveModal(false)}
|
|
onSave={handleSavePlaylist}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|