feat: frontend pages and feature modules polish

Update dashboard (stats, recent tracks/activity), discover, distribution,
education, feed, subscription, support, search, settings, live, cloud,
analytics, auth, chat, social, tracks, playlists, presence, upload,
and library manager. Consistent UI patterns and error handling.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
senke 2026-03-23 15:46:21 +01:00
parent 3b065c8f8a
commit f1457e845b
36 changed files with 1191 additions and 1213 deletions

View file

@ -21,11 +21,11 @@ export function AnalyticsViewHeader({
return (
<div className="flex flex-col lg:flex-row justify-between items-start lg:items-end gap-6 border-b border-white/5 pb-8">
<div>
<h1 className="text-4xl font-heading font-bold text-foreground mb-2 flex items-center gap-3">
<Activity className="text-primary w-8 h-8" /> NEURAL ANALYTICS
<h1 className="text-4xl font-heading text-foreground mb-2 flex items-center gap-3" style={{ fontWeight: 200 }}>
<Activity className="text-primary w-8 h-8" /> Analytics
</h1>
<p className="text-muted-foreground font-mono text-xs tracking-wide">
DEEP PACKET INSPECTION AUDIENCE METRICS
<p className="text-muted-foreground/40 text-xs tracking-[0.15em] uppercase font-heading" style={{ fontWeight: 300 }}>
Creator Insights
</p>
</div>

View file

@ -28,54 +28,64 @@ export function AuthLayout({
role="main"
aria-label="Page d'authentification"
>
{/* Background — immersive gradient mesh */}
<div className="fixed inset-0 bg-background">
<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" />
{/* Background — Sumi-e ink wash atmosphere */}
<div className="fixed inset-0 bg-[var(--sumi-bg-void)]">
{/* Ink wash layers */}
<div
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' }}
className="absolute inset-0"
style={{
background: `
radial-gradient(ellipse at 20% 30%, rgba(184,58,30, 0.04) 0%, transparent 50%),
radial-gradient(ellipse at 80% 70%, rgba(184,134,11, 0.025) 0%, transparent 40%),
radial-gradient(ellipse at 50% 50%, rgba(232,224,208, 0.01) 0%, transparent 60%)
`,
}}
/>
<div
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" />
{/* Ink drip streaks */}
<div className="absolute top-0 left-[15%] w-px h-[40%] bg-gradient-to-b from-[var(--sumi-border-strong)] to-transparent opacity-30" />
<div className="absolute top-0 left-[75%] w-px h-[25%] bg-gradient-to-b from-[var(--sumi-border-default)] to-transparent opacity-20" />
<div className="absolute top-0 left-[45%] w-px h-[55%] bg-gradient-to-b from-[var(--sumi-border-faint)] to-transparent opacity-15" />
{/* Ghost kanji — scattered */}
<span className="ghost-kanji left-[8%] top-[15%] text-[180px] opacity-[0.02]"></span>
<span className="ghost-kanji right-[12%] bottom-[20%] text-[120px] opacity-[0.015]"></span>
</div>
<div className="max-w-md w-full mx-auto space-y-8 relative z-10 animate-auth-enter">
{/* Logo and Title */}
<div className="max-w-md w-full mx-auto space-y-10 relative z-10 animate-ink-reveal">
{/* Logo — Hanko seal */}
<header className="text-center">
<div className="flex items-center justify-center mb-8">
<div className="flex flex-col items-center mb-10">
<div
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"
className="h-14 w-14 rounded-sm bg-[var(--sumi-accent)] flex items-center justify-center hanko-seal mb-5"
aria-hidden="true"
>
<span className="text-primary-foreground font-bold text-2xl tracking-tight">V</span>
<span className="text-[var(--sumi-bg-base)] font-heading text-2xl relative z-10 font-semibold">V</span>
</div>
<span className="ml-3 font-heading font-bold text-3xl text-foreground tracking-tight">veza</span>
<span className="font-heading text-2xl text-foreground tracking-[0.2em]" style={{ fontWeight: 200 }}>VEZA</span>
<span className="text-[10px] text-muted-foreground/30 tracking-[0.3em] mt-1 font-heading" style={{ fontWeight: 300 }}> STREAMING</span>
{/* Gold accent line */}
<div className="w-16 h-px bg-gradient-to-r from-transparent via-[var(--sumi-kin)]/30 to-transparent mt-4" />
</div>
<h1
id="auth-form-title"
className="text-3xl font-heading font-bold text-foreground mb-2 tracking-tight"
className="font-heading text-2xl text-foreground mb-2"
style={{ fontWeight: 300 }}
>
{title}
</h1>
{subtitle && (
<p className="text-sm text-muted-foreground leading-relaxed" role="doc-subtitle">
<p className="text-sm text-muted-foreground/50 leading-relaxed font-heading" style={{ fontWeight: 300 }} role="doc-subtitle">
{subtitle}
</p>
)}
</header>
{/* Content Card — glass effect */}
{/* Content Card — ink card */}
<Card
variant="surface"
padding="lg"
className="w-full bg-card/80 backdrop-blur-xl border-[var(--sumi-glass-border)] shadow-2xl ring-1 ring-white/5"
className="w-full ink-card bg-[var(--sumi-surface-card)]/80 backdrop-blur-xl border-[var(--sumi-border-faint)] shadow-2xl"
aria-labelledby="auth-form-title"
>
{children}
@ -84,14 +94,15 @@ export function AuthLayout({
{/* Footer Links */}
{footerLinks && footerLinks.length > 0 && (
<nav
className="text-center space-x-4"
className="text-center space-x-6"
aria-label="Navigation d'authentification"
>
{footerLinks.map((link) => (
<Link
key={link.to}
to={link.to}
className="text-sm text-muted-foreground hover:text-foreground transition-colors duration-[var(--duration-fast)] focus:outline-none focus:ring-2 focus:ring-primary/30 focus:ring-offset-2 focus:ring-offset-background rounded"
className="text-sm text-muted-foreground/40 hover:text-foreground transition-colors duration-300 focus:outline-none focus:ring-2 focus:ring-primary/30 focus:ring-offset-2 focus:ring-offset-background rounded font-heading"
style={{ fontWeight: 300 }}
>
{link.label}
</Link>

View file

@ -252,10 +252,10 @@ export function LoginPage() {
<div className="relative my-6">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-border" />
<div className="w-full section-divider" />
</div>
<div className="relative flex justify-center text-xs">
<span className="bg-card px-3 text-muted-foreground">or continue with</span>
<span className="bg-card/80 backdrop-blur-sm px-4 text-muted-foreground/70">or continue with</span>
</div>
</div>
</>
@ -315,7 +315,7 @@ export function LoginPage() {
<AuthButton
type="submit"
loading={loading}
className="w-full bg-primary text-primary-foreground hover:opacity-90 shadow-sm"
className="w-full bg-primary text-primary-foreground hover:brightness-110 shadow-md shadow-primary/20 transition-all duration-[var(--sumi-duration-normal)]"
data-testid="login-submit"
>
Sign In

View file

@ -43,10 +43,13 @@ export const ChatRoom: React.FC<ChatRoomProps> = ({ conversationId }) => {
const highlightTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
let cancelled = false;
adminService.getClientFeatureFlags().then((flags) => {
if (cancelled) return;
const webrtc = flags.find((f) => f.name === 'WEBRTC_CALLS');
setWebrtcEnabled(webrtc?.enabled ?? true);
}).catch(() => setWebrtcEnabled(true));
}).catch(() => { if (!cancelled) setWebrtcEnabled(true); });
return () => { cancelled = true; };
}, []);
// Cleanup highlight timeout on unmount

View file

@ -56,7 +56,10 @@ export function CloudView() {
};
const handleCreateFolder = () => {
toast.info('New folder dialog coming soon');
const name = window.prompt('Folder name:');
if (name?.trim()) {
toast(`Folder "${name.trim()}" — feature under development`);
}
};
if (isLoading) {

View file

@ -1,64 +1,49 @@
import { Card, CardContent, CardDescription, CardHeader } from '@/components/ui/card';
import { useTranslation } from '@/hooks/useTranslation';
interface SectionHeaderProps {
title: string;
viewAllPath?: string;
}
function SectionHeader({ title, viewAllPath }: SectionHeaderProps) {
const { t } = useTranslation();
return (
<div className="flex items-center justify-between mb-4">
<h2 className="text-heading-3">{title}</h2>
{viewAllPath && (
<a
href={viewAllPath}
className="text-caption hover:text-foreground transition-colors"
>
{t('dashboard.viewAll')}
</a>
)}
</div>
);
}
import { Link } from 'react-router-dom';
export function RecentActivityCard() {
const { t } = useTranslation();
const activities = [
{ dotColor: 'bg-[var(--sumi-accent)]', textKey: 'dashboard.activity.newTrackAdded', timeKey: 'dashboard.activity.recently' },
{ dotColor: 'bg-[var(--sumi-sage)]', textKey: 'dashboard.activity.messageFrom', timeKey: 'dashboard.activity.recently', params: { user: 'alice' } },
{ dotColor: 'bg-[var(--sumi-vermillion)]', textKey: 'dashboard.activity.newFavoriteAdded', timeKey: 'dashboard.activity.recently' },
];
return (
<Card className="md:col-span-2" variant="glass">
<CardHeader>
<SectionHeader title={t('dashboard.recentActivity')} viewAllPath="/library" />
<CardDescription>
{t('dashboard.recentActivityDescription')}
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
<div className="flex items-center space-x-4 p-3 rounded-lg hover:bg-muted/50 transition-colors duration-[var(--duration-fast)] border border-transparent hover:border-border">
<div className="w-2 h-2 bg-primary rounded-full shadow-status-dot-cyan animate-pulse" />
<div className="flex-1 space-y-1">
<p className="text-sm font-medium text-foreground">{t('dashboard.activity.newTrackAdded')}</p>
<p className="text-xs text-muted-foreground">2 hours ago</p>
<div className="ink-card p-5 md:col-span-2 relative">
<span className="ghost-kanji right-3 -top-2 text-[70px]"></span>
<div className="flex items-center justify-between mb-5">
<h2 className="font-heading text-lg tracking-tight" style={{ fontWeight: 300 }}>{t('dashboard.recentActivity')}</h2>
<Link
to="/library"
className="text-[10px] text-muted-foreground/40 hover:text-foreground transition-colors tracking-[0.15em] uppercase font-heading"
style={{ fontWeight: 300 }}
>
{t('dashboard.viewAll')}
</Link>
</div>
<p className="text-sm text-muted-foreground/40 mb-4 font-heading" style={{ fontWeight: 300 }}>
{t('dashboard.recentActivityDescription')}
</p>
<div className="space-y-0">
{activities.map((activity, i) => (
<div key={i} className="flex items-center gap-4 py-3 border-b border-[var(--sumi-border-faint)] last:border-b-0">
<div className={`w-1.5 h-1.5 rounded-full ${activity.dotColor} shrink-0`} />
<div className="flex-1 min-w-0">
<p className="text-sm text-foreground/80 truncate font-heading" style={{ fontWeight: 300 }}>
{activity.params ? t(activity.textKey, activity.params) : t(activity.textKey)}
</p>
</div>
<span className="text-[10px] text-muted-foreground/30 shrink-0 font-heading" style={{ fontWeight: 300 }}>
{t(activity.timeKey)}
</span>
</div>
<div className="flex items-center space-x-4 p-3 rounded-lg hover:bg-muted/50 transition-colors duration-[var(--duration-fast)] border border-transparent hover:border-border">
<div className="w-2 h-2 bg-success rounded-full shadow-status-dot-lime" />
<div className="flex-1 space-y-1">
<p className="text-sm font-medium text-foreground">{t('dashboard.activity.messageFrom', { user: 'alice' })}</p>
<p className="text-xs text-muted-foreground">4 hours ago</p>
</div>
</div>
<div className="flex items-center space-x-4 p-3 rounded-lg hover:bg-muted/50 transition-colors duration-[var(--duration-fast)] border border-transparent hover:border-border">
<div className="w-2 h-2 bg-destructive rounded-full shadow-status-dot-magenta" />
<div className="flex-1 space-y-1">
<p className="text-sm font-medium text-foreground">{t('dashboard.activity.newFavoriteAdded')}</p>
<p className="text-xs text-muted-foreground">6 hours ago</p>
</div>
</div>
</div>
</CardContent>
</Card>
))}
</div>
</div>
);
}

View file

@ -1,5 +1,4 @@
import { Music } from 'lucide-react';
import { Card, CardContent, CardDescription, CardHeader } from '@/components/ui/card';
import { ContentTransition } from '@/components/ui/content-transition';
import { useTranslation } from '@/hooks/useTranslation';
import { Link } from 'react-router-dom';
@ -10,28 +9,6 @@ interface LibraryItem {
description?: string;
}
interface SectionHeaderProps {
title: string;
viewAllPath?: string;
}
function SectionHeader({ title, viewAllPath }: SectionHeaderProps) {
const { t } = useTranslation();
return (
<div className="flex items-center justify-between mb-4">
<h2 className="text-heading-3">{title}</h2>
{viewAllPath && (
<Link
to={viewAllPath}
className="text-caption hover:text-foreground transition-colors"
>
{t('dashboard.viewAll')}
</Link>
)}
</div>
);
}
interface RecentTracksCardProps {
items: LibraryItem[];
isLoading: boolean;
@ -41,54 +18,68 @@ export function RecentTracksCard({ items, isLoading }: RecentTracksCardProps) {
const { t } = useTranslation();
return (
<Card variant="glass">
<CardHeader>
<SectionHeader title={t('dashboard.recentTracks')} viewAllPath="/library" />
<CardDescription>
{t('dashboard.recentTracksDescription')}
</CardDescription>
</CardHeader>
<CardContent>
<ContentTransition
isLoading={isLoading}
skeleton={
<div className="space-y-4">
{[...Array(3)].map((_, i) => (
<div key={i} className="flex items-center space-x-4 animate-pulse">
<div className="w-10 h-10 bg-muted rounded" />
<div className="flex-1 space-y-2">
<div className="h-3 bg-muted rounded w-3/4" />
<div className="h-2 bg-muted rounded w-1/2" />
</div>
</div>
))}
</div>
}
<div className="ink-card p-5 relative">
<span className="ghost-kanji right-3 -top-2 text-[70px]"></span>
<div className="flex items-center justify-between mb-5">
<h2 className="font-heading text-lg tracking-tight" style={{ fontWeight: 300 }}>{t('dashboard.recentTracks')}</h2>
<Link
to="/library"
className="text-[10px] text-muted-foreground/40 hover:text-foreground transition-colors tracking-[0.15em] uppercase font-heading"
style={{ fontWeight: 300 }}
>
{t('dashboard.viewAll')}
</Link>
</div>
<p className="text-sm text-muted-foreground/40 mb-4 font-heading" style={{ fontWeight: 300 }}>
{t('dashboard.recentTracksDescription')}
</p>
<ContentTransition
isLoading={isLoading}
skeleton={
<div className="space-y-4">
{items.slice(0, 3).map((item) => (
<div key={item.id} className="flex items-center space-x-4 p-2 rounded-lg hover:bg-white/5 transition-colors duration-[var(--duration-fast)] cursor-pointer group border border-transparent hover:border-white/5">
<div className="w-10 h-10 bg-muted/50 rounded flex items-center justify-center border border-border group-hover:border-primary/50 transition-colors shadow-lg">
<Music className="h-4 w-4 text-muted-foreground group-hover:text-primary transition-colors" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate text-foreground group-hover:text-primary transition-colors">
{item.title}
</p>
<p className="text-xs text-muted-foreground truncate group-hover:text-foreground/80">
{item.description || 'No description'}
</p>
{[...Array(3)].map((_, i) => (
<div key={i} className="flex items-center gap-4 animate-pulse">
<div className="w-10 h-10 bg-muted/20 rounded-sm" />
<div className="flex-1 space-y-2">
<div className="h-3 bg-muted/20 rounded-sm w-3/4" />
<div className="h-2 bg-muted/20 rounded-sm w-1/2" />
</div>
</div>
))}
{items.length === 0 && (
<p className="text-sm text-muted-foreground text-center py-8">
{t('dashboard.noTracksInLibrary')}
</p>
)}
</div>
</ContentTransition>
</CardContent>
</Card>
}
>
<div className="space-y-0">
{items.slice(0, 3).map((item, i) => (
<Link
key={item.id}
to={`/tracks/${item.id}`}
className="flex items-center gap-4 py-3 border-b border-[var(--sumi-border-faint)] last:border-b-0 group transition-all duration-300"
>
<span className="text-[10px] text-muted-foreground/30 w-4 text-right font-mono shrink-0">{i + 1}</span>
<div className="w-9 h-9 bg-[var(--sumi-surface-inset)] rounded-sm flex items-center justify-center shrink-0 group-hover:bg-[var(--sumi-accent-subtle)] transition-colors duration-300">
<Music className="h-3.5 w-3.5 text-muted-foreground/40 group-hover:text-[var(--sumi-accent)] transition-colors duration-300" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm truncate text-foreground/80 group-hover:text-foreground transition-colors duration-300 font-heading" style={{ fontWeight: 400 }}>
{item.title}
</p>
<p className="text-[11px] text-muted-foreground/30 truncate font-heading" style={{ fontWeight: 300 }}>
{item.description || '—'}
</p>
</div>
</Link>
))}
{items.length === 0 && (
<p className="text-sm text-muted-foreground/40 text-center py-10 font-heading" style={{ fontWeight: 300 }}>
{t('dashboard.noTracksInLibrary')}
</p>
)}
</div>
</ContentTransition>
</div>
);
}

View file

@ -1,64 +1,79 @@
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { AnimatedNumber } from '@/components/ui/AnimatedNumber';
import { cn } from '@/lib/utils';
import { useTranslation } from '@/hooks/useTranslation';
import { Music, MessageSquare, Heart, Users } from 'lucide-react';
import {
useLibraryItems,
useLibraryStatus,
} from '@/utils/storeSelectors';
const STATS = [
const STAT_META = [
{
titleKey: 'dashboard.stats.tracksListened',
value: 1234,
change: '+12%',
titleKey: 'dashboard.stats.tracksInLibrary',
icon: Music,
color: 'text-primary',
shadow: 'drop-shadow-stat-icon',
kanji: '曲',
iconColor: 'text-primary',
},
{
titleKey: 'dashboard.stats.playlists',
icon: Heart,
kanji: '集',
iconColor: 'text-destructive',
},
{
titleKey: 'dashboard.stats.messagesSent',
value: 567,
change: '+8%',
icon: MessageSquare,
color: 'text-success',
shadow: 'drop-shadow-stat-icon',
},
{
titleKey: 'dashboard.stats.favorites',
value: 89,
change: '+23%',
icon: Heart,
color: 'text-destructive',
shadow: 'drop-shadow-stat-icon',
kanji: '話',
iconColor: 'text-success',
},
{
titleKey: 'dashboard.stats.activeFriends',
value: 45,
change: '+5%',
icon: Users,
color: 'text-destructive',
shadow: 'drop-shadow-stat-icon',
kanji: '友',
iconColor: 'text-warning',
},
] as const;
export function StatsSection() {
const { t } = useTranslation();
const items = useLibraryItems();
const { isLoading } = useLibraryStatus();
const values: number[] = [
items.length,
0,
0,
0,
];
return (
<section aria-label="Performance statistics" className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
{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)]">
{STAT_META.map((stat, i) => (
<div
key={stat.titleKey}
className="group relative ink-card p-5 transition-all duration-300 animate-ink-reveal hover:border-l-2 hover:border-l-[var(--sumi-accent)]"
style={{ animationDelay: `${i * 100}ms` }}
>
{/* Ghost kanji decorator */}
<span className="ghost-kanji right-3 -top-1 text-[60px]">{stat.kanji}</span>
<div className="flex items-center gap-3 mb-3">
<stat.icon className={cn("h-4 w-4", stat.iconColor)} />
<span className="text-[10px] text-muted-foreground/40 uppercase tracking-[0.15em] font-heading" style={{ fontWeight: 300 }}>
{t(stat.titleKey)}
</CardTitle>
<stat.icon className={cn("h-4 w-4 transition-all duration-[var(--sumi-duration-normal)]", stat.color, stat.shadow, "group-hover:scale-110")} />
</CardHeader>
<CardContent>
<AnimatedNumber value={stat.value} className="text-2xl font-bold text-foreground tracking-tight" />
<p className="text-xs text-muted-foreground mt-1">
<span className="text-success font-medium">{stat.change}</span> {t('dashboard.fromLastMonth')}
</span>
</div>
{isLoading && i === 0 ? (
<div className="h-10 w-16 bg-muted/20 rounded-sm animate-pulse" />
) : (
<p className="font-heading text-3xl text-foreground tracking-tight" style={{ fontWeight: 200 }}>
{typeof values[i] === 'number' ? values[i].toLocaleString() : values[i]}
</p>
</CardContent>
</Card>
)}
{/* Bottom ink bleed */}
<div className="absolute bottom-0 left-[8%] right-[20%] h-px bg-gradient-to-r from-[var(--sumi-border-default)] to-transparent" />
</div>
))}
</section>
);

View file

@ -24,6 +24,7 @@ import {
import { cn } from '@/lib/utils';
import { useEffect, useMemo, useCallback } from 'react';
import { ErrorDisplay } from '@/components/ui/ErrorDisplay';
import { ContentFadeIn } from '@/components/ui/ContentFadeIn';
import { useTranslation } from '@/hooks/useTranslation';
import { StatsSection } from '../components/StatsSection';
import { RecentActivityCard } from '../components/RecentActivityCard';
@ -39,26 +40,33 @@ function WelcomeBanner({ username }: { username: string }) {
hour < 12 ? 'dashboard.goodMorning' : hour < 18 ? 'dashboard.goodAfternoon' : 'dashboard.goodEvening';
return (
<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 animate-content-reveal">
<div className="relative overflow-hidden rounded-sm p-10 md:p-14 mb-8">
{/* Ink wash atmosphere — sumi-nagashi */}
<div className="absolute inset-0 sumi-notan" />
<div
className="absolute inset-0"
style={{
background: `
radial-gradient(ellipse at 15% 60%, rgba(184,58,30, 0.05) 0%, transparent 55%),
radial-gradient(ellipse at 85% 25%, rgba(232,224,208, 0.012) 0%, transparent 45%),
radial-gradient(ellipse at 50% 90%, rgba(184,134,11, 0.02) 0%, transparent 40%)
`,
}}
/>
{/* Ghost kanji — 音 (sound/music) */}
<span className="ghost-kanji right-6 md:right-12 -top-4 text-[140px] md:text-[200px]"></span>
{/* Gold accent line */}
<div className="absolute bottom-0 left-[5%] right-[15%] h-px bg-gradient-to-r from-transparent via-[var(--sumi-kin)]/20 to-transparent" />
<div className="relative z-10 animate-ink-reveal">
<h1 className="font-heading text-4xl md:text-5xl tracking-tight" style={{ fontWeight: 200 }}>
{t(greetingKey)},{' '}
<span className="text-transparent bg-clip-text bg-gradient-to-r from-primary via-primary/80 to-info font-bold">
{username}
</span>
<span className="text-[var(--sumi-accent)]">{username}</span>
</h1>
<p className="text-muted-foreground mt-2 text-base animate-content-reveal" style={{ animationDelay: '120ms' }}>
<div className="brush-underline mt-1" />
<p className="text-muted-foreground mt-5 text-base font-heading" style={{ fontWeight: 300 }}>
{t('dashboard.subtitle')}
</p>
</div>
@ -79,18 +87,18 @@ const QUICK_ACTIONS = [
function QuickActions() {
const { t } = useTranslation();
return (
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
{QUICK_ACTIONS.map((action, i) => (
<Link
key={action.labelKey}
to={action.path}
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` }}
className="group ink-card flex flex-col items-start gap-4 p-5 border-l-2 border-l-transparent hover:border-l-[var(--sumi-accent)] transition-all duration-300 ease-out animate-ink-reveal"
style={{ animationDelay: `${i * 100}ms` }}
>
<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 className={cn('p-2 rounded-sm transition-all duration-300 group-hover:scale-105', action.color)}>
<action.icon className="h-5 w-5" />
</div>
<span className="text-sm font-medium group-hover:text-foreground transition-colors">
<span className="text-sm text-muted-foreground/60 group-hover:text-foreground transition-colors duration-300 font-heading" style={{ fontWeight: 300 }}>
{t(action.labelKey)}
</span>
</Link>
@ -111,12 +119,13 @@ function SectionHeader({
}) {
const { t } = useTranslation();
return (
<div className="flex items-center justify-between mb-4">
<h2 className="text-heading-3">{title}</h2>
<div className="flex items-center justify-between mb-5">
<h2 className="font-heading text-xl tracking-tight brush-underline" style={{ fontWeight: 300 }}>{title}</h2>
{viewAllPath && (
<Link
to={viewAllPath}
className="text-caption hover:text-foreground transition-colors"
className="text-[10px] text-muted-foreground/40 hover:text-foreground transition-colors tracking-[0.15em] uppercase font-heading"
style={{ fontWeight: 300 }}
>
{t('dashboard.viewAll')}
</Link>
@ -158,7 +167,7 @@ function DashboardPage() {
}
return (
<div className="space-y-6 p-6 pb-24 animate-fade-in">
<ContentFadeIn className="min-h-layout-page space-y-6 pb-24">
{/* Welcome Banner */}
<WelcomeBanner username={user?.first_name || user?.username || 'there'} />
@ -188,39 +197,36 @@ function DashboardPage() {
</section>
{/* Quick Actions */}
<Card variant="glass" className="overflow-hidden animate-content-reveal" style={{ animationDelay: '460ms' }}>
<CardHeader>
<SectionHeader title={t('dashboard.quickActions')} />
<CardDescription>
{t('dashboard.quickActionsDescription')}
</CardDescription>
</CardHeader>
<CardContent>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
{quickActionButtons.map((action, i) => (
<Button
key={i}
variant="outline"
onClick={action.action}
className={cn(
"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 group-hover:rotate-6",
action.color
)}>
<action.icon className="h-5 w-5" />
</div>
<span className="text-muted-foreground group-hover:text-foreground transition-colors duration-[var(--duration-fast)]">{t(action.labelKey)}</span>
</Button>
))}
</div>
</CardContent>
</Card>
</div>
<div className="ink-card p-6 md:p-8 animate-ink-reveal relative" style={{ animationDelay: '460ms' }}>
<span className="ghost-kanji right-4 top-0 text-[100px]"></span>
<SectionHeader title={t('dashboard.quickActions')} />
<p className="text-sm text-muted-foreground/40 mb-6 font-heading" style={{ fontWeight: 300 }}>
{t('dashboard.quickActionsDescription')}
</p>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
{quickActionButtons.map((action, i) => (
<Button
key={i}
variant="outline"
onClick={action.action}
className={cn(
"h-24 flex-col gap-3 bg-transparent border-l-2 border-l-transparent border-t-0 border-r-0 border-b-0 rounded-none rounded-r-sm hover:border-l-[var(--sumi-accent)] transition-all duration-300 group animate-ink-reveal",
action.border
)}
style={{ animationDelay: `${500 + i * 100}ms` }}
>
<div className={cn(
"w-10 h-10 rounded-sm bg-muted/20 flex items-center justify-center transition-all duration-300 group-hover:scale-105",
action.color
)}>
<action.icon className="h-5 w-5" />
</div>
<span className="text-sm text-muted-foreground/50 group-hover:text-foreground transition-colors duration-300 font-heading" style={{ fontWeight: 300 }}>{t(action.labelKey)}</span>
</Button>
))}
</div>
</div>
</ContentFadeIn>
);
}

View file

@ -4,9 +4,9 @@
*/
import { useCallback, useEffect, useRef } from 'react';
import { useSearchParams } from 'react-router-dom';
import { useSearchParams, useNavigate } from 'react-router-dom';
import { useInfiniteQuery, useQuery } from '@tanstack/react-query';
import { useAudio } from '@/context/AudioContext';
import { usePlayerStore } from '@/features/player/store/playerStore';
import { ContentFadeIn } from '@/components/ui/ContentFadeIn';
import { TrackGrid } from '@/features/tracks/components/TrackGrid';
import { TrackCardSkeleton } from '@/features/tracks/components/TrackCardSkeleton';
@ -18,30 +18,31 @@ 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
// Spotify-style genre gradient colors — rich, two-tone gradients
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',
'from-[#e13300] to-[#ff6b3d]',
'from-[#8400e7] to-[#b44dff]',
'from-[#1e3264] to-[#3d5a9e]',
'from-[#e8115b] to-[#ff4d8d]',
'from-[#148a08] to-[#2cc41a]',
'from-[#e91429] to-[#ff5c6e]',
'from-[#477d95] to-[#6ab0cc]',
'from-[#8c67ab] to-[#b896d6]',
'from-[#ba5d07] to-[#e89234]',
'from-[#1e3264] to-[#608108]',
'from-[#dc148c] to-[#ff4db8]',
'from-[#186962] to-[#2aaa9e]',
'from-[#7358ff] to-[#a18dff]',
'from-[#c84040] to-[#e87070]',
'from-[#0d73ec] to-[#4da3ff]',
];
export function DiscoverPage() {
const [searchParams, setSearchParams] = useSearchParams();
const genreFromQuery = searchParams.get('genre');
const tagFromQuery = searchParams.get('tag');
const { playTrack } = useAudio();
const play = usePlayerStore((s) => s.play);
const navigate = useNavigate();
const loadMoreRef = useRef<HTMLDivElement>(null);
const browseGenre = genreFromQuery;
@ -123,9 +124,16 @@ export function DiscoverPage() {
const handlePlay = useCallback(
(track: { id: string; title: string; artist?: string; duration: number; url: string; cover?: string }) => {
playTrack?.(track);
play(track);
},
[playTrack]
[play]
);
const handleTrackClick = useCallback(
(track: { id: string }) => {
navigate(`/tracks/${track.id}`);
},
[navigate],
);
const handleGenreClick = (genre: Genre) => {
@ -143,13 +151,14 @@ export function DiscoverPage() {
<ContentFadeIn className="min-h-layout-page pb-24">
<div className="flex items-center gap-3">
<Music2 className="w-8 h-8 text-primary" />
<h1 className="text-2xl font-heading font-bold">Découvrir</h1>
<h1 className="text-2xl font-heading font-bold">Discover</h1>
</div>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4 mt-6">
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-3 mt-6">
{Array.from({ length: 12 }).map((_, i) => (
<div
key={i}
className="h-24 rounded-lg bg-muted/50 animate-pulse"
className="min-h-[7.5rem] rounded-2xl bg-muted/40 animate-pulse"
style={{ animationDelay: `${i * 50}ms` }}
/>
))}
</div>
@ -170,7 +179,7 @@ export function DiscoverPage() {
className="-ml-2 hover:bg-[var(--sumi-bg-hover)]"
>
<ChevronLeft className="w-5 h-5" />
Retour
Back
</Button>
) : null}
<div className="w-12 h-12 rounded-2xl bg-gradient-to-br from-primary/30 to-primary/10 flex items-center justify-center">
@ -182,44 +191,47 @@ export function DiscoverPage() {
? genres?.find((g) => g.slug === browseGenre)?.name ?? browseGenre
: browseTag
? browseTag
: 'Découvrir'}
: 'Discover'}
</h1>
{showGenreList && (
<p className="text-sm text-muted-foreground mt-0.5">Explore par genre, tag ou playlist éditoriale</p>
<p className="text-sm text-muted-foreground mt-0.5">Explore by genre, tag, or editorial playlist</p>
)}
</div>
</div>
{showGenreList && genres ? (
<section className="space-y-4">
<h2 className="text-lg font-heading font-semibold">
Par genre
<h2 className="text-lg font-heading font-semibold tracking-tight">
By Genre
</h2>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-3">
{genres.map((g, i) => (
<button
key={g.slug}
onClick={() => handleGenreClick(g)}
className={cn(
'relative overflow-hidden rounded-xl min-h-28 p-4 text-left',
'relative overflow-hidden rounded-2xl min-h-[7.5rem] p-4 pb-5 text-left',
'bg-gradient-to-br',
GENRE_GRADIENTS[i % GENRE_GRADIENTS.length],
'hover:scale-[1.03] hover:shadow-lg active:scale-[0.98]',
'hover:scale-[1.04] hover:shadow-xl hover:shadow-black/20 active:scale-[0.97]',
'transition-all duration-[var(--sumi-duration-normal)] ease-[var(--sumi-ease-out)]',
'group animate-content-reveal',
'group animate-card-enter',
)}
style={{ animationDelay: `${i * 40}ms` }}
>
<span className="relative z-10 font-heading font-bold text-white text-base drop-shadow-sm">
<span className="relative z-10 font-heading font-bold text-white text-base drop-shadow-md">
{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">
<span className="relative z-10 block mt-1 text-xs text-white/60 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" />
{/* Decorative circles */}
<div className="absolute -bottom-3 -right-3 w-24 h-24 rounded-full bg-white/10 rotate-12 group-hover:scale-125 transition-transform duration-500 ease-out" />
<div className="absolute -top-4 -right-6 w-16 h-16 rounded-full bg-white/[0.07] group-hover:scale-110 transition-transform duration-700" />
{/* Bottom highlight */}
<div className="absolute inset-x-0 bottom-0 h-1/2 bg-gradient-to-t from-black/15 to-transparent pointer-events-none" />
</button>
))}
</div>
@ -228,8 +240,9 @@ export function DiscoverPage() {
{showGenreList ? (
<section className="space-y-4">
<h2 className="text-lg font-heading font-semibold">
Playlists éditoriales
<div className="section-divider my-2" />
<h2 className="text-lg font-heading font-semibold tracking-tight">
Editorial Playlists
</h2>
{editorialLoading ? (
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
@ -274,8 +287,9 @@ export function DiscoverPage() {
<>
<TrackGrid
tracks={tracks}
emptyMessage="Aucun morceau dans ce genre"
emptyMessage="No tracks in this genre"
onTrackPlay={handlePlay}
onTrackClick={handleTrackClick}
gap="md"
/>
<div ref={loadMoreRef} className="flex justify-center py-8">

View file

@ -1,5 +1,6 @@
import React, { useEffect, useState, useCallback } from 'react';
import { distributionService } from '@/services/distributionService';
import { ContentFadeIn } from '@/components/ui/ContentFadeIn';
import type {
TrackDistribution,
ExternalStreamingRoyalty,
@ -29,12 +30,12 @@ function formatStreams(n: number): string {
}
const STATUS_COLORS: Record<DistributionStatus, string> = {
submitted: 'bg-blue-900/50 text-blue-300',
processing: 'bg-yellow-900/50 text-yellow-300',
live: 'bg-green-900/50 text-green-300',
rejected: 'bg-red-900/50 text-red-300',
removed: 'bg-gray-700 text-gray-400',
failed: 'bg-red-900/50 text-red-300',
submitted: 'bg-primary/20 text-primary',
processing: 'bg-warning/20 text-warning',
live: 'bg-emerald-500/20 text-emerald-400',
rejected: 'bg-destructive/20 text-destructive',
removed: 'bg-muted text-muted-foreground',
failed: 'bg-destructive/20 text-destructive',
};
const PLATFORM_NAMES: Record<string, string> = {
@ -94,161 +95,168 @@ export function DistributionPage(): React.ReactElement {
if (loading) {
return (
<div className="flex items-center justify-center min-h-[400px]">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-purple-500" />
</div>
<ContentFadeIn className="min-h-layout-page pb-24">
<div className="flex items-center justify-center min-h-[400px]">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
</div>
</ContentFadeIn>
);
}
return (
<div className="max-w-6xl mx-auto px-4 py-8">
<h1 className="text-3xl font-bold mb-2">Distribution</h1>
<p className="text-gray-400 mb-8">
Distribute your tracks to Spotify, Apple Music, Deezer and track your
streaming revenue.
</p>
<ContentFadeIn className="min-h-layout-page pb-24">
<div className="space-y-8">
{/* Header */}
<div>
<h1 className="text-2xl font-heading font-bold tracking-tight mb-1">Distribution</h1>
<p className="text-sm text-muted-foreground">
Distribute your tracks to Spotify, Apple Music, Deezer and track your
streaming revenue.
</p>
</div>
{error && (
{error && (
<div
className="bg-destructive/20 border border-destructive/50 text-destructive px-4 py-3 rounded-lg"
role="alert"
>
{error}
</div>
)}
{/* Revenue summary */}
{summary && summary.total_streams > 0 && (
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4">
<div className="rounded-xl border border-border bg-card p-5">
<p className="text-muted-foreground text-sm">Total Streams</p>
<p className="text-2xl font-bold text-foreground">
{formatStreams(summary.total_streams)}
</p>
</div>
<div className="rounded-xl border border-border bg-card p-5">
<p className="text-muted-foreground text-sm">Total Revenue</p>
<p className="text-2xl font-bold text-emerald-400">
{formatCents(summary.total_revenue_cents)}
</p>
</div>
<div className="rounded-xl border border-border bg-card p-5">
<p className="text-muted-foreground text-sm">Platforms</p>
<div className="flex gap-3 mt-1">
{Object.entries(summary.by_platform).map(([platform, data]) => (
<span key={platform} className="text-sm text-foreground">
{PLATFORM_ICONS[platform] || '🎵'}{' '}
{formatStreams(data.streams)}
</span>
))}
</div>
</div>
</div>
)}
{/* Tabs */}
<div
className="bg-red-900/30 border border-red-500 text-red-300 px-4 py-3 rounded mb-6"
role="alert"
className="flex border-b border-border"
role="tablist"
aria-label="Distribution sections"
>
{error}
<button
role="tab"
aria-selected={activeTab === 'distributions'}
onClick={() => setActiveTab('distributions')}
className={`px-4 py-2 font-medium text-sm border-b-2 transition-colors ${
activeTab === 'distributions'
? 'border-primary text-foreground'
: 'border-transparent text-muted-foreground hover:text-foreground'
}`}
>
Distributions ({distributions.length})
</button>
<button
role="tab"
aria-selected={activeTab === 'royalties'}
onClick={() => setActiveTab('royalties')}
className={`px-4 py-2 font-medium text-sm border-b-2 transition-colors ${
activeTab === 'royalties'
? 'border-primary text-foreground'
: 'border-transparent text-muted-foreground hover:text-foreground'
}`}
>
Streaming Revenue ({royalties.length})
</button>
</div>
)}
{/* Revenue summary */}
{summary && summary.total_streams > 0 && (
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-8">
<div className="bg-gray-800 rounded-lg p-5 border border-gray-700">
<p className="text-gray-400 text-sm">Total Streams</p>
<p className="text-2xl font-bold">
{formatStreams(summary.total_streams)}
</p>
{/* Distributions tab */}
{activeTab === 'distributions' && (
<div role="tabpanel" aria-label="Distributions">
{distributions.length === 0 ? (
<div className="text-center py-12 text-muted-foreground">
<p className="text-lg mb-2">No distributions yet</p>
<p className="text-sm">
Submit your tracks to Spotify, Apple Music, and Deezer from
your track page.
</p>
</div>
) : (
<div className="space-y-4">
{distributions.map((dist) => (
<DistributionCard key={dist.id} distribution={dist} />
))}
</div>
)}
</div>
<div className="bg-gray-800 rounded-lg p-5 border border-gray-700">
<p className="text-gray-400 text-sm">Total Revenue</p>
<p className="text-2xl font-bold text-green-400">
{formatCents(summary.total_revenue_cents)}
</p>
</div>
<div className="bg-gray-800 rounded-lg p-5 border border-gray-700">
<p className="text-gray-400 text-sm">Platforms</p>
<div className="flex gap-3 mt-1">
{Object.entries(summary.by_platform).map(([platform, data]) => (
<span key={platform} className="text-sm">
{PLATFORM_ICONS[platform] || '🎵'}{' '}
{formatStreams(data.streams)}
</span>
))}
</div>
</div>
</div>
)}
)}
{/* Tabs */}
<div
className="flex border-b border-gray-700 mb-6"
role="tablist"
aria-label="Distribution sections"
>
<button
role="tab"
aria-selected={activeTab === 'distributions'}
onClick={() => setActiveTab('distributions')}
className={`px-4 py-2 font-medium text-sm border-b-2 transition ${
activeTab === 'distributions'
? 'border-purple-500 text-white'
: 'border-transparent text-gray-400 hover:text-white'
}`}
>
Distributions ({distributions.length})
</button>
<button
role="tab"
aria-selected={activeTab === 'royalties'}
onClick={() => setActiveTab('royalties')}
className={`px-4 py-2 font-medium text-sm border-b-2 transition ${
activeTab === 'royalties'
? 'border-purple-500 text-white'
: 'border-transparent text-gray-400 hover:text-white'
}`}
>
Streaming Revenue ({royalties.length})
</button>
</div>
{/* Distributions tab */}
{activeTab === 'distributions' && (
<div role="tabpanel" aria-label="Distributions">
{distributions.length === 0 ? (
<div className="text-center py-12 text-gray-400">
<p className="text-lg mb-2">No distributions yet</p>
<p className="text-sm">
Submit your tracks to Spotify, Apple Music, and Deezer from
your track page.
</p>
</div>
) : (
<div className="space-y-4">
{distributions.map((dist) => (
<DistributionCard key={dist.id} distribution={dist} />
))}
</div>
)}
</div>
)}
{/* Royalties tab */}
{activeTab === 'royalties' && (
<div role="tabpanel" aria-label="Streaming Revenue">
{royalties.length === 0 ? (
<div className="text-center py-12 text-gray-400">
<p className="text-lg mb-2">No streaming revenue yet</p>
<p className="text-sm">
Revenue from external platforms appears here once your tracks
are live and generating streams.
</p>
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm" aria-label="Streaming revenue">
<thead>
<tr className="text-gray-400 border-b border-gray-700">
<th className="text-left py-2 pr-4">Period</th>
<th className="text-left py-2 pr-4">Platform</th>
<th className="text-right py-2 pr-4">Streams</th>
<th className="text-right py-2">Revenue</th>
</tr>
</thead>
<tbody>
{royalties.map((r) => (
<tr
key={r.id}
className="border-b border-gray-700/50"
>
<td className="py-2 pr-4">
{formatDate(r.reporting_period_start)}
</td>
<td className="py-2 pr-4">
{PLATFORM_ICONS[r.platform] || '🎵'}{' '}
{PLATFORM_NAMES[r.platform] || r.platform}
</td>
<td className="py-2 pr-4 text-right">
{formatStreams(r.total_streams)}
</td>
<td className="py-2 text-right text-green-400">
{formatCents(r.total_revenue_cents, r.currency)}
</td>
{/* Royalties tab */}
{activeTab === 'royalties' && (
<div role="tabpanel" aria-label="Streaming Revenue">
{royalties.length === 0 ? (
<div className="text-center py-12 text-muted-foreground">
<p className="text-lg mb-2">No streaming revenue yet</p>
<p className="text-sm">
Revenue from external platforms appears here once your tracks
are live and generating streams.
</p>
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm" aria-label="Streaming revenue">
<thead>
<tr className="text-muted-foreground border-b border-border">
<th className="text-left py-2 pr-4">Period</th>
<th className="text-left py-2 pr-4">Platform</th>
<th className="text-right py-2 pr-4">Streams</th>
<th className="text-right py-2">Revenue</th>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
)}
</div>
</thead>
<tbody>
{royalties.map((r) => (
<tr
key={r.id}
className="border-b border-border/50"
>
<td className="py-2 pr-4 text-foreground">
{formatDate(r.reporting_period_start)}
</td>
<td className="py-2 pr-4 text-foreground">
{PLATFORM_ICONS[r.platform] || '🎵'}{' '}
{PLATFORM_NAMES[r.platform] || r.platform}
</td>
<td className="py-2 pr-4 text-right text-foreground">
{formatStreams(r.total_streams)}
</td>
<td className="py-2 text-right text-emerald-400">
{formatCents(r.total_revenue_cents, r.currency)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
)}
</div>
</ContentFadeIn>
);
}
@ -263,21 +271,21 @@ function DistributionCard({
: distribution.metadata;
return (
<div className="bg-gray-800 rounded-lg p-5 border border-gray-700">
<div className="rounded-xl border border-border bg-card p-5">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<h3 className="font-semibold text-lg">
<h3 className="font-semibold text-lg text-foreground">
{metadata?.track_title || 'Untitled Track'}
</h3>
<p className="text-gray-400 text-sm">
<p className="text-muted-foreground text-sm">
{metadata?.artist_name} {metadata?.album_name || 'Single'}
</p>
<p className="text-gray-500 text-xs mt-1">
<p className="text-muted-foreground/60 text-xs mt-1">
Submitted {formatDate(distribution.submitted_at)}
</p>
</div>
<span
className={`px-2 py-1 rounded text-xs font-medium ${STATUS_COLORS[distribution.overall_status] || 'bg-gray-700 text-gray-300'}`}
className={`px-2 py-1 rounded-md text-xs font-medium ${STATUS_COLORS[distribution.overall_status] || 'bg-muted text-muted-foreground'}`}
>
{distribution.overall_status}
</span>
@ -289,14 +297,14 @@ function DistributionCard({
([platform, status]) => (
<div
key={platform}
className="flex items-center gap-2 bg-gray-900/50 rounded px-3 py-2 text-sm"
className="flex items-center gap-2 bg-muted/40 rounded-lg px-3 py-2 text-sm"
>
<span>{PLATFORM_ICONS[platform] || '🎵'}</span>
<span className="text-gray-300">
<span className="text-muted-foreground">
{PLATFORM_NAMES[platform] || platform}
</span>
<span
className={`px-1.5 py-0.5 rounded text-xs ${STATUS_COLORS[(status as PlatformStatusDisplay).status as DistributionStatus] || 'bg-gray-700 text-gray-300'}`}
className={`px-1.5 py-0.5 rounded-md text-xs ${STATUS_COLORS[(status as PlatformStatusDisplay).status as DistributionStatus] || 'bg-muted text-muted-foreground'}`}
>
{(status as PlatformStatusDisplay).status}
</span>
@ -305,7 +313,7 @@ function DistributionCard({
href={(status as PlatformStatusDisplay).url}
target="_blank"
rel="noopener noreferrer"
className="text-purple-400 hover:text-purple-300 text-xs underline"
className="text-primary hover:text-primary/80 text-xs underline"
aria-label={`Open on ${PLATFORM_NAMES[platform] || platform}`}
>
Open

View file

@ -1,4 +1,7 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import { GraduationCap } from 'lucide-react';
import { ContentFadeIn } from '@/components/ui/ContentFadeIn';
import { cn } from '@/lib/utils';
import type { Course, Lesson, LessonProgress } from '@/types/education';
import {
listPublishedCourses,
@ -29,10 +32,10 @@ function formatPrice(cents: number, currency: string): string {
}).format(cents / 100);
}
const levelColors: Record<string, string> = {
beginner: '#22c55e',
intermediate: '#f59e0b',
advanced: '#ef4444',
const levelClasses: Record<string, string> = {
beginner: 'bg-success text-white',
intermediate: 'bg-amber-500 text-white',
advanced: 'bg-destructive text-white',
};
export function EducationPage() {
@ -190,550 +193,411 @@ export function EducationPage() {
}, []);
return (
<div style={{ padding: '24px', maxWidth: '1200px', margin: '0 auto' }}>
<h1 style={{ fontSize: '28px', fontWeight: 700, marginBottom: '8px' }}>
Formation & Education
</h1>
<p style={{ color: '#6b7280', marginBottom: '24px' }}>
Learn from creators, share your knowledge
</p>
{error && (
<div
role="alert"
style={{
padding: '12px 16px',
background: '#fef2f2',
border: '1px solid #fecaca',
borderRadius: '8px',
color: '#991b1b',
marginBottom: '16px',
}}
>
{error}
<ContentFadeIn className="min-h-layout-page pb-24">
<div className="max-w-5xl mx-auto p-6">
{/* Page header */}
<div className="flex items-center gap-4 mb-2">
<div className="w-12 h-12 rounded-2xl bg-gradient-to-br from-primary/30 to-primary/10 flex items-center justify-center">
<GraduationCap className="w-6 h-6 text-primary" />
</div>
<div>
<h1 className="text-2xl font-heading font-bold tracking-tight">
Education & Training
</h1>
<p className="text-sm text-muted-foreground mt-0.5">
Learn from creators, share your knowledge
</p>
</div>
</div>
)}
{/* Tab Navigation */}
<div
role="tablist"
aria-label="Education sections"
style={{
display: 'flex',
gap: '4px',
borderBottom: '2px solid #e5e7eb',
marginBottom: '24px',
}}
>
{([
{ key: 'catalog', label: 'Course Catalog' },
{ key: 'my-courses', label: 'My Courses' },
{ key: 'certificates', label: 'Certificates' },
] as const).map((tab) => (
<button
key={tab.key}
role="tab"
aria-selected={activeTab === tab.key}
aria-controls={`panel-${tab.key}`}
onClick={() => {
setActiveTab(tab.key);
setSelectedCourse(null);
setSelectedLesson(null);
}}
style={{
padding: '10px 20px',
border: 'none',
background: 'none',
cursor: 'pointer',
fontWeight: activeTab === tab.key ? 600 : 400,
color: activeTab === tab.key ? '#7c3aed' : '#6b7280',
borderBottom: activeTab === tab.key ? '2px solid #7c3aed' : '2px solid transparent',
marginBottom: '-2px',
}}
<div className="mb-6" />
{error && (
<div
role="alert"
className="px-4 py-3 bg-destructive/10 border border-destructive/20 rounded-lg text-destructive mb-4"
>
{tab.label}
</button>
))}
</div>
{error}
</div>
)}
{/* Course Detail View */}
{selectedCourse ? (
<div>
<button
onClick={() => {
setSelectedCourse(null);
setSelectedLesson(null);
}}
aria-label="Back to course list"
style={{
padding: '8px 16px',
border: '1px solid #d1d5db',
borderRadius: '6px',
background: 'white',
cursor: 'pointer',
marginBottom: '16px',
}}
>
Back to courses
</button>
{/* Tab Navigation */}
<div
role="tablist"
aria-label="Education sections"
className="flex gap-1 border-b-2 border-border mb-6"
>
{([
{ key: 'catalog', label: 'Course Catalog' },
{ key: 'my-courses', label: 'My Courses' },
{ key: 'certificates', label: 'Certificates' },
] as const).map((tab) => (
<button
key={tab.key}
role="tab"
aria-selected={activeTab === tab.key}
aria-controls={`panel-${tab.key}`}
onClick={() => {
setActiveTab(tab.key);
setSelectedCourse(null);
setSelectedLesson(null);
}}
className={cn(
'px-5 py-2.5 border-none bg-transparent cursor-pointer -mb-[2px] transition-colors',
activeTab === tab.key
? 'font-semibold text-primary border-b-2 border-primary'
: 'font-normal text-muted-foreground border-b-2 border-transparent hover:text-foreground',
)}
>
{tab.label}
</button>
))}
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 350px', gap: '24px' }}>
{/* Main content */}
<div>
<h2 style={{ fontSize: '24px', fontWeight: 700, marginBottom: '8px' }}>
{selectedCourse.title}
</h2>
{/* Course Detail View */}
{selectedCourse ? (
<div>
<button
onClick={() => {
setSelectedCourse(null);
setSelectedLesson(null);
}}
aria-label="Back to course list"
className="px-4 py-2 border border-border rounded-md bg-card cursor-pointer mb-4 hover:bg-muted transition-colors"
>
Back to courses
</button>
<div style={{ display: 'flex', gap: '12px', marginBottom: '16px', flexWrap: 'wrap' }}>
<span
style={{
padding: '2px 10px',
borderRadius: '12px',
fontSize: '13px',
background: levelColors[selectedCourse.level] || '#6b7280',
color: 'white',
}}
>
{selectedCourse.level}
</span>
{selectedCourse.category && (
<div className="grid grid-cols-1 lg:grid-cols-[1fr_350px] gap-6">
{/* Main content */}
<div>
<h2 className="text-2xl font-bold mb-2">
{selectedCourse.title}
</h2>
<div className="flex gap-3 mb-4 flex-wrap">
<span
style={{
padding: '2px 10px',
borderRadius: '12px',
fontSize: '13px',
background: '#ede9fe',
color: '#7c3aed',
}}
className={cn(
'px-2.5 py-0.5 rounded-full text-xs',
levelClasses[selectedCourse.level] || 'bg-muted-foreground text-white',
)}
>
{selectedCourse.category}
{selectedCourse.level}
</span>
)}
<span style={{ fontSize: '13px', color: '#6b7280' }}>
{selectedCourse.lesson_count} lessons | {formatDuration(selectedCourse.total_duration_seconds)}
</span>
{selectedCourse.average_rating > 0 && (
<span style={{ fontSize: '13px', color: '#f59e0b' }}>
{'★'.repeat(Math.round(selectedCourse.average_rating))} ({selectedCourse.review_count} reviews)
</span>
)}
</div>
<p style={{ color: '#374151', marginBottom: '24px', lineHeight: 1.6 }}>
{selectedCourse.description}
</p>
{/* Video Player */}
{selectedLesson && (
<div style={{ marginBottom: '24px' }}>
<h3 style={{ fontSize: '18px', fontWeight: 600, marginBottom: '12px' }}>
Lesson {selectedLesson.order_index}: {selectedLesson.title}
</h3>
{selectedLesson.hls_master_playlist_url ? (
<video
ref={videoRef}
controls
onTimeUpdate={handleProgressUpdate}
style={{
width: '100%',
borderRadius: '8px',
background: '#000',
maxHeight: '500px',
}}
aria-label={`Video: ${selectedLesson.title}`}
/>
) : (
<div
style={{
width: '100%',
height: '300px',
background: '#1f2937',
borderRadius: '8px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '#9ca3af',
}}
>
Video not yet available (transcoding: {selectedLesson.transcoding_status})
</div>
)}
{selectedLesson.description && (
<p style={{ marginTop: '12px', color: '#6b7280' }}>{selectedLesson.description}</p>
)}
</div>
)}
{/* Enrollment progress */}
{isEnrolled && totalLessons > 0 && (
<div style={{ marginBottom: '24px' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '8px' }}>
<span style={{ fontWeight: 600 }}>Progress</span>
<span style={{ color: '#6b7280' }}>
{completedLessons}/{totalLessons} lessons completed
{selectedCourse.category && (
<span className="px-2.5 py-0.5 rounded-full text-xs bg-primary/10 text-primary">
{selectedCourse.category}
</span>
)}
<span className="text-xs text-muted-foreground">
{selectedCourse.lesson_count} lessons | {formatDuration(selectedCourse.total_duration_seconds)}
</span>
{selectedCourse.average_rating > 0 && (
<span className="text-xs text-amber-500">
{'★'.repeat(Math.round(selectedCourse.average_rating))} ({selectedCourse.review_count} reviews)
</span>
</div>
<div
role="progressbar"
aria-valuenow={completedLessons}
aria-valuemin={0}
aria-valuemax={totalLessons}
aria-label="Course completion progress"
style={{
width: '100%',
height: '8px',
background: '#e5e7eb',
borderRadius: '4px',
overflow: 'hidden',
}}
>
<div
style={{
width: `${(completedLessons / totalLessons) * 100}%`,
height: '100%',
background: '#7c3aed',
borderRadius: '4px',
transition: 'width 0.3s',
}}
/>
</div>
{courseComplete && !certificates.some((c) => c.course_id === selectedCourse.id) && (
<button
onClick={() => handleIssueCertificate(selectedCourse.id)}
style={{
marginTop: '12px',
padding: '10px 24px',
background: '#7c3aed',
color: 'white',
border: 'none',
borderRadius: '8px',
fontWeight: 600,
cursor: 'pointer',
}}
>
Get Certificate
</button>
)}
</div>
)}
{/* Lesson list */}
<h3 style={{ fontSize: '18px', fontWeight: 600, marginBottom: '12px' }}>Lessons</h3>
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
{lessons.map((lesson) => {
const lp = lessonProgressMap.get(lesson.id);
const canPlay = isEnrolled || lesson.is_preview_free;
return (
<button
key={lesson.id}
onClick={() => canPlay && handleSelectLesson(lesson)}
disabled={!canPlay}
aria-label={`Lesson ${lesson.order_index}: ${lesson.title}${lp?.is_completed ? ' (completed)' : ''}`}
style={{
display: 'flex',
alignItems: 'center',
gap: '12px',
padding: '12px 16px',
border: selectedLesson?.id === lesson.id ? '2px solid #7c3aed' : '1px solid #e5e7eb',
borderRadius: '8px',
background: canPlay ? 'white' : '#f9fafb',
cursor: canPlay ? 'pointer' : 'not-allowed',
textAlign: 'left',
width: '100%',
opacity: canPlay ? 1 : 0.6,
}}
>
<span
style={{
width: '28px',
height: '28px',
borderRadius: '50%',
background: lp?.is_completed ? '#22c55e' : '#e5e7eb',
color: lp?.is_completed ? 'white' : '#6b7280',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '13px',
fontWeight: 600,
flexShrink: 0,
}}
>
{lp?.is_completed ? '✓' : lesson.order_index}
</span>
<div style={{ flex: 1 }}>
<div style={{ fontWeight: 500 }}>{lesson.title}</div>
<div style={{ fontSize: '13px', color: '#6b7280' }}>
{formatDuration(lesson.duration_seconds)}
{lesson.is_preview_free && !isEnrolled && ' (Free preview)'}
{lp && !lp.is_completed && `${lp.watched_percentage}% watched`}
</div>
<p className="text-foreground mb-6 leading-relaxed">
{selectedCourse.description}
</p>
{/* Video Player */}
{selectedLesson && (
<div className="mb-6">
<h3 className="text-lg font-semibold mb-3">
Lesson {selectedLesson.order_index}: {selectedLesson.title}
</h3>
{selectedLesson.hls_master_playlist_url ? (
<video
ref={videoRef}
controls
onTimeUpdate={handleProgressUpdate}
className="w-full rounded-lg bg-black max-h-[500px]"
aria-label={`Video: ${selectedLesson.title}`}
/>
) : (
<div className="w-full h-[300px] bg-gray-800 rounded-lg flex items-center justify-center text-muted-foreground">
Video not yet available (transcoding: {selectedLesson.transcoding_status})
</div>
</button>
);
})}
</div>
</div>
{/* Sidebar */}
<div>
<div
style={{
border: '1px solid #e5e7eb',
borderRadius: '12px',
padding: '24px',
position: 'sticky',
top: '24px',
}}
>
{selectedCourse.cover_image_url && (
<img
src={selectedCourse.cover_image_url}
alt={selectedCourse.title}
style={{
width: '100%',
borderRadius: '8px',
marginBottom: '16px',
}}
/>
)}
<div style={{ fontSize: '28px', fontWeight: 700, marginBottom: '16px' }}>
{formatPrice(selectedCourse.price_cents, selectedCourse.currency)}
</div>
{isEnrolled ? (
<div
style={{
padding: '12px',
background: '#f0fdf4',
borderRadius: '8px',
color: '#166534',
fontWeight: 600,
textAlign: 'center',
}}
>
Enrolled
)}
{selectedLesson.description && (
<p className="mt-3 text-muted-foreground">{selectedLesson.description}</p>
)}
</div>
) : (
<button
onClick={handleEnroll}
aria-label={`Enroll in ${selectedCourse.title}`}
style={{
width: '100%',
padding: '12px',
background: '#7c3aed',
color: 'white',
border: 'none',
borderRadius: '8px',
fontWeight: 600,
cursor: 'pointer',
fontSize: '16px',
}}
>
{selectedCourse.price_cents === 0 ? 'Enroll Free' : 'Enroll Now'}
</button>
)}
<div style={{ marginTop: '16px', fontSize: '14px', color: '#6b7280' }}>
<div style={{ marginBottom: '8px' }}>Language: {selectedCourse.language}</div>
<div style={{ marginBottom: '8px' }}>
Enrollments: {selectedCourse.enrollment_count}
</div>
{selectedCourse.tags.length > 0 && (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '4px', marginTop: '8px' }}>
{selectedCourse.tags.map((tag) => (
<span
key={tag}
style={{
padding: '2px 8px',
background: '#f3f4f6',
borderRadius: '4px',
fontSize: '12px',
}}
>
{tag}
</span>
))}
{/* Enrollment progress */}
{isEnrolled && totalLessons > 0 && (
<div className="mb-6">
<div className="flex justify-between mb-2">
<span className="font-semibold">Progress</span>
<span className="text-muted-foreground">
{completedLessons}/{totalLessons} lessons completed
</span>
</div>
<div
role="progressbar"
aria-valuenow={completedLessons}
aria-valuemin={0}
aria-valuemax={totalLessons}
aria-label="Course completion progress"
className="w-full h-2 bg-muted rounded overflow-hidden"
>
<div
className="h-full bg-primary rounded transition-[width] duration-300"
style={{ width: `${(completedLessons / totalLessons) * 100}%` }}
/>
</div>
{courseComplete && !certificates.some((c) => c.course_id === selectedCourse.id) && (
<button
onClick={() => handleIssueCertificate(selectedCourse.id)}
className="mt-3 px-6 py-2.5 bg-primary text-primary-foreground border-none rounded-lg font-semibold cursor-pointer hover:bg-primary/90 transition-colors"
>
Get Certificate
</button>
)}
</div>
)}
{/* Lesson list */}
<h3 className="text-lg font-semibold mb-3">Lessons</h3>
<div className="flex flex-col gap-2">
{lessons.map((lesson) => {
const lp = lessonProgressMap.get(lesson.id);
const canPlay = isEnrolled || lesson.is_preview_free;
return (
<button
key={lesson.id}
onClick={() => canPlay && handleSelectLesson(lesson)}
disabled={!canPlay}
aria-label={`Lesson ${lesson.order_index}: ${lesson.title}${lp?.is_completed ? ' (completed)' : ''}`}
className={cn(
'flex items-center gap-3 px-4 py-3 rounded-lg text-left w-full transition-colors',
selectedLesson?.id === lesson.id
? 'border-2 border-primary'
: 'border border-border',
canPlay
? 'bg-card cursor-pointer hover:bg-muted/50'
: 'bg-muted cursor-not-allowed opacity-60',
)}
>
<span
className={cn(
'w-7 h-7 rounded-full flex items-center justify-center text-xs font-semibold shrink-0',
lp?.is_completed
? 'bg-success text-white'
: 'bg-muted text-muted-foreground',
)}
>
{lp?.is_completed ? '✓' : lesson.order_index}
</span>
<div className="flex-1">
<div className="font-medium">{lesson.title}</div>
<div className="text-xs text-muted-foreground">
{formatDuration(lesson.duration_seconds)}
{lesson.is_preview_free && !isEnrolled && ' (Free preview)'}
{lp && !lp.is_completed && `${lp.watched_percentage}% watched`}
</div>
</div>
</button>
);
})}
</div>
</div>
{/* Sidebar */}
<div>
<div className="border border-border rounded-xl p-6 sticky top-6">
{selectedCourse.cover_image_url && (
<img
src={selectedCourse.cover_image_url}
alt={selectedCourse.title}
className="w-full rounded-lg mb-4"
/>
)}
<div className="text-3xl font-bold mb-4">
{formatPrice(selectedCourse.price_cents, selectedCourse.currency)}
</div>
{isEnrolled ? (
<div className="p-3 bg-success/10 rounded-lg text-success font-semibold text-center">
Enrolled
</div>
) : (
<button
onClick={handleEnroll}
aria-label={`Enroll in ${selectedCourse.title}`}
className="w-full p-3 bg-primary text-primary-foreground border-none rounded-lg font-semibold cursor-pointer text-base hover:bg-primary/90 transition-colors"
>
{selectedCourse.price_cents === 0 ? 'Enroll Free' : 'Enroll Now'}
</button>
)}
<div className="mt-4 text-sm text-muted-foreground">
<div className="mb-2">Language: {selectedCourse.language}</div>
<div className="mb-2">
Enrollments: {selectedCourse.enrollment_count}
</div>
{selectedCourse.tags.length > 0 && (
<div className="flex flex-wrap gap-1 mt-2">
{selectedCourse.tags.map((tag) => (
<span
key={tag}
className="px-2 py-0.5 bg-muted rounded text-xs"
>
{tag}
</span>
))}
</div>
)}
</div>
</div>
</div>
</div>
</div>
</div>
) : (
<>
{/* Catalog Tab */}
{activeTab === 'catalog' && (
<div
id="panel-catalog"
role="tabpanel"
aria-labelledby="tab-catalog"
>
{loading ? (
<div style={{ textAlign: 'center', padding: '48px', color: '#6b7280' }}>
Loading courses...
</div>
) : courses.length === 0 ? (
<div style={{ textAlign: 'center', padding: '48px', color: '#6b7280' }}>
No courses available yet
</div>
) : (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(320px, 1fr))', gap: '20px' }}>
{courses.map((course) => (
<button
key={course.id}
onClick={() => handleSelectCourse(course)}
aria-label={`View course: ${course.title}`}
style={{
border: '1px solid #e5e7eb',
borderRadius: '12px',
overflow: 'hidden',
cursor: 'pointer',
background: 'white',
textAlign: 'left',
width: '100%',
padding: 0,
}}
>
{course.cover_image_url && (
<img
src={course.cover_image_url}
alt=""
style={{ width: '100%', height: '180px', objectFit: 'cover' }}
/>
)}
<div style={{ padding: '16px' }}>
<h3 style={{ fontSize: '16px', fontWeight: 600, marginBottom: '8px' }}>
{course.title}
</h3>
<div style={{ display: 'flex', gap: '8px', marginBottom: '8px', flexWrap: 'wrap' }}>
<span
style={{
padding: '2px 8px',
borderRadius: '10px',
fontSize: '12px',
background: levelColors[course.level] || '#6b7280',
color: 'white',
}}
>
{course.level}
</span>
{course.category && (
<span
style={{
padding: '2px 8px',
borderRadius: '10px',
fontSize: '12px',
background: '#ede9fe',
color: '#7c3aed',
}}
>
{course.category}
</span>
)}
</div>
<div style={{ fontSize: '13px', color: '#6b7280', marginBottom: '8px' }}>
{course.lesson_count} lessons | {formatDuration(course.total_duration_seconds)}
{course.average_rating > 0 && ` | ${'★'.repeat(Math.round(course.average_rating))}`}
</div>
<div style={{ fontWeight: 700, color: '#7c3aed' }}>
{formatPrice(course.price_cents, course.currency)}
</div>
</div>
</button>
))}
</div>
)}
</div>
)}
{/* My Courses Tab */}
{activeTab === 'my-courses' && (
<div
id="panel-my-courses"
role="tabpanel"
aria-labelledby="tab-my-courses"
>
{courses.filter((c) => enrolledCourseIds.has(c.id)).length === 0 ? (
<div style={{ textAlign: 'center', padding: '48px', color: '#6b7280' }}>
You haven't enrolled in any courses yet
</div>
) : (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(320px, 1fr))', gap: '20px' }}>
{courses
.filter((c) => enrolledCourseIds.has(c.id))
.map((course) => (
) : (
<>
{/* Catalog Tab */}
{activeTab === 'catalog' && (
<div
id="panel-catalog"
role="tabpanel"
aria-labelledby="tab-catalog"
>
{loading ? (
<div className="text-center py-12 text-muted-foreground">
Loading courses...
</div>
) : courses.length === 0 ? (
<div className="text-center py-12 text-muted-foreground">
No courses available yet
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
{courses.map((course) => (
<button
key={course.id}
onClick={() => handleSelectCourse(course)}
aria-label={`Continue course: ${course.title}`}
style={{
border: '1px solid #e5e7eb',
borderRadius: '12px',
overflow: 'hidden',
cursor: 'pointer',
background: 'white',
textAlign: 'left',
width: '100%',
padding: 0,
}}
aria-label={`View course: ${course.title}`}
className="border border-border rounded-xl overflow-hidden cursor-pointer bg-card text-left w-full p-0 hover:border-primary/50 transition-colors"
>
<div style={{ padding: '16px' }}>
<h3 style={{ fontSize: '16px', fontWeight: 600, marginBottom: '8px' }}>
{course.cover_image_url && (
<img
src={course.cover_image_url}
alt=""
className="w-full h-[180px] object-cover"
/>
)}
<div className="p-4">
<h3 className="text-base font-semibold mb-2">
{course.title}
</h3>
<div style={{ fontSize: '13px', color: '#6b7280' }}>
<div className="flex gap-2 mb-2 flex-wrap">
<span
className={cn(
'px-2 py-0.5 rounded-full text-xs',
levelClasses[course.level] || 'bg-muted-foreground text-white',
)}
>
{course.level}
</span>
{course.category && (
<span className="px-2 py-0.5 rounded-full text-xs bg-primary/10 text-primary">
{course.category}
</span>
)}
</div>
<div className="text-xs text-muted-foreground mb-2">
{course.lesson_count} lessons | {formatDuration(course.total_duration_seconds)}
{course.average_rating > 0 && ` | ${'★'.repeat(Math.round(course.average_rating))}`}
</div>
<div className="font-bold text-primary">
{formatPrice(course.price_cents, course.currency)}
</div>
</div>
</button>
))}
</div>
)}
</div>
)}
</div>
)}
</div>
)}
{/* Certificates Tab */}
{activeTab === 'certificates' && (
<div
id="panel-certificates"
role="tabpanel"
aria-labelledby="tab-certificates"
>
{certificates.length === 0 ? (
<div style={{ textAlign: 'center', padding: '48px', color: '#6b7280' }}>
No certificates yet. Complete a course to earn one!
</div>
) : (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(320px, 1fr))', gap: '20px' }}>
{certificates.map((cert) => (
<div
key={cert.certificate_code}
style={{
border: '2px solid #7c3aed',
borderRadius: '12px',
padding: '24px',
background: 'linear-gradient(135deg, #faf5ff, #ede9fe)',
}}
>
<div style={{ fontSize: '14px', color: '#7c3aed', fontWeight: 600, marginBottom: '8px' }}>
Certificate of Completion
{/* My Courses Tab */}
{activeTab === 'my-courses' && (
<div
id="panel-my-courses"
role="tabpanel"
aria-labelledby="tab-my-courses"
>
{courses.filter((c) => enrolledCourseIds.has(c.id)).length === 0 ? (
<div className="text-center py-12 text-muted-foreground">
You haven't enrolled in any courses yet
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
{courses
.filter((c) => enrolledCourseIds.has(c.id))
.map((course) => (
<button
key={course.id}
onClick={() => handleSelectCourse(course)}
aria-label={`Continue course: ${course.title}`}
className="border border-border rounded-xl overflow-hidden cursor-pointer bg-card text-left w-full p-0 hover:border-primary/50 transition-colors"
>
<div className="p-4">
<h3 className="text-base font-semibold mb-2">
{course.title}
</h3>
<div className="text-xs text-muted-foreground">
{course.lesson_count} lessons | {formatDuration(course.total_duration_seconds)}
</div>
</div>
</button>
))}
</div>
)}
</div>
)}
{/* Certificates Tab */}
{activeTab === 'certificates' && (
<div
id="panel-certificates"
role="tabpanel"
aria-labelledby="tab-certificates"
>
{certificates.length === 0 ? (
<div className="text-center py-12 text-muted-foreground">
No certificates yet. Complete a course to earn one!
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
{certificates.map((cert) => (
<div
key={cert.certificate_code}
className="border-2 border-primary rounded-xl p-6 bg-gradient-to-br from-primary/5 to-primary/10"
>
<div className="text-sm text-primary font-semibold mb-2">
Certificate of Completion
</div>
<div className="text-xs text-muted-foreground mb-1">
Code: {cert.certificate_code}
</div>
<div className="text-xs text-muted-foreground">
Issued: {new Date(cert.issue_date).toLocaleDateString()}
</div>
</div>
<div style={{ fontSize: '12px', color: '#6b7280', marginBottom: '4px' }}>
Code: {cert.certificate_code}
</div>
<div style={{ fontSize: '12px', color: '#6b7280' }}>
Issued: {new Date(cert.issue_date).toLocaleDateString()}
</div>
</div>
))}
</div>
)}
</div>
)}
</>
)}
</div>
))}
</div>
)}
</div>
)}
</>
)}
</div>
</ContentFadeIn>
);
}

View file

@ -15,8 +15,8 @@ import { Home, ArrowLeft, Search, Library, TrendingUp } from 'lucide-react';
function NotFoundPage() {
const quickLinks = [
{ to: '/dashboard', label: 'Dashboard', icon: Home },
{ to: '/library', label: 'Ma bibliothèque', icon: Library },
{ to: '/search', label: 'Rechercher', icon: Search },
{ to: '/library', label: 'My Library', icon: Library },
{ to: '/search', label: 'Search', icon: Search },
{ to: '/marketplace', label: 'Marketplace', icon: TrendingUp },
];
@ -28,9 +28,9 @@ function NotFoundPage() {
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-muted transition-colors duration-[var(--duration-fast)]">
<Search className="h-8 w-8 text-muted-foreground" />
</div>
<CardTitle className="text-2xl font-heading font-bold tracking-tight">Page non trouvée</CardTitle>
<CardTitle className="text-2xl font-heading font-bold tracking-tight">Page Not Found</CardTitle>
<CardDescription>
La page que vous recherchez n'existe pas ou a é déplacée.
The page you're looking for doesn't exist or has been moved.
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
@ -38,8 +38,8 @@ function NotFoundPage() {
404
</div>
<p className="text-muted-foreground">
Il semble que vous ayez suivi un lien cassé ou tapé une URL
incorrecte. Voici quelques options pour continuer :
It looks like you followed a broken link or entered an incorrect URL.
Here are some options to continue:
</p>
{/* Quick Actions */}
@ -47,7 +47,7 @@ function NotFoundPage() {
<Button asChild className="flex-1">
<Link to="/dashboard">
<Home className="mr-2 h-4 w-4" />
Retour au dashboard
Back to Dashboard
</Link>
</Button>
<Button
@ -56,14 +56,14 @@ function NotFoundPage() {
className="flex-1"
>
<ArrowLeft className="mr-2 h-4 w-4" />
Page précédente
Previous Page
</Button>
</div>
{/* Quick Links */}
<div className="border-t pt-4">
<p className="text-sm font-medium text-foreground mb-3">
Liens rapides :
Quick links:
</p>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2">
{quickLinks.map((link) => {
@ -89,13 +89,13 @@ function NotFoundPage() {
{/* Helpful Suggestions */}
<div className="border-t pt-4 text-left">
<p className="text-sm font-medium text-foreground mb-2">
Suggestions :
Suggestions:
</p>
<ul className="text-sm text-muted-foreground space-y-1 list-disc list-inside">
<li>Vérifiez l'orthographe de l'URL</li>
<li>Utilisez la recherche pour trouver ce que vous cherchez</li>
<li>Consultez votre bibliothèque ou le marketplace</li>
<li>Contactez le support si le problème persiste</li>
<li>Check the URL for typos</li>
<li>Use search to find what you're looking for</li>
<li>Browse your library or the marketplace</li>
<li>Contact support if the problem persists</li>
</ul>
</div>
</CardContent>

View file

@ -8,7 +8,7 @@ import { Link } from 'react-router-dom';
import { Avatar } from '@/components/ui/avatar';
import { getSuggestions } from '@/features/profile/services/profileService';
import { FollowButton } from '@/features/profile/components/FollowButton';
import { UserPlus, Loader2 } from 'lucide-react';
import { UserPlus, Loader2, ChevronRight } from 'lucide-react';
export function SuggestionsWidget() {
const { data, isLoading } = useQuery({
@ -19,13 +19,15 @@ export function SuggestionsWidget() {
if (isLoading) {
return (
<div className="rounded-xl border border-border bg-card p-4">
<h3 className="text-sm font-semibold text-foreground mb-3 flex items-center gap-2">
<UserPlus className="w-4 h-4" />
Comptes à suivre
</h3>
<div className="flex items-center justify-center py-8">
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
<div className="rounded-2xl border border-border bg-[var(--sumi-surface-subtle)] overflow-hidden">
<div className="p-4 pb-3">
<h3 className="text-sm font-semibold text-foreground flex items-center gap-2">
<UserPlus className="w-4 h-4 text-primary" />
Suggested Accounts
</h3>
</div>
<div className="flex items-center justify-center py-10 px-4">
<Loader2 className="w-5 h-5 animate-spin text-muted-foreground" />
</div>
</div>
);
@ -35,16 +37,21 @@ export function SuggestionsWidget() {
if (suggestions.length === 0) return null;
return (
<div className="rounded-xl border border-border bg-card p-4">
<h3 className="text-sm font-semibold text-foreground mb-3 flex items-center gap-2">
<UserPlus className="w-4 h-4" />
Comptes à suivre
</h3>
<ul className="space-y-3">
<div className="rounded-2xl border border-border bg-[var(--sumi-surface-subtle)] overflow-hidden">
{/* Header with subtle gradient top accent */}
<div className="relative p-4 pb-3">
<div className="absolute inset-x-0 top-0 h-0.5 bg-gradient-to-r from-transparent via-primary/40 to-transparent" />
<h3 className="text-sm font-semibold text-foreground flex items-center gap-2">
<UserPlus className="w-4 h-4 text-primary" />
Suggested Accounts
</h3>
</div>
<ul className="px-2 pb-2 space-y-0.5">
{suggestions.map((user) => (
<li
key={user.id}
className="flex items-center gap-3 p-2 rounded-lg hover:bg-muted/50 transition-colors"
className="flex items-center gap-3 p-2.5 rounded-xl hover:bg-[var(--sumi-bg-hover)] transition-all duration-[var(--sumi-duration-fast)] group"
>
<Link to={`/u/${user.username}`} className="flex items-center gap-3 flex-1 min-w-0">
<Avatar
@ -52,12 +59,12 @@ export function SuggestionsWidget() {
alt={user.username}
fallback={user.username.slice(0, 2).toUpperCase()}
size="sm"
className="flex-shrink-0"
className="flex-shrink-0 ring-2 ring-background group-hover:ring-primary/20 transition-all duration-[var(--sumi-duration-fast)]"
/>
<div className="min-w-0 flex-1">
<p className="font-medium text-foreground truncate">@{user.username}</p>
<p className="text-xs text-muted-foreground">
{user.followers_count.toLocaleString()} abonnés
<p className="text-sm font-medium text-foreground truncate">@{user.username}</p>
<p className="text-xs text-muted-foreground/80">
{(user.followers_count ?? 0).toLocaleString()} followers
</p>
</div>
</Link>
@ -65,6 +72,17 @@ export function SuggestionsWidget() {
</li>
))}
</ul>
{/* See all link */}
<div className="px-4 py-3 border-t border-[var(--sumi-border-faint)]">
<Link
to="/social"
className="text-xs font-medium text-muted-foreground hover:text-primary transition-colors duration-[var(--sumi-duration-fast)] flex items-center gap-1 group/link"
>
See all
<ChevronRight className="w-3 h-3 group-hover/link:translate-x-0.5 transition-transform duration-[var(--sumi-duration-fast)]" />
</Link>
</div>
</div>
);
}

View file

@ -4,18 +4,22 @@
*/
import { useEffect, useCallback, useRef } from 'react';
import { useNavigate } from 'react-router-dom';
import { useInfiniteQuery } from '@tanstack/react-query';
import { useAudio } from '@/context/AudioContext';
import { usePlayerStore } from '@/features/player/store/playerStore';
import { ContentFadeIn } from '@/components/ui/ContentFadeIn';
import { TrackGrid } from '@/features/tracks/components/TrackGrid';
import { TrackCardSkeleton } from '@/features/tracks/components/TrackCardSkeleton';
import { ErrorDisplay } from '@/components/ui/ErrorDisplay';
import { SuggestionsWidget } from '../components/SuggestionsWidget';
import { feedService } from '@/services/feedService';
import { Music2, Loader2 } from 'lucide-react';
import { Music2, Loader2, Disc3, Sparkles } from 'lucide-react';
import { useTranslation } from 'react-i18next';
export function FeedPage() {
const { playTrack } = useAudio();
const { t } = useTranslation();
const play = usePlayerStore((s) => s.play);
const navigate = useNavigate();
const loadMoreRef = useRef<HTMLDivElement>(null);
const {
@ -59,19 +63,22 @@ export function FeedPage() {
const handlePlay = useCallback(
(track: { id: string; title: string; artist?: string; duration: number; url: string; cover?: string }) => {
playTrack?.(track);
play(track);
},
[playTrack],
[play],
);
const handleTrackClick = useCallback(
(track: { id: string }) => {
navigate(`/tracks/${track.id}`);
},
[navigate],
);
if (isLoading) {
return (
<ContentFadeIn className="min-h-layout-page pb-24">
<div className="space-y-6">
<div className="flex items-center gap-3">
<Music2 className="w-8 h-8 text-primary" />
<h1 className="text-2xl font-heading font-bold">Feed</h1>
</div>
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
{Array.from({ length: 8 }).map((_, i) => (
<TrackCardSkeleton key={i} />
@ -85,7 +92,7 @@ export function FeedPage() {
if (error) {
return (
<ContentFadeIn className="min-h-layout-page">
<ErrorDisplay error={error} variant="card" onRetry={() => refetch()} />
<ErrorDisplay error={error} variant="card" onRetry={() => { refetch(); }} />
</ContentFadeIn>
);
}
@ -93,56 +100,80 @@ export function FeedPage() {
return (
<ContentFadeIn className="min-h-layout-page pb-24">
<div className="grid grid-cols-1 lg:grid-cols-[1fr_20rem] gap-8">
<div className="space-y-6 min-w-0">
<div className="space-y-8 min-w-0">
{/* Page header */}
<div className="flex items-center gap-3">
<Music2 className="w-8 h-8 text-primary" />
<h1 className="text-2xl font-heading font-bold">Feed</h1>
<div className="w-10 h-10 rounded-2xl bg-gradient-to-br from-primary/30 to-primary/10 flex items-center justify-center">
<Music2 className="w-5 h-5 text-primary" />
</div>
<div>
<h1 className="text-2xl font-heading tracking-tight" style={{ fontWeight: 300 }}>{t('feed.title')}</h1>
<p className="text-xs text-muted-foreground/40 mt-0.5 font-heading" style={{ fontWeight: 300 }}>{t('feed.subtitle')}</p>
</div>
</div>
{tracks.length === 0 ? (
<div className="flex flex-col items-center justify-center py-24 gap-4 text-center">
<Music2 className="w-16 h-16 text-muted-foreground/50" />
<p className="text-muted-foreground max-w-sm">
Suivez des artistes pour voir leurs nouveaux morceaux ici.
</p>
<div className="flex flex-col items-center justify-center py-20 gap-5 text-center">
<div className="relative">
<div className="w-24 h-24 rounded-full bg-primary/5 flex items-center justify-center">
<Disc3 className="w-12 h-12 text-muted-foreground/30" />
</div>
<div className="absolute inset-0 rounded-full animate-ping bg-primary/5 pointer-events-none" style={{ animationDuration: '3s' }} />
</div>
<div className="space-y-2">
<p className="text-foreground font-medium">{t('feed.emptyTitle')}</p>
<p className="text-sm text-muted-foreground max-w-xs">
{t('feed.emptyDescription')}
</p>
</div>
</div>
) : (
<>
{byGenres?.items && byGenres.items.length > 0 && (
<section className="space-y-3">
<h2 className="text-lg font-heading font-semibold">
Nouvelles sorties dans vos genres
</h2>
<section className="space-y-4">
<div className="flex items-center gap-2">
<Sparkles className="w-4 h-4 text-warning" />
<h2 className="text-lg font-heading font-semibold tracking-tight">
{t('feed.newReleasesInGenres')}
</h2>
</div>
<TrackGrid
tracks={byGenres.items}
emptyMessage=""
onTrackPlay={handlePlay}
onTrackClick={handleTrackClick}
gap="md"
/>
<div className="section-divider" />
</section>
)}
<section className="space-y-3">
<h2 className="text-lg font-heading font-semibold">
<section className="space-y-4">
<h2 className="text-lg font-heading font-semibold tracking-tight">
{byGenres?.items && byGenres.items.length > 0
? 'Artistes suivis'
: 'Feed'}
? t('feed.followedArtists')
: t('feed.subtitle')}
</h2>
<TrackGrid
tracks={tracks}
emptyMessage="Aucun nouveau morceau"
emptyMessage="No new tracks"
onTrackPlay={handlePlay}
onTrackClick={handleTrackClick}
gap="md"
/>
</section>
<div ref={loadMoreRef} className="flex justify-center py-8">
<div ref={loadMoreRef} className="flex justify-center py-6">
{isFetchingNextPage && (
<Loader2 className="w-8 h-8 animate-spin text-muted-foreground" />
<div className="flex items-center gap-2 text-muted-foreground">
<Loader2 className="w-5 h-5 animate-spin" />
<span className="text-xs">{t('common.loading')}</span>
</div>
)}
</div>
</>
)}
</div>
<aside className="hidden lg:block">
<div className="sticky top-24">
<div className="sticky top-24 space-y-4">
<SuggestionsWidget />
</div>
</aside>

View file

@ -119,7 +119,7 @@ export function useLibraryManager(
artist: track.artist,
album: t.album,
duration: track.duration,
url: t.stream_manifest_url ?? track.file_path,
url: t.stream_manifest_url || `/api/v1/tracks/${track.id}/download`,
cover: t.cover_art_path,
genre: t.genre,
};

View file

@ -1,4 +1,5 @@
import { motion } from 'framer-motion';
import { Radio } from 'lucide-react';
import { Card } from '@/components/ui/card';
interface LiveViewRecommendedProps {
@ -25,11 +26,9 @@ export function LiveViewRecommended({ onChannelClick }: LiveViewRecommendedProps
onClick={() => onChannelClick?.(i)}
>
<div className="aspect-video relative">
<img
src={`https://picsum.photos/300/200?random=${i}`}
alt=""
className="w-full h-full object-cover"
/>
<div className="w-full h-full bg-gradient-to-br from-[var(--sumi-bg-overlay)] to-[var(--sumi-surface-inset)] flex items-center justify-center">
<Radio className="w-8 h-8 text-muted-foreground/30" />
</div>
<div className="absolute bottom-2 left-2 bg-background/80 px-2 py-0.5 rounded text-xs text-foreground">
DJ Set
</div>

View file

@ -24,11 +24,9 @@ export function LiveViewStreamInfo({
<div className="flex justify-between items-start">
<div className="flex gap-4">
<div className="w-12 h-12 rounded-full bg-gradient-neon p-0.5">
<img
src="https://picsum.photos/100/100"
alt=""
className="w-full h-full rounded-full object-cover border-2 border-border"
/>
<div className="w-full h-full rounded-full bg-[var(--sumi-surface-inset)] border-2 border-border flex items-center justify-center">
<span className="text-xs font-bold text-muted-foreground">DJ</span>
</div>
</div>
<div>
<h1 className="text-3xl font-bold text-foreground tracking-tight">{stream.title}</h1>

View file

@ -74,22 +74,25 @@ function PlaylistCardComponent({
>
<CardContent className="p-0">
{/* Cover Image */}
<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">
<div className="relative aspect-square bg-gradient-to-br from-primary/20 to-sumi-vermillion/10 overflow-hidden rounded-lg m-3 mb-0">
{playlist.cover_url ? (
<img
src={playlist.cover_url}
alt={`Couverture de la playlist ${playlist.title}`}
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-[var(--sumi-duration-normal)]"
alt={`Cover for playlist ${playlist.title}`}
className="w-full h-full object-cover group-hover:scale-[1.04] transition-transform duration-[var(--sumi-duration-slow)] ease-[var(--sumi-ease-out)]"
loading="lazy"
/>
) : (
<div
className="w-full h-full flex items-center justify-center"
role="img"
aria-label={`Pas de couverture pour la playlist ${playlist.title}`}
aria-label={`No cover for playlist ${playlist.title}`}
>
<Music className="w-16 h-16 text-white/50" aria-hidden="true" />
<Music className="w-12 h-12 text-white/30" aria-hidden="true" />
</div>
)}
{/* Inner border for depth */}
<div className="absolute inset-0 rounded-lg shadow-[inset_0_0_0_1px_rgba(255,255,255,0.06)] pointer-events-none" />
{/* Selection Checkbox */}
{selectable && (
<div className="absolute top-2 left-2 z-10">
@ -106,8 +109,8 @@ function PlaylistCardComponent({
)}
aria-label={
selected
? `Désélectionner ${playlist.title}`
: `Sélectionner ${playlist.title}`
? `Deselect ${playlist.title}`
: `Select ${playlist.title}`
}
aria-checked={selected}
role="checkbox"
@ -121,7 +124,7 @@ function PlaylistCardComponent({
{playlist.is_public ? (
<div
className="bg-success/80 text-foreground px-2 py-1 rounded-full text-xs flex items-center gap-1"
aria-label="Playlist publique"
aria-label="Public playlist"
>
<Users className="w-3 h-3" aria-hidden="true" />
Public
@ -129,10 +132,10 @@ function PlaylistCardComponent({
) : (
<div
className="bg-muted/80 text-foreground px-2 py-1 rounded-full text-xs flex items-center gap-1"
aria-label="Playlist privée"
aria-label="Private playlist"
>
<Lock className="w-3 h-3" aria-hidden="true" />
Privé
Private
</div>
)}
</div>
@ -162,9 +165,9 @@ function PlaylistCardComponent({
{playlist.user && (
<span
className="truncate sm:ml-2"
aria-label={`Créée par ${playlist.user.username}`}
aria-label={`Created by ${playlist.user.username}`}
>
par {playlist.user.username}
by {playlist.user.username}
</span>
)}
</div>
@ -181,7 +184,7 @@ function PlaylistCardComponent({
type="button"
onClick={handleClick}
onKeyDown={handleKeyDown}
aria-label={`${selected ? 'Désélectionner' : 'Sélectionner'} la playlist ${playlist.title}`}
aria-label={`${selected ? 'Deselect' : 'Select'} playlist ${playlist.title}`}
className="appearance-none bg-transparent border-0 p-0 text-left w-full touch-manipulation rounded-xl focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
>
{cardContent}
@ -196,7 +199,7 @@ function PlaylistCardComponent({
to={`/playlists/${playlist.id}`}
onClick={handleClick}
onKeyDown={handleKeyDown}
aria-label={`Voir la playlist ${playlist.title}`}
aria-label={`View playlist ${playlist.title}`}
className="touch-manipulation rounded-xl focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
>
{cardContent}

View file

@ -18,13 +18,19 @@ export function usePresenceSync(enabled: boolean) {
if (trackId === prevTrackIdRef.current) return;
prevTrackIdRef.current = trackId;
// Build payload — omit fields rather than sending undefined
// (undefined serializes incorrectly and causes "invalid request body" on backend)
const payload = currentTrack
? {
status_message: `Écoute ${currentTrack.title}`,
track_id: currentTrack.id,
track_title: currentTrack.title,
}
: { status_message: undefined, track_id: null, track_title: null };
: {
status_message: '',
track_id: null as string | null,
track_title: null as string | null,
};
updatePresence(payload).catch(() => {
// Silently ignore - presence update is best-effort

View file

@ -38,5 +38,7 @@ export async function getPresence(userId: string): Promise<UserPresence> {
export async function updatePresence(
payload: UpdatePresencePayload,
): Promise<void> {
await apiClient.put('/users/me/presence', payload);
await apiClient.put('/users/me/presence', payload, {
_disableToast: true,
} as Record<string, unknown>);
}

View file

@ -1,4 +1,5 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Card } from '@/components/ui/card';
import { Music, User, Sparkles, History } from 'lucide-react';
import { useSearchHistory } from '../../hooks/useSearchHistory';
@ -8,6 +9,7 @@ interface SearchPageDiscoveryProps {
}
export function SearchPageDiscovery({ onQuerySelect }: SearchPageDiscoveryProps) {
const navigate = useNavigate();
const { getHistory, clearHistory } = useSearchHistory();
const [, setRefreshKey] = useState(0);
const history = getHistory();
@ -24,14 +26,14 @@ export function SearchPageDiscovery({ onQuerySelect }: SearchPageDiscoveryProps)
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-bold flex items-center gap-2">
<History className="w-5 h-5 text-muted-foreground" />
Recherches récentes
Recent Searches
</h3>
<button
type="button"
onClick={handleClear}
className="text-sm text-muted-foreground hover:text-foreground"
>
Effacer
Clear
</button>
</div>
<div className="flex flex-wrap gap-2">
@ -40,7 +42,7 @@ export function SearchPageDiscovery({ onQuerySelect }: SearchPageDiscoveryProps)
key={q}
type="button"
onClick={() => onQuerySelect?.(q)}
className="px-4 py-2 rounded-full bg-muted/50 hover:bg-muted text-sm"
className="px-4 py-2 rounded-full bg-[var(--sumi-surface-subtle)] hover:bg-[var(--sumi-bg-hover)] text-sm text-muted-foreground hover:text-foreground transition-all duration-[var(--sumi-duration-fast)] border border-transparent hover:border-[var(--sumi-border-faint)]"
>
{q}
</button>
@ -48,36 +50,42 @@ export function SearchPageDiscovery({ onQuerySelect }: SearchPageDiscoveryProps)
</div>
</section>
)}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 opacity-80">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<Card
variant="glass"
className="p-6 text-center hover:bg-white/5 transition-colors cursor-pointer group"
className="p-6 text-center hover:bg-[var(--sumi-bg-hover)] hover-lift transition-all duration-[var(--sumi-duration-normal)] cursor-pointer group animate-card-enter"
style={{ animationDelay: '100ms' }}
onClick={() => navigate('/feed')}
>
<div className="w-12 h-12 rounded-full bg-primary/10 flex items-center justify-center mx-auto mb-4 group-hover:bg-primary/20 transition-colors">
<div className="w-14 h-14 rounded-2xl bg-primary/10 flex items-center justify-center mx-auto mb-4 group-hover:bg-primary/15 group-hover:scale-110 transition-all duration-[var(--sumi-duration-normal)]">
<Music className="w-6 h-6 text-primary" />
</div>
<h3 className="font-bold text-lg mb-1">New Releases</h3>
<p className="text-xs text-muted-foreground">Fresh signals from the void</p>
<h3 className="font-bold text-base mb-1 tracking-tight">New Releases</h3>
<p className="text-xs text-muted-foreground/70">Latest tracks from your artists</p>
</Card>
<Card
variant="glass"
className="p-6 text-center hover:bg-white/5 transition-colors cursor-pointer group"
className="p-6 text-center hover:bg-[var(--sumi-bg-hover)] hover-lift transition-all duration-[var(--sumi-duration-normal)] cursor-pointer group animate-card-enter"
style={{ animationDelay: '180ms' }}
onClick={() => navigate('/discover')}
>
<div className="w-12 h-12 rounded-full bg-destructive/10 flex items-center justify-center mx-auto mb-4 group-hover:bg-destructive/20 transition-colors">
<div className="w-14 h-14 rounded-2xl bg-destructive/10 flex items-center justify-center mx-auto mb-4 group-hover:bg-destructive/15 group-hover:scale-110 transition-all duration-[var(--sumi-duration-normal)]">
<Sparkles className="w-6 h-6 text-destructive" />
</div>
<h3 className="font-bold text-lg mb-1">Curated Mixes</h3>
<p className="text-xs text-muted-foreground">Hand-picked by the algorithm</p>
<h3 className="font-bold text-base mb-1 tracking-tight">Curated Mixes</h3>
<p className="text-xs text-muted-foreground/70">Handpicked selections for you</p>
</Card>
<Card
variant="glass"
className="p-6 text-center hover:bg-white/5 transition-colors cursor-pointer group"
className="p-6 text-center hover:bg-[var(--sumi-bg-hover)] hover-lift transition-all duration-[var(--sumi-duration-normal)] cursor-pointer group animate-card-enter"
style={{ animationDelay: '260ms' }}
onClick={() => navigate('/social')}
>
<div className="w-12 h-12 rounded-full bg-success/10 flex items-center justify-center mx-auto mb-4 group-hover:bg-success/20 transition-colors">
<div className="w-14 h-14 rounded-2xl bg-success/10 flex items-center justify-center mx-auto mb-4 group-hover:bg-success/15 group-hover:scale-110 transition-all duration-[var(--sumi-duration-normal)]">
<User className="w-6 h-6 text-success" />
</div>
<h3 className="font-bold text-lg mb-1">Top Artists</h3>
<p className="text-xs text-muted-foreground">Trending creators this week</p>
<h3 className="font-bold text-base mb-1 tracking-tight">Top Artists</h3>
<p className="text-xs text-muted-foreground/70">Trending creators this week</p>
</Card>
</div>
</div>

View file

@ -80,25 +80,22 @@ export function SearchPageHeader({
}
return (
<div className="mb-12 text-center max-w-3xl mx-auto">
<h1 className="text-display md:text-5xl font-heading mb-6 text-foreground">
Explore the{' '}
<span className="text-transparent bg-clip-text bg-gradient-to-r from-primary to-secondary">
Nebula
</span>
<div className="mb-12 text-center max-w-3xl mx-auto animate-content-reveal">
<h1 className="text-display md:text-5xl font-heading mb-8 text-foreground tracking-tight">
Search
</h1>
<div className="relative group" ref={dropdownRef}>
<div className="absolute -inset-1 bg-gradient-to-r from-primary to-secondary rounded-2xl blur opacity-20 group-hover:opacity-40 transition duration-[var(--sumi-duration-slow)]" />
<div className="relative flex flex-col bg-card/80 backdrop-blur-xl border border-border rounded-2xl overflow-hidden shadow-2xl">
<div className="absolute -inset-1 bg-gradient-to-r from-primary/60 via-info/40 to-primary/60 rounded-2xl blur-lg opacity-15 group-hover:opacity-30 transition-opacity duration-[var(--sumi-duration-slow)]" />
<div className="relative flex flex-col bg-[var(--sumi-glass-bg)] backdrop-blur-xl border border-[var(--sumi-glass-border)] rounded-2xl overflow-hidden shadow-2xl">
<div className="flex items-center">
<Search className="w-5 h-5 ml-4 text-muted-foreground flex-shrink-0" />
<Search className="w-5 h-5 ml-5 text-muted-foreground/60 flex-shrink-0 transition-colors duration-[var(--sumi-duration-fast)] group-focus-within:text-primary" />
<input
ref={inputRef}
value={query}
onChange={(e) => onQueryChange(e.target.value)}
onFocus={() => suggestions && setShowDropdown(true)}
placeholder="Search for tracks, artists, signals..."
className="w-full bg-transparent border-none py-4 px-4 text-lg text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-0 font-sans"
placeholder="Search for tracks, artists, playlists..."
className="w-full bg-transparent border-none py-4 px-4 text-lg text-foreground placeholder:text-muted-foreground/40 focus:outline-none focus:ring-0 font-sans"
autoFocus
aria-label="Search"
aria-expanded={showDropdown}
@ -106,7 +103,7 @@ export function SearchPageHeader({
role="combobox"
/>
<HelpText
text='Utilisez AND, OR, NOT et "phrase exacte" pour affiner votre recherche.'
text='Use AND, OR, NOT and "exact phrase" to refine your search.'
position="bottom"
className="mr-2"
/>
@ -114,7 +111,7 @@ export function SearchPageHeader({
<button
type="button"
onClick={onClear}
className="p-2 mr-2 hover:bg-muted/50 rounded-full transition-colors duration-[var(--duration-fast)] text-muted-foreground hover:text-foreground"
className="p-2 mr-2 hover:bg-[var(--sumi-bg-hover)] rounded-full transition-colors duration-[var(--sumi-duration-fast)] text-muted-foreground hover:text-foreground"
aria-label="Clear search"
>
<X className="w-5 h-5" />
@ -123,14 +120,14 @@ export function SearchPageHeader({
</div>
{showDropdown && suggestionItems.length > 0 && (
<ul
className="border-t border-border bg-card py-2 max-h-60 overflow-y-auto"
className="border-t border-[var(--sumi-border-faint)] bg-[var(--sumi-surface-card)] py-1.5 max-h-60 overflow-y-auto"
role="listbox"
>
{suggestionItems.slice(0, 10).map((item, i) => (
<li key={`${item.text}-${i}`}>
<button
type="button"
className="w-full text-left py-2 px-4 hover:bg-muted/50 text-foreground"
className="w-full text-left py-2.5 px-5 hover:bg-[var(--sumi-bg-hover)] text-foreground text-sm transition-colors duration-[var(--sumi-duration-fast)]"
onClick={() => pickSuggestion(item.text)}
role="option"
>

View file

@ -166,10 +166,10 @@ export function SettingsPage() {
<div className="mb-8 flex items-end justify-between">
<div>
<h1 className="text-display font-heading text-foreground mb-2">System Config</h1>
<h1 className="text-display font-heading text-foreground mb-2">Settings</h1>
<p className="text-muted-foreground flex items-center gap-2">
<Shield className="w-4 h-4 text-primary" />
Manage your neural link and interface preferences.
Manage your account and interface preferences.
</p>
</div>
<Button onClick={handleSave} disabled={isSaving} className="shadow-glow-cyan min-w-36" size="lg">

View file

@ -16,7 +16,7 @@ export function SocialView({ onViewProfile }: SocialViewProps) {
}
return (
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8 animate-fadeIn pb-20 min-h-layout-page">
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8 animate-fadeIn pb-24 min-h-layout-page">
<SocialViewSidebar
activeTab={activeTab}
onTabChange={(t) => setActiveTab(t as SocialTabKey)}

View file

@ -60,7 +60,7 @@ export function SocialViewFeedItem({ item, onPlay }: SocialViewFeedItemProps) {
} catch {
setIsLiked(prevLiked);
setLikeCount(prevCount);
showError('Erreur lors du like');
showError('Failed to like');
}
}, [isLiked, likeCount, targetId, targetType, showError]);
@ -76,11 +76,11 @@ export function SocialViewFeedItem({ item, onPlay }: SocialViewFeedItemProps) {
setIsSubmittingComment(true);
try {
await socialService.postComment(targetId, content, targetType);
showSuccess('Commentaire ajouté');
showSuccess('Comment added');
setCommentText('');
setShowCommentForm(false);
} catch {
showError('Erreur lors de l\'envoi du commentaire');
showError('Failed to post comment');
} finally {
setIsSubmittingComment(false);
}
@ -205,7 +205,7 @@ export function SocialViewFeedItem({ item, onPlay }: SocialViewFeedItemProps) {
type="text"
value={commentText}
onChange={(e) => setCommentText(e.target.value)}
placeholder="Ajouter un commentaire..."
placeholder="Add a comment..."
className="flex-1 rounded-lg border border-border bg-background px-3 py-2 text-sm placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring"
disabled={isSubmittingComment}
maxLength={2000}
@ -217,7 +217,7 @@ export function SocialViewFeedItem({ item, onPlay }: SocialViewFeedItemProps) {
className="gap-1.5"
>
<Send className="w-3.5 h-3.5" />
Envoyer
Send
</Button>
</form>
)}

View file

@ -2,6 +2,7 @@ import { Card } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Compass, TrendingUp, Users } from 'lucide-react';
import type { SocialTabKey } from './types';
import { useUser } from '@/features/auth/hooks/useUser';
interface SocialViewSidebarProps {
activeTab: SocialTabKey;
@ -14,6 +15,7 @@ export function SocialViewSidebar({
onTabChange,
onProfileClick,
}: SocialViewSidebarProps) {
const { data: user } = useUser();
return (
<div className="hidden lg:block lg:col-span-3 space-y-8">
<Card variant="glass" className="p-0 overflow-hidden border-white/5 bg-black/20 backdrop-blur-xl transition-shadow duration-[var(--sumi-duration-normal)]">
@ -26,7 +28,7 @@ export function SocialViewSidebar({
>
<div className="w-20 h-20 rounded-full border-4 border-border overflow-hidden bg-muted">
<img
src="https://picsum.photos/id/237/200/200"
src={user?.avatar_url || ''}
alt=""
className="w-full h-full object-cover"
/>

View file

@ -2,7 +2,7 @@ import { Skeleton } from '@/components/ui/skeleton';
export function SocialViewSkeleton() {
return (
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8 animate-fadeIn pb-20 min-h-layout-page">
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8 animate-fadeIn pb-24 min-h-layout-page">
<div className="hidden lg:block lg:col-span-3 space-y-8">
<Skeleton className="h-48 rounded-xl" />
<Skeleton className="h-32 rounded-xl" />

View file

@ -1,5 +1,6 @@
import React, { useEffect, useState, useCallback } from 'react';
import { subscriptionService } from '@/services/subscriptionService';
import { ContentFadeIn } from '@/components/ui/ContentFadeIn';
import type {
SubscriptionPlan,
UserSubscription,
@ -129,8 +130,8 @@ export function SubscriptionPage(): React.ReactElement {
if (loading) {
return (
<div className="flex items-center justify-center min-h-[400px]">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-purple-500" />
<div className="flex items-center justify-center min-h-layout-page pb-24">
<div className="animate-spin rounded-full h-8 w-8 border-2 border-primary border-t-transparent" />
</div>
);
}
@ -138,16 +139,17 @@ export function SubscriptionPage(): React.ReactElement {
const currentPlanName = currentSub?.plan?.name ?? 'free';
return (
<div className="max-w-6xl mx-auto px-4 py-8">
<h1 className="text-3xl font-bold mb-2">Subscription Plans</h1>
<p className="text-gray-400 mb-8">
<ContentFadeIn className="min-h-layout-page pb-24">
<div className="max-w-6xl mx-auto px-4 md:px-8 py-8">
<h1 className="text-3xl font-bold mb-2 text-foreground">Subscription Plans</h1>
<p className="text-muted-foreground mb-8">
Choose the plan that fits your creative journey. No hidden fees, cancel
anytime.
</p>
{error && (
<div
className="bg-red-900/30 border border-red-500 text-red-300 px-4 py-3 rounded mb-6"
className="bg-destructive/10 border border-destructive/30 text-destructive rounded-lg px-4 py-3 mb-6"
role="alert"
aria-live="assertive"
>
@ -157,7 +159,7 @@ export function SubscriptionPage(): React.ReactElement {
{successMessage && (
<div
className="bg-green-900/30 border border-green-500 text-green-300 px-4 py-3 rounded mb-6"
className="bg-success/10 border border-success/30 text-success rounded-lg px-4 py-3 mb-6"
role="status"
aria-live="polite"
>
@ -167,19 +169,19 @@ export function SubscriptionPage(): React.ReactElement {
{/* Current subscription status */}
{currentSub && (
<div className="bg-gray-800 rounded-lg p-6 mb-8 border border-gray-700">
<h2 className="text-lg font-semibold mb-2">Current Subscription</h2>
<div className="bg-card/80 backdrop-blur-xl rounded-xl p-6 mb-8 border border-border">
<h2 className="text-lg font-semibold mb-2 text-foreground">Current Subscription</h2>
<div className="flex flex-wrap gap-4 items-center">
<span className="text-purple-400 font-medium">
<span className="text-primary font-medium">
{currentSub.plan.display_name}
</span>
<span
className={`px-2 py-1 rounded text-xs font-medium ${
currentSub.status === 'active'
? 'bg-green-900/50 text-green-300'
? 'bg-success/20 text-success'
: currentSub.status === 'trialing'
? 'bg-blue-900/50 text-blue-300'
: 'bg-yellow-900/50 text-yellow-300'
? 'bg-muted/50 text-muted-foreground'
: 'bg-warning/20 text-warning'
}`}
>
{currentSub.status === 'trialing'
@ -187,7 +189,7 @@ export function SubscriptionPage(): React.ReactElement {
: currentSub.status}
</span>
{currentSub.cancel_at_period_end && (
<span className="text-yellow-400 text-sm">
<span className="text-warning text-sm">
Cancels on {formatDate(currentSub.current_period_end)}
</span>
)}
@ -197,7 +199,7 @@ export function SubscriptionPage(): React.ReactElement {
<button
onClick={handleReactivate}
disabled={actionLoading}
className="px-4 py-2 bg-purple-600 hover:bg-purple-700 rounded text-sm font-medium disabled:opacity-50"
className="px-4 py-2 bg-primary hover:bg-primary/90 text-primary-foreground rounded-lg text-sm font-medium disabled:opacity-50 transition-colors"
aria-label="Reactivate subscription"
>
{actionLoading ? 'Processing...' : 'Reactivate'}
@ -207,7 +209,7 @@ export function SubscriptionPage(): React.ReactElement {
<button
onClick={handleCancel}
disabled={actionLoading}
className="px-4 py-2 bg-gray-600 hover:bg-gray-700 rounded text-sm font-medium disabled:opacity-50"
className="px-4 py-2 bg-muted hover:bg-muted/80 text-foreground rounded-lg text-sm font-medium disabled:opacity-50 transition-colors"
aria-label="Cancel subscription"
>
{actionLoading ? 'Processing...' : 'Cancel Subscription'}
@ -221,7 +223,7 @@ export function SubscriptionPage(): React.ReactElement {
{/* Billing cycle toggle */}
<div className="flex justify-center mb-8">
<div
className="inline-flex bg-gray-800 rounded-lg p-1"
className="inline-flex bg-muted rounded-xl p-1 border border-border"
role="radiogroup"
aria-label="Billing cycle"
>
@ -229,10 +231,10 @@ export function SubscriptionPage(): React.ReactElement {
role="radio"
aria-checked={billingCycle === 'monthly'}
onClick={() => handleCycleChange('monthly')}
className={`px-4 py-2 rounded-md text-sm font-medium transition ${
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
billingCycle === 'monthly'
? 'bg-purple-600 text-white'
: 'text-gray-400 hover:text-white'
? 'bg-primary text-primary-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground'
}`}
>
Monthly
@ -241,14 +243,14 @@ export function SubscriptionPage(): React.ReactElement {
role="radio"
aria-checked={billingCycle === 'yearly'}
onClick={() => handleCycleChange('yearly')}
className={`px-4 py-2 rounded-md text-sm font-medium transition ${
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
billingCycle === 'yearly'
? 'bg-purple-600 text-white'
: 'text-gray-400 hover:text-white'
? 'bg-primary text-primary-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground'
}`}
>
Yearly
<span className="ml-1 text-xs text-green-400">(Save 17%)</span>
<span className="ml-1 text-xs text-success">(Save 17%)</span>
</button>
</div>
</div>
@ -265,32 +267,32 @@ export function SubscriptionPage(): React.ReactElement {
return (
<div
key={plan.id}
className={`bg-gray-800 rounded-xl p-6 border ${
className={`bg-card/80 backdrop-blur-xl rounded-xl p-6 border ${
plan.name === 'premium'
? 'border-purple-500 ring-1 ring-purple-500/50'
: 'border-gray-700'
? 'border-primary ring-1 ring-primary/50'
: 'border-border'
} flex flex-col`}
>
{plan.name === 'premium' && (
<div className="text-center mb-3">
<span className="bg-purple-600 text-xs font-bold px-3 py-1 rounded-full">
<span className="bg-primary text-primary-foreground text-xs font-bold px-3 py-1 rounded-full">
MOST POPULAR
</span>
</div>
)}
<h3 className="text-xl font-bold mb-1">{plan.display_name}</h3>
<p className="text-gray-400 text-sm mb-4">{plan.description}</p>
<h3 className="text-xl font-bold mb-1 text-foreground">{plan.display_name}</h3>
<p className="text-muted-foreground text-sm mb-4">{plan.description}</p>
<div className="mb-6">
{price === 0 ? (
<span className="text-3xl font-bold">Free</span>
<span className="text-3xl font-bold text-foreground">Free</span>
) : (
<>
<span className="text-3xl font-bold">
<span className="text-3xl font-bold text-foreground">
{formatCents(price, plan.currency)}
</span>
<span className="text-gray-400 text-sm">
<span className="text-muted-foreground text-sm">
/{billingCycle === 'yearly' ? 'year' : 'month'}
</span>
</>
@ -298,7 +300,7 @@ export function SubscriptionPage(): React.ReactElement {
</div>
{plan.trial_days > 0 && (
<p className="text-blue-400 text-sm mb-4">
<p className="text-primary text-sm mb-4">
{plan.trial_days}-day free trial
</p>
)}
@ -331,12 +333,12 @@ export function SubscriptionPage(): React.ReactElement {
<button
onClick={() => handleSubscribe(plan.id)}
disabled={isCurrentPlan || actionLoading}
className={`w-full py-3 rounded-lg font-medium transition ${
className={`w-full py-3 rounded-lg font-medium transition-colors ${
isCurrentPlan
? 'bg-gray-700 text-gray-400 cursor-not-allowed'
? 'bg-muted text-muted-foreground cursor-not-allowed'
: plan.name === 'premium'
? 'bg-purple-600 hover:bg-purple-700 text-white'
: 'bg-gray-700 hover:bg-gray-600 text-white'
? 'bg-primary hover:bg-primary/90 text-primary-foreground'
: 'bg-muted hover:bg-muted/80 text-foreground'
} disabled:opacity-50`}
aria-label={
isCurrentPlan
@ -359,12 +361,12 @@ export function SubscriptionPage(): React.ReactElement {
{/* Invoices section */}
{invoices.length > 0 && (
<div className="bg-gray-800 rounded-lg p-6 border border-gray-700">
<h2 className="text-lg font-semibold mb-4">Billing History</h2>
<div className="bg-card/80 backdrop-blur-xl rounded-xl p-6 border border-border">
<h2 className="text-lg font-semibold mb-4 text-foreground">Billing History</h2>
<div className="overflow-x-auto">
<table className="w-full text-sm" aria-label="Billing history">
<thead>
<tr className="text-gray-400 border-b border-gray-700">
<tr className="text-muted-foreground border-b border-border">
<th className="text-left py-2 pr-4">Date</th>
<th className="text-left py-2 pr-4">Period</th>
<th className="text-left py-2 pr-4">Amount</th>
@ -375,26 +377,26 @@ export function SubscriptionPage(): React.ReactElement {
{invoices.map((invoice) => (
<tr
key={invoice.id}
className="border-b border-gray-700/50"
className="border-b border-border/50"
>
<td className="py-2 pr-4">
<td className="py-2 pr-4 text-foreground">
{formatDate(invoice.created_at)}
</td>
<td className="py-2 pr-4 text-gray-400">
<td className="py-2 pr-4 text-muted-foreground">
{formatDate(invoice.billing_period_start)} -{' '}
{formatDate(invoice.billing_period_end)}
</td>
<td className="py-2 pr-4">
<td className="py-2 pr-4 text-foreground">
{formatCents(invoice.amount_cents, invoice.currency)}
</td>
<td className="py-2">
<span
className={`px-2 py-0.5 rounded text-xs ${
invoice.status === 'paid'
? 'bg-green-900/50 text-green-300'
? 'bg-success/20 text-success'
: invoice.status === 'pending'
? 'bg-yellow-900/50 text-yellow-300'
: 'bg-red-900/50 text-red-300'
? 'bg-warning/20 text-warning'
: 'bg-destructive/20 text-destructive'
}`}
>
{invoice.status}
@ -407,15 +409,16 @@ export function SubscriptionPage(): React.ReactElement {
</div>
</div>
)}
</div>
</div>
</ContentFadeIn>
);
}
function PlanFeature({ label }: { label: string }): React.ReactElement {
return (
<li className="flex items-center text-sm text-gray-300">
<li className="flex items-center text-sm text-muted-foreground">
<svg
className="w-4 h-4 text-green-400 mr-2 flex-shrink-0"
className="w-4 h-4 text-success mr-2 flex-shrink-0"
fill="currentColor"
viewBox="0 0 20 20"
aria-hidden="true"

View file

@ -5,7 +5,6 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { DashboardLayout } from '@/components/layout/DashboardLayout';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
@ -60,8 +59,7 @@ export function SupportPage() {
const isValid = email.includes('@') && subject.trim().length >= 3 && message.trim().length >= 10;
return (
<DashboardLayout>
<div className="max-w-2xl mx-auto px-4 py-8 md:py-12">
<div className="max-w-2xl mx-auto px-4 py-8 md:py-12">
<div className="space-y-2 mb-8">
<h1 className="text-3xl font-heading font-bold text-foreground">
{t('support.title', 'Support')}
@ -219,7 +217,6 @@ export function SupportPage() {
))}
</div>
</div>
</div>
</DashboardLayout>
</div>
);
}

View file

@ -84,7 +84,7 @@ export function LikeButton({
setIsUpdating(true);
},
onSuccess: () => {
showSuccess('Ajouté aux favoris');
showSuccess('Added to favorites');
onLikeChange?.(true, likeCount + 1);
// Invalidate queries to refresh data
queryClient.invalidateQueries({ queryKey: ['trackLikes', trackId] });
@ -99,7 +99,7 @@ export function LikeButton({
err.response?.data?.error?.message ||
err.response?.data?.message ||
err.message ||
"Erreur lors de l'ajout aux favoris";
'Failed to add to favorites';
showError(errorMessage);
},
onSettled: () => {
@ -117,7 +117,7 @@ export function LikeButton({
setIsUpdating(true);
},
onSuccess: () => {
showSuccess('Retiré des favoris');
showSuccess('Removed from favorites');
onLikeChange?.(false, Math.max(0, likeCount - 1));
// Invalidate queries to refresh data
queryClient.invalidateQueries({ queryKey: ['trackLikes', trackId] });
@ -132,7 +132,7 @@ export function LikeButton({
err.response?.data?.error?.message ||
err.response?.data?.message ||
err.message ||
'Erreur lors du retrait des favoris';
'Failed to remove from favorites';
showError(errorMessage);
},
onSettled: () => {
@ -177,7 +177,7 @@ export function LikeButton({
isLiked && 'text-destructive hover:text-destructive/90',
compact && 'h-auto p-1',
)}
aria-label={isLiked ? 'Retirer des favoris' : 'Ajouter aux favoris'}
aria-label={isLiked ? 'Remove from favorites' : 'Add to favorites'}
aria-pressed={isLiked}
>
{isLoading ? (

View file

@ -40,19 +40,21 @@ function TrackCardComponent({
};
const likeCount = track.like_count ?? 0;
const safeTitle = typeof track.title === 'string' ? track.title : String(track.title ?? '');
const safeArtist = typeof track.artist === 'string' ? track.artist : (track.artist != null ? String(track.artist) : '');
return (
<div role="article" aria-label={`Track: ${track.title}`} data-testid="track-card">
<button
type="button"
<div role="article" aria-label={`Track: ${safeTitle}`} data-testid="track-card">
<div
role="button"
tabIndex={onClick ? 0 : -1}
className={cn(
'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]',
'group relative rounded-sm overflow-hidden border-0 cursor-pointer appearance-none p-0 text-left w-full',
'bg-transparent',
'transition-all duration-300 ease-out',
'hover:-translate-y-1 active:translate-y-0',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background',
isPlaying && 'border-l-2 border-l-[var(--sumi-accent)]',
className,
)}
onClick={() => onClick?.(track)}
@ -61,14 +63,15 @@ function TrackCardComponent({
onClick?.(track);
}
}}
aria-label={`Piste: ${track.title}`}
aria-label={`Piste: ${safeTitle}`}
>
<div className="relative aspect-square overflow-hidden rounded-lg m-3 mb-0 shadow-sm">
<div className="relative aspect-square overflow-hidden rounded-sm m-2 mb-0">
{track.cover ? (
<img
src={track.cover}
alt={`Cover de ${track.title}`}
className="object-cover w-full h-full shadow-inner"
alt={`Cover de ${safeTitle}`}
className="object-cover w-full h-full transition-transform duration-[var(--sumi-duration-slow)] ease-[var(--sumi-ease-out)] group-hover:scale-[1.04]"
loading="lazy"
onError={(e) => {
e.currentTarget.style.display = 'none';
e.currentTarget.nextElementSibling?.classList.remove('hidden');
@ -79,12 +82,12 @@ function TrackCardComponent({
{/* Fallback / Placeholder if no cover or error */}
<div
className={cn(
'w-full h-full bg-muted flex items-center justify-center',
'w-full h-full bg-gradient-to-br from-[var(--sumi-bg-overlay)] to-[var(--sumi-surface-inset)] flex items-center justify-center',
track.cover ? 'hidden' : '',
)}
>
<svg
className="w-10 h-10 text-muted-foreground/90"
className="w-12 h-12 text-muted-foreground/40"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
@ -93,18 +96,21 @@ function TrackCardComponent({
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
strokeWidth={1.5}
d="M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zM9 10l12-3"
/>
</svg>
</div>
{/* Washi paper grain on cover */}
<div className="absolute inset-0 rounded-sm noise pointer-events-none opacity-30" />
{/* Play Button — Spotify-style: floats at bottom-right, slides up on hover */}
{(onPlay || isPlaying) && (
<button
type="button"
aria-label={
isPlaying ? `Pause ${track.title}` : `Lire ${track.title}`
isPlaying ? `Pause ${safeTitle}` : `Lire ${safeTitle}`
}
onClick={handlePlay}
onKeyDown={(e) => {
@ -115,14 +121,14 @@ function TrackCardComponent({
}}
className={cn(
'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',
'rounded-full bg-primary text-primary-foreground w-11 h-11 flex items-center justify-center',
'shadow-xl shadow-black/40',
'transition-all duration-[var(--sumi-duration-normal)] ease-[var(--sumi-ease-spring)]',
'hover:scale-105 hover:bg-primary/90 active:scale-95',
'hover:scale-110 hover:brightness-110 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',
? 'opacity-100 translate-y-0 scale-100'
: 'opacity-0 translate-y-3 scale-90 group-hover:opacity-100 group-hover:translate-y-0 group-hover:scale-100',
)}
>
{isPlaying ? (
@ -136,8 +142,9 @@ function TrackCardComponent({
)}
</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 depth on hover */}
<div className="absolute inset-0 bg-gradient-to-t from-black/30 via-transparent to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-[var(--sumi-duration-normal)] pointer-events-none" />
{/* Gradient Overlay for Actions */}
{showActions && (
@ -150,10 +157,10 @@ function TrackCardComponent({
size="icon"
showCount={false}
compact
className="text-foreground hover:text-destructive"
className="text-white/90 hover:text-destructive"
/>
<button
aria-label={`Plus d'options pour ${track.title}`}
aria-label={`More options pour ${safeTitle}`}
onClick={handleMore}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
@ -161,37 +168,45 @@ function TrackCardComponent({
onMore?.(track);
}
}}
className="text-foreground hover:text-primary transition-colors duration-[var(--sumi-duration-normal)] p-1 focus:outline-none focus:ring-2 focus:ring-primary rounded-full"
className="text-white/90 hover:text-primary transition-colors duration-[var(--sumi-duration-normal)] p-1 focus:outline-none focus:ring-2 focus:ring-primary rounded-full"
>
<span aria-hidden="true"></span>
<span className="sr-only">Plus d'options</span>
<span className="sr-only">More options</span>
</button>
</div>
)}
</div>
<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>
<div className="p-3 space-y-1.5">
<div className="flex items-center gap-2 min-w-0">
<h3 className="font-heading text-sm leading-tight truncate text-foreground flex-1 min-w-0" style={{ fontWeight: 400 }}>
{safeTitle}
</h3>
{/* Now-playing EQ bars indicator */}
{isPlaying && (
<div className="eq-bars text-primary shrink-0" aria-label="En lecture">
<span /><span /><span />
</div>
)}
</div>
<div className="flex items-center gap-1.5">
{track.artist && (
{safeArtist && (
<p className="text-xs text-muted-foreground truncate">
{track.artist}
{safeArtist}
</p>
)}
{showDuration && track.artist && (
<span className="text-muted-foreground/40 text-xs" aria-hidden>·</span>
{showDuration && safeArtist && (
<span className="text-muted-foreground/30 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 className="text-xs text-muted-foreground/60 tabular-nums shrink-0">
{Math.floor((Number(track.duration) || 0) / 60)}:
{String((Number(track.duration) || 0) % 60).padStart(2, '0')}
</p>
)}
</div>
</div>
</button>
</div>
</div>
);
}

View file

@ -15,23 +15,22 @@ export function TrackCardSkeleton({ className }: TrackCardSkeletonProps) {
return (
<div
className={cn(
'rounded-xl overflow-hidden border-0 shadow-lg bg-card',
'rounded-xl overflow-hidden border-0 bg-[var(--sumi-surface-subtle)]',
'min-h-0',
className,
)}
role="status"
aria-label="Chargement du morceau"
>
{/* Cover — aspect-square comme TrackCard (CLS: hauteur déterministe) */}
<div className="aspect-square w-full min-h-0">
<Skeleton className="w-full h-full rounded-t-[var(--radius-xl)] rounded-b-none" />
{/* Cover — m-3 mb-0 rounded-lg like TrackCard to prevent layout shift */}
<div className="m-3 mb-0 aspect-square min-h-0">
<Skeleton className="w-full h-full rounded-lg" />
</div>
{/* Info — p-4 space-y-1 comme TrackCard, min-h-14 évite layout shift */}
<div className="p-4 space-y-1 min-h-14">
{/* Info — p-3.5 space-y-1 like TrackCard */}
<div className="p-3.5 space-y-1.5">
<Skeleton className="h-4 w-4/5 rounded-md" />
<Skeleton className="h-3 w-3/5 rounded-md" />
<Skeleton className="h-3 w-16 rounded-md pt-1" />
</div>
<span className="sr-only">Chargement...</span>
</div>

View file

@ -61,7 +61,7 @@ export function TrackSearchResults({
title: track.title,
artist: track.artist,
duration: track.duration ?? 0,
url: track.file_path || '',
url: `/api/v1/tracks/${track.id}/download`,
cover: track.cover_art_path,
genre: track.genre,
like_count: track.like_count,
@ -119,7 +119,7 @@ export function TrackSearchResults({
className={cn('rounded-xl', className)}
>
<AlertCircle className="h-4 w-4" />
<AlertTitle>Erreur</AlertTitle>
<AlertTitle>Error</AlertTitle>
<AlertDescription className="tracking-tight">{error}</AlertDescription>
</Alert>
);
@ -134,9 +134,9 @@ export function TrackSearchResults({
)}
>
<Music className="h-12 w-12 mx-auto mb-4 opacity-50 transition-transform duration-[var(--sumi-duration-normal)]" />
<p>Aucun track trouvé</p>
<p>No tracks found</p>
{total === 0 && (
<p className="text-sm mt-2">Essayez de modifier vos critères de recherche</p>
<p className="text-sm mt-2">Try adjusting your search criteria</p>
)}
</div>
);
@ -151,7 +151,7 @@ export function TrackSearchResults({
>
{/* Results Count */}
<div className="text-sm text-muted-foreground/90 tracking-tight tabular-nums">
{total} résultat{total > 1 ? 's' : ''} trouvé{total > 1 ? 's' : ''}
{total} result{total > 1 ? 's' : ''} found
</div>
{/* Track Grid */}
@ -233,7 +233,7 @@ export function TrackSearchResults({
{totalPages > 1 && (
<div className="flex items-center justify-between pt-4 border-t border-border/80">
<div className="text-sm text-muted-foreground/90 tracking-tight tabular-nums">
Page {page} sur {totalPages}
Page {page} of {totalPages}
</div>
<div className="flex gap-2">
<Button
@ -244,7 +244,7 @@ export function TrackSearchResults({
className="rounded-md transition-colors duration-[var(--sumi-duration-normal)] active:scale-95"
>
<ChevronLeft className="h-4 w-4 mr-1" />
Précédent
Previous
</Button>
<Button
variant="outline"
@ -253,7 +253,7 @@ export function TrackSearchResults({
disabled={page >= totalPages}
className="rounded-md transition-colors duration-[var(--sumi-duration-normal)] active:scale-95"
>
Suivant
Next
<ChevronRight className="h-4 w-4 ml-1" />
</Button>
</div>

View file

@ -52,7 +52,7 @@ export function useTrackDetailPage(trackIdOverride?: string) {
artist: t.artist,
album: t.album,
duration: t.duration,
url: (t as { stream_manifest_url?: string; file_path?: string }).stream_manifest_url || t.file_path,
url: (t as { stream_manifest_url?: string }).stream_manifest_url || `/api/v1/tracks/${t.id}/download`,
cover: (t as { cover_art_path?: string }).cover_art_path,
genre: t.genre,
});

View file

@ -24,13 +24,13 @@ export function UploadModalDropzone({
<input {...getInputProps()} />
<FileAudio className="mx-auto h-12 w-12 text-muted-foreground mb-4" />
<p className="text-lg font-medium mb-2">
{isDragActive ? 'Déposez le fichier ici' : 'Glissez-déposez un fichier audio'}
{isDragActive ? 'Drop the file here' : 'Drag and drop an audio file'}
</p>
<p className="text-sm text-muted-foreground mb-4">
ou cliquez pour sélectionner
or click to select
</p>
<p className="text-xs text-muted-foreground">
Formats acceptés: MP3, WAV, OGG, FLAC, M4A, AAC (max 100 MB)
Accepted formats: MP3, WAV, OGG, FLAC, M4A, AAC (max 100 MB)
</p>
</div>
);