veza/apps/web/src/features/player/components/PlayerQueue.tsx
senke 559cfbee3e refactor(web): zero out 3 ESLint warning buckets (storybook + react-refresh + non-null-assertion)
Three rules cleaned in parallel passes — 187 fewer warnings, 0 TS
errors, 0 behaviour change beyond one incidental auth bugfix
flagged below.

storybook/no-redundant-story-name (23 → 0) — 14 stories files
  Storybook v7+ infers the story name from the variable name, so
  `name: 'Default'` next to `export const Default: Story = …` is
  pure noise. Removed only when the name was redundant ;
  preserved when the label was a French translation
  ('Par défaut', 'Chargement', 'Avec erreur', etc.) since those
  are intentional.

react-refresh/only-export-components (25 → 0) — 21 files
  Each warning marks a file that exports a React component AND a
  hook / context / constant / barrel re-export. Suppressed
  per-line with the suppression-with-justification pattern :
    // eslint-disable-next-line react-refresh/only-export-components -- <kind>; refactor would split a tightly-coupled API
  The justification matters — every comment names the specific
  thing being co-located (hook / context / CVA constant / lazy
  registry / route config / test util / backward-compat barrel).
  Splitting these would create 21 new files for a HMR-only DX
  win that's already a non-issue in practice.

@typescript-eslint/no-non-null-assertion (139 → 0) — 43 files
  Distribution of fixes :
    ~85 cases : refactored to explicit guard
                `if (!x) throw new Error('invariant: …')`
                or hoisted into local with narrowing.
    ~36 cases : helper extraction (one tooltip test had 16
                `wrapper!` patterns reduced to a single
                `getWrapper()` helper).
    ~18 cases : suppressed with specific reason :
                static literal arrays where index is provably
                in bounds, mock fixtures with structural
                guarantees, filter-then-map patterns where the
                filter excludes the null branch.
  One incidental find : services/api/auth.ts threw on missing
  tokens but didn't guard `user` ; added the missing check while
  refactoring the `user!` to a guard.

baseline post-commit : 921 warnings, 0 errors, 0 TS errors.
The remaining buckets are no-restricted-syntax (757, design-system
guardrail), no-explicit-any (115), exhaustive-deps (49).

CI --max-warnings will be lowered to 921 in the follow-up commit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 23:30:22 +02:00

318 lines
18 KiB
TypeScript

