HoverCard component (new): - Rich preview cards on hover with framer-motion animation - Viewport-aware positioning, portal rendering, open/close delays - UserHoverContent: Discord-style user preview (avatar, bio, stats, follow) - TrackHoverContent: Spotify-style track preview (cover, stats, play) Audio player — Spotify-like 3-column layout: - grid-cols-3 layout: track info | controls | volume+queue - Progress bar moved to top edge (minimal variant) - Glassmorphism (bg-background/95 backdrop-blur-md) - Prominent centered play button (h-10 w-10 rounded-full, active:scale-95) - Title marquee animation for long track names - Reduced padding for tighter premium feel Scrollbar styling: - Migrated hardcoded rgba() to semantic tokens via color-mix(in oklch) - Added transition on thumb hover for smooth visual feedback ContextMenu integration: - TrackListRow wrapped with ContextMenu (play, like, more actions) - Dynamic items based on available callbacks Co-authored-by: Cursor <cursoragent@cursor.com>
301 lines
9.8 KiB
TypeScript
301 lines
9.8 KiB
TypeScript
import React, { useMemo } from 'react';
|
|
import { cn } from '@/lib/utils';
|
|
import { Play, Heart, MoreHorizontal } from 'lucide-react';
|
|
import { ContextMenu } from '@/components/ui/context-menu';
|
|
import type { ContextMenuEntry } from '@/components/ui/context-menu';
|
|
import type { Track } from '../../player/types';
|
|
|
|
export interface TrackListRowProps {
|
|
track: Track;
|
|
showMetadata?: boolean;
|
|
showCover?: boolean;
|
|
showDuration?: boolean;
|
|
onTrackClick?: (track: Track) => void;
|
|
onTrackPlay?: (track: Track) => void;
|
|
onTrackLike?: (track: Track) => void;
|
|
onTrackMore?: (track: Track) => void;
|
|
showSelection?: boolean;
|
|
onTrackSelect?: (id: string, selected: boolean) => void;
|
|
isSelected?: boolean;
|
|
isLiked?: boolean;
|
|
isPlaying?: boolean;
|
|
format?: 'list' | 'table';
|
|
className?: string;
|
|
showActions?: boolean;
|
|
}
|
|
|
|
export function TrackListRow({
|
|
track,
|
|
showMetadata = true,
|
|
showCover = true,
|
|
showDuration = true,
|
|
onTrackClick,
|
|
onTrackPlay,
|
|
onTrackLike,
|
|
onTrackMore,
|
|
showSelection,
|
|
onTrackSelect,
|
|
isSelected,
|
|
isLiked,
|
|
isPlaying,
|
|
format = 'list',
|
|
className,
|
|
showActions = true,
|
|
}: TrackListRowProps) {
|
|
const handlePlay = (e: React.MouseEvent) => {
|
|
e.stopPropagation();
|
|
onTrackPlay?.(track);
|
|
};
|
|
|
|
const handleLike = (e: React.MouseEvent) => {
|
|
e.stopPropagation();
|
|
onTrackLike?.(track);
|
|
};
|
|
|
|
const handleMore = (e: React.MouseEvent) => {
|
|
e.stopPropagation();
|
|
onTrackMore?.(track);
|
|
};
|
|
|
|
const handleSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
e.stopPropagation();
|
|
onTrackSelect?.(track.id, e.target.checked);
|
|
};
|
|
|
|
const PlayIcon = isPlaying ? 'II' : '▶';
|
|
const LikeIcon = isLiked ? '♥' : '♡';
|
|
|
|
// ── Context menu items (only for callbacks that exist) ──────────────
|
|
const contextMenuItems = useMemo<ContextMenuEntry[]>(() => {
|
|
const items: ContextMenuEntry[] = [];
|
|
|
|
if (onTrackPlay) {
|
|
items.push({
|
|
id: 'play',
|
|
label: isPlaying ? 'Pause' : 'Lire',
|
|
icon: <Play className="w-4 h-4" />,
|
|
onClick: () => onTrackPlay(track),
|
|
});
|
|
}
|
|
|
|
if (onTrackLike) {
|
|
items.push({
|
|
id: 'like',
|
|
label: isLiked ? 'Retirer des favoris' : 'Ajouter aux favoris',
|
|
icon: <Heart className={cn('w-4 h-4', isLiked && 'fill-current')} />,
|
|
onClick: () => onTrackLike(track),
|
|
});
|
|
}
|
|
|
|
if (onTrackMore) {
|
|
if (items.length > 0) {
|
|
items.push({ type: 'separator' as const });
|
|
}
|
|
items.push({
|
|
id: 'more',
|
|
label: "Plus d'options",
|
|
icon: <MoreHorizontal className="w-4 h-4" />,
|
|
onClick: () => onTrackMore(track),
|
|
});
|
|
}
|
|
|
|
return items;
|
|
}, [track, onTrackPlay, onTrackLike, onTrackMore, isPlaying, isLiked]);
|
|
|
|
const hasContextMenu = contextMenuItems.length > 0;
|
|
|
|
// ── Table format ────────────────────────────────────────────────────
|
|
if (format === 'table') {
|
|
const tableRow = (
|
|
<tr
|
|
role="row"
|
|
className={cn(
|
|
'hover:bg-muted/50 transition-colors duration-[var(--duration-normal)]',
|
|
isSelected && 'bg-primary/10',
|
|
className,
|
|
)}
|
|
onClick={() => onTrackClick?.(track)}
|
|
>
|
|
{showSelection && (
|
|
<td className="w-8 p-2">
|
|
<input
|
|
type="checkbox"
|
|
checked={isSelected}
|
|
onChange={handleSelect}
|
|
onClick={(e) => e.stopPropagation()}
|
|
aria-label="Sélectionner"
|
|
/>
|
|
</td>
|
|
)}
|
|
<td className="p-2">
|
|
<div className="flex items-center gap-4">
|
|
{showCover && (
|
|
<div className="relative w-8 h-8 flex-shrink-0 group">
|
|
{track.cover ? (
|
|
<img
|
|
src={track.cover}
|
|
alt={`Cover de ${track.title}`}
|
|
className="w-full h-full object-cover rounded-[var(--radius-md)]"
|
|
/>
|
|
) : (
|
|
<div className="w-full h-full bg-muted flex items-center justify-center rounded-[var(--radius-md)]">
|
|
<svg
|
|
className="w-4 h-4 text-muted-foreground/90"
|
|
fill="none"
|
|
viewBox="0 0 24 24"
|
|
stroke="currentColor"
|
|
>
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
strokeWidth={2}
|
|
d="M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zM9 10l12-3"
|
|
/>
|
|
</svg>
|
|
</div>
|
|
)}
|
|
{showActions && (
|
|
<div className="absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100 bg-black/40 transition-opacity duration-[var(--duration-normal)] rounded-[var(--radius-md)]">
|
|
<button
|
|
onClick={handlePlay}
|
|
aria-label={isPlaying ? 'Mettre en pause' : 'Lire'}
|
|
className="text-white"
|
|
>
|
|
{PlayIcon}
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
<span className="tracking-tight">{track.title}</span>
|
|
</div>
|
|
</td>
|
|
<td className="p-2 text-muted-foreground/90 tracking-tight">{track.artist}</td>
|
|
{showMetadata && (
|
|
<td className="p-2 text-muted-foreground/90 tracking-tight">{track.album}</td>
|
|
)}
|
|
{showDuration && (
|
|
<td className="p-2 text-right text-muted-foreground/90 text-xs tabular-nums tracking-tight">
|
|
{Math.floor(track.duration / 60)}:
|
|
{String(track.duration % 60).padStart(2, '0')}
|
|
</td>
|
|
)}
|
|
</tr>
|
|
);
|
|
|
|
return hasContextMenu ? (
|
|
<ContextMenu items={contextMenuItems}>{tableRow}</ContextMenu>
|
|
) : (
|
|
tableRow
|
|
);
|
|
}
|
|
|
|
// ── List format ─────────────────────────────────────────────────────
|
|
const listRow = (
|
|
<li
|
|
role="listitem"
|
|
className={cn(
|
|
'flex items-center gap-4 p-2 rounded-[var(--radius-md)] hover:bg-muted/50 group h-14',
|
|
'transition-colors duration-[var(--duration-normal)]',
|
|
isSelected && 'bg-primary/10',
|
|
className,
|
|
)}
|
|
onClick={() => onTrackClick?.(track)}
|
|
>
|
|
{showSelection && (
|
|
<input
|
|
type="checkbox"
|
|
checked={isSelected}
|
|
onChange={handleSelect}
|
|
onClick={(e) => e.stopPropagation()}
|
|
aria-label="Sélectionner"
|
|
/>
|
|
)}
|
|
|
|
{/* Cover/Play Button */}
|
|
<div className="relative w-10 h-10 flex-shrink-0">
|
|
{showCover && (
|
|
<>
|
|
{track.cover ? (
|
|
<img
|
|
src={track.cover}
|
|
alt={`Cover de ${track.title}`}
|
|
className="w-full h-full object-cover rounded-[var(--radius-md)]"
|
|
/>
|
|
) : (
|
|
<div className="w-full h-full bg-muted flex items-center justify-center rounded-[var(--radius-md)]">
|
|
<svg
|
|
className="w-5 h-5 text-muted-foreground/90"
|
|
fill="none"
|
|
viewBox="0 0 24 24"
|
|
stroke="currentColor"
|
|
>
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
strokeWidth={2}
|
|
d="M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zM9 10l12-3"
|
|
/>
|
|
</svg>
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
|
|
{showActions && (
|
|
<div className="absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100 bg-black/40 transition-opacity duration-[var(--duration-normal)] rounded-[var(--radius-md)]">
|
|
<button
|
|
aria-label={isPlaying ? 'Mettre en pause' : 'Lire'}
|
|
onClick={handlePlay}
|
|
className="text-white"
|
|
>
|
|
{PlayIcon}
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="flex-1 min-w-0">
|
|
<div className="font-medium truncate tracking-tight">{track.title}</div>
|
|
{showMetadata && (
|
|
<div className="text-xs text-muted-foreground/90 truncate tracking-tight">
|
|
{track.artist} {track.album && `• ${track.album}`}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{showDuration && (
|
|
<div className="text-xs text-muted-foreground/90 tabular-nums tracking-tight">
|
|
{Math.floor(track.duration / 60)}:
|
|
{String(track.duration % 60).padStart(2, '0')}
|
|
</div>
|
|
)}
|
|
|
|
{/* Actions */}
|
|
{showActions && (
|
|
<div className="opacity-0 group-hover:opacity-100 flex gap-2">
|
|
<button
|
|
aria-label={isLiked ? 'Retirer des favoris' : 'Ajouter aux favoris'}
|
|
onClick={handleLike}
|
|
className="hover:text-primary transition-colors duration-[var(--duration-normal)]"
|
|
>
|
|
{LikeIcon}
|
|
</button>
|
|
<button
|
|
aria-label="Plus d'options"
|
|
onClick={handleMore}
|
|
className="hover:text-primary transition-colors duration-[var(--duration-normal)]"
|
|
>
|
|
...
|
|
</button>
|
|
</div>
|
|
)}
|
|
</li>
|
|
);
|
|
|
|
return hasContextMenu ? (
|
|
<ContextMenu items={contextMenuItems}>{listRow}</ContextMenu>
|
|
) : (
|
|
listRow
|
|
);
|
|
}
|