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:
parent
c39e732049
commit
cc49c9d7c1
@ -30,28 +30,30 @@ export function LanguageSelector(): React.ReactElement {
|
||||
const [currentLocale, setCurrentLocale] = useState<Locale>(getLocale())
|
||||
|
||||
useEffect(() => {
|
||||
// Load saved locale from IndexedDB
|
||||
const loadLocale = async (): Promise<void> => {
|
||||
// Load saved locale from localStorage
|
||||
const loadLocale = (): void => {
|
||||
try {
|
||||
const { storageService } = await import('@/lib/storage/indexedDB')
|
||||
const savedLocale = await storageService.get<Locale>(LOCALE_STORAGE_KEY, 'app_storage')
|
||||
if (typeof window !== 'undefined') {
|
||||
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)
|
||||
}
|
||||
}
|
||||
void loadLocale()
|
||||
loadLocale()
|
||||
}, [])
|
||||
|
||||
const handleLocaleChange = async (locale: Locale): Promise<void> => {
|
||||
const handleLocaleChange = (locale: Locale): void => {
|
||||
setLocale(locale)
|
||||
setCurrentLocale(locale)
|
||||
try {
|
||||
const { storageService } = await import('@/lib/storage/indexedDB')
|
||||
await storageService.set(LOCALE_STORAGE_KEY, locale, 'app_storage')
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.setItem(LOCALE_STORAGE_KEY, locale)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error saving locale:', e)
|
||||
}
|
||||
|
||||
88
components/LanguageSettingsManager.tsx
Normal file
88
components/LanguageSettingsManager.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -12,11 +12,12 @@ export function useI18n(locale: Locale = 'fr'): {
|
||||
useEffect(() => {
|
||||
const load = async (): Promise<void> => {
|
||||
try {
|
||||
// Get saved locale from IndexedDB or use provided locale
|
||||
// Get saved locale from localStorage or use provided locale
|
||||
let savedLocale: Locale | null = null
|
||||
try {
|
||||
const { storageService } = await import('@/lib/storage/indexedDB')
|
||||
savedLocale = await storageService.get<Locale>('zapwall-locale', 'app_storage')
|
||||
if (typeof window !== 'undefined') {
|
||||
savedLocale = localStorage.getItem('zapwall-locale') as Locale | null
|
||||
}
|
||||
} catch {
|
||||
// Fallback to provided locale
|
||||
}
|
||||
|
||||
@ -219,7 +219,7 @@ class NostrService {
|
||||
}
|
||||
|
||||
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> {
|
||||
@ -235,7 +235,7 @@ class NostrService {
|
||||
},
|
||||
]
|
||||
|
||||
const parseProfile = (event: Event) => {
|
||||
const parseProfile = (event: Event): NostrProfile | null => {
|
||||
try {
|
||||
const profile = JSON.parse(event.content) as NostrProfile
|
||||
return { ...profile, pubkey }
|
||||
|
||||
@ -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 previewContent = previewTag ?? lines[0] ?? content.substring(0, 200)
|
||||
return { previewContent }
|
||||
|
||||
@ -19,14 +19,14 @@ export function subscribeWithTimeout<T>(
|
||||
const sub = createSubscription(pool, [relayUrl], filters)
|
||||
let timeoutId: NodeJS.Timeout | null = null
|
||||
|
||||
const cleanup = () => {
|
||||
const cleanup = (): void => {
|
||||
if (timeoutId) {
|
||||
clearTimeout(timeoutId)
|
||||
}
|
||||
sub.unsub()
|
||||
}
|
||||
|
||||
const resolveOnce = (value: T | null) => {
|
||||
const resolveOnce = (value: T | null): void => {
|
||||
if (resolved.value) {
|
||||
return
|
||||
}
|
||||
@ -35,11 +35,13 @@ export function subscribeWithTimeout<T>(
|
||||
resolve(value)
|
||||
}
|
||||
|
||||
sub.on('event', async (event: Event) => {
|
||||
sub.on('event', async (event: Event): Promise<void> => {
|
||||
const result = await parser(event)
|
||||
resolveOnce(result)
|
||||
})
|
||||
sub.on('eose', () => resolveOnce(null))
|
||||
sub.on('eose', (): void => {
|
||||
resolveOnce(null)
|
||||
})
|
||||
timeoutId = setTimeout(() => resolveOnce(null), timeout)
|
||||
})
|
||||
}
|
||||
|
||||
@ -18,7 +18,30 @@ export function extractTypeAndCategory(event: { tags: string[][] }): { type?: Ta
|
||||
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 {
|
||||
id: findTag('id'),
|
||||
service: findTag('service'),
|
||||
|
||||
@ -127,4 +127,3 @@ class SettingsCacheService {
|
||||
}
|
||||
|
||||
export const settingsCache = new SettingsCacheService()
|
||||
|
||||
|
||||
@ -263,3 +263,8 @@ settings.nip95.list.editUrl=Click to edit URL
|
||||
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.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
|
||||
|
||||
@ -225,6 +225,11 @@ settings.nip95.list.editUrl=Cliquer pour modifier l'URL
|
||||
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.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.create.title=Créer un compte
|
||||
|
||||
@ -4,14 +4,13 @@ import { useI18n } from '@/hooks/useI18n'
|
||||
import React from 'react'
|
||||
|
||||
function I18nProvider({ children }: { children: React.ReactNode }) {
|
||||
// Get saved locale from IndexedDB or default to French
|
||||
const getInitialLocale = async (): Promise<'fr' | 'en'> => {
|
||||
// Get saved locale from localStorage or default to French
|
||||
const getInitialLocale = (): 'fr' | 'en' => {
|
||||
if (typeof window === 'undefined') {
|
||||
return 'fr'
|
||||
}
|
||||
try {
|
||||
const { storageService } = await import('@/lib/storage/indexedDB')
|
||||
const savedLocale = await storageService.get<'fr' | 'en'>('zapwall-locale', 'app_storage')
|
||||
const savedLocale = localStorage.getItem('zapwall-locale') as 'fr' | 'en' | null
|
||||
if (savedLocale === 'fr' || savedLocale === 'en') {
|
||||
return savedLocale
|
||||
}
|
||||
@ -27,10 +26,9 @@ function I18nProvider({ children }: { children: React.ReactNode }) {
|
||||
const [localeLoaded, setLocaleLoaded] = React.useState(false)
|
||||
|
||||
React.useEffect(() => {
|
||||
getInitialLocale().then((locale) => {
|
||||
const locale = getInitialLocale()
|
||||
setInitialLocale(locale)
|
||||
setLocaleLoaded(true)
|
||||
})
|
||||
}, [])
|
||||
|
||||
const { loaded } = useI18n(initialLocale)
|
||||
|
||||
@ -278,7 +278,7 @@ export default function AuthorPage(): React.ReactElement {
|
||||
if (typeof pubkey === 'string') {
|
||||
// Try to parse as new format first (hash_index_version)
|
||||
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
|
||||
hashIdOrPubkey = urlMatch[1]
|
||||
} else {
|
||||
|
||||
@ -4,6 +4,7 @@ import { Footer } from '@/components/Footer'
|
||||
import { Nip95ConfigManager } from '@/components/Nip95ConfigManager'
|
||||
import { KeyManagementManager } from '@/components/KeyManagementManager'
|
||||
import { CacheUpdateManager } from '@/components/CacheUpdateManager'
|
||||
import { LanguageSettingsManager } from '@/components/LanguageSettingsManager'
|
||||
import { t } from '@/lib/i18n'
|
||||
|
||||
export default function SettingsPage(): React.ReactElement {
|
||||
@ -20,6 +21,7 @@ export default function SettingsPage(): React.ReactElement {
|
||||
<div className="w-full px-4 py-8">
|
||||
<h1 className="text-3xl font-bold text-neon-cyan mb-8">{t('settings.title')}</h1>
|
||||
<div className="space-y-8">
|
||||
<LanguageSettingsManager />
|
||||
<KeyManagementManager />
|
||||
<Nip95ConfigManager />
|
||||
<CacheUpdateManager />
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user