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:
senke 2026-02-09 23:14:00 +01:00
parent c8d88e7f44
commit aeade50247
16 changed files with 372 additions and 266 deletions

View file

@ -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

View file

@ -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

View file

@ -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>

View file

@ -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>
);
}

View file

@ -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>

View file

@ -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>
);
}

View file

@ -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

View file

@ -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>
);
}

View file

@ -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 && (

View file

@ -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>
);

View file

@ -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>

View file

@ -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>
);
}

View file

@ -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>

View 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>
),
);
}

View file

@ -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"

View file

@ -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: