Introduce a hand-rolled i18n layer across all three UIs, each reading its
text from per-language JSON files (English base + Polish + Danish). Nothing
in the converted screens hardcodes English any more.
- Web App (Vue): src/i18n/{en,pl,da}.json + index.js exposing t()/tSplit(),
reactive to the signed-in profile locale. Every view, component, form and
the status labels in lib/format.js go through t().
- API Server panel (Vue): src/i18n/ with its own localStorage-persisted
language (the panel has no user profile) and a header language picker.
Chrome, cards, login and API section titles translated; endpoint reference
descriptions intentionally kept in English. Rebuilt embedded dist.
- Phone App (Flutter): assets/i18n/ + lib/i18n.dart loaded at startup,
driven by AppSettings.locale. Nav, login, lock, dashboard, the full
Settings panel (incl. language picker) and format.dart status labels
translated; remaining detail screens fall back to English.
Language = the language half of the existing BCP-47 locale; the region half
still drives date/number/currency formatting. Missing keys fall back to
English, and plurals use Intl.PluralRules / Intl.plural so Polish gets the
correct one/few/many forms. Settings flags languages without a translation.
Tests updated to assert the localized (Polish) status wording; all pass.
See TRANSLATIONS.md for the format and how to add a language.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
128 lines
4.2 KiB
Vue
128 lines
4.2 KiB
Vue
<script setup>
|
|
import { ref, onMounted } from "vue";
|
|
import { request } from "../api";
|
|
import { t } from "../i18n";
|
|
|
|
// Superadmin-only: retarget the PocketBase this server talks to. The change is
|
|
// applied at runtime AND persisted to the server's .env, so it survives a
|
|
// restart. A blank password means "keep the stored one".
|
|
const cfg = ref(null);
|
|
const form = ref({ url: "", adminEmail: "", adminPassword: "" });
|
|
const probe = ref(null);
|
|
const error = ref("");
|
|
const notice = ref("");
|
|
const busy = ref(false);
|
|
|
|
async function load() {
|
|
try {
|
|
cfg.value = await request("/api/admin/pb-config");
|
|
form.value = {
|
|
url: cfg.value.url,
|
|
adminEmail: cfg.value.adminEmail,
|
|
adminPassword: "",
|
|
};
|
|
probe.value = cfg.value.probe;
|
|
} catch (e) {
|
|
error.value = e.message;
|
|
}
|
|
}
|
|
onMounted(load);
|
|
|
|
async function test() {
|
|
error.value = "";
|
|
notice.value = "";
|
|
busy.value = true;
|
|
try {
|
|
probe.value = await request("/api/admin/pb-config/test", {
|
|
method: "POST",
|
|
body: form.value,
|
|
});
|
|
notice.value = probe.value.superuser
|
|
? t("pocketbase.testOk")
|
|
: probe.value.reachable
|
|
? t("pocketbase.testReachableNoAuth")
|
|
: t("pocketbase.testUnreachable");
|
|
} catch (e) {
|
|
error.value = e.message;
|
|
} finally {
|
|
busy.value = false;
|
|
}
|
|
}
|
|
|
|
async function save() {
|
|
error.value = "";
|
|
notice.value = "";
|
|
busy.value = true;
|
|
try {
|
|
const out = await request("/api/admin/pb-config", { method: "PUT", body: form.value });
|
|
cfg.value = out.config;
|
|
probe.value = out.config.probe;
|
|
form.value.adminPassword = "";
|
|
notice.value = out.warning || t("pocketbase.savedNotice");
|
|
} catch (e) {
|
|
error.value = e.message;
|
|
} finally {
|
|
busy.value = false;
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<div class="dh-card overflow-hidden">
|
|
<div class="flex items-center justify-between border-b border-subtle px-5 py-4">
|
|
<div>
|
|
<div class="text-base font-bold tracking-[-0.02em] text-strong">{{ t("pocketbase.title") }}</div>
|
|
<p class="mt-0.5 text-xs text-muted">{{ t("pocketbase.subtitle") }}</p>
|
|
</div>
|
|
<span
|
|
v-if="probe"
|
|
class="dh-pill"
|
|
:class="probe.superuser
|
|
? 'bg-success-soft text-success'
|
|
: probe.reachable
|
|
? 'bg-warning-soft text-warning'
|
|
: 'bg-danger-soft text-danger'"
|
|
>
|
|
<span class="h-1.5 w-1.5 rounded-full bg-current"></span>
|
|
{{ probe.superuser ? t("pocketbase.connected") : probe.reachable ? t("pocketbase.noSuperuser") : t("pocketbase.unreachable") }}
|
|
</span>
|
|
</div>
|
|
|
|
<div class="flex flex-col gap-4 px-5 py-4">
|
|
<div>
|
|
<label class="dh-label" for="pb-url">{{ t("pocketbase.baseUrl") }}</label>
|
|
<input id="pb-url" v-model="form.url" class="dh-input" placeholder="http://10.2.1.10:8027" />
|
|
</div>
|
|
|
|
<div class="grid gap-4 sm:grid-cols-2">
|
|
<div>
|
|
<label class="dh-label" for="pb-email">{{ t("pocketbase.superuserEmail") }}</label>
|
|
<input id="pb-email" v-model="form.adminEmail" class="dh-input" autocomplete="off" />
|
|
</div>
|
|
<div>
|
|
<label class="dh-label" for="pb-password">{{ t("pocketbase.superuserPassword") }}</label>
|
|
<input
|
|
id="pb-password"
|
|
v-model="form.adminPassword"
|
|
class="dh-input"
|
|
type="password"
|
|
autocomplete="new-password"
|
|
:placeholder="cfg?.adminConfigured ? t('pocketbase.passwordUnchanged') : t('pocketbase.passwordNotSet')"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<p v-if="probe?.detail" class="data text-xs text-muted">{{ probe.detail }}</p>
|
|
<p v-if="notice" class="rounded-control bg-info-soft px-3 py-2 text-xs text-info">{{ notice }}</p>
|
|
<p v-if="error" class="rounded-control bg-danger-soft px-3 py-2 text-xs text-danger">{{ error }}</p>
|
|
|
|
<div class="flex items-center gap-2">
|
|
<button class="dh-btn" :disabled="busy" @click="save">{{ t("pocketbase.saveApply") }}</button>
|
|
<button class="dh-btn-ghost" :disabled="busy" @click="test">{{ t("pocketbase.testConnection") }}</button>
|
|
<span class="flex-1"></span>
|
|
<span class="eyebrow">{{ t("pocketbase.persistedEnv") }}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|