initial
This commit is contained in:
@@ -0,0 +1,28 @@
|
||||
import { DEFAULT_LOCALE, SUPPORTED_LOCALES, type SupportedLocale } from './types.js';
|
||||
|
||||
export function isSupportedLocale(value: string | null | undefined): value is SupportedLocale {
|
||||
return (
|
||||
typeof value === 'string' &&
|
||||
(SUPPORTED_LOCALES as readonly string[]).includes(value)
|
||||
);
|
||||
}
|
||||
|
||||
// Strip region tag ("de-DE" -> "de"), lowercase, validate.
|
||||
export function normaliseLocale(input: string | null | undefined): SupportedLocale | null {
|
||||
if (!input) return null;
|
||||
const short = input.toLowerCase().split('-')[0];
|
||||
return isSupportedLocale(short) ? short : null;
|
||||
}
|
||||
|
||||
// Detect the best-guess initial locale using only platform-agnostic inputs.
|
||||
// Host apps layer their own precedence on top (cached pref -> user profile).
|
||||
export function detectBrowserLocale(
|
||||
navigatorLanguages: readonly string[] | undefined,
|
||||
): SupportedLocale {
|
||||
if (!navigatorLanguages) return DEFAULT_LOCALE;
|
||||
for (const lang of navigatorLanguages) {
|
||||
const match = normaliseLocale(lang);
|
||||
if (match) return match;
|
||||
}
|
||||
return DEFAULT_LOCALE;
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
// Extract an ERR_* code from a thrown error. Supabase wraps Postgres errors
|
||||
// as `PostgrestError { message, code, details, hint }`; `auth.signInWithOtp`
|
||||
// passes the trigger's raise text via `AuthApiError { message }`.
|
||||
//
|
||||
// We recognise anything that looks like `ERR_[A-Z0-9_]+`.
|
||||
|
||||
const CODE_PATTERN = /ERR_[A-Z0-9_]+/;
|
||||
|
||||
export function extractErrorCode(err: unknown): string | null {
|
||||
if (!err) return null;
|
||||
|
||||
if (typeof err === 'string') {
|
||||
const m = err.match(CODE_PATTERN);
|
||||
return m ? m[0] : null;
|
||||
}
|
||||
|
||||
if (err instanceof Error) {
|
||||
const m = err.message.match(CODE_PATTERN);
|
||||
return m ? m[0] : null;
|
||||
}
|
||||
|
||||
if (typeof err === 'object') {
|
||||
const record = err as Record<string, unknown>;
|
||||
const candidates: unknown[] = [record.code, record.message, record.details, record.hint];
|
||||
for (const candidate of candidates) {
|
||||
if (typeof candidate !== 'string') continue;
|
||||
const m = candidate.match(CODE_PATTERN);
|
||||
if (m) return m[0];
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import i18next, { type i18n as I18nInstance, type Resource } from 'i18next';
|
||||
import { initReactI18next } from 'react-i18next';
|
||||
|
||||
import { resources } from './resources.js';
|
||||
import { DEFAULT_LOCALE, type SupportedLocale } from './types.js';
|
||||
|
||||
export * from './detect.js';
|
||||
export * from './error-map.js';
|
||||
export * from './types.js';
|
||||
|
||||
export interface InitI18nOptions {
|
||||
initialLocale: SupportedLocale;
|
||||
// Called whenever the active language changes.
|
||||
onLanguageChanged?: (locale: SupportedLocale) => void;
|
||||
}
|
||||
|
||||
// Idempotent init — safe to call from multiple entry points.
|
||||
// Returns the configured i18next instance.
|
||||
export function initI18n(options: InitI18nOptions): I18nInstance {
|
||||
if (!i18next.isInitialized) {
|
||||
void i18next.use(initReactI18next).init({
|
||||
resources: resources as unknown as Resource,
|
||||
lng: options.initialLocale,
|
||||
fallbackLng: DEFAULT_LOCALE,
|
||||
defaultNS: 'common',
|
||||
ns: ['common', 'auth', 'errors', 'app'],
|
||||
interpolation: { escapeValue: false },
|
||||
returnNull: false,
|
||||
});
|
||||
} else if (i18next.language !== options.initialLocale) {
|
||||
void i18next.changeLanguage(options.initialLocale);
|
||||
}
|
||||
|
||||
if (options.onLanguageChanged) {
|
||||
i18next.off('languageChanged');
|
||||
i18next.on('languageChanged', (lng: string) => {
|
||||
if (isSupportedLocale(lng)) {
|
||||
options.onLanguageChanged?.(lng);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return i18next;
|
||||
}
|
||||
|
||||
export function changeLocale(locale: SupportedLocale): Promise<unknown> {
|
||||
return i18next.changeLanguage(locale);
|
||||
}
|
||||
|
||||
export { i18next };
|
||||
|
||||
// Local type guard (detect.ts exports this too; re-declared here to keep this
|
||||
// file free of cyclic references).
|
||||
function isSupportedLocale(value: string): value is SupportedLocale {
|
||||
return value === 'en' || value === 'de';
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
{
|
||||
"nav": {
|
||||
"chats": "Chats",
|
||||
"friends": "Freunde",
|
||||
"settings": "Einstellungen",
|
||||
"admin": "Admin"
|
||||
},
|
||||
"sidebar": {
|
||||
"search_placeholder": "Suchen…",
|
||||
"new_chat": "Neuer Chat",
|
||||
"sign_out": "Abmelden"
|
||||
},
|
||||
"presence": {
|
||||
"online": "Online",
|
||||
"idle": "Abwesend",
|
||||
"dnd": "Nicht stören",
|
||||
"invisible": "Unsichtbar",
|
||||
"offline": "Offline"
|
||||
},
|
||||
"chats": {
|
||||
"empty_title": "Noch keine Unterhaltungen",
|
||||
"empty_subtitle": "Starte einen Chat über den Freunde-Tab.",
|
||||
"select_prompt": "Unterhaltung auswählen",
|
||||
"select_subtitle": "Wähle einen Chat aus der Liste oder starte einen neuen.",
|
||||
"deleted": "(gelöscht)",
|
||||
"edited": "bearbeitet",
|
||||
"seen": "Gelesen",
|
||||
"typing_one": "{{name}} schreibt…",
|
||||
"typing_many": "{{count}} schreiben…",
|
||||
"new_chat": "Neuer Chat",
|
||||
"new_group": "Neue Gruppe",
|
||||
"call_outgoing": "Ausgehender Anruf",
|
||||
"call_incoming": "Eingehender Anruf",
|
||||
"call_missed": "Verpasster Anruf",
|
||||
"call_no_answer": "Keine Antwort",
|
||||
"call_declined": "Anruf abgelehnt"
|
||||
},
|
||||
"call": {
|
||||
"start_audio": "Sprachanruf",
|
||||
"incoming_title": "Eingehender Anruf",
|
||||
"incoming_from": "{{name}} ruft an",
|
||||
"incoming_group_from": "{{name}} ruft die Gruppe",
|
||||
"outgoing_ringing": "Klingelt…",
|
||||
"connecting": "Verbinde…",
|
||||
"connected": "Im Gespräch",
|
||||
"accept": "Annehmen",
|
||||
"decline": "Ablehnen",
|
||||
"hangup": "Auflegen",
|
||||
"mute": "Stumm",
|
||||
"unmute": "Laut",
|
||||
"busy": "Besetzt — bereits im Gespräch",
|
||||
"active_in_conv": "Laufender Anruf · {{count}} im Raum",
|
||||
"join": "Beitreten",
|
||||
"in_call": "Im Anruf",
|
||||
"waiting_for_peers": "Warte auf andere…",
|
||||
"voice_connected": "Sprachchat verbunden",
|
||||
"still_live": "Anruf läuft noch",
|
||||
"share_screen": "Bildschirm teilen",
|
||||
"stop_share_screen": "Screen-Share stoppen",
|
||||
"is_sharing_screen": "{{name}} teilt den Bildschirm",
|
||||
"watch_screen": "Bildschirm anschauen",
|
||||
"stop_watching": "Nicht mehr anschauen",
|
||||
"fullscreen": "Vollbild",
|
||||
"e2ee_active_hint": "Audio + Video sind Ende-zu-Ende-verschlüsselt"
|
||||
},
|
||||
"group": {
|
||||
"create_title": "Neue Gruppe",
|
||||
"create_name_label": "Gruppenname",
|
||||
"create_name_placeholder": "Team-Chat",
|
||||
"create_members_label": "Freunde hinzufügen",
|
||||
"create_members_empty": "Noch keine Freunde — erst welche hinzufügen.",
|
||||
"create_cta": "Gruppe erstellen",
|
||||
"create_cta_loading": "Erstelle…",
|
||||
"info_title": "Gruppen-Info",
|
||||
"info_members": "Mitglieder",
|
||||
"info_role_admin": "Admin",
|
||||
"info_role_mod": "Mod",
|
||||
"info_role_member": "Mitglied",
|
||||
"info_add_title": "Mitglieder hinzufügen",
|
||||
"info_add_empty": "Alle deine Freunde sind bereits in dieser Gruppe.",
|
||||
"info_add_help": "Klick auf einen Freund um ihn direkt hinzuzufügen.",
|
||||
"info_leave": "Gruppe verlassen",
|
||||
"info_leave_confirm": "Diese Gruppe wirklich verlassen?"
|
||||
},
|
||||
"friends": {
|
||||
"title": "Freunde",
|
||||
"search_placeholder": "Nach Benutzername suchen…",
|
||||
"search_min_chars": "Mindestens 2 Zeichen eingeben.",
|
||||
"search_no_results": "Keine Treffer.",
|
||||
"search_results_title": "Suchergebnisse",
|
||||
"send_request": "Anfrage senden",
|
||||
"request_sent": "Anfrage gesendet",
|
||||
"already_friends": "Bereits befreundet",
|
||||
"incoming_request": "Möchte befreundet sein",
|
||||
"tab_friends": "Freunde",
|
||||
"tab_pending": "Ausstehend",
|
||||
"tab_requests": "Anfragen",
|
||||
"empty_friends": "Noch keine Freunde.",
|
||||
"empty_pending": "Keine ausgehenden Anfragen.",
|
||||
"empty_requests": "Keine eingehenden Anfragen.",
|
||||
"action_message": "Nachricht",
|
||||
"action_unfriend": "Entfernen",
|
||||
"action_accept": "Annehmen",
|
||||
"action_decline": "Ablehnen",
|
||||
"action_cancel": "Abbrechen",
|
||||
"confirm_unfriend": "Diesen Freund entfernen?"
|
||||
},
|
||||
"admin": {
|
||||
"nav": "Admin",
|
||||
"title": "Admin-Panel",
|
||||
"settings_title": "Globale Einstellungen",
|
||||
"invites_enabled": "Neue Registrierungen erlauben",
|
||||
"invites_enabled_hint": "Wenn aus, kann sich niemand neu registrieren — auch nicht mit gültigem Invite.",
|
||||
"invites_title": "Einladungscodes",
|
||||
"invites_create": "Neuer Code",
|
||||
"invites_empty": "Noch keine Codes.",
|
||||
"invites_disable": "Deaktivieren",
|
||||
"invites_enable": "Aktivieren",
|
||||
"invites_delete": "Löschen",
|
||||
"invites_copy": "Kopieren",
|
||||
"invites_copied": "Kopiert",
|
||||
"users_title": "Benutzer",
|
||||
"users_empty": "Noch keine Profile.",
|
||||
"users_flag_admin": "Admin",
|
||||
"users_flag_banned": "Gesperrt",
|
||||
"users_flag_blocked_inviting": "Darf keine Invites erstellen",
|
||||
"invite_col_code": "Code",
|
||||
"invite_col_uses": "Nutzung",
|
||||
"invite_col_expires": "Läuft ab",
|
||||
"invite_col_status": "Status",
|
||||
"invite_col_created": "Erstellt",
|
||||
"invite_status_active": "Aktiv",
|
||||
"invite_status_disabled": "Deaktiviert",
|
||||
"invite_status_expired": "Abgelaufen",
|
||||
"invite_expires_never": "Nie"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Einstellungen",
|
||||
"section_account": "Account",
|
||||
"section_appearance": "Darstellung",
|
||||
"section_privacy": "Privatsphäre",
|
||||
"section_devices": "Geräte",
|
||||
"section_voice": "Sprache",
|
||||
"audio_quality": "Audio-Qualität",
|
||||
"audio_voice": "Sprache (Empfohlen)",
|
||||
"audio_hifi": "HiFi / Musik",
|
||||
"audio_voice_hint": "Mono 48 kbps mit Noise-Suppression, Echo-Cancellation und Auto-Gain. Optimiert für Sprache im Raum.",
|
||||
"audio_hifi_hint": "Stereo 510 kbps Opus ohne DSP — bester Musik/Broadcast-Sound. Erfordert ruhige Umgebung.",
|
||||
"e2ee_calls": "Ende-zu-Ende-Verschlüsselung (Calls)",
|
||||
"e2ee_calls_hint": "Audio + Video werden vor dem Upload verschlüsselt. Der Server sieht nur Ciphertext. Alle Teilnehmer müssen die Option aktiv haben.",
|
||||
"e2ee_calls_unsupported": "Dein Browser unterstützt keine Insertable Streams. E2EE-Calls nicht verfügbar.",
|
||||
"ptt_enabled": "Push-to-Talk",
|
||||
"ptt_enabled_hint": "Mic bleibt stumm, bis die Taste gehalten wird. Overridet den normalen Mute-Button.",
|
||||
"ptt_key": "Hotkey",
|
||||
"ptt_press_key": "Taste drücken…",
|
||||
"section_screen_share": "Bildschirmfreigabe",
|
||||
"screen_share_quality": "Qualität",
|
||||
"screen_share_hint": "WebRTC passt Bitrate + Auflösung dynamisch an die Netzwerkqualität an (SVC/VP9). Die Werte sind Obergrenzen. Änderungen greifen beim nächsten Call.",
|
||||
"language": "Sprache",
|
||||
"presence": "Status",
|
||||
"show_read_receipts": "Lesebestätigungen anzeigen",
|
||||
"show_read_receipts_hint": "Wenn aus, sehen andere nicht wann du ihre Nachrichten gelesen hast — und du siehst nicht wann sie deine gelesen haben.",
|
||||
"allow_dms_strangers": "DMs von Fremden erlauben",
|
||||
"allow_dms_strangers_hint": "Wenn aus, können dich nur Freunde direkt anschreiben.",
|
||||
"this_device": "Dieses Gerät",
|
||||
"danger_zone": "Gefahrenzone",
|
||||
"sign_out": "Abmelden"
|
||||
},
|
||||
"update": {
|
||||
"available": "Update verfügbar",
|
||||
"install": "Installieren & Neustarten",
|
||||
"downloading": "Lade",
|
||||
"installing": "Installiere…"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
{
|
||||
"brand": {
|
||||
"badge": "Ende-zu-Ende verschlüsselt · nur auf Einladung",
|
||||
"title_line_1": "Private Nachrichten",
|
||||
"title_line_2": "für deinen Kreis.",
|
||||
"subtitle": "Selbst gehostet, Zero-Knowledge-Server, libsodium-Kryptografie. Du und deine Freunde — nichts dazwischen.",
|
||||
"feature_zk_title": "Zero-Knowledge",
|
||||
"feature_zk_desc": "Der Server entschlüsselt niemals deinen Chiffretext.",
|
||||
"feature_selfhost_title": "Self-Hosted",
|
||||
"feature_selfhost_desc": "Dein Supabase. Dein VPS. Deine Schlüssel.",
|
||||
"feature_invite_title": "Nur mit Einladung",
|
||||
"feature_invite_desc": "Keine Suche, keine Fremden. Geschlossener Kreis."
|
||||
},
|
||||
"signup": {
|
||||
"title": "Account erstellen",
|
||||
"subtitle": "Keine Passwörter. Magic Link per E-Mail.",
|
||||
"cta": "Magic Link senden",
|
||||
"cta_sending": "Link wird gesendet…"
|
||||
},
|
||||
"login": {
|
||||
"title": "Willkommen zurück",
|
||||
"subtitle": "E-Mail eingeben — Magic Link folgt.",
|
||||
"cta": "Magic Link senden",
|
||||
"cta_sending": "Link wird gesendet…"
|
||||
},
|
||||
"tab_signup": "Registrieren",
|
||||
"tab_login": "Anmelden",
|
||||
"fields": {
|
||||
"email": "E-Mail",
|
||||
"email_placeholder": "du@beispiel.de",
|
||||
"username": "Benutzername",
|
||||
"username_placeholder": "dennis",
|
||||
"username_hint": "Damit meldest du dich an. Nur Kleinbuchstaben.",
|
||||
"username_invalid": "Kleinbuchstaben a–z, Ziffern, Unterstrich · 3–32 Zeichen.",
|
||||
"invite_code": "Einladungscode",
|
||||
"invite_hint": "Pflichtfeld · Zugang nur auf Einladung."
|
||||
},
|
||||
"sent_banner": "Magic Link an {{email}} gesendet.",
|
||||
"sent_banner_hint": "Dev-Stack: Inbucket öffnen und den 6-stelligen Code aus der Mail kopieren.",
|
||||
"otp_label": "6-stelliger Code",
|
||||
"otp_placeholder": "123456",
|
||||
"otp_hint": "Füge den Code aus der Mail ein.",
|
||||
"otp_cta": "Code prüfen",
|
||||
"otp_cta_loading": "Prüfe…",
|
||||
"otp_back": "Andere E-Mail verwenden",
|
||||
"footer_signup_prompt": "Schon registriert?",
|
||||
"footer_login_prompt": "Neu hier?",
|
||||
"footer_switch_to_login": "Anmelden",
|
||||
"footer_switch_to_signup": "Account erstellen",
|
||||
"footer_studio": "Supabase Studio",
|
||||
"legal_note": "Mit der Registrierung akzeptierst du, dass der Server nur Chiffretext sieht.",
|
||||
"signed_in": {
|
||||
"title": "Angemeldet",
|
||||
"session_active": "Sitzung aktiv",
|
||||
"user_id": "Benutzer-ID",
|
||||
"email": "E-Mail",
|
||||
"username": "Benutzername",
|
||||
"display_name": "Anzeigename",
|
||||
"admin": "Admin",
|
||||
"yes": "Ja",
|
||||
"no": "Nein",
|
||||
"sign_out": "Abmelden",
|
||||
"device_active": "Aktives Gerät",
|
||||
"device_platform": "Plattform",
|
||||
"device_registered_at": "Registriert"
|
||||
},
|
||||
"device": {
|
||||
"title": "Dieses Gerät registrieren",
|
||||
"subtitle": "Erzeugt ein X25519-Schlüsselpaar. Der private Schlüssel bleibt auf diesem Gerät.",
|
||||
"name_label": "Gerätename",
|
||||
"name_hint": "Erscheint in deiner Geräteliste. Wähle einen erkennbaren Namen.",
|
||||
"name_placeholder": "Dennis Laptop",
|
||||
"cta": "Gerät registrieren",
|
||||
"cta_loading": "Schlüsselpaar wird erzeugt…",
|
||||
"security_note_dev": "Dev-Build: Privater Schlüssel liegt unverschlüsselt im localStorage. Stronghold folgt vor dem Release."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"app_name": "ChatApp",
|
||||
"loading": "Lädt…",
|
||||
"finalising_session": "Sitzung wird abgeschlossen…",
|
||||
"cancel": "Abbrechen",
|
||||
"save": "Speichern",
|
||||
"close": "Schließen",
|
||||
"retry": "Wiederholen",
|
||||
"online": "Online",
|
||||
"offline": "Offline",
|
||||
"idle": "Abwesend",
|
||||
"dnd": "Nicht stören",
|
||||
"invisible": "Unsichtbar",
|
||||
"local_stack_online": "Lokaler Stack online",
|
||||
"dev_build": "Dev-Build"
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"generic": "Etwas ist schiefgelaufen.",
|
||||
"network": "Netzwerkfehler. Prüfe deine Verbindung.",
|
||||
"ERR_NOT_AUTH": "Du bist nicht angemeldet.",
|
||||
"ERR_INVITE_CODE_REQUIRED": "Einladungscode ist erforderlich.",
|
||||
"ERR_USERNAME_INVALID": "Benutzername muss aus Kleinbuchstaben a–z, Ziffern oder Unterstrich bestehen (3–32 Zeichen).",
|
||||
"ERR_INVITES_DISABLED": "Registrierungen sind derzeit vom Admin deaktiviert.",
|
||||
"ERR_INVITE_NOT_FOUND": "Einladungscode nicht gefunden.",
|
||||
"ERR_INVITE_DISABLED": "Diese Einladung wurde deaktiviert.",
|
||||
"ERR_INVITE_EXPIRED": "Diese Einladung ist abgelaufen.",
|
||||
"ERR_INVITE_EXHAUSTED": "Diese Einladung wurde bereits aufgebraucht.",
|
||||
"ERR_DM_SELF": "Du kannst dir keine DM an dich selbst schicken.",
|
||||
"ERR_DM_STRANGERS_DISABLED": "Dieser Benutzer akzeptiert keine DMs von Fremden.",
|
||||
"ERR_NO_PENDING_DM": "Keine offene DM-Anfrage gefunden.",
|
||||
"ERR_GROUP_INVITE_NOT_FOUND": "Gruppeneinladung nicht gefunden.",
|
||||
"ERR_GROUP_INVITE_DISABLED": "Diese Gruppeneinladung wurde deaktiviert.",
|
||||
"ERR_GROUP_INVITE_EXPIRED": "Diese Gruppeneinladung ist abgelaufen.",
|
||||
"ERR_GROUP_INVITE_EXHAUSTED": "Diese Gruppeneinladung wurde bereits aufgebraucht.",
|
||||
"ERR_FRIEND_SELF": "Du kannst dich nicht selbst als Freund hinzufügen.",
|
||||
"ERR_FRIEND_SELF_ACCEPT": "Du kannst deine eigene Freundschaftsanfrage nicht annehmen.",
|
||||
"ERR_FRIEND_BAD_TRANSITION": "Ungültiger Freundschaftsstatus-Wechsel.",
|
||||
"ERR_MESSAGE_DELETED": "Diese Nachricht wurde bereits gelöscht.",
|
||||
"ERR_DELETE_FORBIDDEN": "Du darfst diese Nachricht nicht löschen.",
|
||||
"ERR_EDIT_NOT_SENDER": "Nur der Absender kann diese Nachricht bearbeiten.",
|
||||
"ERR_EDIT_WINDOW_EXPIRED": "Das 24-Stunden-Bearbeitungsfenster ist abgelaufen.",
|
||||
"ERR_ENVELOPE_NOT_SENDER": "Nur der Absender kann die Envelopes neu schreiben.",
|
||||
"ERR_ENVELOPE_WINDOW_EXPIRED": "Das 24-Stunden-Envelope-Bearbeitungsfenster ist abgelaufen."
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
{
|
||||
"nav": {
|
||||
"chats": "Chats",
|
||||
"friends": "Friends",
|
||||
"settings": "Settings",
|
||||
"admin": "Admin"
|
||||
},
|
||||
"sidebar": {
|
||||
"search_placeholder": "Search…",
|
||||
"new_chat": "New chat",
|
||||
"sign_out": "Sign out"
|
||||
},
|
||||
"presence": {
|
||||
"online": "Online",
|
||||
"idle": "Idle",
|
||||
"dnd": "Do not disturb",
|
||||
"invisible": "Invisible",
|
||||
"offline": "Offline"
|
||||
},
|
||||
"chats": {
|
||||
"empty_title": "No conversations yet",
|
||||
"empty_subtitle": "Start a chat from the Friends tab.",
|
||||
"select_prompt": "Select a conversation",
|
||||
"select_subtitle": "Pick a chat from the list, or start a new one.",
|
||||
"deleted": "(deleted)",
|
||||
"edited": "edited",
|
||||
"seen": "Seen",
|
||||
"typing_one": "{{name}} is typing…",
|
||||
"typing_many": "{{count}} people are typing…",
|
||||
"new_chat": "New chat",
|
||||
"new_group": "New group",
|
||||
"call_outgoing": "Outgoing call",
|
||||
"call_incoming": "Incoming call",
|
||||
"call_missed": "Missed call",
|
||||
"call_no_answer": "No answer",
|
||||
"call_declined": "Call declined"
|
||||
},
|
||||
"call": {
|
||||
"start_audio": "Voice call",
|
||||
"incoming_title": "Incoming call",
|
||||
"incoming_from": "{{name}} is calling",
|
||||
"incoming_group_from": "{{name}} is calling the group",
|
||||
"outgoing_ringing": "Calling…",
|
||||
"connecting": "Connecting…",
|
||||
"connected": "In call",
|
||||
"accept": "Accept",
|
||||
"decline": "Decline",
|
||||
"hangup": "Hang up",
|
||||
"mute": "Mute",
|
||||
"unmute": "Unmute",
|
||||
"busy": "Busy — already in a call",
|
||||
"active_in_conv": "Active call · {{count}} in room",
|
||||
"join": "Join",
|
||||
"in_call": "In call",
|
||||
"waiting_for_peers": "Waiting for others…",
|
||||
"voice_connected": "Voice connected",
|
||||
"still_live": "Call still live",
|
||||
"share_screen": "Share screen",
|
||||
"stop_share_screen": "Stop sharing",
|
||||
"is_sharing_screen": "{{name}} is sharing their screen",
|
||||
"watch_screen": "Watch screen",
|
||||
"stop_watching": "Stop watching",
|
||||
"fullscreen": "Fullscreen",
|
||||
"e2ee_active_hint": "Audio + video are end-to-end encrypted"
|
||||
},
|
||||
"group": {
|
||||
"create_title": "New group",
|
||||
"create_name_label": "Group name",
|
||||
"create_name_placeholder": "Team chat",
|
||||
"create_members_label": "Add friends",
|
||||
"create_members_empty": "No friends yet — add some first.",
|
||||
"create_cta": "Create group",
|
||||
"create_cta_loading": "Creating…",
|
||||
"info_title": "Group info",
|
||||
"info_members": "Members",
|
||||
"info_role_admin": "Admin",
|
||||
"info_role_mod": "Mod",
|
||||
"info_role_member": "Member",
|
||||
"info_add_title": "Add members",
|
||||
"info_add_empty": "All your friends are already in this group.",
|
||||
"info_add_help": "Pick a friend to add them directly.",
|
||||
"info_leave": "Leave group",
|
||||
"info_leave_confirm": "Leave this group?"
|
||||
},
|
||||
"friends": {
|
||||
"title": "Friends",
|
||||
"search_placeholder": "Search by username…",
|
||||
"search_min_chars": "Type at least 2 characters.",
|
||||
"search_no_results": "No users matched.",
|
||||
"search_results_title": "Search results",
|
||||
"send_request": "Send request",
|
||||
"request_sent": "Request sent",
|
||||
"already_friends": "Already friends",
|
||||
"incoming_request": "Wants to be friends",
|
||||
"tab_friends": "Friends",
|
||||
"tab_pending": "Pending",
|
||||
"tab_requests": "Requests",
|
||||
"empty_friends": "No friends yet.",
|
||||
"empty_pending": "No outgoing requests.",
|
||||
"empty_requests": "No incoming requests.",
|
||||
"action_message": "Message",
|
||||
"action_unfriend": "Unfriend",
|
||||
"action_accept": "Accept",
|
||||
"action_decline": "Decline",
|
||||
"action_cancel": "Cancel",
|
||||
"confirm_unfriend": "Remove this friend?"
|
||||
},
|
||||
"admin": {
|
||||
"nav": "Admin",
|
||||
"title": "Admin panel",
|
||||
"settings_title": "Global settings",
|
||||
"invites_enabled": "Allow new signups",
|
||||
"invites_enabled_hint": "When off, no new users can sign up even with a valid invite.",
|
||||
"invites_title": "Invite codes",
|
||||
"invites_create": "New code",
|
||||
"invites_empty": "No invites yet.",
|
||||
"invites_disable": "Disable",
|
||||
"invites_enable": "Enable",
|
||||
"invites_delete": "Delete",
|
||||
"invites_copy": "Copy",
|
||||
"invites_copied": "Copied",
|
||||
"users_title": "Users",
|
||||
"users_empty": "No profiles yet.",
|
||||
"users_flag_admin": "Admin",
|
||||
"users_flag_banned": "Banned",
|
||||
"users_flag_blocked_inviting": "Blocked from inviting",
|
||||
"invite_col_code": "Code",
|
||||
"invite_col_uses": "Uses",
|
||||
"invite_col_expires": "Expires",
|
||||
"invite_col_status": "Status",
|
||||
"invite_col_created": "Created",
|
||||
"invite_status_active": "Active",
|
||||
"invite_status_disabled": "Disabled",
|
||||
"invite_status_expired": "Expired",
|
||||
"invite_expires_never": "Never"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Settings",
|
||||
"section_account": "Account",
|
||||
"section_appearance": "Appearance",
|
||||
"section_privacy": "Privacy",
|
||||
"section_devices": "Devices",
|
||||
"section_voice": "Voice",
|
||||
"audio_quality": "Audio quality",
|
||||
"audio_voice": "Voice (Recommended)",
|
||||
"audio_hifi": "HiFi / Music",
|
||||
"audio_voice_hint": "Mono 48 kbps with noise suppression, echo cancellation and auto-gain. Optimised for speech in a room.",
|
||||
"audio_hifi_hint": "Stereo 510 kbps Opus with all DSP off — best for music/broadcast. Requires a quiet environment.",
|
||||
"e2ee_calls": "End-to-end encryption (calls)",
|
||||
"e2ee_calls_hint": "Audio + video are encrypted before upload. The server only sees ciphertext. All participants must have the option enabled.",
|
||||
"e2ee_calls_unsupported": "Your browser doesn't support Insertable Streams. E2EE calls unavailable.",
|
||||
"ptt_enabled": "Push-to-Talk",
|
||||
"ptt_enabled_hint": "Mic stays muted until the key is held. Overrides the regular mute button.",
|
||||
"ptt_key": "Hotkey",
|
||||
"ptt_press_key": "Press a key…",
|
||||
"section_screen_share": "Screen share",
|
||||
"screen_share_quality": "Quality",
|
||||
"screen_share_hint": "WebRTC dynamically adjusts bitrate + resolution to match network conditions (SVC/VP9). Values are upper bounds. Changes apply on the next call.",
|
||||
"language": "Language",
|
||||
"presence": "Presence",
|
||||
"show_read_receipts": "Show read receipts",
|
||||
"show_read_receipts_hint": "When off, others can't see when you read their messages — and you won't see when they read yours.",
|
||||
"allow_dms_strangers": "Allow DMs from strangers",
|
||||
"allow_dms_strangers_hint": "When off, only friends can DM you.",
|
||||
"this_device": "This device",
|
||||
"danger_zone": "Danger zone",
|
||||
"sign_out": "Sign out"
|
||||
},
|
||||
"update": {
|
||||
"available": "Update available",
|
||||
"install": "Install & restart",
|
||||
"downloading": "Downloading",
|
||||
"installing": "Installing…"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
{
|
||||
"brand": {
|
||||
"badge": "End-to-end encrypted · invite-only",
|
||||
"title_line_1": "Private messaging",
|
||||
"title_line_2": "for your circle.",
|
||||
"subtitle": "Self-hosted, zero-knowledge server, libsodium crypto. You and your friends — nothing between.",
|
||||
"feature_zk_title": "Zero-knowledge",
|
||||
"feature_zk_desc": "Ciphertext never gets decrypted on the server.",
|
||||
"feature_selfhost_title": "Self-hosted",
|
||||
"feature_selfhost_desc": "Your Supabase. Your VPS. Your keys.",
|
||||
"feature_invite_title": "Invite-only",
|
||||
"feature_invite_desc": "No discovery, no strangers. Trusted circle."
|
||||
},
|
||||
"signup": {
|
||||
"title": "Create your account",
|
||||
"subtitle": "No passwords. Magic link via email.",
|
||||
"cta": "Send magic link",
|
||||
"cta_sending": "Sending link…"
|
||||
},
|
||||
"login": {
|
||||
"title": "Welcome back",
|
||||
"subtitle": "Enter your email — magic link follows.",
|
||||
"cta": "Send magic link",
|
||||
"cta_sending": "Sending link…"
|
||||
},
|
||||
"tab_signup": "Sign up",
|
||||
"tab_login": "Log in",
|
||||
"fields": {
|
||||
"email": "Email",
|
||||
"email_placeholder": "you@example.com",
|
||||
"username": "Username",
|
||||
"username_placeholder": "dennis",
|
||||
"username_hint": "You log in with this. Lowercase only.",
|
||||
"username_invalid": "Lowercase a–z, digits, underscore · 3–32 chars.",
|
||||
"invite_code": "Invite code",
|
||||
"invite_hint": "Required · invite-only access."
|
||||
},
|
||||
"sent_banner": "Magic link sent to {{email}}.",
|
||||
"sent_banner_hint": "Dev stack: open Inbucket and copy the 6-digit code from the email.",
|
||||
"otp_label": "6-digit code",
|
||||
"otp_placeholder": "123456",
|
||||
"otp_hint": "Paste the code from the email.",
|
||||
"otp_cta": "Verify code",
|
||||
"otp_cta_loading": "Verifying…",
|
||||
"otp_back": "Use a different email",
|
||||
"footer_signup_prompt": "Already registered?",
|
||||
"footer_login_prompt": "New here?",
|
||||
"footer_switch_to_login": "Log in",
|
||||
"footer_switch_to_signup": "Create account",
|
||||
"footer_studio": "Supabase Studio",
|
||||
"legal_note": "By signing up you accept that the server sees only ciphertext.",
|
||||
"signed_in": {
|
||||
"title": "Signed in",
|
||||
"session_active": "Session active",
|
||||
"user_id": "User ID",
|
||||
"email": "Email",
|
||||
"username": "Username",
|
||||
"display_name": "Display name",
|
||||
"admin": "Admin",
|
||||
"yes": "yes",
|
||||
"no": "no",
|
||||
"sign_out": "Sign out",
|
||||
"device_active": "Active device",
|
||||
"device_platform": "Platform",
|
||||
"device_registered_at": "Registered"
|
||||
},
|
||||
"device": {
|
||||
"title": "Register this device",
|
||||
"subtitle": "Generates an X25519 keypair. Private key stays on this device.",
|
||||
"name_label": "Device name",
|
||||
"name_hint": "Shown to you in your device list. Keep it recognisable.",
|
||||
"name_placeholder": "Dennis Laptop",
|
||||
"cta": "Register device",
|
||||
"cta_loading": "Generating keypair…",
|
||||
"security_note_dev": "Dev build: private key stored unencrypted in localStorage. Stronghold comes before release."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"app_name": "ChatApp",
|
||||
"loading": "Loading…",
|
||||
"finalising_session": "Finalising session…",
|
||||
"cancel": "Cancel",
|
||||
"save": "Save",
|
||||
"close": "Close",
|
||||
"retry": "Retry",
|
||||
"online": "Online",
|
||||
"offline": "Offline",
|
||||
"idle": "Idle",
|
||||
"dnd": "Do not disturb",
|
||||
"invisible": "Invisible",
|
||||
"local_stack_online": "local stack online",
|
||||
"dev_build": "dev build"
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"generic": "Something went wrong.",
|
||||
"network": "Network error. Check your connection.",
|
||||
"ERR_NOT_AUTH": "You are not signed in.",
|
||||
"ERR_INVITE_CODE_REQUIRED": "Invite code is required.",
|
||||
"ERR_USERNAME_INVALID": "Username must be lowercase a–z, digits, underscore, 3–32 chars.",
|
||||
"ERR_INVITES_DISABLED": "Signups are currently disabled by the admin.",
|
||||
"ERR_INVITE_NOT_FOUND": "Invite code not found.",
|
||||
"ERR_INVITE_DISABLED": "This invite has been disabled.",
|
||||
"ERR_INVITE_EXPIRED": "This invite has expired.",
|
||||
"ERR_INVITE_EXHAUSTED": "This invite has already been used up.",
|
||||
"ERR_DM_SELF": "You cannot DM yourself.",
|
||||
"ERR_DM_STRANGERS_DISABLED": "This user does not accept DMs from strangers.",
|
||||
"ERR_NO_PENDING_DM": "No pending DM request found.",
|
||||
"ERR_GROUP_INVITE_NOT_FOUND": "Group invite not found.",
|
||||
"ERR_GROUP_INVITE_DISABLED": "This group invite has been disabled.",
|
||||
"ERR_GROUP_INVITE_EXPIRED": "This group invite has expired.",
|
||||
"ERR_GROUP_INVITE_EXHAUSTED": "This group invite has already been used up.",
|
||||
"ERR_FRIEND_SELF": "You cannot add yourself as a friend.",
|
||||
"ERR_FRIEND_SELF_ACCEPT": "You cannot accept your own friend request.",
|
||||
"ERR_FRIEND_BAD_TRANSITION": "Invalid friendship state transition.",
|
||||
"ERR_MESSAGE_DELETED": "This message was already deleted.",
|
||||
"ERR_DELETE_FORBIDDEN": "You are not allowed to delete this message.",
|
||||
"ERR_EDIT_NOT_SENDER": "Only the sender can edit this message.",
|
||||
"ERR_EDIT_WINDOW_EXPIRED": "The 24-hour edit window has passed.",
|
||||
"ERR_ENVELOPE_NOT_SENDER": "Only the sender can rewrite envelopes.",
|
||||
"ERR_ENVELOPE_WINDOW_EXPIRED": "The 24-hour envelope edit window has passed."
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import deApp from './locales/de/app.json';
|
||||
import deAuth from './locales/de/auth.json';
|
||||
import deCommon from './locales/de/common.json';
|
||||
import deErrors from './locales/de/errors.json';
|
||||
import enApp from './locales/en/app.json';
|
||||
import enAuth from './locales/en/auth.json';
|
||||
import enCommon from './locales/en/common.json';
|
||||
import enErrors from './locales/en/errors.json';
|
||||
import type { Resources, SupportedLocale } from './types.js';
|
||||
|
||||
export const resources: Record<SupportedLocale, Resources> = {
|
||||
en: { common: enCommon, auth: enAuth, errors: enErrors, app: enApp },
|
||||
de: { common: deCommon, auth: deAuth, errors: deErrors, app: deApp },
|
||||
};
|
||||
@@ -0,0 +1,18 @@
|
||||
import type enApp from './locales/en/app.json';
|
||||
import type enAuth from './locales/en/auth.json';
|
||||
import type enCommon from './locales/en/common.json';
|
||||
import type enErrors from './locales/en/errors.json';
|
||||
|
||||
export const SUPPORTED_LOCALES = ['en', 'de'] as const;
|
||||
export type SupportedLocale = (typeof SUPPORTED_LOCALES)[number];
|
||||
|
||||
export const DEFAULT_LOCALE: SupportedLocale = 'en';
|
||||
|
||||
export interface Resources {
|
||||
common: typeof enCommon;
|
||||
auth: typeof enAuth;
|
||||
errors: typeof enErrors;
|
||||
app: typeof enApp;
|
||||
}
|
||||
|
||||
export type Namespace = keyof Resources;
|
||||
Reference in New Issue
Block a user