feat(ui): tooltip adoption + search highlighting & skeleton loading
Tooltip adoption (18 conversions across 11 files): - Player controls: shuffle, repeat, mute, expand, close, lyrics, auto-scroll - Navbar: theme toggle - File browser: download, add tag, AI auto-tag, watermark, process with AI - Notifications: mark as read - Share links: open link, revoke link - Chat: scroll to bottom Search polish: - New highlightMatch utility — wraps matching text in <mark> with primary color - Applied to track titles, artist names, playlist names in SearchPageResults - Applied to suggestion dropdown titles and subtitles - Replaced spinner loading state with content-aware SearchPageSkeleton - Skeleton matches actual results layout (tab bar, track cards, artist circles) Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
c8d88e7f44
commit
aeade50247
16 changed files with 372 additions and 266 deletions
|
|
@ -12,6 +12,7 @@ import {
|
|||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Tooltip } from '@/components/ui/tooltip';
|
||||
import { useCartStore } from '../../stores/cartStore';
|
||||
import { useTheme } from '../theme/ThemeProvider';
|
||||
import { Notification } from '../../types';
|
||||
|
|
@ -143,15 +144,16 @@ export const Navbar: React.FC<NavbarProps> = ({ onNavigate, onLogout }) => {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={toggleTheme}
|
||||
title={`Theme: ${theme}`}
|
||||
className="hidden sm:flex"
|
||||
>
|
||||
<Palette className="w-5 h-5" />
|
||||
</Button>
|
||||
<Tooltip content={`Theme: ${theme}`}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={toggleTheme}
|
||||
className="hidden sm:flex"
|
||||
>
|
||||
<Palette className="w-5 h-5" />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
|
||||
{/* Cart Trigger */}
|
||||
<Button
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import {
|
|||
} from 'lucide-react';
|
||||
import { Notification } from '../../types';
|
||||
import { Button } from '../ui/button';
|
||||
import { Tooltip } from '@/components/ui/tooltip';
|
||||
|
||||
interface NotificationItemProps {
|
||||
notification: Notification;
|
||||
|
|
@ -70,13 +71,14 @@ export const NotificationItem: React.FC<NotificationItemProps> = ({
|
|||
|
||||
<div className="flex flex-col gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
{!notification.read && (
|
||||
<button
|
||||
onClick={() => onRead(notification.id)}
|
||||
className="p-1.5 hover:bg-white/10 rounded-full text-kodo-steel"
|
||||
title="Mark as read"
|
||||
>
|
||||
<Circle className="w-3 h-3 fill-current" />
|
||||
</button>
|
||||
<Tooltip content="Mark as read">
|
||||
<button
|
||||
onClick={() => onRead(notification.id)}
|
||||
className="p-1.5 hover:bg-white/10 rounded-full text-kodo-steel"
|
||||
>
|
||||
<Circle className="w-3 h-3 fill-current" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
{notification.actionUrl && (
|
||||
<Button
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Tooltip } from '@/components/ui/tooltip';
|
||||
import { Copy, Check, ExternalLink, Trash2, Calendar, Eye } from 'lucide-react';
|
||||
import { format, formatDistanceToNow } from 'date-fns';
|
||||
import { fr } from 'date-fns/locale';
|
||||
|
|
@ -51,14 +52,15 @@ export function ShareLinkManagerItem({
|
|||
<Copy className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => onOpen(shareUrl)}
|
||||
title="Ouvrir le lien"
|
||||
>
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
</Button>
|
||||
<Tooltip content="Ouvrir le lien">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => onOpen(shareUrl)}
|
||||
>
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-4 text-xs text-muted-foreground">
|
||||
{share.expires_at && (
|
||||
|
|
@ -98,15 +100,16 @@ export function ShareLinkManagerItem({
|
|||
</div>
|
||||
</div>
|
||||
{onRevoke && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => onRevoke(share.id)}
|
||||
className="text-destructive hover:text-destructive"
|
||||
title="Révoquer le lien"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
<Tooltip content="Révoquer le lien">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => onRevoke(share.id)}
|
||||
className="text-destructive hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,30 +1,33 @@
|
|||
import { Tooltip } from '@/components/ui/tooltip';
|
||||
|
||||
interface VirtualizedChatMessagesScrollButtonProps {
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
export function VirtualizedChatMessagesScrollButton({ onClick }: VirtualizedChatMessagesScrollButtonProps) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className="absolute bottom-4 right-4 bg-primary hover:bg-primary/90 text-primary-foreground rounded-full p-2 shadow-lg transition-colors"
|
||||
title="Revenir en bas"
|
||||
aria-label="Revenir en bas"
|
||||
>
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden
|
||||
<Tooltip content="Revenir en bas">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className="absolute bottom-4 right-4 bg-primary hover:bg-primary/90 text-primary-foreground rounded-full p-2 shadow-lg transition-colors"
|
||||
aria-label="Revenir en bas"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M19 14l-7 7m0 0l-7-7m7 7V3"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M19 14l-7 7m0 0l-7-7m7 7V3"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@
|
|||
|
||||
import { ChevronUp, X } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Tooltip } from '@/components/ui/tooltip';
|
||||
import { usePlayer } from '../hooks/usePlayer';
|
||||
import { TrackInfo } from './TrackInfo';
|
||||
import { PlayPauseButton } from './PlayPauseButton';
|
||||
|
|
@ -111,39 +112,41 @@ export function MiniPlayer({
|
|||
</div>
|
||||
|
||||
{/* Toggle Button */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
className={cn(
|
||||
'p-2 rounded-lg text-muted-foreground',
|
||||
'hover:bg-muted',
|
||||
'focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2',
|
||||
'transition-colors',
|
||||
)}
|
||||
aria-label="Agrandir le lecteur"
|
||||
title="Agrandir le lecteur"
|
||||
>
|
||||
<ChevronUp className="h-5 w-5" aria-hidden="true" />
|
||||
<span className="sr-only">Agrandir le lecteur</span>
|
||||
</button>
|
||||
|
||||
{/* Close Button (optional) */}
|
||||
{onClose && (
|
||||
<Tooltip content="Agrandir le lecteur">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
onClick={onToggle}
|
||||
className={cn(
|
||||
'p-2 rounded-lg text-muted-foreground',
|
||||
'hover:bg-muted',
|
||||
'focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2',
|
||||
'transition-colors',
|
||||
)}
|
||||
aria-label="Fermer le mini lecteur"
|
||||
title="Fermer le mini lecteur"
|
||||
aria-label="Agrandir le lecteur"
|
||||
>
|
||||
<X className="h-5 w-5" aria-hidden="true" />
|
||||
<span className="sr-only">Fermer le mini lecteur</span>
|
||||
<ChevronUp className="h-5 w-5" aria-hidden="true" />
|
||||
<span className="sr-only">Agrandir le lecteur</span>
|
||||
</button>
|
||||
</Tooltip>
|
||||
|
||||
{/* Close Button (optional) */}
|
||||
{onClose && (
|
||||
<Tooltip content="Fermer le mini lecteur">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className={cn(
|
||||
'p-2 rounded-lg text-muted-foreground',
|
||||
'hover:bg-muted',
|
||||
'focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2',
|
||||
'transition-colors',
|
||||
)}
|
||||
aria-label="Fermer le mini lecteur"
|
||||
>
|
||||
<X className="h-5 w-5" aria-hidden="true" />
|
||||
<span className="sr-only">Fermer le mini lecteur</span>
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { Play, Pause, SkipBack, SkipForward, Shuffle, Repeat } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Tooltip } from '@/components/ui/tooltip';
|
||||
|
||||
export interface PlayerControlsProps {
|
||||
isPlaying: boolean;
|
||||
|
|
@ -27,18 +28,19 @@ export function PlayerControls({
|
|||
}: PlayerControlsProps) {
|
||||
return (
|
||||
<div className={cn("flex items-center gap-4 md:gap-6", isExpanded && "gap-8")}>
|
||||
<button
|
||||
onClick={onShuffle}
|
||||
className={cn(
|
||||
"p-2 rounded-full transition-all duration-[var(--duration-normal)]",
|
||||
shuffle
|
||||
? "text-primary bg-primary/10 shadow-queue-item-current"
|
||||
: "text-muted-foreground hover:text-white hover:bg-white/5"
|
||||
)}
|
||||
title="Shuffle"
|
||||
>
|
||||
<Shuffle className={cn("w-4 h-4", isExpanded && "w-5 h-5")} />
|
||||
</button>
|
||||
<Tooltip content="Shuffle">
|
||||
<button
|
||||
onClick={onShuffle}
|
||||
className={cn(
|
||||
"p-2 rounded-full transition-all duration-[var(--duration-normal)]",
|
||||
shuffle
|
||||
? "text-primary bg-primary/10 shadow-queue-item-current"
|
||||
: "text-muted-foreground hover:text-white hover:bg-white/5"
|
||||
)}
|
||||
>
|
||||
<Shuffle className={cn("w-4 h-4", isExpanded && "w-5 h-5")} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
|
||||
<button
|
||||
onClick={onPrevious}
|
||||
|
|
@ -68,21 +70,22 @@ export function PlayerControls({
|
|||
<SkipForward className={cn("w-5 h-5 fill-current", isExpanded && "w-6 h-6")} />
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={onRepeat}
|
||||
className={cn(
|
||||
"p-2 rounded-full transition-all duration-[var(--duration-normal)] relative",
|
||||
repeat !== 'off'
|
||||
? "text-primary bg-primary/10 shadow-queue-item-current"
|
||||
: "text-muted-foreground hover:text-white hover:bg-white/5"
|
||||
)}
|
||||
title="Repeat"
|
||||
>
|
||||
<Repeat className={cn("w-4 h-4", isExpanded && "w-5 h-5")} />
|
||||
{repeat === 'track' && (
|
||||
<span className="absolute -top-1 -right-1 text-[8px] font-bold bg-primary text-black px-1 rounded-full">1</span>
|
||||
)}
|
||||
</button>
|
||||
<Tooltip content="Repeat">
|
||||
<button
|
||||
onClick={onRepeat}
|
||||
className={cn(
|
||||
"p-2 rounded-full transition-all duration-[var(--duration-normal)] relative",
|
||||
repeat !== 'off'
|
||||
? "text-primary bg-primary/10 shadow-queue-item-current"
|
||||
: "text-muted-foreground hover:text-white hover:bg-white/5"
|
||||
)}
|
||||
>
|
||||
<Repeat className={cn("w-4 h-4", isExpanded && "w-5 h-5")} />
|
||||
{repeat === 'track' && (
|
||||
<span className="absolute -top-1 -right-1 text-[8px] font-bold bg-primary text-black px-1 rounded-full">1</span>
|
||||
)}
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { usePlayerStore } from '../store/playerStore';
|
|||
import { cn } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Slider } from '@/components/ui/slider';
|
||||
import { Tooltip } from '@/components/ui/tooltip';
|
||||
import {
|
||||
ChevronDown, Heart, MoreHorizontal, Share2,
|
||||
Mic2, AlignLeft
|
||||
|
|
@ -167,15 +168,16 @@ export function PlayerExpanded({ isOpen, onClose, currentTime, duration, onSeek,
|
|||
<Button size="icon" variant="ghost" className="text-muted-foreground hover:text-white">
|
||||
<Share2 className="w-5 h-5" />
|
||||
</Button>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className={cn("transition-colors", showLyrics ? "text-primary" : "text-muted-foreground hover:text-white")}
|
||||
onClick={() => setShowLyrics(!showLyrics)}
|
||||
title={showLyrics ? "Hide lyrics" : "Show lyrics"}
|
||||
>
|
||||
<Mic2 className="w-5 h-5" />
|
||||
</Button>
|
||||
<Tooltip content={showLyrics ? "Hide lyrics" : "Show lyrics"}>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className={cn("transition-colors", showLyrics ? "text-primary" : "text-muted-foreground hover:text-white")}
|
||||
onClick={() => setShowLyrics(!showLyrics)}
|
||||
>
|
||||
<Mic2 className="w-5 h-5" />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -191,15 +193,16 @@ export function PlayerExpanded({ isOpen, onClose, currentTime, duration, onSeek,
|
|||
onMouseLeave={() => setAutoScrollLyrics(true)}
|
||||
>
|
||||
<div className="absolute top-2 right-2 z-10 opacity-0 group-hover/lyrics:opacity-100 transition-opacity">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={autoScrollLyrics ? "bg-primary/20 text-primary" : "text-muted-foreground"}
|
||||
onClick={() => setAutoScrollLyrics(!autoScrollLyrics)}
|
||||
title="Auto-scroll"
|
||||
>
|
||||
<AlignLeft className="w-4 h-4" />
|
||||
</Button>
|
||||
<Tooltip content="Auto-scroll">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={autoScrollLyrics ? "bg-primary/20 text-primary" : "text-muted-foreground"}
|
||||
onClick={() => setAutoScrollLyrics(!autoScrollLyrics)}
|
||||
>
|
||||
<AlignLeft className="w-4 h-4" />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
{lyrics?.length ? (
|
||||
<div
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@
|
|||
|
||||
import { Repeat, Shuffle } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Tooltip } from '@/components/ui/tooltip';
|
||||
|
||||
export interface RepeatShuffleButtonsProps {
|
||||
repeat: 'off' | 'track' | 'playlist';
|
||||
|
|
@ -86,64 +87,66 @@ export function RepeatShuffleButtons({
|
|||
return (
|
||||
<div className={cn('flex items-center gap-2', className)}>
|
||||
{/* Repeat Button */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRepeatClick}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
'rounded-full flex items-center justify-center transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2 relative',
|
||||
sizeClasses[size],
|
||||
variantClasses[variant],
|
||||
repeat !== 'off' &&
|
||||
'bg-primary text-primary-foreground hover:bg-primary',
|
||||
disabled && 'opacity-50 cursor-not-allowed',
|
||||
)}
|
||||
aria-label={getRepeatAriaLabel()}
|
||||
aria-pressed={repeat !== 'off'}
|
||||
aria-disabled={disabled}
|
||||
title={getRepeatLabel()}
|
||||
>
|
||||
<Repeat
|
||||
className={cn(iconSizes[size], repeat === 'track' && 'fill-current')}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{repeat === 'playlist' && (
|
||||
<span
|
||||
className="absolute bottom-0 right-0 text-[8px] font-bold leading-none bg-primary rounded-full w-3 h-3 flex items-center justify-center"
|
||||
<Tooltip content={getRepeatLabel()} disabled={disabled}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRepeatClick}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
'rounded-full flex items-center justify-center transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2 relative',
|
||||
sizeClasses[size],
|
||||
variantClasses[variant],
|
||||
repeat !== 'off' &&
|
||||
'bg-primary text-primary-foreground hover:bg-primary',
|
||||
disabled && 'opacity-50 cursor-not-allowed',
|
||||
)}
|
||||
aria-label={getRepeatAriaLabel()}
|
||||
aria-pressed={repeat !== 'off'}
|
||||
aria-disabled={disabled}
|
||||
>
|
||||
<Repeat
|
||||
className={cn(iconSizes[size], repeat === 'track' && 'fill-current')}
|
||||
aria-hidden="true"
|
||||
>
|
||||
1
|
||||
</span>
|
||||
)}
|
||||
<span className="sr-only">{getRepeatLabel()}</span>
|
||||
</button>
|
||||
/>
|
||||
{repeat === 'playlist' && (
|
||||
<span
|
||||
className="absolute bottom-0 right-0 text-[8px] font-bold leading-none bg-primary rounded-full w-3 h-3 flex items-center justify-center"
|
||||
aria-hidden="true"
|
||||
>
|
||||
1
|
||||
</span>
|
||||
)}
|
||||
<span className="sr-only">{getRepeatLabel()}</span>
|
||||
</button>
|
||||
</Tooltip>
|
||||
|
||||
{/* Shuffle Button */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onShuffleToggle}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
'rounded-full flex items-center justify-center transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2',
|
||||
sizeClasses[size],
|
||||
variantClasses[variant],
|
||||
shuffle &&
|
||||
'bg-primary text-primary-foreground hover:bg-primary',
|
||||
disabled && 'opacity-50 cursor-not-allowed',
|
||||
)}
|
||||
aria-label={shuffle ? 'Mélanger activé' : 'Mélanger désactivé'}
|
||||
aria-pressed={shuffle}
|
||||
aria-disabled={disabled}
|
||||
title={shuffle ? 'Mélanger activé' : 'Mélanger désactivé'}
|
||||
>
|
||||
<Shuffle
|
||||
className={cn(iconSizes[size], shuffle && 'fill-current')}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="sr-only">
|
||||
{shuffle ? 'Mélanger activé' : 'Mélanger désactivé'}
|
||||
</span>
|
||||
</button>
|
||||
<Tooltip content={shuffle ? 'Mélanger activé' : 'Mélanger désactivé'} disabled={disabled}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onShuffleToggle}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
'rounded-full flex items-center justify-center transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2',
|
||||
sizeClasses[size],
|
||||
variantClasses[variant],
|
||||
shuffle &&
|
||||
'bg-primary text-primary-foreground hover:bg-primary',
|
||||
disabled && 'opacity-50 cursor-not-allowed',
|
||||
)}
|
||||
aria-label={shuffle ? 'Mélanger activé' : 'Mélanger désactivé'}
|
||||
aria-pressed={shuffle}
|
||||
aria-disabled={disabled}
|
||||
>
|
||||
<Shuffle
|
||||
className={cn(iconSizes[size], shuffle && 'fill-current')}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="sr-only">
|
||||
{shuffle ? 'Mélanger activé' : 'Mélanger désactivé'}
|
||||
</span>
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@
|
|||
import React, { useState, useRef, useCallback, useEffect } from 'react';
|
||||
import { Volume2, VolumeX, Volume1 } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Tooltip } from '@/components/ui/tooltip';
|
||||
|
||||
export interface VolumeControlProps {
|
||||
volume: number; // 0-100
|
||||
|
|
@ -101,23 +102,24 @@ export function VolumeControl({
|
|||
return (
|
||||
<div className={cn('flex items-center gap-2', className)}>
|
||||
{/* Mute Button */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onMuteToggle}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
'rounded-full flex items-center justify-center transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2 h-10 w-10',
|
||||
'bg-transparent text-foreground hover:bg-muted focus:ring-muted',
|
||||
disabled && 'opacity-50 cursor-not-allowed',
|
||||
)}
|
||||
aria-label={getVolumeLabel()}
|
||||
aria-pressed={muted}
|
||||
aria-disabled={disabled}
|
||||
title={getVolumeLabel()}
|
||||
>
|
||||
{getVolumeIcon()}
|
||||
<span className="sr-only">{getVolumeLabel()}</span>
|
||||
</button>
|
||||
<Tooltip content={getVolumeLabel()} disabled={disabled}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onMuteToggle}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
'rounded-full flex items-center justify-center transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2 h-10 w-10',
|
||||
'bg-transparent text-foreground hover:bg-muted focus:ring-muted',
|
||||
disabled && 'opacity-50 cursor-not-allowed',
|
||||
)}
|
||||
aria-label={getVolumeLabel()}
|
||||
aria-pressed={muted}
|
||||
aria-disabled={disabled}
|
||||
>
|
||||
{getVolumeIcon()}
|
||||
<span className="sr-only">{getVolumeLabel()}</span>
|
||||
</button>
|
||||
</Tooltip>
|
||||
|
||||
{/* Volume Slider */}
|
||||
{showSlider && (
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ import { SearchPageEmpty } from './SearchPageEmpty';
|
|||
import { SearchPageError } from './SearchPageError';
|
||||
import { SearchPageResults } from './SearchPageResults';
|
||||
import { SearchPageSkeleton } from './SearchPageSkeleton';
|
||||
import { LoadingSpinner } from '@/components/ui/loading-spinner';
|
||||
|
||||
/**
|
||||
* Search page — orchestrator.
|
||||
|
|
@ -38,19 +37,13 @@ export function SearchPage() {
|
|||
)}
|
||||
|
||||
{isLoading ? (
|
||||
<div
|
||||
className="flex flex-col items-center justify-center py-20 opacity-50"
|
||||
aria-busy="true"
|
||||
>
|
||||
<LoadingSpinner className="w-12 h-12 mb-4" />
|
||||
<p className="text-sm font-mono animate-pulse">Scanning frequencies...</p>
|
||||
</div>
|
||||
<SearchPageSkeleton />
|
||||
) : !query ? (
|
||||
<SearchPageDiscovery />
|
||||
) : !hasResults ? (
|
||||
<SearchPageEmpty />
|
||||
) : results ? (
|
||||
<SearchPageResults results={results} />
|
||||
<SearchPageResults results={results} query={query} />
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -5,12 +5,14 @@ import { Avatar } from '@/components/ui/avatar';
|
|||
import { Music, User } from 'lucide-react';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
import type { SearchResults } from '@/types/search';
|
||||
import { highlightMatch } from '../../utils/highlightMatch';
|
||||
|
||||
interface SearchPageResultsProps {
|
||||
results: SearchResults;
|
||||
query?: string;
|
||||
}
|
||||
|
||||
export function SearchPageResults({ results }: SearchPageResultsProps) {
|
||||
export function SearchPageResults({ results, query = '' }: SearchPageResultsProps) {
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
|
|
@ -72,10 +74,10 @@ export function SearchPageResults({ results }: SearchPageResultsProps) {
|
|||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h4 className="font-bold truncate group-hover:text-primary transition-colors">
|
||||
{track.title}
|
||||
{highlightMatch(track.title, query)}
|
||||
</h4>
|
||||
<p className="text-xs text-muted-foreground truncate">
|
||||
{track.artist}
|
||||
{highlightMatch(track.artist, query)}
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
|
|
@ -105,7 +107,7 @@ export function SearchPageResults({ results }: SearchPageResultsProps) {
|
|||
className="w-24 h-24 mb-4 shadow-lg group-hover:scale-105 transition-transform"
|
||||
/>
|
||||
<h4 className="font-bold truncate w-full group-hover:text-primary transition-colors">
|
||||
{artist.username}
|
||||
{highlightMatch(artist.username, query)}
|
||||
</h4>
|
||||
<p className="text-xs text-muted-foreground truncate">
|
||||
{artist.followers_count ?? 0} followers
|
||||
|
|
@ -143,8 +145,8 @@ export function SearchPageResults({ results }: SearchPageResultsProps) {
|
|||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h4 className="font-bold group-hover:text-primary">{track.title}</h4>
|
||||
<p className="text-muted-foreground text-sm">{track.artist}</p>
|
||||
<h4 className="font-bold group-hover:text-primary">{highlightMatch(track.title, query)}</h4>
|
||||
<p className="text-muted-foreground text-sm">{highlightMatch(track.artist, query)}</p>
|
||||
</div>
|
||||
<div className="text-xs font-mono text-muted-foreground flex-shrink-0">
|
||||
{track.created_at ? `${formatDistanceToNow(new Date(track.created_at))} ago` : null}
|
||||
|
|
@ -172,7 +174,7 @@ export function SearchPageResults({ results }: SearchPageResultsProps) {
|
|||
className="w-32 h-32 mb-4 shadow-lg group-hover:scale-105 transition-transform"
|
||||
/>
|
||||
<h4 className="font-bold text-lg group-hover:text-primary">
|
||||
{artist.username}
|
||||
{highlightMatch(artist.username, query)}
|
||||
</h4>
|
||||
</Card>
|
||||
))}
|
||||
|
|
@ -202,7 +204,7 @@ export function SearchPageResults({ results }: SearchPageResultsProps) {
|
|||
<div className="absolute inset-0 bg-black/20 group-hover:bg-transparent transition-colors" />
|
||||
</div>
|
||||
<div className="p-4">
|
||||
<h4 className="font-bold group-hover:text-primary">{playlist.title}</h4>
|
||||
<h4 className="font-bold group-hover:text-primary">{highlightMatch(playlist.title, query)}</h4>
|
||||
<p className="text-xs text-muted-foreground line-clamp-2 mt-1">
|
||||
{playlist.description ?? 'No description'}
|
||||
</p>
|
||||
|
|
|
|||
|
|
@ -1,20 +1,66 @@
|
|||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
|
||||
/**
|
||||
* Skeleton aligned with SearchPage layout to avoid layout shift.
|
||||
* Same structure: header (title + input) + content area.
|
||||
* Content-aware skeleton matching SearchPageResults layout.
|
||||
*
|
||||
* Renders two sections:
|
||||
* 1. "Top Tracks" — 6 horizontal cards (icon + title/subtitle)
|
||||
* 2. "Artists" — 5 avatar cards
|
||||
*
|
||||
* No arbitrary values — uses Tailwind scale + layout primitives.
|
||||
*/
|
||||
export function SearchPageSkeleton() {
|
||||
return (
|
||||
<div className="min-h-layout-page pb-24 container mx-auto px-4 py-8 max-w-6xl">
|
||||
<div className="mb-12 text-center max-w-3xl mx-auto">
|
||||
<Skeleton className="h-12 w-64 mx-auto mb-6 rounded" />
|
||||
<Skeleton className="h-14 w-full rounded-2xl" />
|
||||
</div>
|
||||
<div className="flex flex-col items-center justify-center py-20">
|
||||
<Skeleton className="w-12 h-12 rounded-full mb-4" />
|
||||
<Skeleton className="h-4 w-48 rounded" />
|
||||
<div className="space-y-12" aria-busy="true">
|
||||
{/* Tabs bar skeleton */}
|
||||
<div className="flex gap-8 border-b border-white/10 pb-3">
|
||||
<Skeleton className="h-6 w-24 rounded" />
|
||||
<Skeleton className="h-6 w-20 rounded" />
|
||||
<Skeleton className="h-6 w-20 rounded" />
|
||||
<Skeleton className="h-6 w-24 rounded" />
|
||||
</div>
|
||||
|
||||
{/* Top Tracks section */}
|
||||
<section className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Skeleton className="h-5 w-5 rounded" />
|
||||
<Skeleton className="h-6 w-28 rounded" />
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex items-center gap-4 rounded-xl p-3"
|
||||
>
|
||||
<Skeleton className="h-16 w-16 rounded-lg flex-shrink-0" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<Skeleton className="h-4 w-3/4 rounded" />
|
||||
<Skeleton className="h-3 w-1/2 rounded" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Artists section */}
|
||||
<section className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Skeleton className="h-5 w-5 rounded" />
|
||||
<Skeleton className="h-6 w-20 rounded" />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-5 gap-4">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex flex-col items-center gap-4 rounded-xl p-4"
|
||||
>
|
||||
<Skeleton variant="circular" className="h-24 w-24" />
|
||||
<Skeleton className="h-4 w-2/3 rounded" />
|
||||
<Skeleton className="h-3 w-1/2 rounded" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { Clock, Music, User, List } from 'lucide-react';
|
|||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { SearchResult } from './types';
|
||||
import { highlightMatch } from '../../utils/highlightMatch';
|
||||
|
||||
export type DisplayItem =
|
||||
| { type: 'history'; data: string }
|
||||
|
|
@ -140,10 +141,10 @@ export function SearchDropdown({
|
|||
{getResultIcon(result.type)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium truncate">{result.title}</div>
|
||||
<div className="font-medium truncate">{highlightMatch(result.title, query)}</div>
|
||||
{result.subtitle && (
|
||||
<div className="text-xs text-muted-foreground truncate">
|
||||
{result.subtitle}
|
||||
{highlightMatch(result.subtitle, query)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
33
apps/web/src/features/search/utils/highlightMatch.tsx
Normal file
33
apps/web/src/features/search/utils/highlightMatch.tsx
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import React from 'react';
|
||||
|
||||
/**
|
||||
* Highlights portions of `text` that match `query` (case-insensitive).
|
||||
*
|
||||
* Returns the original string when there's nothing to highlight,
|
||||
* or a ReactNode array with `<mark>` wrappers around matched fragments.
|
||||
*/
|
||||
export function highlightMatch(
|
||||
text: string,
|
||||
query: string,
|
||||
): React.ReactNode {
|
||||
if (!query.trim()) return text;
|
||||
|
||||
const escaped = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
const regex = new RegExp(`(${escaped})`, 'gi');
|
||||
const parts = text.split(regex);
|
||||
|
||||
if (parts.length === 1) return text;
|
||||
|
||||
return parts.map((part, i) =>
|
||||
regex.test(part) ? (
|
||||
<mark
|
||||
key={i}
|
||||
className="bg-primary/20 text-primary rounded-sm px-0.5"
|
||||
>
|
||||
{part}
|
||||
</mark>
|
||||
) : (
|
||||
<React.Fragment key={i}>{part}</React.Fragment>
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
import React from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Tooltip } from '@/components/ui/tooltip';
|
||||
import {
|
||||
CheckSquare,
|
||||
Square,
|
||||
|
|
@ -122,15 +123,16 @@ export function FileTableRow({
|
|||
<td className="p-4 text-right">
|
||||
<div className="flex justify-end gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
{file.type === 'audio' && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="p-1.5 text-muted-foreground"
|
||||
title="Process with AI"
|
||||
onClick={() => onAction?.('ai', file)}
|
||||
>
|
||||
<Wand2 className="w-4 h-4" />
|
||||
</Button>
|
||||
<Tooltip content="Process with AI">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="p-1.5 text-muted-foreground"
|
||||
onClick={() => onAction?.('ai', file)}
|
||||
>
|
||||
<Wand2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import React from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Tooltip } from '@/components/ui/tooltip';
|
||||
import { SearchInput } from '@/components/ui/input';
|
||||
import {
|
||||
LayoutGrid,
|
||||
|
|
@ -130,24 +131,26 @@ export function FileToolbar({
|
|||
<div className="flex gap-2 w-full xl:w-auto justify-between xl:justify-end">
|
||||
{selectedCount > 0 && (
|
||||
<div className="flex gap-2 mr-2 animate-fadeIn">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onBulkDownload}
|
||||
title="Download"
|
||||
aria-label="Télécharger"
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onBulkTag}
|
||||
title="Add Tag"
|
||||
aria-label="Ajouter un tag"
|
||||
>
|
||||
<Tag className="w-4 h-4" />
|
||||
</Button>
|
||||
<Tooltip content="Download">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onBulkDownload}
|
||||
aria-label="Télécharger"
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Tooltip content="Add Tag">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onBulkTag}
|
||||
aria-label="Ajouter un tag"
|
||||
>
|
||||
<Tag className="w-4 h-4" />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
|
|
@ -161,22 +164,24 @@ export function FileToolbar({
|
|||
)}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={onMetadataClick}
|
||||
title="AI Auto-Tag"
|
||||
aria-label="AI Auto-Tag"
|
||||
>
|
||||
<Wand2 className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={onWatermarkClick}
|
||||
title="Watermark Settings"
|
||||
aria-label="Paramètres de filigrane"
|
||||
>
|
||||
<Stamp className="w-4 h-4" />
|
||||
</Button>
|
||||
<Tooltip content="AI Auto-Tag">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={onMetadataClick}
|
||||
aria-label="AI Auto-Tag"
|
||||
>
|
||||
<Wand2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Tooltip content="Watermark Settings">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={onWatermarkClick}
|
||||
aria-label="Paramètres de filigrane"
|
||||
>
|
||||
<Stamp className="w-4 h-4" />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<div className="bg-kodo-void rounded-lg p-1 border border-border flex items-center">
|
||||
<span className="text-xs text-muted-foreground px-2 uppercase font-bold">
|
||||
Sort:
|
||||
|
|
|
|||
Loading…
Reference in a new issue