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())
|
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)
|
||||||
}
|
}
|
||||||
|
|||||||
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(() => {
|
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
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 }
|
||||||
|
|||||||
@ -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 }
|
||||||
|
|||||||
@ -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)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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'),
|
||||||
|
|||||||
@ -127,4 +127,3 @@ class SettingsCacheService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const settingsCache = new 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.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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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 />
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user