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:
parent
4fd537e3ba
commit
dfeff836ce
28 changed files with 4675 additions and 2518 deletions
54
.github/workflows/chromatic.yml
vendored
Normal file
54
.github/workflows/chromatic.yml
vendored
Normal 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"
|
||||||
|
|
@ -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 ?? ['/'];
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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 = {
|
||||||
|
|
|
||||||
|
|
@ -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()}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
124
apps/web/src/components/sumi/SumiButton.stories.tsx
Normal file
124
apps/web/src/components/sumi/SumiButton.stories.tsx
Normal 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>
|
||||||
|
),
|
||||||
|
};
|
||||||
136
apps/web/src/components/sumi/SumiButton.tsx
Normal file
136
apps/web/src/components/sumi/SumiButton.tsx
Normal 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';
|
||||||
176
apps/web/src/components/sumi/SumiCanvas.stories.tsx
Normal file
176
apps/web/src/components/sumi/SumiCanvas.stories.tsx
Normal 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>
|
||||||
|
),
|
||||||
|
};
|
||||||
202
apps/web/src/components/sumi/SumiCanvas.tsx
Normal file
202
apps/web/src/components/sumi/SumiCanvas.tsx
Normal 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 (0–1) */
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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 = {
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
186
apps/web/src/hooks/useSeason.ts
Normal file
186
apps/web/src/hooks/useSeason.ts
Normal 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 (0–1) */
|
||||||
|
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]);
|
||||||
|
}
|
||||||
243
apps/web/src/hooks/useTimeOfDay.ts
Normal file
243
apps/web/src/hooks/useTimeOfDay.ts
Normal 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.02–0.06) to stay subtle */
|
||||||
|
tintOpacity: number;
|
||||||
|
/** Ink wash opacity multiplier — how visible the lavis backgrounds are */
|
||||||
|
inkOpacity: number;
|
||||||
|
/** Atmospheric haze amount (0–1) */
|
||||||
|
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 (0–1) */
|
||||||
|
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: 5–7h — 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: 7–11h — 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: 11–14h — 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: 14–17h — 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: 17–20h — 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: 20–5h — 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: 20–5)
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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
3692
package-lock.json
generated
File diff suppressed because it is too large
Load diff
Loading…
Reference in a new issue