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:
parent
f047276362
commit
4b57b46bac
34 changed files with 946 additions and 449 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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 ? (
|
||||
<>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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.2–1.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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 || '';
|
||||
|
|
|
|||
|
|
@ -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}`,
|
||||
|
|
|
|||
Loading…
Reference in a new issue