+
{track.cover && (

)}
-
{track.title}
+
{track.artist}
diff --git a/apps/web/src/components/ui/hover-card/HoverCard.tsx b/apps/web/src/components/ui/hover-card/HoverCard.tsx
new file mode 100644
index 000000000..3e753b208
--- /dev/null
+++ b/apps/web/src/components/ui/hover-card/HoverCard.tsx
@@ -0,0 +1,268 @@
+import { useState, useEffect, useRef, useCallback } from 'react';
+import { createPortal } from 'react-dom';
+import { motion, AnimatePresence } from 'framer-motion';
+import { cn } from '@/lib/utils';
+import type { HoverCardProps, HoverCardPosition } from './types';
+
+/**
+ * Calculate the position of the hover card relative to the trigger element.
+ * Flips to the opposite side when there isn't enough viewport space.
+ */
+function calculateCardPosition(
+ triggerRect: DOMRect,
+ cardRect: DOMRect,
+ preferred: HoverCardPosition,
+ gap = 12,
+): { top: number; left: number; actual: HoverCardPosition } {
+ const viewport = {
+ width: window.innerWidth,
+ height: window.innerHeight,
+ };
+
+ let actual = preferred;
+
+ // Check if preferred position has enough space, otherwise flip
+ switch (preferred) {
+ case 'top':
+ if (triggerRect.top - cardRect.height - gap < 0) actual = 'bottom';
+ break;
+ case 'bottom':
+ if (triggerRect.bottom + cardRect.height + gap > viewport.height) actual = 'top';
+ break;
+ case 'left':
+ if (triggerRect.left - cardRect.width - gap < 0) actual = 'right';
+ break;
+ case 'right':
+ if (triggerRect.right + cardRect.width + gap > viewport.width) actual = 'left';
+ break;
+ }
+
+ let top = 0;
+ let left = 0;
+ const padding = 8;
+
+ switch (actual) {
+ case 'top':
+ top = triggerRect.top - cardRect.height - gap;
+ left = triggerRect.left + triggerRect.width / 2 - cardRect.width / 2;
+ break;
+ case 'bottom':
+ top = triggerRect.bottom + gap;
+ left = triggerRect.left + triggerRect.width / 2 - cardRect.width / 2;
+ break;
+ case 'left':
+ top = triggerRect.top + triggerRect.height / 2 - cardRect.height / 2;
+ left = triggerRect.left - cardRect.width - gap;
+ break;
+ case 'right':
+ top = triggerRect.top + triggerRect.height / 2 - cardRect.height / 2;
+ left = triggerRect.right + gap;
+ break;
+ }
+
+ // Clamp within viewport
+ left = Math.max(padding, Math.min(left, viewport.width - cardRect.width - padding));
+ top = Math.max(padding, Math.min(top, viewport.height - cardRect.height - padding));
+
+ return { top, left, actual };
+}
+
+/**
+ * Get the initial y-offset for the entrance animation based on position.
+ */
+function getInitialY(position: HoverCardPosition): number {
+ switch (position) {
+ case 'top':
+ return 4;
+ case 'bottom':
+ return -4;
+ default:
+ return 0;
+ }
+}
+
+/**
+ * HoverCard - Discord-style rich preview card on hover
+ *
+ * Shows a rich content card when hovering over a trigger element.
+ * Uses a portal for rendering and framer-motion for smooth animations.
+ *
+ * @example
+ * ```tsx
+ *
}
+ * >
+ *
@johndoe
+ *
+ * ```
+ */
+export function HoverCard({
+ content,
+ children,
+ position = 'bottom',
+ openDelay = 400,
+ closeDelay = 200,
+ disabled = false,
+ className,
+ maxWidth,
+}: HoverCardProps) {
+ const [visible, setVisible] = useState(false);
+ const [cardStyle, setCardStyle] = useState
({});
+ const [actualPosition, setActualPosition] = useState(position);
+
+ const triggerRef = useRef(null);
+ const cardRef = useRef(null);
+ const openTimerRef = useRef | null>(null);
+ const closeTimerRef = useRef | null>(null);
+
+ const clearTimers = useCallback(() => {
+ if (openTimerRef.current) {
+ clearTimeout(openTimerRef.current);
+ openTimerRef.current = null;
+ }
+ if (closeTimerRef.current) {
+ clearTimeout(closeTimerRef.current);
+ closeTimerRef.current = null;
+ }
+ }, []);
+
+ const open = useCallback(() => {
+ if (closeTimerRef.current) {
+ clearTimeout(closeTimerRef.current);
+ closeTimerRef.current = null;
+ }
+ if (openTimerRef.current) return;
+
+ openTimerRef.current = setTimeout(() => {
+ setVisible(true);
+ openTimerRef.current = null;
+ }, openDelay);
+ }, [openDelay]);
+
+ const close = useCallback(() => {
+ if (openTimerRef.current) {
+ clearTimeout(openTimerRef.current);
+ openTimerRef.current = null;
+ }
+ if (closeTimerRef.current) return;
+
+ closeTimerRef.current = setTimeout(() => {
+ setVisible(false);
+ closeTimerRef.current = null;
+ }, closeDelay);
+ }, [closeDelay]);
+
+ // Recalculate position when visible
+ useEffect(() => {
+ if (!visible || !triggerRef.current) return;
+
+ const updatePosition = () => {
+ if (!triggerRef.current || !cardRef.current) return;
+
+ const triggerRect = triggerRef.current.getBoundingClientRect();
+ const cardRect = cardRef.current.getBoundingClientRect();
+ const result = calculateCardPosition(triggerRect, cardRect, position);
+
+ setActualPosition(result.actual);
+ setCardStyle({
+ top: result.top,
+ left: result.left,
+ });
+ };
+
+ // Position on next frame after mount
+ requestAnimationFrame(updatePosition);
+ }, [visible, position]);
+
+ // Close on scroll or resize
+ useEffect(() => {
+ if (!visible) return;
+
+ const handleDismiss = () => {
+ clearTimers();
+ setVisible(false);
+ };
+
+ window.addEventListener('scroll', handleDismiss, true);
+ window.addEventListener('resize', handleDismiss);
+
+ return () => {
+ window.removeEventListener('scroll', handleDismiss, true);
+ window.removeEventListener('resize', handleDismiss);
+ };
+ }, [visible, clearTimers]);
+
+ // Close on click outside
+ useEffect(() => {
+ if (!visible) return;
+
+ const handleClickOutside = (e: MouseEvent) => {
+ const target = e.target as Node;
+ if (
+ triggerRef.current &&
+ !triggerRef.current.contains(target) &&
+ cardRef.current &&
+ !cardRef.current.contains(target)
+ ) {
+ clearTimers();
+ setVisible(false);
+ }
+ };
+
+ document.addEventListener('mousedown', handleClickOutside);
+ return () => {
+ document.removeEventListener('mousedown', handleClickOutside);
+ };
+ }, [visible, clearTimers]);
+
+ // Cleanup timers on unmount
+ useEffect(() => {
+ return clearTimers;
+ }, [clearTimers]);
+
+ if (disabled) {
+ return <>{children}>;
+ }
+
+ return (
+ <>
+
+ {children}
+
+
+ {createPortal(
+
+ {visible && (
+
+ {content}
+
+ )}
+ ,
+ document.body,
+ )}
+ >
+ );
+}
diff --git a/apps/web/src/components/ui/hover-card/TrackHoverContent.tsx b/apps/web/src/components/ui/hover-card/TrackHoverContent.tsx
new file mode 100644
index 000000000..c3773818e
--- /dev/null
+++ b/apps/web/src/components/ui/hover-card/TrackHoverContent.tsx
@@ -0,0 +1,126 @@
+import { Button } from '@/components/ui/button';
+import { Play, Clock, Tag, Activity } from 'lucide-react';
+
+export interface TrackHoverContentProps {
+ /** Track title */
+ title: string;
+ /** Artist name */
+ artist: string;
+ /** Album name */
+ album?: string;
+ /** Cover art URL */
+ cover?: string;
+ /** Duration string (e.g. "3:45") */
+ duration?: string;
+ /** Genre label */
+ genre?: string;
+ /** Beats per minute */
+ bpm?: number;
+ /** Callback when play button is clicked */
+ onPlay?: () => void;
+}
+
+/**
+ * TrackHoverContent - Spotify-style track preview card
+ *
+ * Designed to be used as the `content` prop of `HoverCard`.
+ *
+ * @example
+ * ```tsx
+ * play(track)}
+ * />
+ * }
+ * >
+ * Midnight Sun
+ *
+ * ```
+ */
+export function TrackHoverContent({
+ title,
+ artist,
+ album,
+ cover,
+ duration,
+ genre,
+ bpm,
+ onPlay,
+}: TrackHoverContentProps) {
+ return (
+
+ {/* Cover art */}
+
+
+ {cover ? (
+

+ ) : (
+
+ )}
+
+
+ {/* Play overlay */}
+ {onPlay && (
+
+ )}
+
+
+ {/* Track info */}
+
+
+ {title}
+
+
{artist}
+ {album && (
+
{album}
+ )}
+
+ {/* Stats row */}
+ {(duration || genre || bpm) && (
+
+ {duration && (
+
+
+ {duration}
+
+ )}
+ {genre && (
+
+
+ {genre}
+
+ )}
+ {bpm && (
+
+
+ {bpm} BPM
+
+ )}
+
+ )}
+
+
+ );
+}
diff --git a/apps/web/src/components/ui/hover-card/UserHoverContent.tsx b/apps/web/src/components/ui/hover-card/UserHoverContent.tsx
new file mode 100644
index 000000000..88024d4c4
--- /dev/null
+++ b/apps/web/src/components/ui/hover-card/UserHoverContent.tsx
@@ -0,0 +1,128 @@
+import { Avatar } from '@/components/ui/avatar';
+import { Button } from '@/components/ui/button';
+import { Users, Music } from 'lucide-react';
+
+export interface UserHoverContentProps {
+ /** Display name */
+ name: string;
+ /** Username handle */
+ username: string;
+ /** Avatar image URL */
+ avatar?: string;
+ /** Short biography */
+ bio?: string;
+ /** Number of followers */
+ followersCount?: number;
+ /** Number of tracks */
+ tracksCount?: number;
+ /** Whether the current user follows this user */
+ isFollowing?: boolean;
+ /** Callback when follow button is clicked */
+ onFollow?: () => void;
+}
+
+/**
+ * UserHoverContent - Discord-style user card preview
+ *
+ * Designed to be used as the `content` prop of `HoverCard`.
+ *
+ * @example
+ * ```tsx
+ *
+ * }
+ * >
+ * @janedoe
+ *
+ * ```
+ */
+export function UserHoverContent({
+ name,
+ username,
+ avatar,
+ bio,
+ followersCount,
+ tracksCount,
+ isFollowing = false,
+ onFollow,
+}: UserHoverContentProps) {
+ return (
+
+ {/* Cover gradient */}
+
+
+ {/* Avatar + identity */}
+
+
+
+
+
+ {name}
+
+
@{username}
+
+
+
+ {/* Bio */}
+ {bio && (
+
+ {bio}
+
+ )}
+
+ {/* Stats */}
+ {(followersCount != null || tracksCount != null) && (
+
+ {followersCount != null && (
+
+
+
+ {followersCount.toLocaleString()}
+
+ followers
+
+ )}
+ {tracksCount != null && (
+
+
+
+ {tracksCount.toLocaleString()}
+
+ tracks
+
+ )}
+
+ )}
+
+ {/* Follow button */}
+ {onFollow && (
+
+
+
+ )}
+
+ {/* Bottom spacing when no follow button */}
+ {!onFollow &&
}
+
+ );
+}
diff --git a/apps/web/src/components/ui/hover-card/index.ts b/apps/web/src/components/ui/hover-card/index.ts
new file mode 100644
index 000000000..014e6ae6c
--- /dev/null
+++ b/apps/web/src/components/ui/hover-card/index.ts
@@ -0,0 +1,6 @@
+export { HoverCard } from './HoverCard';
+export { UserHoverContent } from './UserHoverContent';
+export { TrackHoverContent } from './TrackHoverContent';
+export type { HoverCardProps, HoverCardPosition } from './types';
+export type { UserHoverContentProps } from './UserHoverContent';
+export type { TrackHoverContentProps } from './TrackHoverContent';
diff --git a/apps/web/src/components/ui/hover-card/types.ts b/apps/web/src/components/ui/hover-card/types.ts
new file mode 100644
index 000000000..6fdde9ca4
--- /dev/null
+++ b/apps/web/src/components/ui/hover-card/types.ts
@@ -0,0 +1,22 @@
+import { ReactNode } from 'react';
+
+export type HoverCardPosition = 'top' | 'bottom' | 'left' | 'right';
+
+export interface HoverCardProps {
+ /** The content to show in the hover card */
+ content: ReactNode;
+ /** The trigger element */
+ children: ReactNode;
+ /** Preferred position */
+ position?: HoverCardPosition;
+ /** Delay before showing (ms) */
+ openDelay?: number;
+ /** Delay before hiding (ms) */
+ closeDelay?: number;
+ /** Whether the card is disabled */
+ disabled?: boolean;
+ /** Custom class for the card */
+ className?: string;
+ /** Max width */
+ maxWidth?: number;
+}
diff --git a/apps/web/src/features/tracks/components/TrackListRow.tsx b/apps/web/src/features/tracks/components/TrackListRow.tsx
index a9c382a1c..0fb748f02 100644
--- a/apps/web/src/features/tracks/components/TrackListRow.tsx
+++ b/apps/web/src/features/tracks/components/TrackListRow.tsx
@@ -1,5 +1,8 @@
-import React from 'react';
+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 {
@@ -62,8 +65,48 @@ export function TrackListRow({
const PlayIcon = isPlaying ? 'II' : '▶';
const LikeIcon = isLiked ? '♥' : '♡';
+ // ── Context menu items (only for callbacks that exist) ──────────────
+ const contextMenuItems = useMemo(() => {
+ const items: ContextMenuEntry[] = [];
+
+ if (onTrackPlay) {
+ items.push({
+ id: 'play',
+ label: isPlaying ? 'Pause' : 'Lire',
+ icon: ,
+ onClick: () => onTrackPlay(track),
+ });
+ }
+
+ if (onTrackLike) {
+ items.push({
+ id: 'like',
+ label: isLiked ? 'Retirer des favoris' : 'Ajouter aux favoris',
+ icon: ,
+ onClick: () => onTrackLike(track),
+ });
+ }
+
+ if (onTrackMore) {
+ if (items.length > 0) {
+ items.push({ type: 'separator' as const });
+ }
+ items.push({
+ id: 'more',
+ label: "Plus d'options",
+ icon: ,
+ onClick: () => onTrackMore(track),
+ });
+ }
+
+ return items;
+ }, [track, onTrackPlay, onTrackLike, onTrackMore, isPlaying, isLiked]);
+
+ const hasContextMenu = contextMenuItems.length > 0;
+
+ // ── Table format ────────────────────────────────────────────────────
if (format === 'table') {
- return (
+ const tableRow = (
);
+
+ return hasContextMenu ? (
+ {tableRow}
+ ) : (
+ tableRow
+ );
}
- return (
+ // ── List format ─────────────────────────────────────────────────────
+ const listRow = (
);
+
+ return hasContextMenu ? (
+ {listRow}
+ ) : (
+ listRow
+ );
}
diff --git a/apps/web/src/index.css b/apps/web/src/index.css
index 6089ed0b0..158c58eaf 100644
--- a/apps/web/src/index.css
+++ b/apps/web/src/index.css
@@ -449,18 +449,19 @@
}
::-webkit-scrollbar-thumb {
- background: rgba(255, 255, 255, 0.12);
+ background: color-mix(in oklch, var(--muted-foreground) 30%, transparent);
border-radius: 9999px;
+ transition: background-color 0.2s ease;
}
::-webkit-scrollbar-thumb:hover {
- background: rgba(255, 255, 255, 0.22);
+ background: color-mix(in oklch, var(--muted-foreground) 50%, transparent);
}
@supports (scrollbar-width: thin) {
* {
scrollbar-width: thin;
- scrollbar-color: rgba(255, 255, 255, 0.2) transparent;
+ scrollbar-color: color-mix(in oklch, var(--muted-foreground) 30%, transparent) transparent;
}
}
@@ -918,6 +919,10 @@
animation: eq-bounce 0.5s ease-in-out infinite;
}
+ .animate-marquee {
+ animation: marquee 10s linear infinite;
+ }
+
/* Hover Effects */
.hover-lift {
@apply transition-transform duration-[var(--duration-fast)];
@@ -1136,6 +1141,16 @@
}
}
+@keyframes marquee {
+ 0%, 20% {
+ transform: translateX(0);
+ }
+
+ 80%, 100% {
+ transform: translateX(-100%);
+ }
+}
+
@keyframes achievement-slide {
from {
transform: translateX(100%);