import { useEffect, useState } from 'react';
import { usePlayerStore } from '../store/playerStore';
import { useQueueSessionStore } from '../store/queueSessionStore';
import { useUIStore } from '@/stores/ui';
import { cn } from '@/lib/utils';
import { X, GripVertical, ListMusic, Sparkles, Share2 } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { ScrollArea } from '@/components/ui/scroll-area';
import { EmptyState } from '@/components/ui/empty-state';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useAuthStore } from '@/features/auth/store/authStore';
import { tracksApi } from '@/services/api/tracks';
import { queueApi } from '@/services/api/queue';
import toast from '@/utils/toast';
import type { Track as PlayerTrack } from '../types';
import type { Track as ApiTrack } from '@/features/tracks/types/track';
interface PlayerQueueProps {
isOpen: boolean;
onClose: () => void;
currentTrackId?: string;
onPlay: (track: PlayerTrack) => void;
}
function mapApiTrackToPlayerTrack(t: ApiTrack): PlayerTrack {
const apiTrack = t as ApiTrack & { stream_manifest_url?: string; cover_art_path?: string };
return {
id: t.id,
title: t.title,
artist: t.artist,
duration: t.duration ?? 0,
url: apiTrack.stream_manifest_url || `/api/v1/tracks/${t.id}/stream`,
cover: apiTrack.cover_art_path,
genre: t.genre,
like_count: t.like_count,
};
}
function mapSessionItemToPlayerTrack(item: { id: string; track?: { id: string; title?: string; artist?: string; duration?: number; cover_art_path?: string; genre?: string; like_count?: number } }): PlayerTrack | null {
const t = item.track;
if (!t) return null;
return {
id: t.id,
title: t.title ?? '',
artist: t.artist ?? '',
duration: t.duration ?? 0,
url: `/api/v1/tracks/${t.id}/stream`,
cover: t.cover_art_path,
genre: t.genre,
like_count: t.like_count,
};
}
export function PlayerQueue({ isOpen, onClose, onPlay }: PlayerQueueProps) {
const { queue, currentIndex, removeFromQueue, clearQueue, setQueueFromSession } = usePlayerStore();
const { sidebarOpen } = useUIStore();
const queryClient = useQueryClient();
const { activeSessionToken, isCreator, setSession, clearSession } = useQueueSessionStore();
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
const [sessionItemIds, setSessionItemIds] = useState<Map<string, string>>(new Map()); // trackId -> itemId for remove
// Detect ?session=TOKEN in URL (join via share link)
useEffect(() => {
const params = new URLSearchParams(window.location.search);
const token = params.get('session');
if (token && isAuthenticated) {
setSession(token, false);
// Remove from URL without full reload
params.delete('session');
const newUrl = window.location.pathname + (params.toString() ? `?${params}` : '');
window.history.replaceState({}, '', newUrl);
}
}, [isAuthenticated, setSession]);
const { data: recommendations = [], isLoading: recommendationsLoading } = useQuery({
queryKey: ['track-recommendations'],
queryFn: () => tracksApi.getRecommendations({ limit: 10 }),
enabled: isOpen && isAuthenticated && queue.length === 0 && !activeSessionToken,
staleTime: 60_000,
});
const playerTracks = recommendations.map(mapApiTrackToPlayerTrack);
// Poll session when active (v0.203 Lot D1)
const { data: sessionData } = useQuery({
queryKey: ['queue-session', activeSessionToken],
queryFn: () => {
if (!activeSessionToken) throw new Error('activeSessionToken required');
return queueApi.getQueueSession(activeSessionToken);
},
enabled: !!activeSessionToken && isOpen,
refetchInterval: 8000,
});
useEffect(() => {
if (sessionData?.items && activeSessionToken) {
const tracks = sessionData.items
.map((it) => mapSessionItemToPlayerTrack(it))
.filter((t): t is PlayerTrack => t != null);
const idMap = new Map<string, string>();
sessionData.items.forEach((it) => {
const t = (it as { id: string; track?: { id: string } }).track;
if (t) idMap.set(t.id, it.id);
});
setSessionItemIds(idMap);
setQueueFromSession?.(tracks);
}
}, [sessionData, activeSessionToken, setQueueFromSession]);
const shareMutation = useMutation({
mutationFn: async () => {
const res = await queueApi.createQueueSession();
const token = res.session.share_token;
for (const track of queue) {
await queueApi.addToSessionQueue(token, track.id);
}
const fullUrl = `${window.location.origin}/?session=${token}`;
await navigator.clipboard.writeText(fullUrl);
setSession(token, true);
return fullUrl;
},
onSuccess: () => {
toast.success('Lien copié ! Partagez la queue avec vos amis.');
},
onError: () => {
toast.error('Impossible de créer la session partagée.');
},
});
const displayQueue: PlayerTrack[] = activeSessionToken && sessionData?.items?.length ? (
sessionData.items
.map((it) => mapSessionItemToPlayerTrack(it))
.filter((t): t is PlayerTrack => t != null)
) : queue;
if (!isOpen) return null;
return (
<div
className={cn(
"fixed bottom-24 left-4 right-4 z-40 transition-all duration-[var(--sumi-duration-normal)] ease-[var(--sumi-ease-out)] transform",
sidebarOpen ? "lg:left-main-expanded" : "lg:left-main-collapsed",
"lg:right-4",
isOpen ? "translate-y-0 opacity-100" : "translate-y-10 opacity-0 pointer-events-none"
)}
>
<div className="max-w-4xl mx-auto bg-black/80 backdrop-blur-2xl border border-white/10 rounded-2xl shadow-2xl overflow-hidden max-h-layout-drawer flex flex-col">
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-white/5 bg-white/5">
<div className="flex items-center gap-2">
<h3 className="text-foreground font-bold font-heading tracking-wide">Play Queue</h3>
<Badge variant="secondary" className="border-primary/20 text-primary bg-primary/10">
{displayQueue.length} Tracks
</Badge>
{activeSessionToken && (
<Badge variant="success" className="border-green-500/30 text-green-400 bg-green-500/10">
Queue partagée
</Badge>
)}
</div>
<div className="flex items-center gap-2">
{isAuthenticated && displayQueue.length > 0 && !activeSessionToken && (
<button
onClick={() => shareMutation.mutate()}
disabled={shareMutation.isPending}
className="px-3 py-1.5 text-xs text-muted-foreground hover:text-foreground hover:bg-white/10 rounded-md transition-colors duration-[var(--duration-fast)] flex items-center gap-1.5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
>
<Share2 className="w-3.5 h-3.5" />
Partager
</button>
)}
{activeSessionToken && isCreator && (
<button
onClick={() => { clearSession(); clearQueue(); }}
className="px-3 py-1.5 text-xs text-muted-foreground hover:text-destructive hover:bg-destructive/10 rounded-md"
>
Arrêter le partage
</button>
)}
<button
onClick={!activeSessionToken ? clearQueue : undefined}
disabled={!!activeSessionToken}
className="px-3 py-1.5 text-xs text-muted-foreground hover:text-foreground hover:bg-white/10 rounded-md transition-colors duration-[var(--duration-fast)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:opacity-50 disabled:pointer-events-none"
>
Clear
</button>
<button
onClick={onClose}
className="p-1.5 text-muted-foreground hover:text-foreground hover:bg-white/10 rounded-full transition-colors duration-[var(--duration-fast)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 focus-visible:ring-offset-2 focus-visible:ring-offset-background"
>
<X className="w-5 h-5" />
</button>
</div>
</div>
{/* Content */}
<div className="flex-1 overflow-hidden relative">
{displayQueue.length === 0 ? (
<div className="flex flex-col gap-4 p-4">
{playerTracks.length > 0 ? (
<>
<h4 className="text-sm font-medium text-muted-foreground flex items-center gap-2">
<Sparkles className="w-4 h-4 text-primary" />
À écouter ensuite
</h4>
<ScrollArea className="max-h-layout-list">
<div className="space-y-1">
{playerTracks.map((track) => (
<div
key={track.id}
className="group flex items-center gap-3 p-2 rounded-lg hover:bg-white/5 transition-colors cursor-pointer border border-transparent hover:border-white/5"
onClick={() => onPlay(track)}
>
<div className="w-6 text-center text-xs font-mono text-muted-foreground"></div>
<div className="flex-1 min-w-0">
<h4 className="text-sm font-medium truncate text-foreground">{track.title}</h4>
<p className="text-xs text-muted-foreground truncate">{track.artist}</p>
</div>
</div>
))}
</div>
</ScrollArea>
<p className="text-xs text-muted-foreground">Click to play</p>
</>
) : recommendationsLoading ? (
<div className="flex items-center justify-center py-8 text-muted-foreground text-sm">Loading suggestions</div>
) : (
<EmptyState
icon={<ListMusic className="w-full h-full" />}
title="Your queue is empty"
description="Add tracks to keep the vibe going."
size="sm"
className="border-0 shadow-none bg-transparent"
/>
)}
</div>
) : (
<ScrollArea className="h-full max-h-layout-list">
<div className="p-2 space-y-1">
{displayQueue.map((track, index) => {
const isCurrent = index === currentIndex;
const isPast = index < currentIndex;
return (
<div
key={`${track.id}-${index}`}
className={cn(
"group flex items-center gap-3 p-2 rounded-lg transition-all duration-[var(--duration-fast)] border border-transparent",
isCurrent
? "bg-primary/10 border-primary/20 shadow-queue-item-current"
: "hover:bg-white/5 hover:border-white/5",
isPast && "opacity-50"
)}
>
{/* Drag Handle (Simulated) */}
<div className="text-white/20 group-hover:text-white/40 cursor-grab px-1">
<GripVertical className="w-4 h-4" />
</div>
{/* Number/Status */}
<div className="w-6 text-center text-xs font-mono text-muted-foreground">
{isCurrent ? (
<div className="w-2 h-2 rounded-full bg-primary mx-auto animate-pulse shadow-queue-item-current" />
) : (
index + 1
)}
</div>
{/* Info */}
<div
className="flex-1 min-w-0 cursor-pointer"
onClick={() => !isCurrent && onPlay(track)}
>
<h4 className={cn(
"text-sm font-medium truncate transition-colors",
isCurrent ? "text-primary" : "text-foreground group-hover:text-foreground"
)}>
{track.title}
</h4>
<p className="text-xs text-muted-foreground truncate opacity-70 group-hover:opacity-100">
{track.artist}
</p>
</div>
{/* Actions */}
<button
onClick={(e) => {
e.stopPropagation();
if (activeSessionToken) {
const itemId = sessionItemIds.get(track.id);
if (itemId) {
queueApi.removeFromSessionQueue(activeSessionToken, itemId).then(() => {
void queryClient.invalidateQueries({ queryKey: ['queue-session', activeSessionToken] });
}).catch(() => {});
}
} else {
removeFromQueue(index);
}
}}
className="opacity-0 group-hover:opacity-100 p-2 text-muted-foreground hover:text-destructive hover:bg-destructive/10 rounded-full transition-all duration-[var(--duration-fast)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 focus-visible:ring-offset-2 focus-visible:ring-offset-background"
>
<X className="w-3 h-3" />
</button>
</div>
);
})}
</div>
</ScrollArea>
)}
</div>
</div>
{/* Backdrop for explicit dismissal on mobile if needed */}
<div
className="fixed inset-0 bg-black/20 -z-10 backdrop-blur-sm md:hidden"
onClick={onClose}
/>
</div>
);
}