feat(ui): premium empty states + focus ring consistency
Empty states enhanced: - EmptyState component gains variant prop (default/centered/card) - Soft entry animation (fade + scale) via new CSS keyframe - Icon wrapped in muted background circle - Library: "Your library is empty" + "Upload Track" action - Search: "No results found" + improved description - Wishlist: "Explore the marketplace" + Browse button - Queue: "Nothing in your queue" with autoplay context - Chat: improved no-conversation and no-messages states Focus ring consistency (6 files fixed): - input.tsx: ring-primary/30 → ring-ring + ring-offset - checkbox.tsx: peer-focus → peer-focus-visible + ring-ring - textarea.tsx: focus:ring-1 → focus-visible:ring-2 + ring-ring - List.tsx: added ring-offset-background - TrackListRow.tsx: full focus-visible on rows + action buttons - PlaylistCard.tsx: focus-visible on checkbox button Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
b1f5fbf0bf
commit
cd764d32cb
14 changed files with 186 additions and 84 deletions
|
|
@ -97,11 +97,16 @@ export const WishlistView: React.FC = () => {
|
|||
if (wishlist.length === 0) {
|
||||
return (
|
||||
<EmptyState
|
||||
variant="centered"
|
||||
icon={<Heart className="w-full h-full" />}
|
||||
title="Your wishlist is empty"
|
||||
description="Save items you want to listen to later or purchase in the future."
|
||||
description="Explore the marketplace and save items you love."
|
||||
action={{
|
||||
label: 'Browse Marketplace',
|
||||
onClick: () => (window.location.href = '/marketplace'),
|
||||
}}
|
||||
size="lg"
|
||||
className="min-h-96 animate-fadeIn"
|
||||
className="min-h-96"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -70,7 +70,7 @@ export function List({
|
|||
variant === 'bordered' && 'px-4 py-4',
|
||||
variant === 'spaced' && 'px-2 py-2',
|
||||
variant === 'default' && 'px-2 py-2',
|
||||
item.onClick && !item.disabled && 'cursor-pointer hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
|
||||
item.onClick && !item.disabled && 'cursor-pointer hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background',
|
||||
item.disabled && 'cursor-not-allowed opacity-50',
|
||||
itemClassName,
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import React, { useState } from 'react';
|
|||
import { useAudio } from '../../../context/AudioContext';
|
||||
import { Card } from '../../ui/card';
|
||||
import { Button } from '../../ui/button';
|
||||
import { EmptyState } from '../../ui/empty-state';
|
||||
import { SaveQueueAsPlaylistModal } from './SaveQueueAsPlaylistModal';
|
||||
import {
|
||||
Play,
|
||||
|
|
@ -157,17 +158,17 @@ export const QueueView: React.FC = () => {
|
|||
|
||||
<div className="space-y-2">
|
||||
{queue.length === 0 ? (
|
||||
<div className="text-center py-12 border-2 border-dashed border-border rounded-xl text-muted-foreground">
|
||||
<ListMusic className="w-12 h-12 mx-auto mb-4 opacity-50" />
|
||||
<p className="text-sm">
|
||||
Queue is empty. Add tracks to keep the vibe going.
|
||||
</p>
|
||||
{autoplay && (
|
||||
<p className="text-xs text-success mt-2">
|
||||
Autoplay is on. We'll pick a song for you.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<EmptyState
|
||||
variant="card"
|
||||
icon={<ListMusic className="w-full h-full" />}
|
||||
title="Nothing in your queue"
|
||||
description={
|
||||
autoplay
|
||||
? 'Autoplay is on — we\u2019ll pick something for you.'
|
||||
: 'Start playing music and add tracks to build your queue.'
|
||||
}
|
||||
size="md"
|
||||
/>
|
||||
) : (
|
||||
queue.map((track, i) => (
|
||||
<div
|
||||
|
|
|
|||
|
|
@ -108,7 +108,7 @@ export const Checkbox = React.forwardRef<HTMLInputElement, CheckboxProps>(
|
|||
className="
|
||||
w-5 h-5 rounded border border-border bg-kodo-graphite
|
||||
peer-checked:bg-primary peer-checked:border-border
|
||||
peer-focus:ring-2 peer-focus:ring-kodo-steel/30
|
||||
peer-focus-visible:ring-2 peer-focus-visible:ring-ring peer-focus-visible:ring-offset-2 peer-focus-visible:ring-offset-background
|
||||
transition-all duration-[var(--duration-fast)]
|
||||
"
|
||||
></div>
|
||||
|
|
|
|||
|
|
@ -83,6 +83,17 @@ export interface EmptyStateProps {
|
|||
* @default 'md'
|
||||
*/
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
|
||||
/**
|
||||
* Variante d'affichage de l'état vide
|
||||
*
|
||||
* - `default`: Affiché dans un Card (comportement actuel)
|
||||
* - `centered`: Centré verticalement et horizontalement dans le parent
|
||||
* - `card`: Affiché dans un Card avec bordure en pointillé
|
||||
*
|
||||
* @default 'default'
|
||||
*/
|
||||
variant?: 'default' | 'centered' | 'card';
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -90,6 +101,7 @@ export interface EmptyStateProps {
|
|||
*
|
||||
* Composant pour afficher un état vide lorsqu'une liste ou une section est vide.
|
||||
* Design Kodo intégré avec support pour icône, titre, description et action.
|
||||
* Inclut une animation d'entrée subtile (fade-in + scale) pour un rendu poli.
|
||||
*
|
||||
* FE-COMP-003: Add empty states to all list views
|
||||
*
|
||||
|
|
@ -104,8 +116,9 @@ export interface EmptyStateProps {
|
|||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* // État vide avec icône et action
|
||||
* // État vide centré avec icône et action
|
||||
* <EmptyState
|
||||
* variant="centered"
|
||||
* icon={<Inbox />}
|
||||
* title="Aucun message"
|
||||
* description="Vous n'avez pas encore de messages"
|
||||
|
|
@ -128,6 +141,7 @@ export function EmptyState({
|
|||
action,
|
||||
className,
|
||||
size = 'md',
|
||||
variant = 'default',
|
||||
}: EmptyStateProps) {
|
||||
const sizeClasses = {
|
||||
sm: 'py-6',
|
||||
|
|
@ -141,33 +155,82 @@ export function EmptyState({
|
|||
lg: 'h-16 w-16',
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className={cn(className)}>
|
||||
<CardContent className={cn('text-center', sizeClasses[size])}>
|
||||
{icon && (
|
||||
<div className="flex justify-center mb-4">
|
||||
<div className={cn('text-muted-foreground', iconSizeClasses[size])}>
|
||||
const iconBgSizeClasses = {
|
||||
sm: 'p-3',
|
||||
md: 'p-4',
|
||||
lg: 'p-5',
|
||||
};
|
||||
|
||||
const content = (
|
||||
<div className="flex flex-col items-center animate-empty-state-in">
|
||||
{icon && (
|
||||
<div className="flex justify-center mb-4">
|
||||
<div
|
||||
className={cn(
|
||||
'bg-muted rounded-full flex items-center justify-center',
|
||||
iconBgSizeClasses[size],
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn('text-muted-foreground', iconSizeClasses[size])}
|
||||
>
|
||||
{icon}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<h3 className="text-lg font-semibold mb-2 text-foreground font-display">
|
||||
{title}
|
||||
</h3>
|
||||
{description && (
|
||||
<p className="text-sm text-muted-foreground mb-4 max-w-md mx-auto text-center">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
{action && (
|
||||
<Button
|
||||
onClick={action.onClick}
|
||||
variant={action.variant || 'default'}
|
||||
size={size === 'sm' ? 'sm' : 'default'}
|
||||
>
|
||||
{action.label}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (variant === 'centered') {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-1 items-center justify-center text-center',
|
||||
sizeClasses[size],
|
||||
className,
|
||||
)}
|
||||
<h3 className="text-lg font-semibold mb-2 text-foreground font-display">
|
||||
{title}
|
||||
</h3>
|
||||
{description && (
|
||||
<p className="text-sm text-muted-foreground mb-4 max-w-md mx-auto">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
{action && (
|
||||
<Button
|
||||
onClick={action.onClick}
|
||||
variant={action.variant || 'default'}
|
||||
size={size === 'sm' ? 'sm' : 'default'}
|
||||
>
|
||||
{action.label}
|
||||
</Button>
|
||||
>
|
||||
{content}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (variant === 'card') {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'border-2 border-dashed border-border rounded-xl text-center',
|
||||
sizeClasses[size],
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{content}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className={cn(className)}>
|
||||
<CardContent className={cn('text-center', sizeClasses[size])}>
|
||||
{content}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
|||
className={cn(
|
||||
"flex h-11 w-full rounded-xl border border-white/10 bg-black/40 px-3 py-2 text-sm text-white placeholder:text-muted-foreground/50 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:cursor-not-allowed disabled:opacity-50",
|
||||
"backdrop-blur-sm transition-all duration-[var(--duration-fast)]",
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/30 focus-visible:border-primary/50",
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
|
||||
"hover:bg-white/5 hover:border-white/20",
|
||||
icon && "pl-10",
|
||||
error && "border-destructive focus-visible:ring-destructive/30",
|
||||
|
|
|
|||
|
|
@ -87,7 +87,7 @@ const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
|
|||
'text-white placeholder-gray-500',
|
||||
'font-body text-base',
|
||||
'rounded-lg',
|
||||
'focus:outline-none focus:border-border focus:ring-1 focus:ring-kodo-steel',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background',
|
||||
'transition-all duration-[var(--duration-fast)]',
|
||||
'min-h-[100px] resize-y',
|
||||
className,
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import { TypingIndicator } from './TypingIndicator';
|
|||
import {
|
||||
Search, X, Disc,
|
||||
Clock,
|
||||
MessageSquare, Wifi
|
||||
MessageSquare
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
|
@ -61,13 +61,18 @@ export const ChatRoom: React.FC<ChatRoomProps> = ({ conversationId }) => {
|
|||
|
||||
if (!conversationId) {
|
||||
return (
|
||||
<div className="flex-1 flex flex-col items-center justify-center text-muted-foreground opacity-50 space-y-4">
|
||||
<div className="w-24 h-24 rounded-full bg-white/5 flex items-center justify-center animate-pulse">
|
||||
<Wifi className="w-10 h-10 text-kodo-steel opacity-50" />
|
||||
<div className="flex-1 flex flex-col items-center justify-center text-muted-foreground space-y-4 animate-empty-state-in">
|
||||
<div className="w-24 h-24 rounded-full bg-muted flex items-center justify-center">
|
||||
<MessageSquare className="w-10 h-10 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<p className="text-sm font-medium text-foreground mb-1">
|
||||
No conversation selected
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Pick a channel from the sidebar to start chatting.
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-sm font-mono uppercase tracking-widest">
|
||||
Awaiting Frequency Selection
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -118,14 +123,14 @@ export const ChatRoom: React.FC<ChatRoomProps> = ({ conversationId }) => {
|
|||
<div className="flex-1 overflow-y-auto custom-scrollbar p-6 space-y-4 scroll-smooth">
|
||||
{/* Welcome Message for Empty Room */}
|
||||
{currentMessages.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center h-[50vh] text-center space-y-4 opacity-60">
|
||||
<div className="w-12 h-12 rounded-xl bg-muted/10 flex items-center justify-center border border-border/20">
|
||||
<MessageSquare className="w-6 h-6 text-kodo-steel" />
|
||||
<div className="flex flex-col items-center justify-center h-[50vh] text-center space-y-4 animate-empty-state-in">
|
||||
<div className="w-14 h-14 rounded-full bg-muted flex items-center justify-center">
|
||||
<MessageSquare className="w-7 h-7 text-muted-foreground" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-white font-medium">Channel Established</p>
|
||||
<p className="text-foreground font-medium">No messages yet</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Begin transmission on this frequency.
|
||||
Send the first message to start the conversation.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { cn } from '@/lib/utils';
|
||||
import { MessageSquarePlus } from 'lucide-react';
|
||||
|
||||
export interface ChatSidebarEmptyProps {
|
||||
className?: string;
|
||||
|
|
@ -8,13 +9,19 @@ export function ChatSidebarEmpty({ className }: ChatSidebarEmptyProps) {
|
|||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'text-muted-foreground/70 text-sm p-4 text-center italic border border-dashed border-border rounded-xl m-2',
|
||||
'flex flex-col items-center gap-3 text-sm p-6 text-center border border-dashed border-border rounded-xl m-2 animate-empty-state-in',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
No active frequencies detected.
|
||||
<br />
|
||||
Initialize a new channel.
|
||||
<div className="w-10 h-10 rounded-full bg-muted flex items-center justify-center">
|
||||
<MessageSquarePlus className="w-5 h-5 text-muted-foreground" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-foreground font-medium text-sm">No conversations yet</p>
|
||||
<p className="text-muted-foreground text-xs mt-1">
|
||||
Start a new conversation to get going.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { Button } from '@/components/ui/button';
|
||||
import { EmptyState } from '@/components/ui/empty-state';
|
||||
import { FileAudio } from 'lucide-react';
|
||||
|
||||
interface LibraryPageEmptyProps {
|
||||
|
|
@ -7,21 +7,17 @@ interface LibraryPageEmptyProps {
|
|||
|
||||
export function LibraryPageEmpty({ onUploadClick }: LibraryPageEmptyProps) {
|
||||
return (
|
||||
<div className="flex-1 flex flex-col items-center justify-center text-center min-h-layout-page-sm animate-fadeIn">
|
||||
<div className="w-24 h-24 bg-muted/10 rounded-full flex items-center justify-center mb-6 border border-border transition-colors duration-[var(--duration-normal)] hover:border-primary/20 hover:bg-muted/20">
|
||||
<FileAudio className="w-10 h-10 text-muted-foreground" />
|
||||
</div>
|
||||
<h3 className="text-xl font-display font-bold text-foreground mb-2">It's empty here</h3>
|
||||
<p className="text-muted-foreground max-w-sm mx-auto mb-8">
|
||||
Start by uploading your first audio files to populate your library.
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onUploadClick}
|
||||
className="border-primary/50 text-primary hover:bg-primary/10 transition-colors duration-[var(--duration-fast)]"
|
||||
>
|
||||
Upload File
|
||||
</Button>
|
||||
</div>
|
||||
<EmptyState
|
||||
variant="centered"
|
||||
icon={<FileAudio className="w-full h-full" />}
|
||||
title="Your library is empty"
|
||||
description="Upload your first track or create a playlist to get started."
|
||||
action={{
|
||||
label: 'Upload Track',
|
||||
onClick: onUploadClick,
|
||||
}}
|
||||
size="lg"
|
||||
className="min-h-layout-page-sm"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -97,6 +97,7 @@ function PlaylistCardComponent({
|
|||
className={cn(
|
||||
'w-6 h-6 rounded-full border-2 flex items-center justify-center transition-all',
|
||||
'touch-manipulation min-h-6 min-w-6',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background',
|
||||
selected
|
||||
? 'bg-primary border-primary text-white'
|
||||
: 'bg-white/90 dark:bg-kodo-graphite/90 border-border dark:border-border text-transparent hover:border-border/50',
|
||||
|
|
|
|||
|
|
@ -1,11 +1,15 @@
|
|||
import { EmptyState } from '@/components/ui/empty-state';
|
||||
import { SearchX } from 'lucide-react';
|
||||
|
||||
export function SearchPageEmpty() {
|
||||
return (
|
||||
<div className="text-center py-20">
|
||||
<div className="text-6xl mb-4" aria-hidden>
|
||||
🔭
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold mb-2">No signals found</h2>
|
||||
<p className="text-muted-foreground">Try adjusting your search frequency.</p>
|
||||
</div>
|
||||
<EmptyState
|
||||
variant="centered"
|
||||
icon={<SearchX className="w-full h-full" />}
|
||||
title="No results found"
|
||||
description="Try adjusting your search or use different keywords."
|
||||
size="lg"
|
||||
className="py-20"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -111,9 +111,11 @@ export function TrackListRow({
|
|||
role="row"
|
||||
className={cn(
|
||||
'hover:bg-muted/50 transition-colors duration-[var(--duration-normal)]',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background',
|
||||
isSelected && 'bg-primary/10',
|
||||
className,
|
||||
)}
|
||||
tabIndex={onTrackClick ? 0 : undefined}
|
||||
onClick={() => onTrackClick?.(track)}
|
||||
>
|
||||
{showSelection && (
|
||||
|
|
@ -159,7 +161,7 @@ export function TrackListRow({
|
|||
<button
|
||||
onClick={handlePlay}
|
||||
aria-label={isPlaying ? 'Mettre en pause' : 'Lire'}
|
||||
className="text-white"
|
||||
className="text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background rounded-sm"
|
||||
>
|
||||
{PlayIcon}
|
||||
</button>
|
||||
|
|
@ -197,9 +199,11 @@ export function TrackListRow({
|
|||
className={cn(
|
||||
'flex items-center gap-4 p-2 rounded-[var(--radius-md)] hover:bg-muted/50 group h-14',
|
||||
'transition-colors duration-[var(--duration-normal)]',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background',
|
||||
isSelected && 'bg-primary/10',
|
||||
className,
|
||||
)}
|
||||
tabIndex={onTrackClick ? 0 : undefined}
|
||||
onClick={() => onTrackClick?.(track)}
|
||||
>
|
||||
{showSelection && (
|
||||
|
|
@ -247,7 +251,7 @@ export function TrackListRow({
|
|||
<button
|
||||
aria-label={isPlaying ? 'Mettre en pause' : 'Lire'}
|
||||
onClick={handlePlay}
|
||||
className="text-white"
|
||||
className="text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background rounded-sm"
|
||||
>
|
||||
{PlayIcon}
|
||||
</button>
|
||||
|
|
@ -277,14 +281,14 @@ export function TrackListRow({
|
|||
<button
|
||||
aria-label={isLiked ? 'Retirer des favoris' : 'Ajouter aux favoris'}
|
||||
onClick={handleLike}
|
||||
className="hover:text-primary transition-colors duration-[var(--duration-normal)]"
|
||||
className="hover:text-primary transition-colors duration-[var(--duration-normal)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background rounded-sm"
|
||||
>
|
||||
{LikeIcon}
|
||||
</button>
|
||||
<button
|
||||
aria-label="Plus d'options"
|
||||
onClick={handleMore}
|
||||
className="hover:text-primary transition-colors duration-[var(--duration-normal)]"
|
||||
className="hover:text-primary transition-colors duration-[var(--duration-normal)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background rounded-sm"
|
||||
>
|
||||
...
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -907,6 +907,10 @@
|
|||
animation: fade-in var(--duration-normal) var(--ease-out);
|
||||
}
|
||||
|
||||
.animate-empty-state-in {
|
||||
animation: empty-state-in var(--duration-normal) var(--ease-out) both;
|
||||
}
|
||||
|
||||
.animate-spin-slow {
|
||||
animation: spin-slow 10s linear infinite;
|
||||
}
|
||||
|
|
@ -1067,6 +1071,18 @@
|
|||
}
|
||||
}
|
||||
|
||||
@keyframes empty-state-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.96);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
0% {
|
||||
background-position: 200% 0;
|
||||
|
|
|
|||
Loading…
Reference in a new issue