One Calendar is a privacy-first calendar web app built with Next.js. It has modern security features, including e2ee, password-protected sharing, and self-destructing share links 📅
calendar.xyehr.cn
1'use client'
2
3import { useEffect, useState } from 'react'
4import {
5 getEncryptionState,
6 readEncryptedLocalStorage,
7 subscribeEncryptionState,
8 writeEncryptedLocalStorage,
9} from '@/hooks/useLocalStorage'
10import {
11 translations as localeTranslations,
12 type Language,
13} from '@/lib/locales'
14
15const LANGUAGE_STORAGE_KEY = 'preferred-language'
16
17export const supportedLanguages = Object.keys(localeTranslations) as Language[]
18
19const baseLanguage = 'en' as const
20
21export const translations = Object.fromEntries(
22 supportedLanguages.map((lang) => [
23 lang,
24 {
25 ...localeTranslations[baseLanguage],
26 ...localeTranslations[lang],
27 },
28 ]),
29) as typeof localeTranslations
30
31const LANGUAGE_AUTONYM: Partial<Record<Language, string>> = {
32 en: 'English',
33 'en-GB': 'British English',
34 de: 'Deutsch',
35 es: 'Español',
36 fr: 'Français',
37 ja: '日本語',
38 yue: '粵語',
39 'zh-CN': '简体中文',
40 'zh-HK': '繁體中文(香港)',
41 'zh-TW': '繁體中文(台灣)',
42 it: 'Italiano',
43 ko: '한국어',
44 pl: 'Polski',
45 nl: 'Nederlands',
46 pt: 'Português',
47 ru: 'Русский',
48 sv: 'Svenska',
49 fi: 'Suomi',
50 hi: 'हिन्दी',
51 nb: 'Norsk bokmål',
52 vi: 'Tiếng Việt',
53 ro: 'Română',
54 uk: 'Українська',
55 is: 'Íslenska',
56 sw: 'Kiswahili',
57 bn: 'বাংলা',
58 el: 'Ελληνικά',
59 sq: 'Shqip',
60 lt: 'Lietuvių',
61 lv: 'Latviešu',
62 sl: 'Slovenščina',
63 mk: 'Македонски',
64 sr: 'Српски',
65}
66
67const byExactLowercase = new Map(
68 supportedLanguages.map((lang) => [lang.toLowerCase(), lang] as const),
69)
70
71const byBaseLowercase = new Map(
72 supportedLanguages.map(
73 (lang) => [lang.toLowerCase().split('-')[0], lang] as const,
74 ),
75)
76
77const normalizeLanguage = (
78 value: string | null | undefined,
79): Language | null => {
80 if (!value) return null
81
82 const normalized = value.toLowerCase()
83 const exact = byExactLowercase.get(normalized)
84 if (exact) return exact
85
86 const base = normalized.split('-')[0]
87 return byBaseLowercase.get(base) ?? null
88}
89
90export const getLanguageAutonym = (language: Language) => {
91 const configured = LANGUAGE_AUTONYM[language]
92 if (configured) return configured
93
94 return (
95 new Intl.DisplayNames([language], { type: 'language' }).of(language) ??
96 language
97 )
98}
99
100const zhLanguages: Language[] = ['zh-CN', 'zh-HK', 'zh-TW']
101
102export const isZhLanguage = (language: Language) =>
103 zhLanguages.includes(language)
104
105export const getStoredLanguage = async (): Promise<Language> => {
106 const storedLanguage = await readEncryptedLocalStorage<string | null>(
107 LANGUAGE_STORAGE_KEY,
108 null,
109 )
110 return normalizeLanguage(storedLanguage) ?? detectSystemLanguage()
111}
112
113function detectSystemLanguage(): Language {
114 if (typeof window === 'undefined') {
115 return 'en'
116 }
117
118 const browserLang = navigator.language
119 return normalizeLanguage(browserLang) ?? 'en'
120}
121
122export function useLanguage(): [Language, (lang: Language) => void] {
123 const [language, setLanguageState] = useState<Language>('en')
124
125 useEffect(() => {
126 let active = true
127 const loadLanguage = () =>
128 readEncryptedLocalStorage<string | null>(LANGUAGE_STORAGE_KEY, null).then(
129 (storedLanguage) => {
130 if (!active) return
131 const normalized =
132 normalizeLanguage(storedLanguage) ?? detectSystemLanguage()
133 setLanguageState(normalized)
134 if (storedLanguage && normalized !== storedLanguage) {
135 void writeEncryptedLocalStorage(LANGUAGE_STORAGE_KEY, normalized)
136 }
137 },
138 )
139
140 loadLanguage()
141
142 const handleStorageChange = (e: StorageEvent) => {
143 if (e.key === LANGUAGE_STORAGE_KEY) {
144 readEncryptedLocalStorage<string | null>(
145 LANGUAGE_STORAGE_KEY,
146 null,
147 ).then((newLanguage) => {
148 const normalized = normalizeLanguage(newLanguage)
149 if (normalized) {
150 setLanguageState(normalized)
151 }
152 })
153 }
154 }
155
156 const handleCustomLanguageChange = (event: Event) => {
157 const customEvent = event as CustomEvent<{ language?: string }>
158 const normalized = normalizeLanguage(customEvent.detail?.language)
159 if (normalized) {
160 setLanguageState(normalized)
161 }
162 }
163
164 const unsubscribe = subscribeEncryptionState(() => {
165 if (getEncryptionState().ready) {
166 loadLanguage()
167 }
168 })
169
170 window.addEventListener('storage', handleStorageChange)
171 window.addEventListener('languagechange', handleCustomLanguageChange)
172 return () => {
173 active = false
174 unsubscribe()
175 window.removeEventListener('storage', handleStorageChange)
176 window.removeEventListener('languagechange', handleCustomLanguageChange)
177 }
178 }, [])
179
180 const setLanguage = (lang: Language) => {
181 setLanguageState(lang)
182 void writeEncryptedLocalStorage(LANGUAGE_STORAGE_KEY, lang)
183
184 window.dispatchEvent(
185 new CustomEvent('languagechange', { detail: { language: lang } }),
186 )
187 }
188
189 return [language, setLanguage]
190}
191export type { Language }