Add language preference selector in settings

**Motivations:**
- Allow users to set their preferred language (fr/en) in the settings page
- Load language preference from localStorage at startup to configure the application locale

**Root causes:**
- Language preference was only available in the header via LanguageSelector component
- Language preference was stored in IndexedDB instead of localStorage
- No centralized language settings management in the settings page

**Correctifs:**
- Created LanguageSettingsManager component for settings page
- Migrated language storage from IndexedDB to localStorage for consistency
- Updated _app.tsx to load locale from localStorage synchronously at startup
- Updated useI18n hook to use localStorage instead of IndexedDB
- Updated LanguageSelector component to use localStorage instead of IndexedDB

**Evolutions:**
- Added language preference section in settings page (displayed first)
- Language preference is now loaded at application startup from localStorage
- Added translations for language settings (settings.language.*)

**Pages affectées:**
- components/LanguageSettingsManager.tsx (new)
- pages/settings.tsx
- pages/_app.tsx
- hooks/useI18n.ts
- components/LanguageSelector.tsx
- locales/fr.txt
- locales/en.txt
This commit is contained in:
Nicolas Cantu 2026-01-06 14:57:38 +01:00
parent c39e732049
commit cc49c9d7c1
13 changed files with 157 additions and 32 deletions

View File

@ -30,28 +30,30 @@ export function LanguageSelector(): React.ReactElement {
const [currentLocale, setCurrentLocale] = useState<Locale>(getLocale()) const [currentLocale, setCurrentLocale] = useState<Locale>(getLocale())
useEffect(() => { useEffect(() => {
// Load saved locale from IndexedDB // Load saved locale from localStorage
const loadLocale = async (): Promise<void> => { const loadLocale = (): void => {
try { try {
const { storageService } = await import('@/lib/storage/indexedDB') if (typeof window !== 'undefined') {
const savedLocale = await storageService.get<Locale>(LOCALE_STORAGE_KEY, 'app_storage') const savedLocale = localStorage.getItem(LOCALE_STORAGE_KEY) as Locale | null
if (savedLocale && (savedLocale === 'fr' || savedLocale === 'en')) { if (savedLocale && (savedLocale === 'fr' || savedLocale === 'en')) {
setLocale(savedLocale) setLocale(savedLocale)
setCurrentLocale(savedLocale) setCurrentLocale(savedLocale)
} }
}
} catch (e) { } catch (e) {
console.error('Error loading locale:', e) console.error('Error loading locale:', e)
} }
} }
void loadLocale() loadLocale()
}, []) }, [])
const handleLocaleChange = async (locale: Locale): Promise<void> => { const handleLocaleChange = (locale: Locale): void => {
setLocale(locale) setLocale(locale)
setCurrentLocale(locale) setCurrentLocale(locale)
try { try {
const { storageService } = await import('@/lib/storage/indexedDB') if (typeof window !== 'undefined') {
await storageService.set(LOCALE_STORAGE_KEY, locale, 'app_storage') localStorage.setItem(LOCALE_STORAGE_KEY, locale)
}
} catch (e) { } catch (e) {
console.error('Error saving locale:', e) console.error('Error saving locale:', e)
} }

View File

@ -0,0 +1,88 @@
import { useState, useEffect } from 'react'
import { setLocale, getLocale, type Locale } from '@/lib/i18n'
import { t } from '@/lib/i18n'
const LOCALE_STORAGE_KEY = 'zapwall-locale'
interface LocaleOptionProps {
locale: Locale
label: string
currentLocale: Locale
onClick: (locale: Locale) => void
}
function LocaleOption({ locale, label, currentLocale, onClick }: LocaleOptionProps): React.ReactElement {
const isActive = currentLocale === locale
return (
<button
onClick={() => onClick(locale)}
className={`px-4 py-2 rounded-lg font-medium transition-colors border ${
isActive
? 'bg-neon-cyan/20 text-neon-cyan border-neon-cyan/50'
: 'bg-cyber-darker text-cyber-accent hover:text-neon-cyan border-neon-cyan/30 hover:border-neon-cyan/50'
}`}
>
{label}
</button>
)
}
export function LanguageSettingsManager(): React.ReactElement {
const [currentLocale, setCurrentLocale] = useState<Locale>(getLocale())
const [loading, setLoading] = useState(true)
useEffect(() => {
const loadLocale = (): void => {
try {
if (typeof window === 'undefined') {
setLoading(false)
return
}
const savedLocale = localStorage.getItem(LOCALE_STORAGE_KEY) as Locale | null
if (savedLocale && (savedLocale === 'fr' || savedLocale === 'en')) {
setLocale(savedLocale)
setCurrentLocale(savedLocale)
}
} catch (e) {
console.error('Error loading locale:', e)
} finally {
setLoading(false)
}
}
loadLocale()
}, [])
const handleLocaleChange = (locale: Locale): void => {
setLocale(locale)
setCurrentLocale(locale)
try {
if (typeof window !== 'undefined') {
localStorage.setItem(LOCALE_STORAGE_KEY, locale)
}
} catch (e) {
console.error('Error saving locale:', e)
}
// Force page reload to update all translations
window.location.reload()
}
if (loading) {
return (
<div className="bg-cyber-darker border border-neon-cyan/30 rounded-lg p-6">
<div>{t('settings.language.loading')}</div>
</div>
)
}
return (
<div className="bg-cyber-darker border border-neon-cyan/30 rounded-lg p-6">
<h2 className="text-2xl font-bold text-neon-cyan mb-4">{t('settings.language.title')}</h2>
<p className="text-cyber-accent mb-4 text-sm">{t('settings.language.description')}</p>
<div className="flex items-center gap-3">
<LocaleOption locale="fr" label={t('settings.language.french')} currentLocale={currentLocale} onClick={handleLocaleChange} />
<LocaleOption locale="en" label={t('settings.language.english')} currentLocale={currentLocale} onClick={handleLocaleChange} />
</div>
</div>
)
}

View File

@ -12,11 +12,12 @@ export function useI18n(locale: Locale = 'fr'): {
useEffect(() => { useEffect(() => {
const load = async (): Promise<void> => { const load = async (): Promise<void> => {
try { try {
// Get saved locale from IndexedDB or use provided locale // Get saved locale from localStorage or use provided locale
let savedLocale: Locale | null = null let savedLocale: Locale | null = null
try { try {
const { storageService } = await import('@/lib/storage/indexedDB') if (typeof window !== 'undefined') {
savedLocale = await storageService.get<Locale>('zapwall-locale', 'app_storage') savedLocale = localStorage.getItem('zapwall-locale') as Locale | null
}
} catch { } catch {
// Fallback to provided locale // Fallback to provided locale
} }

View File

@ -219,7 +219,7 @@ class NostrService {
} }
const filters = [{ ids: [eventId], kinds: [1] }] const filters = [{ ids: [eventId], kinds: [1] }]
return subscribeWithTimeout(this.pool, filters, (event: Event) => event, 5000) return subscribeWithTimeout(this.pool, filters, (event: Event): Event => event, 5000)
} }
getProfile(pubkey: string): Promise<NostrProfile | null> { getProfile(pubkey: string): Promise<NostrProfile | null> {
@ -235,7 +235,7 @@ class NostrService {
}, },
] ]
const parseProfile = (event: Event) => { const parseProfile = (event: Event): NostrProfile | null => {
try { try {
const profile = JSON.parse(event.content) as NostrProfile const profile = JSON.parse(event.content) as NostrProfile
return { ...profile, pubkey } return { ...profile, pubkey }

View File

@ -159,7 +159,7 @@ export async function parseReviewFromEvent(event: Event): Promise<Review | null>
} }
function getPreviewContent(content: string, previewTag?: string) { function getPreviewContent(content: string, previewTag?: string): { previewContent: string } {
const lines = content.split('\n') const lines = content.split('\n')
const previewContent = previewTag ?? lines[0] ?? content.substring(0, 200) const previewContent = previewTag ?? lines[0] ?? content.substring(0, 200)
return { previewContent } return { previewContent }

View File

@ -19,14 +19,14 @@ export function subscribeWithTimeout<T>(
const sub = createSubscription(pool, [relayUrl], filters) const sub = createSubscription(pool, [relayUrl], filters)
let timeoutId: NodeJS.Timeout | null = null let timeoutId: NodeJS.Timeout | null = null
const cleanup = () => { const cleanup = (): void => {
if (timeoutId) { if (timeoutId) {
clearTimeout(timeoutId) clearTimeout(timeoutId)
} }
sub.unsub() sub.unsub()
} }
const resolveOnce = (value: T | null) => { const resolveOnce = (value: T | null): void => {
if (resolved.value) { if (resolved.value) {
return return
} }
@ -35,11 +35,13 @@ export function subscribeWithTimeout<T>(
resolve(value) resolve(value)
} }
sub.on('event', async (event: Event) => { sub.on('event', async (event: Event): Promise<void> => {
const result = await parser(event) const result = await parser(event)
resolveOnce(result) resolveOnce(result)
}) })
sub.on('eose', () => resolveOnce(null)) sub.on('eose', (): void => {
resolveOnce(null)
})
timeoutId = setTimeout(() => resolveOnce(null), timeout) timeoutId = setTimeout(() => resolveOnce(null), timeout)
}) })
} }

View File

@ -18,7 +18,30 @@ export function extractTypeAndCategory(event: { tags: string[][] }): { type?: Ta
return result return result
} }
export function extractCommonTags(findTag: (key: string) => string | undefined, hasTag: (key: string) => boolean) { export function extractCommonTags(findTag: (key: string) => string | undefined, hasTag: (key: string) => boolean): {
id?: string
service?: string
version: number
hidden: boolean
paywall: boolean
payment: boolean
title?: string
preview?: string
description?: string
mainnetAddress?: string
totalSponsoring?: number
pictureUrl?: string
seriesId?: string
coverUrl?: string
bannerUrl?: string
zapAmount?: number
invoice?: string
paymentHash?: string
encryptedKey?: string
articleId?: string
reviewerPubkey?: string
json?: string
} {
return { return {
id: findTag('id'), id: findTag('id'),
service: findTag('service'), service: findTag('service'),

View File

@ -127,4 +127,3 @@ class SettingsCacheService {
} }
export const settingsCache = new SettingsCacheService() export const settingsCache = new SettingsCacheService()

View File

@ -263,3 +263,8 @@ settings.nip95.list.editUrl=Click to edit URL
settings.nip95.note.title=Note: settings.nip95.note.title=Note:
settings.nip95.note.priority=Endpoints are tried in priority order (lower number = higher priority). Only enabled endpoints will be used for uploads. settings.nip95.note.priority=Endpoints are tried in priority order (lower number = higher priority). Only enabled endpoints will be used for uploads.
settings.nip95.note.fallback=If an endpoint fails, the next enabled endpoint will be tried automatically. settings.nip95.note.fallback=If an endpoint fails, the next enabled endpoint will be tried automatically.
settings.language.title=Preferred Language
settings.language.description=Choose your preferred language for the interface
settings.language.loading=Loading...
settings.language.french=French
settings.language.english=English

View File

@ -225,6 +225,11 @@ settings.nip95.list.editUrl=Cliquer pour modifier l'URL
settings.nip95.note.title=Note : settings.nip95.note.title=Note :
settings.nip95.note.priority=Les endpoints sont essayés dans l'ordre de priorité (nombre plus bas = priorité plus haute). Seuls les endpoints activés seront utilisés pour les uploads. settings.nip95.note.priority=Les endpoints sont essayés dans l'ordre de priorité (nombre plus bas = priorité plus haute). Seuls les endpoints activés seront utilisés pour les uploads.
settings.nip95.note.fallback=Si un endpoint échoue, le prochain endpoint activé sera essayé automatiquement. settings.nip95.note.fallback=Si un endpoint échoue, le prochain endpoint activé sera essayé automatiquement.
settings.language.title=Langue de préférence
settings.language.description=Choisissez votre langue préférée pour l'interface
settings.language.loading=Chargement...
settings.language.french=Français
settings.language.english=Anglais
# Account # Account
account.create.title=Créer un compte account.create.title=Créer un compte

View File

@ -4,14 +4,13 @@ import { useI18n } from '@/hooks/useI18n'
import React from 'react' import React from 'react'
function I18nProvider({ children }: { children: React.ReactNode }) { function I18nProvider({ children }: { children: React.ReactNode }) {
// Get saved locale from IndexedDB or default to French // Get saved locale from localStorage or default to French
const getInitialLocale = async (): Promise<'fr' | 'en'> => { const getInitialLocale = (): 'fr' | 'en' => {
if (typeof window === 'undefined') { if (typeof window === 'undefined') {
return 'fr' return 'fr'
} }
try { try {
const { storageService } = await import('@/lib/storage/indexedDB') const savedLocale = localStorage.getItem('zapwall-locale') as 'fr' | 'en' | null
const savedLocale = await storageService.get<'fr' | 'en'>('zapwall-locale', 'app_storage')
if (savedLocale === 'fr' || savedLocale === 'en') { if (savedLocale === 'fr' || savedLocale === 'en') {
return savedLocale return savedLocale
} }
@ -27,10 +26,9 @@ function I18nProvider({ children }: { children: React.ReactNode }) {
const [localeLoaded, setLocaleLoaded] = React.useState(false) const [localeLoaded, setLocaleLoaded] = React.useState(false)
React.useEffect(() => { React.useEffect(() => {
getInitialLocale().then((locale) => { const locale = getInitialLocale()
setInitialLocale(locale) setInitialLocale(locale)
setLocaleLoaded(true) setLocaleLoaded(true)
})
}, []) }, [])
const { loaded } = useI18n(initialLocale) const { loaded } = useI18n(initialLocale)

View File

@ -278,7 +278,7 @@ export default function AuthorPage(): React.ReactElement {
if (typeof pubkey === 'string') { if (typeof pubkey === 'string') {
// Try to parse as new format first (hash_index_version) // Try to parse as new format first (hash_index_version)
const urlMatch = pubkey.match(/^([a-f0-9]+)_(\d+)_(\d+)$/i) const urlMatch = pubkey.match(/^([a-f0-9]+)_(\d+)_(\d+)$/i)
if (urlMatch && urlMatch[1]) { if (urlMatch?.[1]) {
// Extract hash ID from the format hash_index_version // Extract hash ID from the format hash_index_version
hashIdOrPubkey = urlMatch[1] hashIdOrPubkey = urlMatch[1]
} else { } else {

View File

@ -4,6 +4,7 @@ import { Footer } from '@/components/Footer'
import { Nip95ConfigManager } from '@/components/Nip95ConfigManager' import { Nip95ConfigManager } from '@/components/Nip95ConfigManager'
import { KeyManagementManager } from '@/components/KeyManagementManager' import { KeyManagementManager } from '@/components/KeyManagementManager'
import { CacheUpdateManager } from '@/components/CacheUpdateManager' import { CacheUpdateManager } from '@/components/CacheUpdateManager'
import { LanguageSettingsManager } from '@/components/LanguageSettingsManager'
import { t } from '@/lib/i18n' import { t } from '@/lib/i18n'
export default function SettingsPage(): React.ReactElement { export default function SettingsPage(): React.ReactElement {
@ -20,6 +21,7 @@ export default function SettingsPage(): React.ReactElement {
<div className="w-full px-4 py-8"> <div className="w-full px-4 py-8">
<h1 className="text-3xl font-bold text-neon-cyan mb-8">{t('settings.title')}</h1> <h1 className="text-3xl font-bold text-neon-cyan mb-8">{t('settings.title')}</h1>
<div className="space-y-8"> <div className="space-y-8">
<LanguageSettingsManager />
<KeyManagementManager /> <KeyManagementManager />
<Nip95ConfigManager /> <Nip95ConfigManager />
<CacheUpdateManager /> <CacheUpdateManager />