+
{t('preferences.language')}
+
+ {t('preferences.theme')}
+
+
)
diff --git a/src/shared/i18n/config.ts b/src/shared/i18n/config.ts
index 0ea1fa0..3529c1f 100644
--- a/src/shared/i18n/config.ts
+++ b/src/shared/i18n/config.ts
@@ -10,7 +10,7 @@ void i18n
.init({
fallbackLng: 'en',
supportedLngs: ['en', 'cs'],
- ns: ['common', 'auth', 'fields', 'settings', 'crypto'],
+ ns: ['common', 'auth', 'fields', 'settings', 'crypto', 'landing'],
defaultNS: 'common',
interpolation: {
escapeValue: false,
diff --git a/src/shared/i18n/locales/cs/common.json b/src/shared/i18n/locales/cs/common.json
index 311e8cb..969b88a 100644
--- a/src/shared/i18n/locales/cs/common.json
+++ b/src/shared/i18n/locales/cs/common.json
@@ -16,6 +16,10 @@
"loading": "Načítání...",
"error": "Něco se pokazilo"
},
+ "preAlpha": {
+ "ariaLabel": "Upozornění na předběžnou verzi",
+ "message": "Cipher Note je předběžná alfa verze. Očekávejte zásadní změny a občasné resety dat."
+ },
"nav": {
"dashboard": "Nástěnka",
"settings": "Nastavení",
@@ -25,6 +29,11 @@
"recover": "Obnovit účet",
"menu": "Menu",
"closeMenu": "Zavřít menu",
- "mainNav": "Hlavní navigace"
+ "mainNav": "Hlavní navigace",
+ "backToHome": "Zpět na domovskou stránku",
+ "languageSelection": "Výběr jazyka",
+ "switchLanguage": "Přepnout jazyk",
+ "themeSelection": "Výběr motivu",
+ "switchTheme": "Přepnout motiv"
}
}
diff --git a/src/shared/i18n/locales/cs/landing.json b/src/shared/i18n/locales/cs/landing.json
new file mode 100644
index 0000000..f998aba
--- /dev/null
+++ b/src/shared/i18n/locales/cs/landing.json
@@ -0,0 +1,48 @@
+{
+ "hero": {
+ "title": "Vaše poznámky. Vaše klíče.",
+ "subtitle": "End-to-end šifrované poznámky, které ani naše servery nedokážou přečíst. Postaveno na zero-knowledge kryptografii.",
+ "cta": "Vytvořit účet zdarma",
+ "login": "Přihlásit se"
+ },
+ "features": {
+ "heading": "Bezpečnost bez kompromisu",
+ "zeroKnowledge": {
+ "title": "Zero-Knowledge šifrování",
+ "description": "Vaše heslo nikdy neopustí zařízení. Šifrovací klíče odvozujeme lokálně - server vidí pouze šifrovaný text."
+ },
+ "splitKey": {
+ "title": "Rozdělená derivace klíčů",
+ "description": "Jedno heslo, dva nezávislé klíče: jeden pro autentizaci, druhý pro šifrování. Ani narušení serveru nic neodhalí."
+ },
+ "recovery": {
+ "title": "Záloha obnovovací frází",
+ "description": "BIP-39 mnemonická fráze vám umožní obnovit data, pokud zapomenete heslo. Zapište si ji a uložte na bezpečné místo."
+ },
+ "openDesign": {
+ "title": "Transparentní architektura",
+ "description": "Postaveno na AES-256-GCM, Argon2id a HKDF. Žádné proprietární algoritmy. Každé kryptografické rozhodnutí je ověřitelné."
+ }
+ },
+ "howItWorks": {
+ "heading": "Jak to funguje",
+ "step1": {
+ "title": "Vytvořte si účet",
+ "description": "Zvolte uživatelské jméno a heslo. Vaše heslo se nikdy neodesílá na server - k autentizaci se používá pouze odvozený hash."
+ },
+ "step2": {
+ "title": "Zapište své poznámky",
+ "description": "Přidejte poznámky, webové stránky a e-maily do šifrovaného trezoru. Rozhraní je jednoduché a přehledné."
+ },
+ "step3": {
+ "title": "Okamžitě zašifrováno",
+ "description": "Každé pole je zašifrováno jedinečným klíčem před opuštěním prohlížeče. Server ukládá pouze šifrovaný text."
+ }
+ },
+ "security": {
+ "statement": "Postaveno na AES-256-GCM, Argon2id a HKDF. Žádné sledování. Žádné reklamy. Žádné kompromisy."
+ },
+ "footer": {
+ "tagline": "Soukromí není vymoženost - je to základ."
+ }
+}
diff --git a/src/shared/i18n/locales/cs/settings.json b/src/shared/i18n/locales/cs/settings.json
index 8309468..d7246a7 100644
--- a/src/shared/i18n/locales/cs/settings.json
+++ b/src/shared/i18n/locales/cs/settings.json
@@ -11,6 +11,7 @@
"title": "Předvolby",
"description": "Jazyk a předvolby zobrazení.",
"language": "Jazyk",
+ "theme": "Motiv",
"languageName": {
"en": "Angličtina",
"cs": "Čeština"
diff --git a/src/shared/i18n/locales/en/common.json b/src/shared/i18n/locales/en/common.json
index 5af01ca..8b6effd 100644
--- a/src/shared/i18n/locales/en/common.json
+++ b/src/shared/i18n/locales/en/common.json
@@ -16,6 +16,10 @@
"loading": "Loading...",
"error": "Something went wrong"
},
+ "preAlpha": {
+ "ariaLabel": "Pre-alpha software notice",
+ "message": "Cipher Note is in pre-alpha version. Expect breaking changes and periodic data resets."
+ },
"nav": {
"dashboard": "Dashboard",
"settings": "Settings",
@@ -25,6 +29,11 @@
"recover": "Recover account",
"menu": "Menu",
"closeMenu": "Close menu",
- "mainNav": "Main navigation"
+ "mainNav": "Main navigation",
+ "backToHome": "Back to home",
+ "languageSelection": "Language selection",
+ "switchLanguage": "Switch language",
+ "themeSelection": "Theme selection",
+ "switchTheme": "Switch theme"
}
}
diff --git a/src/shared/i18n/locales/en/landing.json b/src/shared/i18n/locales/en/landing.json
new file mode 100644
index 0000000..97c5eb6
--- /dev/null
+++ b/src/shared/i18n/locales/en/landing.json
@@ -0,0 +1,48 @@
+{
+ "hero": {
+ "title": "Your notes. Your keys.",
+ "subtitle": "End-to-end encrypted notes that not even our servers can read. Built on zero-knowledge cryptography.",
+ "cta": "Create a free account",
+ "login": "Sign in"
+ },
+ "features": {
+ "heading": "Security without compromise",
+ "zeroKnowledge": {
+ "title": "Zero-Knowledge Encryption",
+ "description": "Your password never leaves your device. We derive encryption keys locally - the server only sees ciphertext."
+ },
+ "splitKey": {
+ "title": "Split Key Derivation",
+ "description": "One password, two independent keys: one for authentication, one for encryption. Even a server breach reveals nothing."
+ },
+ "recovery": {
+ "title": "Recovery Phrase Backup",
+ "description": "A BIP-39 mnemonic phrase lets you recover your data if you forget your password. Write it down, keep it safe."
+ },
+ "openDesign": {
+ "title": "Transparent Architecture",
+ "description": "Built on AES-256-GCM, Argon2id, and HKDF. No proprietary algorithms. Every cryptographic choice is auditable."
+ }
+ },
+ "howItWorks": {
+ "heading": "How it works",
+ "step1": {
+ "title": "Create your account",
+ "description": "Pick a username and password. Your password is never sent to the server - only a derived hash is used for authentication."
+ },
+ "step2": {
+ "title": "Write your notes",
+ "description": "Add notes, websites, and emails to your encrypted vault. The interface is simple and focused."
+ },
+ "step3": {
+ "title": "Encrypted instantly",
+ "description": "Every field is encrypted with a unique key before leaving your browser. The server stores only ciphertext."
+ }
+ },
+ "security": {
+ "statement": "Built on AES-256-GCM, Argon2id, and HKDF. No tracking. No ads. No compromise."
+ },
+ "footer": {
+ "tagline": "Privacy is not a feature - it's the foundation."
+ }
+}
diff --git a/src/shared/i18n/locales/en/settings.json b/src/shared/i18n/locales/en/settings.json
index d2245b5..149f052 100644
--- a/src/shared/i18n/locales/en/settings.json
+++ b/src/shared/i18n/locales/en/settings.json
@@ -11,6 +11,7 @@
"title": "Preferences",
"description": "Language and display preferences.",
"language": "Language",
+ "theme": "Theme",
"languageName": {
"en": "English",
"cs": "Czech"
diff --git a/src/shared/ui/PreAlphaBanner.tsx b/src/shared/ui/PreAlphaBanner.tsx
new file mode 100644
index 0000000..8f8a3b7
--- /dev/null
+++ b/src/shared/ui/PreAlphaBanner.tsx
@@ -0,0 +1,21 @@
+import { useTranslation } from 'react-i18next'
+import { TriangleAlert } from 'lucide-react'
+
+function PreAlphaBanner() {
+ const { t } = useTranslation('common')
+
+ return (
+
+
+
+ {t('preAlpha.message')}
+
+
+ )
+}
+
+export { PreAlphaBanner }
diff --git a/src/shared/ui/SegmentedControl.tsx b/src/shared/ui/SegmentedControl.tsx
new file mode 100644
index 0000000..304d59a
--- /dev/null
+++ b/src/shared/ui/SegmentedControl.tsx
@@ -0,0 +1,42 @@
+import { cn } from '@/shared/lib/utils'
+
+interface SegmentedControlItem {
+ value: string
+ label: string
+ icon?: React.ReactNode
+}
+
+interface SegmentedControlProps {
+ items: SegmentedControlItem[]
+ value: string
+ onChange: (value: string) => void
+ ariaLabel?: string
+}
+
+function SegmentedControl({ items, value, onChange, ariaLabel }: SegmentedControlProps) {
+ return (
+
+ {items.map((item) => (
+
+ ))}
+
+ )
+}
+
+export { SegmentedControl }
+export type { SegmentedControlItem }
diff --git a/src/shared/ui/nav/LanguageSwitcher.test.tsx b/src/shared/ui/nav/LanguageSwitcher.test.tsx
index 098af66..48c47ac 100644
--- a/src/shared/ui/nav/LanguageSwitcher.test.tsx
+++ b/src/shared/ui/nav/LanguageSwitcher.test.tsx
@@ -12,39 +12,39 @@ describe('LanguageSwitcher', () => {
it('renders compact variant with language code', () => {
render()
- expect(screen.getByRole('button', { name: 'EN' })).toBeInTheDocument()
+ expect(screen.getByRole('button', { name: 'Switch language' })).toHaveTextContent('EN')
})
it('renders full variant with language names', () => {
render()
- expect(screen.getByRole('button', { name: 'English' })).toBeInTheDocument()
- expect(screen.getByRole('button', { name: 'Czech' })).toBeInTheDocument()
+ expect(screen.getByRole('tab', { name: 'English' })).toBeInTheDocument()
+ expect(screen.getByRole('tab', { name: 'Čeština' })).toBeInTheDocument()
})
it('defaults to compact variant', () => {
render()
- expect(screen.getByRole('button', { name: 'EN' })).toBeInTheDocument()
+ expect(screen.getByRole('button', { name: 'Switch language' })).toHaveTextContent('EN')
})
it('switches language when clicked in compact variant', async () => {
const user = userEvent.setup()
render()
- await user.click(screen.getByRole('button', { name: 'EN' }))
+ await user.click(screen.getByRole('button', { name: 'Switch language' }))
expect(i18next.language.startsWith('cs')).toBe(true)
})
it('switches language when clicked in full variant', async () => {
const user = userEvent.setup()
render()
- await user.click(screen.getByRole('button', { name: 'Czech' }))
+ await user.click(screen.getByRole('tab', { name: 'Čeština' }))
expect(i18next.language.startsWith('cs')).toBe(true)
})
- it('marks active language with aria-current in full variant', () => {
+ it('marks active language with aria-pressed in full variant', () => {
render()
- const enButton = screen.getByRole('button', { name: 'English' })
- const csButton = screen.getByRole('button', { name: 'Czech' })
- expect(enButton).toHaveAttribute('aria-current')
- expect(csButton).not.toHaveAttribute('aria-current')
+ const enButton = screen.getByRole('tab', { name: 'English' })
+ const csButton = screen.getByRole('tab', { name: 'Čeština' })
+ expect(enButton).toHaveAttribute('aria-pressed', 'true')
+ expect(csButton).toHaveAttribute('aria-pressed', 'false')
})
})
diff --git a/src/shared/ui/nav/LanguageSwitcher.tsx b/src/shared/ui/nav/LanguageSwitcher.tsx
index ec8e928..f6dd3e4 100644
--- a/src/shared/ui/nav/LanguageSwitcher.tsx
+++ b/src/shared/ui/nav/LanguageSwitcher.tsx
@@ -1,47 +1,51 @@
import { useTranslation } from 'react-i18next'
import { Button } from '@/shared/ui/button'
+import { SegmentedControl } from '@/shared/ui/SegmentedControl'
const LANGUAGES = [
{ code: 'en', label: 'EN', name: 'English' },
{ code: 'cs', label: 'CS', name: 'Čeština' },
] as const
+const LANGUAGE_ITEMS = LANGUAGES.map((lang) => ({
+ value: lang.code,
+ label: lang.name,
+}))
+
+const LANGUAGE_CODES = LANGUAGES.map((lang) => lang.code) as string[]
+
interface LanguageSwitcherProps {
variant?: 'compact' | 'full'
}
function LanguageSwitcher({ variant = 'compact' }: LanguageSwitcherProps) {
- const { t, i18n } = useTranslation('settings')
+ const { i18n, t } = useTranslation('common')
const currentCode = i18n.language?.split('-')[0] ?? 'en'
if (variant === 'full') {
return (
-
- {LANGUAGES.map((lang) => (
-
- ))}
-
+ void i18n.changeLanguage(code)}
+ aria-label={t('nav.languageSelection')}
+ />
)
}
- const currentLang = LANGUAGES.find((lang) => lang.code === currentCode) ?? LANGUAGES[0]
- const nextLang = LANGUAGES.find((lang) => lang.code !== currentLang.code) ?? LANGUAGES[0]
+ const currentIndex = LANGUAGE_CODES.indexOf(currentCode)
+ const nextIndex = (currentIndex + 1) % LANGUAGE_CODES.length
+ const nextCode = LANGUAGE_CODES[nextIndex]
function toggleLanguage() {
- void i18n.changeLanguage(nextLang.code)
+ void i18n.changeLanguage(nextCode)
}
+ const currentLang = LANGUAGES.find((lang) => lang.code === currentCode) ?? LANGUAGES[0]
+
return (
-