feat(ui): add SUMI design system components, seasonal hooks, and i18n updates

Add SumiButton and SumiCanvas components with lavis ink wash aesthetic.
Add useSeason and useTimeOfDay hooks for time-aware UI tinting.
Update storybook config, UI components, locales (en/es/fr), and dependencies.
Add Chromatic CI workflow.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
senke 2026-03-31 19:15:54 +02:00
parent 4fd537e3ba
commit dfeff836ce
28 changed files with 4675 additions and 2518 deletions

54
.github/workflows/chromatic.yml vendored Normal file
View file

@ -0,0 +1,54 @@
# Chromatic: visual regression testing for Storybook.
# Runs on every push/PR that touches the web app.
# Requires CHROMATIC_PROJECT_TOKEN secret in GitHub repo settings.
name: Chromatic
on:
push:
branches: [main, develop]
paths:
- "apps/web/**"
- ".github/workflows/chromatic.yml"
pull_request:
paths:
- "apps/web/**"
- ".github/workflows/chromatic.yml"
workflow_dispatch:
jobs:
chromatic:
name: Visual regression tests
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
fetch-depth: 0 # Required for Chromatic to detect changes
- name: Set up Node
uses: actions/setup-node@1d0ff469b7ec7b3cb9d8673fde0c81c44821de2a # v4.2.0
with:
node-version: "20"
cache: "npm"
- name: Install dependencies
run: npm ci
working-directory: apps/web
- name: Run Chromatic
uses: chromaui/action@latest
with:
projectToken: ${{ secrets.CHROMATIC_PROJECT_TOKEN }}
workingDir: apps/web
buildScriptName: build-storybook
exitZeroOnChanges: true # Don't fail PR on visual changes, just flag them
exitOnceUploaded: true # Speed up CI — don't wait for full processing
onlyChanged: true # Only snapshot stories affected by changes
externals: |
apps/web/src/**/*.css
apps/web/public/**
env:
VITE_API_URL: /api/v1
VITE_USE_MSW: "true"
VITE_STORYBOOK: "true"

View file

@ -29,7 +29,8 @@ const queryClient = new QueryClient({
}); });
export const StorybookDecorator: Decorator = (Story, context) => { export const StorybookDecorator: Decorator = (Story, context) => {
const isDark = context.globals?.backgrounds?.value !== '#ffffff'; const bgValue = context.globals?.backgrounds?.value;
const isDark = bgValue !== 'light'; // only 'light' triggers light mode, everything else = dark
const initialEntries = const initialEntries =
(context.parameters?.router as { initialEntries?: string[] } | undefined)?.initialEntries ?? ['/']; (context.parameters?.router as { initialEntries?: string[] } | undefined)?.initialEntries ?? ['/'];

View file

@ -1,7 +1,11 @@
// This file has been automatically migrated to valid ESM format by Storybook.
import { createRequire } from "node:module";
import type { StorybookConfig } from '@storybook/react-vite'; import type { StorybookConfig } from '@storybook/react-vite';
import { dirname, join } from "path" import { dirname, join } from "path"
const require = createRequire(import.meta.url);
function getAbsolutePath(value: string) { function getAbsolutePath(value: string) {
return dirname(require.resolve(join(value, "package.json"))) return dirname(require.resolve(join(value, "package.json")))
} }
@ -12,16 +16,14 @@ const config: StorybookConfig = {
"../src/**/*.stories.@(js|jsx|mjs|ts|tsx)" "../src/**/*.stories.@(js|jsx|mjs|ts|tsx)"
], ],
"addons": [ "addons": [
getAbsolutePath('@storybook/addon-essentials'),
getAbsolutePath('@storybook/addon-a11y'), getAbsolutePath('@storybook/addon-a11y'),
getAbsolutePath('@storybook/addon-interactions'), getAbsolutePath("@storybook/addon-docs"),
getAbsolutePath('msw-storybook-addon'), getAbsolutePath("@storybook/addon-mcp"),
], ],
"staticDirs": ['../public'], "staticDirs": ['../public'],
"framework": getAbsolutePath('@storybook/react-vite'), "framework": getAbsolutePath('@storybook/react-vite'),
"docs": { "docs": {
"defaultName": "Documentation", defaultName: "Documentation"
"autodocs": true
}, },
"typescript": { "typescript": {
"reactDocgen": "react-docgen-typescript", "reactDocgen": "react-docgen-typescript",

View file

@ -56,27 +56,33 @@ const preview: Preview = {
expanded: true, expanded: true,
}, },
a11y: { a11y: {
test: 'todo', test: 'error-on-violation',
}, },
viewport: { viewport: {
viewports: customViewports, options: customViewports,
}, },
backgrounds: { backgrounds: {
default: 'dark', options: {
values: [ dark: { name: 'dark', value: '#121215' },
{ name: 'dark', value: '#121215' }, light: { name: 'light', value: '#faf9f6' },
{ name: 'light', value: '#faf9f6' }, raised: { name: 'raised', value: '#1a1a1f' }
{ name: 'raised', value: '#1a1a1f' }, }
],
}, },
layout: 'centered', layout: 'centered',
docs: { docs: {
toc: true, // Enable table of contents in docs toc: true, // Enable table of contents in docs
}, },
}, },
decorators: [StorybookDecorator], decorators: [StorybookDecorator],
tags: ['autodocs'], tags: ['autodocs'],
loaders: [mswLoader], loaders: [mswLoader],
initialGlobals: {
backgrounds: {
value: 'dark'
}
}
}; };
export default preview; export default preview;

View file

