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:
parent
3b065c8f8a
commit
f1457e845b
36 changed files with 1191 additions and 1213 deletions
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 été 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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 ? (
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
Loading…
Reference in a new issue