feat(ui): hover cards, Spotify player layout, scrollbar tokens, context menu integration
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>
This commit is contained in:
parent
aeade50247
commit
b1f5fbf0bf
12 changed files with 737 additions and 56 deletions
|
|
@ -63,10 +63,20 @@ export function AudioPlayer(_props: Props = {}) {
|
|||
return (
|
||||
<>
|
||||
<audio ref={audioRef} preload="metadata" />
|
||||
<div className="fixed bottom-0 left-0 right-0 bg-background border-t border-border shadow-lg z-50">
|
||||
<div className="container mx-auto px-4 py-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="fixed bottom-0 left-0 right-0 bg-background/95 backdrop-blur-md border-t border-border z-50">
|
||||
{/* Full-width progress bar at the very top of the player */}
|
||||
<AudioPlayerProgress
|
||||
currentTime={currentTime}
|
||||
duration={duration}
|
||||
onSeek={handleSeek}
|
||||
variant="minimal"
|
||||
/>
|
||||
<div className="container mx-auto px-4 py-2">
|
||||
<div className="grid grid-cols-3 items-center gap-4">
|
||||
{/* Left: Track info */}
|
||||
<AudioPlayerTrackInfo track={currentTrack} />
|
||||
|
||||
{/* Center: Playback controls */}
|
||||
<AudioPlayerControls
|
||||
isPlaying={isPlaying}
|
||||
shuffle={shuffle}
|
||||
|
|
@ -77,38 +87,37 @@ export function AudioPlayer(_props: Props = {}) {
|
|||
onToggleShuffle={toggleShuffle}
|
||||
onRepeatCycle={handleRepeatCycle}
|
||||
/>
|
||||
<AudioPlayerProgress
|
||||
currentTime={currentTime}
|
||||
duration={duration}
|
||||
onSeek={handleSeek}
|
||||
/>
|
||||
<AudioPlayerVolume
|
||||
volume={volume}
|
||||
muted={muted}
|
||||
onVolumeChange={handleVolumeChange}
|
||||
onToggleMute={toggleMute}
|
||||
/>
|
||||
<Tooltip
|
||||
content={
|
||||
showQueue
|
||||
? t('player.hideQueue', 'Hide queue')
|
||||
: t('player.showQueue', 'Show queue')
|
||||
}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setShowQueue(!showQueue)}
|
||||
className={showQueue ? 'text-primary' : ''}
|
||||
aria-label={
|
||||
|
||||
{/* Right: Volume + Queue */}
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<AudioPlayerVolume
|
||||
volume={volume}
|
||||
muted={muted}
|
||||
onVolumeChange={handleVolumeChange}
|
||||
onToggleMute={toggleMute}
|
||||
/>
|
||||
<Tooltip
|
||||
content={
|
||||
showQueue
|
||||
? t('player.hideQueue', 'Hide queue')
|
||||
: t('player.showQueue', 'Show queue')
|
||||
}
|
||||
>
|
||||
<List className="h-4 w-4" />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setShowQueue(!showQueue)}
|
||||
className={showQueue ? 'text-primary' : ''}
|
||||
aria-label={
|
||||
showQueue
|
||||
? t('player.hideQueue', 'Hide queue')
|
||||
: t('player.showQueue', 'Show queue')
|
||||
}
|
||||
>
|
||||
<List className="h-4 w-4" />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ export function AudioPlayerControls({
|
|||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<Tooltip content={shuffle ? t('player.shuffleOn', 'Shuffle: On') : t('player.shuffleOff', 'Shuffle: Off')}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
|
|
@ -47,7 +47,12 @@ export function AudioPlayerControls({
|
|||
</Button>
|
||||
</Tooltip>
|
||||
<Tooltip content={isPlaying ? t('player.pause', 'Pause') : t('player.play', 'Play')}>
|
||||
<Button size="icon" onClick={onPlayPause} aria-label={isPlaying ? t('player.pause', 'Pause') : t('player.play', 'Play')}>
|
||||
<Button
|
||||
size="default"
|
||||
onClick={onPlayPause}
|
||||
className="h-10 w-10 rounded-full transition-transform active:scale-95"
|
||||
aria-label={isPlaying ? t('player.pause', 'Pause') : t('player.play', 'Play')}
|
||||
>
|
||||
{isPlaying ? <Pause className="h-5 w-5" /> : <Play className="h-5 w-5" />}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
|
|
|
|||
|
|
@ -5,13 +5,30 @@ interface AudioPlayerProgressProps {
|
|||
currentTime: number;
|
||||
duration: number;
|
||||
onSeek: (value: number[]) => void;
|
||||
/** "default" shows time labels on sides; "minimal" renders a thin full-width bar (for top-of-player placement). */
|
||||
variant?: 'default' | 'minimal';
|
||||
}
|
||||
|
||||
export function AudioPlayerProgress({
|
||||
currentTime,
|
||||
duration,
|
||||
onSeek,
|
||||
variant = 'default',
|
||||
}: AudioPlayerProgressProps) {
|
||||
if (variant === 'minimal') {
|
||||
return (
|
||||
<div className="w-full group">
|
||||
<Slider
|
||||
value={[currentTime]}
|
||||
max={duration || 1}
|
||||
step={0.1}
|
||||
onValueChange={onSeek}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 flex-1">
|
||||
<span className="text-xs text-muted-foreground w-12 text-right">
|
||||
|
|
|
|||
|
|
@ -12,34 +12,38 @@ export function AudioPlayerSkeleton({ className }: AudioPlayerSkeletonProps) {
|
|||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'fixed bottom-0 left-0 right-0 bg-background border-t border-border z-50',
|
||||
'fixed bottom-0 left-0 right-0 bg-background/95 backdrop-blur-md border-t border-border z-50',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="container mx-auto px-4 py-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-4 flex-1 min-w-0">
|
||||
<Skeleton className="w-12 h-12 rounded" />
|
||||
<div className="flex-1 space-y-2">
|
||||
{/* Top progress bar skeleton */}
|
||||
<Skeleton className="w-full h-1 rounded-none" />
|
||||
<div className="container mx-auto px-4 py-2">
|
||||
<div className="grid grid-cols-3 items-center gap-4">
|
||||
{/* Left: Track info */}
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<Skeleton className="w-10 h-10 rounded shrink-0" />
|
||||
<div className="flex-1 space-y-2 min-w-0">
|
||||
<Skeleton className="h-3 w-3/4 rounded" />
|
||||
<Skeleton className="h-2 w-1/2 rounded" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{[1, 2, 3, 4, 5].map((i) => (
|
||||
{/* Center: Controls */}
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
{[1, 2].map((i) => (
|
||||
<Skeleton key={i} className="h-9 w-9 rounded" />
|
||||
))}
|
||||
<Skeleton className="h-10 w-10 rounded-full" />
|
||||
{[4, 5].map((i) => (
|
||||
<Skeleton key={i} className="h-9 w-9 rounded" />
|
||||
))}
|
||||
</div>
|
||||
<div className="flex-1 flex items-center gap-2">
|
||||
<Skeleton className="w-12 h-3 rounded" />
|
||||
<Skeleton className="flex-1 h-2 rounded" />
|
||||
<Skeleton className="w-12 h-3 rounded" />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Right: Volume + Queue */}
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Skeleton className="h-9 w-9 rounded" />
|
||||
<Skeleton className="w-24 h-2 rounded" />
|
||||
<Skeleton className="h-9 w-9 rounded" />
|
||||
</div>
|
||||
<Skeleton className="h-9 w-9 rounded" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,3 +1,6 @@
|
|||
import { useRef, useState, useEffect } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface Track {
|
||||
title?: string;
|
||||
artist?: string;
|
||||
|
|
@ -9,17 +12,39 @@ interface AudioPlayerTrackInfoProps {
|
|||
}
|
||||
|
||||
export function AudioPlayerTrackInfo({ track }: AudioPlayerTrackInfoProps) {
|
||||
const titleRef = useRef<HTMLParagraphElement>(null);
|
||||
const [isOverflowing, setIsOverflowing] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const el = titleRef.current;
|
||||
if (el) {
|
||||
setIsOverflowing(el.scrollWidth > el.clientWidth);
|
||||
}
|
||||
}, [track.title]);
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-4 flex-1 min-w-0">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
{track.cover && (
|
||||
<img
|
||||
src={track.cover}
|
||||
alt={track.title ?? ''}
|
||||
className="w-12 h-12 rounded object-cover"
|
||||
className="w-10 h-10 rounded object-cover shrink-0"
|
||||
/>
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium truncate">{track.title}</p>
|
||||
<div className="overflow-hidden">
|
||||
<p
|
||||
ref={titleRef}
|
||||
className={cn(
|
||||
'text-sm font-medium text-foreground whitespace-nowrap',
|
||||
isOverflowing
|
||||
? 'animate-marquee'
|
||||
: 'truncate',
|
||||
)}
|
||||
>
|
||||
{track.title}
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground truncate">{track.artist}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
268
apps/web/src/components/ui/hover-card/HoverCard.tsx
Normal file
268
apps/web/src/components/ui/hover-card/HoverCard.tsx
Normal file
|
|
@ -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
|
||||
* <HoverCard
|
||||
* content={<UserHoverContent name="John" username="johndoe" />}
|
||||
* >
|
||||
* <span className="text-primary cursor-pointer">@johndoe</span>
|
||||
* </HoverCard>
|
||||
* ```
|
||||
*/
|
||||
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<React.CSSProperties>({});
|
||||
const [actualPosition, setActualPosition] = useState<HoverCardPosition>(position);
|
||||
|
||||
const triggerRef = useRef<HTMLDivElement>(null);
|
||||
const cardRef = useRef<HTMLDivElement>(null);
|
||||
const openTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const closeTimerRef = useRef<ReturnType<typeof setTimeout> | 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 (
|
||||
<>
|
||||
<div
|
||||
ref={triggerRef}
|
||||
className="inline-block"
|
||||
onMouseEnter={open}
|
||||
onMouseLeave={close}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
|
||||
{createPortal(
|
||||
<AnimatePresence>
|
||||
{visible && (
|
||||
<motion.div
|
||||
ref={cardRef}
|
||||
role="dialog"
|
||||
className={cn(
|
||||
'fixed z-50 p-4 max-w-xs',
|
||||
'bg-popover border border-border rounded-xl shadow-lg backdrop-blur-md',
|
||||
className,
|
||||
)}
|
||||
style={{
|
||||
...cardStyle,
|
||||
...(maxWidth != null && { maxWidth: `${maxWidth}px` }),
|
||||
}}
|
||||
initial={{ opacity: 0, scale: 0.96, y: getInitialY(actualPosition) }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.96 }}
|
||||
transition={{ duration: 0.15, ease: 'easeOut' }}
|
||||
onMouseEnter={open}
|
||||
onMouseLeave={close}
|
||||
>
|
||||
{content}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>,
|
||||
document.body,
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
126
apps/web/src/components/ui/hover-card/TrackHoverContent.tsx
Normal file
126
apps/web/src/components/ui/hover-card/TrackHoverContent.tsx
Normal file
|
|
@ -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
|
||||
* <HoverCard
|
||||
* content={
|
||||
* <TrackHoverContent
|
||||
* title="Midnight Sun"
|
||||
* artist="Aurora"
|
||||
* album="Ethereal"
|
||||
* cover="/covers/midnight.jpg"
|
||||
* duration="4:32"
|
||||
* genre="Ambient"
|
||||
* bpm={120}
|
||||
* onPlay={() => play(track)}
|
||||
* />
|
||||
* }
|
||||
* >
|
||||
* <span>Midnight Sun</span>
|
||||
* </HoverCard>
|
||||
* ```
|
||||
*/
|
||||
export function TrackHoverContent({
|
||||
title,
|
||||
artist,
|
||||
album,
|
||||
cover,
|
||||
duration,
|
||||
genre,
|
||||
bpm,
|
||||
onPlay,
|
||||
}: TrackHoverContentProps) {
|
||||
return (
|
||||
<div className="flex gap-3">
|
||||
{/* Cover art */}
|
||||
<div className="relative flex-shrink-0">
|
||||
<div className="size-16 rounded-lg overflow-hidden bg-muted">
|
||||
{cover ? (
|
||||
<img
|
||||
src={cover}
|
||||
alt={`${title} cover`}
|
||||
className="size-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="size-full flex items-center justify-center">
|
||||
<Activity className="size-6 text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Play overlay */}
|
||||
{onPlay && (
|
||||
<Button
|
||||
variant="default"
|
||||
size="icon"
|
||||
className="absolute inset-0 m-auto size-8 opacity-0 hover:opacity-100 transition-opacity"
|
||||
onClick={onPlay}
|
||||
aria-label={`Play ${title}`}
|
||||
>
|
||||
<Play className="size-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Track info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-semibold text-foreground truncate">
|
||||
{title}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground truncate">{artist}</p>
|
||||
{album && (
|
||||
<p className="text-xs text-muted-foreground/70 truncate">{album}</p>
|
||||
)}
|
||||
|
||||
{/* Stats row */}
|
||||
{(duration || genre || bpm) && (
|
||||
<div className="mt-2 flex items-center gap-3 text-xs text-muted-foreground">
|
||||
{duration && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock className="size-3" />
|
||||
{duration}
|
||||
</span>
|
||||
)}
|
||||
{genre && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Tag className="size-3" />
|
||||
{genre}
|
||||
</span>
|
||||
)}
|
||||
{bpm && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Activity className="size-3" />
|
||||
{bpm} BPM
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
128
apps/web/src/components/ui/hover-card/UserHoverContent.tsx
Normal file
128
apps/web/src/components/ui/hover-card/UserHoverContent.tsx
Normal file
|
|
@ -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
|
||||
* <HoverCard
|
||||
* content={
|
||||
* <UserHoverContent
|
||||
* name="Jane Doe"
|
||||
* username="janedoe"
|
||||
* avatar="/avatars/jane.jpg"
|
||||
* bio="Producer & DJ"
|
||||
* followersCount={1200}
|
||||
* tracksCount={34}
|
||||
* />
|
||||
* }
|
||||
* >
|
||||
* <span>@janedoe</span>
|
||||
* </HoverCard>
|
||||
* ```
|
||||
*/
|
||||
export function UserHoverContent({
|
||||
name,
|
||||
username,
|
||||
avatar,
|
||||
bio,
|
||||
followersCount,
|
||||
tracksCount,
|
||||
isFollowing = false,
|
||||
onFollow,
|
||||
}: UserHoverContentProps) {
|
||||
return (
|
||||
<div className="-m-4">
|
||||
{/* Cover gradient */}
|
||||
<div className="h-12 rounded-t-xl bg-gradient-to-r from-primary/40 to-accent/40" />
|
||||
|
||||
{/* Avatar + identity */}
|
||||
<div className="px-4 -mt-5">
|
||||
<Avatar
|
||||
src={avatar}
|
||||
fallback={name}
|
||||
size="lg"
|
||||
className="ring-4 ring-popover"
|
||||
/>
|
||||
|
||||
<div className="mt-2">
|
||||
<p className="text-sm font-semibold text-foreground leading-tight">
|
||||
{name}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">@{username}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bio */}
|
||||
{bio && (
|
||||
<p className="mt-2 px-4 text-sm text-muted-foreground line-clamp-2">
|
||||
{bio}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Stats */}
|
||||
{(followersCount != null || tracksCount != null) && (
|
||||
<div className="mt-3 px-4 flex items-center gap-4 text-xs text-muted-foreground">
|
||||
{followersCount != null && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Users className="size-3.5" />
|
||||
<span className="font-medium text-foreground">
|
||||
{followersCount.toLocaleString()}
|
||||
</span>
|
||||
followers
|
||||
</span>
|
||||
)}
|
||||
{tracksCount != null && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Music className="size-3.5" />
|
||||
<span className="font-medium text-foreground">
|
||||
{tracksCount.toLocaleString()}
|
||||
</span>
|
||||
tracks
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Follow button */}
|
||||
{onFollow && (
|
||||
<div className="mt-3 px-4 pb-4">
|
||||
<Button
|
||||
variant={isFollowing ? 'outline' : 'default'}
|
||||
size="sm"
|
||||
className="w-full"
|
||||
onClick={onFollow}
|
||||
>
|
||||
{isFollowing ? 'Following' : 'Follow'}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Bottom spacing when no follow button */}
|
||||
{!onFollow && <div className="pb-4" />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
6
apps/web/src/components/ui/hover-card/index.ts
Normal file
6
apps/web/src/components/ui/hover-card/index.ts
Normal file
|
|
@ -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';
|
||||
22
apps/web/src/components/ui/hover-card/types.ts
Normal file
22
apps/web/src/components/ui/hover-card/types.ts
Normal file
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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<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') {
|
||||
return (
|
||||
const tableRow = (
|
||||
<tr
|
||||
role="row"
|
||||
className={cn(
|
||||
|
|
@ -139,9 +182,16 @@ export function TrackListRow({
|
|||
)}
|
||||
</tr>
|
||||
);
|
||||
|
||||
return hasContextMenu ? (
|
||||
<ContextMenu items={contextMenuItems}>{tableRow}</ContextMenu>
|
||||
) : (
|
||||
tableRow
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
// ── List format ─────────────────────────────────────────────────────
|
||||
const listRow = (
|
||||
<li
|
||||
role="listitem"
|
||||
className={cn(
|
||||
|
|
@ -242,4 +292,10 @@ export function TrackListRow({
|
|||
)}
|
||||
</li>
|
||||
);
|
||||
|
||||
return hasContextMenu ? (
|
||||
<ContextMenu items={contextMenuItems}>{listRow}</ContextMenu>
|
||||
) : (
|
||||
listRow
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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%);
|
||||
|
|
|
|||
Loading…
Reference in a new issue