feat: frontend improvements — UI polish, player bar, auth flow, i18n

- Header, Sidebar, Toast, Dropdown, EmptyState component refinements
- Auth flow: LoginPage, RegisterPage, AuthInput, AuthLayout improvements
- Player bar: glass effect, progress, track info, controls enhancements
- Dashboard, Discover, Search pages updates
- PlaylistCard, TrackCard component improvements
- Auth store and API interceptors hardening
- i18n: updated en/es/fr locale files
- CSS additions in index.css
- Package.json and vite config updates

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
senke 2026-03-18 11:35:44 +01:00
parent f047276362
commit 4b57b46bac
34 changed files with 946 additions and 449 deletions

View file

@ -25,18 +25,7 @@
"test:hooks": "vitest run src/hooks",
"test:misc": "vitest run src/config src/context src/lib src/router src/schemas src/stores src/utils src/__tests__",
"test:groups": "npm run test:auth && npm run test:tracks && npm run test:playlists && npm run test:player && npm run test:streaming && npm run test:settings-profile-chat && npm run test:components-ui && npm run test:components-other && npm run test:services && npm run test:hooks && npm run test:misc",
"test:e2e": "playwright test",
"test:e2e:msw": "cross-env VITE_USE_MSW=1 playwright test",
"test:e2e:mocks": "playwright test --config=playwright.config.mocks.ts",
"test:e2e:mocks:ui": "playwright test --config=playwright.config.mocks.ts --ui",
"test:visual": "playwright test --config=playwright.config.visual.ts",
"test:visual:update": "playwright test --config=playwright.config.visual.ts --update-snapshots",
"test:visual:report": "playwright show-report e2e/playwright-report-visual",
"visual:capture": "playwright test --config=playwright.config.visual.ts",
"visual:update": "cross-env VISUAL_UPDATE_BASELINES=1 playwright test --config=playwright.config.visual.ts",
"visual:baseline": "node scripts/capture-visual-baseline.mjs --baseline",
"visual:compare": "node scripts/generate-visual-report.mjs",
"visual:test": "playwright test --config=playwright.config.visual.ts",
"test:e2e": "echo 'E2E tests moved to repo root: npm run e2e'",
"lint": "eslint . --ext ts,tsx",
"lint:fix": "eslint . --ext ts,tsx --fix",
"lint:ui": "eslint src/components src/features --ext ts,tsx",
@ -67,7 +56,7 @@
"storybook": "cross-env VITE_API_URL=/api/v1 VITE_USE_MSW=true VITE_STORYBOOK=true storybook dev -p 6006",
"build-storybook": "cross-env VITE_API_URL=/api/v1 VITE_USE_MSW=true VITE_STORYBOOK=true storybook build",
"test:storybook": "node scripts/audit-storybook.js",
"test:storybook:playwright": "playwright test --config=playwright.config.storybook.ts",
"test:storybook:playwright": "echo 'Storybook tests moved to repo root: npm run e2e -- --grep storybook'",
"validate:storybook": "node scripts/validate-storybook.cjs"
},
"dependencies": {
@ -108,7 +97,6 @@
},
"devDependencies": {
"@openapitools/openapi-generator-cli": "^2.27.0",
"@playwright/test": "^1.58.2",
"@storybook/addon-a11y": "^8.6.15",
"@storybook/addon-essentials": "^8.6.15",
"@storybook/addon-interactions": "^8.6.15",
@ -145,7 +133,6 @@
"msw": "^2.11.2",
"msw-storybook-addon": "^2.0.6",
"pixelmatch": "^5.3.0",
"playwright": "^1.58.1",
"pngjs": "^7.0.0",
"prettier": "^3.2.5",
"rollup-plugin-visualizer": "^6.0.5",

View file

@ -57,7 +57,7 @@ export function Header(_props: HeaderProps) {
};
return (
<header className="fixed top-0 left-0 right-0 h-header z-[var(--sumi-z-sticky)] pointer-events-none">
<header data-testid="app-header" className="fixed top-0 left-0 right-0 h-header z-[var(--sumi-z-sticky)] pointer-events-none">
<div className={cn(
'absolute top-0 right-0 h-header bg-[var(--sumi-glass-bg)] backdrop-blur-[12px] border-b border-[var(--sumi-border-faint)] flex items-center justify-between px-4 md:px-6 pointer-events-auto transition-all duration-[var(--sumi-duration-fast)]',
sidebarOpen ? 'left-header-expanded' : 'left-header-collapsed',
@ -72,18 +72,26 @@ export function Header(_props: HeaderProps) {
<Menu className="w-5 h-5" />
</button>
{/* Search — Spotify-style: navigate to /search on focus or Enter */}
<div className="flex-1 max-w-md relative hidden md:block group">
{/* Search — Spotify-style: pill shape, smooth focus expansion */}
<div className="flex-1 max-w-lg relative hidden md:block">
<div
role="search"
className="relative flex items-center group/search rounded-full focus-within:ring-2 focus-within:ring-primary/50 transition-all duration-[var(--duration-fast)]"
className="relative flex items-center rounded-full transition-all duration-[var(--sumi-duration-normal)] ease-[var(--sumi-ease-out)] group/search"
>
<Search className="absolute left-3 w-4 h-4 text-muted-foreground pointer-events-none" />
<Search className="absolute left-3.5 w-4 h-4 text-muted-foreground pointer-events-none transition-colors duration-[var(--sumi-duration-fast)] group-focus-within/search:text-primary" />
<input
data-testid="search-input"
type="search"
placeholder={t('header.searchPlaceholder')}
aria-label={t('header.searchAriaLabel')}
className="w-full h-10 pl-10 pr-4 bg-muted/30 border-0 rounded-full text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-0 focus-visible:ring-2 focus-visible:ring-primary/50 focus-visible:ring-inset transition-all duration-[var(--duration-fast)]"
className={cn(
'w-full h-10 pl-10 pr-20 rounded-full text-sm text-foreground',
'bg-[var(--sumi-surface-subtle)] border border-transparent',
'placeholder:text-muted-foreground/50',
'focus:outline-none focus:bg-[var(--sumi-surface-card)] focus:border-[var(--sumi-border-accent)] focus:shadow-[var(--sumi-shadow-glow)]',
'hover:bg-[var(--sumi-bg-hover)]',
'transition-all duration-[var(--sumi-duration-normal)] ease-[var(--sumi-ease-out)]',
)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
@ -92,7 +100,7 @@ export function Header(_props: HeaderProps) {
}
}}
/>
<kbd className="absolute right-3 hidden sm:inline-flex items-center gap-0.5 px-2 py-0.5 rounded bg-muted/50 text-xs font-medium text-muted-foreground">
<kbd className="absolute right-3 hidden sm:inline-flex items-center gap-1 px-2 py-1 rounded-md bg-[var(--sumi-bg-overlay)] text-[10px] font-mono font-medium text-muted-foreground/70 border border-[var(--sumi-border-faint)]">
<Command className="w-3 h-3" />K
</kbd>
</div>
@ -122,28 +130,31 @@ export function Header(_props: HeaderProps) {
</Button>
</Tooltip>
{/* User — compact pill */}
{/* User — compact pill (Discord-style) */}
<div className="relative">
<button
data-testid="user-menu"
onClick={() => setIsUserMenuOpen(!isUserMenuOpen)}
className="flex items-center gap-2 pl-0.5 pr-2 py-0.5 rounded-full hover:bg-muted/50 transition-colors duration-[var(--duration-fast)] focus:outline-none focus:ring-2 focus:ring-ring group"
className="flex items-center gap-2 pl-0.5 pr-2.5 py-0.5 rounded-full hover:bg-[var(--sumi-bg-hover)] transition-all duration-[var(--sumi-duration-normal)] ease-[var(--sumi-ease-out)] focus:outline-none focus:ring-2 focus:ring-ring group"
>
<div className="w-8 h-8 rounded-full bg-primary/20 flex items-center justify-center shrink-0 group-hover:ring-2 group-hover:ring-primary/50 group-hover:scale-105 transition-all">
<span className="text-xs font-semibold text-primary">
<div className="relative w-8 h-8 rounded-full bg-gradient-to-br from-primary/30 to-primary/10 flex items-center justify-center shrink-0 group-hover:ring-2 group-hover:ring-primary/40 group-hover:scale-105 transition-all duration-[var(--sumi-duration-normal)]">
<span className="text-xs font-bold text-primary">
{user?.username?.substring(0, 2).toUpperCase() || 'VZ'}
</span>
{/* Online indicator */}
<span className="absolute -bottom-0.5 -right-0.5 w-3 h-3 rounded-full bg-[var(--sumi-sage)] border-2 border-[var(--sumi-bg-base)]" aria-hidden="true" />
</div>
<span className="hidden lg:block text-sm font-medium text-foreground truncate max-w-24">
<span className="hidden lg:block text-sm font-medium text-foreground truncate max-w-28">
{user?.username}
</span>
</button>
{isUserMenuOpen && (
<FocusTrap active={isUserMenuOpen} onEscape={() => setIsUserMenuOpen(false)}>
<div className="absolute right-0 top-full mt-2 w-56 bg-card backdrop-blur-xl border border-border rounded-xl shadow-xl p-2 z-50 animate-scaleIn origin-top-right">
<div className="px-3 py-2.5 border-b border-border mb-1">
<div className="absolute right-0 top-full mt-2 w-64 bg-[var(--sumi-surface-elevated)] backdrop-blur-2xl border border-[var(--sumi-glass-border)] rounded-2xl shadow-2xl p-1.5 z-50 animate-scaleIn origin-top-right ring-1 ring-white/5">
<div className="px-3 py-3 border-b border-[var(--sumi-border-faint)] mb-1">
<p className="text-sm font-semibold text-foreground truncate">{user?.username}</p>
<p className="text-xs text-muted-foreground truncate">{user?.email}</p>
<p className="text-xs text-muted-foreground truncate mt-0.5">{user?.email}</p>
{!user?.is_verified && (
<div className="mt-2 flex justify-center"><EmailVerificationBadge verified={false} /></div>
)}

View file

@ -4,12 +4,13 @@ import { useTranslation } from 'react-i18next';
import {
Home, Users, Disc, Radio, Settings, LogOut, ShoppingBag, Music2,
BarChart2, Shield, Box, MessageSquare,
Layers, Cpu, Heart, ListMusic, CreditCard, DollarSign, Terminal,
ChevronLeft, ChevronRight,
Layers, Heart, ListMusic, CreditCard, DollarSign, Terminal,
ChevronLeft, ChevronRight, Compass, Headphones,
} from 'lucide-react';
import { NavItem } from '../../types';
import { useUIStore } from '@/stores/ui';
import { useSidebarNavigation } from '@/hooks/useSidebarNavigation';
import { useUser } from '@/features/auth/hooks/useUser';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { Tooltip } from '@/components/ui/tooltip';
@ -21,30 +22,31 @@ interface SidebarProps {
// Section key mapping for i18n
const sectionKeys: Record<string, string> = {
workspace: 'nav.sections.workspace',
vezaNetwork: 'nav.sections.vezaNetwork',
commerce: 'nav.sections.commerce',
home: 'nav.sections.home',
create: 'nav.sections.create',
connect: 'nav.sections.connect',
library: 'nav.sections.library',
more: 'nav.sections.more',
system: 'nav.sections.system',
};
// Icon map — static, does not need translation
const iconMap: Record<string, React.ReactNode> = {
dashboard: <Home className="w-4 h-4" />,
tracks: <Layers className="w-4 h-4" />,
gear: <Box className="w-4 h-4" />,
analytics: <BarChart2 className="w-4 h-4" />,
social: <Users className="w-4 h-4" />,
discover: <Compass className="w-4 h-4" />,
feed: <Music2 className="w-4 h-4" />,
marketplace: <ShoppingBag className="w-4 h-4" />,
tracks: <Headphones className="w-4 h-4" />,
playlists: <ListMusic className="w-4 h-4" />,
favoris: <Heart className="w-4 h-4" />,
gear: <Box className="w-4 h-4" />,
live: <Radio className="w-4 h-4" />,
chat: <MessageSquare className="w-4 h-4" />,
marketplace: <ShoppingBag className="w-4 h-4" />,
social: <Users className="w-4 h-4" />,
analytics: <BarChart2 className="w-4 h-4" />,
sell: <DollarSign className="w-4 h-4" />,
wishlist: <Heart className="w-4 h-4" />,
purchases: <CreditCard className="w-4 h-4" />,
playlists: <ListMusic className="w-4 h-4" />,
favoris: <Heart className="w-4 h-4" />,
queue: <Disc className="w-4 h-4" />,
developer: <Terminal className="w-4 h-4" />,
admin: <Shield className="w-4 h-4" />,
};
@ -52,13 +54,12 @@ const iconMap: Record<string, React.ReactNode> = {
// Badge data — static
const badgeMap: Record<string, number> = { live: 3, chat: 12 };
// Navigation structure definition (ids only, labels resolved via t())
// Navigation structure — streamlined for music platform UX
const navStructure: { sectionKey: string; itemIds: string[] }[] = [
{ sectionKey: 'workspace', itemIds: ['dashboard', 'tracks', 'gear', 'analytics'] },
{ sectionKey: 'vezaNetwork', itemIds: ['social', 'feed', 'marketplace', 'live', 'chat'] },
{ sectionKey: 'commerce', itemIds: ['sell', 'wishlist', 'purchases'] },
{ sectionKey: 'library', itemIds: ['playlists', 'favoris', 'queue'] },
{ sectionKey: 'system', itemIds: ['developer', 'admin'] },
{ sectionKey: 'home', itemIds: ['dashboard', 'discover', 'feed'] },
{ sectionKey: 'library', itemIds: ['tracks', 'playlists', 'favoris'] },
{ sectionKey: 'connect', itemIds: ['live', 'chat', 'social'] },
{ sectionKey: 'more', itemIds: ['marketplace', 'analytics', 'sell', 'purchases'] },
];
function buildNavItems(t: (key: string) => string): { section: string; items: NavItem[] }[] {
@ -74,23 +75,24 @@ function buildNavItems(t: (key: string) => string): { section: string; items: Na
}
const routeMap: Record<string, string> = {
dashboard: '/dashboard', tracks: '/library', gear: '/gear',
analytics: '/analytics', social: '/social', feed: '/feed', marketplace: '/marketplace', live: '/live',
'go-live': '/live/go-live',
chat: '/chat', sell: '/sell', wishlist: '/wishlist',
purchases: '/purchases', playlists: '/playlists', favoris: '/playlists/favoris', queue: '/queue', developer: '/developer',
admin: '/admin', settings: '/settings',
dashboard: '/dashboard', discover: '/search', feed: '/feed',
tracks: '/library', playlists: '/playlists', favoris: '/playlists/favoris',
gear: '/gear', live: '/live', chat: '/chat', social: '/social',
marketplace: '/marketplace', analytics: '/analytics',
sell: '/sell', wishlist: '/wishlist', purchases: '/purchases',
developer: '/developer', admin: '/admin', settings: '/settings',
};
const navItemBaseClasses = cn(
'w-full flex items-center px-3 py-2 rounded-lg text-sm font-medium transition-all duration-[var(--duration-fast)] group relative',
'w-full flex items-center px-3 py-2 rounded-lg text-sm font-medium transition-all duration-200 ease-[cubic-bezier(0.34,1.56,0.64,1)] group relative',
'hover:scale-[1.02] active:scale-[0.98]',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 focus-visible:ring-offset-2 focus-visible:ring-offset-background'
);
const navItemInactiveClasses =
'text-muted-foreground hover:text-foreground hover:bg-sidebar-accent active:bg-sidebar-accent/80';
const navItemActiveClasses = 'bg-primary/10 text-primary sidebar-active-indicator';
const navItemActiveClasses = 'bg-primary/12 text-primary font-semibold rounded-lg shadow-[0_0_12px_-3px] shadow-primary/20';
const LG_BREAKPOINT = 1024;
@ -99,7 +101,31 @@ export const Sidebar: React.FC<SidebarProps> = ({ currentView }) => {
const location = useLocation();
const { sidebarOpen, setSidebarOpen } = useUIStore();
const { handleMobileNav, handleLogout } = useSidebarNavigation();
const navItems = useMemo(() => buildNavItems(t), [t]);
const { data: user } = useUser();
const isAdmin = user?.role === 'admin' || user?.role === 'superadmin';
const navItems = useMemo(() => {
const items = buildNavItems(t);
// Add admin/developer links for admin users
if (isAdmin) {
items.push({
section: t(sectionKeys.system ?? 'nav.sections.system', 'System'),
items: ['developer', 'admin'].map((id) => ({
id,
label: t(`nav.items.${id}`),
icon: iconMap[id],
})),
});
}
return items;
}, [t, isAdmin]);
const userInitials = useMemo(() => {
if (!user) return '?';
if (user.first_name && user.last_name) {
return `${user.first_name[0]}${user.last_name[0]}`.toUpperCase();
}
return (user.username?.[0] ?? '?').toUpperCase();
}, [user]);
const [isMobile, setIsMobile] = useState(() =>
typeof window !== 'undefined' ? window.innerWidth < LG_BREAKPOINT : false,
);
@ -135,26 +161,30 @@ export const Sidebar: React.FC<SidebarProps> = ({ currentView }) => {
<aside
data-testid="app-sidebar"
className={cn(
'fixed left-sidebar bottom-sidebar top-sidebar rounded-xl flex flex-col transition-shell z-sidebar overflow-hidden',
'fixed left-sidebar bottom-sidebar top-sidebar rounded-xl flex flex-col z-sidebar overflow-hidden',
'transition-all duration-300 ease-[cubic-bezier(0.34,1.56,0.64,1)]',
'bg-[var(--sumi-bg-raised)] backdrop-blur-md border-r border-[var(--sumi-border-faint)]',
sidebarOpen ? 'w-sidebar-expanded translate-x-0 opacity-100' : '-translate-x-full lg:translate-x-0 lg:opacity-100 lg:w-sidebar-collapsed'
)}
aria-label="Main sidebar"
>
{/* Header — minimal Spotify-style */}
{/* Header — Veza branding */}
<div className="px-4 py-4 flex items-center gap-3 relative">
<div className="w-8 h-8 rounded-lg bg-sidebar-accent flex items-center justify-center flex-shrink-0">
<Cpu className="w-4 h-4 text-muted-foreground" />
<div className="w-8 h-8 rounded-xl bg-gradient-to-br from-primary to-primary/70 flex items-center justify-center flex-shrink-0 shadow-md shadow-primary/25 transition-transform duration-300 ease-[cubic-bezier(0.34,1.56,0.64,1)] hover:scale-105">
<span className="text-primary-foreground font-bold text-base leading-none select-none" aria-hidden="true">V</span>
</div>
<div className={cn('transition-shell overflow-hidden min-w-0', sidebarOpen ? 'opacity-100' : 'w-0 opacity-0')}>
<h2 className="text-sm font-semibold text-foreground truncate">
System Hub
<div className={cn(
'transition-all duration-300 ease-[cubic-bezier(0.34,1.56,0.64,1)] overflow-hidden min-w-0',
sidebarOpen ? 'opacity-100 max-w-[160px]' : 'max-w-0 opacity-0'
)}>
<h2 className="text-sm font-bold text-foreground truncate tracking-wide">
veza
</h2>
<div className="flex items-center gap-1.5 mt-0.5">
<span className="w-1.5 h-1.5 rounded-full bg-primary shrink-0 animate-pulse" aria-hidden="true" />
<span className="w-1.5 h-1.5 rounded-full bg-emerald-500 shrink-0 animate-pulse" aria-hidden="true" />
<span className="text-xs text-muted-foreground truncate">
Online
{t('nav.status.connected', 'Connected')}
</span>
</div>
</div>
@ -165,7 +195,7 @@ export const Sidebar: React.FC<SidebarProps> = ({ currentView }) => {
onClick={() => setSidebarOpen(!sidebarOpen)}
aria-label={sidebarOpen ? 'Collapse sidebar' : 'Expand sidebar'}
className={cn(
'ml-auto text-muted-foreground hover:text-foreground hidden lg:flex hover:bg-sidebar-accent',
'ml-auto text-muted-foreground hover:text-foreground hidden lg:flex hover:bg-sidebar-accent transition-transform duration-300 ease-[cubic-bezier(0.34,1.56,0.64,1)]',
!sidebarOpen && 'absolute left-1/2 -translate-x-1/2 top-1/2 -translate-y-1/2'
)}
>
@ -183,13 +213,16 @@ export const Sidebar: React.FC<SidebarProps> = ({ currentView }) => {
<div key={group.section}>
{/* Section divider between groups */}
{idx > 0 && (
<div
className={cn(
'h-px bg-border/50 mx-3 my-1.5 transition-opacity duration-[var(--sumi-duration-normal)]',
!sidebarOpen && 'mx-1'
)}
aria-hidden="true"
/>
sidebarOpen ? (
<div
className="h-px bg-border/50 mx-3 my-1.5 transition-opacity duration-300"
aria-hidden="true"
/>
) : (
<div className="flex justify-center my-2" aria-hidden="true">
<span className="w-1 h-1 rounded-full bg-muted-foreground/40" />
</div>
)
)}
<h3
@ -265,8 +298,47 @@ export const Sidebar: React.FC<SidebarProps> = ({ currentView }) => {
))}
</nav>
{/* Footer */}
{/* Footer — Discord-style user panel */}
<div className="p-2 border-t border-[var(--sumi-border-faint)] space-y-0.5">
{/* User avatar section */}
{user && (
<div className={cn(
'flex items-center gap-3 px-3 py-2 rounded-lg mb-1',
'bg-sidebar-accent/50 transition-all duration-300 ease-[cubic-bezier(0.34,1.56,0.64,1)]',
!sidebarOpen && 'justify-center px-0'
)}>
<div className="relative flex-shrink-0">
{user.avatar_url ? (
<img
src={user.avatar_url}
alt={user.username}
className="w-8 h-8 rounded-full object-cover ring-2 ring-background"
/>
) : (
<div className="w-8 h-8 rounded-full bg-primary/20 text-primary flex items-center justify-center text-xs font-bold ring-2 ring-background select-none">
{userInitials}
</div>
)}
{/* Online indicator dot */}
<span
className="absolute -bottom-0.5 -right-0.5 w-3 h-3 rounded-full bg-emerald-500 border-2 border-[var(--sumi-bg-raised)]"
aria-label={t('nav.status.connected', 'Connected')}
/>
</div>
<div className={cn(
'min-w-0 transition-all duration-300 ease-[cubic-bezier(0.34,1.56,0.64,1)] overflow-hidden',
sidebarOpen ? 'opacity-100 max-w-[140px]' : 'max-w-0 opacity-0'
)}>
<p className="text-sm font-semibold text-foreground truncate leading-tight">
{user.username}
</p>
<p className="text-xs text-muted-foreground truncate leading-tight">
{t('nav.status.connected', 'Connected')}
</p>
</div>
</div>
)}
<Tooltip content={t('nav.settings')} position="right" disabled={sidebarOpen}>
<Link
to="/settings"

View file

@ -81,30 +81,38 @@ export const Toast: React.FC<ToastProps> = ({ id, type, message, onClose }) => {
}, [id, onClose]);
const styles = {
success: 'border-success text-foreground bg-card/90',
error: 'border-destructive text-foreground bg-card/90',
info: 'border-border text-foreground bg-card/90',
success: 'border-[var(--sumi-sage)]/30 bg-[var(--sumi-glass-bg)]',
error: 'border-[var(--sumi-vermillion)]/30 bg-[var(--sumi-glass-bg)]',
info: 'border-[var(--sumi-glass-border)] bg-[var(--sumi-glass-bg)]',
};
const iconBg = {
success: 'bg-[var(--sumi-sage-subtle)]',
error: 'bg-[var(--sumi-vermillion-subtle)]',
info: 'bg-[var(--sumi-accent-subtle)]',
};
const icons = {
success: <CheckCircle className="w-5 h-5 text-success" />,
error: <AlertCircle className="w-5 h-5 text-destructive" />,
info: <Info className="w-5 h-5 text-muted-foreground" />,
success: <CheckCircle className="w-4 h-4 text-[var(--sumi-sage)]" />,
error: <AlertCircle className="w-4 h-4 text-[var(--sumi-vermillion)]" />,
info: <Info className="w-4 h-4 text-[var(--sumi-accent)]" />,
};
return (
<div
role="alert"
className={`flex items-center gap-4 p-4 rounded-lg border shadow-2xl min-w-72 animate-slideInRight backdrop-blur-md mb-3 ${styles[type]}`}
className={`flex items-center gap-3 p-3.5 rounded-xl border shadow-2xl min-w-72 max-w-md animate-slide-in-right backdrop-blur-2xl mb-3 ring-1 ring-white/5 text-foreground ${styles[type]}`}
>
{icons[type]}
<p className="flex-1 text-sm font-medium">{message}</p>
<div className={`shrink-0 w-8 h-8 rounded-lg flex items-center justify-center ${iconBg[type]}`}>
{icons[type]}
</div>
<p className="flex-1 text-sm font-medium leading-snug">{message}</p>
<button
onClick={() => onClose(id)}
className="text-muted-foreground hover:text-foreground transition-colors"
className="shrink-0 text-muted-foreground hover:text-foreground transition-colors duration-[var(--sumi-duration-fast)] p-1 rounded-lg hover:bg-[var(--sumi-bg-hover)]"
aria-label="Fermer la notification"
>
<X className="w-4 h-4" />
<X className="w-3.5 h-3.5" />
</button>
</div>
);

View file

@ -160,9 +160,10 @@ export function Dropdown({
return (
<div ref={containerRef} className={cn('relative', className)}>
<button
type="button"
ref={triggerRef as unknown as React.RefObject<HTMLButtonElement>}
<div
role="button"
tabIndex={0}
ref={triggerRef as unknown as React.RefObject<HTMLDivElement>}
onClick={() => handleOpenChange(!open)}
aria-haspopup="menu"
aria-expanded={open}
@ -171,11 +172,15 @@ export function Dropdown({
e.preventDefault();
handleOpenChange(true);
}
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handleOpenChange(!open);
}
}}
className="appearance-none bg-transparent border-0 p-0 inline-flex cursor-pointer text-inherit font-inherit focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background rounded-lg"
className="appearance-none bg-transparent border-0 p-0 inline-flex cursor-pointer text-inherit font-inherit rounded-lg"
>
{trigger}
</button>
</div>
{open && (
<div
className="fixed inset-0 z-40"

View file

@ -164,26 +164,26 @@ export function EmptyState({
const content = (
<div className="flex flex-col items-center animate-empty-state-in">
{icon && (
<div className="flex justify-center mb-4">
<div className="flex justify-center mb-5">
<div
className={cn(
'bg-muted rounded-full flex items-center justify-center',
'bg-[var(--sumi-surface-subtle)] rounded-2xl flex items-center justify-center ring-1 ring-[var(--sumi-border-faint)]',
iconBgSizeClasses[size],
)}
>
<div
className={cn('text-muted-foreground', iconSizeClasses[size])}
className={cn('text-muted-foreground/60', iconSizeClasses[size])}
>
{icon}
</div>
</div>
</div>
)}
<h3 className="text-lg font-semibold mb-2 text-foreground font-heading">
<h3 className="text-lg font-semibold mb-2 text-foreground font-heading tracking-tight">
{title}
</h3>
{description && (
<p className="text-sm text-muted-foreground mb-4 max-w-md mx-auto text-center">
<p className="text-sm text-muted-foreground/80 mb-5 max-w-sm mx-auto text-center leading-relaxed">
{description}
</p>
)}
@ -192,6 +192,7 @@ export function EmptyState({
onClick={action.onClick}
variant={action.variant || 'default'}
size={size === 'sm' ? 'sm' : 'default'}
className="shadow-sm"
>
{action.label}
</Button>

View file

@ -40,10 +40,11 @@ export function AuthInput({
id={inputId}
type={inputType}
className={cn(
'w-full px-4 py-2.5 border rounded-xl focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/20 transition-all duration-[var(--sumi-duration-slow)] ease-in-out',
// Focus glow
'focus-visible:shadow-[0_0_0_3px_oklch(var(--primary)/0.15),0_0_12px_oklch(var(--primary)/0.1)]',
'bg-card border-border text-foreground placeholder:text-muted-foreground',
'w-full px-4 py-3 border rounded-xl focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/30 transition-all duration-[var(--sumi-duration-slow)] ease-[var(--sumi-ease-out)]',
// Focus glow — subtle but visible
'focus-visible:shadow-[var(--sumi-shadow-glow)]',
'bg-[var(--sumi-surface-subtle)] border-[var(--sumi-border-default)] text-foreground placeholder:text-muted-foreground/60',
'hover:border-[var(--sumi-border-strong)] hover:bg-[var(--sumi-surface-card)]',
error
? 'border-destructive focus-visible:border-destructive'
: 'focus-visible:border-primary',

View file

@ -28,41 +28,44 @@ export function AuthLayout({
role="main"
aria-label="Page d'authentification"
>
{/* Background gradient */}
{/* Background — immersive gradient mesh */}
<div className="fixed inset-0 bg-background">
<div className="absolute inset-0 bg-gradient-to-br from-primary/5 via-transparent to-primary/5" />
<div className="absolute top-1/4 left-1/4 w-96 h-96 bg-primary/10 rounded-full blur-3xl animate-pulse" />
<div className="absolute inset-0 bg-gradient-to-br from-primary/8 via-transparent to-sumi-vermillion/4" />
<div className="absolute top-0 left-0 w-full h-full bg-[radial-gradient(ellipse_at_20%_30%,var(--sumi-accent-subtle)_0%,transparent_50%)]" />
<div className="absolute top-0 left-0 w-full h-full bg-[radial-gradient(ellipse_at_80%_70%,var(--sumi-gold-subtle)_0%,transparent_40%)]" />
<div className="absolute top-1/4 left-1/4 w-[500px] h-[500px] bg-primary/8 rounded-full blur-[100px] animate-pulse" />
<div
className="absolute bottom-1/4 right-1/4 w-64 h-64 bg-primary/5 rounded-full blur-3xl animate-pulse"
className="absolute bottom-1/4 right-1/4 w-80 h-80 bg-sumi-vermillion/5 rounded-full blur-[80px] animate-pulse"
style={{ animationDelay: '2s' }}
/>
{/* Subtle secondary accent blob */}
<div
className="absolute top-2/3 left-1/2 w-72 h-72 bg-secondary/5 rounded-full blur-3xl animate-pulse"
className="absolute top-2/3 left-1/2 w-72 h-72 bg-sumi-sage/4 rounded-full blur-[80px] animate-pulse"
style={{ animationDelay: '4s' }}
/>
{/* Subtle grid pattern */}
<div className="absolute inset-0 bg-[linear-gradient(var(--sumi-border-faint)_1px,transparent_1px),linear-gradient(90deg,var(--sumi-border-faint)_1px,transparent_1px)] bg-[size:64px_64px] opacity-30" />
</div>
<div className="max-w-md w-full mx-auto space-y-8 relative z-10 animate-auth-enter">
{/* Logo and Title */}
<header className="text-center">
<div className="flex items-center justify-center mb-6">
<div className="flex items-center justify-center mb-8">
<div
className="h-12 w-12 rounded-xl bg-primary flex items-center justify-center shadow-sm"
className="h-14 w-14 rounded-2xl bg-gradient-to-br from-primary to-primary/70 flex items-center justify-center shadow-lg shadow-primary/20 ring-1 ring-white/10"
aria-hidden="true"
>
<span className="text-primary-foreground font-bold text-2xl">V</span>
<span className="text-primary-foreground font-bold text-2xl tracking-tight">V</span>
</div>
<span className="ml-3 font-bold text-3xl text-foreground">Veza</span>
<span className="ml-3 font-heading font-bold text-3xl text-foreground tracking-tight">veza</span>
</div>
<h1
id="auth-form-title"
className="text-3xl font-bold text-foreground mb-2"
className="text-3xl font-heading font-bold text-foreground mb-2 tracking-tight"
>
{title}
</h1>
{subtitle && (
<p className="text-sm text-muted-foreground" role="doc-subtitle">
<p className="text-sm text-muted-foreground leading-relaxed" role="doc-subtitle">
{subtitle}
</p>
)}
@ -72,7 +75,7 @@ export function AuthLayout({
<Card
variant="surface"
padding="lg"
className="w-full bg-card/80 backdrop-blur-md border-border/50 shadow-2xl"
className="w-full bg-card/80 backdrop-blur-xl border-[var(--sumi-glass-border)] shadow-2xl ring-1 ring-white/5"
aria-labelledby="auth-form-title"
>
{children}

View file

@ -41,6 +41,7 @@ export function RegisterPageForm({
onSubmit={onSubmit}
className="space-y-4"
aria-label="Formulaire d'inscription"
data-testid="register-form"
>
{error && (
<div
@ -181,6 +182,7 @@ export function RegisterPageForm({
type="submit"
loading={loading}
className="w-full bg-primary text-primary-foreground hover:opacity-90 shadow-sm"
data-testid="register-submit"
>
{loading ? (
<>

View file

@ -36,7 +36,7 @@ type Pending2FA = { email: string; password: string; remember_me: boolean };
export function LoginPage() {
const navigate = useNavigate();
const queryClient = useQueryClient();
const { isAuthenticated, isLoading, complete2FALogin } = useAuthStore();
const { isAuthenticated, isLoading, complete2FALogin, error: authStoreError } = useAuthStore();
const { mutate: handleLogin, isPending: loading, error } = useLogin();
const [formData, setFormData] = useState<LoginFormData>({
email: '',
@ -50,6 +50,14 @@ export function LoginPage() {
const [pending2FA, setPending2FA] = useState<Pending2FA | null>(null);
const [loading2FA, setLoading2FA] = useState(false);
const [error2FA, setError2FA] = useState<string | null>(null);
const [loginError, setLoginError] = useState<string | null>(() => {
const stored = sessionStorage.getItem('login_error');
if (stored) {
sessionStorage.removeItem('login_error');
return stored;
}
return null;
});
// Charger l'email sauvegardé au montage
useEffect(() => {
@ -103,6 +111,8 @@ export function LoginPage() {
const handleChange = (field: keyof LoginFormData, value: string) => {
setFormData({ ...formData, [field]: value });
if (loginError) setLoginError(null);
if (authStoreError) useAuthStore.getState().clearError?.();
if (errors[field]) {
setErrors({ ...errors, [field]: undefined });
}
@ -116,6 +126,7 @@ export function LoginPage() {
} else {
localStorage.removeItem('rememberedEmail');
}
setLoginError(null);
handleLogin(
{ ...formData, remember_me },
{
@ -133,6 +144,9 @@ export function LoginPage() {
navigate('/dashboard', { replace: true });
},
onError: (err) => {
const msg = getLoginErrorMessage(err);
setLoginError(msg);
sessionStorage.setItem('login_error', msg);
logger.error('Login error', {
error: err instanceof Error ? err.message : String(err),
stack: err instanceof Error ? err.stack : undefined,
@ -218,40 +232,43 @@ export function LoginPage() {
footerLinks={[{ label: "Don't have an account? Sign up", to: '/register' }]}
>
<div className="space-y-6">
{/* OAuth providers */}
<div className="flex flex-col gap-3">
{oauthProviders.map((p) => {
const id = p.id as 'google' | 'github' | 'discord' | 'spotify';
if (!['google', 'github', 'discord', 'spotify'].includes(id)) return null;
const authUrl = p.authorizeUrl ?? p.auth_url;
return (
<OAuthButton
key={p.id}
provider={id}
onClick={() => handleOAuthLogin(p.id, authUrl)}
/>
);
})}
</div>
{/* OAuth providers + divider (hidden when no providers) */}
{oauthProviders.length > 0 && (
<>
<div className="flex flex-col gap-3">
{oauthProviders.map((p) => {
const id = p.id as 'google' | 'github' | 'discord' | 'spotify';
if (!['google', 'github', 'discord', 'spotify'].includes(id)) return null;
const authUrl = p.authorizeUrl ?? p.auth_url;
return (
<OAuthButton
key={p.id}
provider={id}
onClick={() => handleOAuthLogin(p.id, authUrl)}
/>
);
})}
</div>
{/* Divider */}
<div className="relative my-6">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-border" />
</div>
<div className="relative flex justify-center text-xs">
<span className="bg-card px-3 text-muted-foreground">or continue with</span>
</div>
</div>
<div className="relative my-6">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-border" />
</div>
<div className="relative flex justify-center text-xs">
<span className="bg-card px-3 text-muted-foreground">or continue with</span>
</div>
</div>
</>
)}
<form onSubmit={onSubmit} className="space-y-4">
{error && (
<form onSubmit={onSubmit} className="space-y-4" data-testid="login-form">
{(loginError || error) && (
<div
className="bg-destructive/10 border border-destructive/30 text-destructive px-4 py-3 rounded-lg text-sm flex items-center gap-2 animate-in fade-in slide-in-from-top-1"
role="alert"
>
<AlertCircle className="w-4 h-4 flex-shrink-0" />
<p>{getLoginErrorMessage(error)}</p>
<p>{loginError || getLoginErrorMessage(error)}</p>
</div>
)}
@ -299,6 +316,7 @@ export function LoginPage() {
type="submit"
loading={loading}
className="w-full bg-primary text-primary-foreground hover:opacity-90 shadow-sm"
data-testid="login-submit"
>
Sign In
</AuthButton>

View file

@ -2,3 +2,4 @@
* Register page re-export from feature module.
*/
export { RegisterPage } from '../components/register-page';
export { RegisterPage as default } from '../components/register-page';

View file

@ -28,6 +28,9 @@ export interface AuthState {
error: ApiError | null;
}
// Deduplication: prevent concurrent refreshUser calls
let _refreshUserPromise: Promise<void> | null = null;
/** Credentials for completing 2FA login (POST /auth/login/2fa). */
export interface Complete2FACredentials {
email: string;
@ -88,11 +91,16 @@ export const useAuthStore = create<AuthStore>()(
return response;
} catch (error: unknown) {
const apiError = parseApiError(error);
set({
error: parseApiError(error),
error: apiError,
isLoading: false,
isAuthenticated: false,
});
// Persist login error for display after component remount
try {
sessionStorage.setItem('login_error', apiError.message || 'Identifiants incorrects');
} catch { /* ignore */ }
throw error;
}
},
@ -222,7 +230,10 @@ export const useAuthStore = create<AuthStore>()(
},
refreshUser: async () => {
// Action 4.3.1.2: Simplified using React Query - no manual promise deduplication needed
// Deduplicate concurrent calls to prevent multiple /auth/me requests
if (_refreshUserPromise) return _refreshUserPromise;
_refreshUserPromise = (async () => {
try {
const currentState = useAuthStore.getState();
// CRITIQUE FIX #2: Ne pas réinitialiser isAuthenticated si on était déjà authentifié
const hasAuth = currentState.isAuthenticated;
@ -268,14 +279,21 @@ export const useAuthStore = create<AuthStore>()(
} else {
// CRITIQUE FIX #2: Pour les autres erreurs, PRÉSERVER l'état existant
// Cela évite les problèmes de réseau temporaires qui réinitialiseraient l'état
const existingError = useAuthStore.getState().error;
set({
error: apiError,
// Preserve existing login errors (401) over transient errors (429, network)
error: existingError && (existingError.code === 401 || existingError.code === 1001) ? existingError : apiError,
isLoading: false,
// PRÉSERVER l'état d'authentification existant
isAuthenticated: hasAuth ? true : false,
});
}
}
} finally {
_refreshUserPromise = null;
}
})();
return _refreshUserPromise;
},
checkAuthStatus: async () => {

View file

@ -44,8 +44,8 @@ export function StatsSection() {
return (
<section aria-label="Performance statistics" className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
{STATS.map((stat) => (
<Card key={stat.titleKey} variant="glass" className="group hover:border-primary/50 transition-all duration-[var(--sumi-duration-normal)]">
{STATS.map((stat, i) => (
<Card key={stat.titleKey} variant="glass" className="group hover:border-primary/50 hover-lift transition-all duration-[var(--sumi-duration-normal)] animate-content-reveal" style={{ animationDelay: `${i * 80}ms` }}>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground group-hover:text-foreground transition-colors duration-[var(--duration-fast)]">
{t(stat.titleKey)}

View file

@ -39,17 +39,26 @@ function WelcomeBanner({ username }: { username: string }) {
hour < 12 ? 'dashboard.goodMorning' : hour < 18 ? 'dashboard.goodAfternoon' : 'dashboard.goodEvening';
return (
<div className="relative overflow-hidden rounded-xl bg-gradient-to-r from-primary/20 via-primary/10 to-transparent p-6 mb-6">
{/* Decorative elements */}
<div className="absolute top-0 right-0 w-64 h-64 bg-primary/10 rounded-full blur-3xl -translate-y-1/2 translate-x-1/2" />
<div className="relative overflow-hidden rounded-2xl p-8 md:p-10 mb-6 bg-gradient-to-br from-primary/20 via-primary/5 to-transparent border border-primary/10"
style={{
backgroundImage: `
radial-gradient(ellipse 80% 60% at 10% 90%, var(--sumi-accent-muted), transparent),
radial-gradient(ellipse 60% 80% at 80% 20%, var(--sumi-accent-subtle), transparent),
radial-gradient(ellipse 50% 50% at 50% 50%, var(--sumi-accent-subtle), transparent)
`,
}}
>
{/* Decorative mesh orbs */}
<div className="absolute top-0 right-0 w-72 h-72 bg-primary/10 rounded-full blur-3xl -translate-y-1/3 translate-x-1/3 animate-glow-breathe" />
<div className="absolute bottom-0 left-1/4 w-48 h-48 bg-primary/8 rounded-full blur-3xl translate-y-1/2 animate-glow-breathe" style={{ animationDelay: '1.5s' }} />
<div className="relative z-10">
<h1 className="text-heading-1">
<h1 className="text-heading-1 animate-content-reveal">
{t(greetingKey)},{' '}
<span className="text-transparent bg-clip-text bg-gradient-to-r from-primary to-secondary">
<span className="text-transparent bg-clip-text bg-gradient-to-r from-primary via-primary/80 to-info font-bold">
{username}
</span>
</h1>
<p className="text-muted-foreground mt-1">
<p className="text-muted-foreground mt-2 text-base animate-content-reveal" style={{ animationDelay: '120ms' }}>
{t('dashboard.subtitle')}
</p>
</div>
@ -70,16 +79,16 @@ const QUICK_ACTIONS = [
function QuickActions() {
const { t } = useTranslation();
return (
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 mb-6">
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
{QUICK_ACTIONS.map((action, i) => (
<Link
key={action.labelKey}
to={action.path}
className="group flex items-center gap-3 p-4 rounded-xl border border-border hover:border-primary/30 hover:bg-muted/50 transition-all duration-[var(--sumi-duration-normal)] animate-stagger-in"
style={{ animationDelay: `${i * 60}ms` }}
className="group flex items-center gap-4 p-5 rounded-xl border border-border hover:border-primary/40 hover:bg-muted/50 hover:shadow-[var(--sumi-shadow-glow)] hover-lift transition-all duration-[var(--sumi-duration-normal)] animate-content-reveal"
style={{ animationDelay: `${i * 80}ms` }}
>
<div className={cn('p-2.5 rounded-lg', action.color)}>
<action.icon className="h-5 w-5" />
<div className={cn('p-3 rounded-xl transition-transform duration-[var(--sumi-duration-normal)] group-hover:scale-110', action.color)}>
<action.icon className="h-5 w-5 transition-colors duration-[var(--sumi-duration-normal)]" />
</div>
<span className="text-sm font-medium group-hover:text-foreground transition-colors">
{t(action.labelKey)}
@ -149,26 +158,37 @@ function DashboardPage() {
}
return (
<div className="space-y-6 p-6 pb-24">
<div className="space-y-6 p-6 pb-24 animate-fade-in">
{/* Welcome Banner */}
<WelcomeBanner username={user?.first_name || user?.username || 'there'} />
{/* Quick Actions */}
<QuickActions />
<div className="animate-content-reveal" style={{ animationDelay: '100ms' }}>
<QuickActions />
</div>
{/* Stats Cards */}
<StatsSection />
<div className="animate-content-reveal" style={{ animationDelay: '200ms' }}>
<StatsSection />
</div>
<section aria-label="Activity and content" className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
<section
aria-label="Activity and content"
className="grid gap-6 md:grid-cols-2 lg:grid-cols-3"
>
{/* Recent Activity */}
<RecentActivityCard />
<div className="animate-content-reveal" style={{ animationDelay: '300ms' }}>
<RecentActivityCard />
</div>
{/* Recent Tracks */}
<RecentTracksCard items={items} isLoading={isLoading} />
<div className="animate-content-reveal" style={{ animationDelay: '380ms' }}>
<RecentTracksCard items={items} isLoading={isLoading} />
</div>
</section>
{/* Quick Actions */}
<Card variant="glass" className="overflow-hidden">
<Card variant="glass" className="overflow-hidden animate-content-reveal" style={{ animationDelay: '460ms' }}>
<CardHeader>
<SectionHeader title={t('dashboard.quickActions')} />
<CardDescription>
@ -183,12 +203,13 @@ function DashboardPage() {
variant="outline"
onClick={action.action}
className={cn(
"h-24 flex-col gap-3 bg-muted/30 border-border hover:bg-muted/50 transition-all duration-[var(--sumi-duration-normal)] group",
"h-24 flex-col gap-3 bg-muted/30 border-border hover:bg-muted/50 hover:shadow-[var(--sumi-shadow-glow)] hover-lift transition-all duration-[var(--sumi-duration-normal)] group animate-content-reveal",
action.border
)}
style={{ animationDelay: `${500 + i * 80}ms` }}
>
<div className={cn(
"w-10 h-10 rounded-full bg-muted/50 flex items-center justify-center transition-all duration-[var(--sumi-duration-normal)] group-hover:scale-110",
"w-10 h-10 rounded-full bg-muted/50 flex items-center justify-center transition-all duration-[var(--sumi-duration-normal)] group-hover:scale-110 group-hover:rotate-6",
action.color
)}>
<action.icon className="h-5 w-5" />

View file

@ -14,8 +14,28 @@ import { ErrorDisplay } from '@/components/ui/ErrorDisplay';
import { discoverService, type Genre } from '@/services/discoverService';
import { PlaylistCard } from '@/features/playlists/components/PlaylistCard';
import { PlaylistCardSkeleton } from '@/features/playlists/components/PlaylistCardSkeleton';
import { Music2, Loader2, ChevronLeft } from 'lucide-react';
import { Music2, Loader2, ChevronLeft, Compass } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
// Spotify-style genre gradient colors — warm, varied palette
const GENRE_GRADIENTS = [
'from-[#e13300] to-[#e13300]/60',
'from-[#8400e7] to-[#8400e7]/60',
'from-[#1e3264] to-[#1e3264]/60',
'from-[#e8115b] to-[#e8115b]/60',
'from-[#148a08] to-[#148a08]/60',
'from-[#e91429] to-[#e91429]/60',
'from-[#477d95] to-[#477d95]/60',
'from-[#8c67ab] to-[#8c67ab]/60',
'from-[#ba5d07] to-[#ba5d07]/60',
'from-[#1e3264] to-[#608108]/60',
'from-[#dc148c] to-[#dc148c]/60',
'from-[#186962] to-[#186962]/60',
'from-[#7358ff] to-[#7358ff]/60',
'from-[#e61e32] to-[#e61e32]/60',
'from-[#0d73ec] to-[#0d73ec]/60',
];
export function DiscoverPage() {
const [searchParams, setSearchParams] = useSearchParams();
@ -139,42 +159,67 @@ export function DiscoverPage() {
return (
<ContentFadeIn className="min-h-layout-page pb-24">
<div className="space-y-6">
<div className="flex items-center gap-3">
<div className="space-y-8">
{/* Header */}
<div className="flex items-center gap-4">
{browseGenre || browseTag ? (
<Button
variant="ghost"
size="sm"
onClick={goBack}
className="-ml-2"
className="-ml-2 hover:bg-[var(--sumi-bg-hover)]"
>
<ChevronLeft className="w-5 h-5" />
Retour
</Button>
) : null}
<Music2 className="w-8 h-8 text-primary" />
<h1 className="text-2xl font-heading font-bold">
{browseGenre
? `Genre : ${genres?.find((g) => g.slug === browseGenre)?.name ?? browseGenre}`
: browseTag
? `Tag : ${browseTag}`
: 'Découvrir'}
</h1>
<div className="w-12 h-12 rounded-2xl bg-gradient-to-br from-primary/30 to-primary/10 flex items-center justify-center">
<Compass className="w-6 h-6 text-primary" />
</div>
<div>
<h1 className="text-2xl font-heading font-bold tracking-tight">
{browseGenre
? genres?.find((g) => g.slug === browseGenre)?.name ?? browseGenre
: browseTag
? browseTag
: 'Découvrir'}
</h1>
{showGenreList && (
<p className="text-sm text-muted-foreground mt-0.5">Explore par genre, tag ou playlist éditoriale</p>
)}
</div>
</div>
{showGenreList && genres ? (
<section className="space-y-3">
<section className="space-y-4">
<h2 className="text-lg font-heading font-semibold">
Par genre
</h2>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
{genres.map((g) => (
{genres.map((g, i) => (
<button
key={g.slug}
onClick={() => handleGenreClick(g)}
className="flex flex-col items-center justify-center min-h-24 rounded-lg bg-muted/50 hover:bg-muted transition-colors p-4"
className={cn(
'relative overflow-hidden rounded-xl min-h-28 p-4 text-left',
'bg-gradient-to-br',
GENRE_GRADIENTS[i % GENRE_GRADIENTS.length],
'hover:scale-[1.03] hover:shadow-lg active:scale-[0.98]',
'transition-all duration-[var(--sumi-duration-normal)] ease-[var(--sumi-ease-out)]',
'group animate-content-reveal',
)}
style={{ animationDelay: `${i * 40}ms` }}
>
<span className="font-medium text-center">{g.name}</span>
<span className="relative z-10 font-heading font-bold text-white text-base drop-shadow-sm">
{g.name}
</span>
{'count' in g && (g as Genre & { count?: number }).count != null && (
<span className="relative z-10 block mt-1 text-xs text-white/70 font-medium">
{(g as Genre & { count?: number }).count} tracks
</span>
)}
{/* Decorative circle */}
<div className="absolute -bottom-2 -right-4 w-20 h-20 rounded-full bg-white/10 rotate-12 group-hover:scale-110 transition-transform duration-500" />
</button>
))}
</div>
@ -182,7 +227,7 @@ export function DiscoverPage() {
) : null}
{showGenreList ? (
<section className="space-y-3">
<section className="space-y-4">
<h2 className="text-lg font-heading font-semibold">
Playlists éditoriales
</h2>
@ -224,7 +269,7 @@ export function DiscoverPage() {
))}
</div>
) : error ? (
<ErrorDisplay error={error} variant="card" onRetry={() => refetch()} />
<ErrorDisplay error={error} variant="card" onRetry={() => { refetch(); }} />
) : (
<>
<TrackGrid

View file

@ -148,9 +148,17 @@ export function GlobalPlayer() {
/>
<section
className="flex flex-col items-center justify-center gap-0.5 flex-shrink-0 min-w-0"
className="flex items-center justify-center gap-1 sm:gap-1.5 flex-shrink-0 min-w-0"
aria-label="Playback controls"
>
<div
className={cn(
'hidden md:flex items-center gap-1 text-xs font-mono text-muted-foreground whitespace-nowrap shrink-0',
isIdle ? 'opacity-50' : 'opacity-90',
)}
>
<span>{formatTime(player.currentTime)}</span>
</div>
<PlayerControls
compact
isPlaying={player.isPlaying}
@ -170,23 +178,21 @@ export function GlobalPlayer() {
shuffle={player.shuffle}
repeat={player.repeat}
/>
<div className="hidden sm:block">
<div
className={cn(
'hidden md:flex items-center gap-1 text-xs font-mono text-muted-foreground whitespace-nowrap shrink-0',
isIdle ? 'opacity-50' : 'opacity-90',
)}
>
<span>{formatTime(player.duration)}</span>
</div>
<div className="hidden lg:block">
<PlaybackSpeedControl
speed={player.playbackSpeed}
onSpeedChange={player.setPlaybackSpeed}
disabled={isIdle}
/>
</div>
<div
className={cn(
'hidden sm:flex items-center gap-1.5 text-xs font-mono text-muted-foreground whitespace-nowrap shrink-0',
isIdle ? 'opacity-50' : 'opacity-90',
)}
>
<span>{formatTime(player.currentTime)}</span>
<span className="opacity-30">/</span>
<span>{formatTime(player.duration)}</span>
</div>
</section>
<PlayerBarRight

View file

@ -54,6 +54,7 @@ export function PlayerControls({
</Tooltip>
<button
data-testid="prev-button"
onClick={onPrevious}
className={cn(iconBtnClass, size, "text-foreground hover:text-primary hover:bg-white/5")}
>
@ -61,6 +62,7 @@ export function PlayerControls({
</button>
<button
data-testid="play-button"
onClick={onPlayPause}
className={cn(
"flex items-center justify-center rounded-full bg-primary text-black flex-shrink-0 active:scale-95 transition-all shadow-sm",
@ -75,6 +77,7 @@ export function PlayerControls({
</button>
<button
data-testid="next-button"
onClick={onNext}
className={cn(iconBtnClass, size, "text-foreground hover:text-primary hover:bg-white/5")}
>

View file

@ -1,6 +1,7 @@
/**
* PlayerBarGlass Glassmorphism container (KŌDŌ v3)
* backdrop-blur + semantic tokens + gradient overlay (Spotify/Discord standard)
* backdrop-blur + semantic tokens + gradient overlay + noise texture
* Spotify/Discord-grade polish with accent gradient, top-edge highlight, and noise depth
*/
import { cn } from '@/lib/utils';
@ -18,12 +19,13 @@ export function PlayerBarGlass({
}: PlayerBarGlassProps) {
return (
<div
data-testid="player-bar"
className={cn(
'relative w-full rounded-xl overflow-hidden',
'backdrop-blur-[16px]',
'relative w-full rounded-xl overflow-hidden noise',
'backdrop-blur-[20px]',
'bg-[var(--sumi-glass-bg)]',
'border border-[var(--sumi-glass-border)]',
'transition-all duration-[var(--sumi-duration-normal)] ease-[var(--sumi-ease-out)]',
'transition-[box-shadow,border-color,transform] duration-[var(--sumi-duration-normal)] ease-[var(--sumi-ease-out)]',
'shadow-[var(--sumi-shadow-xl)] player-bar-entrance',
isHovered && 'shadow-[var(--sumi-shadow-xl)] border-[var(--sumi-border-accent)]',
!isHovered && 'shadow-[var(--sumi-shadow-lg)]',
@ -31,10 +33,33 @@ export function PlayerBarGlass({
)}
>
{children}
{/* Top-edge highlight line — 1px gradient shimmer */}
<div
className="absolute top-0 left-0 right-0 h-px pointer-events-none z-10"
style={{
background:
'linear-gradient(90deg, transparent 0%, rgba(255,255,255,0.05) 30%, rgba(255,255,255,0.08) 50%, rgba(255,255,255,0.05) 70%, transparent 100%)',
}}
/>
{/* Accent gradient overlay — subtle radial glow from bottom-center */}
<div
className={cn(
'absolute inset-0 pointer-events-none z-0',
'transition-opacity duration-[var(--sumi-duration-slow)] ease-[var(--sumi-ease-out)]',
isHovered ? 'opacity-100' : 'opacity-40',
)}
style={{
background:
'radial-gradient(ellipse 80% 60% at 50% 100%, var(--sumi-accent-subtle) 0%, transparent 70%)',
}}
/>
{/* Subtle accent tint on hover — SUMI */}
<div
className={cn(
'absolute inset-0 pointer-events-none -z-10',
'absolute inset-0 pointer-events-none z-0',
'bg-[var(--sumi-accent-subtle)]',
'opacity-0 transition-opacity duration-[var(--sumi-duration-slow)] ease-[var(--sumi-ease-out)]',
isHovered && 'opacity-100',

View file

@ -1,14 +1,15 @@
/**
* PlayerBarProgress Seek bar with waveform preview
* Lot F: F3 Waveform basique (données mock ou générées client-side)
* Spotify-grade: hover expand, thumb knob, gradient fill, buffered indicator,
* smooth seek interaction with cursor feedback
*/
import { useRef, useMemo } from 'react';
import { useRef, useState, useMemo, useCallback } from 'react';
import { cn } from '@/lib/utils';
const WAVEFORM_BARS = 48;
/** Génère des barres waveform placeholder (0.21.0) */
/** Generates placeholder waveform bars (0.2-1.0) */
function generatePlaceholderWaveform(seed = 0): number[] {
const bars: number[] = [];
for (let i = 0; i < WAVEFORM_BARS; i++) {
@ -21,6 +22,7 @@ function generatePlaceholderWaveform(seed = 0): number[] {
interface PlayerBarProgressProps {
currentTime: number;
duration: number;
buffered?: number;
onSeek: (pct: number) => void;
waveformData?: number[];
className?: string;
@ -29,25 +31,62 @@ interface PlayerBarProgressProps {
export function PlayerBarProgress({
currentTime,
duration,
buffered = 0,
onSeek,
waveformData,
className,
}: PlayerBarProgressProps) {
const barRef = useRef<HTMLDivElement>(null);
const [isHovered, setIsHovered] = useState(false);
const [isDragging, setIsDragging] = useState(false);
const [hoverPct, setHoverPct] = useState(0);
const pct = duration > 0 ? Math.max(0, Math.min(1, currentTime / duration)) : 0;
const bufferedPct = duration > 0 ? Math.max(0, Math.min(1, buffered / duration)) : 0;
const waveform = useMemo(
() => waveformData ?? generatePlaceholderWaveform(),
[waveformData],
);
const getPctFromEvent = useCallback(
(e: React.MouseEvent<HTMLDivElement> | MouseEvent) => {
if (!barRef.current) return 0;
const rect = barRef.current.getBoundingClientRect();
const x = e.clientX - rect.left;
return Math.max(0, Math.min(1, x / rect.width));
},
[],
);
const handleClick = (e: React.MouseEvent<HTMLDivElement>) => {
if (!barRef.current) return;
const rect = barRef.current.getBoundingClientRect();
const x = e.clientX - rect.left;
onSeek(Math.max(0, Math.min(1, x / rect.width)));
onSeek(getPctFromEvent(e));
};
const handleMouseDown = (e: React.MouseEvent<HTMLDivElement>) => {
setIsDragging(true);
onSeek(getPctFromEvent(e));
const handleMouseMove = (ev: MouseEvent) => {
onSeek(getPctFromEvent(ev));
};
const handleMouseUp = () => {
setIsDragging(false);
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
};
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
};
const handleMouseMove = (e: React.MouseEvent<HTMLDivElement>) => {
setHoverPct(getPctFromEvent(e));
};
const active = isHovered || isDragging;
return (
<div
ref={barRef}
@ -58,26 +97,76 @@ export function PlayerBarProgress({
aria-valuenow={currentTime}
tabIndex={0}
className={cn(
'absolute bottom-0 left-0 right-0 h-2.5 z-20 cursor-pointer overflow-hidden rounded-full',
'bg-[var(--sumi-border-default)] hover:bg-[var(--sumi-border-strong)] transition-colors duration-[var(--sumi-duration-fast)]',
'absolute bottom-0 left-0 right-0 z-20 group/progress',
'cursor-pointer select-none',
// Height transitions on hover — h-1 default, h-1.5 on hover
'transition-[height] duration-[var(--sumi-duration-fast)] ease-[var(--sumi-ease-out)]',
active ? 'h-1.5' : 'h-1',
className,
)}
onClick={handleClick}
onMouseDown={handleMouseDown}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
onMouseMove={handleMouseMove}
onKeyDown={(e) => {
if (e.key === 'ArrowRight') onSeek(Math.min(1, pct + 0.02));
if (e.key === 'ArrowLeft') onSeek(Math.max(0, pct - 0.02));
}}
>
{/* Waveform bars (background) */}
<div className="absolute inset-0 flex items-center justify-between gap-px px-0.5 rounded-full">
{waveform.map((h, i) => (
{/* Track background */}
<div className="absolute inset-0 rounded-full bg-[var(--sumi-border-default)] overflow-hidden">
{/* Waveform bars (background) */}
<div className="absolute inset-0 flex items-center justify-between gap-px px-0.5 rounded-full">
{waveform.map((h, i) => (
<div
key={i}
className="flex-1 min-w-0.5 rounded-sm bg-muted-foreground/20"
style={{ height: `${Math.max(20, h * 100)}%` }}
/>
))}
</div>
{/* Buffered portion — lighter fill */}
<div
className="absolute inset-y-0 left-0 rounded-full bg-[var(--sumi-accent-subtle)] transition-[transform] duration-150 ease-out will-change-transform pointer-events-none"
style={{ transform: `scaleX(${bufferedPct})`, transformOrigin: 'left' }}
/>
{/* Progress fill — accent gradient */}
<div
className="absolute inset-y-0 left-0 rounded-full pointer-events-none will-change-transform transition-[transform] duration-75 ease-out"
style={{
transform: `scaleX(${pct})`,
transformOrigin: 'left',
background:
'linear-gradient(90deg, var(--sumi-accent) 0%, var(--sumi-accent-hover) 100%)',
}}
/>
{/* Hover preview line */}
{active && (
<div
key={i}
className="flex-1 min-w-0.5 rounded-sm bg-muted-foreground/30"
style={{ height: `${Math.max(20, h * 100)}%` }}
className="absolute top-0 bottom-0 w-px bg-foreground/30 pointer-events-none transition-opacity duration-100"
style={{ left: `${hoverPct * 100}%` }}
/>
))}
)}
</div>
{/* Progress fill */}
{/* Circular thumb/knob — appears on hover (Spotify-style) */}
<div
className="absolute inset-0 rounded-full bg-[var(--sumi-accent)] transition-[transform] duration-75 ease-out will-change-transform pointer-events-none"
style={{ transform: `scaleX(${pct})`, transformOrigin: 'left' }}
className={cn(
'absolute top-1/2 -translate-y-1/2 -translate-x-1/2',
'w-3 h-3 rounded-full',
'bg-foreground shadow-[0_0_4px_rgba(0,0,0,0.3)]',
'transition-[opacity,transform] duration-[var(--sumi-duration-fast)] ease-[var(--sumi-ease-out)]',
'pointer-events-none',
active
? 'opacity-100 scale-100'
: 'opacity-0 scale-75',
)}
style={{ left: `${pct * 100}%` }}
aria-hidden="true"
/>
</div>
);

View file

@ -55,7 +55,7 @@ export function PlayerBarRight({
<div className="hidden xl:block shrink-0">
<AudioWaveform levels={waveformLevels} playing={isPlaying} />
</div>
<div className="flex items-center gap-0.5 group/volume flex-shrink-0">
<div data-testid="volume-control" className="flex items-center gap-0.5 group/volume flex-shrink-0">
<Button
variant="ghost"
size="icon"
@ -115,6 +115,7 @@ export function PlayerBarRight({
<Button
variant="ghost"
size="icon"
data-testid="queue-button"
className={cn(
btnClass,
showQueue ? 'text-primary bg-primary/10' : 'text-muted-foreground hover:text-foreground',

View file

@ -1,8 +1,10 @@
/**
* PlayerBarTrackInfo Cover + title/artist
* Micro-interactions: hover scale on cover, click to expand
* Vinyl-spin animation on cover when playing, marquee for long titles,
* circular cover with glow ring Spotify-grade polish
*/
import { useRef, useState, useEffect } from 'react';
import { Maximize2 } from 'lucide-react';
import { cn } from '@/lib/utils';
@ -23,53 +25,105 @@ export function PlayerBarTrackInfo({
isPlaying,
onExpand,
}: PlayerBarTrackInfoProps) {
const titleRef = useRef<HTMLHeadingElement>(null);
const [isTitleOverflowing, setIsTitleOverflowing] = useState(false);
useEffect(() => {
const el = titleRef.current;
if (el) {
setIsTitleOverflowing(el.scrollWidth > el.clientWidth);
}
}, [title]);
return (
<section
className="flex items-center gap-1.5 sm:gap-2 min-w-0 flex-1 overflow-hidden"
aria-label="Track info"
>
{/* Cover art — circular vinyl style */}
<div
className={cn(
'relative w-9 h-9 sm:w-10 sm:h-10 md:w-11 md:h-11 rounded-lg overflow-hidden flex-shrink-0',
'relative flex-shrink-0',
'w-9 h-9 sm:w-10 sm:h-10 md:w-11 md:h-11',
'transition-transform duration-300 ease-out',
'hover:scale-105 active:scale-95',
!isIdle && 'cursor-pointer group/art',
)}
onClick={!isIdle ? onExpand : undefined}
>
{cover ? (
<img
src={cover}
alt=""
className={cn(
'w-full h-full object-cover transition-transform duration-700',
isPlaying && 'scale-110',
)}
/>
) : (
<div className="w-full h-full bg-[var(--sumi-border-faint)] flex items-center justify-center">
<Maximize2 className={cn('w-5 h-5 text-muted-foreground', isIdle && 'opacity-20')} />
</div>
)}
{/* Glow ring — visible when playing */}
<div
className={cn(
'absolute -inset-[3px] rounded-full',
'transition-opacity duration-[var(--sumi-duration-slow)] ease-[var(--sumi-ease-out)]',
isPlaying ? 'opacity-100' : 'opacity-0',
)}
style={{
background:
'conic-gradient(from 0deg, var(--sumi-accent-muted), var(--sumi-accent-subtle), var(--sumi-accent-muted))',
filter: 'blur(2px)',
}}
aria-hidden="true"
/>
{/* Cover container — circular with vinyl spin */}
<div
className={cn(
'relative w-full h-full rounded-full overflow-hidden',
'ring-1 ring-[var(--sumi-glass-border)]',
isPlaying && 'ring-[var(--sumi-accent-muted)]',
// Vinyl spin when playing, paused when not
'animate-vinyl-spin',
!isPlaying && 'paused',
)}
style={{
animationPlayState: isPlaying ? 'running' : 'paused',
}}
>
{cover ? (
<img
src={cover}
alt=""
className="w-full h-full object-cover"
/>
) : (
<div className="w-full h-full bg-[var(--sumi-border-faint)] flex items-center justify-center">
<Maximize2
className={cn(
'w-5 h-5 text-muted-foreground',
isIdle && 'opacity-20',
)}
/>
</div>
)}
</div>
{/* Expand overlay on hover */}
{!isIdle && (
<div className="absolute inset-0 bg-black/50 flex items-center justify-center opacity-0 group-hover/art:opacity-100 transition-opacity duration-150">
<div className="absolute inset-0 rounded-full bg-black/50 flex items-center justify-center opacity-0 group-hover/art:opacity-100 transition-opacity duration-150 z-10">
<Maximize2 className="w-5 h-5 text-foreground" />
</div>
)}
</div>
{/* Title & artist */}
<div
className="flex flex-col justify-center min-w-0 overflow-hidden cursor-pointer"
onClick={!isIdle ? onExpand : undefined}
>
<h3
className={cn(
'font-heading font-bold text-xs sm:text-sm text-foreground truncate',
'transition-colors duration-150',
!isIdle && 'hover:text-primary',
)}
>
{title}
</h3>
<div className="overflow-hidden">
<h3
ref={titleRef}
className={cn(
'font-heading font-bold text-xs sm:text-sm text-foreground whitespace-nowrap',
'transition-colors duration-150',
!isIdle && 'hover:text-primary',
isTitleOverflowing ? 'animate-marquee' : 'truncate',
)}
>
{title}
</h3>
</div>
<p
className={cn(
'text-xs text-muted-foreground truncate',

View file

@ -19,6 +19,7 @@ export function usePlayer(
const internalAudioRef = useRef<HTMLAudioElement | null>(null);
const audioRef = audioElementRef?.current || internalAudioRef.current;
const nextWillFadeInRef = useRef(false);
const syncFailedRef = useRef(false);
// Initialiser le service audio avec l'élément audio
useEffect(() => {
@ -45,6 +46,14 @@ export function usePlayer(
useEffect(() => {
if (!audioRef) return;
// Initialize duration from track metadata (reliable) before HLS reports it
if (store.currentTrack?.duration && store.currentTrack.duration > 0) {
store.setDuration(store.currentTrack.duration);
}
// Reset sync failure guard when track changes
syncFailedRef.current = false;
const loadTrack = async () => {
try {
await audioPlayerService.loadTrack(store.currentTrack);
@ -63,6 +72,8 @@ export function usePlayer(
const syncPlayback = async () => {
try {
if (store.isPlaying) {
// Prevent retry loop: if play already failed, don't re-attempt
if (syncFailedRef.current) return;
if (nextWillFadeInRef.current && (store.crossfadeSeconds ?? 0) > 0) {
nextWillFadeInRef.current = false;
const targetVol = store.muted ? 0 : store.volume / 100;
@ -72,17 +83,21 @@ export function usePlayer(
} else {
await audioPlayerService.play();
}
syncFailedRef.current = false;
} else {
syncFailedRef.current = false;
audioPlayerService.pause();
}
} catch (error) {
syncFailedRef.current = true;
logger.error('Failed to sync playback:', { error });
store.pause();
}
};
syncPlayback();
}, [audioRef, store.isPlaying, store]);
// Only react to isPlaying changes, not the entire store
}, [audioRef, store.isPlaying]);
// Configurer les callbacks d'événements audio
useEffect(() => {
@ -122,8 +137,13 @@ export function usePlayer(
});
// Callback pour les changements de durée
audioPlayerService.onDurationChange((duration) => {
store.setDuration(duration);
// Only update if audio element reports a valid duration that's longer than current
// (HLS streams may initially report a short segment duration)
audioPlayerService.onDurationChange((audioDuration) => {
const trackDuration = store.currentTrack?.duration ?? 0;
if (audioDuration > 0 && (trackDuration === 0 || audioDuration >= trackDuration * 0.9)) {
store.setDuration(audioDuration);
}
});
// Callback quand la track se termine
@ -176,7 +196,8 @@ export function usePlayer(
audioPlayerService.onPlay(null);
audioPlayerService.onPause(null);
};
}, [audioRef, store]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [audioRef]);
// Wrapper pour play qui charge la track dans le service
const handlePlay = useCallback(

View file

@ -48,9 +48,10 @@ export function useQueueSync() {
useEffect(() => {
if (!isAuthenticated || activeSessionToken) return;
const unsubscribe = usePlayerStore.subscribe((state) => {
const unsubscribe = usePlayerStore.subscribe(
(state) => state.queue,
(queue) => {
if (isRestoringRef.current) return;
const { queue } = state;
if (queue.length === 0) {
queueApi.clearQueue().catch(() => {});
@ -82,12 +83,23 @@ export function useQueueSync() {
const newTracks = queue.filter(
(t) => !prevQueue.some((p) => p.id === t.id),
);
Promise.all(newTracks.map((t) => queueApi.addToQueue(t.id)))
.then((items) => {
prevQueueRef.current = [...prevQueue, ...newTracks];
// Serialize queue adds to avoid CSRF token contention
(async () => {
const items: { id: string }[] = [];
for (const t of newTracks) {
try {
const item = await queueApi.addToQueue(t.id);
items.push(item);
} catch {
// Stop on first error to avoid spamming
break;
}
}
if (items.length > 0) {
prevQueueRef.current = [...prevQueue, ...newTracks.slice(0, items.length)];
prevItemIdsRef.current = [...prevIds, ...items.map((i) => i.id)];
})
.catch(() => {});
}
})();
return;
}
if (queue.length === prevQueue.length) {
@ -108,7 +120,9 @@ export function useQueueSync() {
}
}
}
});
},
{ equalityFn: (a, b) => a === b },
);
return unsubscribe;
}, [isAuthenticated, activeSessionToken]);

View file

@ -64,7 +64,9 @@ function PlaylistCardComponent({
const cardContent = (
<Card
className={cn(
'group cursor-pointer active:opacity-90 transition-all duration-[var(--sumi-duration-normal)] hover:shadow-lg',
'group cursor-pointer transition-all duration-[var(--sumi-duration-normal)] ease-[var(--sumi-ease-out)]',
'bg-[var(--sumi-surface-subtle)] hover:bg-[var(--sumi-bg-hover)]',
'hover:-translate-y-1 hover:shadow-lg active:translate-y-0 active:scale-[0.98]',
'touch-manipulation focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background',
selectable && selected && 'ring-2 ring-primary border-primary/30',
className,
@ -72,7 +74,7 @@ function PlaylistCardComponent({
>
<CardContent className="p-0">
{/* Cover Image */}
<div className="relative aspect-square bg-gradient-to-br from-primary/30 to-secondary/30 overflow-hidden">
<div className="relative aspect-square bg-gradient-to-br from-primary/20 to-sumi-vermillion/10 overflow-hidden rounded-lg m-3 mb-0 shadow-sm">
{playlist.cover_url ? (
<img
src={playlist.cover_url}
@ -136,23 +138,23 @@ function PlaylistCardComponent({
</div>
</div>
{/* Playlist Info - Mobile optimized */}
<div className="p-4 sm:p-4">
{/* Playlist Info */}
<div className="p-3.5">
<h3
className="font-semibold text-base sm:text-lg truncate mb-1"
className="font-semibold text-sm truncate mb-1 tracking-tight"
id={`playlist-title-${playlist.id}`}
>
{playlist.title}
</h3>
{playlist.description && (
<p
className="text-xs sm:text-sm text-muted-foreground line-clamp-2 mb-2"
className="text-xs text-muted-foreground line-clamp-2 mb-2"
id={`playlist-description-${playlist.id}`}
>
{playlist.description}
</p>
)}
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-1 sm:gap-0 text-xs sm:text-sm text-muted-foreground">
<div className="flex items-center gap-1.5 text-xs text-muted-foreground/70">
<span aria-describedby={`playlist-title-${playlist.id}`}>
{playlist.track_count} track
{playlist.track_count !== 1 ? 's' : ''}
@ -174,7 +176,7 @@ function PlaylistCardComponent({
// Si sélectionnable, ne pas utiliser Link pour éviter la navigation
if (selectable) {
return (
<article aria-label={`Playlist: ${playlist.title}`}>
<div role="article" aria-label={`Playlist: ${playlist.title}`} data-testid="playlist-card">
<button
type="button"
onClick={handleClick}
@ -184,12 +186,12 @@ function PlaylistCardComponent({
>
{cardContent}
</button>
</article>
</div>
);
}
return (
<article aria-label={`Playlist: ${playlist.title}`}>
<div role="article" aria-label={`Playlist: ${playlist.title}`} data-testid="playlist-card">
<Link
to={`/playlists/${playlist.id}`}
onClick={handleClick}
@ -199,7 +201,7 @@ function PlaylistCardComponent({
>
{cardContent}
</Link>
</article>
</div>
);
}

View file

@ -17,10 +17,11 @@ interface SearchPageResultsProps {
export function SearchPageResults({ results, query = '', activeTab = 'all', onTabChange }: SearchPageResultsProps) {
const navigate = useNavigate();
const totalResults =
(results.tracks?.length || 0) +
(results.artists?.length || 0) +
(results.playlists?.length || 0);
const tracks = tracks ?? [];
const artists = artists ?? [];
const playlists = playlists ?? [];
const totalResults = tracks.length + artists.length + playlists.length;
return (
<div aria-live="polite" aria-atomic="true">
@ -39,30 +40,30 @@ export function SearchPageResults({ results, query = '', activeTab = 'all', onTa
value="tracks"
className="rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:text-primary py-3 px-0 text-lg font-heading bg-transparent"
>
Tracks ({results.tracks.length})
Tracks ({tracks.length})
</TabsTrigger>
<TabsTrigger
value="artists"
className="rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:text-primary py-3 px-0 text-lg font-heading bg-transparent"
>
Artists ({results.artists.length})
Artists ({artists.length})
</TabsTrigger>
<TabsTrigger
value="playlists"
className="rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:text-primary py-3 px-0 text-lg font-heading bg-transparent"
>
Playlists ({results.playlists.length})
Playlists ({playlists.length})
</TabsTrigger>
</TabsList>
<TabsContent value="all" className="space-y-12">
{results.tracks.length > 0 && (
{tracks.length > 0 && (
<section>
<h3 className="text-xl font-bold mb-4 flex items-center gap-2">
<Music className="w-5 h-5 text-primary" /> Top Tracks
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{results.tracks.slice(0, 6).map((track) => (
{tracks.slice(0, 6).map((track) => (
<Card
key={track.id}
variant="glass"
@ -95,13 +96,13 @@ export function SearchPageResults({ results, query = '', activeTab = 'all', onTa
</div>
</section>
)}
{results.artists.length > 0 && (
{artists.length > 0 && (
<section>
<h3 className="text-xl font-bold mb-4 flex items-center gap-2">
<User className="w-5 h-5 text-primary" /> Artists
</h3>
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-5 gap-4">
{results.artists.slice(0, 5).map((artist) => (
{artists.slice(0, 5).map((artist) => (
<Card
key={artist.id}
variant="glass"
@ -128,7 +129,7 @@ export function SearchPageResults({ results, query = '', activeTab = 'all', onTa
<TabsContent value="tracks">
<div className="grid grid-cols-1 gap-2">
{results.tracks.map((track) => (
{tracks.map((track) => (
<button
type="button"
key={track.id}
@ -159,7 +160,7 @@ export function SearchPageResults({ results, query = '', activeTab = 'all', onTa
<TabsContent value="artists">
<div className="grid grid-cols-2 md:grid-cols-4 gap-6">
{results.artists.map((artist) => (
{artists.map((artist) => (
<Card
key={artist.id}
variant="glass"
@ -183,7 +184,7 @@ export function SearchPageResults({ results, query = '', activeTab = 'all', onTa
<TabsContent value="playlists">
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{results.playlists.map((playlist) => (
{playlists.map((playlist) => (
<Card
key={playlist.id}
variant="glass"

View file

@ -83,7 +83,7 @@ export function useSearchPage() {
const hasResults =
!!results &&
(results.tracks.length > 0 || results.artists.length > 0 || results.playlists.length > 0);
((results.tracks?.length ?? 0) > 0 || (results.artists?.length ?? 0) > 0 || (results.playlists?.length ?? 0) > 0);
return {
query,

View file

@ -42,13 +42,16 @@ function TrackCardComponent({
const likeCount = track.like_count ?? 0;
return (
<article aria-label={`Track: ${track.title}`}>
<div role="article" aria-label={`Track: ${track.title}`} data-testid="track-card">
<button
type="button"
tabIndex={onClick ? 0 : -1}
className={cn(
'group relative rounded-xl overflow-hidden border-0 shadow-lg shadow-black/5 cursor-pointer appearance-none p-0 text-left w-full',
'bg-card hover:shadow-xl hover:shadow-black/10 transition-[box-shadow,transform,background-color] duration-[var(--sumi-duration-normal)] ease-[var(--sumi-ease-out)] active:scale-[0.98]',
'group relative rounded-xl overflow-hidden border-0 cursor-pointer appearance-none p-0 text-left w-full',
'bg-[var(--sumi-surface-subtle)] hover:bg-[var(--sumi-bg-hover)]',
'shadow-sm hover:shadow-lg hover:shadow-black/10',
'transition-all duration-[var(--sumi-duration-normal)] ease-[var(--sumi-ease-out)]',
'hover:-translate-y-1 active:translate-y-0 active:scale-[0.98]',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background',
className,
)}
@ -60,7 +63,7 @@ function TrackCardComponent({
}}
aria-label={`Piste: ${track.title}`}
>
<div className="relative aspect-square overflow-hidden rounded-t-[var(--radius-xl)]">
<div className="relative aspect-square overflow-hidden rounded-lg m-3 mb-0 shadow-sm">
{track.cover ? (
<img
src={track.cover}
@ -96,53 +99,45 @@ function TrackCardComponent({
</svg>
</div>
{/* Overlay with Play Button */}
{/* Play Button — Spotify-style: floats at bottom-right, slides up on hover */}
{(onPlay || isPlaying) && (
<div
<button
type="button"
aria-label={
isPlaying ? `Pause ${track.title}` : `Lire ${track.title}`
}
onClick={handlePlay}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onPlay?.(track);
}
}}
className={cn(
'absolute inset-0 bg-black/40 flex items-center justify-center transition-opacity duration-[var(--sumi-duration-normal)]',
isPlaying ? 'opacity-100' : 'opacity-0 group-hover:opacity-100',
'absolute bottom-2 right-2 z-10',
'rounded-full bg-primary text-primary-foreground w-12 h-12 flex items-center justify-center',
'shadow-xl shadow-black/30',
'transition-all duration-[var(--sumi-duration-normal)] ease-[var(--sumi-ease-spring)]',
'hover:scale-105 hover:bg-primary/90 active:scale-95',
'focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2',
isPlaying
? 'opacity-100 translate-y-0'
: 'opacity-0 translate-y-2 group-hover:opacity-100 group-hover:translate-y-0',
)}
>
<button
type="button"
aria-label={
isPlaying ? `Pause ${track.title}` : `Lire ${track.title}`
}
onClick={handlePlay}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onPlay?.(track);
}
}}
className={cn(
'rounded-full bg-primary text-primary-foreground p-4 shadow-lg focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2 transition-transform duration-[var(--sumi-duration-normal)] active:scale-95',
isPlaying && 'animate-pulse',
)}
>
{isPlaying ? (
<>
<span className="font-bold" aria-hidden="true">
||
</span>
<span className="sr-only">Pause</span>
</>
) : (
<>
<svg
className="w-6 h-6 fill-current"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path d="M8 5v14l11-7z" />
</svg>
<span className="sr-only">Lire</span>
</>
)}
</button>
</div>
{isPlaying ? (
<svg className="w-5 h-5 fill-current" viewBox="0 0 24 24" aria-hidden="true">
<path d="M6 4h4v16H6V4zm8 0h4v16h-4V4z" />
</svg>
) : (
<svg className="w-5 h-5 fill-current ml-0.5" viewBox="0 0 24 24" aria-hidden="true">
<path d="M8 5v14l11-7z" />
</svg>
)}
</button>
)}
{/* Gradient overlay for depth */}
<div className="absolute inset-x-0 bottom-0 h-1/3 bg-gradient-to-t from-black/20 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-[var(--sumi-duration-normal)]" />
{/* Gradient Overlay for Actions */}
{showActions && (
@ -175,24 +170,29 @@ function TrackCardComponent({
)}
</div>
<div className="p-4 space-y-1">
<h3 className="font-medium leading-none truncate tracking-tight text-foreground">
<div className="p-3.5 space-y-1.5">
<h3 className="font-semibold text-sm leading-tight truncate tracking-tight text-foreground">
{track.title}
</h3>
{track.artist && (
<p className="text-xs text-muted-foreground/90 truncate tracking-tight">
{track.artist}
</p>
)}
{showDuration && (
<p className="text-xs text-muted-foreground/90 pt-1 tracking-tight tabular-nums">
{Math.floor(track.duration / 60)}:
{String(track.duration % 60).padStart(2, '0')}
</p>
)}
<div className="flex items-center gap-1.5">
{track.artist && (
<p className="text-xs text-muted-foreground truncate">
{track.artist}
</p>
)}
{showDuration && track.artist && (
<span className="text-muted-foreground/40 text-xs" aria-hidden>·</span>
)}
{showDuration && (
<p className="text-xs text-muted-foreground/70 tabular-nums shrink-0">
{Math.floor(track.duration / 60)}:
{String(track.duration % 60).padStart(2, '0')}
</p>
)}
</div>
</div>
</button>
</article>
</div>
);
}

View file

@ -752,6 +752,61 @@
.animate-empty-state-in { animation: sumi-scale-in var(--sumi-duration-normal) var(--sumi-ease-out) both; }
.animate-stagger-in { animation: sumi-slide-up var(--sumi-duration-normal) var(--sumi-ease-out) both; }
.animate-glow-pulse { animation: sumi-pulse 2s ease-in-out infinite; }
.animate-content-reveal { animation: content-reveal var(--sumi-duration-slow) var(--sumi-ease-out) both; }
.animate-glow-breathe { animation: glow-breathe 3s ease-in-out infinite; }
.animate-vinyl-spin { animation: vinyl-spin 3s linear infinite; }
.animate-vinyl-spin.paused { animation-play-state: paused; }
.animate-slide-in-right { animation: slide-in-right var(--sumi-duration-normal) var(--sumi-ease-spring); }
.animate-subtle-float { animation: subtle-float 6s ease-in-out infinite; }
/* Player bar entrance */
.player-bar-entrance {
animation: player-bar-entrance var(--sumi-duration-slower) var(--sumi-ease-spring) both;
}
/* Spotify-style progress bar */
.progress-bar-animated {
background: linear-gradient(90deg, var(--sumi-accent) 0%, var(--sumi-accent-hover) 50%, var(--sumi-accent) 100%);
background-size: 200% 100%;
animation: progress-shimmer 2s ease-in-out infinite;
}
/* Discord-style hover card lift */
.hover-lift {
transition: transform var(--sumi-duration-normal) var(--sumi-ease-out),
box-shadow var(--sumi-duration-normal) var(--sumi-ease-out);
}
.hover-lift:hover {
transform: translateY(-2px);
box-shadow: var(--sumi-shadow-lg);
}
.hover-lift:active {
transform: translateY(0);
box-shadow: var(--sumi-shadow-sm);
}
/* Scrollbar hide utility */
.scrollbar-hide {
-ms-overflow-style: none;
scrollbar-width: none;
}
.scrollbar-hide::-webkit-scrollbar {
display: none;
}
/* Line clamp utilities */
.line-clamp-2 {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.line-clamp-3 {
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
/* Ink Wash Texture (hero/feature sections) */
.sumi-wash-texture { position: relative; }
@ -781,6 +836,14 @@
@media (prefers-reduced-motion: reduce) {
.animate-stagger-in { animation: none; }
.animate-glow-pulse { animation: none; }
.animate-content-reveal { animation: none; }
.animate-glow-breathe { animation: none; }
.animate-vinyl-spin { animation: none; }
.animate-slide-in-right { animation: none; }
.animate-subtle-float { animation: none; }
.progress-bar-animated { animation: none; }
.hover-lift { transition: none; }
.hover-lift:hover { transform: none; }
}
}
@ -924,6 +987,48 @@
to { opacity: 1; transform: translateY(0) scale(1); }
}
/* Player bar entrance — Spotify-style float up */
@keyframes player-bar-entrance {
from { opacity: 0; transform: translateY(20px) scale(0.98); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
/* Spotify-style content reveal */
@keyframes content-reveal {
from { opacity: 0; transform: translateY(16px) scale(0.99); filter: blur(4px); }
to { opacity: 1; transform: translateY(0) scale(1); filter: blur(0); }
}
/* Discord-style hover glow */
@keyframes glow-breathe {
0%, 100% { box-shadow: 0 0 0 0 rgba(124,157,214, 0); }
50% { box-shadow: 0 0 20px 2px rgba(124,157,214, 0.15); }
}
/* Vinyl spin for now-playing */
@keyframes vinyl-spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
/* Progress bar shimmer (Spotify-style) */
@keyframes progress-shimmer {
0% { background-position: -200% 0; }
100% { background-position: 200% 0; }
}
/* Notification slide in from right */
@keyframes slide-in-right {
from { opacity: 0; transform: translateX(100%); }
to { opacity: 1; transform: translateX(0); }
}
/* Subtle float for cards */
@keyframes subtle-float {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-4px); }
}
@keyframes bar-fill {
from { width: 0; }
}

View file

@ -500,28 +500,30 @@
},
"nav": {
"sections": {
"workspace": "My workspace",
"vezaNetwork": "Veza Network",
"commerce": "Commerce",
"home": "Home",
"create": "Create",
"connect": "Connect",
"library": "Library",
"more": "More",
"system": "System"
},
"items": {
"dashboard": "Command Center",
"tracks": "Projects",
"gear": "Gear Locker",
"analytics": "Performance",
"social": "Community Feed",
"dashboard": "Dashboard",
"discover": "Discover",
"tracks": "My Tracks",
"gear": "Gear",
"analytics": "Analytics",
"social": "Community",
"feed": "Feed",
"marketplace": "Marketplace",
"live": "Live Sessions",
"chat": "Channels",
"sell": "Seller Dashboard",
"live": "Live",
"chat": "Chat",
"sell": "Sell",
"wishlist": "Wishlist",
"purchases": "Purchases",
"playlists": "Playlists",
"favoris": "Favorites",
"queue": "Play Queue",
"queue": "Queue",
"developer": "Developer API",
"admin": "Admin Panel"
},

View file

@ -498,28 +498,30 @@
},
"nav": {
"sections": {
"workspace": "Mi espacio",
"vezaNetwork": "Red Veza",
"commerce": "Comercio",
"home": "Inicio",
"create": "Crear",
"connect": "Conectar",
"library": "Biblioteca",
"more": "Más",
"system": "Sistema"
},
"items": {
"dashboard": "Centro de control",
"tracks": "Proyectos",
"gear": "Arsenal",
"analytics": "Rendimiento",
"dashboard": "Panel",
"discover": "Descubrir",
"tracks": "Mis Tracks",
"gear": "Equipo",
"analytics": "Estadísticas",
"social": "Comunidad",
"feed": "Feed",
"marketplace": "Marketplace",
"live": "Sesiones en vivo",
"chat": "Canales",
"sell": "Panel de vendedor",
"wishlist": "Lista de deseos",
"live": "En vivo",
"chat": "Mensajes",
"sell": "Vender",
"wishlist": "Deseos",
"purchases": "Compras",
"playlists": "Playlists",
"favoris": "Favoritos",
"queue": "Cola de reproducción",
"queue": "Cola",
"developer": "API de desarrollador",
"admin": "Admin"
},

View file

@ -500,24 +500,26 @@
},
"nav": {
"sections": {
"workspace": "Mon espace",
"vezaNetwork": "Réseau Veza",
"commerce": "Commerce",
"home": "Accueil",
"create": "Créer",
"connect": "Connecter",
"library": "Bibliothèque",
"more": "Plus",
"system": "Système"
},
"items": {
"dashboard": "Centre de contrôle",
"tracks": "Projets",
"gear": "Arsenal",
"analytics": "Performances",
"dashboard": "Tableau de bord",
"discover": "Découvrir",
"tracks": "Mes Tracks",
"gear": "Équipement",
"analytics": "Statistiques",
"social": "Communauté",
"feed": "Fil",
"marketplace": "Marketplace",
"live": "Sessions Live",
"chat": "Canaux",
"sell": "Tableau vendeur",
"wishlist": "Liste de souhaits",
"live": "Live",
"chat": "Messages",
"sell": "Vendre",
"wishlist": "Souhaits",
"purchases": "Achats",
"playlists": "Playlists",
"favoris": "Favoris",

View file

@ -33,13 +33,21 @@ const processQueue = (error: Error | null) => {
const redirectToLogin = (message: string) => {
if (typeof window !== 'undefined') {
// Clear persisted auth state synchronously to prevent rehydration loop
try {
localStorage.removeItem('auth-storage');
} catch { /* ignore */ }
import('@/features/auth/store/authStore')
.then(({ useAuthStore }) => useAuthStore.getState().logoutLocal())
.catch((err: unknown) =>
logger.error('[API] Failed to import auth store for logout', { error: err }),
);
sessionStorage.setItem('auth_error', message);
window.location.href = '/login';
.then(({ useAuthStore }) => {
useAuthStore.getState().logoutLocal();
sessionStorage.setItem('auth_error', message);
window.location.href = '/login';
})
.catch(() => {
// Fallback: redirect even if import fails
sessionStorage.setItem('auth_error', message);
window.location.href = '/login';
});
}
};
@ -56,6 +64,8 @@ export function handleAuthError(
const isRefreshEndpoint = originalRequest?.url?.includes('/auth/refresh');
const isLogoutEndpoint = originalRequest?.url?.includes('/auth/logout');
const isAuthMeEndpoint = originalRequest?.url?.includes('/auth/me');
const isLoginEndpoint = originalRequest?.url?.includes('/auth/login');
const isRegisterEndpoint = originalRequest?.url?.includes('/auth/register');
// 401/400 on /auth/refresh
if (
@ -85,13 +95,16 @@ export function handleAuthError(
}
// 401 on other endpoints - refresh token
// Skip login/register endpoints: their 401 should be shown to the user as form errors
if (
error.response?.status === 401 &&
originalRequest &&
!originalRequest._retry &&
!isRefreshEndpoint &&
!isLogoutEndpoint &&
!isAuthMeEndpoint
!isAuthMeEndpoint &&
!isLoginEndpoint &&
!isRegisterEndpoint
) {
if (isRefreshing) {
return new Promise((resolve, reject) => {
@ -187,16 +200,7 @@ export function handleAuthRedirectOn401(
if (errorCategory === 'authentication') {
TokenStorage.clearTokens();
csrfService.clearToken();
import('@/features/auth/store/authStore')
.then(({ useAuthStore }) => useAuthStore.getState().logoutLocal())
.catch((err: unknown) =>
logger.error('[API] Failed to import auth store for logout', { error: err }),
);
sessionStorage.setItem(
'auth_error',
'Votre session a expiré. Veuillez vous reconnecter.',
);
window.location.href = '/login';
redirectToLogin('Votre session a expiré. Veuillez vous reconnecter.');
}
}
}

View file

@ -196,47 +196,7 @@ export function createErrorResponseHandler(apiClient: AxiosInstance) {
const authResult = handleAuthError(error, originalRequest, apiClient, requestId);
if (authResult !== null) return authResult;
// Second CSRF check (duplicate 403 handling)
const errorDataObj = error.response?.data as
| { error?: { message?: string }; message?: string }
| undefined;
const isCSRFError2 =
error.response?.status === 403 &&
originalRequest &&
!originalRequest._csrfRetry &&
error.response?.data &&
typeof error.response.data === 'object' &&
(errorDataObj?.error?.message?.toLowerCase().includes('csrf') ||
errorDataObj?.message?.toLowerCase().includes('csrf'));
if (isCSRFError2) {
const method = originalRequest.method?.toUpperCase();
const isStateChanging = ['POST', 'PUT', 'DELETE', 'PATCH'].includes(
method || '',
);
if (isStateChanging) {
originalRequest._csrfRetry = true;
try {
const newCsrfToken = await csrfService.refreshToken();
if (originalRequest.headers && newCsrfToken) {
originalRequest.headers['X-CSRF-Token'] = newCsrfToken;
}
return apiClient.request(originalRequest);
} catch (csrfError) {
const errMsg =
csrfError instanceof Error
? csrfError.message
: String(csrfError);
if (!errMsg.includes('HTML page instead of JSON')) {
logger.error(
'[API] Failed to refresh CSRF token after CSRF error',
{ message: errMsg },
);
}
return Promise.reject(parseApiError(error));
}
}
}
// (Duplicate CSRF handler removed — already handled above)
// --- Rate limit (429) ---
const status = error.response?.status;
@ -258,7 +218,8 @@ export function createErrorResponseHandler(apiClient: AxiosInstance) {
if (apiError.message) {
toast.error(apiError.message, {
duration: retryAfterSeconds * 1000,
duration: 6000,
id: 'rate-limit-toast',
});
}
return Promise.reject(apiError);
@ -349,16 +310,21 @@ export function createErrorResponseHandler(apiClient: AxiosInstance) {
const apiError = parseApiError(error);
// Auth redirect on 401 (fallback)
// Skip login/register: their 401 should be shown as form errors, not cause a redirect
const isRefreshEndpoint = originalRequest?.url?.includes('/auth/refresh');
const isLogoutEndpoint = originalRequest?.url?.includes('/auth/logout');
const isAuthMeEndpoint = originalRequest?.url?.includes('/auth/me');
handleAuthRedirectOn401(
apiError,
status,
isRefreshEndpoint,
isLogoutEndpoint,
isAuthMeEndpoint,
);
const isLoginEndpoint = originalRequest?.url?.includes('/auth/login');
const isRegisterEndpoint = originalRequest?.url?.includes('/auth/register');
if (!isLoginEndpoint && !isRegisterEndpoint) {
handleAuthRedirectOn401(
apiError,
status,
isRefreshEndpoint,
isLogoutEndpoint,
isAuthMeEndpoint,
);
}
// --- Error toast ---
const isWrongServerError =
@ -382,7 +348,13 @@ export function createErrorResponseHandler(apiClient: AxiosInstance) {
);
recordNetworkError(apiError);
}
const toastId = isNetworkError ? 'network-error-toast' : undefined;
const toastId = isNetworkError
? 'network-error-toast'
: status && status >= 500
? 'server-error-toast'
: status === 403
? 'forbidden-error-toast'
: undefined;
if (shouldShowToast && typeof window !== 'undefined') {
const url = originalRequest?.url || '';

View file

@ -72,9 +72,10 @@ export default defineConfig(({ mode }) => {
secure: false,
},
'/ws': {
target: `http://${domain}:${chatPort}`,
target: `http://${domain}:${backendPort}`,
changeOrigin: true,
ws: true,
rewrite: (path: string) => path.replace(/^\/ws/, '/api/v1/ws'),
},
'/stream': {
target: `http://${domain}:${streamPort}`,