This commit is contained in:
2026-04-18 23:11:35 +02:00
commit f7cfd2a86e
196 changed files with 35538 additions and 0 deletions
+28
View File
@@ -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;
}
+33
View File
@@ -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;
}
+56
View File
@@ -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 az, Ziffern, Unterstrich · 332 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 az, Ziffern oder Unterstrich bestehen (332 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 az, digits, underscore · 332 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 az, digits, underscore, 332 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."
}
+14
View File
@@ -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 },
};
+18
View File
@@ -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;