@ -57,7 +57,9 @@
"build-storybook": "cross-env VITE_API_URL=/api/v1 VITE_USE_MSW=true VITE_STORYBOOK=true storybook build", "build-storybook": "cross-env VITE_API_URL=/api/v1 VITE_USE_MSW=true VITE_STORYBOOK=true storybook build",
"test:storybook": "node scripts/audit-storybook.js", "test:storybook": "node scripts/audit-storybook.js",
"test:storybook:playwright": "echo 'Storybook tests moved to repo root: npm run e2e -- --grep storybook'", "test:storybook:playwright": "echo 'Storybook tests moved to repo root: npm run e2e -- --grep storybook'",
"validate:storybook": "node scripts/validate-storybook.cjs" "validate:storybook": "node scripts/validate-storybook.cjs",
"chromatic": "chromatic --project-token=${CHROMATIC_PROJECT_TOKEN}",
"test:storybook:vitest": "vitest --project storybook"
}, },
"dependencies": { "dependencies": {
"@dnd-kit/core": "^6.3.1", "@dnd-kit/core": "^6.3.1",
@ -69,6 +71,7 @@
"@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-slot": "^1.2.4",
"@sentry/react": "^10.32.1", "@sentry/react": "^10.32.1",
"@storybook/addon-mcp": "^0.4.2",
"@tanstack/react-query": "^5.17.0", "@tanstack/react-query": "^5.17.0",
"@tanstack/react-virtual": "^3.13.12", "@tanstack/react-virtual": "^3.13.12",
"axios": "^1.13.5", "axios": "^1.13.5",
@ -97,11 +100,12 @@
}, },
"devDependencies": { "devDependencies": {
"@openapitools/openapi-generator-cli": "^2.27.0", "@openapitools/openapi-generator-cli": "^2.27.0",
"@storybook/addon-a11y": "^8.6.15", "@storybook/addon-a11y": "^10.3.3",
"@storybook/addon-essentials": "^8.6.15", "@storybook/addon-docs": "^10.3.3",
"@storybook/addon-interactions": "^8.6.15", "@storybook/addon-vitest": "^10.3.3",
"@storybook/builder-vite": "^8.6.15", "@storybook/blocks": "^8.6.14",
"@storybook/react-vite": "^8.6.15", "@storybook/builder-vite": "^10.3.3",
"@storybook/react-vite": "^10.3.3",
"@tailwindcss/postcss": "^4.0.0", "@tailwindcss/postcss": "^4.0.0",
"@testing-library/jest-dom": "^6.4.2", "@testing-library/jest-dom": "^6.4.2",
"@testing-library/react": "^14.2.1", "@testing-library/react": "^14.2.1",
@ -128,6 +132,7 @@
"eslint-plugin-react": "^7.37.0", "eslint-plugin-react": "^7.37.0",
"eslint-plugin-react-hooks": "^5.0.0", "eslint-plugin-react-hooks": "^5.0.0",
"eslint-plugin-react-refresh": "^0.4.5", "eslint-plugin-react-refresh": "^0.4.5",
"eslint-plugin-storybook": "10.3.3",
"husky": "^9.1.7", "husky": "^9.1.7",
"jsdom": "^24.0.0", "jsdom": "^24.0.0",
"msw": "^2.11.2", "msw": "^2.11.2",
@ -136,7 +141,7 @@
"pngjs": "^7.0.0", "pngjs": "^7.0.0",
"prettier": "^3.2.5", "prettier": "^3.2.5",
"rollup-plugin-visualizer": "^6.0.5", "rollup-plugin-visualizer": "^6.0.5",
"storybook": "^8.6.15", "storybook": "^10.3.3",
"storybook-dark-mode": "^4.0.2", "storybook-dark-mode": "^4.0.2",
"swagger-ui-dist": "^5.31.0", "swagger-ui-dist": "^5.31.0",
"swagger-ui-react": "^5.31.0", "swagger-ui-react": "^5.31.0",

View file

@ -1,11 +1,57 @@
import React from 'react'; import React, { useEffect } from 'react';
import { Link } from 'react-router-dom';
import { useTranslation } from '@/hooks/useTranslation';
export const DesignSystemDemo: React.FC = () => { export const DesignSystemDemo: React.FC = () => {
const { t } = useTranslation();
useEffect(() => {
document.title = t('designSystem.pageTitle');
}, [t]);
return ( return (
<div className="p-8 text-foreground"> <main
<h1 className="text-2xl font-bold mb-4">Design System Demo</h1> id="main-content"
<p>Component under construction.</p> className="min-h-screen bg-[var(--sumi-bg-void)] text-[var(--sumi-text-primary)]"
data-testid="design-system-page"
>
<header className="flex flex-col items-center justify-center px-4 sm:px-6 pt-16 pb-12">
<h1
className="text-3xl sm:text-4xl tracking-[0.1em] mb-3"
style={{ fontFamily: 'var(--sumi-font-heading)', fontWeight: 200 }}
>
{t('designSystem.title')}
</h1>
<div
className="w-16 h-px bg-gradient-to-r from-transparent via-[var(--sumi-kin)]/30 to-transparent mb-4"
aria-hidden="true"
/>
<p
className="text-sm text-[var(--sumi-text-secondary)]"
style={{ fontFamily: 'var(--sumi-font-body)' }}
>
{t('designSystem.subtitle')}
</p>
</header>
<section className="px-4 sm:px-6 pb-16 max-w-[800px] mx-auto text-center">
<p
className="text-sm text-[var(--sumi-text-tertiary)] leading-relaxed"
style={{ fontFamily: 'var(--sumi-font-body)' }}
>
{t('designSystem.underConstruction')}
</p>
<div className="mt-8">
<Link
to="/"
className="text-xs tracking-[0.1em] px-4 py-1.5 border border-[var(--sumi-border-strong)] rounded-sm text-[var(--sumi-text-secondary)] hover:text-[var(--sumi-text-primary)] hover:border-[var(--sumi-accent)]/40 transition-all"
style={{ fontFamily: 'var(--sumi-font-heading)', fontWeight: 300 }}
>
{t('designSystem.backToHome')}
</Link>
</div> </div>
</section>
</main>
); );
}; };

View file

@ -38,6 +38,7 @@ const typeConfig: Record<string, { icon: React.ElementType; className: string }>
export function AnnouncementBanner() { export function AnnouncementBanner() {
const [announcements, setAnnouncements] = useState<Announcement[]>([]); const [announcements, setAnnouncements] = useState<Announcement[]>([]);
const [dismissed, setDismissed] = useState<Set<string>>(loadDismissed); const [dismissed, setDismissed] = useState<Set<string>>(loadDismissed);
const [showAll, setShowAll] = useState(false);
useEffect(() => { useEffect(() => {
adminService.getActiveAnnouncements().then(setAnnouncements).catch(() => {}); adminService.getActiveAnnouncements().then(setAnnouncements).catch(() => {});
@ -54,9 +55,8 @@ export function AnnouncementBanner() {
const visible = announcements.filter((a) => !dismissed.has(a.id)); const visible = announcements.filter((a) => !dismissed.has(a.id));
if (visible.length === 0) return null; if (visible.length === 0) return null;
// Show max 1 announcement at a time to prevent content being pushed below fold const shown = showAll ? visible : visible.slice(0, 1);
const shown = visible.slice(0, 1); const remaining = showAll ? 0 : visible.length - shown.length;
const remaining = visible.length - shown.length;
return ( return (
<div className="space-y-2 px-4 pt-2"> <div className="space-y-2 px-4 pt-2">
@ -90,9 +90,14 @@ export function AnnouncementBanner() {
); );
})} })}
{remaining > 0 && ( {remaining > 0 && (
<p className="text-[10px] text-muted-foreground/50 text-center tracking-[0.1em] font-heading py-1 px-3 rounded-md bg-muted/30 border border-border/30 mx-auto w-fit" style={{ fontWeight: 300 }}> <button
type="button"
onClick={() => setShowAll(true)}
className="text-[10px] text-muted-foreground/50 hover:text-foreground text-center tracking-[0.1em] font-heading py-1 px-3 rounded-md bg-muted/30 border border-border/30 mx-auto w-fit block cursor-pointer transition-colors"
style={{ fontWeight: 300 }}
>
+{remaining} more +{remaining} more
</p> </button>
)} )}
</div> </div>
); );

View file

@ -1,5 +1,5 @@
import type { Meta, StoryObj } from '@storybook/react'; import type { Meta, StoryObj } from '@storybook/react';
import { fn } from '@storybook/test'; import { fn } from 'storybook/test';
import { ToastComponent } from './Toast'; import { ToastComponent } from './Toast';
const mockToast = { const mockToast = {

View file

@ -135,6 +135,7 @@ export function Header(_props: HeaderProps) {
variant="ghost" variant="ghost"
size="icon" size="icon"
onClick={toggleTheme} onClick={toggleTheme}
aria-label={t('common.changeTheme')}
className="min-h-10 min-w-10 rounded-full hover:bg-muted/50 text-muted-foreground hover:text-foreground transition-colors duration-[var(--duration-fast)]" className="min-h-10 min-w-10 rounded-full hover:bg-muted/50 text-muted-foreground hover:text-foreground transition-colors duration-[var(--duration-fast)]"
> >
{getThemeIcon()} {getThemeIcon()}

View file

@ -7,6 +7,7 @@ import { CSS } from '@dnd-kit/utilities';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { GripVertical, Play, X } from 'lucide-react'; import { GripVertical, Play, X } from 'lucide-react';
import { formatTime } from '@/features/player/services/playerService'; import { formatTime } from '@/features/player/services/playerService';
import { useTranslation } from '@/hooks/useTranslation';
import type { Track } from '@/features/player/types'; import type { Track } from '@/features/player/types';
interface QueueSortableItemProps { interface QueueSortableItemProps {
@ -22,6 +23,7 @@ export function QueueSortableItem({
onPlay, onPlay,
onRemove, onRemove,
}: QueueSortableItemProps) { }: QueueSortableItemProps) {
const { t } = useTranslation();
const { const {
attributes, attributes,
listeners, listeners,
@ -50,6 +52,7 @@ export function QueueSortableItem({
<div <div
{...attributes} {...attributes}
{...listeners} {...listeners}
aria-label={t('queue.reorderTrack', { title: track.title })}
className="text-muted-foreground cursor-grab active:cursor-grabbing hover:text-foreground p-1" className="text-muted-foreground cursor-grab active:cursor-grabbing hover:text-foreground p-1"
> >
<GripVertical className="w-5 h-5" /> <GripVertical className="w-5 h-5" />
@ -57,15 +60,17 @@ export function QueueSortableItem({
<div className="w-10 h-10 rounded overflow-hidden flex-shrink-0 relative"> <div className="w-10 h-10 rounded overflow-hidden flex-shrink-0 relative">
<img <img
src={track.cover || '/placeholder.svg'} src={track.cover || '/placeholder.svg'}
alt="" alt={track.title}
className="w-full h-full object-cover" className="w-full h-full object-cover"
/> />
<div <button
type="button"
className="absolute inset-0 bg-background/50 hidden group-hover:flex items-center justify-center cursor-pointer" className="absolute inset-0 bg-background/50 hidden group-hover:flex items-center justify-center cursor-pointer"
onClick={() => onPlay(track)} onClick={() => onPlay(track)}
aria-label={t('queue.playTrack', { title: track.title })}
> >
<Play className="w-4 h-4 text-foreground fill-current" /> <Play className="w-4 h-4 text-foreground fill-current" />
</div> </button>
</div> </div>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="text-sm font-bold text-foreground truncate"> <div className="text-sm font-bold text-foreground truncate">
@ -82,7 +87,7 @@ export function QueueSortableItem({
type="button" type="button"
className="p-2 text-muted-foreground hover:text-destructive opacity-0 group-hover:opacity-100 transition-opacity" className="p-2 text-muted-foreground hover:text-destructive opacity-0 group-hover:opacity-100 transition-opacity"
onClick={onRemove} onClick={onRemove}
aria-label="Remove from queue" aria-label={t('queue.removeFromQueue')}
> >
<X className="w-4 h-4" /> <X className="w-4 h-4" />
</button> </button>

View file

@ -34,8 +34,10 @@ import {
} from 'lucide-react'; } from 'lucide-react';
import { useToast } from '../../../components/feedback/ToastProvider'; import { useToast } from '../../../components/feedback/ToastProvider';
import { ConfirmationDialog } from '@/components/ui/confirmation-dialog'; import { ConfirmationDialog } from '@/components/ui/confirmation-dialog';
import { useTranslation } from '@/hooks/useTranslation';
export const QueueView: React.FC = () => { export const QueueView: React.FC = () => {
const { t } = useTranslation();
const { const {
queue, queue,
currentTrack, currentTrack,
@ -53,6 +55,8 @@ export const QueueView: React.FC = () => {
const [showSaveModal, setShowSaveModal] = useState(false); const [showSaveModal, setShowSaveModal] = useState(false);
const [showClearConfirm, setShowClearConfirm] = useState(false); const [showClearConfirm, setShowClearConfirm] = useState(false);
const isQueueEmpty = !currentTrack && upNext.length === 0;
const sensors = useSensors( const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 8 } }), useSensor(PointerSensor, { activationConstraint: { distance: 8 } }),
useSensor(KeyboardSensor, { useSensor(KeyboardSensor, {
@ -84,7 +88,7 @@ export const QueueView: React.FC = () => {
...upNext, ...upNext,
]; ];
if (tracksToSave.length === 0) { if (tracksToSave.length === 0) {
addToast('Queue is empty', 'error'); addToast(t('queue.emptyQueueError'), 'error');
throw new Error('Queue is empty'); throw new Error('Queue is empty');
} }
const playlist = await createPlaylist({ const playlist = await createPlaylist({
@ -95,18 +99,18 @@ export const QueueView: React.FC = () => {
for (const track of tracksToSave) { for (const track of tracksToSave) {
await addTrack(playlist.id, String(track.id)); await addTrack(playlist.id, String(track.id));
} }
addToast(`Queue saved as "${name}"`, 'success'); addToast(t('queue.savedAs', { name }), 'success');
}; };
return ( return (
<div className="max-w-4xl mx-auto space-y-6 animate-fadeIn pb-20"> <div className="max-w-4xl mx-auto space-y-6 animate-fadeIn pb-20 px-4">
<div className="flex flex-col md:flex-row justify-between items-end border-b border-border/50 pb-6 gap-4"> <div className="flex flex-col md:flex-row justify-between items-end border-b border-border/50 pb-6 gap-4">
<div> <div>
<h1 className="text-3xl font-heading font-bold text-foreground mb-2"> <h1 className="text-3xl font-heading font-bold text-foreground mb-2">
PLAY QUEUE {t('queue.heading')}
</h1> </h1>
<p className="text-muted-foreground font-mono text-sm"> <p className="text-muted-foreground font-mono text-sm">
{upNext.length} tracks upcoming {t('queue.tracksUpcoming', { count: upNext.length })}
</p> </p>
</div> </div>
<div className="flex gap-4"> <div className="flex gap-4">
@ -114,16 +118,18 @@ export const QueueView: React.FC = () => {
variant="ghost" variant="ghost"
onClick={() => setShowSaveModal(true)} onClick={() => setShowSaveModal(true)}
icon={<Save className="w-4 h-4" />} icon={<Save className="w-4 h-4" />}
disabled={isQueueEmpty}
> >
Save Queue {t('queue.saveQueue')}
</Button> </Button>
<Button <Button
variant="ghost" variant="ghost"
className="text-destructive hover:bg-destructive/10" className="text-destructive hover:bg-destructive/10"
onClick={() => setShowClearConfirm(true)} onClick={() => setShowClearConfirm(true)}
icon={<Trash2 className="w-4 h-4" />} icon={<Trash2 className="w-4 h-4" />}
disabled={isQueueEmpty}
> >
Clear {t('queue.clear')}
</Button> </Button>
</div> </div>
</div> </div>
@ -132,19 +138,25 @@ export const QueueView: React.FC = () => {
{currentTrack && ( {currentTrack && (
<div> <div>
<h2 className="text-xs font-bold text-muted-foreground uppercase tracking-widest mb-3"> <h2 className="text-xs font-bold text-muted-foreground uppercase tracking-widest mb-3">
Now Playing {t('queue.nowPlaying')}
</h2> </h2>
<Card <Card
variant="glass" variant="glass"
className="flex items-center gap-4 p-4 border-l-4 border-l-primary" className="flex items-center gap-4 p-4 border-l-4 border-l-primary"
> >
<div <button
type="button"
className="relative w-16 h-16 rounded overflow-hidden flex-shrink-0 group cursor-pointer" className="relative w-16 h-16 rounded overflow-hidden flex-shrink-0 group cursor-pointer"
onClick={togglePlay} onClick={togglePlay}
aria-label={
isPlaying
? t('queue.pauseTrack', { title: currentTrack.title })
: t('queue.playTrack', { title: currentTrack.title })
}
> >
<img <img
src={currentTrack.cover || '/placeholder.svg'} src={currentTrack.cover || '/placeholder.svg'}
alt="" alt={currentTrack.title}
className="w-full h-full object-cover" className="w-full h-full object-cover"
/> />
<div className="absolute inset-0 bg-background/40 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"> <div className="absolute inset-0 bg-background/40 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity">
@ -161,7 +173,7 @@ export const QueueView: React.FC = () => {
<div className="w-1 bg-primary animate-[bounce_0.8s_infinite] h-full"></div> <div className="w-1 bg-primary animate-[bounce_0.8s_infinite] h-full"></div>
</div> </div>
)} )}
</div> </button>
<div className="flex-1"> <div className="flex-1">
<h2 className="text-xl font-bold text-foreground"> <h2 className="text-xl font-bold text-foreground">
{currentTrack.title} {currentTrack.title}
@ -178,7 +190,7 @@ export const QueueView: React.FC = () => {
{/* Up Next */} {/* Up Next */}
<div> <div>
<h2 className="text-xs font-bold text-muted-foreground uppercase tracking-widest mb-3"> <h2 className="text-xs font-bold text-muted-foreground uppercase tracking-widest mb-3">
Up Next {t('queue.upNext')}
</h2> </h2>
<div className="space-y-2"> <div className="space-y-2">
@ -186,8 +198,8 @@ export const QueueView: React.FC = () => {
<EmptyState <EmptyState
variant="card" variant="card"
icon={<ListMusic className="w-full h-full" />} icon={<ListMusic className="w-full h-full" />}
title="Nothing in your queue" title={t('queue.emptyTitle')}
description="Start playing music and add tracks to build your queue." description={t('queue.emptyDescription')}
size="md" size="md"
/> />
) : ( ) : (
@ -215,12 +227,11 @@ export const QueueView: React.FC = () => {
</div> </div>
</div> </div>
{showSaveModal && (
<SaveQueueAsPlaylistModal <SaveQueueAsPlaylistModal
open={showSaveModal}
onClose={() => setShowSaveModal(false)} onClose={() => setShowSaveModal(false)}
onSave={handleSavePlaylist} onSave={handleSavePlaylist}
/> />
)}
<ConfirmationDialog <ConfirmationDialog
open={showClearConfirm} open={showClearConfirm}
@ -229,9 +240,9 @@ export const QueueView: React.FC = () => {
clearQueue(); clearQueue();
setShowClearConfirm(false); setShowClearConfirm(false);
}} }}
title="Clear queue" title={t('queue.clearTitle')}
description="Remove all tracks from your queue. This cannot be undone." description={t('queue.clearDescription')}
confirmLabel="Clear" confirmLabel={t('queue.clearConfirm')}
variant="destructive" variant="destructive"
/> />
</div> </div>

View file

@ -1,5 +1,5 @@
import type { Meta, StoryObj } from '@storybook/react'; import type { Meta, StoryObj } from '@storybook/react';
import { fn } from '@storybook/test'; import { fn } from 'storybook/test';
import { SaveQueueAsPlaylistModal } from './SaveQueueAsPlaylistModal'; import { SaveQueueAsPlaylistModal } from './SaveQueueAsPlaylistModal';
/** /**
@ -21,6 +21,7 @@ const meta: Meta<typeof SaveQueueAsPlaylistModal> = {
}, },
tags: ['autodocs'], tags: ['autodocs'],
args: { args: {
open: true,
onClose: fn(), onClose: fn(),
onSave: fn(), onSave: fn(),
}, },

View file

@ -1,25 +1,28 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { Button } from '../../ui/button'; import { Dialog } from '../../ui/dialog';
import { Input } from '../../ui/input'; import { Input } from '../../ui/input';
import { X, Lock, Globe } from 'lucide-react'; import { Lock, Globe } from 'lucide-react';
import { useToast } from '../../../components/feedback/ToastProvider'; import { useToast } from '../../../components/feedback/ToastProvider';
import { useTranslation } from '@/hooks/useTranslation';
interface SaveQueueAsPlaylistModalProps { interface SaveQueueAsPlaylistModalProps {
open: boolean;
onClose: () => void; onClose: () => void;
onSave: (name: string, isPublic: boolean) => void | Promise<void>; onSave: (name: string, isPublic: boolean) => void | Promise<void>;
} }
export const SaveQueueAsPlaylistModal: React.FC< export const SaveQueueAsPlaylistModal: React.FC<
SaveQueueAsPlaylistModalProps SaveQueueAsPlaylistModalProps
> = ({ onClose, onSave }) => { > = ({ open, onClose, onSave }) => {
const { addToast } = useToast(); const { addToast } = useToast();
const { t } = useTranslation();
const [name, setName] = useState(''); const [name, setName] = useState('');
const [isPublic, setIsPublic] = useState(false); const [isPublic, setIsPublic] = useState(false);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const handleSubmit = async () => { const handleSubmit = async () => {
if (!name) { if (!name) {
addToast('Please name your playlist', 'error'); addToast(t('queue.saveAsPlaylist.nameRequired'), 'error');
return; return;
} }
setSaving(true); setSaving(true);
@ -28,7 +31,9 @@ export const SaveQueueAsPlaylistModal: React.FC<
onClose(); onClose();
} catch (err) { } catch (err) {
addToast( addToast(
err instanceof Error ? err.message : 'Failed to save playlist', err instanceof Error
? err.message
: t('queue.saveAsPlaylist.saveFailed'),
'error', 'error',
); );
} finally { } finally {
@ -37,30 +42,32 @@ export const SaveQueueAsPlaylistModal: React.FC<
}; };
return ( return (
<div className="fixed inset-0 z-[var(--sumi-z-modal)] flex items-center justify-center p-4"> <Dialog
<div open={open}
className="absolute inset-0 bg-background/90 backdrop-blur-sm" onClose={onClose}
onClick={onClose} title={t('queue.saveAsPlaylist.title')}
></div> onConfirm={handleSubmit}
<div className="relative w-full max-w-md bg-card border border-border rounded-xl shadow-2xl animate-scaleIn"> confirmLabel={saving ? t('common.loading') : t('queue.saveAsPlaylist.save')}
<div className="p-4 border-b border-border bg-card flex justify-between items-center"> onCancel={onClose}
<h3 className="font-bold text-foreground">Save Queue as Playlist</h3> showCancel
<button onClick={onClose}> cancelLabel={t('queue.saveAsPlaylist.cancel')}
<X className="w-5 h-5 text-muted-foreground hover:text-foreground" /> >
</button> <div className="space-y-4">
</div>
<div className="p-6 space-y-4">
<Input <Input
label="Playlist Name" id="queue-playlist-name"
label={t('queue.saveAsPlaylist.nameLabel')}
value={name} value={name}
onChange={(e) => setName(e.target.value)} onChange={(e) => setName(e.target.value)}
autoFocus autoFocus
placeholder="My Queue Session" placeholder={t('queue.saveAsPlaylist.namePlaceholder')}
/> />
<div <button
className="flex items-center justify-between p-4 bg-card rounded border border-border cursor-pointer hover:border-border" type="button"
role="switch"
aria-checked={isPublic}
aria-label={t('queue.saveAsPlaylist.toggleVisibility')}
className="flex w-full items-center justify-between p-4 bg-card rounded border border-border cursor-pointer hover:border-border/80"
onClick={() => setIsPublic(!isPublic)} onClick={() => setIsPublic(!isPublic)}
> >
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
@ -69,39 +76,29 @@ export const SaveQueueAsPlaylistModal: React.FC<
) : ( ) : (
<Lock className="w-5 h-5 text-warning" /> <Lock className="w-5 h-5 text-warning" />
)} )}
<div> <div className="text-left">
<div className="text-sm font-bold text-foreground"> <div className="text-sm font-bold text-foreground">
{isPublic ? 'Public Playlist' : 'Private Playlist'} {isPublic
? t('queue.saveAsPlaylist.publicPlaylist')
: t('queue.saveAsPlaylist.privatePlaylist')}
</div> </div>
<div className="text-xs text-muted-foreground"> <div className="text-xs text-muted-foreground">
{isPublic ? 'Visible on your profile' : 'Only visible to you'} {isPublic
? t('queue.saveAsPlaylist.publicDescription')
: t('queue.saveAsPlaylist.privateDescription')}
</div> </div>
</div> </div>
</div> </div>
<div <div
aria-hidden="true"
className={`w-10 h-5 rounded-full relative transition-colors ${isPublic ? 'bg-primary' : 'bg-muted'}`} className={`w-10 h-5 rounded-full relative transition-colors ${isPublic ? 'bg-primary' : 'bg-muted'}`}
> >
<div <div
className={`absolute top-1 w-3 h-3 bg-white rounded-full transition-all ${isPublic ? 'left-6' : 'left-1'}`} className={`absolute top-1 w-3 h-3 bg-white rounded-full transition-all ${isPublic ? 'left-6' : 'left-1'}`}
></div> ></div>
</div> </div>
</button>
</div> </div>
</div> </Dialog>
<div className="p-4 border-t border-border bg-card flex justify-end gap-4">
<Button variant="ghost" onClick={onClose}>
Cancel
</Button>
<Button
variant="primary"
onClick={handleSubmit}
disabled={saving}
loading={saving}
>
Save Playlist
</Button>
</div>
</div>
</div>
); );
}; };

View file

@ -1,5 +1,5 @@
import type { Meta, StoryObj } from '@storybook/react'; import type { Meta, StoryObj } from '@storybook/react';
import { fn } from '@storybook/test'; import { fn } from 'storybook/test';
import { FullPlayer } from './FullPlayer'; import { FullPlayer } from './FullPlayer';
/** /**

View file

@ -0,0 +1,124 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { SumiButton } from './SumiButton';
import { SumiCanvas } from './SumiCanvas';
const meta: Meta<typeof SumiButton> = {
title: 'Design System/SumiButton',
component: SumiButton,
tags: ['autodocs'],
parameters: {
layout: 'centered',
},
argTypes: {
variant: {
control: 'select',
options: ['ink', 'ghost', 'wash', 'seal'],
},
size: {
control: 'select',
options: ['sm', 'md', 'lg'],
},
},
args: {
children: 'Button',
variant: 'ink',
size: 'md',
},
};
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {};
export const AllVariants: Story = {
render: () => (
<div className="flex flex-col items-center gap-8">
<div className="flex items-center gap-4">
<SumiButton variant="ink">Ink</SumiButton>
<SumiButton variant="wash">Wash</SumiButton>
<SumiButton variant="seal">Seal</SumiButton>
<SumiButton variant="ghost">Ghost</SumiButton>
</div>
<div className="flex items-center gap-4">
<SumiButton variant="ink" size="sm">Small</SumiButton>
<SumiButton variant="ink" size="md">Medium</SumiButton>
<SumiButton variant="ink" size="lg">Large</SumiButton>
</div>
</div>
),
};
export const Ink: Story = {
args: { variant: 'ink', children: 'Listen Now' },
};
export const Ghost: Story = {
args: { variant: 'ghost', children: 'Skip' },
};
export const Wash: Story = {
args: { variant: 'wash', children: 'Explore' },
};
export const Seal: Story = {
args: { variant: 'seal', children: 'Create' },
};
export const Loading: Story = {
args: { loading: true, children: 'Uploading...' },
};
export const Disabled: Story = {
args: { disabled: true, children: 'Unavailable' },
};
export const OnCanvas: Story = {
parameters: { layout: 'fullscreen', backgrounds: { disable: true } },
render: () => (
<SumiCanvas timeOfDay="dusk" season="autumn">
<div className="flex min-h-[60vh] flex-col items-center justify-center gap-6">
<p
className="text-sm uppercase tracking-[0.25em]"
style={{ color: 'var(--sumi-text-secondary)' }}
>
Buttons on the living canvas
</p>
<div className="flex gap-4">
<SumiButton variant="ink" size="lg">Play Album</SumiButton>
<SumiButton variant="wash" size="lg">Save</SumiButton>
<SumiButton variant="seal" size="lg">Share</SumiButton>
</div>
<div className="flex gap-3">
<SumiButton variant="ghost" size="sm">Previous</SumiButton>
<SumiButton variant="ghost" size="sm">Next</SumiButton>
</div>
</div>
</SumiCanvas>
),
};
export const VariantsOnAllPeriods: Story = {
parameters: { layout: 'fullscreen', backgrounds: { disable: true } },
render: () => (
<div className="grid grid-cols-3 gap-0">
{(['dawn', 'morning', 'midday', 'afternoon', 'dusk', 'night'] as const).map((period) => (
<SumiCanvas key={period} timeOfDay={period} season="spring">
<div className="flex h-[33vh] flex-col items-center justify-center gap-3 p-4">
<span
className="text-[10px] font-medium uppercase tracking-[0.3em]"
style={{ color: 'var(--sumi-text-tertiary)' }}
>
{period}
</span>
<div className="flex gap-2">
<SumiButton variant="ink" size="sm">Ink</SumiButton>
<SumiButton variant="seal" size="sm">Seal</SumiButton>
<SumiButton variant="wash" size="sm">Wash</SumiButton>
</div>
</div>
</SumiCanvas>
))}
</div>
),
};

View file

@ -0,0 +1,136 @@
/**
* SumiButton A button that feels like it was brushed into existence
*
* Design principles:
* - No hard borders. Edges dissolve like ink meeting water.
* - Hover = ink spreading subtly outward (bokashi effect)
* - Press = ink concentrating inward (darker, denser)
* - Focus = a single confident brush stroke appears around it
* - The button floats slight elevation, breathing shadow
*
* Despite the ethereal aesthetic, contrast ratios meet WCAG AAA.
*/
import React, { forwardRef, type ButtonHTMLAttributes } from 'react';
import { cn } from '@/lib/utils';
export interface SumiButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
/** Visual weight */
variant?: 'ink' | 'ghost' | 'wash' | 'seal';
/** Size */
size?: 'sm' | 'md' | 'lg';
/** Loading state — ink pulses gently */
loading?: boolean;
}
/**
* Variants:
*
* - `ink` Primary. Solid ink fill, white text. The confident brush stroke.
* - `ghost` Secondary. Transparent until hovered, then ink bleeds in.
* - `wash` Tertiary. Diluted ink wash background, like watered-down sumi.
* - `seal` Accent. Vermillion hanko seal style small, decisive.
*/
export const SumiButton = forwardRef<HTMLButtonElement, SumiButtonProps>(
({ variant = 'ink', size = 'md', loading = false, className, children, disabled, ...props }, ref) => {
return (
<button
ref={ref}
disabled={disabled || loading}
className={cn(
// Base — floating, organic
'sumi-button',
'relative inline-flex items-center justify-center',
'font-medium tracking-wide',
'transition-all duration-300',
'outline-none',
// No hard borders, organic edges
'border-0',
// Focus: brush stroke ring
'focus-visible:ring-2 focus-visible:ring-offset-2',
'focus-visible:ring-[var(--sumi-accent)]',
'focus-visible:ring-offset-[var(--sumi-bg-base)]',
// Disabled
'disabled:pointer-events-none disabled:opacity-30',
// Size
size === 'sm' && 'h-8 px-4 text-xs rounded-sm gap-1.5',
size === 'md' && 'h-10 px-6 text-sm rounded gap-2',
size === 'lg' && 'h-12 px-8 text-base rounded gap-2.5',
// Variant styles
variant === 'ink' && [
'bg-[var(--sumi-text-primary)] text-[var(--sumi-bg-base)]',
// Ink diffusion shadow — not a drop shadow, more like ink bleeding into paper
'shadow-[0_2px_12px_-2px_rgba(232,224,208,0.15)]',
// Hover: ink spreads, shadow grows like a puddle
'hover:shadow-[0_4px_24px_-4px_rgba(232,224,208,0.25)]',
'hover:scale-[1.02]',
// Active: ink concentrates, button sinks
'active:scale-[0.98]',
'active:shadow-[0_1px_4px_-1px_rgba(232,224,208,0.1)]',
],
variant === 'ghost' && [
'bg-transparent text-[var(--sumi-text-primary)]',
// Subtle ink bleed on hover
'hover:bg-[var(--sumi-bg-hover)]',
'hover:shadow-[0_0_20px_-4px_rgba(232,224,208,0.05)]',
'active:bg-[var(--sumi-bg-active)]',
],
variant === 'wash' && [
// Diluted ink — like a watercolor wash
'bg-[var(--sumi-bg-wash)] text-[var(--sumi-text-primary)]',
'shadow-[0_1px_8px_-2px_rgba(13,13,11,0.3)]',
'hover:bg-[var(--sumi-bg-raised)]',
'hover:shadow-[0_2px_16px_-4px_rgba(13,13,11,0.4)]',
'hover:scale-[1.01]',
'active:scale-[0.99]',
'active:bg-[var(--sumi-bg-active)]',
],
variant === 'seal' && [
// Vermillion hanko seal — decisive and bold
'bg-[var(--sumi-accent)] text-[var(--sumi-text-inverse)]',
'shadow-[0_2px_12px_-2px_rgba(184,58,30,0.3)]',
'hover:bg-[var(--sumi-accent-hover)]',
'hover:shadow-[0_4px_20px_-4px_rgba(184,58,30,0.4)]',
'hover:scale-[1.02]',
'active:bg-[var(--sumi-accent-active)]',
'active:scale-[0.98]',
],
// Loading pulse
loading && 'animate-pulse',
className,
)}
{...props}
>
{/* Ink bleed effect on hover — expands behind the button */}
<span
className={cn(
'absolute inset-0 rounded-[inherit] opacity-0 transition-opacity duration-500',
'pointer-events-none',
variant === 'ink' && 'bg-[var(--sumi-text-primary)] blur-md group-hover:opacity-10',
variant === 'seal' && 'bg-[var(--sumi-accent)] blur-md group-hover:opacity-15',
)}
aria-hidden="true"
/>
{/* Content */}
<span className="relative z-10 flex items-center gap-[inherit]">
{loading && (
<span className="inline-block h-3.5 w-3.5 animate-spin rounded-full border-2 border-current border-t-transparent" />
)}
{children}
</span>
</button>
);
},
);
SumiButton.displayName = 'SumiButton';

View file

@ -0,0 +1,176 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { SumiCanvas } from './SumiCanvas';
import { SumiButton } from './SumiButton';
const meta: Meta<typeof SumiCanvas> = {
title: 'Design System/SumiCanvas',
component: SumiCanvas,
tags: ['autodocs'],
parameters: {
layout: 'fullscreen',
backgrounds: { disable: true },
},
argTypes: {
timeOfDay: {
control: 'select',
options: ['dawn', 'morning', 'midday', 'afternoon', 'dusk', 'night'],
},
season: {
control: 'select',
options: ['spring', 'summer', 'autumn', 'winter'],
},
},
};
export default meta;
type Story = StoryObj<typeof meta>;
const DemoContent = () => (
<div className="flex min-h-screen flex-col items-center justify-center gap-8 p-12">
<h1
className="text-4xl font-light tracking-widest"
style={{ color: 'var(--sumi-text-primary)', fontFamily: 'var(--sumi-font-heading)' }}
>
SUMI
</h1>
<p
className="max-w-md text-center text-lg font-light leading-relaxed"
style={{ color: 'var(--sumi-text-secondary)' }}
>
The interface breathes with the day. Light shifts, ink flows, seasons turn.
</p>
<div className="flex gap-4">
<SumiButton variant="ink">Listen</SumiButton>
<SumiButton variant="wash">Explore</SumiButton>
<SumiButton variant="seal">Create</SumiButton>
<SumiButton variant="ghost">About</SumiButton>
</div>
</div>
);
export const Dawn: Story = {
args: {
timeOfDay: 'dawn',
season: 'spring',
children: undefined,
},
render: (args) => (
<SumiCanvas {...args}>
<DemoContent />
</SumiCanvas>
),
};
export const Morning: Story = {
args: { timeOfDay: 'morning', season: 'spring' },
render: (args) => (
<SumiCanvas {...args}>
<DemoContent />
</SumiCanvas>
),
};
export const Midday: Story = {
args: { timeOfDay: 'midday', season: 'summer' },
render: (args) => (
<SumiCanvas {...args}>
<DemoContent />
</SumiCanvas>
),
};
export const Afternoon: Story = {
args: { timeOfDay: 'afternoon', season: 'autumn' },
render: (args) => (
<SumiCanvas {...args}>
<DemoContent />
</SumiCanvas>
),
};
export const Dusk: Story = {
args: { timeOfDay: 'dusk', season: 'autumn' },
render: (args) => (
<SumiCanvas {...args}>
<DemoContent />
</SumiCanvas>
),
};
export const Night: Story = {
args: { timeOfDay: 'night', season: 'winter' },
render: (args) => (
<SumiCanvas {...args}>
<DemoContent />
</SumiCanvas>
),
};
export const AllPeriods: Story = {
parameters: { layout: 'fullscreen' },
render: () => (
<div className="grid grid-cols-2 gap-0">
{(['dawn', 'morning', 'midday', 'afternoon', 'dusk', 'night'] as const).map((period) => (
<SumiCanvas key={period} timeOfDay={period} season="spring" showAtmosphere>
<div className="flex h-[50vh] flex-col items-center justify-center gap-3">
<span
className="text-xs font-medium uppercase tracking-[0.3em]"
style={{ color: 'var(--sumi-text-secondary)' }}
>
{period}
</span>
<SumiButton variant="ink" size="sm">{period}</SumiButton>
</div>
</SumiCanvas>
))}
</div>
),
};
export const AllSeasons: Story = {
parameters: { layout: 'fullscreen' },
render: () => (
<div className="grid grid-cols-2 gap-0">
{(['spring', 'summer', 'autumn', 'winter'] as const).map((season) => (
<SumiCanvas key={season} timeOfDay="afternoon" season={season} showAtmosphere>
<div className="flex h-[50vh] flex-col items-center justify-center gap-3">
<span
className="text-3xl"
style={{ fontFamily: 'var(--sumi-font-heading)', color: 'var(--sumi-text-primary)' }}
>
{season === 'spring' ? '春' : season === 'summer' ? '夏' : season === 'autumn' ? '秋' : '冬'}
</span>
<span
className="text-xs uppercase tracking-[0.3em]"
style={{ color: 'var(--sumi-text-secondary)' }}
>
{season}
</span>
</div>
</SumiCanvas>
))}
</div>
),
};
export const Minimal: Story = {
args: { minimal: true, timeOfDay: 'dusk' },
render: (args) => (
<SumiCanvas {...args}>
<div className="flex items-center justify-center p-12">
<p style={{ color: 'var(--sumi-text-secondary)' }}>
Minimal mode just tinting, for modals and sidebars
</p>
</div>
</SumiCanvas>
),
};
export const NoLavis: Story = {
args: { showLavis: false, showAtmosphere: true, timeOfDay: 'morning' },
render: (args) => (
<SumiCanvas {...args}>
<DemoContent />
</SumiCanvas>
),
};

View file

@ -0,0 +1,202 @@
/**
* SumiCanvas The living background layer system
*
* Like a 2D game world, the interface exists on stacked visual planes:
*
* z-0 Void Pure color, tinted by time-of-day and season
* z-1 Lavis Scanned ink wash artwork (parallax on scroll)
* z-2 Atmosphere Haze, grain, ambient light gradient
* z-3 Content UI components float here (children)
*
* When lavis assets aren't available yet, z-1 uses generated ink wash
* gradients that still create depth and movement.
*/
import { useRef, useEffect, type CSSProperties } from 'react';
import { cn } from '@/lib/utils';
import { useTimeOfDay, getTimeOfDayPalette, type TimeOfDayPalette } from '@/hooks/useTimeOfDay';
import { useSeason } from '@/hooks/useSeason';
import type { TimeOfDayPeriod } from '@/hooks/useTimeOfDay';
import type { Season } from '@/hooks/useSeason';
export interface SumiCanvasProps {
children: React.ReactNode;
/** Show the ink wash art layer */
showLavis?: boolean;
/** Show the atmospheric haze layer */
showAtmosphere?: boolean;
/** Enable parallax on scroll */
parallax?: boolean;
/** Custom lavis image URL (overrides seasonal) */
lavisUrl?: string;
/** Lavis image opacity override (01) */
lavisOpacity?: number;
/** Override time of day (for Storybook) */
timeOfDay?: TimeOfDayPeriod;
/** Override season (for Storybook) */
season?: Season;
/** Additional className for the content layer */
className?: string;
/** Minimal mode — just tinting, no layers (for modals, sidebars) */
minimal?: boolean;
}
export function SumiCanvas({
children,
showLavis = true,
showAtmosphere = true,
parallax = true,
lavisUrl,
lavisOpacity,
timeOfDay: timeOverride,
season: seasonOverride,
className,
minimal = false,
}: SumiCanvasProps) {
const tod = useTimeOfDay(true);
const seasonCtx = useSeason(seasonOverride);
const containerRef = useRef<HTMLDivElement>(null);
const lavisRef = useRef<HTMLDivElement>(null);
// Use override palette if specified
const palette: TimeOfDayPalette = timeOverride ? getTimeOfDayPalette(timeOverride) : tod;
// Parallax effect on scroll
useEffect(() => {
if (!parallax || minimal || !lavisRef.current) return;
const handleScroll = () => {
if (!lavisRef.current) return;
const scrollY = window.scrollY;
// Lavis layer moves at 30% of scroll speed = depth illusion
lavisRef.current.style.transform = `translateY(${scrollY * 0.3}px)`;
};
window.addEventListener('scroll', handleScroll, { passive: true });
return () => window.removeEventListener('scroll', handleScroll);
}, [parallax, minimal]);
if (minimal) {
return (
<div
className={cn('relative min-h-full', className)}
style={{
'--sumi-tod-tint': palette.tint,
'--sumi-tod-tint-opacity': palette.tintOpacity,
'--sumi-season-tint': seasonCtx.seasonTint,
'--sumi-season-tint-opacity': seasonCtx.seasonTintOpacity,
} as CSSProperties}
>
{children}
</div>
);
}
return (
<div
ref={containerRef}
className="sumi-canvas relative min-h-screen overflow-hidden"
style={{
'--sumi-tod-tint': palette.tint,
'--sumi-tod-tint-opacity': palette.tintOpacity,
'--sumi-tod-ink-opacity': palette.inkOpacity,
'--sumi-tod-haze': palette.haze,
'--sumi-tod-light-angle': `${palette.lightAngle}deg`,
'--sumi-tod-sky': palette.skyTint,
'--sumi-season-tint': seasonCtx.seasonTint,
'--sumi-season-tint-opacity': seasonCtx.seasonTintOpacity,
} as CSSProperties}
>
{/* z-0: Void — base color with time-of-day and seasonal tinting */}
<div
className="sumi-canvas-void absolute inset-0"
aria-hidden="true"
style={{
background: `
linear-gradient(
${palette.lightAngle}deg,
transparent 0%,
${palette.tint} 100%
)
`,
opacity: palette.tintOpacity,
transition: 'opacity 300s linear, background 300s linear',
}}
/>
{/* z-1: Lavis — ink wash artwork */}
{showLavis && (
<div
ref={lavisRef}
className="sumi-canvas-lavis absolute inset-0"
aria-hidden="true"
style={{
opacity: lavisOpacity ?? palette.inkOpacity * 0.4,
transition: 'opacity 300s linear',
willChange: parallax ? 'transform' : undefined,
}}
>
{lavisUrl ? (
<img
src={lavisUrl}
alt=""
className="h-full w-full object-cover"
loading="lazy"
/>
) : (
/* Generated ink wash when no scanned art is available */
<div
className="h-full w-full"
style={{
background: `
radial-gradient(ellipse 80% 50% at 20% 80%, var(--sumi-ink-usuzumi) 0%, transparent 70%),
radial-gradient(ellipse 60% 40% at 75% 20%, var(--sumi-ink-hai) 0%, transparent 60%),
radial-gradient(ellipse 90% 60% at 50% 50%, var(--sumi-ink-sumi) 0%, transparent 80%)
`,
filter: 'blur(40px)',
}}
/>
)}
</div>
)}
{/* z-2: Atmosphere — haze, ambient light */}
{showAtmosphere && palette.haze > 0 && (
<div
className="sumi-canvas-atmosphere absolute inset-0 pointer-events-none"
aria-hidden="true"
style={{
background: `
radial-gradient(
ellipse 120% 80% at 50% 0%,
${palette.skyTint} 0%,
transparent 60%
)
`,
opacity: palette.haze * 0.15,
transition: 'opacity 300s linear',
mixBlendMode: 'soft-light',
}}
/>
)}
{/* Season tint overlay */}
{seasonCtx.seasonTintOpacity > 0 && (
<div
className="absolute inset-0 pointer-events-none"
aria-hidden="true"
style={{
backgroundColor: seasonCtx.seasonTint,
opacity: seasonCtx.seasonTintOpacity,
transition: 'opacity 60s linear, background-color 60s linear',
mixBlendMode: 'overlay',
}}
/>
)}
{/* z-3: Content — where UI components live */}
<div className={cn('sumi-canvas-content relative z-10', className)}>
{children}
</div>
</div>
);
}

View file

@ -1,5 +1,5 @@
import type { Meta, StoryObj } from '@storybook/react'; import type { Meta, StoryObj } from '@storybook/react';
// import { fn } from '@storybook/test'; // import { fn } from 'storybook/test';
import { Checkbox } from './checkbox'; import { Checkbox } from './checkbox';
const meta = { const meta = {

View file

@ -1,6 +1,7 @@
import { Info } from 'lucide-react'; import { Info } from 'lucide-react';
import { Tooltip } from './tooltip'; import { Tooltip } from './tooltip';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { useTranslation } from '@/hooks/useTranslation';
/** /**
* HelpTextProps - Propriétés du composant HelpText * HelpTextProps - Propriétés du composant HelpText
@ -61,6 +62,7 @@ interface HelpTextProps {
* @returns {JSX.Element} Élément span avec icône Info et tooltip * @returns {JSX.Element} Élément span avec icône Info et tooltip
*/ */
export function HelpText({ text, className, position = 'top' }: HelpTextProps) { export function HelpText({ text, className, position = 'top' }: HelpTextProps) {
const { t } = useTranslation();
return ( return (
<Tooltip content={text} position={position}> <Tooltip content={text} position={position}>
<span <span
@ -68,7 +70,7 @@ export function HelpText({ text, className, position = 'top' }: HelpTextProps) {
'inline-flex items-center text-muted-foreground cursor-help', 'inline-flex items-center text-muted-foreground cursor-help',
className, className,
)} )}
aria-label={`Aide: ${text}`} aria-label={`${t('common.help')}: ${text}`}
> >
<Info className="h-3 w-3" aria-hidden="true" /> <Info className="h-3 w-3" aria-hidden="true" />
</span> </span>

View file

@ -109,7 +109,7 @@ export const Checkbox = React.forwardRef<HTMLInputElement, CheckboxProps>(
" "
></div> ></div>
<Check <Check
className="w-3.5 h-3.5 text-black absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 opacity-0 peer-checked:opacity-100 transition-opacity duration-[var(--duration-fast)] pointer-events-none" className="w-3.5 h-3.5 text-primary-foreground absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 opacity-0 peer-checked:opacity-100 transition-opacity duration-[var(--duration-fast)] pointer-events-none"
strokeWidth={3} strokeWidth={3}
/> />
</div> </div>

View file

@ -5,6 +5,7 @@ import { Button } from '../button';
import { FocusTrap } from '../focus-trap'; import { FocusTrap } from '../focus-trap';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { AlertCircle, Info, X } from 'lucide-react'; import { AlertCircle, Info, X } from 'lucide-react';
import { useTranslation } from '@/hooks/useTranslation';
import type { DialogProps, DialogVariant } from './types'; import type { DialogProps, DialogVariant } from './types';
const variantIcons: Record< const variantIcons: Record<
@ -50,6 +51,7 @@ export function Dialog({
closeOnOverlayClick: closeOnOverlayClickProp, closeOnOverlayClick: closeOnOverlayClickProp,
closeOnEscape: closeOnEscapeProp = true, closeOnEscape: closeOnEscapeProp = true,
}: DialogProps) { }: DialogProps) {
const { t } = useTranslation();
const titleId = useId(); const titleId = useId();
const handleClose = () => { const handleClose = () => {
@ -171,7 +173,7 @@ export function Dialog({
size="icon" size="icon"
onClick={handleClose} onClick={handleClose}
className="ml-auto" className="ml-auto"
aria-label="Fermer" aria-label={t('common.close')}
> >
<X className="w-5 h-5" /> <X className="w-5 h-5" />
</Button> </Button>

View file

@ -0,0 +1,186 @@
/**
* useSeason Seasonal asset management for SUMI Design System
*
* Determines the current season and provides the correct asset paths.
* Assets are organized by season in /assets/sumi/{season}/.
*
* The system supports:
* - Automatic season detection based on date
* - Manual override (for Storybook / testing)
* - Graceful fallback if seasonal assets aren't available yet
* - Transition periods between seasons (last/first week blending)
*/
import { useMemo, useState, useEffect } from 'react';
export type Season = 'spring' | 'summer' | 'autumn' | 'winter';
export interface SeasonalContext {
/** Current season */
season: Season;
/** Asset base path for current season */
assetPath: string;
/** Japanese name for the season */
kanji: string;
/** Season-specific accent tint (very subtle) */
seasonTint: string;
/** Opacity for the season tint overlay */
seasonTintOpacity: number;
/** Day of season (1~90) */
dayOfSeason: number;
/** Progress through the season (01) */
progress: number;
/** Whether we're in a transition week between seasons */
isTransitioning: boolean;
/** If transitioning, the previous season */
previousSeason: Season | null;
/** Transition blend factor (0 = full previous, 1 = full current) */
transitionBlend: number;
}
interface SeasonConfig {
season: Season;
kanji: string;
startMonth: number; // 1-indexed
startDay: number;
tintHue: number;
tintSat: number;
tintLit: number;
tintOpacity: number;
}
const SEASONS: SeasonConfig[] = [
{
season: 'spring',
kanji: '春',
startMonth: 3,
startDay: 20,
tintHue: 340, // Sakura pink
tintSat: 20,
tintLit: 85,
tintOpacity: 0.03,
},
{
season: 'summer',
kanji: '夏',
startMonth: 6,
startDay: 21,
tintHue: 160, // Deep green
tintSat: 15,
tintLit: 60,
tintOpacity: 0.02,
},
{
season: 'autumn',
kanji: '秋',
startMonth: 9,
startDay: 22,
tintHue: 25, // Warm amber
tintSat: 30,
tintLit: 65,
tintOpacity: 0.04,
},
{
season: 'winter',
kanji: '冬',
startMonth: 12,
startDay: 21,
tintHue: 210, // Cold blue
tintSat: 10,
tintLit: 75,
tintOpacity: 0.02,
},
];
function getSeasonStartDate(config: SeasonConfig, year: number): Date {
return new Date(year, config.startMonth - 1, config.startDay);
}
function getSeasonForDate(date: Date): { current: SeasonConfig; dayOfSeason: number; progress: number } {
const year = date.getFullYear();
const time = date.getTime();
// Build array of season start dates for this year and next
const starts = SEASONS.map((s, i) => ({
config: s,
start: getSeasonStartDate(s, s.startMonth === 12 && date.getMonth() < 2 ? year - 1 : year),
nextStart: getSeasonStartDate(
SEASONS[(i + 1) % SEASONS.length],
SEASONS[(i + 1) % SEASONS.length].startMonth < s.startMonth ? year + 1 : year,
),
}));
for (const entry of starts) {
if (time >= entry.start.getTime() && time < entry.nextStart.getTime()) {
const totalDays = (entry.nextStart.getTime() - entry.start.getTime()) / (1000 * 60 * 60 * 24);
const dayOfSeason = Math.floor((time - entry.start.getTime()) / (1000 * 60 * 60 * 24)) + 1;
return {
current: entry.config,
dayOfSeason,
progress: dayOfSeason / totalDays,
};
}
}
// Fallback: winter
return { current: SEASONS[3], dayOfSeason: 1, progress: 0 };
}
const TRANSITION_DAYS = 7;
function computeSeasonalContext(date: Date): SeasonalContext {
const { current, dayOfSeason, progress } = getSeasonForDate(date);
const currentIndex = SEASONS.indexOf(current);
const previousSeason = SEASONS[(currentIndex - 1 + SEASONS.length) % SEASONS.length];
const isTransitioning = dayOfSeason <= TRANSITION_DAYS;
const transitionBlend = isTransitioning ? dayOfSeason / TRANSITION_DAYS : 1;
return {
season: current.season,
assetPath: `/assets/sumi/${current.season}`,
kanji: current.kanji,
seasonTint: `hsl(${current.tintHue}, ${current.tintSat}%, ${current.tintLit}%)`,
seasonTintOpacity: current.tintOpacity * transitionBlend,
dayOfSeason,
progress,
isTransitioning,
previousSeason: isTransitioning ? previousSeason.season : null,
transitionBlend,
};
}
/**
* Returns the current seasonal context.
* Updates once per day.
*/
export function useSeason(overrideSeason?: Season): SeasonalContext {
const [date, setDate] = useState(() => new Date());
useEffect(() => {
// Check once per hour if the day changed
const interval = setInterval(() => {
const now = new Date();
if (now.getDate() !== date.getDate()) setDate(now);
}, 60 * 60 * 1000);
return () => clearInterval(interval);
}, [date]);
return useMemo(() => {
if (overrideSeason) {
const config = SEASONS.find((s) => s.season === overrideSeason) ?? SEASONS[0];
return {
season: config.season,
assetPath: `/assets/sumi/${config.season}`,
kanji: config.kanji,
seasonTint: `hsl(${config.tintHue}, ${config.tintSat}%, ${config.tintLit}%)`,
seasonTintOpacity: config.tintOpacity,
dayOfSeason: 45,
progress: 0.5,
isTransitioning: false,
previousSeason: null,
transitionBlend: 1,
};
}
return computeSeasonalContext(date);
}, [date, overrideSeason]);
}

View file

@ -0,0 +1,243 @@
/**
* useTimeOfDay Enhanced circadian engine for SUMI Design System
*
* Goes beyond warmth/brightness: provides a full ambient palette
* that shifts through 6 periods of the day. Each period has its own
* tint color, ink opacity, atmosphere density, and ambient light direction.
*
* The UI should feel like a living space that breathes with the sun.
*/
import { useEffect, useMemo, useState } from 'react';
export type TimeOfDayPeriod = 'dawn' | 'morning' | 'midday' | 'afternoon' | 'dusk' | 'night';
export interface TimeOfDayPalette {
/** Current period name */
period: TimeOfDayPeriod;
/** Ambient tint applied as a very subtle overlay (HSL string) */
tint: string;
/** Tint opacity — always very low (0.020.06) to stay subtle */
tintOpacity: number;
/** Ink wash opacity multiplier — how visible the lavis backgrounds are */
inkOpacity: number;
/** Atmospheric haze amount (01) */
haze: number;
/** Ambient light angle in degrees (for subtle gradient direction) */
lightAngle: number;
/** Sky color for the topmost gradient (very faint) */
skyTint: string;
/** Hour (0-23) */
hour: number;
/** Progress within the current period (01) */
progress: number;
}
interface PeriodConfig {
period: TimeOfDayPeriod;
startHour: number;
endHour: number;
tintHue: number;
tintSat: number;
tintLit: number;
tintOpacity: number;
inkOpacity: number;
haze: number;
lightAngle: number;
skyTint: string;
}
const PERIODS: PeriodConfig[] = [
{
// Dawn: 57h — warm amber glow from the east
period: 'dawn',
startHour: 5,
endHour: 7,
tintHue: 25,
tintSat: 60,
tintLit: 70,
tintOpacity: 0.04,
inkOpacity: 0.7,
haze: 0.3,
lightAngle: 90,
skyTint: 'hsl(25, 50%, 85%)',
},
{
// Morning: 711h — clean, bright, neutral with slight warmth
period: 'morning',
startHour: 7,
endHour: 11,
tintHue: 40,
tintSat: 20,
tintLit: 80,
tintOpacity: 0.02,
inkOpacity: 0.85,
haze: 0.1,
lightAngle: 120,
skyTint: 'hsl(45, 20%, 92%)',
},
{
// Midday: 1114h — strongest light, most clarity, no tint
period: 'midday',
startHour: 11,
endHour: 14,
tintHue: 45,
tintSat: 10,
tintLit: 90,
tintOpacity: 0.01,
inkOpacity: 1.0,
haze: 0.0,
lightAngle: 180,
skyTint: 'hsl(50, 10%, 95%)',
},
{
// Afternoon: 1417h — golden warmth starts creeping in
period: 'afternoon',
startHour: 14,
endHour: 17,
tintHue: 35,
tintSat: 40,
tintLit: 75,
tintOpacity: 0.03,
inkOpacity: 0.9,
haze: 0.15,
lightAngle: 240,
skyTint: 'hsl(35, 35%, 88%)',
},
{
// Dusk: 1720h — dramatic warm light, deep oranges and purples
period: 'dusk',
startHour: 17,
endHour: 20,
tintHue: 15,
tintSat: 50,
tintLit: 60,
tintOpacity: 0.05,
inkOpacity: 0.75,
haze: 0.25,
lightAngle: 270,
skyTint: 'hsl(15, 45%, 75%)',
},
{
// Night: 205h — cool, deep, minimal interference
period: 'night',
startHour: 20,
endHour: 5,
tintHue: 220,
tintSat: 15,
tintLit: 30,
tintOpacity: 0.03,
inkOpacity: 0.6,
haze: 0.2,
lightAngle: 0,
skyTint: 'hsl(220, 15%, 15%)',
},
];
function getPeriodForHour(hour: number): PeriodConfig {
for (const p of PERIODS) {
if (p.startHour < p.endHour) {
if (hour >= p.startHour && hour < p.endHour) return p;
} else {
// Wraps midnight (night: 205)
if (hour >= p.startHour || hour < p.endHour) return p;
}
}
return PERIODS[5]; // fallback to night
}
function getProgressInPeriod(hour: number, minutes: number, config: PeriodConfig): number {
const currentMinutes = hour * 60 + minutes;
const startMinutes = config.startHour * 60;
let endMinutes = config.endHour * 60;
if (config.startHour > config.endHour) {
// Wraps midnight
if (hour >= config.startHour) {
endMinutes = config.endHour * 60 + 24 * 60;
} else {
return (currentMinutes + 24 * 60 - startMinutes) / (endMinutes + 24 * 60 - startMinutes);
}
}
return Math.max(0, Math.min(1, (currentMinutes - startMinutes) / (endMinutes - startMinutes)));
}
function lerp(a: number, b: number, t: number): number {
return a + (b - a) * t;
}
function computePalette(now: Date): TimeOfDayPalette {
const hour = now.getHours();
const minutes = now.getMinutes();
const current = getPeriodForHour(hour);
const progress = getProgressInPeriod(hour, minutes, current);
// Find next period for smooth interpolation
const nextPeriodIndex = (PERIODS.indexOf(current) + 1) % PERIODS.length;
const next = PERIODS[nextPeriodIndex];
// Smooth transition in the last 30% of each period
const transitionZone = progress > 0.7 ? (progress - 0.7) / 0.3 : 0;
const eased = transitionZone * transitionZone; // ease-in for imperceptible blending
return {
period: current.period,
tint: `hsl(${Math.round(lerp(current.tintHue, next.tintHue, eased))}, ${Math.round(lerp(current.tintSat, next.tintSat, eased))}%, ${Math.round(lerp(current.tintLit, next.tintLit, eased))}%)`,
tintOpacity: lerp(current.tintOpacity, next.tintOpacity, eased),
inkOpacity: lerp(current.inkOpacity, next.inkOpacity, eased),
haze: lerp(current.haze, next.haze, eased),
lightAngle: lerp(current.lightAngle, next.lightAngle, eased),
skyTint: current.skyTint,
hour,
progress,
};
}
/**
* Returns the current time-of-day palette.
* Updates every 5 minutes for imperceptible transitions.
*
* @param enabled - If false, returns a neutral midday palette
*/
export function useTimeOfDay(enabled = true): TimeOfDayPalette {
const [now, setNow] = useState(() => new Date());
useEffect(() => {
if (!enabled) return;
const interval = setInterval(() => setNow(new Date()), 5 * 60 * 1000);
return () => clearInterval(interval);
}, [enabled]);
return useMemo(() => {
if (!enabled) {
return {
period: 'midday' as const,
tint: 'hsl(45, 10%, 90%)',
tintOpacity: 0,
inkOpacity: 1,
haze: 0,
lightAngle: 180,
skyTint: 'hsl(50, 10%, 95%)',
hour: 12,
progress: 0.5,
};
}
return computePalette(now);
}, [now, enabled]);
}
/** Force a specific period (for Storybook / testing) */
export function getTimeOfDayPalette(period: TimeOfDayPeriod): TimeOfDayPalette {
const config = PERIODS.find((p) => p.period === period) ?? PERIODS[2];
return {
period: config.period,
tint: `hsl(${config.tintHue}, ${config.tintSat}%, ${config.tintLit}%)`,
tintOpacity: config.tintOpacity,
inkOpacity: config.inkOpacity,
haze: config.haze,
lightAngle: config.lightAngle,
skyTint: config.skyTint,
hour: config.startHour,
progress: 0.5,
};
}

View file

@ -7,6 +7,7 @@
"delete": "Delete", "delete": "Delete",
"confirm": "Confirm", "confirm": "Confirm",
"close": "Close", "close": "Close",
"help": "Help",
"back": "Back", "back": "Back",
"next": "Next", "next": "Next",
"previous": "Previous", "previous": "Previous",
@ -26,6 +27,8 @@
"register": "Register", "register": "Register",
"email": "Email", "email": "Email",
"password": "Password", "password": "Password",
"showPassword": "Show password",
"hidePassword": "Hide password",
"username": "Username", "username": "Username",
"firstName": "First Name", "firstName": "First Name",
"lastName": "Last Name", "lastName": "Last Name",
@ -50,7 +53,8 @@
"notifications": "Notifications", "notifications": "Notifications",
"retry": "Retry", "retry": "Retry",
"retrying": "Retrying...", "retrying": "Retrying...",
"dismiss": "Dismiss" "dismiss": "Dismiss",
"loadingAria": "Loading in progress"
}, },
"auth": { "auth": {
"login": { "login": {
@ -63,29 +67,144 @@
"loginButton": "Sign in", "loginButton": "Sign in",
"noAccount": "Don't have an account?", "noAccount": "Don't have an account?",
"createAccount": "Create account", "createAccount": "Create account",
"orContinueWith": "or continue with",
"footerLink": "Don't have an account? Sign up",
"oauthProvider": "Sign in with {{provider}}",
"errors": { "errors": {
"invalidCredentials": "Invalid email or password", "invalidCredentials": "Invalid email or password",
"accountLocked": "Account locked", "accountLocked": "Account locked",
"emailNotVerified": "Email not verified" "emailNotVerified": "Email not verified",
"emailRequired": "Email is required",
"emailInvalid": "Invalid email format",
"passwordRequired": "Password is required",
"connectionError": "Connection error. Check your internet.",
"genericError": "An error occurred. Please try again."
} }
}, },
"twoFactor": {
"title": "Two-factor authentication",
"subtitle": "Enter the code from your authenticator app",
"backToSignIn": "Back to sign in",
"verificationCode": "Verification Code",
"enterCode": "Enter the 6-digit code from your authenticator app to continue signing in.",
"lostAccess": "Lost access?",
"useBackupCode": "Use a backup code",
"useAuthenticator": "Use authenticator code instead",
"backupCode": "Backup Code",
"verify": "Verify",
"verifying": "Verifying...",
"cancel": "Cancel",
"enterCodeError": "Please enter a verification code"
},
"layout": {
"pageLabel": "Authentication page",
"navLabel": "Authentication navigation"
},
"register": { "register": {
"title": "Create Account", "title": "Sign Up",
"subtitle": "Join the Veza community", "subtitle": "Create your account",
"firstName": "First Name", "firstName": "First Name",
"lastName": "Last Name", "lastName": "Last Name",
"username": "Username", "username": "Username",
"email": "Email", "email": "Email",
"password": "Password", "password": "Password",
"confirmPassword": "Confirm Password", "confirmPassword": "Confirm Password",
"registerButton": "Create Account", "registerButton": "Sign Up",
"loadingText": "Signing up...",
"hasAccount": "Already have an account?", "hasAccount": "Already have an account?",
"loginLink": "Sign in", "loginLink": "Sign in",
"footerLink": "Already have an account? Sign in",
"formAriaLabel": "Registration form",
"errors": { "errors": {
"emailRequired": "Email required",
"emailInvalid": "Invalid email",
"usernameRequired": "Username required",
"usernameTooShort": "Username must be at least 3 characters",
"usernameUnavailable": "This username is already taken",
"passwordRequired": "Password required",
"passwordTooShort": "Password must be at least 12 characters",
"passwordWeak": "Password must contain uppercase, lowercase, digit and special character",
"confirmRequired": "Password confirmation required",
"passwordMismatch": "Passwords do not match", "passwordMismatch": "Passwords do not match",
"emailExists": "This email is already in use", "emailExists": "This email is already in use",
"usernameExists": "This username is already taken", "usernameExists": "This username is already taken",
"weakPassword": "Password must contain at least 12 characters" "weakPassword": "Password must contain at least 12 characters",
"termsRequired": "You must accept the terms of service and privacy policy"
},
"terms": {
"accept": "I accept the",
"termsOfService": "Terms of Service",
"termsAriaLabel": "Read the Terms of Service",
"and": "and the",
"privacyPolicy": "Privacy Policy",
"privacyAriaLabel": "Read the Privacy Policy",
"description": "You must accept the terms of service and privacy policy to create an account"
},
"usernameCheck": {
"checking": "Checking...",
"available": "This username is available",
"unavailable": "This username is already taken"
},
"passwordStrength": {
"label": "Password strength: {{level}}",
"weak": "Weak",
"fair": "Fair",
"good": "Good",
"strong": "Strong",
"reqLength": "At least 12 characters ({{current}}/12)",
"reqCase": "Uppercase and lowercase",
"reqDigit": "A digit",
"reqSpecial": "A special character (!@#$%^&*...)"
},
"verification": {
"title": "Registration successful!",
"emailSent": "A verification email has been sent to",
"checkInbox": "Please check your inbox and click the verification link.",
"resendButton": "Resend verification email",
"resendLoading": "Sending...",
"resendSuccess": "Verification email resent successfully!",
"resendError": "Failed to resend email. Please try again."
}
},
"verifyEmail": {
"title": {
"verifying": "Email Verification",
"success": "Email Verified",
"error": "Email Verification"
},
"subtitle": {
"verifying": "Verification in progress...",
"success": "Your email has been successfully verified",
"error": "An error occurred"
},
"message": {
"verifying": "Verifying your email...",
"success": "Your email has been successfully verified!",
"invalidLink": "Invalid or missing verification link",
"defaultError": "Verification failed",
"resendSuccess": "Verification email sent! Please check your inbox.",
"emailNotFound": "Email not found. Please register again or contact support.",
"resendError": "Failed to send the email"
},
"button": {
"retry": "Retry",
"resend": "Resend verification email",
"resendCooldown": "Resend in {{seconds}}s",
"resendCooldownAriaLabel": "Resend verification email in {{seconds}} seconds",
"resendAriaLabel": "Resend verification email"
},
"success": {
"title": "Success!",
"redirecting": "You will be redirected to the login page..."
},
"error": {
"title": "Error"
},
"srOnly": {
"verifying": "Verifying your email, please wait"
},
"footer": {
"backToLogin": "Back to login"
} }
}, },
"forgotPassword": { "forgotPassword": {
@ -94,16 +213,135 @@
"email": "Email", "email": "Email",
"sendButton": "Send Reset Link", "sendButton": "Send Reset Link",
"backToLogin": "Back to login", "backToLogin": "Back to login",
"success": "Reset email sent" "success": "Reset email sent",
"successTitle": "Check your email",
"successBody": "If an account with that email exists, we've sent password reset instructions.",
"checkInbox": "Please check your inbox and click the link to reset your password.",
"resendButton": "Resend email",
"formAriaLabel": "Password reset form",
"pageTitle": "Forgot Password - Veza",
"errors": {
"emailRequired": "Email is required",
"emailInvalid": "Invalid email format"
} }
}, },
"resetPassword": {
"title": "Reset Password",
"subtitle": "Enter your new password",
"pageTitle": "Reset Password - Veza",
"password": "New password",
"confirmPassword": "Confirm password",
"submitButton": "Reset password",
"backToLogin": "Back to login",
"requestNewLink": "Request a new link",
"formAriaLabel": "Password reset form",
"invalidToken": {
"title": "Invalid reset link",
"subtitle": "The reset link is invalid or has expired",
"heading": "Invalid link",
"body": "The reset link is invalid or has expired. Please request a new link."
},
"success": {
"title": "Password reset",
"subtitle": "Your password has been changed successfully",
"heading": "Success!",
"body": "Your password has been reset successfully.",
"redirecting": "You will be redirected to the login page in {{seconds}}s..."
},
"errors": {
"passwordRequired": "Password is required",
"passwordTooShort": "Password must be at least 8 characters",
"confirmRequired": "Password confirmation is required",
"passwordMismatch": "Passwords do not match"
}
}
},
"landing": {
"nav": {
"product": "PRODUCT",
"platform": "PLATFORM",
"login": "LOGIN",
"ariaLabel": "Main navigation"
},
"hero": {
"tagline1": "Professional audio hardware — open, repairable, transparent.",
"tagline2": "Ethical music platform — no tracking, no algorithm.",
"cta": "Launching soon — Join the first ones",
"placeholder": "your@email.com",
"submit": "JOIN",
"discover": "DISCOVER"
},
"values": {
"kicker": "三つの柱",
"title": "Three commitments",
"card1": {
"title": "Open Hardware",
"desc": "Schematics published under CERN-OHL license. You can build, repair and improve every component. No planned obsolescence."
},
"card2": {
"title": "Ethical Platform",
"desc": "Zero behavioral tracking. Zero manipulation algorithm. Chronological feed. Private data. Open-source code (AGPL-3.0)."
},
"card3": {
"title": "Artist Community",
"desc": "Streaming, marketplace, real-time chat, collaborative playlists. Transparent compensation. Artists control their music."
}
},
"product": {
"kicker": "First product",
"title": "Condenser Microphone",
"desc": "Large diaphragm. OPA1642 preamp. Machined aluminum body. Published schematics, standard components, repair guide included. 5-year warranty.",
"feat1": "KiCAD schematics published — CERN-OHL-W",
"feat2": "Repairable — no glue, standard components",
"feat3": "Made in France — documented sourcing",
"feat4": "~€150 — full cost transparency",
"cta": "GET NOTIFIED AT LAUNCH"
},
"platform": {
"kicker": "The platform",
"subtitle": "墨 STREAMING — FROM MIC TO LISTENER",
"streaming": "HLS Streaming",
"community": "Community",
"marketplace": "Marketplace",
"privacy": "Privacy",
"openSource": "Open source",
"zeroTracking": "Zero tracking",
"stats": "435,000 lines of code. External security audit. 34 test suites. Go backend + Rust stream server + React frontend. Self-hosted. No cloud. No VC."
},
"notify": {
"title": "Join the first ones",
"desc": "Sign up to be notified at launch. No spam — one single email on launch day.",
"submit": "NOTIFY ME",
"placeholder": "your@email.com",
"ariaLabel": "Email address for launch notification"
},
"form": {
"successHero": "Registration confirmed. See you soon.",
"successCta": "Noted. Thank you.",
"errorSubscription": "Subscription failed",
"errorGeneric": "An error occurred",
"loadingAriaLabel": "Submitting..."
},
"footer": {
"openSource": "Open Source",
"privacy": "Privacy",
"contact": "Contact",
"tagline": "ETHICAL AUDIO TECHNOLOGY — MADE IN FRANCE"
},
"pageTitle": "TALAS — Ethical Audio Hardware & Platform"
},
"feed": { "feed": {
"title": "Feed", "title": "Feed",
"subtitle": "Latest tracks from your artists", "subtitle": "Latest tracks from your artists",
"emptyTitle": "Your feed is empty", "emptyTitle": "Your feed is empty",
"emptyDescription": "Follow artists to see their latest tracks here.", "emptyDescription": "Follow artists to see their latest tracks here.",
"newReleasesInGenres": "New releases in your genres", "newReleasesInGenres": "New releases in your genres",
"followedArtists": "Followed artists" "followedArtists": "Followed artists",
"noNewTracks": "No new tracks",
"suggestedAccounts": "Suggested Accounts",
"followers_one": "{{count}} follower",
"followers_other": "{{count}} followers",
"seeAll": "See all"
}, },
"dashboard": { "dashboard": {
"title": "Dashboard", "title": "Dashboard",
@ -156,6 +394,13 @@
"goBack": "Go back", "goBack": "Go back",
"stayTuned": "Stay tuned — we'll let you know when it's ready." "stayTuned": "Stay tuned — we'll let you know when it's ready."
}, },
"designSystem": {
"pageTitle": "Design System — Veza",
"title": "Design System",
"subtitle": "Component library and visual reference",
"underConstruction": "This page is under construction. Components will be showcased here soon.",
"backToHome": "Back to home"
},
"player": { "player": {
"miniPlayerAriaLabel": "Mini audio player", "miniPlayerAriaLabel": "Mini audio player",
"expandPlayer": "Expand player", "expandPlayer": "Expand player",
@ -217,6 +462,22 @@
"description": "Upload your first track or create a playlist to get started.", "description": "Upload your first track or create a playlist to get started.",
"uploadButton": "Upload file", "uploadButton": "Upload file",
"uploadTrack": "Upload Track" "uploadTrack": "Upload Track"
},
"new": "New",
"searchPlaceholder": "Search...",
"table": {
"label": "My tracks list",
"title": "Title",
"artist": "Artist",
"date": "Date",
"duration": "Duration",
"download": "Download",
"delete": "Delete",
"moreOptions": "More options for {{title}}"
},
"grid": {
"label": "Library tracks grid",
"play": "Play {{title}}"
} }
}, },
"profile": { "profile": {
@ -245,6 +506,58 @@
"bioPlaceholder": "Tell us about yourself..." "bioPlaceholder": "Tell us about yourself..."
} }
}, },
"profilePublic": {
"pageTitle": "{{displayName}} — Veza",
"about": "About",
"noBio": "Systems online. No bio data available.",
"links": "Links",
"joined": "Joined {{date}}",
"tabs": {
"tracks": "Tracks",
"playlists": "Playlists",
"reposts": "Reposts",
"feed": "Feed"
},
"stats": {
"tracks": "Tracks",
"playlists": "Playlists",
"followers": "Followers",
"following": "Following"
},
"empty": {
"noTracks": "No tracks yet",
"noTracksDesc": "This user currently has no public tracks.",
"noPlaylists": "No playlists yet",
"noPlaylistsDesc": "No public playlists found for this user.",
"noPosts": "No posts yet",
"noPostsDesc": "This user hasn't posted anything yet.",
"noReposts": "No reposts yet",
"noRepostsDesc": "This user hasn't reposted any tracks yet."
},
"reposted": "Reposted",
"unknownArtist": "Unknown Artist",
"error": {
"notFound": "User Not Found",
"notFoundDesc": "The signal was lost in the void. We couldn't find the profile you were looking for.",
"generic": "Something went wrong",
"genericDesc": "We couldn't load this profile. Check your connection and try again.",
"tryAgain": "Try again",
"returnToBase": "Return to Base"
},
"private": {
"title": "Private Profile",
"description": "This profile is hidden. Its content is not visible."
},
"follow": {
"follow": "Follow",
"following": "Following",
"subscribing": "Subscribing...",
"unsubscribing": "Unsubscribing...",
"followSuccess": "You are now following this user",
"unfollowSuccess": "You are no longer following this user"
},
"loading": "Loading profile"
},
"settings": { "settings": {
"title": "Settings", "title": "Settings",
"subtitle": "Manage your preferences and account settings", "subtitle": "Manage your preferences and account settings",
@ -485,11 +798,150 @@
"genre": "Genre", "genre": "Genre",
"year": "Year", "year": "Year",
"plays": "Plays", "plays": "Plays",
"likes": "Likes" "likes": "Likes",
"grid": {
"label": "Tracks grid",
"track": "Track: {{title}}",
"play": "Play {{title}}",
"pause": "Pause {{title}}",
"coverAlt": "Cover art for {{title}}",
"moreOptions": "More options for {{title}}",
"nowPlaying": "Now playing",
"densityCompact": "Compact",
"densityDefault": "Default",
"densityLarge": "Large"
},
"detail": {
"back": "Back",
"addToQueue": "Add to Queue",
"addedToQueue": "Added to queue",
"listenTogether": "Listen together",
"edit": "Edit",
"startListening": "Start listening",
"close": "Close",
"listenTogetherHelp": "Share this link with friends to listen together. Playback will be synchronized.",
"shareLink": "Share link",
"couldNotCreateSession": "Could not create listening session",
"linkCopied": "Link copied to clipboard",
"linkCopyFailed": "Failed to copy link",
"playsLabel": "Plays",
"likesLabel": "Likes",
"format": "Format",
"bitrate": "Bitrate",
"sampleRate": "Sample Rate",
"bpm": "BPM",
"key": "Key",
"tags": "Tags",
"uploaded": "Uploaded",
"discussion": "Discussion",
"analytics": "Analytics",
"history": "History",
"lyrics": "Lyrics",
"stems": "Stems",
"performanceData": "Performance Data",
"versionHistory": "Version History",
"notFound": "Track not found",
"failedToLoad": "Failed to load track",
"goBack": "Go Back"
},
"commentSection": {
"title": "Comments",
"titleWithCount": "Comments ({{count}})",
"placeholder": "Write a comment...",
"empty": "No comments yet. Be the first to comment!",
"publishError": "Error publishing comment",
"publishSuccess": "Comment published",
"loginToComment": "Log in to comment",
"loadError": "Failed to load comments"
},
"repost": {
"reposted": "Track added to your profile",
"repostFailed": "Repost failed",
"unreposted": "Repost removed",
"unrepostFailed": "Failed to remove repost",
"repostAction": "Repost to your profile",
"unrepostAction": "Remove repost"
},
"likeAction": {
"added": "Added to favorites",
"addFailed": "Failed to add to favorites",
"removed": "Removed from favorites",
"removeFailed": "Failed to remove from favorites"
},
"shareDialog": {
"title": "Share Track",
"creatingLink": "Creating share link...",
"shareLink": "Share Link",
"expiresIn": "This link will expire in 7 day(s)",
"close": "Close",
"copyLink": "Copy Link",
"createFailed": "Failed to create share link",
"linkCopied": "Link copied to clipboard",
"linkCopyFailed": "Failed to copy link"
},
"lyricsSection": {
"title": "Lyrics",
"loadError": "Unable to load lyrics.",
"empty": "No lyrics available for this track.",
"showLess": "Show less",
"showMore": "Show more"
},
"stemsSection": {
"title": "Stems",
"upload": "Upload",
"loading": "Loading stems...",
"loadError": "Failed to load stems.",
"empty": "No stems available.",
"uploadHelp": "Upload stems (WAV, AIFF, FLAC) to share with collaborators."
},
"stats": {
"scanning": "SCANNING...",
"telemetryError": "Telemetry interrupted",
"views": "Views",
"likes": "Likes",
"comments": "Comms",
"downloads": "Data",
"playTime": "Pulse"
}
},
"discover": {
"title": "Discover",
"subtitle": "Explore by genre, tag, or editorial playlist",
"byGenre": "By Genre",
"editorialPlaylists": "Editorial Playlists",
"noEditorialPlaylists": "No editorial playlists available yet",
"back": "Back",
"noTracksInGenre": "No tracks in this genre",
"browseGenre": "Browse {{genre}} tracks",
"trackCount": "{{count}} tracks"
}, },
"playlists": { "playlists": {
"title": "Playlists", "title": "Playlists",
"pageTitle": "Playlists — Veza",
"subtitle": "Discover and manage your playlists",
"create": "Create Playlist", "create": "Create Playlist",
"createButton": "Create",
"createButtonMobile": "New",
"createNewPlaylist": "Create a new playlist",
"importButton": "Import",
"selectButton": "Select",
"deselectButton": "Cancel",
"enableSelection": "Enable selection",
"disableSelection": "Disable selection",
"searchPlaceholder": "Search playlists...",
"filtersButton": "Filters",
"filtersActive": "Active",
"clearFilters": "Clear",
"filterVisibility": "Visibility",
"filterOwner": "Owner",
"filterSortBy": "Sort By",
"sortToggle": "Toggle sort order",
"all": "All",
"myPlaylists": "My Playlists",
"others": "Others",
"sortByDate": "Date",
"sortByTitle": "Title",
"sortByTracks": "Tracks",
"edit": "Edit Playlist", "edit": "Edit Playlist",
"delete": "Delete Playlist", "delete": "Delete Playlist",
"follow": "Follow", "follow": "Follow",
@ -503,10 +955,188 @@
"addCollaborator": "Add Collaborator", "addCollaborator": "Add Collaborator",
"removeCollaborator": "Remove Collaborator", "removeCollaborator": "Remove Collaborator",
"noPlaylists": "No playlists available", "noPlaylists": "No playlists available",
"emptyTitle": "No playlists yet",
"emptyDescription": "Start by creating your first playlist to organize your tracks.",
"loading": "Loading playlists...", "loading": "Loading playlists...",
"tracks": "Tracks", "tracks": "Tracks",
"trackListLabel": "Playlist tracks",
"trackItem": "Track {{position}}: {{title}}",
"public": "Public", "public": "Public",
"private": "Private" "private": "Private",
"createDialog": {
"title": "Create a playlist",
"titleLabel": "Title",
"titlePlaceholder": "My new playlist",
"titleRequired": "Title is required",
"descriptionLabel": "Description",
"descriptionPlaceholder": "Describe your playlist...",
"publicPlaylist": "Public playlist",
"cancel": "Cancel playlist creation",
"cancelButton": "Cancel",
"submit": "Create the playlist",
"submitButton": "Create"
},
"shared": {
"playAll": "Play All",
"shuffle": "Shuffle",
"copyLink": "Copy link",
"linkCopied": "Link copied to clipboard",
"sharedPlaylist": "Shared playlist",
"trackCount": "{{count}} track",
"trackCount_other": "{{count}} tracks",
"notFound": "Playlist Not Found",
"backToLibrary": "Back to Library",
"noTracks": "No tracks in this playlist",
"noTracksDescription": "This shared playlist is empty.",
"publicSignal": "Public",
"encrypted": "Private",
"updated": "Updated {{date}}",
"followers": "{{count}} followers"
},
"detail": {
"reorder": "Reorder",
"playTrack": "Play {{title}}",
"pauseTrack": "Pause {{title}}",
"coverAlt": "Cover of {{title}}",
"addToFavorites": "Add {{title}} to Favorites",
"filterTracks": "Filter tracks...",
"addTracks": "Add Tracks",
"squadMembers": "Squad Members",
"invite": "Invite",
"suggestedForYou": "Suggested for you",
"recommendations": "Recommendations",
"trackAdded": "Track added",
"trackRemoved": "Track removed",
"reordered": "Reordered",
"playlistReordered": "Playlist reordered",
"reorderError": "Unable to reorder the playlist. Please try again.",
"emptyTracks": "No tracks in this playlist",
"emptyTracksDescription": "Add tracks to this playlist to get started.",
"loadingTracks": "Loading tracks",
"loadingTracksProgress": "Loading tracks..."
},
"actions": {
"groupLabel": "Playlist actions",
"edit": "Edit",
"editPlaylist": "Edit playlist",
"saving": "Saving...",
"saved": "Saved",
"share": "Share",
"sharePlaylist": "Share playlist",
"delete": "Delete",
"deletePlaylist": "Delete playlist",
"deleteTitle": "Delete playlist",
"deleteConfirmation": "Are you sure you want to delete \"{{title}}\"? This action is irreversible. All tracks in the playlist will be removed.",
"deleteConfirm": "Delete",
"deleteCancel": "Cancel",
"updateSuccess": "Playlist updated successfully",
"updateError": "Error updating playlist",
"deleteSuccess": "Playlist deleted successfully",
"deleteError": "Error deleting playlist"
},
"editDialog": {
"title": "Edit playlist",
"save": "Save",
"saving": "Saving...",
"cancel": "Cancel",
"titleLabel": "Title",
"titlePlaceholder": "Playlist title",
"descriptionLabel": "Description",
"descriptionPlaceholder": "Playlist description",
"coverUrlLabel": "Cover URL",
"isPublic": "Public playlist",
"savingInProgress": "Saving in progress..."
},
"form": {
"editAriaLabel": "Edit playlist form",
"createAriaLabel": "Create playlist form",
"titleLabel": "Title",
"titlePlaceholder": "My playlist",
"titleRequired": "Title is required",
"titleMaxLength": "Title cannot exceed 200 characters",
"descriptionLabel": "Description",
"descriptionPlaceholder": "Describe your playlist...",
"descriptionMaxLength": "Description cannot exceed 2000 characters",
"coverUrlLabel": "Cover URL",
"coverUrlMaxLength": "URL cannot exceed 500 characters",
"coverUrlInvalid": "Cover URL must be valid",
"isPublic": "Public playlist",
"cancel": "Cancel",
"cancelEdit": "Cancel editing",
"save": "Save",
"create": "Create",
"saveChanges": "Save changes",
"createPlaylist": "Create playlist",
"updateSuccess": "Playlist updated successfully",
"createSuccess": "Playlist created successfully",
"genericError": "An error occurred"
},
"duplicate": {
"button": "Duplicate",
"duplicating": "Duplicating...",
"ariaLabel": "Duplicate playlist",
"copySuffix": "{{title}} (copy)",
"success": "Playlist duplicated successfully",
"error": "Error duplicating playlist"
},
"export": {
"button": "Export",
"exporting": "Exporting...",
"json": "Export as JSON",
"csv": "Export as CSV",
"m3u": "Export as M3U",
"success": "Playlist exported as {{format}}",
"authRequired": "You must be logged in to export a playlist",
"forbidden": "You don't have permission to export this playlist",
"notFound": "Playlist not found",
"error": "Error during export",
"genericError": "An error occurred during export"
},
"followBtn": {
"follow": "Follow",
"following": "Following",
"unfollowing": "Unfollowing...",
"subscribing": "Subscribing...",
"followSuccess": "You are now following this playlist",
"followError": "Error following playlist",
"unfollowSuccess": "You are no longer following this playlist",
"unfollowError": "Error unfollowing playlist"
}
},
"queue": {
"pageTitle": "Queue — Veza",
"heading": "PLAY QUEUE",
"tracksUpcoming_one": "{{count}} track upcoming",
"tracksUpcoming_other": "{{count}} tracks upcoming",
"nowPlaying": "Now Playing",
"upNext": "Up Next",
"emptyTitle": "Nothing in your queue",
"emptyDescription": "Start playing music and add tracks to build your queue.",
"saveQueue": "Save Queue",
"clear": "Clear",
"clearTitle": "Clear queue",
"clearDescription": "Remove all tracks from your queue. This cannot be undone.",
"clearConfirm": "Clear",
"removeFromQueue": "Remove from queue",
"reorderTrack": "Reorder {{title}}",
"playTrack": "Play {{title}}",
"pauseTrack": "Pause {{title}}",
"emptyQueueError": "Queue is empty",
"savedAs": "Queue saved as \"{{name}}\"",
"saveAsPlaylist": {
"title": "Save Queue as Playlist",
"nameLabel": "Playlist Name",
"namePlaceholder": "My Queue Session",
"privatePlaylist": "Private Playlist",
"publicPlaylist": "Public Playlist",
"privateDescription": "Only visible to you",
"publicDescription": "Visible on your profile",
"toggleVisibility": "Toggle playlist visibility",
"cancel": "Cancel",
"save": "Save Playlist",
"nameRequired": "Please name your playlist",
"saveFailed": "Failed to save playlist"
}
}, },
"notifications": { "notifications": {
"title": "Notifications", "title": "Notifications",
@ -524,13 +1154,35 @@
}, },
"search": { "search": {
"title": "Search", "title": "Search",
"pageTitle": "Search — Veza",
"heading": "Search",
"placeholder": "Search tracks, playlists, users...", "placeholder": "Search tracks, playlists, users...",
"searchPlaceholder": "Search for tracks, artists, playlists...",
"clearSearch": "Clear search",
"results": "Results", "results": "Results",
"noResults": "No results found", "resultsCount_one": "{{count}} result found",
"resultsCount_other": "{{count}} results found",
"allResults": "All Results",
"topTracks": "Top Tracks",
"tracks": "Tracks", "tracks": "Tracks",
"artists": "Artists",
"playlists": "Playlists", "playlists": "Playlists",
"users": "Users", "users": "Users",
"all": "All" "all": "All",
"noResults": "No results found",
"noResultsHint": "Try adjusting your search or use different keywords.",
"noDescription": "No description",
"recentSearches": "Recent Searches",
"clearHistory": "Clear search history",
"helpText": "Use AND, OR, NOT and \"exact phrase\" to refine your search.",
"discovery": {
"newReleases": "New Releases",
"newReleasesDesc": "Latest tracks from your artists",
"curatedMixes": "Curated Mixes",
"curatedMixesDesc": "Handpicked selections for you",
"exploreArtists": "Explore Artists",
"exploreArtistsDesc": "Discover community artists"
}
}, },
"analytics": { "analytics": {
"title": "Analytics", "title": "Analytics",

View file

@ -7,6 +7,7 @@
"delete": "Eliminar", "delete": "Eliminar",
"confirm": "Confirmar", "confirm": "Confirmar",
"close": "Cerrar", "close": "Cerrar",
"help": "Ayuda",
"back": "Volver", "back": "Volver",
"next": "Siguiente", "next": "Siguiente",
"previous": "Anterior", "previous": "Anterior",
@ -26,6 +27,8 @@
"register": "Registrarse", "register": "Registrarse",
"email": "Correo electrónico", "email": "Correo electrónico",
"password": "Contraseña", "password": "Contraseña",
"showPassword": "Mostrar contraseña",
"hidePassword": "Ocultar contraseña",
"username": "Nombre de usuario", "username": "Nombre de usuario",
"firstName": "Nombre", "firstName": "Nombre",
"lastName": "Apellido", "lastName": "Apellido",
@ -50,7 +53,8 @@
"notifications": "Notificaciones", "notifications": "Notificaciones",
"retry": "Reintentar", "retry": "Reintentar",
"retrying": "Reintentando...", "retrying": "Reintentando...",
"dismiss": "Descartar" "dismiss": "Descartar",
"loadingAria": "Cargando"
}, },
"auth": { "auth": {
"login": { "login": {
@ -63,29 +67,144 @@
"loginButton": "Iniciar sesión", "loginButton": "Iniciar sesión",
"noAccount": "¿No tienes una cuenta?", "noAccount": "¿No tienes una cuenta?",
"createAccount": "Crear cuenta", "createAccount": "Crear cuenta",
"orContinueWith": "o continuar con",
"footerLink": "¿No tienes cuenta? Regístrate",
"oauthProvider": "Iniciar sesión con {{provider}}",
"errors": { "errors": {
"invalidCredentials": "Correo o contraseña incorrectos", "invalidCredentials": "Correo o contraseña incorrectos",
"accountLocked": "Cuenta bloqueada", "accountLocked": "Cuenta bloqueada",
"emailNotVerified": "Correo no verificado" "emailNotVerified": "Correo no verificado",
"emailRequired": "El correo es obligatorio",
"emailInvalid": "Formato de correo inválido",
"passwordRequired": "La contraseña es obligatoria",
"connectionError": "Error de conexión. Verifica tu internet.",
"genericError": "Ocurrió un error. Inténtalo de nuevo."
} }
}, },
"twoFactor": {
"title": "Autenticación de dos factores",
"subtitle": "Ingresa el código de tu aplicación de autenticación",
"backToSignIn": "Volver a iniciar sesión",
"verificationCode": "Código de verificación",
"enterCode": "Ingresa el código de 6 dígitos de tu aplicación de autenticación para continuar.",
"lostAccess": "¿Perdiste el acceso?",
"useBackupCode": "Usar un código de respaldo",
"useAuthenticator": "Usar código de autenticador",
"backupCode": "Código de respaldo",
"verify": "Verificar",
"verifying": "Verificando...",
"cancel": "Cancelar",
"enterCodeError": "Por favor ingresa un código de verificación"
},
"layout": {
"pageLabel": "Página de autenticación",
"navLabel": "Navegación de autenticación"
},
"register": { "register": {
"title": "Crear cuenta", "title": "Registro",
"subtitle": "Únete a la comunidad Veza", "subtitle": "Crea tu cuenta",
"firstName": "Nombre", "firstName": "Nombre",
"lastName": "Apellido", "lastName": "Apellido",
"username": "Nombre de usuario", "username": "Nombre de usuario",
"email": "Correo electrónico", "email": "Correo electrónico",
"password": "Contraseña", "password": "Contraseña",
"confirmPassword": "Confirmar contraseña", "confirmPassword": "Confirmar contraseña",
"registerButton": "Crear cuenta", "registerButton": "Registrarse",
"loadingText": "Registrando...",
"hasAccount": "¿Ya tienes una cuenta?", "hasAccount": "¿Ya tienes una cuenta?",
"loginLink": "Iniciar sesión", "loginLink": "Iniciar sesión",
"footerLink": "¿Ya tienes una cuenta? Iniciar sesión",
"formAriaLabel": "Formulario de registro",
"errors": { "errors": {
"emailRequired": "Correo requerido",
"emailInvalid": "Correo inválido",
"usernameRequired": "Nombre de usuario requerido",
"usernameTooShort": "El nombre de usuario debe tener al menos 3 caracteres",
"usernameUnavailable": "Este nombre de usuario ya está en uso",
"passwordRequired": "Contraseña requerida",
"passwordTooShort": "La contraseña debe tener al menos 12 caracteres",
"passwordWeak": "La contraseña debe contener mayúscula, minúscula, número y carácter especial",
"confirmRequired": "Confirmación de contraseña requerida",
"passwordMismatch": "Las contraseñas no coinciden", "passwordMismatch": "Las contraseñas no coinciden",
"emailExists": "Este correo ya está en uso", "emailExists": "Este correo ya está en uso",
"usernameExists": "Este nombre de usuario ya está en uso", "usernameExists": "Este nombre de usuario ya está en uso",
"weakPassword": "La contraseña debe tener al menos 12 caracteres" "weakPassword": "La contraseña debe tener al menos 12 caracteres",
"termsRequired": "Debes aceptar los términos de servicio y la política de privacidad"
},
"terms": {
"accept": "Acepto los",
"termsOfService": "términos de servicio",
"termsAriaLabel": "Leer los términos de servicio",
"and": "y la",
"privacyPolicy": "política de privacidad",
"privacyAriaLabel": "Leer la política de privacidad",
"description": "Debes aceptar los términos de servicio y la política de privacidad para crear una cuenta"
},
"usernameCheck": {
"checking": "Verificando...",
"available": "Este nombre de usuario está disponible",
"unavailable": "Este nombre de usuario ya está en uso"
},
"passwordStrength": {
"label": "Fuerza de la contraseña: {{level}}",
"weak": "Débil",
"fair": "Regular",
"good": "Buena",
"strong": "Fuerte",
"reqLength": "Al menos 12 caracteres ({{current}}/12)",
"reqCase": "Mayúscula y minúscula",
"reqDigit": "Un número",
"reqSpecial": "Un carácter especial (!@#$%^&*...)"
},
"verification": {
"title": "¡Registro exitoso!",
"emailSent": "Se ha enviado un correo de verificación a",
"checkInbox": "Revisa tu bandeja de entrada y haz clic en el enlace de verificación.",
"resendButton": "Reenviar correo de verificación",
"resendLoading": "Enviando...",
"resendSuccess": "¡Correo de verificación reenviado con éxito!",
"resendError": "No se pudo reenviar el correo. Inténtalo de nuevo."
}
},
"verifyEmail": {
"title": {
"verifying": "Verificación de correo",
"success": "Correo verificado",
"error": "Verificación de correo"
},
"subtitle": {
"verifying": "Verificación en curso...",
"success": "Tu correo ha sido verificado con éxito",
"error": "Ocurrió un error"
},
"message": {
"verifying": "Verificando tu correo electrónico...",
"success": "¡Tu correo ha sido verificado con éxito!",
"invalidLink": "Enlace de verificación inválido o faltante",
"defaultError": "La verificación falló",
"resendSuccess": "¡Correo de verificación enviado! Revisa tu bandeja de entrada.",
"emailNotFound": "Correo no encontrado. Regístrate de nuevo o contacta al soporte.",
"resendError": "Error al enviar el correo"
},
"button": {
"retry": "Reintentar",
"resend": "Reenviar correo de verificación",
"resendCooldown": "Reenviar en {{seconds}}s",
"resendCooldownAriaLabel": "Reenviar correo de verificación en {{seconds}} segundos",
"resendAriaLabel": "Reenviar correo de verificación"
},
"success": {
"title": "¡Éxito!",
"redirecting": "Serás redirigido a la página de inicio de sesión..."
},
"error": {
"title": "Error"
},
"srOnly": {
"verifying": "Verificando tu correo electrónico, por favor espera"
},
"footer": {
"backToLogin": "Volver al inicio de sesión"
} }
}, },
"forgotPassword": { "forgotPassword": {
@ -94,9 +213,136 @@
"email": "Correo electrónico", "email": "Correo electrónico",
"sendButton": "Enviar enlace", "sendButton": "Enviar enlace",
"backToLogin": "Volver al inicio de sesión", "backToLogin": "Volver al inicio de sesión",
"success": "Correo de restablecimiento enviado" "success": "Correo de restablecimiento enviado",
"successTitle": "Revisa tu correo",
"successBody": "Si existe una cuenta con ese correo, hemos enviado instrucciones para restablecer la contraseña.",
"checkInbox": "Revisa tu bandeja de entrada y haz clic en el enlace para restablecer tu contraseña.",
"resendButton": "Reenviar correo",
"formAriaLabel": "Formulario de restablecimiento de contraseña",
"pageTitle": "Contraseña olvidada - Veza",
"errors": {
"emailRequired": "Correo requerido",
"emailInvalid": "Formato de correo inválido"
} }
}, },
"resetPassword": {
"title": "Restablecer contraseña",
"subtitle": "Ingresa tu nueva contraseña",
"pageTitle": "Restablecer contraseña - Veza",
"password": "Nueva contraseña",
"confirmPassword": "Confirmar contraseña",
"submitButton": "Restablecer contraseña",
"backToLogin": "Volver al inicio de sesión",
"requestNewLink": "Solicitar un nuevo enlace",
"formAriaLabel": "Formulario de restablecimiento de contraseña",
"invalidToken": {
"title": "Enlace de restablecimiento inválido",
"subtitle": "El enlace de restablecimiento es inválido o ha expirado",
"heading": "Enlace inválido",
"body": "El enlace de restablecimiento es inválido o ha expirado. Solicita un nuevo enlace."
},
"success": {
"title": "Contraseña restablecida",
"subtitle": "Tu contraseña ha sido cambiada con éxito",
"heading": "¡Éxito!",
"body": "Tu contraseña ha sido restablecida con éxito.",
"redirecting": "Serás redirigido a la página de inicio de sesión en {{seconds}}s..."
},
"errors": {
"passwordRequired": "Contraseña requerida",
"passwordTooShort": "La contraseña debe tener al menos 8 caracteres",
"confirmRequired": "Confirmación de contraseña requerida",
"passwordMismatch": "Las contraseñas no coinciden"
}
}
},
"landing": {
"nav": {
"product": "PRODUCTO",
"platform": "PLATAFORMA",
"login": "INICIAR SESIÓN",
"ariaLabel": "Navegación principal"
},
"hero": {
"tagline1": "Hardware de audio profesional — abierto, reparable, transparente.",
"tagline2": "Plataforma musical ética — sin rastreo, sin algoritmo.",
"cta": "Lanzamiento pronto — Únete a los primeros",
"placeholder": "tu@email.com",
"submit": "UNIRSE",
"discover": "DESCUBRIR"
},
"values": {
"kicker": "三つの柱",
"title": "Tres compromisos",
"card1": {
"title": "Hardware Abierto",
"desc": "Esquemas publicados bajo licencia CERN-OHL. Puedes construir, reparar y mejorar cada componente. Sin obsolescencia programada."
},
"card2": {
"title": "Plataforma Ética",
"desc": "Cero rastreo comportamental. Cero algoritmo de manipulación. Feed cronológico. Datos privados. Código open-source (AGPL-3.0)."
},
"card3": {
"title": "Comunidad Artista",
"desc": "Streaming, marketplace, chat en tiempo real, playlists colaborativas. Compensación transparente. Los artistas controlan su música."
}
},
"product": {
"kicker": "Primer producto",
"title": "Micrófono Condensador",
"desc": "Diafragma grande. Preamplificador OPA1642. Cuerpo de aluminio mecanizado. Esquemas publicados, componentes estándar, guía de reparación incluida. Garantía de 5 años.",
"feat1": "Esquemas KiCAD publicados — CERN-OHL-W",
"feat2": "Reparable — sin pegamento, componentes estándar",
"feat3": "Fabricado en Francia — sourcing documentado",
"feat4": "~150 € — transparencia total de costos",
"cta": "RECIBIR NOTIFICACIÓN DEL LANZAMIENTO"
},
"platform": {
"kicker": "La plataforma",
"subtitle": "墨 STREAMING — DEL MICRÓFONO AL OYENTE",
"streaming": "Streaming HLS",
"community": "Comunidad",
"marketplace": "Marketplace",
"privacy": "Privacidad",
"openSource": "Open source",
"zeroTracking": "Cero rastreo",
"stats": "435 000 líneas de código. Auditoría de seguridad externa. 34 suites de tests. Backend Go + Stream server Rust + Frontend React. Auto-hospedado. Sin cloud. Sin VC."
},
"notify": {
"title": "Únete a los primeros",
"desc": "Regístrate para ser notificado del lanzamiento. Sin spam — un solo correo el día del lanzamiento.",
"submit": "NOTIFICARME",
"placeholder": "tu@email.com",
"ariaLabel": "Correo electrónico para notificación de lanzamiento"
},
"form": {
"successHero": "Registro confirmado. ¡Hasta pronto!",
"successCta": "Registro confirmado. ¡Hasta pronto!",
"errorSubscription": "La suscripción falló",
"errorGeneric": "Ocurrió un error",
"loadingAriaLabel": "Enviando..."
},
"footer": {
"openSource": "Open Source",
"privacy": "Privacidad",
"contact": "Contacto",
"tagline": "TECNOLOGÍA AUDIO ÉTICA — HECHO EN FRANCIA"
},
"pageTitle": "TALAS — Hardware de audio ético y Plataforma"
},
"feed": {
"title": "Feed",
"subtitle": "Últimas pistas de tus artistas",
"emptyTitle": "Tu feed está vacío",
"emptyDescription": "Sigue artistas para ver sus últimas pistas aquí.",
"newReleasesInGenres": "Nuevos lanzamientos en tus géneros",
"followedArtists": "Artistas seguidos",
"noNewTracks": "No hay pistas nuevas",
"suggestedAccounts": "Cuentas sugeridas",
"followers_one": "{{count}} seguidor",
"followers_other": "{{count}} seguidores",
"seeAll": "Ver todo"
},
"dashboard": { "dashboard": {
"title": "Panel de control", "title": "Panel de control",
"welcome": "¡Bienvenido, {{name}}!", "welcome": "¡Bienvenido, {{name}}!",
@ -144,6 +390,13 @@
"notifyMe": "Notificarme", "notifyMe": "Notificarme",
"goBack": "Volver" "goBack": "Volver"
}, },
"designSystem": {
"pageTitle": "Design System — Veza",
"title": "Design System",
"subtitle": "Biblioteca de componentes y referencia visual",
"underConstruction": "Esta página está en construcción. Los componentes se mostrarán aquí pronto.",
"backToHome": "Volver al inicio"
},
"player": { "player": {
"miniPlayerAriaLabel": "Mini reproductor de audio", "miniPlayerAriaLabel": "Mini reproductor de audio",
"expandPlayer": "Expandir reproductor", "expandPlayer": "Expandir reproductor",
@ -218,6 +471,22 @@
"description": "Sube tu primera pista o crea una playlist para empezar.", "description": "Sube tu primera pista o crea una playlist para empezar.",
"uploadButton": "Subir archivo", "uploadButton": "Subir archivo",
"uploadTrack": "Subir pista" "uploadTrack": "Subir pista"
},
"new": "Nuevo",
"searchPlaceholder": "Buscar...",
"table": {
"label": "Lista de mis pistas",
"title": "Título",
"artist": "Artista",
"date": "Fecha",
"duration": "Duración",
"download": "Descargar",
"delete": "Eliminar",
"moreOptions": "Más opciones para {{title}}"
},
"grid": {
"label": "Cuadrícula de pistas de la biblioteca",
"play": "Reproducir {{title}}"
} }
}, },
"profile": { "profile": {
@ -246,6 +515,58 @@
"bioPlaceholder": "Cuéntanos sobre ti..." "bioPlaceholder": "Cuéntanos sobre ti..."
} }
}, },
"profilePublic": {
"pageTitle": "{{displayName}} — Veza",
"about": "Acerca de",
"noBio": "Sistemas en línea. Sin biografía disponible.",
"links": "Enlaces",
"joined": "Miembro desde {{date}}",
"tabs": {
"tracks": "Pistas",
"playlists": "Playlists",
"reposts": "Reposts",
"feed": "Feed"
},
"stats": {
"tracks": "Pistas",
"playlists": "Playlists",
"followers": "Seguidores",
"following": "Siguiendo"
},
"empty": {
"noTracks": "Aún no hay pistas",
"noTracksDesc": "Este usuario aún no tiene pistas públicas.",
"noPlaylists": "Aún no hay playlists",
"noPlaylistsDesc": "No se encontraron playlists públicas para este usuario.",
"noPosts": "Aún no hay publicaciones",
"noPostsDesc": "Este usuario aún no ha publicado nada.",
"noReposts": "Aún no hay reposts",
"noRepostsDesc": "Este usuario aún no ha reposteado pistas."
},
"reposted": "Reposteado",
"unknownArtist": "Artista desconocido",
"error": {
"notFound": "Usuario no encontrado",
"notFoundDesc": "La señal se perdió en el vacío. No encontramos el perfil que buscabas.",
"generic": "Algo salió mal",
"genericDesc": "No pudimos cargar este perfil. Verifica tu conexión e inténtalo de nuevo.",
"tryAgain": "Reintentar",
"returnToBase": "Volver a la base"
},
"private": {
"title": "Perfil privado",
"description": "Este perfil está oculto. Su contenido no es visible."
},
"follow": {
"follow": "Seguir",
"following": "Siguiendo",
"subscribing": "Suscribiendo...",
"unsubscribing": "Cancelando...",
"followSuccess": "Ahora sigues a este usuario",
"unfollowSuccess": "Ya no sigues a este usuario"
},
"loading": "Cargando perfil"
},
"settings": { "settings": {
"title": "Configuración", "title": "Configuración",
"subtitle": "Gestiona tus preferencias y configuración de cuenta", "subtitle": "Gestiona tus preferencias y configuración de cuenta",
@ -486,11 +807,150 @@
"genre": "Género", "genre": "Género",
"year": "Año", "year": "Año",
"plays": "Reproducciones", "plays": "Reproducciones",
"likes": "Me gusta" "likes": "Me gusta",
"grid": {
"label": "Cuadrícula de pistas",
"track": "Pista: {{title}}",
"play": "Reproducir {{title}}",
"pause": "Pausar {{title}}",
"coverAlt": "Portada de {{title}}",
"moreOptions": "Más opciones para {{title}}",
"nowPlaying": "Reproduciendo",
"densityCompact": "Compacto",
"densityDefault": "Estándar",
"densityLarge": "Grande"
},
"detail": {
"back": "Volver",
"addToQueue": "Añadir a la cola",
"addedToQueue": "Añadido a la cola",
"listenTogether": "Escuchar juntos",
"edit": "Editar",
"startListening": "Empezar a escuchar",
"close": "Cerrar",
"listenTogetherHelp": "Comparte este enlace con amigos para escuchar juntos. La reproducción estará sincronizada.",
"shareLink": "Enlace para compartir",
"couldNotCreateSession": "No se pudo crear la sesión de escucha",
"linkCopied": "Enlace copiado al portapapeles",
"linkCopyFailed": "Error al copiar el enlace",
"playsLabel": "Reproducciones",
"likesLabel": "Me gusta",
"format": "Formato",
"bitrate": "Tasa de bits",
"sampleRate": "Frecuencia de muestreo",
"bpm": "BPM",
"key": "Tonalidad",
"tags": "Etiquetas",
"uploaded": "Subido",
"discussion": "Discusión",
"analytics": "Estadísticas",
"history": "Historial",
"lyrics": "Letras",
"stems": "Stems",
"performanceData": "Datos de rendimiento",
"versionHistory": "Historial de versiones",
"notFound": "Pista no encontrada",
"failedToLoad": "Error al cargar la pista",
"goBack": "Volver"
},
"commentSection": {
"title": "Comentarios",
"titleWithCount": "Comentarios ({{count}})",
"placeholder": "Escribe un comentario...",
"empty": "Aún no hay comentarios. ¡Sé el primero en comentar!",
"publishError": "Error al publicar el comentario",
"publishSuccess": "Comentario publicado",
"loginToComment": "Inicia sesión para comentar",
"loadError": "Error al cargar los comentarios"
},
"repost": {
"reposted": "Pista añadida a tu perfil",
"repostFailed": "Error al repostear",
"unreposted": "Repost eliminado",
"unrepostFailed": "Error al eliminar el repost",
"repostAction": "Repostear en tu perfil",
"unrepostAction": "Eliminar repost"
},
"likeAction": {
"added": "Añadido a favoritos",
"addFailed": "Error al añadir a favoritos",
"removed": "Eliminado de favoritos",
"removeFailed": "Error al eliminar de favoritos"
},
"shareDialog": {
"title": "Compartir pista",
"creatingLink": "Creando enlace para compartir...",
"shareLink": "Enlace para compartir",
"expiresIn": "Este enlace expira en 7 día(s)",
"close": "Cerrar",
"copyLink": "Copiar enlace",
"createFailed": "Error al crear el enlace para compartir",
"linkCopied": "Enlace copiado al portapapeles",
"linkCopyFailed": "Error al copiar el enlace"
},
"lyricsSection": {
"title": "Letras",
"loadError": "No se pudieron cargar las letras.",
"empty": "No hay letras disponibles para esta pista.",
"showLess": "Ver menos",
"showMore": "Ver más"
},
"stemsSection": {
"title": "Stems",
"upload": "Subir",
"loading": "Cargando stems...",
"loadError": "Error al cargar los stems.",
"empty": "No hay stems disponibles.",
"uploadHelp": "Sube stems (WAV, AIFF, FLAC) para compartir con colaboradores."
},
"stats": {
"scanning": "ANALIZANDO...",
"telemetryError": "Telemetría interrumpida",
"views": "Vistas",
"likes": "Me gusta",
"comments": "Com.",
"downloads": "Datos",
"playTime": "Duración"
}
},
"discover": {
"title": "Descubrir",
"subtitle": "Explora por género, etiqueta o playlist editorial",
"byGenre": "Por género",
"editorialPlaylists": "Playlists editoriales",
"noEditorialPlaylists": "No hay playlists editoriales disponibles por el momento",
"back": "Volver",
"noTracksInGenre": "No hay pistas en este género",
"browseGenre": "Explorar pistas de {{genre}}",
"trackCount": "{{count}} pistas"
}, },
"playlists": { "playlists": {
"title": "Playlists", "title": "Playlists",
"pageTitle": "Playlists — Veza",
"subtitle": "Descubre y gestiona tus playlists",
"create": "Crear playlist", "create": "Crear playlist",
"createButton": "Crear",
"createButtonMobile": "Nueva",
"createNewPlaylist": "Crear una nueva playlist",
"importButton": "Importar",
"selectButton": "Seleccionar",
"deselectButton": "Cancelar",
"enableSelection": "Activar selección",
"disableSelection": "Desactivar selección",
"searchPlaceholder": "Buscar playlists...",
"filtersButton": "Filtros",
"filtersActive": "Activo",
"clearFilters": "Borrar",
"filterVisibility": "Visibilidad",
"filterOwner": "Propietario",
"filterSortBy": "Ordenar por",
"sortToggle": "Cambiar orden",
"all": "Todo",
"myPlaylists": "Mis playlists",
"others": "Otros",
"sortByDate": "Fecha",
"sortByTitle": "Título",
"sortByTracks": "Pistas",
"edit": "Editar playlist", "edit": "Editar playlist",
"delete": "Eliminar playlist", "delete": "Eliminar playlist",
"follow": "Seguir", "follow": "Seguir",
@ -504,10 +964,188 @@
"addCollaborator": "Añadir colaborador", "addCollaborator": "Añadir colaborador",
"removeCollaborator": "Quitar colaborador", "removeCollaborator": "Quitar colaborador",
"noPlaylists": "No hay playlists disponibles", "noPlaylists": "No hay playlists disponibles",
"emptyTitle": "Aún no hay playlists",
"emptyDescription": "Comienza creando tu primera playlist para organizar tus pistas.",
"loading": "Cargando playlists...", "loading": "Cargando playlists...",
"tracks": "Pistas", "tracks": "Pistas",
"trackListLabel": "Pistas de la playlist",
"trackItem": "Pista {{position}}: {{title}}",
"public": "Pública", "public": "Pública",
"private": "Privada" "private": "Privada",
"createDialog": {
"title": "Crear una playlist",
"titleLabel": "Título",
"titlePlaceholder": "Mi nueva playlist",
"titleRequired": "El título es obligatorio",
"descriptionLabel": "Descripción",
"descriptionPlaceholder": "Describe tu playlist...",
"publicPlaylist": "Playlist pública",
"cancel": "Cancelar creación de playlist",
"cancelButton": "Cancelar",
"submit": "Crear la playlist",
"submitButton": "Crear"
},
"shared": {
"playAll": "Reproducir todo",
"shuffle": "Aleatorio",
"copyLink": "Copiar enlace",
"linkCopied": "Enlace copiado al portapapeles",
"sharedPlaylist": "Playlist compartida",
"trackCount": "{{count}} pista",
"trackCount_other": "{{count}} pistas",
"notFound": "Playlist no encontrada",
"backToLibrary": "Volver a la biblioteca",
"noTracks": "No hay pistas en esta playlist",
"noTracksDescription": "Esta playlist compartida está vacía.",
"publicSignal": "Pública",
"encrypted": "Privada",
"updated": "Actualizada el {{date}}",
"followers": "{{count}} seguidores"
},
"detail": {
"reorder": "Reordenar",
"playTrack": "Reproducir {{title}}",
"pauseTrack": "Pausar {{title}}",
"coverAlt": "Portada de {{title}}",
"addToFavorites": "Añadir {{title}} a Favoritos",
"filterTracks": "Filtrar pistas...",
"addTracks": "Añadir pistas",
"squadMembers": "Miembros del grupo",
"invite": "Invitar",
"suggestedForYou": "Sugerencias para ti",
"recommendations": "Recomendaciones",
"trackAdded": "Pista añadida",
"trackRemoved": "Pista eliminada",
"reordered": "Reordenado",
"playlistReordered": "Playlist reordenada",
"reorderError": "No se pudo reordenar la playlist. Inténtalo de nuevo.",
"emptyTracks": "No hay pistas en esta playlist",
"emptyTracksDescription": "Añade pistas a esta playlist para comenzar.",
"loadingTracks": "Cargando pistas",
"loadingTracksProgress": "Cargando pistas..."
},
"actions": {
"groupLabel": "Acciones de la playlist",
"edit": "Editar",
"editPlaylist": "Editar playlist",
"saving": "Guardando...",
"saved": "Guardado",
"share": "Compartir",
"sharePlaylist": "Compartir playlist",
"delete": "Eliminar",
"deletePlaylist": "Eliminar playlist",
"deleteTitle": "Eliminar playlist",
"deleteConfirmation": "¿Estás seguro de que quieres eliminar \"{{title}}\"? Esta acción es irreversible. Todas las pistas de la playlist se eliminarán.",
"deleteConfirm": "Eliminar",
"deleteCancel": "Cancelar",
"updateSuccess": "Playlist actualizada con éxito",
"updateError": "Error al actualizar la playlist",
"deleteSuccess": "Playlist eliminada con éxito",
"deleteError": "Error al eliminar la playlist"
},
"editDialog": {
"title": "Editar playlist",
"save": "Guardar",
"saving": "Guardando...",
"cancel": "Cancelar",
"titleLabel": "Título",
"titlePlaceholder": "Título de la playlist",
"descriptionLabel": "Descripción",
"descriptionPlaceholder": "Descripción de la playlist",
"coverUrlLabel": "URL de la portada",
"isPublic": "Playlist pública",
"savingInProgress": "Guardando..."
},
"form": {
"editAriaLabel": "Formulario de edición de playlist",
"createAriaLabel": "Formulario de creación de playlist",
"titleLabel": "Título",
"titlePlaceholder": "Mi playlist",
"titleRequired": "El título es obligatorio",
"titleMaxLength": "El título no puede superar los 200 caracteres",
"descriptionLabel": "Descripción",
"descriptionPlaceholder": "Describe tu playlist...",
"descriptionMaxLength": "La descripción no puede superar los 2000 caracteres",
"coverUrlLabel": "URL de la portada",
"coverUrlMaxLength": "La URL no puede superar los 500 caracteres",
"coverUrlInvalid": "La URL de la portada debe ser válida",
"isPublic": "Playlist pública",
"cancel": "Cancelar",
"cancelEdit": "Cancelar edición",
"save": "Guardar",
"create": "Crear",
"saveChanges": "Guardar cambios",
"createPlaylist": "Crear playlist",
"updateSuccess": "Playlist actualizada con éxito",
"createSuccess": "Playlist creada con éxito",
"genericError": "Se produjo un error"
},
"duplicate": {
"button": "Duplicar",
"duplicating": "Duplicando...",
"ariaLabel": "Duplicar playlist",
"copySuffix": "{{title}} (copia)",
"success": "Playlist duplicada con éxito",
"error": "Error al duplicar la playlist"
},
"export": {
"button": "Exportar",
"exporting": "Exportando...",
"json": "Exportar como JSON",
"csv": "Exportar como CSV",
"m3u": "Exportar como M3U",
"success": "Playlist exportada en formato {{format}}",
"authRequired": "Debes iniciar sesión para exportar una playlist",
"forbidden": "No tienes permiso para exportar esta playlist",
"notFound": "Playlist no encontrada",
"error": "Error durante la exportación",
"genericError": "Se produjo un error durante la exportación"
},
"followBtn": {
"follow": "Seguir",
"following": "Siguiendo",
"unfollowing": "Dejando de seguir...",
"subscribing": "Siguiendo...",
"followSuccess": "Ahora sigues esta playlist",
"followError": "Error al seguir la playlist",
"unfollowSuccess": "Ya no sigues esta playlist",
"unfollowError": "Error al dejar de seguir la playlist"
}
},
"queue": {
"pageTitle": "Cola de reproducción — Veza",
"heading": "COLA DE REPRODUCCIÓN",
"tracksUpcoming_one": "{{count}} pista siguiente",
"tracksUpcoming_other": "{{count}} pistas siguientes",
"nowPlaying": "Reproduciendo ahora",
"upNext": "A continuación",
"emptyTitle": "Nada en tu cola",
"emptyDescription": "Reproduce música y añade pistas para llenar tu cola.",
"saveQueue": "Guardar cola",
"clear": "Vaciar",
"clearTitle": "Vaciar la cola",
"clearDescription": "Eliminar todas las pistas de tu cola. Esta acción no se puede deshacer.",
"clearConfirm": "Vaciar",
"removeFromQueue": "Quitar de la cola",
"reorderTrack": "Reordenar {{title}}",
"playTrack": "Reproducir {{title}}",
"pauseTrack": "Pausar {{title}}",
"emptyQueueError": "La cola está vacía",
"savedAs": "Cola guardada como \"{{name}}\"",
"saveAsPlaylist": {
"title": "Guardar cola como playlist",
"nameLabel": "Nombre de la playlist",
"namePlaceholder": "Mi sesión de cola",
"privatePlaylist": "Playlist privada",
"publicPlaylist": "Playlist pública",
"privateDescription": "Solo visible para ti",
"publicDescription": "Visible en tu perfil",
"toggleVisibility": "Cambiar visibilidad de la playlist",
"cancel": "Cancelar",
"save": "Guardar playlist",
"nameRequired": "Por favor, nombra tu playlist",
"saveFailed": "Error al guardar la playlist"
}
}, },
"notifications": { "notifications": {
"title": "Notificaciones", "title": "Notificaciones",
@ -525,13 +1163,35 @@
}, },
"search": { "search": {
"title": "Búsqueda", "title": "Búsqueda",
"pageTitle": "Búsqueda — Veza",
"heading": "Búsqueda",
"placeholder": "Buscar pistas, playlists, usuarios...", "placeholder": "Buscar pistas, playlists, usuarios...",
"searchPlaceholder": "Buscar pistas, artistas, playlists...",
"clearSearch": "Borrar búsqueda",
"results": "Resultados", "results": "Resultados",
"noResults": "No se encontraron resultados", "resultsCount_one": "{{count}} resultado encontrado",
"resultsCount_other": "{{count}} resultados encontrados",
"allResults": "Todos los resultados",
"topTracks": "Mejores pistas",
"tracks": "Pistas", "tracks": "Pistas",
"artists": "Artistas",
"playlists": "Playlists", "playlists": "Playlists",
"users": "Usuarios", "users": "Usuarios",
"all": "Todo" "all": "Todo",
"noResults": "No se encontraron resultados",
"noResultsHint": "Intenta ajustar tu búsqueda o usa palabras clave diferentes.",
"noDescription": "Sin descripción",
"recentSearches": "Búsquedas recientes",
"clearHistory": "Borrar historial de búsqueda",
"helpText": "Usa AND, OR, NOT y \"frase exacta\" para refinar tu búsqueda.",
"discovery": {
"newReleases": "Novedades",
"newReleasesDesc": "Últimas pistas de tus artistas",
"curatedMixes": "Selecciones",
"curatedMixesDesc": "Selecciones hechas a mano para ti",
"exploreArtists": "Explorar artistas",
"exploreArtistsDesc": "Descubre artistas de la comunidad"
}
}, },
"analytics": { "analytics": {
"title": "Analíticas", "title": "Analíticas",

View file

@ -7,6 +7,7 @@
"delete": "Supprimer", "delete": "Supprimer",
"confirm": "Confirmer", "confirm": "Confirmer",
"close": "Fermer", "close": "Fermer",
"help": "Aide",
"back": "Retour", "back": "Retour",
"next": "Suivant", "next": "Suivant",
"previous": "Précédent", "previous": "Précédent",
@ -26,6 +27,8 @@
"register": "S'inscrire", "register": "S'inscrire",
"email": "Email", "email": "Email",
"password": "Mot de passe", "password": "Mot de passe",
"showPassword": "Afficher le mot de passe",
"hidePassword": "Masquer le mot de passe",
"username": "Nom d'utilisateur", "username": "Nom d'utilisateur",
"firstName": "Prénom", "firstName": "Prénom",
"lastName": "Nom", "lastName": "Nom",
@ -50,7 +53,8 @@
"notifications": "Notifications", "notifications": "Notifications",
"retry": "Réessayer", "retry": "Réessayer",
"retrying": "Nouvelle tentative...", "retrying": "Nouvelle tentative...",
"dismiss": "Fermer" "dismiss": "Fermer",
"loadingAria": "Chargement en cours"
}, },
"auth": { "auth": {
"login": { "login": {
@ -63,29 +67,144 @@
"loginButton": "Se connecter", "loginButton": "Se connecter",
"noAccount": "Pas encore de compte ?", "noAccount": "Pas encore de compte ?",
"createAccount": "Créer un compte", "createAccount": "Créer un compte",
"orContinueWith": "ou continuer avec",
"footerLink": "Pas encore de compte ? S'inscrire",
"oauthProvider": "Se connecter avec {{provider}}",
"errors": { "errors": {
"invalidCredentials": "Email ou mot de passe incorrect", "invalidCredentials": "Email ou mot de passe incorrect",
"accountLocked": "Compte verrouillé", "accountLocked": "Compte verrouillé",
"emailNotVerified": "Email non vérifié" "emailNotVerified": "Email non vérifié",
"emailRequired": "Email requis",
"emailInvalid": "Format email invalide",
"passwordRequired": "Mot de passe requis",
"connectionError": "Erreur de connexion. Vérifiez votre internet.",
"genericError": "Une erreur est survenue. Veuillez réessayer."
} }
}, },
"twoFactor": {
"title": "Authentification à deux facteurs",
"subtitle": "Entrez le code de votre application d'authentification",
"backToSignIn": "Retour à la connexion",
"verificationCode": "Code de vérification",
"enterCode": "Entrez le code à 6 chiffres de votre application d'authentification pour continuer.",
"lostAccess": "Accès perdu ?",
"useBackupCode": "Utiliser un code de secours",
"useAuthenticator": "Utiliser le code d'authentification",
"backupCode": "Code de secours",
"verify": "Vérifier",
"verifying": "Vérification...",
"cancel": "Annuler",
"enterCodeError": "Veuillez entrer un code de vérification"
},
"layout": {
"pageLabel": "Page d'authentification",
"navLabel": "Navigation d'authentification"
},
"register": { "register": {
"title": "Créer un compte", "title": "Inscription",
"subtitle": "Rejoignez la communauté Veza", "subtitle": "Créez votre compte",
"firstName": "Prénom", "firstName": "Prénom",
"lastName": "Nom", "lastName": "Nom",
"username": "Nom d'utilisateur", "username": "Nom d'utilisateur",
"email": "Email", "email": "Email",
"password": "Mot de passe", "password": "Mot de passe",
"confirmPassword": "Confirmer le mot de passe", "confirmPassword": "Confirmer le mot de passe",
"registerButton": "Créer le compte", "registerButton": "S'inscrire",
"loadingText": "Inscription en cours...",
"hasAccount": "Déjà un compte ?", "hasAccount": "Déjà un compte ?",
"loginLink": "Se connecter", "loginLink": "Se connecter",
"footerLink": "Déjà un compte ? Se connecter",
"formAriaLabel": "Formulaire d'inscription",
"errors": { "errors": {
"emailRequired": "Email requis",
"emailInvalid": "Email invalide",
"usernameRequired": "Nom d'utilisateur requis",
"usernameTooShort": "Le nom d'utilisateur doit contenir au moins 3 caractères",
"usernameUnavailable": "Ce nom d'utilisateur est déjà pris",
"passwordRequired": "Mot de passe requis",
"passwordTooShort": "Le mot de passe doit contenir au moins 12 caractères",
"passwordWeak": "Le mot de passe doit contenir majuscule, minuscule, chiffre et caractère spécial",
"confirmRequired": "Confirmation du mot de passe requise",
"passwordMismatch": "Les mots de passe ne correspondent pas", "passwordMismatch": "Les mots de passe ne correspondent pas",
"emailExists": "Cet email est déjà utilisé", "emailExists": "Cet email est déjà utilisé",
"usernameExists": "Ce nom d'utilisateur est déjà pris", "usernameExists": "Ce nom d'utilisateur est déjà pris",
"weakPassword": "Le mot de passe doit contenir au moins 8 caractères" "weakPassword": "Le mot de passe doit contenir au moins 12 caractères",
"termsRequired": "Vous devez accepter les conditions d'utilisation et la politique de confidentialité"
},
"terms": {
"accept": "J'accepte les",
"termsOfService": "conditions d'utilisation",
"termsAriaLabel": "Lire les conditions d'utilisation",
"and": "et la",
"privacyPolicy": "politique de confidentialité",
"privacyAriaLabel": "Lire la politique de confidentialité",
"description": "Vous devez accepter les conditions d'utilisation et la politique de confidentialité pour créer un compte"
},
"usernameCheck": {
"checking": "Vérification...",
"available": "Ce nom d'utilisateur est disponible",
"unavailable": "Ce nom d'utilisateur est déjà pris"
},
"passwordStrength": {
"label": "Force du mot de passe : {{level}}",
"weak": "Faible",
"fair": "Correct",
"good": "Bon",
"strong": "Fort",
"reqLength": "Au moins 12 caractères ({{current}}/12)",
"reqCase": "Majuscule et minuscule",
"reqDigit": "Un chiffre",
"reqSpecial": "Un caractère spécial (!@#$%^&*...)"
},
"verification": {
"title": "Inscription réussie !",
"emailSent": "Un email de vérification a été envoyé à",
"checkInbox": "Veuillez vérifier votre boîte mail et cliquer sur le lien de vérification.",
"resendButton": "Renvoyer l'email de vérification",
"resendLoading": "Envoi en cours...",
"resendSuccess": "Email de vérification renvoyé avec succès !",
"resendError": "Impossible de renvoyer l'email. Veuillez réessayer."
}
},
"verifyEmail": {
"title": {
"verifying": "Vérification de l'email",
"success": "Email vérifié",
"error": "Vérification de l'email"
},
"subtitle": {
"verifying": "Vérification en cours...",
"success": "Votre email a été vérifié avec succès",
"error": "Une erreur s'est produite"
},
"message": {
"verifying": "Vérification de votre email en cours...",
"success": "Votre email a été vérifié avec succès !",
"invalidLink": "Lien de vérification invalide ou manquant",
"defaultError": "La vérification a échoué",
"resendSuccess": "Email de vérification envoyé ! Veuillez vérifier votre boîte mail.",
"emailNotFound": "Email non trouvé. Veuillez vous réinscrire ou contacter le support.",
"resendError": "Échec de l'envoi de l'email"
},
"button": {
"retry": "Réessayer",
"resend": "Renvoyer l'email de vérification",
"resendCooldown": "Renvoyer dans {{seconds}}s",
"resendCooldownAriaLabel": "Renvoyer l'email de vérification dans {{seconds}} secondes",
"resendAriaLabel": "Renvoyer l'email de vérification"
},
"success": {
"title": "Succès !",
"redirecting": "Vous allez être redirigé vers la page de connexion..."
},
"error": {
"title": "Erreur"
},
"srOnly": {
"verifying": "Vérification de votre email en cours, veuillez patienter"
},
"footer": {
"backToLogin": "Retour à la connexion"
} }
}, },
"forgotPassword": { "forgotPassword": {
@ -94,16 +213,135 @@
"email": "Email", "email": "Email",
"sendButton": "Envoyer le lien", "sendButton": "Envoyer le lien",
"backToLogin": "Retour à la connexion", "backToLogin": "Retour à la connexion",
"success": "Email de réinitialisation envoyé" "success": "Email de réinitialisation envoyé",
"successTitle": "Vérifiez votre email",
"successBody": "Si un compte avec cette adresse email existe, nous avons envoyé les instructions de réinitialisation.",
"checkInbox": "Veuillez vérifier votre boîte mail et cliquer sur le lien pour réinitialiser votre mot de passe.",
"resendButton": "Renvoyer l'email",
"formAriaLabel": "Formulaire de réinitialisation de mot de passe",
"pageTitle": "Mot de passe oublié - Veza",
"errors": {
"emailRequired": "Email requis",
"emailInvalid": "Format d'email invalide"
} }
}, },
"resetPassword": {
"title": "Réinitialiser le mot de passe",
"subtitle": "Entrez votre nouveau mot de passe",
"pageTitle": "Réinitialisation mot de passe - Veza",
"password": "Nouveau mot de passe",
"confirmPassword": "Confirmer le mot de passe",
"submitButton": "Réinitialiser le mot de passe",
"backToLogin": "Retour à la connexion",
"requestNewLink": "Demander un nouveau lien",
"formAriaLabel": "Formulaire de réinitialisation de mot de passe",
"invalidToken": {
"title": "Lien de réinitialisation invalide",
"subtitle": "Le lien de réinitialisation est invalide ou a expiré",
"heading": "Lien invalide",
"body": "Le lien de réinitialisation est invalide ou a expiré. Veuillez demander un nouveau lien."
},
"success": {
"title": "Mot de passe réinitialisé",
"subtitle": "Votre mot de passe a été modifié avec succès",
"heading": "Succès !",
"body": "Votre mot de passe a été réinitialisé avec succès.",
"redirecting": "Vous allez être redirigé vers la page de connexion dans {{seconds}}s..."
},
"errors": {
"passwordRequired": "Mot de passe requis",
"passwordTooShort": "Le mot de passe doit contenir au moins 8 caractères",
"confirmRequired": "Confirmation du mot de passe requise",
"passwordMismatch": "Les mots de passe ne correspondent pas"
}
}
},
"landing": {
"nav": {
"product": "PRODUIT",
"platform": "PLATEFORME",
"login": "CONNEXION",
"ariaLabel": "Navigation principale"
},
"hero": {
"tagline1": "Matériel audio professionnel — ouvert, réparable, transparent.",
"tagline2": "Plateforme musicale éthique — sans tracking, sans algorithme.",
"cta": "Lancement bientôt — Rejoins les premiers",
"placeholder": "ton@email.com",
"submit": "REJOINDRE",
"discover": "DÉCOUVRIR"
},
"values": {
"kicker": "三つの柱",
"title": "Trois engagements",
"card1": {
"title": "Hardware Ouvert",
"desc": "Schémas publiés sous licence CERN-OHL. Tu peux construire, réparer et améliorer chaque composant. Pas d'obsolescence programmée."
},
"card2": {
"title": "Plateforme Éthique",
"desc": "Zéro tracking comportemental. Zéro algorithme de manipulation. Flux chronologique. Données privées. Code open-source (AGPL-3.0)."
},
"card3": {
"title": "Communauté Artiste",
"desc": "Streaming, marketplace, chat en temps réel, playlists collaboratives. Rémunération transparente. Les artistes contrôlent leur musique."
}
},
"product": {
"kicker": "Premier produit",
"title": "Microphone Condensateur",
"desc": "Large diaphragme. Préampli OPA1642. Corps aluminium usiné. Schémas publiés, composants standards, guide de réparation inclus. Garantie 5 ans.",
"feat1": "Schémas KiCAD publiés — CERN-OHL-W",
"feat2": "Réparable — pas de colle, composants standards",
"feat3": "Fabriqué en France — sourcing documenté",
"feat4": "~150 € — transparence totale des coûts",
"cta": "ÊTRE NOTIFIÉ DU LANCEMENT"
},
"platform": {
"kicker": "La plateforme",
"subtitle": "墨 STREAMING — DU MICRO À L'AUDITEUR",
"streaming": "Streaming HLS",
"community": "Communauté",
"marketplace": "Marketplace",
"privacy": "Vie privée",
"openSource": "Open source",
"zeroTracking": "Zéro tracking",
"stats": "435 000 lignes de code. Audit de sécurité externe. 34 suites de tests. Backend Go + Stream server Rust + Frontend React. Auto-hébergé. Pas de cloud. Pas de VC."
},
"notify": {
"title": "Rejoins les premiers",
"desc": "Inscris-toi pour être notifié du lancement. Pas de spam — un seul email le jour J.",
"submit": "ME NOTIFIER",
"placeholder": "ton@email.com",
"ariaLabel": "Adresse email pour notification de lancement"
},
"form": {
"successHero": "Inscription confirmée. À bientôt.",
"successCta": "Inscription confirmée. À bientôt.",
"errorSubscription": "L'inscription a échoué",
"errorGeneric": "Une erreur est survenue",
"loadingAriaLabel": "Envoi en cours..."
},
"footer": {
"openSource": "Open Source",
"privacy": "Confidentialité",
"contact": "Contact",
"tagline": "TECHNOLOGIE AUDIO ÉTHIQUE — FAIT EN FRANCE"
},
"pageTitle": "TALAS — Matériel audio éthique & Plateforme"
},
"feed": { "feed": {
"title": "Feed", "title": "Feed",
"subtitle": "Derniers morceaux de vos artistes", "subtitle": "Derniers morceaux de vos artistes",
"emptyTitle": "Votre feed est vide", "emptyTitle": "Votre feed est vide",
"emptyDescription": "Suivez des artistes pour voir leurs nouveaux morceaux ici.", "emptyDescription": "Suivez des artistes pour voir leurs nouveaux morceaux ici.",
"newReleasesInGenres": "Nouvelles sorties dans vos genres", "newReleasesInGenres": "Nouvelles sorties dans vos genres",
"followedArtists": "Artistes suivis" "followedArtists": "Artistes suivis",
"noNewTracks": "Pas de nouveaux morceaux",
"suggestedAccounts": "Comptes suggérés",
"followers_one": "{{count}} abonné",
"followers_other": "{{count}} abonnés",
"seeAll": "Voir tout"
}, },
"dashboard": { "dashboard": {
"title": "Tableau de bord", "title": "Tableau de bord",
@ -156,6 +394,13 @@
"goBack": "Retour", "goBack": "Retour",
"stayTuned": "Restez à l'écoute — nous vous informerons dès que ce sera prêt." "stayTuned": "Restez à l'écoute — nous vous informerons dès que ce sera prêt."
}, },
"designSystem": {
"pageTitle": "Design System — Veza",
"title": "Design System",
"subtitle": "Bibliothèque de composants et référence visuelle",
"underConstruction": "Cette page est en construction. Les composants seront présentés ici prochainement.",
"backToHome": "Retour à l'accueil"
},
"player": { "player": {
"miniPlayerAriaLabel": "Mini lecteur audio", "miniPlayerAriaLabel": "Mini lecteur audio",
"expandPlayer": "Agrandir le lecteur", "expandPlayer": "Agrandir le lecteur",
@ -217,6 +462,22 @@
"description": "Téléversez votre premier titre ou créez une playlist pour commencer.", "description": "Téléversez votre premier titre ou créez une playlist pour commencer.",
"uploadButton": "Téléverser un fichier", "uploadButton": "Téléverser un fichier",
"uploadTrack": "Téléverser un titre" "uploadTrack": "Téléverser un titre"
},
"new": "Nouveau",
"searchPlaceholder": "Rechercher...",
"table": {
"label": "Liste de mes pistes",
"title": "Titre",
"artist": "Artiste",
"date": "Date",
"duration": "Durée",
"download": "Télécharger",
"delete": "Supprimer",
"moreOptions": "Plus d'options pour {{title}}"
},
"grid": {
"label": "Grille de pistes de la bibliothèque",
"play": "Lire {{title}}"
} }
}, },
"profile": { "profile": {
@ -245,6 +506,58 @@
"bioPlaceholder": "Parlez-nous de vous..." "bioPlaceholder": "Parlez-nous de vous..."
} }
}, },
"profilePublic": {
"pageTitle": "{{displayName}} — Veza",
"about": "À propos",
"noBio": "Systèmes en ligne. Aucune bio disponible.",
"links": "Liens",
"joined": "Membre depuis {{date}}",
"tabs": {
"tracks": "Morceaux",
"playlists": "Playlists",
"reposts": "Reposts",
"feed": "Fil"
},
"stats": {
"tracks": "Morceaux",
"playlists": "Playlists",
"followers": "Abonnés",
"following": "Abonnements"
},
"empty": {
"noTracks": "Pas encore de morceaux",
"noTracksDesc": "Cet utilisateur n'a pas encore de morceaux publics.",
"noPlaylists": "Pas encore de playlists",
"noPlaylistsDesc": "Aucune playlist publique trouvée pour cet utilisateur.",
"noPosts": "Pas encore de publications",
"noPostsDesc": "Cet utilisateur n'a pas encore publié.",
"noReposts": "Pas encore de reposts",
"noRepostsDesc": "Cet utilisateur n'a pas encore reposté de morceaux."
},
"reposted": "Reposté",
"unknownArtist": "Artiste inconnu",
"error": {
"notFound": "Utilisateur introuvable",
"notFoundDesc": "Le signal s'est perdu dans le vide. Nous n'avons pas trouvé le profil recherché.",
"generic": "Une erreur est survenue",
"genericDesc": "Impossible de charger ce profil. Vérifiez votre connexion et réessayez.",
"tryAgain": "Réessayer",
"returnToBase": "Retour à la base"
},
"private": {
"title": "Profil privé",
"description": "Ce profil est masqué. Son contenu n'est pas visible."
},
"follow": {
"follow": "Suivre",
"following": "Abonné",
"subscribing": "Abonnement...",
"unsubscribing": "Désabonnement...",
"followSuccess": "Vous suivez maintenant cet utilisateur",
"unfollowSuccess": "Vous ne suivez plus cet utilisateur"
},
"loading": "Chargement du profil"
},
"settings": { "settings": {
"title": "Paramètres", "title": "Paramètres",
"subtitle": "Gérez vos préférences et paramètres de compte", "subtitle": "Gérez vos préférences et paramètres de compte",
@ -485,11 +798,150 @@
"genre": "Genre", "genre": "Genre",
"year": "Année", "year": "Année",
"plays": "Lectures", "plays": "Lectures",
"likes": "J'aime" "likes": "J'aime",
"grid": {
"label": "Grille de pistes",
"track": "Piste : {{title}}",
"play": "Lire {{title}}",
"pause": "Pause {{title}}",
"coverAlt": "Pochette de {{title}}",
"moreOptions": "Plus d'options pour {{title}}",
"nowPlaying": "En lecture",
"densityCompact": "Compact",
"densityDefault": "Standard",
"densityLarge": "Grand"
},
"detail": {
"back": "Retour",
"addToQueue": "Ajouter à la file",
"addedToQueue": "Ajouté à la file",
"listenTogether": "Écouter ensemble",
"edit": "Modifier",
"startListening": "Commencer l'écoute",
"close": "Fermer",
"listenTogetherHelp": "Partagez ce lien avec vos amis pour écouter ensemble. La lecture sera synchronisée.",
"shareLink": "Lien de partage",
"couldNotCreateSession": "Impossible de créer la session d'écoute",
"linkCopied": "Lien copié dans le presse-papiers",
"linkCopyFailed": "Échec de la copie du lien",
"playsLabel": "Lectures",
"likesLabel": "J'aime",
"format": "Format",
"bitrate": "Débit",
"sampleRate": "Fréquence d'échantillonnage",
"bpm": "BPM",
"key": "Tonalité",
"tags": "Tags",
"uploaded": "Téléversé",
"discussion": "Discussion",
"analytics": "Statistiques",
"history": "Historique",
"lyrics": "Paroles",
"stems": "Stems",
"performanceData": "Données de performance",
"versionHistory": "Historique des versions",
"notFound": "Piste introuvable",
"failedToLoad": "Échec du chargement de la piste",
"goBack": "Retour"
},
"commentSection": {
"title": "Commentaires",
"titleWithCount": "Commentaires ({{count}})",
"placeholder": "Écrire un commentaire...",
"empty": "Aucun commentaire pour le moment. Soyez le premier à commenter !",
"publishError": "Erreur lors de la publication",
"publishSuccess": "Commentaire publié",
"loginToComment": "Connectez-vous pour commenter",
"loadError": "Échec du chargement des commentaires"
},
"repost": {
"reposted": "Track ajouté à votre profil",
"repostFailed": "Échec du repost",
"unreposted": "Repost retiré",
"unrepostFailed": "Échec de l'annulation du repost",
"repostAction": "Reposter sur votre profil",
"unrepostAction": "Retirer le repost"
},
"likeAction": {
"added": "Ajouté aux favoris",
"addFailed": "Échec de l'ajout aux favoris",
"removed": "Retiré des favoris",
"removeFailed": "Échec de la suppression des favoris"
},
"shareDialog": {
"title": "Partager la piste",
"creatingLink": "Création du lien de partage...",
"shareLink": "Lien de partage",
"expiresIn": "Ce lien expire dans 7 jour(s)",
"close": "Fermer",
"copyLink": "Copier le lien",
"createFailed": "Échec de la création du lien de partage",
"linkCopied": "Lien copié dans le presse-papiers",
"linkCopyFailed": "Échec de la copie du lien"
},
"lyricsSection": {
"title": "Paroles",
"loadError": "Impossible de charger les paroles.",
"empty": "Aucune parole disponible pour cette piste.",
"showLess": "Voir moins",
"showMore": "Voir plus"
},
"stemsSection": {
"title": "Stems",
"upload": "Téléverser",
"loading": "Chargement des stems...",
"loadError": "Échec du chargement des stems.",
"empty": "Aucun stem disponible.",
"uploadHelp": "Téléversez des stems (WAV, AIFF, FLAC) pour les partager avec vos collaborateurs."
},
"stats": {
"scanning": "ANALYSE...",
"telemetryError": "Télémétrie interrompue",
"views": "Vues",
"likes": "J'aime",
"comments": "Comm.",
"downloads": "Données",
"playTime": "Durée"
}
},
"discover": {
"title": "Découvrir",
"subtitle": "Explorez par genre, tag ou playlist éditoriale",
"byGenre": "Par genre",
"editorialPlaylists": "Playlists éditoriales",
"noEditorialPlaylists": "Aucune playlist éditoriale disponible pour le moment",
"back": "Retour",
"noTracksInGenre": "Aucune piste dans ce genre",
"browseGenre": "Parcourir les pistes {{genre}}",
"trackCount": "{{count}} pistes"
}, },
"playlists": { "playlists": {
"title": "Playlists", "title": "Playlists",
"pageTitle": "Playlists — Veza",
"subtitle": "Découvrez et gérez vos playlists",
"create": "Créer une playlist", "create": "Créer une playlist",
"createButton": "Créer",
"createButtonMobile": "Nouvelle",
"createNewPlaylist": "Créer une nouvelle playlist",
"importButton": "Importer",
"selectButton": "Sélectionner",
"deselectButton": "Annuler",
"enableSelection": "Activer la sélection",
"disableSelection": "Désactiver la sélection",
"searchPlaceholder": "Rechercher des playlists...",
"filtersButton": "Filtres",
"filtersActive": "Actif",
"clearFilters": "Effacer",
"filterVisibility": "Visibilité",
"filterOwner": "Propriétaire",
"filterSortBy": "Trier par",
"sortToggle": "Inverser l'ordre de tri",
"all": "Tout",
"myPlaylists": "Mes playlists",
"others": "Autres",
"sortByDate": "Date",
"sortByTitle": "Titre",
"sortByTracks": "Pistes",
"edit": "Modifier la playlist", "edit": "Modifier la playlist",
"delete": "Supprimer la playlist", "delete": "Supprimer la playlist",
"follow": "Suivre", "follow": "Suivre",
@ -503,10 +955,188 @@
"addCollaborator": "Ajouter un collaborateur", "addCollaborator": "Ajouter un collaborateur",
"removeCollaborator": "Retirer un collaborateur", "removeCollaborator": "Retirer un collaborateur",
"noPlaylists": "Aucune playlist disponible", "noPlaylists": "Aucune playlist disponible",
"emptyTitle": "Aucune playlist pour le moment",
"emptyDescription": "Commencez par créer votre première playlist pour organiser vos morceaux.",
"loading": "Chargement des playlists...", "loading": "Chargement des playlists...",
"tracks": "Pistes", "tracks": "Pistes",
"trackListLabel": "Pistes de la playlist",
"trackItem": "Piste {{position}} : {{title}}",
"public": "Publique", "public": "Publique",
"private": "Privée" "private": "Privée",
"createDialog": {
"title": "Créer une playlist",
"titleLabel": "Titre",
"titlePlaceholder": "Ma nouvelle playlist",
"titleRequired": "Le titre est requis",
"descriptionLabel": "Description",
"descriptionPlaceholder": "Décrivez votre playlist...",
"publicPlaylist": "Playlist publique",
"cancel": "Annuler la création de playlist",
"cancelButton": "Annuler",
"submit": "Créer la playlist",
"submitButton": "Créer"
},
"shared": {
"playAll": "Tout lire",
"shuffle": "Aléatoire",
"copyLink": "Copier le lien",
"linkCopied": "Lien copié dans le presse-papiers",
"sharedPlaylist": "Playlist partagée",
"trackCount": "{{count}} piste",
"trackCount_other": "{{count}} pistes",
"notFound": "Playlist introuvable",
"backToLibrary": "Retour à la bibliothèque",
"noTracks": "Aucune piste dans cette playlist",
"noTracksDescription": "Cette playlist partagée est vide.",
"publicSignal": "Publique",
"encrypted": "Privée",
"updated": "Mis à jour le {{date}}",
"followers": "{{count}} abonnés"
},
"detail": {
"reorder": "Réorganiser",
"playTrack": "Lire {{title}}",
"pauseTrack": "Mettre en pause {{title}}",
"coverAlt": "Pochette de {{title}}",
"addToFavorites": "Ajouter {{title}} aux favoris",
"filterTracks": "Filtrer les pistes...",
"addTracks": "Ajouter des pistes",
"squadMembers": "Membres du groupe",
"invite": "Inviter",
"suggestedForYou": "Suggestions pour vous",
"recommendations": "Recommandations",
"trackAdded": "Piste ajoutée",
"trackRemoved": "Piste retirée",
"reordered": "Réorganisé",
"playlistReordered": "Playlist réorganisée",
"reorderError": "Impossible de réorganiser la playlist. Veuillez réessayer.",
"emptyTracks": "Aucune piste dans cette playlist",
"emptyTracksDescription": "Ajoutez des pistes à cette playlist pour commencer.",
"loadingTracks": "Chargement des pistes",
"loadingTracksProgress": "Chargement des pistes en cours..."
},
"actions": {
"groupLabel": "Actions de la playlist",
"edit": "Modifier",
"editPlaylist": "Modifier la playlist",
"saving": "Enregistrement...",
"saved": "Enregistré",
"share": "Partager",
"sharePlaylist": "Partager la playlist",
"delete": "Supprimer",
"deletePlaylist": "Supprimer la playlist",
"deleteTitle": "Supprimer la playlist",
"deleteConfirmation": "Êtes-vous sûr de vouloir supprimer « {{title}} » ? Cette action est irréversible. Tous les titres de la playlist seront retirés.",
"deleteConfirm": "Supprimer",
"deleteCancel": "Annuler",
"updateSuccess": "Playlist mise à jour avec succès",
"updateError": "Erreur lors de la mise à jour",
"deleteSuccess": "Playlist supprimée avec succès",
"deleteError": "Erreur lors de la suppression"
},
"editDialog": {
"title": "Modifier la playlist",
"save": "Enregistrer",
"saving": "Enregistrement...",
"cancel": "Annuler",
"titleLabel": "Titre",
"titlePlaceholder": "Titre de la playlist",
"descriptionLabel": "Description",
"descriptionPlaceholder": "Description de la playlist",
"coverUrlLabel": "URL de la couverture",
"isPublic": "Playlist publique",
"savingInProgress": "Enregistrement en cours..."
},
"form": {
"editAriaLabel": "Formulaire de modification de playlist",
"createAriaLabel": "Formulaire de création de playlist",
"titleLabel": "Titre",
"titlePlaceholder": "Ma playlist",
"titleRequired": "Le titre est requis",
"titleMaxLength": "Le titre ne peut pas dépasser 200 caractères",
"descriptionLabel": "Description",
"descriptionPlaceholder": "Décrivez votre playlist...",
"descriptionMaxLength": "La description ne peut pas dépasser 2000 caractères",
"coverUrlLabel": "URL de la couverture",
"coverUrlMaxLength": "L'URL ne peut pas dépasser 500 caractères",
"coverUrlInvalid": "L'URL de la couverture doit être valide",
"isPublic": "Playlist publique",
"cancel": "Annuler",
"cancelEdit": "Annuler la modification",
"save": "Enregistrer",
"create": "Créer",
"saveChanges": "Enregistrer les modifications",
"createPlaylist": "Créer la playlist",
"updateSuccess": "Playlist mise à jour avec succès",
"createSuccess": "Playlist créée avec succès",
"genericError": "Une erreur est survenue"
},
"duplicate": {
"button": "Dupliquer",
"duplicating": "Duplication...",
"ariaLabel": "Dupliquer la playlist",
"copySuffix": "{{title}} (copie)",
"success": "Playlist dupliquée avec succès",
"error": "Erreur lors de la duplication de la playlist"
},
"export": {
"button": "Exporter",
"exporting": "Export...",
"json": "Exporter en JSON",
"csv": "Exporter en CSV",
"m3u": "Exporter en M3U",
"success": "La playlist a été exportée en format {{format}}",
"authRequired": "Vous devez être connecté pour exporter une playlist",
"forbidden": "Vous n'avez pas la permission d'exporter cette playlist",
"notFound": "Playlist non trouvée",
"error": "Erreur lors de l'export",
"genericError": "Une erreur est survenue lors de l'export"
},
"followBtn": {
"follow": "Suivre",
"following": "Abonné",
"unfollowing": "Désabonnement...",
"subscribing": "Abonnement...",
"followSuccess": "Vous suivez maintenant cette playlist",
"followError": "Erreur lors de l'abonnement à la playlist",
"unfollowSuccess": "Vous ne suivez plus cette playlist",
"unfollowError": "Erreur lors du désabonnement de la playlist"
}
},
"queue": {
"pageTitle": "File de lecture — Veza",
"heading": "FILE DE LECTURE",
"tracksUpcoming_one": "{{count}} morceau suivant",
"tracksUpcoming_other": "{{count}} morceaux suivants",
"nowPlaying": "En cours de lecture",
"upNext": "Suivants",
"emptyTitle": "Rien dans votre file de lecture",
"emptyDescription": "Lancez la musique et ajoutez des morceaux pour remplir votre file.",
"saveQueue": "Sauvegarder la file",
"clear": "Vider",
"clearTitle": "Vider la file de lecture",
"clearDescription": "Supprimer tous les morceaux de votre file de lecture. Cette action est irréversible.",
"clearConfirm": "Vider",
"removeFromQueue": "Retirer de la file",
"reorderTrack": "Réordonner {{title}}",
"playTrack": "Lire {{title}}",
"pauseTrack": "Mettre en pause {{title}}",
"emptyQueueError": "La file de lecture est vide",
"savedAs": "File sauvegardée sous \"{{name}}\"",
"saveAsPlaylist": {
"title": "Sauvegarder la file en playlist",
"nameLabel": "Nom de la playlist",
"namePlaceholder": "Ma session de file",
"privatePlaylist": "Playlist privée",
"publicPlaylist": "Playlist publique",
"privateDescription": "Visible uniquement par vous",
"publicDescription": "Visible sur votre profil",
"toggleVisibility": "Changer la visibilité de la playlist",
"cancel": "Annuler",
"save": "Sauvegarder la playlist",
"nameRequired": "Veuillez nommer votre playlist",
"saveFailed": "Échec de la sauvegarde de la playlist"
}
}, },
"notifications": { "notifications": {
"title": "Notifications", "title": "Notifications",
@ -524,13 +1154,35 @@
}, },
"search": { "search": {
"title": "Recherche", "title": "Recherche",
"pageTitle": "Recherche — Veza",
"heading": "Recherche",
"placeholder": "Rechercher des pistes, playlists, utilisateurs...", "placeholder": "Rechercher des pistes, playlists, utilisateurs...",
"searchPlaceholder": "Rechercher des morceaux, artistes, playlists...",
"clearSearch": "Effacer la recherche",
"results": "Résultats", "results": "Résultats",
"noResults": "Aucun résultat trouvé", "resultsCount_one": "{{count}} résultat trouvé",
"resultsCount_other": "{{count}} résultats trouvés",
"allResults": "Tous les résultats",
"topTracks": "Meilleurs morceaux",
"tracks": "Pistes", "tracks": "Pistes",
"artists": "Artistes",
"playlists": "Playlists", "playlists": "Playlists",
"users": "Utilisateurs", "users": "Utilisateurs",
"all": "Tout" "all": "Tout",
"noResults": "Aucun résultat trouvé",
"noResultsHint": "Essayez d'ajuster votre recherche ou utilisez des mots-clés différents.",
"noDescription": "Aucune description",
"recentSearches": "Recherches récentes",
"clearHistory": "Effacer l'historique de recherche",
"helpText": "Utilisez AND, OR, NOT et \"expression exacte\" pour affiner votre recherche.",
"discovery": {
"newReleases": "Nouveautés",
"newReleasesDesc": "Derniers morceaux de vos artistes",
"curatedMixes": "Sélections",
"curatedMixesDesc": "Sélections triées sur le volet pour vous",
"exploreArtists": "Explorer les artistes",
"exploreArtistsDesc": "Découvrez les artistes de la communauté"
}
}, },
"analytics": { "analytics": {
"title": "Analytiques", "title": "Analytiques",

3692
package-lock.json generated

File diff suppressed because it is too large Load diff