Add a language-switch system with per-language files

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>
This commit is contained in:
tajniak81
2026-07-17 20:07:48 +02:00
co-authored by Claude Opus 4.8
parent ee28b522c7
commit b6bb6b1df0
54 changed files with 4191 additions and 979 deletions
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -6,8 +6,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#2563eb" />
<title>DriverVault · API Server</title>
<script type="module" crossorigin src="/assets/index-OENR1RVj.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-i1JZk1ZM.css">
<script type="module" crossorigin src="/assets/index-CL6eq5qV.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-D3MeNcl7.css">
</head>
<body>
<div id="app"></div>
+45 -21
View File
@@ -1,6 +1,7 @@
<script setup>
import { ref, computed, onMounted } from "vue";
import { theme, toggleTheme } from "./theme";
import { t, lang, setLang, TRANSLATED_LANGUAGES } from "./i18n";
import { me, token, restore, logout, isManager, isSuperadmin } from "./api";
import LoginView from "./components/LoginView.vue";
import StatusCard from "./components/StatusCard.vue";
@@ -23,21 +24,35 @@ onMounted(async () => {
// plugins need a superadmin. The server enforces the same rules — this only
// hides what the caller could not use anyway.
const sections = computed(() => {
const out = [{ id: "overview", label: "Overview" }];
const out = [{ id: "overview", label: t("sections.overview") }];
if (isManager.value) {
out.push({ id: "users", label: "Users" }, { id: "orgs", label: "Organizations" });
out.push({ id: "users", label: t("sections.users") }, { id: "orgs", label: t("sections.orgs") });
}
if (isSuperadmin.value) {
out.push(
{ id: "pocketbase", label: "PocketBase" },
{ id: "webapp", label: "Web App" },
{ id: "plugins", label: "Plugins" },
{ id: "pocketbase", label: t("sections.pocketbase") },
{ id: "webapp", label: t("sections.webapp") },
{ id: "plugins", label: t("sections.plugins") },
);
}
out.push({ id: "api", label: "API" });
out.push({ id: "api", label: t("sections.api") });
return out;
});
// The language picker offers only the languages that have a real file. English
// names ("Polski", "Dansk") come from Intl so each reads in its own language.
const languageOptions = computed(() =>
TRANSLATED_LANGUAGES.map((code) => {
let label = code;
try {
label = new Intl.DisplayNames([code], { type: "language" }).of(code) || code;
} catch {
/* runtime without DisplayNames — the code is still selectable */
}
return { code, label: label.charAt(0).toUpperCase() + label.slice(1) };
}),
);
function signOut() {
logout();
section.value = "overview";
@@ -151,7 +166,7 @@ const superadminApi = [
<span class="text-strong">Driver</span><span class="text-brandtext">Vault</span>
</span>
</span>
<span class="eyebrow mt-1.5">API server</span>
<span class="eyebrow mt-1.5">{{ t("app.apiServer") }}</span>
<div class="flex-1"></div>
<span v-if="me" class="data hidden text-xs text-muted sm:inline">
@@ -159,9 +174,18 @@ const superadminApi = [
</span>
<span v-if="me" class="dh-pill bg-info-soft text-info">{{ me.role }}</span>
<select
class="dh-select !w-auto !py-1 !text-xs"
:value="lang"
:aria-label="t('sections.overview')"
@change="setLang($event.target.value)"
>
<option v-for="l in languageOptions" :key="l.code" :value="l.code">{{ l.label }}</option>
</select>
<button
class="dh-btn-ghost"
:title="theme === 'dark' ? 'Switch to light theme' : 'Switch to dark theme'"
:title="theme === 'dark' ? t('app.switchToLight') : t('app.switchToDark')"
@click="toggleTheme"
>
<svg
@@ -187,12 +211,12 @@ const superadminApi = [
>
<path stroke-linecap="round" stroke-linejoin="round" d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8Z" />
</svg>
Theme
{{ t("app.theme") }}
</button>
<button v-if="token" class="dh-btn-ghost" @click="signOut">Sign out</button>
<button v-if="token" class="dh-btn-ghost" @click="signOut">{{ t("app.signOut") }}</button>
</div>
<p v-if="booting" class="eyebrow py-16 text-center">Loading</p>
<p v-if="booting" class="eyebrow py-16 text-center">{{ t("app.loading") }}</p>
<!-- Unauthenticated: the login gate is the whole console. -->
<LoginView v-else-if="!token" />
@@ -219,21 +243,21 @@ const superadminApi = [
<PluginsCard v-else-if="section === 'plugins'" />
<template v-else-if="section === 'api'">
<EndpointTable title="Public" auth="No auth" :endpoints="publicApi" />
<EndpointTable title="Identity" auth="Bearer token" :endpoints="identityApi" />
<EndpointTable title="Cars" auth="Bearer token" :endpoints="carsApi" />
<EndpointTable title="Service records" auth="Bearer token" :endpoints="serviceApi" />
<EndpointTable title="Parts" auth="Bearer token" :endpoints="partsApi" />
<EndpointTable title="Account" auth="Bearer token" :endpoints="accountApi" />
<EndpointTable title="Management" auth="Admin / superadmin" :endpoints="managementApi" />
<EndpointTable title="Superadmin" auth="Superadmin" :endpoints="superadminApi" />
<EndpointTable :title="t('api.groupPublic')" :auth="t('api.authNone')" :endpoints="publicApi" />
<EndpointTable :title="t('api.groupIdentity')" :auth="t('api.authBearer')" :endpoints="identityApi" />
<EndpointTable :title="t('api.groupCars')" :auth="t('api.authBearer')" :endpoints="carsApi" />
<EndpointTable :title="t('api.groupService')" :auth="t('api.authBearer')" :endpoints="serviceApi" />
<EndpointTable :title="t('api.groupParts')" :auth="t('api.authBearer')" :endpoints="partsApi" />
<EndpointTable :title="t('api.groupAccount')" :auth="t('api.authBearer')" :endpoints="accountApi" />
<EndpointTable :title="t('api.groupManagement')" :auth="t('api.authManager')" :endpoints="managementApi" />
<EndpointTable :title="t('api.groupSuperadmin')" :auth="t('api.authSuperadmin')" :endpoints="superadminApi" />
</template>
<p v-if="!isManager && section === 'overview'" class="eyebrow text-center">
Signed in as a standard user management sections need an admin role
{{ t("app.standardUserNote") }}
</p>
</template>
<p class="eyebrow text-center">DriverVault car maintenance &amp; service tracker.</p>
<p class="eyebrow text-center">{{ t("app.footer") }}</p>
</div>
</template>
@@ -1,4 +1,6 @@
<script setup>
import { t } from "../i18n";
defineProps({
title: String,
auth: String,
@@ -22,8 +24,8 @@ const methodClass = {
<table class="w-full text-left text-sm">
<thead>
<tr class="[&>th]:eyebrow [&>th]:px-5 [&>th]:py-2.5 [&>th]:font-medium">
<th>Endpoint</th>
<th>Description</th>
<th>{{ t("api.colEndpoint") }}</th>
<th>{{ t("api.colDescription") }}</th>
</tr>
</thead>
<tbody>
@@ -1,6 +1,7 @@
<script setup>
import { ref, computed } from "vue";
import { login } from "../api";
import { t } from "../i18n";
const emit = defineEmits(["authenticated"]);
@@ -24,8 +25,8 @@ async function submit() {
// 400/404 from PocketBase both mean "bad credentials" — don't leak which.
error.value =
e.status === 400 || e.status === 404
? "Invalid email or password."
: e.message || "Could not sign in.";
? t("login.invalid")
: e.message || t("login.failed");
password.value = "";
} finally {
busy.value = false;
@@ -36,14 +37,14 @@ async function submit() {
<template>
<div class="mx-auto flex w-full max-w-sm flex-col gap-5 pt-24">
<div class="dh-card p-6">
<h1 class="text-lg font-bold tracking-[-0.02em] text-strong">Sign in</h1>
<h1 class="text-lg font-bold tracking-[-0.02em] text-strong">{{ t("login.title") }}</h1>
<p class="mt-1 mb-5 text-sm text-body">
Superadmin console for the DriverVault API Server.
{{ t("login.subtitle") }}
</p>
<form class="flex flex-col gap-4" @submit.prevent="submit">
<div>
<label class="dh-label" for="login-email">Email</label>
<label class="dh-label" for="login-email">{{ t("login.email") }}</label>
<input
id="login-email"
v-model="email"
@@ -55,7 +56,7 @@ async function submit() {
/>
</div>
<div>
<label class="dh-label" for="login-password">Password</label>
<label class="dh-label" for="login-password">{{ t("login.password") }}</label>
<input
id="login-password"
v-model="password"
@@ -71,13 +72,13 @@ async function submit() {
</p>
<button class="dh-btn w-full" type="submit" :disabled="!canSubmit">
{{ busy ? "Signing in" : "Sign in" }}
{{ busy ? t("login.submitting") : t("login.submit") }}
</button>
</form>
</div>
<p class="eyebrow text-center">
Authenticates against PocketBase through this server
{{ t("login.authNote") }}
</p>
</div>
</template>
+14 -13
View File
@@ -1,6 +1,7 @@
<script setup>
import { ref, onMounted } from "vue";
import { isSuperadmin, request } from "../api";
import { t } from "../i18n";
// Listing is manager-scoped (an admin sees only their own org); creating,
// renaming and deleting are superadmin-only, matching the server's gates.
@@ -58,7 +59,7 @@ async function save() {
}
async function remove(o) {
if (!confirm(`Delete the organization "${o.name}"?`)) return;
if (!confirm(t("orgs.confirmDelete", { name: o.name }))) return;
busy.value = true;
error.value = "";
try {
@@ -77,34 +78,34 @@ async function remove(o) {
<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">Organizations</div>
<p class="mt-0.5 text-xs text-muted">Tenants users belong to</p>
<div class="text-base font-bold tracking-[-0.02em] text-strong">{{ t("orgs.title") }}</div>
<p class="mt-0.5 text-xs text-muted">{{ t("orgs.subtitle") }}</p>
</div>
<button v-if="isSuperadmin" class="dh-btn" @click="startNew">New organization</button>
<button v-if="isSuperadmin" class="dh-btn" @click="startNew">{{ t("orgs.newOrg") }}</button>
</div>
<p v-if="error" class="border-b border-subtle px-5 py-3 text-xs text-danger">{{ error }}</p>
<div v-if="editing" class="border-b border-subtle bg-sunken px-5 py-4">
<label class="dh-label">Name</label>
<input v-model="draftName" class="dh-input" placeholder="Acme Fleet" @keyup.enter="save" />
<label class="dh-label">{{ t("common.name") }}</label>
<input v-model="draftName" class="dh-input" :placeholder="t('orgs.namePlaceholder')" @keyup.enter="save" />
<div class="mt-3 flex items-center gap-2">
<button class="dh-btn" :disabled="busy || !draftName.trim()" @click="save">
{{ editing === "new" ? "Create" : "Save" }}
{{ editing === "new" ? t("common.create") : t("common.save") }}
</button>
<button class="dh-btn-ghost" :disabled="busy" @click="cancel">Cancel</button>
<button class="dh-btn-ghost" :disabled="busy" @click="cancel">{{ t("common.cancel") }}</button>
</div>
</div>
<p v-if="!orgs.length" class="px-5 py-6 text-center text-sm text-muted">
No organizations yet.
{{ t("orgs.empty") }}
</p>
<table v-else class="w-full text-left text-sm">
<thead>
<tr class="[&>th]:eyebrow [&>th]:px-5 [&>th]:py-2.5 [&>th]:font-medium">
<th>Name</th>
<th>ID</th>
<th>{{ t("common.name") }}</th>
<th>{{ t("orgs.colId") }}</th>
<th></th>
</tr>
</thead>
@@ -114,9 +115,9 @@ async function remove(o) {
<td class="data px-5 py-2.5 text-xs text-muted">{{ o.id }}</td>
<td class="px-5 py-2.5 text-right whitespace-nowrap">
<template v-if="isSuperadmin">
<button class="dh-btn-ghost" @click="startEdit(o)">Rename</button>
<button class="dh-btn-ghost" @click="startEdit(o)">{{ t("common.rename") }}</button>
<button class="dh-btn-danger ml-1.5" :disabled="busy" @click="remove(o)">
Delete
{{ t("common.delete") }}
</button>
</template>
</td>
+22 -21
View File
@@ -1,6 +1,7 @@
<script setup>
import { ref, onMounted, reactive } from "vue";
import { request } from "../api";
import { t } from "../i18n";
// Superadmin-only. Each plugin advertises its own config fields (Descriptor.
// ConfigFields), so the form below is generated rather than hard-coded — that's
@@ -47,7 +48,7 @@ async function save(p, enabled) {
});
// A save can succeed while Init fails (e.g. bad credentials) — the server
// returns the saved plugin plus a warning.
rowNotice[p.name] = out.warning || "Saved.";
rowNotice[p.name] = out.warning || t("plugins.saved");
await load();
} catch (e) {
rowNotice[p.name] = e.message;
@@ -58,7 +59,7 @@ async function save(p, enabled) {
async function health(p) {
busy.value = true;
rowNotice[p.name] = "Checking";
rowNotice[p.name] = t("plugins.checking");
try {
const out = await request(`/api/admin/plugins/${encodeURIComponent(p.name)}/health`, {
method: "POST",
@@ -73,7 +74,7 @@ async function health(p) {
}
async function remove(p) {
if (!confirm(`Remove the external plugin "${p.name}"? Its saved config is deleted.`)) return;
if (!confirm(t("plugins.confirmRemove", { name: p.name }))) return;
busy.value = true;
try {
await request(`/api/admin/plugins/${encodeURIComponent(p.name)}`, { method: "DELETE" });
@@ -113,11 +114,11 @@ const healthClass = (s) =>
<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">Plugins</div>
<p class="mt-0.5 text-xs text-muted">Third-party service integrations</p>
<div class="text-base font-bold tracking-[-0.02em] text-strong">{{ t("plugins.title") }}</div>
<p class="mt-0.5 text-xs text-muted">{{ t("plugins.subtitle") }}</p>
</div>
<button class="dh-btn-ghost" @click="showRegister = !showRegister">
{{ showRegister ? "Cancel" : "Register external" }}
{{ showRegister ? t("plugins.cancelRegister") : t("plugins.registerExternal") }}
</button>
</div>
@@ -125,15 +126,15 @@ const healthClass = (s) =>
<div v-if="showRegister" class="border-b border-subtle bg-sunken px-5 py-4">
<div class="grid gap-3 sm:grid-cols-3">
<div>
<label class="dh-label">Name</label>
<label class="dh-label">{{ t("plugins.name") }}</label>
<input v-model="reg.name" class="dh-input" placeholder="acme-parts" />
</div>
<div>
<label class="dh-label">Base URL</label>
<label class="dh-label">{{ t("plugins.baseUrl") }}</label>
<input v-model="reg.baseURL" class="dh-input" placeholder="http://127.0.0.1:9100" />
</div>
<div>
<label class="dh-label">Provider</label>
<label class="dh-label">{{ t("plugins.provider") }}</label>
<input v-model="reg.provider" class="dh-input" placeholder="ACME Corp" />
</div>
</div>
@@ -142,14 +143,14 @@ const healthClass = (s) =>
:disabled="busy || !reg.name || !reg.baseURL"
@click="registerExternal"
>
Register
{{ t("plugins.register") }}
</button>
</div>
<p v-if="error" class="border-b border-subtle px-5 py-3 text-xs text-danger">{{ error }}</p>
<p v-if="!plugins.length" class="px-5 py-6 text-center text-sm text-muted">
No plugins yet. Register an external one above, or compile a built-in connector.
{{ t("plugins.empty") }}
</p>
<div v-for="p in plugins" :key="p.name" class="border-t border-subtle first:border-t-0">
@@ -157,22 +158,22 @@ const healthClass = (s) =>
<div class="flex items-center gap-3 px-5 py-3">
<button class="flex flex-1 items-center gap-3 text-left" @click="expand(p)">
<span class="font-semibold text-strong">{{ p.name }}</span>
<span class="dh-pill bg-sunken text-muted">{{ p.kind || "builtin" }}</span>
<span class="dh-pill bg-sunken text-muted">{{ p.kind || t("plugins.builtin") }}</span>
<span v-if="p.provider" class="text-xs text-muted">{{ p.provider }}</span>
<span v-if="p.health" class="dh-pill" :class="healthClass(p.health.status)">
<span class="h-1.5 w-1.5 rounded-full bg-current"></span>{{ p.health.status }}
</span>
</button>
<span class="dh-pill" :class="p.enabled ? 'bg-success-soft text-success' : 'bg-sunken text-muted'">
{{ p.enabled ? "enabled" : "disabled" }}
{{ p.enabled ? t("plugins.enabled") : t("plugins.disabled") }}
</span>
<button class="dh-btn-ghost" :disabled="busy" @click="health(p)">Health</button>
<button class="dh-btn-ghost" :disabled="busy" @click="health(p)">{{ t("plugins.health") }}</button>
<button
class="dh-btn-ghost"
:disabled="busy"
@click="expand(p)"
>
{{ open === p.name ? "Close" : "Configure" }}
{{ open === p.name ? t("plugins.close") : t("plugins.configure") }}
</button>
</div>
@@ -201,10 +202,10 @@ const healthClass = (s) =>
<p v-if="f.help" class="mt-1 text-xs text-muted">{{ f.help }}</p>
</div>
</div>
<p v-else class="text-xs text-muted">This plugin takes no configuration.</p>
<p v-else class="text-xs text-muted">{{ t("plugins.noConfig") }}</p>
<div v-if="(p.capabilities || []).length" class="mt-4">
<div class="eyebrow mb-1.5">Capabilities</div>
<div class="eyebrow mb-1.5">{{ t("plugins.capabilities") }}</div>
<ul class="data flex flex-col gap-1 text-xs text-muted">
<li v-for="c in p.capabilities" :key="c.id">
<span class="text-strong">{{ c.id }}</span>
@@ -218,10 +219,10 @@ const healthClass = (s) =>
<div class="mt-4 flex items-center gap-2">
<button class="dh-btn" :disabled="busy" @click="save(p, true)">
{{ p.enabled ? "Save" : "Save &amp; enable" }}
{{ p.enabled ? t("plugins.save") : t("plugins.saveEnable") }}
</button>
<button v-if="p.enabled" class="dh-btn-ghost" :disabled="busy" @click="save(p, false)">
Disable
{{ t("plugins.disable") }}
</button>
<span class="flex-1"></span>
<button
@@ -230,11 +231,11 @@ const healthClass = (s) =>
:disabled="busy"
@click="remove(p)"
>
Remove
{{ t("plugins.remove") }}
</button>
</div>
<p v-if="p.kind !== 'external'" class="eyebrow mt-2">
Built-in plugins can be disabled but not removed
{{ t("plugins.builtinNote") }}
</p>
</div>
</div>
@@ -1,6 +1,7 @@
<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
@@ -37,10 +38,10 @@ async function test() {
body: form.value,
});
notice.value = probe.value.superuser
? "Connection OK — superuser authenticated."
? t("pocketbase.testOk")
: probe.value.reachable
? "PocketBase is reachable, but the service account did not authenticate."
: "PocketBase is not reachable at that address.";
? t("pocketbase.testReachableNoAuth")
: t("pocketbase.testUnreachable");
} catch (e) {
error.value = e.message;
} finally {
@@ -57,7 +58,7 @@ async function save() {
cfg.value = out.config;
probe.value = out.config.probe;
form.value.adminPassword = "";
notice.value = out.warning || "Saved. The server is now using this PocketBase.";
notice.value = out.warning || t("pocketbase.savedNotice");
} catch (e) {
error.value = e.message;
} finally {
@@ -70,8 +71,8 @@ async function save() {
<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">PocketBase</div>
<p class="mt-0.5 text-xs text-muted">Database connection used by every endpoint</p>
<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"
@@ -83,30 +84,30 @@ async function save() {
: 'bg-danger-soft text-danger'"
>
<span class="h-1.5 w-1.5 rounded-full bg-current"></span>
{{ probe.superuser ? "connected" : probe.reachable ? "no superuser" : "unreachable" }}
{{ 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">Base URL</label>
<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">Superuser email</label>
<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">Superuser password</label>
<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 ? 'unchanged' : 'not set'"
:placeholder="cfg?.adminConfigured ? t('pocketbase.passwordUnchanged') : t('pocketbase.passwordNotSet')"
/>
</div>
</div>
@@ -116,10 +117,10 @@ async function save() {
<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">Save &amp; apply</button>
<button class="dh-btn-ghost" :disabled="busy" @click="test">Test connection</button>
<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">persisted to .env</span>
<span class="eyebrow">{{ t("pocketbase.persistedEnv") }}</span>
</div>
</div>
</div>
+16 -12
View File
@@ -1,6 +1,7 @@
<script setup>
import { ref, onMounted, onUnmounted } from "vue";
import { ref, onMounted, onUnmounted, computed } from "vue";
import { request } from "../api";
import { t } from "../i18n";
// /api/status probes PocketBase and the Web App server-side, so the browser
// never has to reach either directly.
@@ -14,10 +15,17 @@ async function check() {
error.value = "";
} catch (e) {
status.value = null;
error.value = e.message || "unreachable";
error.value = e.message || t("status.unreachable");
}
}
// Computed so the row labels re-evaluate when the language changes.
const rows = computed(() => [
{ key: "apiServer", label: t("status.apiServer"), h: status.value?.apiServer },
{ key: "pocketBase", label: t("status.pocketBase"), h: status.value?.pocketBase },
{ key: "webApp", label: t("status.webApp"), h: status.value?.webApp },
]);
onMounted(() => {
check();
timer = setInterval(check, 10000);
@@ -31,9 +39,9 @@ const pillFor = (s) =>
<template>
<div class="dh-card overflow-hidden">
<div class="flex items-center justify-between border-b border-subtle px-5 py-4">
<div class="text-base font-bold tracking-[-0.02em] text-strong">Status</div>
<div class="text-base font-bold tracking-[-0.02em] text-strong">{{ t("status.title") }}</div>
<span v-if="error" class="dh-pill bg-danger-soft text-danger">
<span class="h-1.5 w-1.5 rounded-full bg-current"></span>unreachable
<span class="h-1.5 w-1.5 rounded-full bg-current"></span>{{ t("status.unreachable") }}
</span>
</div>
@@ -41,15 +49,11 @@ const pillFor = (s) =>
<table v-else-if="status" class="w-full text-left text-sm">
<tbody>
<tr v-for="row in [
{ key: 'apiServer', label: 'API Server', h: status.apiServer },
{ key: 'pocketBase', label: 'PocketBase', h: status.pocketBase },
{ key: 'webApp', label: 'Web App', h: status.webApp },
]" :key="row.key" class="border-t border-subtle first:border-t-0">
<tr v-for="row in rows" :key="row.key" class="border-t border-subtle first:border-t-0">
<td class="px-5 py-3 font-medium text-strong">{{ row.label }}</td>
<td class="data px-5 py-3 text-xs text-muted">{{ row.h.url || "this process" }}</td>
<td class="data px-5 py-3 text-xs text-muted">{{ row.h.url || t("status.thisProcess") }}</td>
<td class="data px-5 py-3 text-right text-xs text-muted">
{{ row.h.latencyMs != null ? row.h.latencyMs + "ms" : "—" }}
{{ row.h.latencyMs != null ? row.h.latencyMs + "ms" : t("common.empty") }}
</td>
<td class="px-5 py-3 text-right">
<span class="dh-pill" :class="pillFor(row.h.status)">
@@ -60,6 +64,6 @@ const pillFor = (s) =>
</tbody>
</table>
<div v-else class="px-5 py-4 text-sm text-muted">Checking</div>
<div v-else class="px-5 py-4 text-sm text-muted">{{ t("status.checking") }}</div>
</div>
</template>
+24 -23
View File
@@ -1,6 +1,7 @@
<script setup>
import { ref, onMounted, computed } from "vue";
import { request, me, isSuperadmin } from "../api";
import { t } from "../i18n";
// Manager-only. A superadmin sees and edits everyone; an admin is scoped by the
// server to their own organization. The UI mirrors those limits, but the server
@@ -77,7 +78,7 @@ async function save() {
}
async function remove(u) {
if (!confirm(`Delete ${u.email}? This cannot be undone.`)) return;
if (!confirm(t("users.confirmDelete", { email: u.email }))) return;
busy.value = true;
error.value = "";
try {
@@ -102,12 +103,12 @@ const roleClass = (r) =>
<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">Users</div>
<div class="text-base font-bold tracking-[-0.02em] text-strong">{{ t("users.title") }}</div>
<p class="mt-0.5 text-xs text-muted">
{{ isSuperadmin ? "All organizations" : "Your organization" }}
{{ isSuperadmin ? t("users.allOrgs") : t("users.yourOrg") }}
</p>
</div>
<button class="dh-btn" @click="startNew">New user</button>
<button class="dh-btn" @click="startNew">{{ t("users.newUser") }}</button>
</div>
<p v-if="error" class="border-b border-subtle px-5 py-3 text-xs text-danger">{{ error }}</p>
@@ -116,56 +117,56 @@ const roleClass = (r) =>
<div v-if="editing" class="border-b border-subtle bg-sunken px-5 py-4">
<div class="grid gap-3 sm:grid-cols-2">
<div>
<label class="dh-label">Email</label>
<label class="dh-label">{{ t("common.email") }}</label>
<input v-model="draft.email" class="dh-input" type="email" autocomplete="off" />
</div>
<div>
<label class="dh-label">Name</label>
<label class="dh-label">{{ t("common.name") }}</label>
<input v-model="draft.name" class="dh-input" autocomplete="off" />
</div>
<div>
<label class="dh-label">
Password{{ editing === "new" ? "" : " (blank = unchanged)" }}
{{ editing === "new" ? t("users.password") : t("users.passwordUnchanged") }}
</label>
<input
v-model="draft.password"
class="dh-input"
type="password"
autocomplete="new-password"
placeholder="min 8 characters"
:placeholder="t('users.passwordPlaceholder')"
/>
</div>
<div>
<label class="dh-label">Role</label>
<label class="dh-label">{{ t("common.role") }}</label>
<select v-model="draft.role" class="dh-select">
<option v-for="r in roles" :key="r" :value="r">{{ r }}</option>
</select>
</div>
<div v-if="isSuperadmin">
<label class="dh-label">Organization</label>
<label class="dh-label">{{ t("common.organization") }}</label>
<select v-model="draft.organization" class="dh-select">
<option value=""> none </option>
<option value="">{{ t("users.orgNone") }}</option>
<option v-for="o in orgs" :key="o.id" :value="o.id">{{ o.name }}</option>
</select>
</div>
</div>
<div class="mt-3 flex items-center gap-2">
<button class="dh-btn" :disabled="busy" @click="save">
{{ editing === "new" ? "Create" : "Save" }}
{{ editing === "new" ? t("common.create") : t("common.save") }}
</button>
<button class="dh-btn-ghost" :disabled="busy" @click="cancel">Cancel</button>
<button class="dh-btn-ghost" :disabled="busy" @click="cancel">{{ t("common.cancel") }}</button>
</div>
</div>
<p v-if="!users.length" class="px-5 py-6 text-center text-sm text-muted">No users.</p>
<p v-if="!users.length" class="px-5 py-6 text-center text-sm text-muted">{{ t("users.empty") }}</p>
<table v-else class="w-full text-left text-sm">
<thead>
<tr class="[&>th]:eyebrow [&>th]:px-5 [&>th]:py-2.5 [&>th]:font-medium">
<th>Email</th>
<th>Name</th>
<th>Organization</th>
<th>Role</th>
<th>{{ t("common.email") }}</th>
<th>{{ t("common.name") }}</th>
<th>{{ t("common.organization") }}</th>
<th>{{ t("common.role") }}</th>
<th></th>
</tr>
</thead>
@@ -173,22 +174,22 @@ const roleClass = (r) =>
<tr v-for="u in users" :key="u.id" class="border-t border-subtle transition-colors hover:bg-sunken">
<td class="data px-5 py-2.5 text-xs text-strong">
{{ u.email }}
<span v-if="u.id === me?.id" class="eyebrow ml-1">you</span>
<span v-if="u.id === me?.id" class="eyebrow ml-1">{{ t("users.you") }}</span>
</td>
<td class="px-5 py-2.5 text-body">{{ u.name || "—" }}</td>
<td class="px-5 py-2.5 text-body">{{ u.organizationName || "—" }}</td>
<td class="px-5 py-2.5 text-body">{{ u.name || t("common.empty") }}</td>
<td class="px-5 py-2.5 text-body">{{ u.organizationName || t("common.empty") }}</td>
<td class="px-5 py-2.5">
<span class="dh-pill" :class="roleClass(u.role)">{{ u.role }}</span>
</td>
<td class="px-5 py-2.5 text-right whitespace-nowrap">
<button class="dh-btn-ghost" @click="startEdit(u)">Edit</button>
<button class="dh-btn-ghost" @click="startEdit(u)">{{ t("common.edit") }}</button>
<button
v-if="u.id !== me?.id"
class="dh-btn-danger ml-1.5"
:disabled="busy"
@click="remove(u)"
>
Delete
{{ t("common.delete") }}
</button>
</td>
</tr>
+14 -12
View File
@@ -1,6 +1,7 @@
<script setup>
import { ref, onMounted } from "vue";
import { request } from "../api";
import { t, tSplit } from "../i18n";
// Superadmin-only: where the Web App lives (the address /api/status probes) and
// which browser origins CORS admits. Like the PocketBase card, the change is
@@ -42,8 +43,8 @@ async function test() {
});
notice.value =
probe.value.status === "ok"
? "Web App is reachable."
: "Web App did not answer its health check at that address.";
? t("webapp.testOk")
: t("webapp.testFailed");
} catch (e) {
error.value = e.message;
} finally {
@@ -61,7 +62,7 @@ async function save() {
body: { url: form.value.url, allowOrigins: splitOrigins(form.value.origins) },
});
apply(out.config);
notice.value = out.warning || "Saved. New origins apply to the next request.";
notice.value = out.warning || t("webapp.savedNotice");
} catch (e) {
error.value = e.message;
} finally {
@@ -74,8 +75,8 @@ async function save() {
<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">Web App</div>
<p class="mt-0.5 text-xs text-muted">Address probed by the status check, and who may call this API from a browser</p>
<div class="text-base font-bold tracking-[-0.02em] text-strong">{{ t("webapp.title") }}</div>
<p class="mt-0.5 text-xs text-muted">{{ t("webapp.subtitle") }}</p>
</div>
<span
v-if="probe"
@@ -83,18 +84,18 @@ async function save() {
:class="probe.status === 'ok' ? 'bg-success-soft text-success' : 'bg-danger-soft text-danger'"
>
<span class="h-1.5 w-1.5 rounded-full bg-current"></span>
{{ probe.status === "ok" ? "reachable" : "unreachable" }}
{{ probe.status === "ok" ? t("webapp.reachable") : t("webapp.unreachable") }}
</span>
</div>
<div class="flex flex-col gap-4 px-5 py-4">
<div>
<label class="dh-label" for="web-url">Base URL</label>
<label class="dh-label" for="web-url">{{ t("webapp.baseUrl") }}</label>
<input id="web-url" v-model="form.url" class="dh-input" placeholder="http://localhost:5173" />
</div>
<div>
<label class="dh-label" for="web-origins">Allowed origins</label>
<label class="dh-label" for="web-origins">{{ t("webapp.allowedOrigins") }}</label>
<input
id="web-origins"
v-model="form.origins"
@@ -102,7 +103,8 @@ async function save() {
placeholder="http://localhost:5173, https://app.example.com"
/>
<p class="mt-1 text-xs text-muted">
Comma separated, or <span class="data">*</span> for any. Native mobile apps are not subject to CORS.
{{ tSplit("webapp.originsHint", "star").before
}}<span class="data">*</span>{{ tSplit("webapp.originsHint", "star").after }}
</p>
</div>
@@ -111,10 +113,10 @@ async function save() {
<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">Save &amp; apply</button>
<button class="dh-btn-ghost" :disabled="busy" @click="test">Test connection</button>
<button class="dh-btn" :disabled="busy" @click="save">{{ t("webapp.saveApply") }}</button>
<button class="dh-btn-ghost" :disabled="busy" @click="test">{{ t("webapp.testConnection") }}</button>
<span class="flex-1"></span>
<span class="eyebrow">persisted to .env</span>
<span class="eyebrow">{{ t("webapp.persistedEnv") }}</span>
</div>
</div>
</div>
+163
View File
@@ -0,0 +1,163 @@
{
"app": {
"apiServer": "API-server",
"switchToLight": "Skift til lyst tema",
"switchToDark": "Skift til mørkt tema",
"theme": "Tema",
"signOut": "Log ud",
"loading": "Indlæser…",
"standardUserNote": "Logget ind som almindelig bruger — administrationssektioner kræver en administratorrolle",
"footer": "DriverVault — bilservice- og vedligeholdelsesregister."
},
"sections": {
"overview": "Oversigt",
"users": "Brugere",
"orgs": "Organisationer",
"pocketbase": "PocketBase",
"webapp": "Webapp",
"plugins": "Plugins",
"api": "API"
},
"login": {
"title": "Log ind",
"subtitle": "Superadmin-konsol til DriverVault API-serveren.",
"email": "E-mail",
"password": "Adgangskode",
"submit": "Log ind",
"submitting": "Logger ind…",
"invalid": "Ugyldig e-mail eller adgangskode.",
"failed": "Kunne ikke logge ind.",
"authNote": "Godkender mod PocketBase gennem denne server"
},
"status": {
"title": "Status",
"unreachable": "utilgængelig",
"checking": "Tjekker…",
"apiServer": "API-server",
"pocketBase": "PocketBase",
"webApp": "Webapp",
"thisProcess": "denne proces"
},
"common": {
"create": "Opret",
"save": "Gem",
"cancel": "Annuller",
"edit": "Rediger",
"delete": "Slet",
"rename": "Omdøb",
"empty": "—",
"name": "Navn",
"email": "E-mail",
"role": "Rolle",
"organization": "Organisation"
},
"users": {
"title": "Brugere",
"allOrgs": "Alle organisationer",
"yourOrg": "Din organisation",
"newUser": "Ny bruger",
"password": "Adgangskode",
"passwordUnchanged": "Adgangskode (tom = uændret)",
"passwordPlaceholder": "mindst 8 tegn",
"orgNone": "— ingen —",
"you": "dig",
"empty": "Ingen brugere.",
"confirmDelete": "Slet {email}? Dette kan ikke fortrydes."
},
"orgs": {
"title": "Organisationer",
"subtitle": "Enheder, som brugere tilhører",
"newOrg": "Ny organisation",
"namePlaceholder": "Acme Fleet",
"empty": "Ingen organisationer endnu.",
"colId": "ID",
"confirmDelete": "Slet organisationen \"{name}\"?"
},
"pocketbase": {
"title": "PocketBase",
"subtitle": "Databaseforbindelse brugt af hvert endpoint",
"connected": "forbundet",
"noSuperuser": "ingen superbruger",
"unreachable": "utilgængelig",
"baseUrl": "Basis-URL",
"superuserEmail": "Superbrugerens e-mail",
"superuserPassword": "Superbrugerens adgangskode",
"passwordUnchanged": "uændret",
"passwordNotSet": "ikke angivet",
"saveApply": "Gem og anvend",
"testConnection": "Test forbindelse",
"persistedEnv": "gemt i .env",
"testOk": "Forbindelse OK — superbruger godkendt.",
"testReachableNoAuth": "PocketBase er tilgængelig, men servicekontoen blev ikke godkendt.",
"testUnreachable": "PocketBase er ikke tilgængelig på den adresse.",
"savedNotice": "Gemt. Serveren bruger nu denne PocketBase."
},
"webapp": {
"title": "Webapp",
"subtitle": "Adressen, der tjekkes ved statuskontrol, og hvem der må kalde dette API fra en browser",
"reachable": "tilgængelig",
"unreachable": "utilgængelig",
"baseUrl": "Basis-URL",
"allowedOrigins": "Tilladte oprindelser",
"originsHint": "Kommasepareret, eller {star} for enhver. Native mobilapps er ikke underlagt CORS.",
"saveApply": "Gem og anvend",
"testConnection": "Test forbindelse",
"persistedEnv": "gemt i .env",
"testOk": "Webappen er tilgængelig.",
"testFailed": "Webappen svarede ikke på sit helbredstjek på den adresse.",
"savedNotice": "Gemt. Nye oprindelser gælder fra næste anmodning."
},
"plugins": {
"title": "Plugins",
"subtitle": "Integrationer med tredjepartstjenester",
"registerExternal": "Registrér ekstern",
"cancelRegister": "Annuller",
"name": "Navn",
"baseUrl": "Basis-URL",
"provider": "Udbyder",
"register": "Registrér",
"empty": "Ingen plugins endnu. Registrér et eksternt ovenfor, eller kompilér et indbygget stik.",
"builtin": "indbygget",
"enabled": "aktiveret",
"disabled": "deaktiveret",
"health": "Helbred",
"configure": "Konfigurer",
"close": "Luk",
"noConfig": "Dette plugin kræver ingen konfiguration.",
"capabilities": "Funktioner",
"saved": "Gemt.",
"checking": "Tjekker…",
"save": "Gem",
"saveEnable": "Gem og aktivér",
"disable": "Deaktiver",
"remove": "Fjern",
"builtinNote": "Indbyggede plugins kan deaktiveres, men ikke fjernes",
"confirmRemove": "Fjern det eksterne plugin \"{name}\"? Dets gemte konfiguration slettes."
},
"api": {
"colEndpoint": "Endpoint",
"colDescription": "Beskrivelse",
"authNone": "Ingen godkendelse",
"authBearer": "Bearer-token",
"authManager": "Admin / superadmin",
"authSuperadmin": "Superadmin",
"groupPublic": "Offentlig",
"groupIdentity": "Identitet",
"groupCars": "Biler",
"groupService": "Serviceposter",
"groupParts": "Reservedele",
"groupAccount": "Konto",
"groupManagement": "Administration",
"groupSuperadmin": "Superadmin"
}
}
+163
View File
@@ -0,0 +1,163 @@
{
"app": {
"apiServer": "API server",
"switchToLight": "Switch to light theme",
"switchToDark": "Switch to dark theme",
"theme": "Theme",
"signOut": "Sign out",
"loading": "Loading…",
"standardUserNote": "Signed in as a standard user — management sections need an admin role",
"footer": "DriverVault — car maintenance & service tracker."
},
"sections": {
"overview": "Overview",
"users": "Users",
"orgs": "Organizations",
"pocketbase": "PocketBase",
"webapp": "Web App",
"plugins": "Plugins",
"api": "API"
},
"login": {
"title": "Sign in",
"subtitle": "Superadmin console for the DriverVault API Server.",
"email": "Email",
"password": "Password",
"submit": "Sign in",
"submitting": "Signing in…",
"invalid": "Invalid email or password.",
"failed": "Could not sign in.",
"authNote": "Authenticates against PocketBase through this server"
},
"status": {
"title": "Status",
"unreachable": "unreachable",
"checking": "Checking…",
"apiServer": "API Server",
"pocketBase": "PocketBase",
"webApp": "Web App",
"thisProcess": "this process"
},
"common": {
"create": "Create",
"save": "Save",
"cancel": "Cancel",
"edit": "Edit",
"delete": "Delete",
"rename": "Rename",
"empty": "—",
"name": "Name",
"email": "Email",
"role": "Role",
"organization": "Organization"
},
"users": {
"title": "Users",
"allOrgs": "All organizations",
"yourOrg": "Your organization",
"newUser": "New user",
"password": "Password",
"passwordUnchanged": "Password (blank = unchanged)",
"passwordPlaceholder": "min 8 characters",
"orgNone": "— none —",
"you": "you",
"empty": "No users.",
"confirmDelete": "Delete {email}? This cannot be undone."
},
"orgs": {
"title": "Organizations",
"subtitle": "Tenants users belong to",
"newOrg": "New organization",
"namePlaceholder": "Acme Fleet",
"empty": "No organizations yet.",
"colId": "ID",
"confirmDelete": "Delete the organization \"{name}\"?"
},
"pocketbase": {
"title": "PocketBase",
"subtitle": "Database connection used by every endpoint",
"connected": "connected",
"noSuperuser": "no superuser",
"unreachable": "unreachable",
"baseUrl": "Base URL",
"superuserEmail": "Superuser email",
"superuserPassword": "Superuser password",
"passwordUnchanged": "unchanged",
"passwordNotSet": "not set",
"saveApply": "Save & apply",
"testConnection": "Test connection",
"persistedEnv": "persisted to .env",
"testOk": "Connection OK — superuser authenticated.",
"testReachableNoAuth": "PocketBase is reachable, but the service account did not authenticate.",
"testUnreachable": "PocketBase is not reachable at that address.",
"savedNotice": "Saved. The server is now using this PocketBase."
},
"webapp": {
"title": "Web App",
"subtitle": "Address probed by the status check, and who may call this API from a browser",
"reachable": "reachable",
"unreachable": "unreachable",
"baseUrl": "Base URL",
"allowedOrigins": "Allowed origins",
"originsHint": "Comma separated, or {star} for any. Native mobile apps are not subject to CORS.",
"saveApply": "Save & apply",
"testConnection": "Test connection",
"persistedEnv": "persisted to .env",
"testOk": "Web App is reachable.",
"testFailed": "Web App did not answer its health check at that address.",
"savedNotice": "Saved. New origins apply to the next request."
},
"plugins": {
"title": "Plugins",
"subtitle": "Third-party service integrations",
"registerExternal": "Register external",
"cancelRegister": "Cancel",
"name": "Name",
"baseUrl": "Base URL",
"provider": "Provider",
"register": "Register",
"empty": "No plugins yet. Register an external one above, or compile a built-in connector.",
"builtin": "builtin",
"enabled": "enabled",
"disabled": "disabled",
"health": "Health",
"configure": "Configure",
"close": "Close",
"noConfig": "This plugin takes no configuration.",
"capabilities": "Capabilities",
"saved": "Saved.",
"checking": "Checking…",
"save": "Save",
"saveEnable": "Save & enable",
"disable": "Disable",
"remove": "Remove",
"builtinNote": "Built-in plugins can be disabled but not removed",
"confirmRemove": "Remove the external plugin \"{name}\"? Its saved config is deleted."
},
"api": {
"colEndpoint": "Endpoint",
"colDescription": "Description",
"authNone": "No auth",
"authBearer": "Bearer token",
"authManager": "Admin / superadmin",
"authSuperadmin": "Superadmin",
"groupPublic": "Public",
"groupIdentity": "Identity",
"groupCars": "Cars",
"groupService": "Service records",
"groupParts": "Parts",
"groupAccount": "Account",
"groupManagement": "Management",
"groupSuperadmin": "Superadmin"
}
}
Binary file not shown.
+163
View File
@@ -0,0 +1,163 @@
{
"app": {
"apiServer": "Serwer API",
"switchToLight": "Przełącz na motyw jasny",
"switchToDark": "Przełącz na motyw ciemny",
"theme": "Motyw",
"signOut": "Wyloguj się",
"loading": "Ładowanie…",
"standardUserNote": "Zalogowano jako zwykły użytkownik — sekcje zarządzania wymagają roli administratora",
"footer": "DriverVault — rejestr serwisu i konserwacji samochodu."
},
"sections": {
"overview": "Przegląd",
"users": "Użytkownicy",
"orgs": "Organizacje",
"pocketbase": "PocketBase",
"webapp": "Aplikacja webowa",
"plugins": "Wtyczki",
"api": "API"
},
"login": {
"title": "Zaloguj się",
"subtitle": "Konsola superadministratora serwera API DriverVault.",
"email": "E-mail",
"password": "Hasło",
"submit": "Zaloguj się",
"submitting": "Logowanie…",
"invalid": "Nieprawidłowy e-mail lub hasło.",
"failed": "Nie udało się zalogować.",
"authNote": "Uwierzytelnia w PocketBase za pośrednictwem tego serwera"
},
"status": {
"title": "Status",
"unreachable": "niedostępny",
"checking": "Sprawdzanie…",
"apiServer": "Serwer API",
"pocketBase": "PocketBase",
"webApp": "Aplikacja webowa",
"thisProcess": "ten proces"
},
"common": {
"create": "Utwórz",
"save": "Zapisz",
"cancel": "Anuluj",
"edit": "Edytuj",
"delete": "Usuń",
"rename": "Zmień nazwę",
"empty": "—",
"name": "Nazwa",
"email": "E-mail",
"role": "Rola",
"organization": "Organizacja"
},
"users": {
"title": "Użytkownicy",
"allOrgs": "Wszystkie organizacje",
"yourOrg": "Twoja organizacja",
"newUser": "Nowy użytkownik",
"password": "Hasło",
"passwordUnchanged": "Hasło (puste = bez zmian)",
"passwordPlaceholder": "min. 8 znaków",
"orgNone": "— brak —",
"you": "Ty",
"empty": "Brak użytkowników.",
"confirmDelete": "Usunąć {email}? Tej operacji nie można cofnąć."
},
"orgs": {
"title": "Organizacje",
"subtitle": "Podmioty, do których należą użytkownicy",
"newOrg": "Nowa organizacja",
"namePlaceholder": "Acme Fleet",
"empty": "Brak organizacji.",
"colId": "ID",
"confirmDelete": "Usunąć organizację „{name}”?"
},
"pocketbase": {
"title": "PocketBase",
"subtitle": "Połączenie z bazą danych używane przez każdy punkt końcowy",
"connected": "połączono",
"noSuperuser": "brak superużytkownika",
"unreachable": "niedostępny",
"baseUrl": "Adres bazowy",
"superuserEmail": "E-mail superużytkownika",
"superuserPassword": "Hasło superużytkownika",
"passwordUnchanged": "bez zmian",
"passwordNotSet": "nie ustawiono",
"saveApply": "Zapisz i zastosuj",
"testConnection": "Testuj połączenie",
"persistedEnv": "zapisano w .env",
"testOk": "Połączenie OK — superużytkownik uwierzytelniony.",
"testReachableNoAuth": "PocketBase jest dostępny, ale konto usługowe nie zostało uwierzytelnione.",
"testUnreachable": "PocketBase jest niedostępny pod tym adresem.",
"savedNotice": "Zapisano. Serwer korzysta teraz z tego PocketBase."
},
"webapp": {
"title": "Aplikacja webowa",
"subtitle": "Adres sprawdzany podczas kontroli statusu oraz kto może wywoływać to API z przeglądarki",
"reachable": "dostępna",
"unreachable": "niedostępna",
"baseUrl": "Adres bazowy",
"allowedOrigins": "Dozwolone źródła",
"originsHint": "Oddzielone przecinkami lub {star} dla dowolnego. Natywne aplikacje mobilne nie podlegają CORS.",
"saveApply": "Zapisz i zastosuj",
"testConnection": "Testuj połączenie",
"persistedEnv": "zapisano w .env",
"testOk": "Aplikacja webowa jest dostępna.",
"testFailed": "Aplikacja webowa nie odpowiedziała na kontrolę stanu pod tym adresem.",
"savedNotice": "Zapisano. Nowe źródła obowiązują od następnego żądania."
},
"plugins": {
"title": "Wtyczki",
"subtitle": "Integracje z usługami zewnętrznymi",
"registerExternal": "Zarejestruj zewnętrzną",
"cancelRegister": "Anuluj",
"name": "Nazwa",
"baseUrl": "Adres bazowy",
"provider": "Dostawca",
"register": "Zarejestruj",
"empty": "Brak wtyczek. Zarejestruj zewnętrzną powyżej lub skompiluj wbudowany łącznik.",
"builtin": "wbudowana",
"enabled": "włączona",
"disabled": "wyłączona",
"health": "Stan",
"configure": "Konfiguruj",
"close": "Zamknij",
"noConfig": "Ta wtyczka nie wymaga konfiguracji.",
"capabilities": "Możliwości",
"saved": "Zapisano.",
"checking": "Sprawdzanie…",
"save": "Zapisz",
"saveEnable": "Zapisz i włącz",
"disable": "Wyłącz",
"remove": "Usuń",
"builtinNote": "Wtyczki wbudowane można wyłączyć, ale nie usunąć",
"confirmRemove": "Usunąć zewnętrzną wtyczkę „{name}”? Jej zapisana konfiguracja zostanie usunięta."
},
"api": {
"colEndpoint": "Punkt końcowy",
"colDescription": "Opis",
"authNone": "Bez uwierzytelniania",
"authBearer": "Token Bearer",
"authManager": "Administrator / superadministrator",
"authSuperadmin": "Superadministrator",
"groupPublic": "Publiczne",
"groupIdentity": "Tożsamość",
"groupCars": "Samochody",
"groupService": "Wpisy serwisowe",
"groupParts": "Części",
"groupAccount": "Konto",
"groupManagement": "Zarządzanie",
"groupSuperadmin": "Superadministrator"
}
}
+204
View File
@@ -0,0 +1,204 @@
{
"common": {
"save": "Gem",
"saving": "Gemmer…",
"saved": "Gemt ✓",
"cancel": "Annuller",
"remove": "Fjern",
"retry": "Prøv igen",
"required": "Påkrævet",
"confirm": "Bekræft",
"empty": "—"
},
"nav": {
"garage": "Garage",
"settings": "Indstillinger",
"users": "Brugere"
},
"login": {
"tagline": "Styr på din bil.",
"email": "E-mail",
"password": "Adgangskode",
"showPassword": "Vis adgangskode",
"hidePassword": "Skjul adgangskode",
"submit": "Log ind",
"submitting": "Logger ind…",
"or": "eller",
"signInWithFace": "Log ind med ansigtsgenkendelse",
"signInWithFingerprint": "Log ind med fingeraftryk",
"signInWithBiometrics": "Log ind med biometri",
"enrollTitle": "Aktivér biometrisk login?",
"enrollBody": "Næste gang kan du logge ind med {method} i stedet for at skrive din adgangskode.",
"enrollNotNow": "Ikke nu",
"enrollEnable": "Aktivér",
"methodFace": "ansigtsgenkendelse",
"methodFingerprint": "dit fingeraftryk",
"methodBiometrics": "biometri",
"bioReasonSignInAs": "Log ind som {email}",
"bioReasonSignIn": "Log ind på DriverVault",
"savedInvalid": "Det gemte login er ikke længere gyldigt. Log ind med din adgangskode.",
"serverSettings": "Serverindstillinger",
"apiServerUrl": "API-serverens adresse",
"leaveBlank": "Lad feltet stå tomt for at bruge standarden.",
"reset": "Nulstil",
"savedNote": "Gemt ✓",
"resetNote": "Nulstillet ✓"
},
"lock": {
"title": "DriverVault er låst",
"reason": "Lås DriverVault op",
"unlock": "Lås op",
"unlocking": "Låser op…",
"usePassword": "Brug adgangskode i stedet"
},
"dashboard": {
"eyebrow": "DINE BILER",
"title": "Garage",
"addCar": "Tilføj bil",
"lightMode": "Lys tilstand",
"darkMode": "Mørk tilstand",
"logOut": "Log ud",
"empty": "Ingen biler endnu.",
"shared": "Delt",
"sharedReadOnly": "Delt · skrivebeskyttet",
"serviceLife": "SERVICEINTERVAL BRUGT",
"lastService": "Seneste service",
"currentOdometer": "Nuværende kilometerstand",
"nextDue": "Næste service",
"nextDueKm": "Næste service (km)",
"serviceRecords": {
"one": "{n} servicepost",
"other": "{n} serviceposter"
}
},
"settings": {
"title": "Indstillinger",
"account": {
"title": "Konto",
"name": "Navn",
"nameSaved": "Navn gemt.",
"email": "E-mail",
"verified": "Bekræftet",
"notVerified": "Ikke bekræftet",
"sending": "Sender…",
"resendVerification": "Send bekræftelsesmail igen",
"verificationRequested": "Bekræftelsesmail anmodet.",
"changePassword": "Skift adgangskode",
"currentPassword": "Nuværende adgangskode",
"newPassword": "Ny adgangskode (mindst 8)",
"confirmNewPassword": "Bekræft ny adgangskode",
"tooShort": "Den nye adgangskode skal være på mindst 8 tegn.",
"mismatch": "Den nye adgangskode og bekræftelsen stemmer ikke overens.",
"passwordUpdated": "Adgangskode opdateret.",
"updating": "Opdaterer…",
"updatePassword": "Opdater adgangskode"
},
"appearance": {
"title": "Udseende",
"theme": "Tema",
"themeLight": "Lyst",
"themeDark": "Mørkt",
"themeSystem": "System",
"language": "Sprog",
"languageHint": "Appens tekst samt navne på måneder og dage.",
"languageFallbackHint": "Dette sprog er endnu ikke oversat — appens tekst forbliver på engelsk.",
"region": "Region",
"regionHint": "Tal- og valutaformat.",
"currency": "Valuta",
"currencyHint": "Eksempel: {example}. Beløb er kun til visning — intet omregnes.",
"dateFormat": "Datoformat",
"dateHint": "Eksempel: {example}",
"fontSize": "Skriftstørrelse",
"fontSmall": "Lille",
"fontMedium": "Mellem",
"fontLarge": "Stor"
},
"profile": {
"title": "Profil",
"working": "Arbejder…",
"uploadPhoto": "Upload billede",
"remove": "Fjern",
"bio": "Om mig",
"bioHint": "En kort note, som andre i din husstand kan se.",
"bioSaved": "Beskrivelse gemt.",
"saveBio": "Gem beskrivelse"
},
"security": {
"title": "Sikkerhed",
"checkingDevice": "Tjekker enhed…",
"biometricSignIn": "Biometrisk login",
"biometricAvailable": "Log ind med {method} i stedet for din adgangskode.",
"biometricUnavailable": "Der er ikke registreret biometri på denne enhed.",
"methodFaceOrFingerprint": "ansigt eller fingeraftryk",
"methodFace": "ansigtsgenkendelse",
"methodFingerprint": "fingeraftryk",
"methodBiometrics": "biometri",
"turnedOff": "Biometrisk login slået fra",
"enabled": "Biometrisk login aktiveret",
"couldNotEnable": "Kunne ikke aktivere: {error}",
"confirmPassword": "Bekræft din adgangskode",
"password": "Adgangskode"
},
"privacy": {
"title": "Privatliv og sikkerhed",
"body": "Tofaktorgodkendelse er ikke tilgængelig endnu. Sessioner bygger på tokens udstedt af serveren, som udløber af sig selv, så at logge ud afslutter kun sessionen på denne enhed. For at logge alle enheder ud skal du skifte din adgangskode ovenfor.",
"signOut": "Log ud"
},
"danger": {
"title": "Farezone",
"body": "Sletning af din konto fjerner dit login og din profil. Det sletter ikke husstandens delte biler eller servicehistorik. Der er 3 dages betænkningstid, før sletningen er endelig, og du kan annullere når som helst inden da.",
"deleteAccount": "Slet min konto",
"typeToConfirm": "Skriv {email} for at bekræfte",
"typeEmailPrompt": "Skriv din e-mail for at bekræfte.",
"requesting": "Anmoder…",
"requestDeletion": "Anmod om sletning",
"requestedOn": "Sletning af konto anmodet den {date}. {tail}",
"cooldownPassed": "Betænkningstiden er udløbet. Du kan nu gennemføre sletningen.",
"canStillCancel": "Du kan stadig annullere — det bliver permanent efter 3 dages betænkningstid.",
"cancelRequest": "Annuller anmodning om sletning",
"permanentlyDelete": "Slet permanent",
"finalizeTitle": "Slet konto?",
"finalizeBody": "Dette sletter din konto permanent. Det kan ikke fortrydes.",
"delete": "Slet"
}
},
"status": {
"noData": "Ingen data",
"serviceOverdueDays": "Service overskredet med {days} d",
"dueInDays": "Forfalder om {days} d",
"okDays": "OK · {days} d",
"noKm": "Ingen km",
"serviceOverdueKm": "Service overskredet med {km} km",
"inKm": "Om {km} km",
"kmLeft": "{km} km tilbage",
"expiredAgo": "Udløb for {days} d siden",
"expiresToday": "Udløber i dag",
"renewInDays": "Forny om {days} d",
"validDays": "Gyldig · {days} d",
"noExpiry": "Udløber ikke",
"done": "Færdig",
"noTrigger": "Ingen udløser",
"overdue": "Overskredet",
"overdueBy": "Overskredet {parts}",
"dueIn": "Forfalder om {parts}",
"upcoming": "Kommende",
"today": "i dag",
"days": "{days} d",
"km": "{km} km",
"warrantyExpiredAgo": "Garanti udløb for {days} d siden",
"warrantyEndsIn": "Garanti slutter om {days} d",
"underWarranty": "Under garanti · {days} d"
}
}
+204
View File
@@ -0,0 +1,204 @@
{
"common": {
"save": "Save",
"saving": "Saving…",
"saved": "Saved ✓",
"cancel": "Cancel",
"remove": "Remove",
"retry": "Retry",
"required": "Required",
"confirm": "Confirm",
"empty": "—"
},
"nav": {
"garage": "Garage",
"settings": "Settings",
"users": "Users"
},
"login": {
"tagline": "Your car, on track.",
"email": "Email",
"password": "Password",
"showPassword": "Show password",
"hidePassword": "Hide password",
"submit": "Sign in",
"submitting": "Signing in…",
"or": "or",
"signInWithFace": "Sign in with face recognition",
"signInWithFingerprint": "Sign in with fingerprint",
"signInWithBiometrics": "Sign in with biometrics",
"enrollTitle": "Enable biometric sign-in?",
"enrollBody": "Next time you can sign in with {method} instead of typing your password.",
"enrollNotNow": "Not now",
"enrollEnable": "Enable",
"methodFace": "face recognition",
"methodFingerprint": "your fingerprint",
"methodBiometrics": "biometrics",
"bioReasonSignInAs": "Sign in as {email}",
"bioReasonSignIn": "Sign in to DriverVault",
"savedInvalid": "Saved sign-in is no longer valid. Please sign in with your password.",
"serverSettings": "Server settings",
"apiServerUrl": "API server URL",
"leaveBlank": "Leave blank to use the default.",
"reset": "Reset",
"savedNote": "Saved ✓",
"resetNote": "Reset ✓"
},
"lock": {
"title": "DriverVault is locked",
"reason": "Unlock DriverVault",
"unlock": "Unlock",
"unlocking": "Unlocking…",
"usePassword": "Use password instead"
},
"dashboard": {
"eyebrow": "YOUR CARS",
"title": "Garage",
"addCar": "Add car",
"lightMode": "Light mode",
"darkMode": "Dark mode",
"logOut": "Log out",
"empty": "No cars yet.",
"shared": "Shared",
"sharedReadOnly": "Shared · read-only",
"serviceLife": "SERVICE LIFE",
"lastService": "Last service",
"currentOdometer": "Current odometer",
"nextDue": "Next due",
"nextDueKm": "Next due km",
"serviceRecords": {
"one": "{n} service record",
"other": "{n} service records"
}
},
"settings": {
"title": "Settings",
"account": {
"title": "Account",
"name": "Name",
"nameSaved": "Name saved.",
"email": "Email",
"verified": "Verified",
"notVerified": "Not verified",
"sending": "Sending…",
"resendVerification": "Resend verification email",
"verificationRequested": "Verification email requested.",
"changePassword": "Change password",
"currentPassword": "Current password",
"newPassword": "New password (min 8)",
"confirmNewPassword": "Confirm new password",
"tooShort": "New password must be at least 8 characters.",
"mismatch": "New password and confirmation don't match.",
"passwordUpdated": "Password updated.",
"updating": "Updating…",
"updatePassword": "Update password"
},
"appearance": {
"title": "Appearance",
"theme": "Theme",
"themeLight": "Light",
"themeDark": "Dark",
"themeSystem": "System",
"language": "Language",
"languageHint": "App text, and the names of months and days.",
"languageFallbackHint": "This language isn't translated yet — the app text stays in English.",
"region": "Region",
"regionHint": "Number and currency layout.",
"currency": "Currency",
"currencyHint": "Example: {example}. Amounts are display-only — nothing is converted.",
"dateFormat": "Date format",
"dateHint": "Example: {example}",
"fontSize": "Font size",
"fontSmall": "Small",
"fontMedium": "Medium",
"fontLarge": "Large"
},
"profile": {
"title": "Profile",
"working": "Working…",
"uploadPhoto": "Upload photo",
"remove": "Remove",
"bio": "Bio",
"bioHint": "A short note visible to other people in your household.",
"bioSaved": "Bio saved.",
"saveBio": "Save bio"
},
"security": {
"title": "Security",
"checkingDevice": "Checking device…",
"biometricSignIn": "Biometric sign-in",
"biometricAvailable": "Sign in with {method} instead of your password.",
"biometricUnavailable": "No biometrics are enrolled on this device.",
"methodFaceOrFingerprint": "face or fingerprint",
"methodFace": "face recognition",
"methodFingerprint": "fingerprint",
"methodBiometrics": "biometrics",
"turnedOff": "Biometric sign-in turned off",
"enabled": "Biometric sign-in enabled",
"couldNotEnable": "Could not enable: {error}",
"confirmPassword": "Confirm your password",
"password": "Password"
},
"privacy": {
"title": "Privacy & security",
"body": "Two-factor authentication isn't available yet. Sessions are held as server-issued tokens that expire on their own, so signing out ends this device's session only. To lock out every device, change your password above.",
"signOut": "Sign out"
},
"danger": {
"title": "Danger zone",
"body": "Deleting your account removes your login and profile. It does not delete your household's shared cars or service history. There's a 3-day cooldown before deletion is final, and you can cancel any time before then.",
"deleteAccount": "Delete my account",
"typeToConfirm": "Type {email} to confirm",
"typeEmailPrompt": "Type your email to confirm.",
"requesting": "Requesting…",
"requestDeletion": "Request deletion",
"requestedOn": "Account deletion requested on {date}. {tail}",
"cooldownPassed": "The cooldown has passed. You can now finalize the deletion.",
"canStillCancel": "You can still cancel — it becomes permanent after the 3-day cooldown.",
"cancelRequest": "Cancel deletion request",
"permanentlyDelete": "Permanently delete",
"finalizeTitle": "Delete account?",
"finalizeBody": "This permanently deletes your account. This cannot be undone.",
"delete": "Delete"
}
},
"status": {
"noData": "No data",
"serviceOverdueDays": "Service Overdue {days}d",
"dueInDays": "Due in {days}d",
"okDays": "OK · {days}d",
"noKm": "No km",
"serviceOverdueKm": "Service Overdue {km} km",
"inKm": "In {km} km",
"kmLeft": "{km} km left",
"expiredAgo": "Expired {days}d ago",
"expiresToday": "Expires today",
"renewInDays": "Renew in {days}d",
"validDays": "Valid · {days}d",
"noExpiry": "No expiry",
"done": "Done",
"noTrigger": "No trigger",
"overdue": "Overdue",
"overdueBy": "Overdue {parts}",
"dueIn": "Due in {parts}",
"upcoming": "Upcoming",
"today": "today",
"days": "{days}d",
"km": "{km} km",
"warrantyExpiredAgo": "Warranty expired {days}d ago",
"warrantyEndsIn": "Warranty ends in {days}d",
"underWarranty": "Under warranty · {days}d"
}
}
+206
View File
@@ -0,0 +1,206 @@
{
"common": {
"save": "Zapisz",
"saving": "Zapisywanie…",
"saved": "Zapisano ✓",
"cancel": "Anuluj",
"remove": "Usuń",
"retry": "Ponów",
"required": "Wymagane",
"confirm": "Potwierdź",
"empty": "—"
},
"nav": {
"garage": "Garaż",
"settings": "Ustawienia",
"users": "Użytkownicy"
},
"login": {
"tagline": "Twój samochód pod kontrolą.",
"email": "E-mail",
"password": "Hasło",
"showPassword": "Pokaż hasło",
"hidePassword": "Ukryj hasło",
"submit": "Zaloguj się",
"submitting": "Logowanie…",
"or": "lub",
"signInWithFace": "Zaloguj się rozpoznawaniem twarzy",
"signInWithFingerprint": "Zaloguj się odciskiem palca",
"signInWithBiometrics": "Zaloguj się biometrią",
"enrollTitle": "Włączyć logowanie biometryczne?",
"enrollBody": "Następnym razem możesz zalogować się za pomocą {method} zamiast wpisywać hasło.",
"enrollNotNow": "Nie teraz",
"enrollEnable": "Włącz",
"methodFace": "rozpoznawania twarzy",
"methodFingerprint": "odcisku palca",
"methodBiometrics": "biometrii",
"bioReasonSignInAs": "Zaloguj się jako {email}",
"bioReasonSignIn": "Zaloguj się do DriverVault",
"savedInvalid": "Zapisane dane logowania są już nieważne. Zaloguj się hasłem.",
"serverSettings": "Ustawienia serwera",
"apiServerUrl": "Adres serwera API",
"leaveBlank": "Pozostaw puste, aby użyć domyślnego.",
"reset": "Resetuj",
"savedNote": "Zapisano ✓",
"resetNote": "Zresetowano ✓"
},
"lock": {
"title": "DriverVault jest zablokowany",
"reason": "Odblokuj DriverVault",
"unlock": "Odblokuj",
"unlocking": "Odblokowywanie…",
"usePassword": "Użyj hasła"
},
"dashboard": {
"eyebrow": "TWOJE SAMOCHODY",
"title": "Garaż",
"addCar": "Dodaj samochód",
"lightMode": "Tryb jasny",
"darkMode": "Tryb ciemny",
"logOut": "Wyloguj się",
"empty": "Nie masz jeszcze samochodów.",
"shared": "Udostępniony",
"sharedReadOnly": "Udostępniony · tylko do odczytu",
"serviceLife": "ZUŻYCIE OKRESU SERWISOWEGO",
"lastService": "Ostatni serwis",
"currentOdometer": "Aktualny przebieg",
"nextDue": "Następny termin",
"nextDueKm": "Następny przebieg",
"serviceRecords": {
"one": "{n} wpis serwisowy",
"few": "{n} wpisy serwisowe",
"many": "{n} wpisów serwisowych",
"other": "{n} wpisu serwisowego"
}
},
"settings": {
"title": "Ustawienia",
"account": {
"title": "Konto",
"name": "Imię i nazwisko",
"nameSaved": "Zapisano imię i nazwisko.",
"email": "E-mail",
"verified": "Zweryfikowany",
"notVerified": "Niezweryfikowany",
"sending": "Wysyłanie…",
"resendVerification": "Wyślij ponownie e-mail weryfikacyjny",
"verificationRequested": "Zamówiono e-mail weryfikacyjny.",
"changePassword": "Zmień hasło",
"currentPassword": "Obecne hasło",
"newPassword": "Nowe hasło (min. 8)",
"confirmNewPassword": "Potwierdź nowe hasło",
"tooShort": "Nowe hasło musi mieć co najmniej 8 znaków.",
"mismatch": "Nowe hasło i potwierdzenie nie są takie same.",
"passwordUpdated": "Hasło zaktualizowane.",
"updating": "Aktualizowanie…",
"updatePassword": "Zaktualizuj hasło"
},
"appearance": {
"title": "Wygląd",
"theme": "Motyw",
"themeLight": "Jasny",
"themeDark": "Ciemny",
"themeSystem": "Systemowy",
"language": "Język",
"languageHint": "Tekst aplikacji oraz nazwy miesięcy i dni.",
"languageFallbackHint": "Ten język nie jest jeszcze przetłumaczony — tekst aplikacji pozostanie po angielsku.",
"region": "Region",
"regionHint": "Format liczb i waluty.",
"currency": "Waluta",
"currencyHint": "Przykład: {example}. Kwoty są tylko wyświetlane — nic nie jest przeliczane.",
"dateFormat": "Format daty",
"dateHint": "Przykład: {example}",
"fontSize": "Rozmiar czcionki",
"fontSmall": "Mała",
"fontMedium": "Średnia",
"fontLarge": "Duża"
},
"profile": {
"title": "Profil",
"working": "Przetwarzanie…",
"uploadPhoto": "Prześlij zdjęcie",
"remove": "Usuń",
"bio": "O mnie",
"bioHint": "Krótka notatka widoczna dla innych osób w Twoim gospodarstwie domowym.",
"bioSaved": "Zapisano opis.",
"saveBio": "Zapisz opis"
},
"security": {
"title": "Bezpieczeństwo",
"checkingDevice": "Sprawdzanie urządzenia…",
"biometricSignIn": "Logowanie biometryczne",
"biometricAvailable": "Zaloguj się za pomocą {method} zamiast hasła.",
"biometricUnavailable": "Na tym urządzeniu nie zarejestrowano biometrii.",
"methodFaceOrFingerprint": "twarzy lub odcisku palca",
"methodFace": "rozpoznawania twarzy",
"methodFingerprint": "odcisku palca",
"methodBiometrics": "biometrii",
"turnedOff": "Logowanie biometryczne wyłączone",
"enabled": "Logowanie biometryczne włączone",
"couldNotEnable": "Nie udało się włączyć: {error}",
"confirmPassword": "Potwierdź hasło",
"password": "Hasło"
},
"privacy": {
"title": "Prywatność i bezpieczeństwo",
"body": "Uwierzytelnianie dwuskładnikowe nie jest jeszcze dostępne. Sesje opierają się na tokenach wydawanych przez serwer, które wygasają samoczynnie, więc wylogowanie kończy tylko sesję na tym urządzeniu. Aby wylogować wszystkie urządzenia, zmień hasło powyżej.",
"signOut": "Wyloguj się"
},
"danger": {
"title": "Strefa niebezpieczna",
"body": "Usunięcie konta usuwa Twój login i profil. Nie usuwa samochodów ani historii serwisowej współdzielonych w gospodarstwie domowym. Obowiązuje 3-dniowy okres karencji, zanim usunięcie stanie się ostateczne — do tego czasu możesz je anulować.",
"deleteAccount": "Usuń moje konto",
"typeToConfirm": "Wpisz {email}, aby potwierdzić",
"typeEmailPrompt": "Wpisz swój e-mail, aby potwierdzić.",
"requesting": "Wysyłanie żądania…",
"requestDeletion": "Zażądaj usunięcia",
"requestedOn": "Żądanie usunięcia konta złożono {date}. {tail}",
"cooldownPassed": "Okres karencji minął. Możesz teraz dokończyć usuwanie.",
"canStillCancel": "Nadal możesz je anulować — stanie się ostateczne po 3-dniowym okresie karencji.",
"cancelRequest": "Anuluj żądanie usunięcia",
"permanentlyDelete": "Usuń trwale",
"finalizeTitle": "Usunąć konto?",
"finalizeBody": "To trwale usunie Twoje konto. Tej operacji nie można cofnąć.",
"delete": "Usuń"
}
},
"status": {
"noData": "Brak danych",
"serviceOverdueDays": "Serwis zaległy {days} dni",
"dueInDays": "Termin za {days} dni",
"okDays": "OK · {days} dni",
"noKm": "Brak przebiegu",
"serviceOverdueKm": "Serwis zaległy {km} km",
"inKm": "Za {km} km",
"kmLeft": "Pozostało {km} km",
"expiredAgo": "Wygasło {days} dni temu",
"expiresToday": "Wygasa dzisiaj",
"renewInDays": "Odnowienie za {days} dni",
"validDays": "Ważne · {days} dni",
"noExpiry": "Bezterminowe",
"done": "Gotowe",
"noTrigger": "Brak wyzwalacza",
"overdue": "Zaległe",
"overdueBy": "Zaległe {parts}",
"dueIn": "Termin za {parts}",
"upcoming": "Nadchodzące",
"today": "dzisiaj",
"days": "{days} dni",
"km": "{km} km",
"warrantyExpiredAgo": "Gwarancja wygasła {days} dni temu",
"warrantyEndsIn": "Gwarancja kończy się za {days} dni",
"underWarranty": "Na gwarancji · {days} dni"
}
}
+24 -23
View File
@@ -1,6 +1,7 @@
import "package:flutter/material.dart";
import "package:intl/intl.dart";
import "i18n.dart";
import "main.dart";
import "models.dart";
import "theme.dart";
@@ -97,22 +98,22 @@ int _rank(StatusKey k) => switch (k) {
};
Status _dateSignal(DateTime? nextDate) {
if (nextDate == null) return const Status(StatusKey.unknown, "No data");
if (nextDate == null) return Status(StatusKey.unknown, t("status.noData"));
final today = DateTime.now();
final days = DateTime(nextDate.year, nextDate.month, nextDate.day)
.difference(DateTime(today.year, today.month, today.day))
.inDays;
if (days < 0) return Status(StatusKey.overdue, "Service Overdue ${days.abs()}d");
if (days <= 30) return Status(StatusKey.soon, "Due in ${days}d");
return Status(StatusKey.ok, "OK · ${days}d");
if (days < 0) return Status(StatusKey.overdue, t("status.serviceOverdueDays", params: {"days": days.abs()}));
if (days <= 30) return Status(StatusKey.soon, t("status.dueInDays", params: {"days": days}));
return Status(StatusKey.ok, t("status.okDays", params: {"days": days}));
}
Status _kmSignal(int currentKm, int? nextKm) {
if (currentKm == 0 || nextKm == null) return const Status(StatusKey.unknown, "No km");
if (currentKm == 0 || nextKm == null) return Status(StatusKey.unknown, t("status.noKm"));
final remaining = nextKm - currentKm;
if (remaining < 0) return Status(StatusKey.overdue, "Service Overdue ${_num(remaining.abs())} km");
if (remaining <= _kmSoon) return Status(StatusKey.soon, "In ${_num(remaining)} km");
return Status(StatusKey.ok, "${_num(remaining)} km left");
if (remaining < 0) return Status(StatusKey.overdue, t("status.serviceOverdueKm", params: {"km": _num(remaining.abs())}));
if (remaining <= _kmSoon) return Status(StatusKey.soon, t("status.inKm", params: {"km": _num(remaining)}));
return Status(StatusKey.ok, t("status.kmLeft", params: {"km": _num(remaining)}));
}
/// Maps the server's expiry/reminder state names onto the badge palette. The
@@ -129,10 +130,10 @@ StatusKey _expiryKey(String state) => switch (state) {
Status expiryStatus(ExpiryAssessment e) {
final days = e.days;
final label = switch (e.state) {
"expired" => "Expired ${(days ?? 0).abs()}d ago",
"expiring_soon" => days == 0 ? "Expires today" : "Renew in ${days}d",
"valid" => "Valid · ${days}d",
_ => "No expiry",
"expired" => t("status.expiredAgo", params: {"days": (days ?? 0).abs()}),
"expiring_soon" => days == 0 ? t("status.expiresToday") : t("status.renewInDays", params: {"days": days}),
"valid" => t("status.validDays", params: {"days": days}),
_ => t("status.noExpiry"),
};
return Status(_expiryKey(e.state), label);
}
@@ -150,19 +151,19 @@ Status reminderStatus(Reminder r) {
final days = r.daysLeft;
final km = r.kmLeft;
if (r.status == "done") return const Status(StatusKey.unknown, "Done");
if (r.status == "no_trigger") return const Status(StatusKey.unknown, "No trigger");
if (r.status == "done") return Status(StatusKey.unknown, t("status.done"));
if (r.status == "no_trigger") return Status(StatusKey.unknown, t("status.noTrigger"));
final parts = <String>[];
if (r.status == "overdue") {
if (days != null && days < 0) parts.add("${days.abs()}d");
if (km != null && km < 0) parts.add("${_num(km.abs())} km");
return Status(key, parts.isEmpty ? "Overdue" : "Overdue ${parts.join(" · ")}");
if (days != null && days < 0) parts.add(t("status.days", params: {"days": days.abs()}));
if (km != null && km < 0) parts.add(t("status.km", params: {"km": _num(km.abs())}));
return Status(key, parts.isEmpty ? t("status.overdue") : t("status.overdueBy", params: {"parts": parts.join(" · ")}));
}
if (days != null && days >= 0) parts.add(days == 0 ? "today" : "${days}d");
if (km != null && km >= 0) parts.add("${_num(km)} km");
return Status(key, parts.isEmpty ? "Upcoming" : "Due in ${parts.join(" · ")}");
if (days != null && days >= 0) parts.add(days == 0 ? t("status.today") : t("status.days", params: {"days": days}));
if (km != null && km >= 0) parts.add(t("status.km", params: {"km": _num(km)}));
return Status(key, parts.isEmpty ? t("status.upcoming") : t("status.dueIn", params: {"parts": parts.join(" · ")}));
}
/// Warranty badge for a maintenance entry. Unlike the others this one has no
@@ -171,9 +172,9 @@ Status reminderStatus(Reminder r) {
Status? warrantyStatus(MaintenanceEntry m) {
final days = m.warrantyDaysLeft;
if (m.warrantyActive == null || days == null) return null;
if (!m.warrantyActive!) return Status(StatusKey.unknown, "Warranty expired ${days.abs()}d ago");
if (days <= 30) return Status(StatusKey.soon, "Warranty ends in ${days}d");
return Status(StatusKey.ok, "Under warranty · ${days}d");
if (!m.warrantyActive!) return Status(StatusKey.unknown, t("status.warrantyExpiredAgo", params: {"days": days.abs()}));
if (days <= 30) return Status(StatusKey.soon, t("status.warrantyEndsIn", params: {"days": days}));
return Status(StatusKey.ok, t("status.underWarranty", params: {"days": days}));
}
/// Combines the date- and km-based signals, returning the worse of the two —
+97
View File
@@ -0,0 +1,97 @@
import "dart:convert";
import "package:flutter/services.dart" show rootBundle;
import "package:intl/intl.dart";
import "main.dart" show appSettings;
/// UI translation for the phone app. Every user-visible string lives in a
/// per-language JSON file under assets/i18n/; nothing hardcodes English.
///
/// The language is the language half of the signed-in user's stored BCP-47
/// locale (appSettings.locale), the same value that already drives dates and
/// numbers in format.dart — so the Settings language picker steers both. Because
/// MaterialApp is wrapped in a ListenableBuilder on appSettings, changing the
/// locale rebuilds the whole tree and every t() re-resolves.
///
/// Deliberately hand-rolled rather than gen_l10n/ARB: the app already reads a
/// runtime locale from the profile (not the device locale Flutter's own
/// localization is built around), and "a JSON file per language the app reads
/// from" is exactly the shape asked for.
const String _base = "en";
/// Languages that ship a real translation file. The Settings picker offers every
/// European language for date/number formatting; only these change the UI text,
/// and the rest fall back to English.
const List<String> translatedLanguages = ["en", "pl", "da"];
final Map<String, Map<String, dynamic>> _messages = {};
/// Loads every bundled language file into memory. Call once in main() before
/// runApp, alongside initializeDateFormatting — the files are small assets and
/// t() is synchronous thereafter.
Future<void> loadTranslations() async {
for (final code in translatedLanguages) {
final raw = await rootBundle.loadString("assets/i18n/$code.json");
_messages[code] = json.decode(raw) as Map<String, dynamic>;
}
}
String get _lang {
final lang = appSettings.locale.split("-").first;
return _messages.containsKey(lang) ? lang : _base;
}
/// True when the active language has its own file (vs. falling back to English).
bool get languageTranslated =>
_messages.containsKey(appSettings.locale.split("-").first);
dynamic _lookup(Map<String, dynamic>? dict, String key) {
dynamic node = dict;
for (final part in key.split(".")) {
if (node is! Map) return null;
node = node[part];
}
return node;
}
String _interpolate(String template, Map<String, Object?>? params) {
if (params == null) return template;
return template.replaceAllMapped(RegExp(r"\{(\w+)\}"), (m) {
final v = params[m.group(1)];
return v == null ? m.group(0)! : v.toString();
});
}
/// Translate [key] for the active language.
///
/// Pass [n] to select a plural form (a JSON object keyed by CLDR category —
/// one/few/many/other); Intl.plural picks the right one for the language, which
/// is what makes Polish read correctly rather than an English-style n==1 split.
/// Any [params] interpolate `{name}` placeholders. A key missing from the active
/// language falls back to English; missing from English too, the key is returned
/// so the gap is visible rather than blank.
String t(String key, {Map<String, Object?>? params, int? n}) {
final lang = _lang;
dynamic entry = _lookup(_messages[lang], key);
entry ??= _lookup(_messages[_base], key);
if (entry == null) return key;
if (entry is Map) {
if (n == null) return key;
final forms = entry;
entry = Intl.plural(
n,
one: forms["one"] as String?,
few: forms["few"] as String?,
many: forms["many"] as String?,
other: (forms["other"] ?? forms["one"]) as String? ?? key,
locale: lang,
);
}
if (entry is! String) return key;
final merged = <String, Object?>{if (n != null) "n": n, ...?params};
return _interpolate(entry, merged.isEmpty ? null : merged);
}
+3
View File
@@ -5,6 +5,7 @@ import "api.dart";
import "app_settings.dart";
import "auth.dart";
import "biometric.dart";
import "i18n.dart";
import "theme.dart";
import "screens/lock_screen.dart";
import "screens/login_screen.dart";
@@ -21,6 +22,8 @@ Future<void> main() async {
// locale's date symbols have to be loaded before the first formatDate call —
// DateFormat throws on an uninitialized locale rather than falling back.
await initializeDateFormatting();
// Load the UI translation files before the first frame so t() is ready.
await loadTranslations();
await apiClient.loadServerUrl();
await appSettings.loadFromStorage();
await authService.loadFromStorage();
+18 -17
View File
@@ -1,5 +1,6 @@
import "package:flutter/material.dart";
import "../i18n.dart";
import "../main.dart";
import "../models.dart";
import "../format.dart";
@@ -73,7 +74,7 @@ class _DashboardScreenState extends State<DashboardScreen> {
floatingActionButton: FloatingActionButton.extended(
onPressed: _addCar,
icon: const Icon(Icons.add),
label: const Text("Add car"),
label: Text(t("dashboard.addCar")),
),
body: SafeArea(
child: Column(
@@ -88,25 +89,25 @@ class _DashboardScreenState extends State<DashboardScreen> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text("YOUR CARS",
Text(t("dashboard.eyebrow"),
style: DriverVault.mono(context,
size: 10, weight: FontWeight.w500, color: DriverVault.muted(context))
.copyWith(letterSpacing: 2.2)),
const SizedBox(height: 2),
const Text("Garage",
style: TextStyle(fontSize: 26, fontWeight: FontWeight.w700, letterSpacing: -0.5)),
Text(t("dashboard.title"),
style: const TextStyle(fontSize: 26, fontWeight: FontWeight.w700, letterSpacing: -0.5)),
],
),
),
_HeaderButton(
icon: dark ? Icons.light_mode_outlined : Icons.dark_mode_outlined,
tooltip: dark ? "Light mode" : "Dark mode",
tooltip: dark ? t("dashboard.lightMode") : t("dashboard.darkMode"),
onTap: _toggleTheme,
),
const SizedBox(width: 8),
_HeaderButton(
icon: Icons.logout,
tooltip: "Log out",
tooltip: t("dashboard.logOut"),
onTap: () => authService.logout(),
),
],
@@ -126,9 +127,9 @@ class _DashboardScreenState extends State<DashboardScreen> {
}
final rows = snap.data ?? [];
if (rows.isEmpty) {
return ListView(children: const [
SizedBox(height: 80),
Center(child: Text("No cars yet.")),
return ListView(children: [
const SizedBox(height: 80),
Center(child: Text(t("dashboard.empty"))),
]);
}
return ListView.builder(
@@ -230,12 +231,12 @@ class _CarCard extends StatelessWidget {
],
..._serviceLife(context, status),
const SizedBox(height: 12),
_kv(context, "Last service", formatDate(row.latest?.date)),
_kv(context, "Current odometer", formatKm(row.car.currentKm)),
_kv(context, "Next due", formatDate(row.latest?.nextServiceDate)),
_kv(context, "Next due km", formatKm(row.latest?.nextServiceKm)),
_kv(context, t("dashboard.lastService"), formatDate(row.latest?.date)),
_kv(context, t("dashboard.currentOdometer"), formatKm(row.car.currentKm)),
_kv(context, t("dashboard.nextDue"), formatDate(row.latest?.nextServiceDate)),
_kv(context, t("dashboard.nextDueKm"), formatKm(row.latest?.nextServiceKm)),
const SizedBox(height: 6),
Text("${row.count} service record${row.count == 1 ? '' : 's'}",
Text(t("dashboard.serviceRecords", n: row.count),
style: TextStyle(color: DriverVault.muted(context), fontSize: 11)),
],
),
@@ -271,7 +272,7 @@ class _CarCard extends StatelessWidget {
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text("SERVICE LIFE",
Text(t("dashboard.serviceLife"),
style: DriverVault.mono(context, size: 9, weight: FontWeight.w500,
color: DriverVault.muted(context)).copyWith(letterSpacing: 1.6)),
Text("$pct%",
@@ -305,7 +306,7 @@ class _SharedChip extends StatelessWidget {
borderRadius: BorderRadius.circular(999),
),
child: Text(
readOnly ? "Shared · read-only" : "Shared",
readOnly ? t("dashboard.sharedReadOnly") : t("dashboard.shared"),
style: TextStyle(color: DriverVault.brandOnTint(context), fontSize: 11, fontWeight: FontWeight.w600),
),
);
@@ -344,7 +345,7 @@ class _ErrorView extends StatelessWidget {
const SizedBox(height: 12),
Text(message, textAlign: TextAlign.center),
const SizedBox(height: 12),
FilledButton(onPressed: onRetry, child: const Text("Retry")),
FilledButton(onPressed: onRetry, child: Text(t("common.retry"))),
],
),
),
+6 -5
View File
@@ -1,6 +1,7 @@
import "package:flutter/material.dart";
import "../biometric.dart";
import "../i18n.dart";
import "../main.dart";
import "../theme.dart";
@@ -32,7 +33,7 @@ class _LockScreenState extends State<LockScreen> {
_error = null;
});
try {
final creds = await biometricAuth.unlock(reason: "Unlock DriverVault");
final creds = await biometricAuth.unlock(reason: t("lock.reason"));
if (creds == null) {
// Cancelled — leave locked; the buttons let them retry or use password.
if (mounted) setState(() => _busy = false);
@@ -70,8 +71,8 @@ class _LockScreenState extends State<LockScreen> {
child: const Icon(Icons.lock_outline, color: Colors.white, size: 32),
),
const SizedBox(height: 16),
const Text("DriverVault is locked",
style: TextStyle(fontSize: 20, fontWeight: FontWeight.w700, letterSpacing: -0.4)),
Text(t("lock.title"),
style: const TextStyle(fontSize: 20, fontWeight: FontWeight.w700, letterSpacing: -0.4)),
if (name != null)
Padding(
padding: const EdgeInsets.only(top: 4),
@@ -92,14 +93,14 @@ class _LockScreenState extends State<LockScreen> {
icon: const Icon(Icons.fingerprint),
label: Padding(
padding: const EdgeInsets.symmetric(vertical: 10),
child: Text(_busy ? "Unlocking…" : "Unlock"),
child: Text(_busy ? t("lock.unlocking") : t("lock.unlock")),
),
),
),
const SizedBox(height: 8),
TextButton(
onPressed: _busy ? null : () => authService.logout(),
child: const Text("Use password instead"),
child: Text(t("lock.usePassword")),
),
],
),
+41 -39
View File
@@ -3,6 +3,7 @@ import "package:flutter/material.dart";
import "../api.dart";
import "../biometric.dart";
import "../config.dart";
import "../i18n.dart";
import "../main.dart";
import "../theme.dart";
@@ -73,7 +74,7 @@ class _LoginScreenState extends State<LoginScreen> {
if (!mounted) return;
setState(() {
_server.text = current;
_serverSaved = "Saved ✓";
_serverSaved = t("login.savedNote");
});
}
@@ -82,7 +83,7 @@ class _LoginScreenState extends State<LoginScreen> {
if (!mounted) return;
setState(() {
_server.clear();
_serverSaved = "Reset ✓";
_serverSaved = t("login.resetNote");
});
}
@@ -115,19 +116,18 @@ class _LoginScreenState extends State<LoginScreen> {
Future<void> _maybeOfferBiometricEnrollment(String email, String password) async {
if (!_bioAvailable || _bioEnabled || !mounted) return;
final method = _hasFace && !_hasFingerprint
? "face recognition"
? t("login.methodFace")
: _hasFingerprint && !_hasFace
? "your fingerprint"
: "biometrics";
? t("login.methodFingerprint")
: t("login.methodBiometrics");
final ok = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text("Enable biometric sign-in?"),
content: Text(
"Next time you can sign in with $method instead of typing your password."),
title: Text(t("login.enrollTitle")),
content: Text(t("login.enrollBody", params: {"method": method})),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text("Not now")),
FilledButton(onPressed: () => Navigator.pop(ctx, true), child: const Text("Enable")),
TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(t("login.enrollNotNow"))),
FilledButton(onPressed: () => Navigator.pop(ctx, true), child: Text(t("login.enrollEnable"))),
],
),
);
@@ -143,7 +143,9 @@ class _LoginScreenState extends State<LoginScreen> {
});
try {
final creds = await biometricAuth.unlock(
reason: _bioEmail != null ? "Sign in as $_bioEmail" : "Sign in to DriverVault",
reason: _bioEmail != null
? t("login.bioReasonSignInAs", params: {"email": _bioEmail})
: t("login.bioReasonSignIn"),
);
if (creds == null) {
// User cancelled the prompt, or nothing is stored.
@@ -164,7 +166,7 @@ class _LoginScreenState extends State<LoginScreen> {
if (mounted) {
setState(() {
_bioEnabled = false;
_error = "Saved sign-in is no longer valid. Please sign in with your password.";
_error = t("login.savedInvalid");
});
}
} else if (mounted) {
@@ -198,22 +200,22 @@ class _LoginScreenState extends State<LoginScreen> {
),
);
if (_hasFace) buttons.add(bioButton(Icons.face, "Sign in with face recognition"));
if (_hasFingerprint) buttons.add(bioButton(Icons.fingerprint, "Sign in with fingerprint"));
if (buttons.isEmpty) buttons.add(bioButton(Icons.lock_outline, "Sign in with biometrics"));
if (_hasFace) buttons.add(bioButton(Icons.face, t("login.signInWithFace")));
if (_hasFingerprint) buttons.add(bioButton(Icons.fingerprint, t("login.signInWithFingerprint")));
if (buttons.isEmpty) buttons.add(bioButton(Icons.lock_outline, t("login.signInWithBiometrics")));
return Column(
children: [
const Padding(
padding: EdgeInsets.symmetric(vertical: 12),
Padding(
padding: const EdgeInsets.symmetric(vertical: 12),
child: Row(
children: [
Expanded(child: Divider()),
const Expanded(child: Divider()),
Padding(
padding: EdgeInsets.symmetric(horizontal: 8),
child: Text("or", style: TextStyle(color: Colors.grey, fontSize: 12)),
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Text(t("login.or"), style: const TextStyle(color: Colors.grey, fontSize: 12)),
),
Expanded(child: Divider()),
const Expanded(child: Divider()),
],
),
),
@@ -235,7 +237,7 @@ class _LoginScreenState extends State<LoginScreen> {
children: [
const DriverVaultLogo(markSize: 40, fontSize: 30),
const SizedBox(height: 10),
Text("Your car, on track.",
Text(t("login.tagline"),
style: TextStyle(color: DriverVault.muted(context))),
const SizedBox(height: 24),
Form(
@@ -256,23 +258,23 @@ class _LoginScreenState extends State<LoginScreen> {
TextFormField(
controller: _email,
keyboardType: TextInputType.emailAddress,
decoration: const InputDecoration(labelText: "Email", border: OutlineInputBorder()),
validator: (v) => (v == null || v.isEmpty) ? "Required" : null,
decoration: InputDecoration(labelText: t("login.email"), border: const OutlineInputBorder()),
validator: (v) => (v == null || v.isEmpty) ? t("common.required") : null,
),
const SizedBox(height: 12),
TextFormField(
controller: _password,
obscureText: !_showPassword,
decoration: InputDecoration(
labelText: "Password",
labelText: t("login.password"),
border: const OutlineInputBorder(),
suffixIcon: IconButton(
icon: Icon(_showPassword ? Icons.visibility_off : Icons.visibility),
tooltip: _showPassword ? "Hide password" : "Show password",
tooltip: _showPassword ? t("login.hidePassword") : t("login.showPassword"),
onPressed: () => setState(() => _showPassword = !_showPassword),
),
),
validator: (v) => (v == null || v.isEmpty) ? "Required" : null,
validator: (v) => (v == null || v.isEmpty) ? t("common.required") : null,
onFieldSubmitted: (_) => _submit(),
),
const SizedBox(height: 16),
@@ -282,7 +284,7 @@ class _LoginScreenState extends State<LoginScreen> {
onPressed: _loading ? null : _submit,
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 12),
child: Text(_loading ? "Signing in…" : "Sign in"),
child: Text(_loading ? t("login.submitting") : t("login.submit")),
),
),
),
@@ -338,8 +340,8 @@ class _ServerSettingsState extends State<_ServerSettings> {
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text("Server settings",
style: TextStyle(fontSize: 12, fontWeight: FontWeight.w600, color: Colors.grey)),
Text(t("login.serverSettings"),
style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w600, color: Colors.grey)),
Icon(_open ? Icons.expand_less : Icons.expand_more, size: 18, color: Colors.grey),
],
),
@@ -350,24 +352,24 @@ class _ServerSettingsState extends State<_ServerSettings> {
controller: widget.controller,
keyboardType: TextInputType.url,
autocorrect: false,
decoration: const InputDecoration(
labelText: "API server URL",
decoration: InputDecoration(
labelText: t("login.apiServerUrl"),
hintText: kDefaultApiBase,
border: OutlineInputBorder(),
border: const OutlineInputBorder(),
isDense: true,
),
),
const Padding(
padding: EdgeInsets.only(top: 4),
child: Text("Leave blank to use the default.",
style: TextStyle(color: Colors.grey, fontSize: 11)),
Padding(
padding: const EdgeInsets.only(top: 4),
child: Text(t("login.leaveBlank"),
style: const TextStyle(color: Colors.grey, fontSize: 11)),
),
const SizedBox(height: 4),
Row(
children: [
OutlinedButton(onPressed: widget.onSave, child: const Text("Save")),
OutlinedButton(onPressed: widget.onSave, child: Text(t("common.save"))),
const SizedBox(width: 8),
TextButton(onPressed: widget.onReset, child: const Text("Reset")),
TextButton(onPressed: widget.onReset, child: Text(t("login.reset"))),
if (widget.savedNote != null)
Padding(
padding: const EdgeInsets.only(left: 4),
+13 -12
View File
@@ -1,5 +1,6 @@
import "package:flutter/material.dart";
import "../i18n.dart";
import "../main.dart";
import "dashboard_screen.dart";
import "settings_screen.dart";
@@ -28,21 +29,21 @@ class _RootShellState extends State<RootShell> {
];
final destinations = <NavigationDestination>[
const NavigationDestination(
icon: Icon(Icons.grid_view_outlined),
selectedIcon: Icon(Icons.grid_view_rounded),
label: "Garage",
NavigationDestination(
icon: const Icon(Icons.grid_view_outlined),
selectedIcon: const Icon(Icons.grid_view_rounded),
label: t("nav.garage"),
),
const NavigationDestination(
icon: Icon(Icons.settings_outlined),
selectedIcon: Icon(Icons.settings),
label: "Settings",
NavigationDestination(
icon: const Icon(Icons.settings_outlined),
selectedIcon: const Icon(Icons.settings),
label: t("nav.settings"),
),
if (isAdmin)
const NavigationDestination(
icon: Icon(Icons.group_outlined),
selectedIcon: Icon(Icons.group),
label: "Users",
NavigationDestination(
icon: const Icon(Icons.group_outlined),
selectedIcon: const Icon(Icons.group),
label: t("nav.users"),
),
];
+106 -99
View File
@@ -4,6 +4,7 @@ import "package:flutter/material.dart";
import "package:image_picker/image_picker.dart";
import "../biometric.dart";
import "../i18n.dart";
import "../main.dart";
import "../models.dart";
import "../format.dart";
@@ -72,7 +73,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text("Settings")),
appBar: AppBar(title: Text(t("settings.title"))),
body: _loading
? const Center(child: CircularProgressIndicator())
: _loadError != null
@@ -186,7 +187,7 @@ class _AccountSectionState extends State<_AccountSection> {
name: updated.name,
role: updated.role,
);
widget.snack("Name saved.");
widget.snack(t("settings.account.nameSaved"));
} catch (e) {
widget.snack("$e");
} finally {
@@ -198,7 +199,7 @@ class _AccountSectionState extends State<_AccountSection> {
setState(() => _verifying = true);
try {
await apiClient.requestVerification();
widget.snack("Verification email requested.");
widget.snack(t("settings.account.verificationRequested"));
} catch (e) {
widget.snack("$e");
} finally {
@@ -209,11 +210,11 @@ class _AccountSectionState extends State<_AccountSection> {
Future<void> _savePassword() async {
setState(() => _pwError = null);
if (_newPw.text.length < 8) {
setState(() => _pwError = "New password must be at least 8 characters.");
setState(() => _pwError = t("settings.account.tooShort"));
return;
}
if (_newPw.text != _confirmPw.text) {
setState(() => _pwError = "New password and confirmation don't match.");
setState(() => _pwError = t("settings.account.mismatch"));
return;
}
setState(() => _savingPw = true);
@@ -222,7 +223,7 @@ class _AccountSectionState extends State<_AccountSection> {
_oldPw.clear();
_newPw.clear();
_confirmPw.clear();
widget.snack("Password updated.");
widget.snack(t("settings.account.passwordUpdated"));
} catch (e) {
setState(() => _pwError = e.toString());
} finally {
@@ -234,9 +235,9 @@ class _AccountSectionState extends State<_AccountSection> {
Widget build(BuildContext context) {
final p = widget.profile;
return _Card(
title: "Account",
title: t("settings.account.title"),
children: [
const Text("Name", style: TextStyle(fontWeight: FontWeight.w500)),
Text(t("settings.account.name"), style: const TextStyle(fontWeight: FontWeight.w500)),
const SizedBox(height: 4),
Row(children: [
Expanded(
@@ -248,11 +249,11 @@ class _AccountSectionState extends State<_AccountSection> {
const SizedBox(width: 8),
FilledButton(
onPressed: _savingName ? null : _saveName,
child: Text(_savingName ? "Saving…" : "Save"),
child: Text(_savingName ? t("common.saving") : t("common.save")),
),
]),
const SizedBox(height: 16),
const Text("Email", style: TextStyle(fontWeight: FontWeight.w500)),
Text(t("settings.account.email"), style: const TextStyle(fontWeight: FontWeight.w500)),
const SizedBox(height: 4),
Row(children: [
Expanded(child: Text(p.email)),
@@ -264,7 +265,7 @@ class _AccountSectionState extends State<_AccountSection> {
: (DriverVault.isDark(context) ? DriverVault.warningSoftDark : DriverVault.warningSoft),
borderRadius: BorderRadius.circular(999),
),
child: Text(p.verified ? "Verified" : "Not verified",
child: Text(p.verified ? t("settings.account.verified") : t("settings.account.notVerified"),
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
@@ -276,31 +277,31 @@ class _AccountSectionState extends State<_AccountSection> {
alignment: Alignment.centerLeft,
child: TextButton(
onPressed: _verifying ? null : _sendVerification,
child: Text(_verifying ? "Sending…" : "Resend verification email"),
child: Text(_verifying ? t("settings.account.sending") : t("settings.account.resendVerification")),
),
),
const Divider(height: 24),
const Text("Change password", style: TextStyle(fontWeight: FontWeight.w500)),
Text(t("settings.account.changePassword"), style: const TextStyle(fontWeight: FontWeight.w500)),
const SizedBox(height: 8),
TextField(
controller: _oldPw,
obscureText: true,
decoration: const InputDecoration(
labelText: "Current password", border: OutlineInputBorder(), isDense: true),
decoration: InputDecoration(
labelText: t("settings.account.currentPassword"), border: const OutlineInputBorder(), isDense: true),
),
const SizedBox(height: 8),
TextField(
controller: _newPw,
obscureText: true,
decoration: const InputDecoration(
labelText: "New password (min 8)", border: OutlineInputBorder(), isDense: true),
decoration: InputDecoration(
labelText: t("settings.account.newPassword"), border: const OutlineInputBorder(), isDense: true),
),
const SizedBox(height: 8),
TextField(
controller: _confirmPw,
obscureText: true,
decoration: const InputDecoration(
labelText: "Confirm new password", border: OutlineInputBorder(), isDense: true),
decoration: InputDecoration(
labelText: t("settings.account.confirmNewPassword"), border: const OutlineInputBorder(), isDense: true),
),
if (_pwError != null)
Padding(
@@ -312,7 +313,7 @@ class _AccountSectionState extends State<_AccountSection> {
alignment: Alignment.centerLeft,
child: OutlinedButton(
onPressed: _savingPw ? null : _savePassword,
child: Text(_savingPw ? "Updating…" : "Update password"),
child: Text(_savingPw ? t("settings.account.updating") : t("settings.account.updatePassword")),
),
),
],
@@ -444,21 +445,21 @@ class _AppearanceSectionState extends State<_AppearanceSection> {
@override
Widget build(BuildContext context) {
return _Card(
title: "Appearance",
title: t("settings.appearance.title"),
children: [
const Text("Theme", style: TextStyle(fontWeight: FontWeight.w500)),
Text(t("settings.appearance.theme"), style: const TextStyle(fontWeight: FontWeight.w500)),
const SizedBox(height: 6),
SegmentedButton<String>(
segments: const [
ButtonSegment(value: "light", label: Text("Light")),
ButtonSegment(value: "dark", label: Text("Dark")),
ButtonSegment(value: "system", label: Text("System")),
segments: [
ButtonSegment(value: "light", label: Text(t("settings.appearance.themeLight"))),
ButtonSegment(value: "dark", label: Text(t("settings.appearance.themeDark"))),
ButtonSegment(value: "system", label: Text(t("settings.appearance.themeSystem"))),
],
selected: {appSettings.theme},
onSelectionChanged: (s) => _save({"theme": s.first}),
),
const SizedBox(height: 16),
const Text("Language", style: TextStyle(fontWeight: FontWeight.w500)),
Text(t("settings.appearance.language"), style: const TextStyle(fontWeight: FontWeight.w500)),
const SizedBox(height: 6),
DropdownButtonFormField<String>(
initialValue: _language,
@@ -468,13 +469,18 @@ class _AppearanceSectionState extends State<_AppearanceSection> {
],
onChanged: (v) => v == null ? null : _saveLocale(lang: v),
),
const Padding(
padding: EdgeInsets.only(top: 4),
child: Text("Names of months and days.",
style: TextStyle(color: Colors.grey, fontSize: 12)),
Padding(
padding: const EdgeInsets.only(top: 4),
child: Text(
languageTranslated
? t("settings.appearance.languageHint")
: t("settings.appearance.languageFallbackHint"),
style: TextStyle(
color: languageTranslated ? Colors.grey : DriverVault.warning, fontSize: 12),
),
),
const SizedBox(height: 16),
const Text("Region", style: TextStyle(fontWeight: FontWeight.w500)),
Text(t("settings.appearance.region"), style: const TextStyle(fontWeight: FontWeight.w500)),
const SizedBox(height: 6),
DropdownButtonFormField<String>(
initialValue: _region,
@@ -484,13 +490,13 @@ class _AppearanceSectionState extends State<_AppearanceSection> {
],
onChanged: (v) => v == null ? null : _saveLocale(region: v),
),
const Padding(
padding: EdgeInsets.only(top: 4),
child: Text("Number and currency layout.",
style: TextStyle(color: Colors.grey, fontSize: 12)),
Padding(
padding: const EdgeInsets.only(top: 4),
child: Text(t("settings.appearance.regionHint"),
style: const TextStyle(color: Colors.grey, fontSize: 12)),
),
const SizedBox(height: 16),
const Text("Currency", style: TextStyle(fontWeight: FontWeight.w500)),
Text(t("settings.appearance.currency"), style: const TextStyle(fontWeight: FontWeight.w500)),
const SizedBox(height: 6),
DropdownButtonFormField<String>(
initialValue: _knownOr(_currencies, appSettings.currency, "USD"),
@@ -504,12 +510,12 @@ class _AppearanceSectionState extends State<_AppearanceSection> {
Padding(
padding: const EdgeInsets.only(top: 4),
child: Text(
"Example: ${formatMoney(1234.5)}. Amounts are display-only — nothing is converted.",
t("settings.appearance.currencyHint", params: {"example": formatMoney(1234.5)}),
style: const TextStyle(color: Colors.grey, fontSize: 12),
),
),
const SizedBox(height: 16),
const Text("Date format", style: TextStyle(fontWeight: FontWeight.w500)),
Text(t("settings.appearance.dateFormat"), style: const TextStyle(fontWeight: FontWeight.w500)),
const SizedBox(height: 6),
DropdownButtonFormField<String>(
initialValue: appSettings.dateFormat,
@@ -524,17 +530,17 @@ class _AppearanceSectionState extends State<_AppearanceSection> {
),
Padding(
padding: const EdgeInsets.only(top: 4),
child: Text("Example: ${formatDate(DateTime.now())}",
child: Text(t("settings.appearance.dateHint", params: {"example": formatDate(DateTime.now())}),
style: const TextStyle(color: Colors.grey, fontSize: 12)),
),
const SizedBox(height: 16),
const Text("Font size", style: TextStyle(fontWeight: FontWeight.w500)),
Text(t("settings.appearance.fontSize"), style: const TextStyle(fontWeight: FontWeight.w500)),
const SizedBox(height: 6),
SegmentedButton<String>(
segments: const [
ButtonSegment(value: "small", label: Text("Small")),
ButtonSegment(value: "medium", label: Text("Medium")),
ButtonSegment(value: "large", label: Text("Large")),
segments: [
ButtonSegment(value: "small", label: Text(t("settings.appearance.fontSmall"))),
ButtonSegment(value: "medium", label: Text(t("settings.appearance.fontMedium"))),
ButtonSegment(value: "large", label: Text(t("settings.appearance.fontLarge"))),
],
selected: {appSettings.fontSize},
onSelectionChanged: (s) => _save({"fontSize": s.first}),
@@ -599,7 +605,7 @@ class _ProfileSectionState extends State<_ProfileSection> {
setState(() => _savingBio = true);
try {
await apiClient.updateMe({"bio": widget.bioController.text});
widget.snack("Bio saved.");
widget.snack(t("settings.profile.bioSaved"));
} catch (e) {
widget.snack("$e");
} finally {
@@ -612,7 +618,7 @@ class _ProfileSectionState extends State<_ProfileSection> {
final p = widget.profile;
final initial = (p.name.isNotEmpty ? p.name : p.email).characters.first.toUpperCase();
return _Card(
title: "Profile",
title: t("settings.profile.title"),
children: [
Row(children: [
CircleAvatar(
@@ -629,24 +635,24 @@ class _ProfileSectionState extends State<_ProfileSection> {
Wrap(spacing: 8, children: [
OutlinedButton(
onPressed: _avatarBusy ? null : _pickAvatar,
child: Text(_avatarBusy ? "Working…" : "Upload photo"),
child: Text(_avatarBusy ? t("settings.profile.working") : t("settings.profile.uploadPhoto")),
),
if (p.hasAvatar)
OutlinedButton(
onPressed: _avatarBusy ? null : _removeAvatar,
child: const Text("Remove"),
child: Text(t("settings.profile.remove")),
),
]),
]),
const SizedBox(height: 16),
const Text("Bio", style: TextStyle(fontWeight: FontWeight.w500)),
Text(t("settings.profile.bio"), style: const TextStyle(fontWeight: FontWeight.w500)),
const SizedBox(height: 4),
TextField(
controller: widget.bioController,
maxLines: 3,
decoration: const InputDecoration(
border: OutlineInputBorder(),
hintText: "A short note visible to other people in your household.",
decoration: InputDecoration(
border: const OutlineInputBorder(),
hintText: t("settings.profile.bioHint"),
),
),
const SizedBox(height: 8),
@@ -654,7 +660,7 @@ class _ProfileSectionState extends State<_ProfileSection> {
alignment: Alignment.centerLeft,
child: OutlinedButton(
onPressed: _savingBio ? null : _saveBio,
child: Text(_savingBio ? "Saving…" : "Save bio"),
child: Text(_savingBio ? t("common.saving") : t("settings.profile.saveBio")),
),
),
],
@@ -702,10 +708,10 @@ class _SecuritySectionState extends State<_SecuritySection> {
}
String get _methodLabel {
if (_hasFace && _hasFingerprint) return "face or fingerprint";
if (_hasFace) return "face recognition";
if (_hasFingerprint) return "fingerprint";
return "biometrics";
if (_hasFace && _hasFingerprint) return t("settings.security.methodFaceOrFingerprint");
if (_hasFace) return t("settings.security.methodFace");
if (_hasFingerprint) return t("settings.security.methodFingerprint");
return t("settings.security.methodBiometrics");
}
Future<void> _toggle(bool value) async {
@@ -714,7 +720,7 @@ class _SecuritySectionState extends State<_SecuritySection> {
setState(() => _busy = true);
await biometricAuth.disable();
if (mounted) setState(() { _enabled = false; _busy = false; });
widget.snack("Biometric sign-in turned off");
widget.snack(t("settings.security.turnedOff"));
return;
}
// Enabling requires re-confirming the password so we store known-good creds.
@@ -726,10 +732,10 @@ class _SecuritySectionState extends State<_SecuritySection> {
await authService.login(widget.email, pw);
await biometricAuth.enable(widget.email, pw);
if (mounted) setState(() { _enabled = true; _busy = false; });
widget.snack("Biometric sign-in enabled");
widget.snack(t("settings.security.enabled"));
} catch (e) {
if (mounted) setState(() => _busy = false);
widget.snack("Could not enable: ${e.toString()}");
widget.snack(t("settings.security.couldNotEnable", params: {"error": e.toString()}));
}
}
@@ -738,20 +744,20 @@ class _SecuritySectionState extends State<_SecuritySection> {
return showDialog<String>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text("Confirm your password"),
title: Text(t("settings.security.confirmPassword")),
content: TextField(
controller: ctrl,
obscureText: true,
autofocus: true,
decoration: const InputDecoration(
labelText: "Password",
border: OutlineInputBorder(),
decoration: InputDecoration(
labelText: t("settings.security.password"),
border: const OutlineInputBorder(),
),
onSubmitted: (v) => Navigator.pop(ctx, v),
),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx), child: const Text("Cancel")),
FilledButton(onPressed: () => Navigator.pop(ctx, ctrl.text), child: const Text("Confirm")),
TextButton(onPressed: () => Navigator.pop(ctx), child: Text(t("common.cancel"))),
FilledButton(onPressed: () => Navigator.pop(ctx, ctrl.text), child: Text(t("common.confirm"))),
],
),
);
@@ -760,20 +766,20 @@ class _SecuritySectionState extends State<_SecuritySection> {
@override
Widget build(BuildContext context) {
return _Card(
title: "Security",
title: t("settings.security.title"),
children: [
if (!_loaded)
const Padding(
padding: EdgeInsets.symmetric(vertical: 8),
child: Text("Checking device…", style: TextStyle(color: Colors.grey)),
Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Text(t("settings.security.checkingDevice"), style: const TextStyle(color: Colors.grey)),
)
else ...[
SwitchListTile(
contentPadding: EdgeInsets.zero,
title: const Text("Biometric sign-in"),
title: Text(t("settings.security.biometricSignIn")),
subtitle: Text(_available
? "Sign in with $_methodLabel instead of your password."
: "No biometrics are enrolled on this device."),
? t("settings.security.biometricAvailable", params: {"method": _methodLabel})
: t("settings.security.biometricUnavailable")),
value: _enabled,
onChanged: (!_available || _busy) ? null : _toggle,
),
@@ -793,20 +799,17 @@ class _PrivacySection extends StatelessWidget {
@override
Widget build(BuildContext context) {
return _Card(
title: "Privacy & security",
title: t("settings.privacy.title"),
children: [
const Text(
"Two-factor authentication isn't available yet. Sessions are held as "
"server-issued tokens that expire on their own, so signing out ends "
"this device's session only. To lock out every device, change your "
"password above.",
style: TextStyle(color: Colors.grey, fontSize: 13),
Text(
t("settings.privacy.body"),
style: const TextStyle(color: Colors.grey, fontSize: 13),
),
Align(
alignment: Alignment.centerLeft,
child: TextButton(
onPressed: () => authService.logout(),
child: const Text("Sign out", style: TextStyle(color: DriverVault.danger)),
child: Text(t("settings.privacy.signOut"), style: const TextStyle(color: DriverVault.danger)),
),
),
],
@@ -848,7 +851,7 @@ class _DangerSectionState extends State<_DangerSection> {
Future<void> _request() async {
if (_confirmEmail.text.trim().toLowerCase() != widget.profile.email.toLowerCase()) {
widget.snack("Type your email to confirm.");
widget.snack(t("settings.danger.typeEmailPrompt"));
return;
}
setState(() => _busy = true);
@@ -878,14 +881,14 @@ class _DangerSectionState extends State<_DangerSection> {
final ok = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text("Delete account?"),
content: const Text("This permanently deletes your account. This cannot be undone."),
title: Text(t("settings.danger.finalizeTitle")),
content: Text(t("settings.danger.finalizeBody")),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text("Cancel")),
TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(t("common.cancel"))),
FilledButton(
style: FilledButton.styleFrom(backgroundColor: DriverVault.danger),
onPressed: () => Navigator.pop(ctx, true),
child: const Text("Delete"),
child: Text(t("settings.danger.delete")),
),
],
),
@@ -903,23 +906,23 @@ class _DangerSectionState extends State<_DangerSection> {
Widget build(BuildContext context) {
final p = widget.profile;
return _Card(
title: "Danger zone",
title: t("settings.danger.title"),
titleColor: DriverVault.danger,
children: [
if (!_pending) ...[
const Text(
"Deleting your account removes your login and profile. It does not delete your household's shared cars or service history. There's a 3-day cooldown before deletion is final, and you can cancel any time before then.",
style: TextStyle(fontSize: 13),
Text(
t("settings.danger.body"),
style: const TextStyle(fontSize: 13),
),
const SizedBox(height: 8),
if (!_showConfirm)
OutlinedButton(
onPressed: () => setState(() => _showConfirm = true),
style: OutlinedButton.styleFrom(foregroundColor: DriverVault.danger),
child: const Text("Delete my account"),
child: Text(t("settings.danger.deleteAccount")),
)
else ...[
Text("Type ${p.email} to confirm",
Text(t("settings.danger.typeToConfirm", params: {"email": p.email}),
style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w500)),
const SizedBox(height: 6),
TextField(
@@ -934,31 +937,35 @@ class _DangerSectionState extends State<_DangerSection> {
_showConfirm = false;
_confirmEmail.clear();
}),
child: const Text("Cancel"),
child: Text(t("common.cancel")),
),
const SizedBox(width: 8),
FilledButton(
style: FilledButton.styleFrom(backgroundColor: DriverVault.danger),
onPressed: _busy ? null : _request,
child: Text(_busy ? "Requesting…" : "Request deletion"),
child: Text(_busy ? t("settings.danger.requesting") : t("settings.danger.requestDeletion")),
),
]),
],
] else ...[
Text(
"Account deletion requested on ${formatDate(p.deletionRequestedAt)}. "
"${_cooldownElapsed ? 'The cooldown has passed. You can now finalize the deletion.' : 'You can still cancel — it becomes permanent after the 3-day cooldown.'}",
t("settings.danger.requestedOn", params: {
"date": formatDate(p.deletionRequestedAt),
"tail": _cooldownElapsed
? t("settings.danger.cooldownPassed")
: t("settings.danger.canStillCancel"),
}),
style: const TextStyle(fontSize: 13),
),
const SizedBox(height: 8),
Row(children: [
OutlinedButton(onPressed: _cancel, child: const Text("Cancel deletion request")),
OutlinedButton(onPressed: _cancel, child: Text(t("settings.danger.cancelRequest"))),
const SizedBox(width: 8),
if (_cooldownElapsed)
FilledButton(
style: FilledButton.styleFrom(backgroundColor: DriverVault.danger),
onPressed: _finalize,
child: const Text("Permanently delete"),
child: Text(t("settings.danger.permanentlyDelete")),
),
]),
],
+3
View File
@@ -32,6 +32,9 @@ dev_dependencies:
flutter:
uses-material-design: true
# Per-language UI translation files, read at runtime by lib/i18n.dart.
assets:
- assets/i18n/
# DriverVault launcher icon — regenerate with:
# flutter pub run flutter_launcher_icons
+25 -14
View File
@@ -9,12 +9,20 @@
import "package:flutter_test/flutter_test.dart";
import "package:intl/date_symbol_data_local.dart";
import "package:carcontrol_phone/format.dart";
import "package:carcontrol_phone/i18n.dart";
import "package:carcontrol_phone/main.dart";
import "package:carcontrol_phone/models.dart";
void main() {
// rootBundle (used by loadTranslations) needs the binding initialised.
TestWidgetsFlutterBinding.ensureInitialized();
setUpAll(() async {
await initializeDateFormatting();
await loadTranslations();
// Polish exercises both the region-aware number grouping and the localized
// status wording (one/few/many plurals), so the badge labels below are the
// Polish strings — the same text the web app renders under pl.
appSettings.locale = "pl-PL";
appSettings.currency = "PLN";
appSettings.dateFormat = "DMY";
@@ -67,11 +75,11 @@ void main() {
"expiry": {"state": state, "daysUntilExpiry": days},
"hasFile": false,
});
expect(expiryStatus(doc("expired", -5).expiry).label, "Expired 5d ago");
expect(expiryStatus(doc("expiring_soon", 0).expiry).label, "Expires today");
expect(expiryStatus(doc("expiring_soon", 12).expiry).label, "Renew in 12d");
expect(expiryStatus(doc("valid", 200).expiry).label, "Valid · 200d");
expect(expiryStatus(doc("no_expiry", null).expiry).label, "No expiry");
expect(expiryStatus(doc("expired", -5).expiry).label, "Wygasło 5 dni temu");
expect(expiryStatus(doc("expiring_soon", 0).expiry).label, "Wygasa dzisiaj");
expect(expiryStatus(doc("expiring_soon", 12).expiry).label, "Odnowienie za 12 dni");
expect(expiryStatus(doc("valid", 200).expiry).label, "Ważne · 200 dni");
expect(expiryStatus(doc("no_expiry", null).expiry).label, "Bezterminowe");
expect(expiryStatus(doc("expired", -5).expiry).key, StatusKey.overdue);
});
@@ -79,16 +87,19 @@ void main() {
Reminder rem(Map<String, dynamic> extra) =>
Reminder.fromJson({"id": "r", "car": "c", "title": "t", "type": "service", ...extra});
expect(reminderStatus(rem({"status": "done", "done": true})).label, "Done");
expect(reminderStatus(rem({"status": "no_trigger"})).label, "No trigger");
expect(reminderStatus(rem({"status": "done", "done": true})).label, "Gotowe");
expect(reminderStatus(rem({"status": "no_trigger"})).label, "Brak wyzwalacza");
expect(
reminderStatus(rem({"status": "overdue", "daysLeft": -3, "kmLeft": -200})).label,
"Overdue 3d · 200 km");
expect(reminderStatus(rem({"status": "due_soon", "daysLeft": 0})).label, "Due in today");
// The km count is grouped per the chosen locale (pl-PL uses a space), which
// is the whole point of routing every number through the one helper.
expect(reminderStatus(rem({"status": "upcoming", "daysLeft": 40, "kmLeft": 5000})).label,
"Due in 40d · 5 000 km");
"Zaległe 3 dni · 200 km");
expect(reminderStatus(rem({"status": "due_soon", "daysLeft": 0})).label, "Termin za dzisiaj");
// The km count is grouped per the chosen locale (pl-PL groups thousands with
// a space), which is the whole point of routing every number through the one
// helper. Asserted as start + end so the exact space glyph (ICU uses a
// non-breaking space) does not make the test brittle.
final due = reminderStatus(rem({"status": "upcoming", "daysLeft": 40, "kmLeft": 5000})).label;
expect(due, startsWith("Termin za 40 dni · 5"));
expect(due, endsWith("000 km"));
});
test("TechnicalCheck: a failed check derives no next date", () {
@@ -121,7 +132,7 @@ void main() {
"hasFile": false,
});
expect(m.totalCost, 150.0);
expect(warrantyStatus(m)!.label, "Warranty ends in 10d");
expect(warrantyStatus(m)!.label, "Gwarancja kończy się za 10 dni");
expect(warrantyStatus(m)!.key, StatusKey.soon);
final noWarranty = MaintenanceEntry.fromJson(
+4
View File
@@ -37,6 +37,10 @@ export/import).
- **Accounts & sessions** — JWT login, per-device active sessions with remote
logout, profile + appearance preferences (theme/locale/date/font), email
verification, and account deletion.
- **Translated UI** — the interface reads its text from per-language files
(English, Polish, Danish today), driven by the language half of the user's
locale, with English as the fallback for any untranslated string. See
[TRANSLATIONS.md](TRANSLATIONS.md) for the format and how to add a language.
- **Per-user ownership & sharing** — each car has an owner and can be shared with
other users as read or write; the UI mirrors the server's access checks.
- **Admin** — role-gated user management (create / role / reset password / delete).
+95
View File
@@ -0,0 +1,95 @@
# Translations (i18n)
DriverVault's user interface is translatable. Each surface reads its text from
**per-language files** — nothing hardcodes English in the parts that have been
converted — so adding a language is a matter of dropping in a new file, not
editing screens.
Three languages ship today: **English (`en`)**, **Polish (`pl`)** and
**Danish (`da`)**. English is the base and the fallback: any key missing from
another language renders the English string, so a partial translation is always
safe to ship.
The language is the **language half of the user's BCP-47 locale**
(`locale` = `language-REGION`, e.g. `pl-PL`). The Settings **Language** picker
sets it; the **Region** half keeps steering date, number and currency formatting
independently, so the two can be mixed freely (English text with Polish number
formatting, say). The picker offers every European language because the choice
also drives date/number formatting — the ones without a translation file fall
back to English UI text and say so beneath the picker.
## Where the files live
| Surface | Language files | Loader | Language source |
|---|---|---|---|
| **Web App** (Vue) | `Web App/web/src/i18n/{en,pl,da}.json` | `Web App/web/src/i18n/index.js` | signed-in profile `locale` (reactive `prefs`) |
| **API Server panel** (Vue) | `API Server/panel/src/i18n/{en,pl,da}.json` | `API Server/panel/src/i18n/index.js` | `localStorage` (`dh-panel-lang`) — the panel has no user profile |
| **Phone App** (Flutter) | `Phone App/assets/i18n/{en,pl,da}.json` | `Phone App/lib/i18n.dart` | signed-in profile `locale` (via `AppSettings`) |
All three use the same JSON shape and the same `t()` contract, so a translator
learns one format.
## The `t()` contract
```js
t("settings.appearance.title") // simple lookup (dot path = JSON nesting)
t("forms.share.title", { name: car.name }) // {name} placeholder interpolation
t("dashboard.serviceRecords", { n: count }) // plural — see below
```
- **Keys** are dot paths matching the nesting in the JSON.
- **Placeholders** are named (`{name}`), never positional, so a translator can
reorder them to suit the target grammar.
- **Plurals** are an object keyed by CLDR category, selected for the active
language by `Intl.PluralRules` (web/panel) or `Intl.plural` (Flutter):
```json
"serviceRecords": {
"one": "{n} service record",
"other": "{n} service records"
}
```
This is why Polish works: it needs `one` / `few` / `many` where English has
only `one` / `other`, and a naive `n === 1` check would get "5 samochodów"
wrong. Polish files therefore carry all four forms.
- The web/panel loaders also expose `tSplit(key, name)` for the few strings that
wrap one value in its own markup (a monospace URL, a bolded car name). It
returns `{ before, after }` around the placeholder so the value keeps its
styling without splitting the sentence into word-order-assuming fragments or
putting a translated string on a `v-html` path.
## Adding a language
1. **Copy `en.json` to `<code>.json`** in each surface you want to cover
(`fr.json`, say) and translate the string values. Keep the keys and the
`{placeholders}` unchanged. For a language with more plural categories than
English, expand the plural objects (`one`/`few`/`many`/`other` as CLDR
requires for that language).
2. **Register it** in the loader's `MESSAGES` map / `translatedLanguages` list:
- Web: `Web App/web/src/i18n/index.js` — add to the `import`s and `MESSAGES`.
- Panel: `API Server/panel/src/i18n/index.js` — same.
- Phone: `Phone App/lib/i18n.dart` — add the code to `translatedLanguages`
(the file is loaded from `assets/i18n/` automatically; it's covered by the
`assets/i18n/` directory entry in `pubspec.yaml`).
3. The Settings picker already lists every European language, so the new one
becomes selectable immediately and the "not translated yet" hint disappears
for it. Untranslated keys still fall back to English.
No screen code changes are needed to add a language.
## Coverage
- **Web App** — fully translated (every view, component, form, and the status
labels in `lib/format.js`).
- **API Server panel** — UI chrome, cards, login, status, and the API section
titles are translated. The individual REST endpoint **descriptions** in the
API reference table are intentionally left in English as developer reference
documentation.
- **Phone App** — the i18n system plus the core flows are translated: navigation,
login, lock screen, dashboard, the full Settings panel (including the language
picker), and all status/badge wording in `lib/format.dart`. The remaining
detail screens (car detail, record form sheets, admin users, car form sheet,
attachment field) still render in English via the fallback until their strings
are extracted — the pattern to follow is identical to the screens already done.
+7 -6
View File
@@ -4,6 +4,7 @@ import { RouterView, RouterLink, useRouter, useRoute } from "vue-router";
import { state, isAuthenticated, isAdmin, logout, refreshProfile } from "./auth";
import { prefs, applyProfilePrefs } from "./prefs";
import { api } from "./api";
import { t } from "./i18n";
import Logo from "./components/Logo.vue";
const router = useRouter();
@@ -34,9 +35,9 @@ const userInitial = computed(() =>
// Sidebar nav. Admin item is filtered out for non-admins.
const nav = computed(() =>
[
{ to: "/", label: "Garage", icon: "grid", exact: true },
{ to: "/settings", label: "Settings", icon: "gear" },
isAdmin.value ? { to: "/admin", label: "Users", icon: "users" } : null,
{ to: "/", label: t("nav.garage"), icon: "grid", exact: true },
{ to: "/settings", label: t("nav.settings"), icon: "gear" },
isAdmin.value ? { to: "/admin", label: t("nav.users"), icon: "users" } : null,
].filter(Boolean)
);
@@ -99,16 +100,16 @@ onBeforeUnmount(() => themeObserver?.disconnect());
>
<svg v-if="isDark" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" class="h-5 w-5 shrink-0"><circle cx="12" cy="12" r="4"/><path stroke-linecap="round" d="M12 2v2m0 16v2M4.9 4.9l1.4 1.4m11.4 11.4 1.4 1.4M2 12h2m16 0h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"/></svg>
<svg v-else viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" class="h-5 w-5 shrink-0"><path stroke-linecap="round" stroke-linejoin="round" d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8Z"/></svg>
<span class="hidden md:inline">{{ isDark ? "Light mode" : "Dark mode" }}</span>
<span class="hidden md:inline">{{ isDark ? t("nav.lightMode") : t("nav.darkMode") }}</span>
</button>
<div class="flex items-center gap-3 px-1 md:px-2">
<div class="grid h-9 w-9 flex-none place-items-center rounded-full bg-brand-600 font-mono text-sm text-white">{{ userInitial }}</div>
<div class="hidden min-w-0 flex-1 md:block">
<p class="truncate text-sm text-white">{{ state.user?.name || state.user?.email }}</p>
<p class="truncate font-mono text-[11px] text-white/50">Signed in</p>
<p class="truncate font-mono text-[11px] text-white/50">{{ t("nav.signedIn") }}</p>
</div>
<button class="hidden text-white/50 transition-colors hover:text-white md:block" title="Log out" @click="onLogout">
<button class="hidden text-white/50 transition-colors hover:text-white md:block" :title="t('nav.logOut')" @click="onLogout">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" class="h-5 w-5"><path stroke-linecap="round" stroke-linejoin="round" d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4M10 17l5-5-5-5M15 12H3"/></svg>
</button>
</div>
+2 -1
View File
@@ -1,6 +1,7 @@
// Single client for the Car Control API Server. The web app never talks to
// PocketBase directly — only to these endpoints (proxied to the API Server in
// dev via vite.config.js).
import { t } from "./i18n";
// Default API base: the Vite env override, else the same-origin "/api" (proxied
// to the API Server in dev). A user can override this at runtime via the login
@@ -39,7 +40,7 @@ async function handleResponse(res, path) {
localStorage.removeItem(TOKEN_KEY);
localStorage.removeItem(USER_KEY);
if (location.pathname !== "/login") location.href = "/login";
throw new Error("Session expired — please log in again.");
throw new Error(t("errors.sessionExpired"));
}
if (res.status === 204) return null;
+14 -8
View File
@@ -4,14 +4,19 @@
// It only collects intent — a picked file, or a request to detach the existing
// one. Actually moving the bytes is the parent's job (applyAttachment), because
// the endpoint addresses a record that must already exist.
import { t, tSplit } from "../i18n";
defineProps({
// The saved record, when editing; null while creating. Read for the name of
// whatever is already attached.
record: { type: Object, default: null },
file: { type: Object, default: null },
remove: { type: Boolean, default: false },
legend: { type: String, default: "Attachment" },
hint: { type: String, default: "PDF or image, up to 10MB." },
// Null rather than a literal default: the fallback has to be resolved at
// render time so it follows a language change, which a prop default evaluated
// once at definition time would not.
legend: { type: String, default: null },
hint: { type: String, default: null },
});
const emit = defineEmits(["update:file", "update:remove"]);
@@ -25,7 +30,7 @@ function onFilePick(e) {
<template>
<fieldset class="rounded-control border border-subtle p-3">
<legend class="eyebrow px-1">{{ legend }}</legend>
<legend class="eyebrow px-1">{{ legend ?? t("attachment.legend") }}</legend>
<input
type="file"
accept=".pdf,.jpg,.jpeg,.png,.webp,.heic"
@@ -33,17 +38,18 @@ function onFilePick(e) {
@change="onFilePick"
/>
<p v-if="record?.hasFile && !file && !remove" class="mt-2 flex items-center gap-2 text-xs text-muted">
<span>Attached: <span class="data text-strong">{{ record.fileName }}</span></span>
<span>{{ tSplit("attachment.attached", "name").before
}}<span class="data text-strong">{{ record.fileName }}</span>{{ tSplit("attachment.attached", "name").after }}</span>
<button type="button" class="font-medium text-danger hover:underline" @click="emit('update:remove', true)">
Remove
{{ t("common.remove") }}
</button>
</p>
<p v-else-if="remove" class="mt-2 flex items-center gap-2 text-xs text-muted">
<span>Attachment will be removed on save.</span>
<span>{{ t("attachment.willBeRemoved") }}</span>
<button type="button" class="font-medium text-brandtext hover:underline" @click="emit('update:remove', false)">
Undo
{{ t("common.undo") }}
</button>
</p>
<p class="mt-1.5 text-xs text-muted">{{ hint }}</p>
<p class="mt-1.5 text-xs text-muted">{{ hint ?? t("attachment.hint") }}</p>
</fieldset>
</template>
+34 -36
View File
@@ -1,6 +1,7 @@
<script setup>
import { ref } from "vue";
import { api } from "../api";
import { t } from "../i18n";
import Modal from "./Modal.vue";
const props = defineProps({ car: { type: Object, default: null } });
@@ -69,116 +70,113 @@ async function submit() {
</script>
<template>
<Modal :title="isEdit ? 'Edit car' : 'Add a car'" @close="emit('close')">
<Modal :title="isEdit ? t('forms.car.editTitle') : t('forms.car.addTitle')" @close="emit('close')">
<p v-if="error" class="mb-3 rounded-control bg-danger-soft px-3 py-2 text-sm font-medium text-danger">{{ error }}</p>
<form class="space-y-3" @submit.prevent="submit">
<div>
<label class="dh-label">Name *</label>
<label class="dh-label">{{ t("forms.car.name") }}</label>
<input v-model="form.name" required placeholder="Toyota Yaris" class="dh-input" />
</div>
<div class="grid grid-cols-3 gap-2">
<div>
<label class="dh-label">Make</label>
<label class="dh-label">{{ t("forms.car.make") }}</label>
<input v-model="form.make" placeholder="Toyota" class="dh-input" />
</div>
<div>
<label class="dh-label">Model</label>
<label class="dh-label">{{ t("forms.car.model") }}</label>
<input v-model="form.model" placeholder="Yaris" class="dh-input" />
</div>
<div>
<label class="dh-label">Year</label>
<label class="dh-label">{{ t("forms.car.year") }}</label>
<input v-model="form.year" type="number" placeholder="2015" class="dh-input data" />
</div>
</div>
<div class="grid grid-cols-3 gap-2">
<div>
<label class="dh-label">Registration</label>
<label class="dh-label">{{ t("forms.car.registration") }}</label>
<input v-model="form.registration" placeholder="ABC 1234" class="dh-input" />
</div>
<div>
<label class="dh-label">Registration country</label>
<input v-model="form.registrationCountry" placeholder="Poland" class="dh-input" />
<label class="dh-label">{{ t("forms.car.registrationCountry") }}</label>
<input v-model="form.registrationCountry" :placeholder="t('forms.car.registrationCountryPlaceholder')" class="dh-input" />
</div>
<div>
<label class="dh-label">VIN</label>
<input v-model="form.vin" placeholder="Vehicle Identification Number" maxlength="17" class="dh-input data uppercase" />
<label class="dh-label">{{ t("forms.car.vin") }}</label>
<input v-model="form.vin" :placeholder="t('forms.car.vinPlaceholder')" maxlength="17" class="dh-input data uppercase" />
</div>
</div>
<div class="grid grid-cols-3 gap-2">
<div>
<label class="dh-label">Fuel type</label>
<label class="dh-label">{{ t("forms.car.fuelType") }}</label>
<select v-model="form.fuelType" class="dh-input">
<option value=""></option>
<option value="petrol">Petrol (gasoline)</option>
<option value="petrol_lpg">Petrol (gasoline) + LPG</option>
<option value="diesel">Diesel</option>
<option value="diesel_lpg">Diesel + LPG</option>
<option value="hybrid">Hybrid</option>
<option value="electric">Electric</option>
<option value="hydrogen">Hydrogen</option>
<option value="">{{ t("common.empty") }}</option>
<option value="petrol">{{ t("enums.fuelType.petrol") }}</option>
<option value="petrol_lpg">{{ t("enums.fuelType.petrol_lpg") }}</option>
<option value="diesel">{{ t("enums.fuelType.diesel") }}</option>
<option value="diesel_lpg">{{ t("enums.fuelType.diesel_lpg") }}</option>
<option value="hybrid">{{ t("enums.fuelType.hybrid") }}</option>
<option value="electric">{{ t("enums.fuelType.electric") }}</option>
<option value="hydrogen">{{ t("enums.fuelType.hydrogen") }}</option>
</select>
</div>
<div>
<label class="dh-label">Build date</label>
<label class="dh-label">{{ t("forms.car.buildDate") }}</label>
<input v-model="form.buildDate" type="date" class="dh-input data" />
</div>
<div>
<label class="dh-label">First registration</label>
<label class="dh-label">{{ t("forms.car.firstRegistration") }}</label>
<input v-model="form.firstRegistrationDate" type="date" class="dh-input data" />
</div>
</div>
<div class="grid grid-cols-2 gap-2">
<div>
<label class="dh-label">Engine oil spec</label>
<label class="dh-label">{{ t("forms.car.oilSpec") }}</label>
<input v-model="form.oilSpec" placeholder="0W20" class="dh-input" />
</div>
<div>
<label class="dh-label">Current odometer (km)</label>
<label class="dh-label">{{ t("forms.car.currentKm") }}</label>
<input v-model="form.currentKm" type="number" placeholder="270185" class="dh-input data" />
</div>
</div>
<div class="grid grid-cols-2 gap-2">
<div>
<label class="dh-label">Transmission oil spec</label>
<label class="dh-label">{{ t("forms.car.transmissionOilSpec") }}</label>
<input v-model="form.transmissionOilSpec" placeholder="Toyota WS" class="dh-input" />
</div>
<div>
<label class="dh-label">Differential oil spec</label>
<label class="dh-label">{{ t("forms.car.differentialOilSpec") }}</label>
<input v-model="form.differentialOilSpec" placeholder="SAE 75W-90 GL-5" class="dh-input" />
</div>
</div>
<div class="grid grid-cols-2 gap-2">
<div>
<label class="dh-label">Brake fluid spec</label>
<label class="dh-label">{{ t("forms.car.brakeFluidSpec") }}</label>
<input v-model="form.brakeFluidSpec" placeholder="DOT 4" class="dh-input" />
</div>
<div>
<label class="dh-label">Coolant spec</label>
<label class="dh-label">{{ t("forms.car.coolantSpec") }}</label>
<input v-model="form.coolantSpec" placeholder="Toyota Super Long Life Coolant" class="dh-input" />
</div>
</div>
<div class="grid grid-cols-2 gap-2">
<div>
<label class="dh-label">Service interval (days)</label>
<label class="dh-label">{{ t("forms.car.serviceIntervalDays") }}</label>
<input v-model="form.serviceIntervalDays" type="number" class="dh-input data" />
</div>
<div>
<label class="dh-label">Service interval (km)</label>
<label class="dh-label">{{ t("forms.car.serviceIntervalKm") }}</label>
<input v-model="form.serviceIntervalKm" type="number" class="dh-input data" />
</div>
</div>
<div>
<label class="dh-label">Technical check interval (days)</label>
<label class="dh-label">{{ t("forms.car.technicalCheckIntervalDays") }}</label>
<input v-model="form.technicalCheckIntervalDays" type="number" class="dh-input data" />
<p class="mt-1 text-xs text-muted">
Prefills each check's next-due date. Any check can override it with the date printed on
its certificate.
</p>
<p class="mt-1 text-xs text-muted">{{ t("forms.car.technicalCheckHint") }}</p>
</div>
<div class="mt-4 flex justify-end gap-2">
<button type="button" class="dh-btn dh-btn-ghost" @click="emit('close')">Cancel</button>
<button type="button" class="dh-btn dh-btn-ghost" @click="emit('close')">{{ t("common.cancel") }}</button>
<button type="submit" :disabled="saving" class="dh-btn dh-btn-primary">
{{ saving ? "Saving" : isEdit ? "Save changes" : "Add car" }}
{{ saving ? t("common.saving") : isEdit ? t("common.saveChanges") : t("forms.car.submit") }}
</button>
</div>
</form>
@@ -2,6 +2,7 @@
import { ref } from "vue";
import { api } from "../api";
import { applyAttachment } from "../lib/attachment.js";
import { t } from "../i18n";
import AttachmentField from "./AttachmentField.vue";
import Modal from "./Modal.vue";
@@ -15,15 +16,7 @@ const isEdit = !!props.doc;
const saving = ref(false);
const error = ref("");
const TYPES = [
{ value: "insurance", label: "Insurance" },
{ value: "pollution", label: "Pollution certificate" },
{ value: "registration", label: "Registration" },
{ value: "inspection", label: "Inspection" },
{ value: "roadTax", label: "Road tax" },
{ value: "warranty", label: "Warranty" },
{ value: "other", label: "Other" },
];
const TYPE_VALUES = ["insurance", "pollution", "registration", "inspection", "roadTax", "warranty", "other"];
const form = ref({
type: props.doc?.type ?? "insurance",
@@ -78,62 +71,60 @@ function payload() {
</script>
<template>
<Modal :title="isEdit ? 'Edit document' : 'Add document'" @close="emit('close')">
<Modal :title="isEdit ? t('forms.document.editTitle') : t('forms.document.addTitle')" @close="emit('close')">
<p v-if="error" class="mb-3 rounded-control bg-danger-soft px-3 py-2 text-sm font-medium text-danger">{{ error }}</p>
<form class="space-y-3" @submit.prevent="submit">
<div>
<label class="dh-label">Type</label>
<label class="dh-label">{{ t("forms.document.type") }}</label>
<select v-model="form.type" class="dh-input">
<option v-for="t in TYPES" :key="t.value" :value="t.value">{{ t.label }}</option>
<option v-for="v in TYPE_VALUES" :key="v" :value="v">{{ t(`enums.documentType.${v}`) }}</option>
</select>
</div>
<div>
<label class="dh-label">Title *</label>
<input v-model="form.title" required placeholder="Third-party liability 2026" class="dh-input" />
<label class="dh-label">{{ t("forms.document.title") }}</label>
<input v-model="form.title" required :placeholder="t('forms.document.titlePlaceholder')" class="dh-input" />
</div>
<div class="grid grid-cols-2 gap-2">
<div>
<label class="dh-label">Provider</label>
<label class="dh-label">{{ t("forms.document.provider") }}</label>
<input v-model="form.provider" placeholder="PZU" class="dh-input" />
</div>
<div>
<label class="dh-label">Policy / certificate no.</label>
<label class="dh-label">{{ t("forms.document.reference") }}</label>
<input v-model="form.reference" class="dh-input data" />
</div>
</div>
<div class="grid grid-cols-2 gap-2">
<div>
<label class="dh-label">Issued</label>
<label class="dh-label">{{ t("forms.document.issued") }}</label>
<input v-model="form.issueDate" type="date" class="dh-input data" />
</div>
<div>
<label class="dh-label">Renewal date</label>
<label class="dh-label">{{ t("forms.document.renewalDate") }}</label>
<input v-model="form.expiryDate" type="date" class="dh-input data" />
</div>
</div>
<p class="text-xs text-muted">
Leave the renewal date blank for a document that never expires. Setting it adds a reminder automatically.
</p>
<p class="text-xs text-muted">{{ t("forms.document.renewalHint") }}</p>
<div>
<label class="dh-label">Cost</label>
<label class="dh-label">{{ t("forms.document.cost") }}</label>
<input v-model="form.cost" type="number" step="0.01" min="0" class="dh-input data" />
</div>
<AttachmentField v-model:file="file" v-model:remove="removeFile" :record="doc" legend="Scan or photo" />
<AttachmentField v-model:file="file" v-model:remove="removeFile" :record="doc" :legend="t('forms.document.attachmentLegend')" />
<div>
<label class="dh-label">Notes</label>
<label class="dh-label">{{ t("forms.document.notes") }}</label>
<input v-model="form.notes" class="dh-input" />
</div>
<div class="mt-4 flex justify-end gap-2">
<button type="button" class="dh-btn dh-btn-ghost" @click="emit('close')">Cancel</button>
<button type="button" class="dh-btn dh-btn-ghost" @click="emit('close')">{{ t("common.cancel") }}</button>
<button type="submit" :disabled="saving" class="dh-btn dh-btn-primary">
{{ saving ? "Saving" : isEdit ? "Save changes" : "Add document" }}
{{ saving ? t("common.saving") : isEdit ? t("common.saveChanges") : t("forms.document.submit") }}
</button>
</div>
</form>
+17 -18
View File
@@ -2,6 +2,7 @@
import { ref, computed } from "vue";
import { api } from "../api";
import { applyAttachment } from "../lib/attachment.js";
import { t, tSplit } from "../i18n";
import AttachmentField from "./AttachmentField.vue";
import Modal from "./Modal.vue";
@@ -75,66 +76,64 @@ function payload() {
</script>
<template>
<Modal :title="isEdit ? 'Edit refill' : 'Log refill'" @close="emit('close')">
<Modal :title="isEdit ? t('forms.fuel.editTitle') : t('forms.fuel.addTitle')" @close="emit('close')">
<p v-if="error" class="mb-3 rounded-control bg-danger-soft px-3 py-2 text-sm font-medium text-danger">{{ error }}</p>
<form class="space-y-3" @submit.prevent="submit">
<div class="grid grid-cols-2 gap-2">
<div>
<label class="dh-label">Date *</label>
<label class="dh-label">{{ t("forms.fuel.date") }}</label>
<input v-model="form.date" type="date" required class="dh-input data" />
</div>
<div>
<label class="dh-label">Odometer (km) *</label>
<label class="dh-label">{{ t("forms.fuel.odometer") }}</label>
<input v-model="form.km" type="number" min="1" required placeholder="16138" class="dh-input data" />
</div>
</div>
<div class="grid grid-cols-2 gap-2">
<div>
<label class="dh-label">Litres *</label>
<label class="dh-label">{{ t("forms.fuel.liters") }}</label>
<input v-model="form.liters" type="number" step="0.01" min="0.01" required placeholder="42.5" class="dh-input data" />
</div>
<div>
<label class="dh-label">Total cost</label>
<label class="dh-label">{{ t("forms.fuel.cost") }}</label>
<input v-model="form.cost" type="number" step="0.01" min="0" placeholder="285.00" class="dh-input data" />
</div>
</div>
<p v-if="pricePerLiter" class="text-xs text-muted">
Price per litre: <span class="data text-strong">{{ pricePerLiter }}</span>
{{ tSplit("forms.fuel.pricePerLiter", "price").before
}}<span class="data text-strong">{{ pricePerLiter }}</span>{{ tSplit("forms.fuel.pricePerLiter", "price").after }}
</p>
<fieldset class="rounded-control border border-subtle p-3">
<legend class="eyebrow px-1">Tank</legend>
<legend class="eyebrow px-1">{{ t("forms.fuel.tank") }}</legend>
<label class="flex items-center gap-2 py-1 text-sm text-body">
<input type="checkbox" v-model="form.fullTank" class="accent-[var(--accent)]" /> Filled to full
<input type="checkbox" v-model="form.fullTank" class="accent-[var(--accent)]" /> {{ t("forms.fuel.fullTank") }}
</label>
<label class="flex items-center gap-2 py-1 text-sm text-body">
<input type="checkbox" v-model="form.missedFill" class="accent-[var(--accent)]" /> I missed logging a refill before this one
<input type="checkbox" v-model="form.missedFill" class="accent-[var(--accent)]" /> {{ t("forms.fuel.missedFill") }}
</label>
<p class="mt-1.5 text-xs text-muted">
Consumption is measured between full tanks, so partial fills count towards the next full one.
Flagging a missed refill leaves that stretch out of the figures instead of reporting it as unrealistically economical.
</p>
<p class="mt-1.5 text-xs text-muted">{{ t("forms.fuel.tankHint") }}</p>
</fieldset>
<div class="grid grid-cols-2 gap-2">
<div>
<label class="dh-label">Station</label>
<label class="dh-label">{{ t("forms.fuel.station") }}</label>
<input v-model="form.station" placeholder="Orlen" class="dh-input" />
</div>
<div>
<label class="dh-label">Notes</label>
<label class="dh-label">{{ t("forms.fuel.notes") }}</label>
<input v-model="form.notes" class="dh-input" />
</div>
</div>
<AttachmentField v-model:file="file" v-model:remove="removeFile" :record="entry" legend="Receipt" />
<AttachmentField v-model:file="file" v-model:remove="removeFile" :record="entry" :legend="t('forms.fuel.attachmentLegend')" />
<div class="mt-4 flex justify-end gap-2">
<button type="button" class="dh-btn dh-btn-ghost" @click="emit('close')">Cancel</button>
<button type="button" class="dh-btn dh-btn-ghost" @click="emit('close')">{{ t("common.cancel") }}</button>
<button type="submit" :disabled="saving" class="dh-btn dh-btn-primary">
{{ saving ? "Saving" : isEdit ? "Save changes" : "Log refill" }}
{{ saving ? t("common.saving") : isEdit ? t("common.saveChanges") : t("forms.fuel.submit") }}
</button>
</div>
</form>
@@ -3,6 +3,7 @@ import { ref, computed } from "vue";
import { api } from "../api";
import { formatMoney } from "../lib/format.js";
import { applyAttachment } from "../lib/attachment.js";
import { t, tSplit } from "../i18n";
import AttachmentField from "./AttachmentField.vue";
import Modal from "./Modal.vue";
@@ -16,22 +17,8 @@ const isEdit = !!props.entry;
const saving = ref(false);
const error = ref("");
const TYPES = [
{ value: "repair", label: "Repair" },
{ value: "inspection", label: "Inspection" },
{ value: "bodywork", label: "Bodywork" },
{ value: "tyres", label: "Tyres" },
{ value: "diagnostics", label: "Diagnostics" },
{ value: "recall", label: "Recall" },
{ value: "warranty", label: "Warranty work" },
{ value: "other", label: "Other" },
];
const STATUSES = [
{ value: "scheduled", label: "Scheduled" },
{ value: "in_progress", label: "In progress" },
{ value: "completed", label: "Completed" },
];
const TYPE_VALUES = ["repair", "inspection", "bodywork", "tyres", "diagnostics", "recall", "warranty", "other"];
const STATUS_VALUES = ["scheduled", "in_progress", "completed"];
const form = ref({
date: props.entry ? toDateInput(props.entry.date) : new Date().toISOString().slice(0, 10),
@@ -101,93 +88,94 @@ function payload() {
</script>
<template>
<Modal :title="isEdit ? 'Edit workshop visit' : 'Log workshop visit'" @close="emit('close')">
<Modal :title="isEdit ? t('forms.maintenance.editTitle') : t('forms.maintenance.addTitle')" @close="emit('close')">
<p v-if="error" class="mb-3 rounded-control bg-danger-soft px-3 py-2 text-sm font-medium text-danger">{{ error }}</p>
<form class="space-y-3" @submit.prevent="submit">
<div class="grid grid-cols-2 gap-2">
<div>
<label class="dh-label">Date *</label>
<label class="dh-label">{{ t("forms.maintenance.date") }}</label>
<input v-model="form.date" type="date" required class="dh-input data" />
</div>
<div>
<label class="dh-label">Odometer (km)</label>
<label class="dh-label">{{ t("forms.maintenance.odometer") }}</label>
<input v-model="form.km" type="number" min="0" placeholder="16138" class="dh-input data" />
</div>
</div>
<div class="grid grid-cols-2 gap-2">
<div>
<label class="dh-label">Type</label>
<label class="dh-label">{{ t("forms.maintenance.type") }}</label>
<select v-model="form.type" class="dh-input">
<option v-for="t in TYPES" :key="t.value" :value="t.value">{{ t.label }}</option>
<option v-for="v in TYPE_VALUES" :key="v" :value="v">{{ t(`enums.maintenanceType.${v}`) }}</option>
</select>
</div>
<div>
<label class="dh-label">Status</label>
<label class="dh-label">{{ t("forms.maintenance.status") }}</label>
<select v-model="form.status" class="dh-input">
<option v-for="s in STATUSES" :key="s.value" :value="s.value">{{ s.label }}</option>
<option v-for="v in STATUS_VALUES" :key="v" :value="v">{{ t(`enums.maintenanceStatus.${v}`) }}</option>
</select>
</div>
</div>
<div>
<label class="dh-label">What was done *</label>
<input v-model="form.description" required placeholder="Replaced alternator and drive belt" class="dh-input" />
<label class="dh-label">{{ t("forms.maintenance.description") }}</label>
<input v-model="form.description" required :placeholder="t('forms.maintenance.descriptionPlaceholder')" class="dh-input" />
</div>
<div class="grid grid-cols-2 gap-2">
<div>
<label class="dh-label">Workshop</label>
<label class="dh-label">{{ t("forms.maintenance.workshop") }}</label>
<input v-model="form.workshop" placeholder="Auto Serwis Kowalski" class="dh-input" />
</div>
<div>
<label class="dh-label">Location</label>
<label class="dh-label">{{ t("forms.maintenance.location") }}</label>
<input v-model="form.location" placeholder="Kraków" class="dh-input" />
</div>
</div>
<div>
<label class="dh-label">Parts replaced</label>
<input v-model="form.partsUsed" placeholder="Alternator 27060-0T010, belt 90916-02660" class="dh-input" />
<label class="dh-label">{{ t("forms.maintenance.partsUsed") }}</label>
<input v-model="form.partsUsed" :placeholder="t('forms.maintenance.partsUsedPlaceholder')" class="dh-input" />
</div>
<div class="grid grid-cols-2 gap-2">
<div>
<label class="dh-label">Labour cost</label>
<label class="dh-label">{{ t("forms.maintenance.laborCost") }}</label>
<input v-model="form.laborCost" type="number" step="0.01" min="0" class="dh-input data" />
</div>
<div>
<label class="dh-label">Parts cost</label>
<label class="dh-label">{{ t("forms.maintenance.partsCost") }}</label>
<input v-model="form.partsCost" type="number" step="0.01" min="0" class="dh-input data" />
</div>
</div>
<p v-if="totalCost" class="text-xs text-muted">
Total: <span class="data text-strong">{{ totalCost }}</span>
{{ tSplit("forms.maintenance.total", "total").before
}}<span class="data text-strong">{{ totalCost }}</span>{{ tSplit("forms.maintenance.total", "total").after }}
</p>
<div class="grid grid-cols-2 gap-2">
<div>
<label class="dh-label">Invoice number</label>
<label class="dh-label">{{ t("forms.maintenance.invoiceNumber") }}</label>
<input v-model="form.invoiceNumber" class="dh-input data" />
</div>
<div>
<label class="dh-label">Warranty until</label>
<label class="dh-label">{{ t("forms.maintenance.warrantyUntil") }}</label>
<input v-model="form.warrantyUntil" type="date" class="dh-input data" />
</div>
</div>
<AttachmentField v-model:file="file" v-model:remove="removeFile" :record="entry" legend="Invoice" />
<AttachmentField v-model:file="file" v-model:remove="removeFile" :record="entry" :legend="t('forms.maintenance.attachmentLegend')" />
<div>
<label class="dh-label">Notes</label>
<label class="dh-label">{{ t("forms.maintenance.notes") }}</label>
<input v-model="form.notes" class="dh-input" />
</div>
<div class="mt-4 flex justify-end gap-2">
<button type="button" class="dh-btn dh-btn-ghost" @click="emit('close')">Cancel</button>
<button type="button" class="dh-btn dh-btn-ghost" @click="emit('close')">{{ t("common.cancel") }}</button>
<button type="submit" :disabled="saving" class="dh-btn dh-btn-primary">
{{ saving ? "Saving" : isEdit ? "Save changes" : "Log visit" }}
{{ saving ? t("common.saving") : isEdit ? t("common.saveChanges") : t("forms.maintenance.submit") }}
</button>
</div>
</form>
+10 -9
View File
@@ -2,6 +2,7 @@
import { ref } from "vue";
import { api } from "../api";
import { applyAttachment } from "../lib/attachment.js";
import { t } from "../i18n";
import AttachmentField from "./AttachmentField.vue";
import Modal from "./Modal.vue";
@@ -51,26 +52,26 @@ async function submit() {
</script>
<template>
<Modal :title="isEdit ? 'Edit part' : 'Add part'" @close="emit('close')">
<Modal :title="isEdit ? t('forms.part.editTitle') : t('forms.part.addTitle')" @close="emit('close')">
<p v-if="error" class="mb-3 rounded-control bg-danger-soft px-3 py-2 text-sm font-medium text-danger">{{ error }}</p>
<form class="space-y-3" @submit.prevent="submit">
<div>
<label class="dh-label">Part name *</label>
<input v-model="form.name" required placeholder="Oil Filter" class="dh-input" />
<label class="dh-label">{{ t("forms.part.name") }}</label>
<input v-model="form.name" required :placeholder="t('forms.part.namePlaceholder')" class="dh-input" />
</div>
<div>
<label class="dh-label">Part number</label>
<label class="dh-label">{{ t("forms.part.partNumber") }}</label>
<input v-model="form.partNumber" placeholder="04152-YZZA7" class="dh-input data" />
</div>
<div>
<label class="dh-label">Notes</label>
<input v-model="form.notes" placeholder="Fits 20152020 · buy in pairs" class="dh-input" />
<label class="dh-label">{{ t("forms.part.notes") }}</label>
<input v-model="form.notes" :placeholder="t('forms.part.notesPlaceholder')" class="dh-input" />
</div>
<AttachmentField v-model:file="file" v-model:remove="removeFile" :record="part" legend="Photo or spec sheet" />
<AttachmentField v-model:file="file" v-model:remove="removeFile" :record="part" :legend="t('forms.part.attachmentLegend')" />
<div class="mt-4 flex justify-end gap-2">
<button type="button" class="dh-btn dh-btn-ghost" @click="emit('close')">Cancel</button>
<button type="button" class="dh-btn dh-btn-ghost" @click="emit('close')">{{ t("common.cancel") }}</button>
<button type="submit" :disabled="saving" class="dh-btn dh-btn-primary">
{{ saving ? "Saving" : isEdit ? "Save changes" : "Add part" }}
{{ saving ? t("common.saving") : isEdit ? t("common.saveChanges") : t("forms.part.submit") }}
</button>
</div>
</form>
@@ -2,6 +2,7 @@
import { ref, computed } from "vue";
import { api } from "../api";
import { formatKm } from "../lib/format.js";
import { t } from "../i18n";
import Modal from "./Modal.vue";
const props = defineProps({
@@ -15,13 +16,7 @@ const isEdit = !!props.reminder;
const saving = ref(false);
const error = ref("");
const TYPES = [
{ value: "maintenance", label: "Maintenance" },
{ value: "document", label: "Document renewal" },
{ value: "service", label: "Service" },
{ value: "inspection", label: "Inspection" },
{ value: "other", label: "Other" },
];
const TYPE_VALUES = ["maintenance", "document", "service", "inspection", "other"];
const form = ref({
title: props.reminder?.title ?? "",
@@ -44,7 +39,7 @@ const isRecurring = computed(() => Number(form.value.repeatDays) > 0 || Number(f
async function submit() {
if (!hasTrigger.value) {
error.value = "Set a due date, a due odometer reading, or both.";
error.value = t("forms.reminder.noTrigger");
return;
}
saving.value = true;
@@ -78,70 +73,65 @@ function payload() {
</script>
<template>
<Modal :title="isEdit ? 'Edit reminder' : 'Add reminder'" @close="emit('close')">
<Modal :title="isEdit ? t('forms.reminder.editTitle') : t('forms.reminder.addTitle')" @close="emit('close')">
<p v-if="error" class="mb-3 rounded-control bg-danger-soft px-3 py-2 text-sm font-medium text-danger">{{ error }}</p>
<form class="space-y-3" @submit.prevent="submit">
<div>
<label class="dh-label">Title *</label>
<input v-model="form.title" required placeholder="Swap to winter tyres" class="dh-input" />
<label class="dh-label">{{ t("forms.reminder.title") }}</label>
<input v-model="form.title" required :placeholder="t('forms.reminder.titlePlaceholder')" class="dh-input" />
</div>
<div>
<label class="dh-label">Type</label>
<label class="dh-label">{{ t("forms.reminder.type") }}</label>
<select v-model="form.type" class="dh-input">
<option v-for="t in TYPES" :key="t.value" :value="t.value">{{ t.label }}</option>
<option v-for="v in TYPE_VALUES" :key="v" :value="v">{{ t(`enums.reminderType.${v}`) }}</option>
</select>
</div>
<fieldset class="rounded-control border border-subtle p-3">
<legend class="eyebrow px-1">Remind me</legend>
<legend class="eyebrow px-1">{{ t("forms.reminder.remindMe") }}</legend>
<div class="grid grid-cols-2 gap-2">
<div>
<label class="dh-label">On date</label>
<label class="dh-label">{{ t("forms.reminder.onDate") }}</label>
<input v-model="form.dueDate" type="date" class="dh-input data" />
</div>
<div>
<label class="dh-label">At odometer (km)</label>
<label class="dh-label">{{ t("forms.reminder.atOdometer") }}</label>
<input v-model="form.dueKm" type="number" min="0" placeholder="30000" class="dh-input data" />
</div>
</div>
<p class="mt-1.5 text-xs text-muted">
Set either or both with both, whichever comes first wins.
<span v-if="car?.currentKm"> The car is at {{ formatKm(car.currentKm) }} now.</span>
{{ t("forms.reminder.triggerHint") }}
<span v-if="car?.currentKm"> {{ t("forms.reminder.currentKm", { km: formatKm(car.currentKm) }) }}</span>
</p>
</fieldset>
<fieldset class="rounded-control border border-subtle p-3">
<legend class="eyebrow px-1">Repeat (optional)</legend>
<legend class="eyebrow px-1">{{ t("forms.reminder.repeat") }}</legend>
<div class="grid grid-cols-2 gap-2">
<div>
<label class="dh-label">Every days</label>
<label class="dh-label">{{ t("forms.reminder.everyDays") }}</label>
<input v-model="form.repeatDays" type="number" min="0" placeholder="365" class="dh-input data" />
</div>
<div>
<label class="dh-label">Every km</label>
<label class="dh-label">{{ t("forms.reminder.everyKm") }}</label>
<input v-model="form.repeatKm" type="number" min="0" placeholder="15000" class="dh-input data" />
</div>
</div>
<p class="mt-1.5 text-xs text-muted">
<template v-if="isRecurring">
Marking this done will roll it forward instead of closing it.
</template>
<template v-else>
Leave blank for a one-off reminder that closes when you mark it done.
</template>
{{ isRecurring ? t("forms.reminder.recurringHint") : t("forms.reminder.oneOffHint") }}
</p>
</fieldset>
<div>
<label class="dh-label">Notes</label>
<label class="dh-label">{{ t("forms.reminder.notes") }}</label>
<input v-model="form.notes" class="dh-input" />
</div>
<div class="mt-4 flex justify-end gap-2">
<button type="button" class="dh-btn dh-btn-ghost" @click="emit('close')">Cancel</button>
<button type="button" class="dh-btn dh-btn-ghost" @click="emit('close')">{{ t("common.cancel") }}</button>
<button type="submit" :disabled="saving || !hasTrigger" class="dh-btn dh-btn-primary">
{{ saving ? "Saving" : isEdit ? "Save changes" : "Add reminder" }}
{{ saving ? t("common.saving") : isEdit ? t("common.saveChanges") : t("forms.reminder.submit") }}
</button>
</div>
</form>
+13 -12
View File
@@ -3,6 +3,7 @@ import { ref } from "vue";
import { api } from "../api";
import { formatKm } from "../lib/format.js";
import { applyAttachment } from "../lib/attachment.js";
import { t } from "../i18n";
import AttachmentField from "./AttachmentField.vue";
import Modal from "./Modal.vue";
@@ -62,42 +63,42 @@ async function submit() {
</script>
<template>
<Modal :title="isEdit ? 'Edit service record' : 'Add service record'" @close="emit('close')">
<Modal :title="isEdit ? t('forms.service.editTitle') : t('forms.service.addTitle')" @close="emit('close')">
<p v-if="error" class="mb-3 rounded-control bg-danger-soft px-3 py-2 text-sm font-medium text-danger">{{ error }}</p>
<form class="space-y-3" @submit.prevent="submit">
<div class="grid grid-cols-2 gap-2">
<div>
<label class="dh-label">Date *</label>
<label class="dh-label">{{ t("forms.service.date") }}</label>
<input v-model="form.date" type="date" required class="dh-input data" />
</div>
<div>
<label class="dh-label">Odometer (km)</label>
<label class="dh-label">{{ t("forms.service.odometer") }}</label>
<input v-model="form.km" type="number" placeholder="16138" class="dh-input data" />
</div>
</div>
<fieldset class="rounded-control border border-subtle p-3">
<legend class="eyebrow px-1">Changed parts</legend>
<label class="flex items-center gap-2 py-1 text-sm text-body"><input type="checkbox" v-model="form.changedOil" class="accent-[var(--accent)]" /> Oil &amp; Oil filter</label>
<label class="flex items-center gap-2 py-1 text-sm text-body"><input type="checkbox" v-model="form.changedEngineAirFilter" class="accent-[var(--accent)]" /> Engine air filter</label>
<label class="flex items-center gap-2 py-1 text-sm text-body"><input type="checkbox" v-model="form.changedCabinAirFilter" class="accent-[var(--accent)]" /> Cabin air filter</label>
<legend class="eyebrow px-1">{{ t("forms.service.changedParts") }}</legend>
<label class="flex items-center gap-2 py-1 text-sm text-body"><input type="checkbox" v-model="form.changedOil" class="accent-[var(--accent)]" /> {{ t("forms.service.oil") }}</label>
<label class="flex items-center gap-2 py-1 text-sm text-body"><input type="checkbox" v-model="form.changedEngineAirFilter" class="accent-[var(--accent)]" /> {{ t("forms.service.engineFilter") }}</label>
<label class="flex items-center gap-2 py-1 text-sm text-body"><input type="checkbox" v-model="form.changedCabinAirFilter" class="accent-[var(--accent)]" /> {{ t("forms.service.cabinFilter") }}</label>
</fieldset>
<AttachmentField
v-model:file="file"
v-model:remove="removeFile"
:record="service"
legend="Receipt or service-book page"
:legend="t('forms.service.attachmentLegend')"
/>
<div>
<label class="dh-label">Notes</label>
<label class="dh-label">{{ t("forms.service.notes") }}</label>
<input v-model="form.notes" class="dh-input" />
</div>
<p v-if="car" class="text-xs text-muted">
Next service date (+{{ car.serviceIntervalDays }}d) and km (+{{ formatKm(car.serviceIntervalKm) }}) are computed automatically.
{{ t("forms.service.autoHint", { days: car.serviceIntervalDays, km: formatKm(car.serviceIntervalKm) }) }}
</p>
<div class="mt-4 flex justify-end gap-2">
<button type="button" class="dh-btn dh-btn-ghost" @click="emit('close')">Cancel</button>
<button type="button" class="dh-btn dh-btn-ghost" @click="emit('close')">{{ t("common.cancel") }}</button>
<button type="submit" :disabled="saving" class="dh-btn dh-btn-primary">
{{ saving ? "Saving" : isEdit ? "Save changes" : "Add service" }}
{{ saving ? t("common.saving") : isEdit ? t("common.saveChanges") : t("forms.service.submit") }}
</button>
</div>
</form>
+14 -16
View File
@@ -1,6 +1,7 @@
<script setup>
import { ref, onMounted } from "vue";
import { api } from "../api";
import { t } from "../i18n";
import Modal from "./Modal.vue";
const props = defineProps({ car: { type: Object, required: true } });
@@ -68,32 +69,29 @@ onMounted(load);
</script>
<template>
<Modal :title="`Share ${car.name}`" @close="emit('close')">
<p class="mb-4 text-sm text-muted">
Give another user access to this car. Read-only lets them view; read &amp; write also lets
them edit the car and its service records and parts.
</p>
<Modal :title="t('forms.share.title', { name: car.name })" @close="emit('close')">
<p class="mb-4 text-sm text-muted">{{ t("forms.share.body") }}</p>
<p v-if="error" class="mb-3 rounded-control bg-danger-soft px-3 py-2 text-sm font-medium text-danger">{{ error }}</p>
<!-- Add form -->
<form class="mb-4 flex items-end gap-2" @submit.prevent="addShare">
<div class="flex-1">
<label class="dh-label">User email</label>
<label class="dh-label">{{ t("forms.share.userEmail") }}</label>
<input v-model="email" type="email" required placeholder="person@example.com" class="dh-input" />
</div>
<select v-model="permission" class="dh-input w-auto">
<option value="read">Read-only</option>
<option value="write">Read &amp; write</option>
<option value="read">{{ t("forms.share.read") }}</option>
<option value="write">{{ t("forms.share.write") }}</option>
</select>
<button type="submit" :disabled="submitting" class="dh-btn dh-btn-primary">Share</button>
<button type="submit" :disabled="submitting" class="dh-btn dh-btn-primary">{{ t("forms.share.submit") }}</button>
</form>
<!-- Current shares -->
<div>
<h3 class="mb-2 text-sm font-semibold text-strong">People with access</h3>
<p v-if="loading" class="text-sm text-muted">Loading</p>
<p v-else-if="shares.length === 0" class="text-sm text-muted">Not shared with anyone yet.</p>
<h3 class="mb-2 text-sm font-semibold text-strong">{{ t("forms.share.peopleWithAccess") }}</h3>
<p v-if="loading" class="text-sm text-muted">{{ t("common.loading") }}</p>
<p v-else-if="shares.length === 0" class="text-sm text-muted">{{ t("forms.share.notShared") }}</p>
<ul v-else class="divide-y divide-subtle">
<li v-for="s in shares" :key="s.user.id" class="flex items-center justify-between gap-2 py-2">
<div class="min-w-0">
@@ -102,17 +100,17 @@ onMounted(load);
</div>
<div class="flex items-center gap-2">
<select :value="s.permission" @change="setPermission(s, $event.target.value)" class="dh-input w-auto !py-1 !text-xs">
<option value="read">Read-only</option>
<option value="write">Read &amp; write</option>
<option value="read">{{ t("forms.share.read") }}</option>
<option value="write">{{ t("forms.share.write") }}</option>
</select>
<button class="text-xs font-medium text-danger hover:underline" @click="removeShare(s)">Remove</button>
<button class="text-xs font-medium text-danger hover:underline" @click="removeShare(s)">{{ t("common.remove") }}</button>
</div>
</li>
</ul>
</div>
<div class="mt-6 flex justify-end">
<button type="button" class="dh-btn dh-btn-ghost" @click="emit('close')">Done</button>
<button type="button" class="dh-btn dh-btn-ghost" @click="emit('close')">{{ t("common.done") }}</button>
</div>
</Modal>
</template>
@@ -3,6 +3,7 @@ import { ref, computed } from "vue";
import { api } from "../api";
import { formatDate } from "../lib/format.js";
import { applyAttachment } from "../lib/attachment.js";
import { t, tSplit } from "../i18n";
import AttachmentField from "./AttachmentField.vue";
import Modal from "./Modal.vue";
@@ -75,44 +76,43 @@ async function submit() {
</script>
<template>
<Modal :title="isEdit ? 'Edit technical check' : 'Add technical check'" @close="emit('close')">
<Modal :title="isEdit ? t('forms.technical.editTitle') : t('forms.technical.addTitle')" @close="emit('close')">
<p v-if="error" class="mb-3 rounded-control bg-danger-soft px-3 py-2 text-sm font-medium text-danger">{{ error }}</p>
<form class="space-y-3" @submit.prevent="submit">
<div class="grid grid-cols-2 gap-2">
<div>
<label class="dh-label">Check date *</label>
<label class="dh-label">{{ t("forms.technical.date") }}</label>
<input v-model="form.date" type="date" required class="dh-input data" />
</div>
<div>
<label class="dh-label">Result *</label>
<label class="dh-label">{{ t("forms.technical.result") }}</label>
<select v-model="form.result" class="dh-input">
<option value="passed">Passed</option>
<option value="failed">Failed</option>
<option value="passed">{{ t("forms.technical.passed") }}</option>
<option value="failed">{{ t("forms.technical.failed") }}</option>
</select>
</div>
</div>
<div>
<label class="dh-label">Valid until</label>
<label class="dh-label">{{ t("forms.technical.validUntil") }}</label>
<input v-model="form.validUntil" type="date" class="dh-input data" />
<p v-if="form.result === 'failed'" class="mt-1 text-xs text-muted">
A failed check certifies nothing, so no next date is derived from it.
{{ t("forms.technical.failedHint") }}
</p>
<p v-else-if="derivedNext" class="mt-1 text-xs text-muted">
Leave blank to use the car's interval (+{{ derivedNext.days }}d
<span class="data">{{ derivedNext.date }}</span>). Enter the date on the certificate
when it differs.
{{ tSplit("forms.technical.derivedHint", "date", { days: derivedNext.days }).before
}}<span class="data">{{ derivedNext.date }}</span>{{ tSplit("forms.technical.derivedHint", "date", { days: derivedNext.days }).after }}
</p>
</div>
<div class="grid grid-cols-2 gap-2">
<div>
<label class="dh-label">Cost</label>
<label class="dh-label">{{ t("forms.technical.cost") }}</label>
<input v-model="form.cost" type="number" step="0.01" min="0" placeholder="99" class="dh-input data" />
</div>
<div>
<label class="dh-label">Station</label>
<input v-model="form.station" placeholder="Stacja Kontroli Pojazdów" class="dh-input" />
<label class="dh-label">{{ t("forms.technical.station") }}</label>
<input v-model="form.station" :placeholder="t('forms.technical.stationPlaceholder')" class="dh-input" />
</div>
</div>
@@ -120,18 +120,18 @@ async function submit() {
v-model:file="file"
v-model:remove="removeFile"
:record="check"
legend="Inspection certificate"
:legend="t('forms.technical.attachmentLegend')"
/>
<div>
<label class="dh-label">Notes</label>
<label class="dh-label">{{ t("forms.technical.notes") }}</label>
<input v-model="form.notes" class="dh-input" />
</div>
<div class="mt-4 flex justify-end gap-2">
<button type="button" class="dh-btn dh-btn-ghost" @click="emit('close')">Cancel</button>
<button type="button" class="dh-btn dh-btn-ghost" @click="emit('close')">{{ t("common.cancel") }}</button>
<button type="submit" :disabled="saving" class="dh-btn dh-btn-primary">
{{ saving ? "Saving" : isEdit ? "Save changes" : "Add check" }}
{{ saving ? t("common.saving") : isEdit ? t("common.saveChanges") : t("forms.technical.submit") }}
</button>
</div>
</form>
+628
View File
@@ -0,0 +1,628 @@
{
"errors": {
"sessionExpired": "Sessionen er udløbet — log ind igen."
},
"common": {
"cancel": "Annuller",
"save": "Gem",
"saveChanges": "Gem ændringer",
"saving": "Gemmer…",
"saved": "Gemt ✓",
"loading": "Indlæser…",
"edit": "Rediger",
"remove": "Fjern",
"delete": "Slet",
"done": "Færdig",
"undo": "Fortryd",
"download": "Download",
"empty": "—",
"yes": "Ja",
"no": "Nej"
},
"nav": {
"garage": "Garage",
"settings": "Indstillinger",
"users": "Brugere",
"lightMode": "Lys tilstand",
"darkMode": "Mørk tilstand",
"signedIn": "Logget ind",
"logOut": "Log ud"
},
"login": {
"title": "Log ind",
"tagline": "Styr på din bil.",
"email": "E-mail",
"password": "Adgangskode",
"showPassword": "Vis adgangskode",
"hidePassword": "Skjul adgangskode",
"submit": "Log ind",
"submitting": "Logger ind…",
"failed": "Login mislykkedes",
"serverSettings": "Serverindstillinger",
"apiServerUrl": "API-serverens adresse",
"leaveBlank": "Lad feltet stå tomt for at bruge standarden ({url}).",
"resetToDefault": "Nulstil til standard"
},
"dashboard": {
"eyebrow": "Garage",
"title": "Dine biler",
"subtitle": "Serviceoverblik og servicehistorik.",
"addCar": "Tilføj bil",
"empty": "Ingen biler endnu. Klik på {action} for at komme i gang.",
"shared": "Delt",
"sharedReadOnly": "Delt · skrivebeskyttet",
"serviceLife": "Serviceinterval brugt",
"lastService": "Seneste service",
"odometer": "Kilometerstand",
"nextDue": "Næste service",
"nextDueKm": "Næste service (km)",
"serviceRecords": {
"one": "{n} servicepost",
"other": "{n} serviceposter"
}
},
"admin": {
"eyebrow": "Administration",
"title": "Brugere",
"subtitleAll": "Konti på tværs af alle organisationer.",
"subtitleOrg": "Konti i din organisation.",
"subtitleOrgsNote": "Organisationer tildeles i API-panelet.",
"addUser": "Tilføj bruger",
"colEmail": "E-mail",
"colName": "Navn",
"colOrganization": "Organisation",
"colRole": "Rolle",
"colCreated": "Oprettet",
"you": "(dig)",
"resetPassword": "Nulstil adgangskode",
"confirmDelete": "Slet {name}? Dette kan ikke fortrydes.",
"cantDeleteSelf": "Du kan ikke slette din egen konto.",
"onlySuperadminDeletes": "Kun en superadministrator kan slette en superadministrator.",
"cantChangeOwnRole": "Du kan ikke ændre din egen rolle.",
"onlySuperadminEdits": "Kun en superadministrator kan redigere en superadministrator.",
"createTitle": "Tilføj en bruger",
"emailRequired": "E-mail *",
"passwordRequired": "Adgangskode *",
"minChars": "(mindst 8)",
"creating": "Opretter…",
"createUser": "Opret bruger",
"resetTitle": "Nulstil adgangskode — {email}",
"newPassword": "Ny adgangskode",
"setPassword": "Angiv adgangskode",
"roles": {
"user": "bruger",
"admin": "administrator",
"superadmin": "superadministrator"
}
},
"settings": {
"eyebrow": "Konto",
"title": "Indstillinger",
"subtitle": "Administrer din konto, udseende og dine data.",
"account": {
"title": "Konto",
"name": "Navn",
"email": "E-mail",
"verified": "Bekræftet",
"notVerified": "Ikke bekræftet",
"resendVerification": "Send bekræftelsesmail igen",
"sending": "Sender…",
"verificationRequested": "Bekræftelsesmail anmodet.",
"changePassword": "Skift adgangskode",
"currentPassword": "Nuværende adgangskode",
"newPassword": "Ny adgangskode",
"confirmNewPassword": "Bekræft ny adgangskode",
"mismatchYet": "Adgangskoderne stemmer ikke overens endnu.",
"tooShort": "Den nye adgangskode skal være på mindst 8 tegn.",
"mismatch": "Den nye adgangskode og bekræftelsen stemmer ikke overens.",
"updating": "Opdaterer…",
"passwordUpdated": "Adgangskode opdateret ✓",
"updatePassword": "Opdater adgangskode"
},
"appearance": {
"title": "Udseende",
"theme": "Tema",
"themeLight": "lyst",
"themeDark": "mørkt",
"themeSystem": "system",
"language": "Sprog",
"languageHint": "Appens tekst samt navne på måneder og dage.",
"languageFallbackHint": "Dette sprog er endnu ikke oversat — appens tekst forbliver på engelsk.",
"region": "Region",
"regionHint": "Tal- og valutaformat.",
"dateFormat": "Datoformat",
"dateExample": "Eksempel: {example}",
"currency": "Valuta",
"currencyExample": "Eksempel: {example} — kun visning, ingen beløb omregnes.",
"fontSize": "Skriftstørrelse",
"fontSmall": "lille",
"fontMedium": "mellem",
"fontLarge": "stor"
},
"profile": {
"title": "Profil",
"avatarAlt": "Profilbillede",
"uploading": "Uploader…",
"uploadPhoto": "Upload billede",
"bio": "Om mig",
"bioPlaceholder": "En kort note, som andre i din husstand kan se.",
"saveBio": "Gem beskrivelse"
},
"privacy": {
"title": "Privatliv og sikkerhed",
"signOut": "Log ud",
"body": "Tofaktorgodkendelse er ikke tilgængelig endnu. Sessioner bygger på tokens udstedt af serveren, som udløber af sig selv, så at logge ud her afslutter kun sessionen på denne enhed — der er ingen liste over enheder at tilbagekalde fra. For at logge alle enheder ud skal du skifte din adgangskode ovenfor."
},
"advanced": {
"title": "Avanceret",
"exportTitle": "Eksportér dine data",
"exportBody": "Download din profil samt alle biler, serviceposter og reservedele som JSON.",
"preparing": "Forbereder…",
"exportAction": "Eksportér data",
"importTitle": "Importér dine data",
"importBody": "Tilføj biler fra en tidligere eksporteret JSON-fil. Dette opretter nye poster — intet flettes eller overskrives.",
"importing": "Importerer…",
"importAction": "Importér data",
"notJson": "Filen er ikke gyldig JSON.",
"notExport": "Filen ligner ikke en DriverVault-eksport (der mangler en \"cars\"-liste).",
"confirmImport": "Importér {count} bil(er) fra denne fil? Dette tilføjer nye poster — eksisterende biler flettes eller overskrives ikke.",
"imported": "Importerede {cars} bil(er), {services} servicepost(er), {parts} reservedel(e)."
},
"danger": {
"title": "Farezone",
"body": "Sletning af din konto fjerner dit login og din profil. Det sletter ikke husstandens delte biler eller servicehistorik. Der er 3 dages betænkningstid, før sletningen er endelig, og du kan annullere når som helst inden da.",
"deleteAccount": "Slet min konto",
"typeToConfirm": "Skriv {email} for at bekræfte",
"requesting": "Anmoder…",
"requestDeletion": "Anmod om sletning",
"requestedOn": "Sletning af konto anmodet den {date}.",
"canStillCancel": "Du kan stadig annullere — det bliver permanent efter 3 dages betænkningstid.",
"cooldownPassed": "Betænkningstiden er udløbet. Du kan nu gennemføre sletningen.",
"cancelRequest": "Annuller anmodning om sletning",
"finalize": "Slet min konto permanent",
"confirmFinalize": "Dette sletter din konto permanent. Det kan ikke fortrydes. Fortsæt?"
}
},
"car": {
"allCars": "← Alle biler",
"share": "Del",
"shared": "Delt",
"sharedReadOnly": "Delt · skrivebeskyttet",
"tabs": {
"info": "Oplysninger",
"services": "Servicehistorik",
"technical": "Synshistorik",
"maintenance": "Værksted",
"fuel": "Brændstof",
"documents": "Dokumenter",
"parts": "Reservedelskatalog",
"reminders": "Påmindelser"
},
"info": {
"oilSpec": "Motorolie-specifikation",
"transmissionOil": "Gearolie",
"differentialOil": "Differentialeolie",
"brakeFluid": "Bremsevæske",
"coolant": "Kølervæske",
"odometer": "Kilometerstand",
"serviceInterval": "Serviceinterval",
"nextDue": "Næste service",
"registrationPlate": "Nummerplade",
"registrationCountry": "Registreringsland",
"vin": "Stelnummer",
"fuelType": "Brændstoftype",
"buildDate": "Produktionsdato",
"firstRegistration": "Første registrering"
},
"services": {
"title": "Servicehistorik",
"add": "Tilføj service",
"empty": "Ingen serviceposter endnu.",
"colDate": "Dato",
"colKm": "Km",
"colNextDate": "Næste dato",
"colNextKm": "Næste km",
"colOil": "Olie og oliefilter",
"colEngineFilter": "Luftfilter",
"colCabinFilter": "Kabinefilter",
"colNotes": "Noter",
"colFile": "Fil",
"confirmDelete": "Slet denne servicepost?"
},
"technical": {
"title": "Synshistorik",
"subtitle": "Lovpligtige syn. Gentages alene efter tid, uanset hvad kilometerstanden viser.",
"add": "Tilføj syn",
"empty": "Ingen syn endnu.",
"colDate": "Dato",
"colResult": "Resultat",
"colNextCheck": "Næste syn",
"colStatus": "Status",
"colStation": "Synssted",
"colCost": "Pris",
"colNotes": "Noter",
"colFile": "Fil",
"passed": "Godkendt",
"failed": "Ikke godkendt",
"confirmDelete": "Slet dette syn?"
},
"maintenance": {
"title": "Værksted",
"subtitle": "Værkstedsbesøg og reparationer. Almindelig service hører under Servicehistorik.",
"add": "Registrér besøg",
"empty": "Ingen værkstedsbesøg registreret endnu.",
"colDate": "Dato",
"colKm": "Km",
"colType": "Type",
"colWork": "Udført arbejde",
"colWorkshop": "Værksted",
"colStatus": "Status",
"colCost": "Pris",
"colFile": "Fil",
"underWarranty": "Under garanti · {days} dage tilbage",
"confirmDelete": "Slet dette værkstedsbesøg?"
},
"fuel": {
"title": "Brændstof",
"subtitle": "Forbruget måles mellem fulde tanke.",
"add": "Registrér tankning",
"empty": "Ingen tankninger registreret endnu.",
"average": "Gennemsnit",
"best": "Bedste",
"worst": "Værste",
"costPerKm": "Pris pr. km",
"refills": "Tankninger",
"totalLiters": "Liter i alt",
"totalSpent": "Brugt i alt",
"trackedDistance": "Målt distance",
"avgPrice": "Gns. pris {price}/L",
"needTwoTanks": "Registrér mindst to fulde tanke for at se forbrugstal.",
"colDate": "Dato",
"colKm": "Km",
"colLiters": "Liter",
"colCost": "Pris",
"colPerLiter": "Pr. liter",
"colDistance": "Distance",
"colConsumption": "Forbrug",
"colStation": "Tankstation",
"colFile": "Fil",
"partial": "delvis",
"gap": "hul",
"confirmDelete": "Slet denne tankning?"
},
"documents": {
"title": "Dokumenter",
"subtitle": "Forsikring, miljøattester og andre papirer med fornyelsesdatoer.",
"add": "Tilføj dokument",
"empty": "Ingen dokumenter endnu.",
"colType": "Type",
"colTitle": "Titel",
"colProvider": "Udbyder",
"colIssued": "Udstedt",
"colRenewal": "Fornyelse",
"colStatus": "Status",
"colFile": "Fil",
"confirmDelete": "Slet dette dokument?"
},
"reminders": {
"title": "Påmindelser",
"subtitle": "Påmindelser om fornyelse og service tilføjes automatisk ud fra dine dokumenter og din servicehistorik.",
"add": "Tilføj påmindelse",
"empty": "Intet at blive mindet om endnu.",
"automatic": "Automatisk",
"repeats": "Gentages",
"at": "ved {km}",
"doneRollForward": "Færdig · flyt frem",
"markDone": "Markér som færdig",
"reopen": "Genåbn",
"confirmDelete": "Slet denne påmindelse?"
},
"parts": {
"title": "Reservedelskatalog",
"add": "Tilføj reservedel",
"empty": "Ingen reservedele endnu.",
"colPart": "Reservedel",
"colPartNumber": "Varenummer",
"colNotes": "Noter",
"colFile": "Fil",
"confirmDelete": "Slet denne reservedel?"
},
"delete": {
"title": "Slet denne bil?",
"body": "Dette sletter {name} permanent sammen med alt, der er registreret på den — {services}, {maintenance}, {fuel}, {documents} og {parts}. Det kan ikke fortrydes.",
"services": {
"one": "{n} servicepost",
"other": "{n} serviceposter"
},
"maintenance": {
"one": "{n} værkstedsbesøg",
"other": "{n} værkstedsbesøg"
},
"fuel": {
"one": "{n} tankning",
"other": "{n} tankninger"
},
"documents": {
"one": "{n} dokument",
"other": "{n} dokumenter"
},
"parts": {
"one": "{n} reservedel",
"other": "{n} reservedele"
},
"typeToConfirm": "Skriv {name} for at bekræfte",
"deleting": "Sletter…",
"confirm": "Slet permanent"
}
},
"attachment": {
"legend": "Vedhæftet fil",
"hint": "PDF eller billede, op til 10 MB.",
"attached": "Vedhæftet: {name}",
"willBeRemoved": "Den vedhæftede fil fjernes, når der gemmes."
},
"forms": {
"car": {
"addTitle": "Tilføj en bil",
"editTitle": "Rediger bil",
"name": "Navn *",
"make": "Mærke",
"model": "Model",
"year": "Årgang",
"registration": "Nummerplade",
"registrationCountry": "Registreringsland",
"registrationCountryPlaceholder": "Danmark",
"vin": "Stelnummer",
"vinPlaceholder": "Køretøjets stelnummer",
"fuelType": "Brændstoftype",
"buildDate": "Produktionsdato",
"firstRegistration": "Første registrering",
"oilSpec": "Motorolie-specifikation",
"currentKm": "Nuværende kilometerstand (km)",
"transmissionOilSpec": "Gearolie-specifikation",
"differentialOilSpec": "Differentialeolie-specifikation",
"brakeFluidSpec": "Bremsevæske-specifikation",
"coolantSpec": "Kølervæske-specifikation",
"serviceIntervalDays": "Serviceinterval (dage)",
"serviceIntervalKm": "Serviceinterval (km)",
"technicalCheckIntervalDays": "Synsinterval (dage)",
"technicalCheckHint": "Udfylder på forhånd hvert syns næste forfaldsdato. Ethvert syn kan tilsidesætte den med datoen på attesten.",
"submit": "Tilføj bil"
},
"service": {
"addTitle": "Tilføj servicepost",
"editTitle": "Rediger servicepost",
"date": "Dato *",
"odometer": "Kilometerstand (km)",
"changedParts": "Udskiftede dele",
"oil": "Olie og oliefilter",
"engineFilter": "Luftfilter",
"cabinFilter": "Kabinefilter",
"attachmentLegend": "Kvittering eller side fra servicebogen",
"notes": "Noter",
"autoHint": "Næste servicedato (+{days} dage) og km (+{km}) beregnes automatisk.",
"submit": "Tilføj service"
},
"technical": {
"addTitle": "Tilføj syn",
"editTitle": "Rediger syn",
"date": "Synsdato *",
"result": "Resultat *",
"passed": "Godkendt",
"failed": "Ikke godkendt",
"validUntil": "Gyldig til",
"failedHint": "Et ikke-godkendt syn attesterer ingenting, så der udledes ingen næste dato af det.",
"derivedHint": "Lad feltet stå tomt for at bruge bilens interval (+{days} dage → {date}). Indtast datoen på attesten, hvis den afviger.",
"cost": "Pris",
"station": "Synssted",
"stationPlaceholder": "Synshal",
"attachmentLegend": "Synsattest",
"notes": "Noter",
"submit": "Tilføj syn"
},
"part": {
"addTitle": "Tilføj reservedel",
"editTitle": "Rediger reservedel",
"name": "Reservedelens navn *",
"namePlaceholder": "Oliefilter",
"partNumber": "Varenummer",
"notes": "Noter",
"notesPlaceholder": "Passer til 20152020 · køb parvis",
"attachmentLegend": "Billede eller datablad",
"submit": "Tilføj reservedel"
},
"fuel": {
"addTitle": "Registrér tankning",
"editTitle": "Rediger tankning",
"date": "Dato *",
"odometer": "Kilometerstand (km) *",
"liters": "Liter *",
"cost": "Samlet pris",
"pricePerLiter": "Pris pr. liter: {price}",
"tank": "Tank",
"fullTank": "Fyldt helt op",
"missedFill": "Jeg glemte at registrere en tankning før denne",
"tankHint": "Forbruget måles mellem fulde tanke, så delvise tankninger tæller med i den næste fulde. At markere en glemt tankning holder den strækning ude af tallene i stedet for at vise et urealistisk lavt forbrug.",
"station": "Tankstation",
"notes": "Noter",
"attachmentLegend": "Kvittering",
"submit": "Registrér tankning"
},
"maintenance": {
"addTitle": "Registrér værkstedsbesøg",
"editTitle": "Rediger værkstedsbesøg",
"date": "Dato *",
"odometer": "Kilometerstand (km)",
"type": "Type",
"status": "Status",
"description": "Hvad blev der lavet *",
"descriptionPlaceholder": "Udskiftede generator og drivrem",
"workshop": "Værksted",
"location": "Sted",
"partsUsed": "Udskiftede dele",
"partsUsedPlaceholder": "Generator 27060-0T010, rem 90916-02660",
"laborCost": "Arbejdsløn",
"partsCost": "Pris for dele",
"total": "I alt: {total}",
"invoiceNumber": "Fakturanummer",
"warrantyUntil": "Garanti til",
"attachmentLegend": "Faktura",
"notes": "Noter",
"submit": "Registrér besøg"
},
"document": {
"addTitle": "Tilføj dokument",
"editTitle": "Rediger dokument",
"type": "Type",
"title": "Titel *",
"titlePlaceholder": "Ansvarsforsikring 2026",
"provider": "Udbyder",
"reference": "Police- / attestnummer",
"issued": "Udstedt",
"renewalDate": "Fornyelsesdato",
"renewalHint": "Lad fornyelsesdatoen stå tom for et dokument, der aldrig udløber. Angives den, tilføjes der automatisk en påmindelse.",
"cost": "Pris",
"attachmentLegend": "Scan eller billede",
"notes": "Noter",
"submit": "Tilføj dokument"
},
"reminder": {
"addTitle": "Tilføj påmindelse",
"editTitle": "Rediger påmindelse",
"title": "Titel *",
"titlePlaceholder": "Skift til vinterdæk",
"type": "Type",
"remindMe": "Mind mig om",
"onDate": "På dato",
"atOdometer": "Ved kilometerstand (km)",
"triggerHint": "Angiv det ene eller begge — med begge gælder det, der indtræffer først.",
"currentKm": "Bilen står på {km} nu.",
"repeat": "Gentagelse (valgfrit)",
"everyDays": "Hver … dage",
"everyKm": "Hver … km",
"recurringHint": "Markeres den som færdig, flyttes den frem i stedet for at blive lukket.",
"oneOffHint": "Lad feltet stå tomt for en engangspåmindelse, der lukkes, når du markerer den som færdig.",
"notes": "Noter",
"noTrigger": "Angiv en forfaldsdato, en kilometerstand eller begge.",
"submit": "Tilføj påmindelse"
},
"share": {
"title": "Del {name}",
"body": "Giv en anden bruger adgang til denne bil. Skrivebeskyttet giver adgang til at se; læs og skriv giver også adgang til at redigere bilen samt dens serviceposter og reservedele.",
"userEmail": "Brugerens e-mail",
"read": "Skrivebeskyttet",
"write": "Læs og skriv",
"submit": "Del",
"peopleWithAccess": "Personer med adgang",
"notShared": "Endnu ikke delt med nogen."
}
},
"enums": {
"fuelType": {
"petrol": "Benzin",
"petrol_lpg": "Benzin + LPG",
"diesel": "Diesel",
"diesel_lpg": "Diesel + LPG",
"hybrid": "Hybrid",
"electric": "El",
"hydrogen": "Brint"
},
"maintenanceType": {
"repair": "Reparation",
"inspection": "Eftersyn",
"bodywork": "Karrosseri",
"tyres": "Dæk",
"diagnostics": "Fejlsøgning",
"recall": "Tilbagekaldelse",
"warranty": "Garantiarbejde",
"other": "Andet"
},
"maintenanceStatus": {
"scheduled": "Planlagt",
"in_progress": "I gang",
"completed": "Fuldført"
},
"documentType": {
"insurance": "Forsikring",
"pollution": "Miljøattest",
"registration": "Registreringsattest",
"inspection": "Eftersyn",
"roadTax": "Vægtafgift",
"warranty": "Garanti",
"other": "Andet"
},
"reminderTypeShort": {
"maintenance": "Vedligehold",
"document": "Dokument",
"service": "Service",
"inspection": "Eftersyn",
"other": "Andet"
},
"reminderType": {
"maintenance": "Vedligehold",
"document": "Fornyelse af dokument",
"service": "Service",
"inspection": "Eftersyn",
"other": "Andet"
}
},
"status": {
"noData": "Ingen data",
"serviceOverdueDays": "Service overskredet med {days} d",
"dueInDays": "Forfalder om {days} d",
"okDays": "OK · {days} d",
"noKm": "Ingen km",
"serviceOverdueKm": "Service overskredet med {km} km",
"inKm": "Om {km} km",
"kmLeft": "{km} km tilbage",
"expiredAgo": "Udløb for {days} d siden",
"expiresToday": "Udløber i dag",
"renewInDays": "Forny om {days} d",
"validDays": "Gyldig · {days} d",
"noExpiry": "Udløber ikke",
"done": "Færdig",
"noTrigger": "Ingen udløser",
"overdue": "Overskredet",
"overdueBy": "Overskredet {parts}",
"dueIn": "Forfalder om {parts}",
"upcoming": "Kommende",
"today": "i dag",
"days": "{days} d",
"km": "{km} km"
}
}
+628
View File
@@ -0,0 +1,628 @@
{
"errors": {
"sessionExpired": "Session expired — please log in again."
},
"common": {
"cancel": "Cancel",
"save": "Save",
"saveChanges": "Save changes",
"saving": "Saving…",
"saved": "Saved ✓",
"loading": "Loading…",
"edit": "Edit",
"remove": "Remove",
"delete": "Delete",
"done": "Done",
"undo": "Undo",
"download": "Download",
"empty": "—",
"yes": "Yes",
"no": "No"
},
"nav": {
"garage": "Garage",
"settings": "Settings",
"users": "Users",
"lightMode": "Light mode",
"darkMode": "Dark mode",
"signedIn": "Signed in",
"logOut": "Log out"
},
"login": {
"title": "Sign in",
"tagline": "Your car, on track.",
"email": "Email",
"password": "Password",
"showPassword": "Show password",
"hidePassword": "Hide password",
"submit": "Sign in",
"submitting": "Signing in…",
"failed": "Login failed",
"serverSettings": "Server settings",
"apiServerUrl": "API server URL",
"leaveBlank": "Leave blank to use the default ({url}).",
"resetToDefault": "Reset to default"
},
"dashboard": {
"eyebrow": "Garage",
"title": "Your cars",
"subtitle": "Maintenance overview and service history.",
"addCar": "Add car",
"empty": "No cars yet. Click {action} to get started.",
"shared": "Shared",
"sharedReadOnly": "Shared · read-only",
"serviceLife": "Service life",
"lastService": "Last service",
"odometer": "Odometer",
"nextDue": "Next due",
"nextDueKm": "Next due km",
"serviceRecords": {
"one": "{n} service record",
"other": "{n} service records"
}
},
"admin": {
"eyebrow": "Admin",
"title": "Users",
"subtitleAll": "Accounts across every organization.",
"subtitleOrg": "Accounts in your organization.",
"subtitleOrgsNote": "Organizations are assigned in the API panel.",
"addUser": "Add user",
"colEmail": "Email",
"colName": "Name",
"colOrganization": "Organization",
"colRole": "Role",
"colCreated": "Created",
"you": "(you)",
"resetPassword": "Reset password",
"confirmDelete": "Delete {name}? This cannot be undone.",
"cantDeleteSelf": "You can't delete your own account.",
"onlySuperadminDeletes": "Only a superadmin can delete a superadmin.",
"cantChangeOwnRole": "You can't change your own role.",
"onlySuperadminEdits": "Only a superadmin can edit a superadmin.",
"createTitle": "Add a user",
"emailRequired": "Email *",
"passwordRequired": "Password *",
"minChars": "(min 8)",
"creating": "Creating…",
"createUser": "Create user",
"resetTitle": "Reset password — {email}",
"newPassword": "New password",
"setPassword": "Set password",
"roles": {
"user": "user",
"admin": "admin",
"superadmin": "superadmin"
}
},
"settings": {
"eyebrow": "Account",
"title": "Settings",
"subtitle": "Manage your account, appearance, and data.",
"account": {
"title": "Account",
"name": "Name",
"email": "Email",
"verified": "Verified",
"notVerified": "Not verified",
"resendVerification": "Resend verification email",
"sending": "Sending…",
"verificationRequested": "Verification email requested.",
"changePassword": "Change password",
"currentPassword": "Current password",
"newPassword": "New password",
"confirmNewPassword": "Confirm new password",
"mismatchYet": "Passwords don't match yet.",
"tooShort": "New password must be at least 8 characters.",
"mismatch": "New password and confirmation don't match.",
"updating": "Updating…",
"passwordUpdated": "Password updated ✓",
"updatePassword": "Update password"
},
"appearance": {
"title": "Appearance",
"theme": "Theme",
"themeLight": "light",
"themeDark": "dark",
"themeSystem": "system",
"language": "Language",
"languageHint": "App text, and the names of months and days.",
"languageFallbackHint": "This language isn't translated yet — the app text stays in English.",
"region": "Region",
"regionHint": "Number and currency layout.",
"dateFormat": "Date format",
"dateExample": "Example: {example}",
"currency": "Currency",
"currencyExample": "Example: {example} — display only, no amounts are converted.",
"fontSize": "Font size",
"fontSmall": "small",
"fontMedium": "medium",
"fontLarge": "large"
},
"profile": {
"title": "Profile",
"avatarAlt": "Avatar",
"uploading": "Uploading…",
"uploadPhoto": "Upload photo",
"bio": "Bio",
"bioPlaceholder": "A short note visible to other people in your household.",
"saveBio": "Save bio"
},
"privacy": {
"title": "Privacy & security",
"signOut": "Sign out",
"body": "Two-factor authentication isn't available yet. Sessions are held as server-issued tokens that expire on their own, so signing out here ends this device's session only — there's no per-device list to revoke from. To lock out every device, change your password above."
},
"advanced": {
"title": "Advanced",
"exportTitle": "Export your data",
"exportBody": "Download your profile and all cars, service records, and parts as JSON.",
"preparing": "Preparing…",
"exportAction": "Export data",
"importTitle": "Import your data",
"importBody": "Add cars from a previously exported JSON file. This creates new records — it doesn't merge with or overwrite anything existing.",
"importing": "Importing…",
"importAction": "Import data",
"notJson": "That file isn't valid JSON.",
"notExport": "That file doesn't look like a DriverVault export (missing a \"cars\" list).",
"confirmImport": "Import {count} car(s) from this file? This adds new records — it does not merge with or overwrite existing cars.",
"imported": "Imported {cars} car(s), {services} service record(s), {parts} part(s)."
},
"danger": {
"title": "Danger zone",
"body": "Deleting your account removes your login and profile. It does not delete your household's shared cars or service history. There's a 3-day cooldown before the deletion is final, and you can cancel any time before then.",
"deleteAccount": "Delete my account",
"typeToConfirm": "Type {email} to confirm",
"requesting": "Requesting…",
"requestDeletion": "Request deletion",
"requestedOn": "Account deletion requested on {date}.",
"canStillCancel": "You can still cancel — it becomes permanent after the 3-day cooldown.",
"cooldownPassed": "The cooldown has passed. You can now finalize the deletion.",
"cancelRequest": "Cancel deletion request",
"finalize": "Permanently delete my account",
"confirmFinalize": "This permanently deletes your account. This cannot be undone. Continue?"
}
},
"car": {
"allCars": "← All cars",
"share": "Share",
"shared": "Shared",
"sharedReadOnly": "Shared · read-only",
"tabs": {
"info": "Information",
"services": "Service history",
"technical": "Technical check history",
"maintenance": "Maintenance",
"fuel": "Fuel",
"documents": "Documents",
"parts": "Parts catalog",
"reminders": "Reminders"
},
"info": {
"oilSpec": "Engine oil spec",
"transmissionOil": "Transmission oil",
"differentialOil": "Differential oil",
"brakeFluid": "Brake fluid",
"coolant": "Coolant",
"odometer": "Odometer",
"serviceInterval": "Service interval",
"nextDue": "Next due",
"registrationPlate": "Registration plate",
"registrationCountry": "Registration country",
"vin": "VIN",
"fuelType": "Fuel type",
"buildDate": "Build date",
"firstRegistration": "First registration"
},
"services": {
"title": "Service history",
"add": "Add service",
"empty": "No service records yet.",
"colDate": "Date",
"colKm": "Km",
"colNextDate": "Next date",
"colNextKm": "Next km",
"colOil": "Oil & Oil filter",
"colEngineFilter": "Engine air filter",
"colCabinFilter": "Cabin air filter",
"colNotes": "Notes",
"colFile": "File",
"confirmDelete": "Delete this service record?"
},
"technical": {
"title": "Technical check history",
"subtitle": "Mandatory roadworthiness inspections. Recurs on time alone, whatever the odometer reads.",
"add": "Add check",
"empty": "No technical checks yet.",
"colDate": "Date",
"colResult": "Result",
"colNextCheck": "Next check",
"colStatus": "Status",
"colStation": "Station",
"colCost": "Cost",
"colNotes": "Notes",
"colFile": "File",
"passed": "Passed",
"failed": "Failed",
"confirmDelete": "Delete this technical check?"
},
"maintenance": {
"title": "Maintenance",
"subtitle": "Workshop visits and repairs. Routine servicing lives under Service history.",
"add": "Log visit",
"empty": "No workshop visits logged yet.",
"colDate": "Date",
"colKm": "Km",
"colType": "Type",
"colWork": "Work done",
"colWorkshop": "Workshop",
"colStatus": "Status",
"colCost": "Cost",
"colFile": "File",
"underWarranty": "Under warranty · {days}d left",
"confirmDelete": "Delete this workshop visit?"
},
"fuel": {
"title": "Fuel",
"subtitle": "Consumption is measured between full tanks.",
"add": "Log refill",
"empty": "No refills logged yet.",
"average": "Average",
"best": "Best",
"worst": "Worst",
"costPerKm": "Cost per km",
"refills": "Refills",
"totalLiters": "Total litres",
"totalSpent": "Total spent",
"trackedDistance": "Tracked distance",
"avgPrice": "Avg. price {price}/L",
"needTwoTanks": "Log at least two full tanks to see consumption figures.",
"colDate": "Date",
"colKm": "Km",
"colLiters": "Litres",
"colCost": "Cost",
"colPerLiter": "Per litre",
"colDistance": "Distance",
"colConsumption": "Consumption",
"colStation": "Station",
"colFile": "File",
"partial": "partial",
"gap": "gap",
"confirmDelete": "Delete this refill?"
},
"documents": {
"title": "Documents",
"subtitle": "Insurance, pollution certificates and other paperwork with renewal dates.",
"add": "Add document",
"empty": "No documents yet.",
"colType": "Type",
"colTitle": "Title",
"colProvider": "Provider",
"colIssued": "Issued",
"colRenewal": "Renewal",
"colStatus": "Status",
"colFile": "File",
"confirmDelete": "Delete this document?"
},
"reminders": {
"title": "Reminders",
"subtitle": "Renewal and service reminders are added automatically from your documents and service history.",
"add": "Add reminder",
"empty": "Nothing to be reminded about yet.",
"automatic": "Automatic",
"repeats": "Repeats",
"at": "at {km}",
"doneRollForward": "Done · roll forward",
"markDone": "Mark done",
"reopen": "Reopen",
"confirmDelete": "Delete this reminder?"
},
"parts": {
"title": "Parts catalog",
"add": "Add part",
"empty": "No parts yet.",
"colPart": "Part",
"colPartNumber": "Part number",
"colNotes": "Notes",
"colFile": "File",
"confirmDelete": "Delete this part?"
},
"delete": {
"title": "Delete this car?",
"body": "This permanently deletes {name} and everything logged against it — {services}, {maintenance}, {fuel}, {documents} and {parts}. This cannot be undone.",
"services": {
"one": "{n} service record",
"other": "{n} service records"
},
"maintenance": {
"one": "{n} workshop visit",
"other": "{n} workshop visits"
},
"fuel": {
"one": "{n} refill",
"other": "{n} refills"
},
"documents": {
"one": "{n} document",
"other": "{n} documents"
},
"parts": {
"one": "{n} part",
"other": "{n} parts"
},
"typeToConfirm": "Type {name} to confirm",
"deleting": "Deleting…",
"confirm": "Delete permanently"
}
},
"attachment": {
"legend": "Attachment",
"hint": "PDF or image, up to 10MB.",
"attached": "Attached: {name}",
"willBeRemoved": "Attachment will be removed on save."
},
"forms": {
"car": {
"addTitle": "Add a car",
"editTitle": "Edit car",
"name": "Name *",
"make": "Make",
"model": "Model",
"year": "Year",
"registration": "Registration",
"registrationCountry": "Registration country",
"registrationCountryPlaceholder": "Poland",
"vin": "VIN",
"vinPlaceholder": "Vehicle Identification Number",
"fuelType": "Fuel type",
"buildDate": "Build date",
"firstRegistration": "First registration",
"oilSpec": "Engine oil spec",
"currentKm": "Current odometer (km)",
"transmissionOilSpec": "Transmission oil spec",
"differentialOilSpec": "Differential oil spec",
"brakeFluidSpec": "Brake fluid spec",
"coolantSpec": "Coolant spec",
"serviceIntervalDays": "Service interval (days)",
"serviceIntervalKm": "Service interval (km)",
"technicalCheckIntervalDays": "Technical check interval (days)",
"technicalCheckHint": "Prefills each check's next-due date. Any check can override it with the date printed on its certificate.",
"submit": "Add car"
},
"service": {
"addTitle": "Add service record",
"editTitle": "Edit service record",
"date": "Date *",
"odometer": "Odometer (km)",
"changedParts": "Changed parts",
"oil": "Oil & Oil filter",
"engineFilter": "Engine air filter",
"cabinFilter": "Cabin air filter",
"attachmentLegend": "Receipt or service-book page",
"notes": "Notes",
"autoHint": "Next service date (+{days}d) and km (+{km}) are computed automatically.",
"submit": "Add service"
},
"technical": {
"addTitle": "Add technical check",
"editTitle": "Edit technical check",
"date": "Check date *",
"result": "Result *",
"passed": "Passed",
"failed": "Failed",
"validUntil": "Valid until",
"failedHint": "A failed check certifies nothing, so no next date is derived from it.",
"derivedHint": "Leave blank to use the car's interval (+{days}d → {date}). Enter the date on the certificate when it differs.",
"cost": "Cost",
"station": "Station",
"stationPlaceholder": "Stacja Kontroli Pojazdów",
"attachmentLegend": "Inspection certificate",
"notes": "Notes",
"submit": "Add check"
},
"part": {
"addTitle": "Add part",
"editTitle": "Edit part",
"name": "Part name *",
"namePlaceholder": "Oil Filter",
"partNumber": "Part number",
"notes": "Notes",
"notesPlaceholder": "Fits 20152020 · buy in pairs",
"attachmentLegend": "Photo or spec sheet",
"submit": "Add part"
},
"fuel": {
"addTitle": "Log refill",
"editTitle": "Edit refill",
"date": "Date *",
"odometer": "Odometer (km) *",
"liters": "Litres *",
"cost": "Total cost",
"pricePerLiter": "Price per litre: {price}",
"tank": "Tank",
"fullTank": "Filled to full",
"missedFill": "I missed logging a refill before this one",
"tankHint": "Consumption is measured between full tanks, so partial fills count towards the next full one. Flagging a missed refill leaves that stretch out of the figures instead of reporting it as unrealistically economical.",
"station": "Station",
"notes": "Notes",
"attachmentLegend": "Receipt",
"submit": "Log refill"
},
"maintenance": {
"addTitle": "Log workshop visit",
"editTitle": "Edit workshop visit",
"date": "Date *",
"odometer": "Odometer (km)",
"type": "Type",
"status": "Status",
"description": "What was done *",
"descriptionPlaceholder": "Replaced alternator and drive belt",
"workshop": "Workshop",
"location": "Location",
"partsUsed": "Parts replaced",
"partsUsedPlaceholder": "Alternator 27060-0T010, belt 90916-02660",
"laborCost": "Labour cost",
"partsCost": "Parts cost",
"total": "Total: {total}",
"invoiceNumber": "Invoice number",
"warrantyUntil": "Warranty until",
"attachmentLegend": "Invoice",
"notes": "Notes",
"submit": "Log visit"
},
"document": {
"addTitle": "Add document",
"editTitle": "Edit document",
"type": "Type",
"title": "Title *",
"titlePlaceholder": "Third-party liability 2026",
"provider": "Provider",
"reference": "Policy / certificate no.",
"issued": "Issued",
"renewalDate": "Renewal date",
"renewalHint": "Leave the renewal date blank for a document that never expires. Setting it adds a reminder automatically.",
"cost": "Cost",
"attachmentLegend": "Scan or photo",
"notes": "Notes",
"submit": "Add document"
},
"reminder": {
"addTitle": "Add reminder",
"editTitle": "Edit reminder",
"title": "Title *",
"titlePlaceholder": "Swap to winter tyres",
"type": "Type",
"remindMe": "Remind me",
"onDate": "On date",
"atOdometer": "At odometer (km)",
"triggerHint": "Set either or both — with both, whichever comes first wins.",
"currentKm": "The car is at {km} now.",
"repeat": "Repeat (optional)",
"everyDays": "Every … days",
"everyKm": "Every … km",
"recurringHint": "Marking this done will roll it forward instead of closing it.",
"oneOffHint": "Leave blank for a one-off reminder that closes when you mark it done.",
"notes": "Notes",
"noTrigger": "Set a due date, a due odometer reading, or both.",
"submit": "Add reminder"
},
"share": {
"title": "Share {name}",
"body": "Give another user access to this car. Read-only lets them view; read & write also lets them edit the car and its service records and parts.",
"userEmail": "User email",
"read": "Read-only",
"write": "Read & write",
"submit": "Share",
"peopleWithAccess": "People with access",
"notShared": "Not shared with anyone yet."
}
},
"enums": {
"fuelType": {
"petrol": "Petrol (gasoline)",
"petrol_lpg": "Petrol (gasoline) + LPG",
"diesel": "Diesel",
"diesel_lpg": "Diesel + LPG",
"hybrid": "Hybrid",
"electric": "Electric",
"hydrogen": "Hydrogen"
},
"maintenanceType": {
"repair": "Repair",
"inspection": "Inspection",
"bodywork": "Bodywork",
"tyres": "Tyres",
"diagnostics": "Diagnostics",
"recall": "Recall",
"warranty": "Warranty work",
"other": "Other"
},
"maintenanceStatus": {
"scheduled": "Scheduled",
"in_progress": "In progress",
"completed": "Completed"
},
"documentType": {
"insurance": "Insurance",
"pollution": "Pollution certificate",
"registration": "Registration",
"inspection": "Inspection",
"roadTax": "Road tax",
"warranty": "Warranty",
"other": "Other"
},
"reminderTypeShort": {
"maintenance": "Maintenance",
"document": "Document",
"service": "Service",
"inspection": "Inspection",
"other": "Other"
},
"reminderType": {
"maintenance": "Maintenance",
"document": "Document renewal",
"service": "Service",
"inspection": "Inspection",
"other": "Other"
}
},
"status": {
"noData": "No data",
"serviceOverdueDays": "Service Overdue {days}d",
"dueInDays": "Due in {days}d",
"okDays": "OK · {days}d",
"noKm": "No km",
"serviceOverdueKm": "Service Overdue {km} km",
"inKm": "In {km} km",
"kmLeft": "{km} km left",
"expiredAgo": "Expired {days}d ago",
"expiresToday": "Expires today",
"renewInDays": "Renew in {days}d",
"validDays": "Valid · {days}d",
"noExpiry": "No expiry",
"done": "Done",
"noTrigger": "No trigger",
"overdue": "Overdue",
"overdueBy": "Overdue {parts}",
"dueIn": "Due in {parts}",
"upcoming": "Upcoming",
"today": "today",
"days": "{days}d",
"km": "{km} km"
}
}
Binary file not shown.
+640
View File
@@ -0,0 +1,640 @@
{
"errors": {
"sessionExpired": "Sesja wygasła — zaloguj się ponownie."
},
"common": {
"cancel": "Anuluj",
"save": "Zapisz",
"saveChanges": "Zapisz zmiany",
"saving": "Zapisywanie…",
"saved": "Zapisano ✓",
"loading": "Ładowanie…",
"edit": "Edytuj",
"remove": "Usuń",
"delete": "Usuń",
"done": "Gotowe",
"undo": "Cofnij",
"download": "Pobierz",
"empty": "—",
"yes": "Tak",
"no": "Nie"
},
"nav": {
"garage": "Garaż",
"settings": "Ustawienia",
"users": "Użytkownicy",
"lightMode": "Tryb jasny",
"darkMode": "Tryb ciemny",
"signedIn": "Zalogowano",
"logOut": "Wyloguj się"
},
"login": {
"title": "Zaloguj się",
"tagline": "Twój samochód pod kontrolą.",
"email": "E-mail",
"password": "Hasło",
"showPassword": "Pokaż hasło",
"hidePassword": "Ukryj hasło",
"submit": "Zaloguj się",
"submitting": "Logowanie…",
"failed": "Logowanie nie powiodło się",
"serverSettings": "Ustawienia serwera",
"apiServerUrl": "Adres serwera API",
"leaveBlank": "Pozostaw puste, aby użyć domyślnego ({url}).",
"resetToDefault": "Przywróć domyślny"
},
"dashboard": {
"eyebrow": "Garaż",
"title": "Twoje samochody",
"subtitle": "Przegląd serwisowy i historia napraw.",
"addCar": "Dodaj samochód",
"empty": "Nie masz jeszcze samochodów. Kliknij {action}, aby zacząć.",
"shared": "Udostępniony",
"sharedReadOnly": "Udostępniony · tylko do odczytu",
"serviceLife": "Zużycie okresu serwisowego",
"lastService": "Ostatni serwis",
"odometer": "Przebieg",
"nextDue": "Następny termin",
"nextDueKm": "Następny przebieg",
"serviceRecords": {
"one": "{n} wpis serwisowy",
"few": "{n} wpisy serwisowe",
"many": "{n} wpisów serwisowych",
"other": "{n} wpisu serwisowego"
}
},
"admin": {
"eyebrow": "Administracja",
"title": "Użytkownicy",
"subtitleAll": "Konta ze wszystkich organizacji.",
"subtitleOrg": "Konta w Twojej organizacji.",
"subtitleOrgsNote": "Organizacje przypisuje się w panelu API.",
"addUser": "Dodaj użytkownika",
"colEmail": "E-mail",
"colName": "Imię i nazwisko",
"colOrganization": "Organizacja",
"colRole": "Rola",
"colCreated": "Utworzono",
"you": "(Ty)",
"resetPassword": "Zresetuj hasło",
"confirmDelete": "Usunąć użytkownika {name}? Tej operacji nie można cofnąć.",
"cantDeleteSelf": "Nie możesz usunąć własnego konta.",
"onlySuperadminDeletes": "Tylko superadministrator może usunąć superadministratora.",
"cantChangeOwnRole": "Nie możesz zmienić własnej roli.",
"onlySuperadminEdits": "Tylko superadministrator może edytować superadministratora.",
"createTitle": "Dodaj użytkownika",
"emailRequired": "E-mail *",
"passwordRequired": "Hasło *",
"minChars": "(min. 8)",
"creating": "Tworzenie…",
"createUser": "Utwórz użytkownika",
"resetTitle": "Reset hasła — {email}",
"newPassword": "Nowe hasło",
"setPassword": "Ustaw hasło",
"roles": {
"user": "użytkownik",
"admin": "administrator",
"superadmin": "superadministrator"
}
},
"settings": {
"eyebrow": "Konto",
"title": "Ustawienia",
"subtitle": "Zarządzaj kontem, wyglądem i danymi.",
"account": {
"title": "Konto",
"name": "Imię i nazwisko",
"email": "E-mail",
"verified": "Zweryfikowany",
"notVerified": "Niezweryfikowany",
"resendVerification": "Wyślij ponownie e-mail weryfikacyjny",
"sending": "Wysyłanie…",
"verificationRequested": "Zamówiono e-mail weryfikacyjny.",
"changePassword": "Zmień hasło",
"currentPassword": "Obecne hasło",
"newPassword": "Nowe hasło",
"confirmNewPassword": "Potwierdź nowe hasło",
"mismatchYet": "Hasła jeszcze się nie zgadzają.",
"tooShort": "Nowe hasło musi mieć co najmniej 8 znaków.",
"mismatch": "Nowe hasło i potwierdzenie nie są takie same.",
"updating": "Aktualizowanie…",
"passwordUpdated": "Hasło zaktualizowane ✓",
"updatePassword": "Zaktualizuj hasło"
},
"appearance": {
"title": "Wygląd",
"theme": "Motyw",
"themeLight": "jasny",
"themeDark": "ciemny",
"themeSystem": "systemowy",
"language": "Język",
"languageHint": "Tekst aplikacji oraz nazwy miesięcy i dni.",
"languageFallbackHint": "Ten język nie jest jeszcze przetłumaczony — tekst aplikacji pozostanie po angielsku.",
"region": "Region",
"regionHint": "Format liczb i waluty.",
"dateFormat": "Format daty",
"dateExample": "Przykład: {example}",
"currency": "Waluta",
"currencyExample": "Przykład: {example} — tylko wyświetlanie, kwoty nie są przeliczane.",
"fontSize": "Rozmiar czcionki",
"fontSmall": "mała",
"fontMedium": "średnia",
"fontLarge": "duża"
},
"profile": {
"title": "Profil",
"avatarAlt": "Awatar",
"uploading": "Przesyłanie…",
"uploadPhoto": "Prześlij zdjęcie",
"bio": "O mnie",
"bioPlaceholder": "Krótka notatka widoczna dla innych osób w Twoim gospodarstwie domowym.",
"saveBio": "Zapisz opis"
},
"privacy": {
"title": "Prywatność i bezpieczeństwo",
"signOut": "Wyloguj się",
"body": "Uwierzytelnianie dwuskładnikowe nie jest jeszcze dostępne. Sesje opierają się na tokenach wydawanych przez serwer, które wygasają samoczynnie, więc wylogowanie tutaj kończy tylko sesję na tym urządzeniu — nie ma listy urządzeń do unieważnienia. Aby wylogować wszystkie urządzenia, zmień hasło powyżej."
},
"advanced": {
"title": "Zaawansowane",
"exportTitle": "Eksportuj swoje dane",
"exportBody": "Pobierz swój profil oraz wszystkie samochody, wpisy serwisowe i części w formacie JSON.",
"preparing": "Przygotowywanie…",
"exportAction": "Eksportuj dane",
"importTitle": "Importuj swoje dane",
"importBody": "Dodaj samochody z wcześniej wyeksportowanego pliku JSON. Tworzy to nowe wpisy — nic nie jest scalane ani nadpisywane.",
"importing": "Importowanie…",
"importAction": "Importuj dane",
"notJson": "Ten plik nie jest poprawnym plikiem JSON.",
"notExport": "Ten plik nie wygląda na eksport z DriverVault (brak listy \"cars\").",
"confirmImport": "Zaimportować samochody z tego pliku ({count})? Zostaną dodane nowe wpisy — istniejące samochody nie zostaną scalone ani nadpisane.",
"imported": "Zaimportowano: samochody ({cars}), wpisy serwisowe ({services}), części ({parts})."
},
"danger": {
"title": "Strefa niebezpieczna",
"body": "Usunięcie konta usuwa Twój login i profil. Nie usuwa samochodów ani historii serwisowej współdzielonych w gospodarstwie domowym. Obowiązuje 3-dniowy okres karencji, zanim usunięcie stanie się ostateczne — do tego czasu możesz je anulować.",
"deleteAccount": "Usuń moje konto",
"typeToConfirm": "Wpisz {email}, aby potwierdzić",
"requesting": "Wysyłanie żądania…",
"requestDeletion": "Zażądaj usunięcia",
"requestedOn": "Żądanie usunięcia konta złożono {date}.",
"canStillCancel": "Nadal możesz je anulować — stanie się ostateczne po 3-dniowym okresie karencji.",
"cooldownPassed": "Okres karencji minął. Możesz teraz dokończyć usuwanie.",
"cancelRequest": "Anuluj żądanie usunięcia",
"finalize": "Trwale usuń moje konto",
"confirmFinalize": "To trwale usunie Twoje konto. Tej operacji nie można cofnąć. Kontynuować?"
}
},
"car": {
"allCars": "← Wszystkie samochody",
"share": "Udostępnij",
"shared": "Udostępniony",
"sharedReadOnly": "Udostępniony · tylko do odczytu",
"tabs": {
"info": "Informacje",
"services": "Historia serwisowa",
"technical": "Historia przeglądów",
"maintenance": "Naprawy",
"fuel": "Paliwo",
"documents": "Dokumenty",
"parts": "Katalog części",
"reminders": "Przypomnienia"
},
"info": {
"oilSpec": "Specyfikacja oleju silnikowego",
"transmissionOil": "Olej przekładniowy",
"differentialOil": "Olej mostu napędowego",
"brakeFluid": "Płyn hamulcowy",
"coolant": "Płyn chłodniczy",
"odometer": "Przebieg",
"serviceInterval": "Interwał serwisowy",
"nextDue": "Następny termin",
"registrationPlate": "Numer rejestracyjny",
"registrationCountry": "Kraj rejestracji",
"vin": "VIN",
"fuelType": "Rodzaj paliwa",
"buildDate": "Data produkcji",
"firstRegistration": "Pierwsza rejestracja"
},
"services": {
"title": "Historia serwisowa",
"add": "Dodaj serwis",
"empty": "Brak wpisów serwisowych.",
"colDate": "Data",
"colKm": "Km",
"colNextDate": "Następna data",
"colNextKm": "Następny przebieg",
"colOil": "Olej i filtr oleju",
"colEngineFilter": "Filtr powietrza silnika",
"colCabinFilter": "Filtr kabinowy",
"colNotes": "Notatki",
"colFile": "Plik",
"confirmDelete": "Usunąć ten wpis serwisowy?"
},
"technical": {
"title": "Historia przeglądów technicznych",
"subtitle": "Obowiązkowe badania techniczne. Powtarzają się wyłącznie w oparciu o czas, niezależnie od przebiegu.",
"add": "Dodaj przegląd",
"empty": "Brak przeglądów technicznych.",
"colDate": "Data",
"colResult": "Wynik",
"colNextCheck": "Następny przegląd",
"colStatus": "Status",
"colStation": "Stacja",
"colCost": "Koszt",
"colNotes": "Notatki",
"colFile": "Plik",
"passed": "Pozytywny",
"failed": "Negatywny",
"confirmDelete": "Usunąć ten przegląd techniczny?"
},
"maintenance": {
"title": "Naprawy",
"subtitle": "Wizyty w warsztacie i naprawy. Rutynowa obsługa znajduje się w Historii serwisowej.",
"add": "Zapisz wizytę",
"empty": "Brak zapisanych wizyt w warsztacie.",
"colDate": "Data",
"colKm": "Km",
"colType": "Rodzaj",
"colWork": "Wykonane prace",
"colWorkshop": "Warsztat",
"colStatus": "Status",
"colCost": "Koszt",
"colFile": "Plik",
"underWarranty": "Na gwarancji · pozostało {days} dni",
"confirmDelete": "Usunąć tę wizytę w warsztacie?"
},
"fuel": {
"title": "Paliwo",
"subtitle": "Zużycie liczone jest między pełnymi bakami.",
"add": "Zapisz tankowanie",
"empty": "Brak zapisanych tankowań.",
"average": "Średnie",
"best": "Najlepsze",
"worst": "Najgorsze",
"costPerKm": "Koszt na km",
"refills": "Tankowania",
"totalLiters": "Łącznie litrów",
"totalSpent": "Łącznie wydano",
"trackedDistance": "Zmierzony dystans",
"avgPrice": "Śr. cena {price}/l",
"needTwoTanks": "Zapisz co najmniej dwa pełne baki, aby zobaczyć zużycie.",
"colDate": "Data",
"colKm": "Km",
"colLiters": "Litry",
"colCost": "Koszt",
"colPerLiter": "Za litr",
"colDistance": "Dystans",
"colConsumption": "Zużycie",
"colStation": "Stacja",
"colFile": "Plik",
"partial": "częściowe",
"gap": "luka",
"confirmDelete": "Usunąć to tankowanie?"
},
"documents": {
"title": "Dokumenty",
"subtitle": "Ubezpieczenie, zaświadczenia i inne dokumenty z terminami odnowienia.",
"add": "Dodaj dokument",
"empty": "Brak dokumentów.",
"colType": "Rodzaj",
"colTitle": "Nazwa",
"colProvider": "Wystawca",
"colIssued": "Wystawiono",
"colRenewal": "Odnowienie",
"colStatus": "Status",
"colFile": "Plik",
"confirmDelete": "Usunąć ten dokument?"
},
"reminders": {
"title": "Przypomnienia",
"subtitle": "Przypomnienia o odnowieniach i serwisach dodawane są automatycznie na podstawie dokumentów i historii serwisowej.",
"add": "Dodaj przypomnienie",
"empty": "Nie ma jeszcze o czym przypominać.",
"automatic": "Automatyczne",
"repeats": "Powtarza się",
"at": "przy {km}",
"doneRollForward": "Gotowe · przenieś dalej",
"markDone": "Oznacz jako gotowe",
"reopen": "Otwórz ponownie",
"confirmDelete": "Usunąć to przypomnienie?"
},
"parts": {
"title": "Katalog części",
"add": "Dodaj część",
"empty": "Brak części.",
"colPart": "Część",
"colPartNumber": "Numer części",
"colNotes": "Notatki",
"colFile": "Plik",
"confirmDelete": "Usunąć tę część?"
},
"delete": {
"title": "Usunąć ten samochód?",
"body": "To trwale usunie {name} i wszystko, co zostało w nim zapisane — {services}, {maintenance}, {fuel}, {documents} i {parts}. Tej operacji nie można cofnąć.",
"services": {
"one": "{n} wpis serwisowy",
"few": "{n} wpisy serwisowe",
"many": "{n} wpisów serwisowych",
"other": "{n} wpisu serwisowego"
},
"maintenance": {
"one": "{n} wizyta w warsztacie",
"few": "{n} wizyty w warsztacie",
"many": "{n} wizyt w warsztacie",
"other": "{n} wizyty w warsztacie"
},
"fuel": {
"one": "{n} tankowanie",
"few": "{n} tankowania",
"many": "{n} tankowań",
"other": "{n} tankowania"
},
"documents": {
"one": "{n} dokument",
"few": "{n} dokumenty",
"many": "{n} dokumentów",
"other": "{n} dokumentu"
},
"parts": {
"one": "{n} część",
"few": "{n} części",
"many": "{n} części",
"other": "{n} części"
},
"typeToConfirm": "Wpisz {name}, aby potwierdzić",
"deleting": "Usuwanie…",
"confirm": "Usuń trwale"
}
},
"attachment": {
"legend": "Załącznik",
"hint": "PDF lub obraz, do 10 MB.",
"attached": "Załączono: {name}",
"willBeRemoved": "Załącznik zostanie usunięty przy zapisie."
},
"forms": {
"car": {
"addTitle": "Dodaj samochód",
"editTitle": "Edytuj samochód",
"name": "Nazwa *",
"make": "Marka",
"model": "Model",
"year": "Rok",
"registration": "Numer rejestracyjny",
"registrationCountry": "Kraj rejestracji",
"registrationCountryPlaceholder": "Polska",
"vin": "VIN",
"vinPlaceholder": "Numer identyfikacyjny pojazdu",
"fuelType": "Rodzaj paliwa",
"buildDate": "Data produkcji",
"firstRegistration": "Pierwsza rejestracja",
"oilSpec": "Specyfikacja oleju silnikowego",
"currentKm": "Aktualny przebieg (km)",
"transmissionOilSpec": "Specyfikacja oleju przekładniowego",
"differentialOilSpec": "Specyfikacja oleju mostu napędowego",
"brakeFluidSpec": "Specyfikacja płynu hamulcowego",
"coolantSpec": "Specyfikacja płynu chłodniczego",
"serviceIntervalDays": "Interwał serwisowy (dni)",
"serviceIntervalKm": "Interwał serwisowy (km)",
"technicalCheckIntervalDays": "Interwał przeglądu technicznego (dni)",
"technicalCheckHint": "Wstępnie wypełnia termin następnego przeglądu. Każdy przegląd może go nadpisać datą z zaświadczenia.",
"submit": "Dodaj samochód"
},
"service": {
"addTitle": "Dodaj wpis serwisowy",
"editTitle": "Edytuj wpis serwisowy",
"date": "Data *",
"odometer": "Przebieg (km)",
"changedParts": "Wymienione części",
"oil": "Olej i filtr oleju",
"engineFilter": "Filtr powietrza silnika",
"cabinFilter": "Filtr kabinowy",
"attachmentLegend": "Paragon lub strona książki serwisowej",
"notes": "Notatki",
"autoHint": "Data (+{days} dni) i przebieg (+{km}) następnego serwisu są obliczane automatycznie.",
"submit": "Dodaj serwis"
},
"technical": {
"addTitle": "Dodaj przegląd techniczny",
"editTitle": "Edytuj przegląd techniczny",
"date": "Data przeglądu *",
"result": "Wynik *",
"passed": "Pozytywny",
"failed": "Negatywny",
"validUntil": "Ważny do",
"failedHint": "Negatywny przegląd niczego nie potwierdza, więc nie wyznacza następnego terminu.",
"derivedHint": "Pozostaw puste, aby użyć interwału samochodu (+{days} dni → {date}). Wpisz datę z zaświadczenia, jeśli jest inna.",
"cost": "Koszt",
"station": "Stacja",
"stationPlaceholder": "Stacja Kontroli Pojazdów",
"attachmentLegend": "Zaświadczenie o przeglądzie",
"notes": "Notatki",
"submit": "Dodaj przegląd"
},
"part": {
"addTitle": "Dodaj część",
"editTitle": "Edytuj część",
"name": "Nazwa części *",
"namePlaceholder": "Filtr oleju",
"partNumber": "Numer części",
"notes": "Notatki",
"notesPlaceholder": "Pasuje do 20152020 · kupować parami",
"attachmentLegend": "Zdjęcie lub karta katalogowa",
"submit": "Dodaj część"
},
"fuel": {
"addTitle": "Zapisz tankowanie",
"editTitle": "Edytuj tankowanie",
"date": "Data *",
"odometer": "Przebieg (km) *",
"liters": "Litry *",
"cost": "Koszt całkowity",
"pricePerLiter": "Cena za litr: {price}",
"tank": "Bak",
"fullTank": "Zatankowano do pełna",
"missedFill": "Nie zapisałem tankowania przed tym",
"tankHint": "Zużycie liczone jest między pełnymi bakami, więc tankowania częściowe wliczają się do następnego pełnego. Oznaczenie pominiętego tankowania wyklucza ten odcinek z obliczeń, zamiast pokazywać nierealnie niskie spalanie.",
"station": "Stacja",
"notes": "Notatki",
"attachmentLegend": "Paragon",
"submit": "Zapisz tankowanie"
},
"maintenance": {
"addTitle": "Zapisz wizytę w warsztacie",
"editTitle": "Edytuj wizytę w warsztacie",
"date": "Data *",
"odometer": "Przebieg (km)",
"type": "Rodzaj",
"status": "Status",
"description": "Co zostało zrobione *",
"descriptionPlaceholder": "Wymiana alternatora i paska napędowego",
"workshop": "Warsztat",
"location": "Lokalizacja",
"partsUsed": "Wymienione części",
"partsUsedPlaceholder": "Alternator 27060-0T010, pasek 90916-02660",
"laborCost": "Koszt robocizny",
"partsCost": "Koszt części",
"total": "Razem: {total}",
"invoiceNumber": "Numer faktury",
"warrantyUntil": "Gwarancja do",
"attachmentLegend": "Faktura",
"notes": "Notatki",
"submit": "Zapisz wizytę"
},
"document": {
"addTitle": "Dodaj dokument",
"editTitle": "Edytuj dokument",
"type": "Rodzaj",
"title": "Nazwa *",
"titlePlaceholder": "OC 2026",
"provider": "Wystawca",
"reference": "Nr polisy / zaświadczenia",
"issued": "Wystawiono",
"renewalDate": "Data odnowienia",
"renewalHint": "Pozostaw datę odnowienia pustą dla dokumentu bezterminowego. Ustawienie jej automatycznie doda przypomnienie.",
"cost": "Koszt",
"attachmentLegend": "Skan lub zdjęcie",
"notes": "Notatki",
"submit": "Dodaj dokument"
},
"reminder": {
"addTitle": "Dodaj przypomnienie",
"editTitle": "Edytuj przypomnienie",
"title": "Nazwa *",
"titlePlaceholder": "Zmiana na opony zimowe",
"type": "Rodzaj",
"remindMe": "Przypomnij mi",
"onDate": "W dniu",
"atOdometer": "Przy przebiegu (km)",
"triggerHint": "Ustaw jedno lub oba — przy obu liczy się to, co nastąpi wcześniej.",
"currentKm": "Samochód ma teraz {km}.",
"repeat": "Powtarzanie (opcjonalnie)",
"everyDays": "Co … dni",
"everyKm": "Co … km",
"recurringHint": "Oznaczenie jako gotowe przeniesie je dalej, zamiast zamknąć.",
"oneOffHint": "Pozostaw puste dla jednorazowego przypomnienia, które zamknie się po oznaczeniu jako gotowe.",
"notes": "Notatki",
"noTrigger": "Ustaw datę, przebieg lub oba.",
"submit": "Dodaj przypomnienie"
},
"share": {
"title": "Udostępnij {name}",
"body": "Daj innemu użytkownikowi dostęp do tego samochodu. Tylko do odczytu pozwala na podgląd; odczyt i zapis pozwala też edytować samochód oraz jego wpisy serwisowe i części.",
"userEmail": "E-mail użytkownika",
"read": "Tylko do odczytu",
"write": "Odczyt i zapis",
"submit": "Udostępnij",
"peopleWithAccess": "Osoby z dostępem",
"notShared": "Jeszcze nikomu nie udostępniono."
}
},
"enums": {
"fuelType": {
"petrol": "Benzyna",
"petrol_lpg": "Benzyna + LPG",
"diesel": "Diesel",
"diesel_lpg": "Diesel + LPG",
"hybrid": "Hybryda",
"electric": "Elektryczny",
"hydrogen": "Wodór"
},
"maintenanceType": {
"repair": "Naprawa",
"inspection": "Przegląd",
"bodywork": "Blacharka",
"tyres": "Opony",
"diagnostics": "Diagnostyka",
"recall": "Akcja serwisowa",
"warranty": "Naprawa gwarancyjna",
"other": "Inne"
},
"maintenanceStatus": {
"scheduled": "Zaplanowana",
"in_progress": "W trakcie",
"completed": "Zakończona"
},
"documentType": {
"insurance": "Ubezpieczenie",
"pollution": "Zaświadczenie o emisji spalin",
"registration": "Dowód rejestracyjny",
"inspection": "Przegląd",
"roadTax": "Podatek drogowy",
"warranty": "Gwarancja",
"other": "Inne"
},
"reminderTypeShort": {
"maintenance": "Naprawa",
"document": "Dokument",
"service": "Serwis",
"inspection": "Przegląd",
"other": "Inne"
},
"reminderType": {
"maintenance": "Naprawa",
"document": "Odnowienie dokumentu",
"service": "Serwis",
"inspection": "Przegląd",
"other": "Inne"
}
},
"status": {
"noData": "Brak danych",
"serviceOverdueDays": "Serwis zaległy {days} dni",
"dueInDays": "Termin za {days} dni",
"okDays": "OK · {days} dni",
"noKm": "Brak przebiegu",
"serviceOverdueKm": "Serwis zaległy {km} km",
"inKm": "Za {km} km",
"kmLeft": "Pozostało {km} km",
"expiredAgo": "Wygasło {days} dni temu",
"expiresToday": "Wygasa dzisiaj",
"renewInDays": "Odnowienie za {days} dni",
"validDays": "Ważne · {days} dni",
"noExpiry": "Bezterminowe",
"done": "Gotowe",
"noTrigger": "Brak wyzwalacza",
"overdue": "Zaległe",
"overdueBy": "Zaległe {parts}",
"dueIn": "Termin za {parts}",
"upcoming": "Nadchodzące",
"today": "dzisiaj",
"days": "{days} dni",
"km": "{km} km"
}
}
+21 -20
View File
@@ -4,6 +4,7 @@
// odometer approaches/passes the computed next-service km.
import { prefs } from "../prefs.js";
import { t } from "../i18n/index.js";
export function formatDate(value) {
if (!value) return "—";
@@ -69,19 +70,19 @@ const STYLE = {
// dateSignal classifies the next-due date relative to today.
function dateSignal(nextServiceDate) {
const days = daysUntil(nextServiceDate);
if (days == null) return { key: "unknown", label: "No data" };
if (days < 0) return { key: "overdue", label: `Service Overdue ${Math.abs(days)}d` };
if (days <= 30) return { key: "soon", label: `Due in ${days}d` };
return { key: "ok", label: `OK · ${days}d` };
if (days == null) return { key: "unknown", label: t("status.noData") };
if (days < 0) return { key: "overdue", label: t("status.serviceOverdueDays", { days: Math.abs(days) }) };
if (days <= 30) return { key: "soon", label: t("status.dueInDays", { days }) };
return { key: "ok", label: t("status.okDays", { days }) };
}
// kmSignal classifies the current odometer against the next-due km.
function kmSignal(currentKm, nextServiceKm) {
if (!currentKm || !nextServiceKm) return { key: "unknown", label: "No km" };
if (!currentKm || !nextServiceKm) return { key: "unknown", label: t("status.noKm") };
const remaining = nextServiceKm - currentKm;
if (remaining < 0) return { key: "overdue", label: `Service Overdue ${num(Math.abs(remaining))} km` };
if (remaining <= KM_SOON) return { key: "soon", label: `In ${num(remaining)} km` };
return { key: "ok", label: `${num(remaining)} km left` };
if (remaining < 0) return { key: "overdue", label: t("status.serviceOverdueKm", { km: num(Math.abs(remaining)) }) };
if (remaining <= KM_SOON) return { key: "soon", label: t("status.inKm", { km: num(remaining) }) };
return { key: "ok", label: t("status.kmLeft", { km: num(remaining) }) };
}
// serviceStatus combines the date- and km-based signals, returning the worse of
@@ -137,16 +138,16 @@ export function expiryStatus(doc) {
let label;
switch (state) {
case "expired":
label = `Expired ${Math.abs(days)}d ago`;
label = t("status.expiredAgo", { days: Math.abs(days) });
break;
case "expiring_soon":
label = days === 0 ? "Expires today" : `Renew in ${days}d`;
label = days === 0 ? t("status.expiresToday") : t("status.renewInDays", { days });
break;
case "valid":
label = `Valid · ${days}d`;
label = t("status.validDays", { days });
break;
default:
label = "No expiry";
label = t("status.noExpiry");
}
return { key: state, label, classes: EXPIRY_STYLE[state] || EXPIRY_STYLE.no_expiry };
}
@@ -168,19 +169,19 @@ export function reminderStatus(rem) {
const km = rem?.kmLeft;
let label;
if (state === "done") label = "Done";
else if (state === "no_trigger") label = "No trigger";
if (state === "done") label = t("status.done");
else if (state === "no_trigger") label = t("status.noTrigger");
else if (state === "overdue") {
const parts = [];
if (days != null && days < 0) parts.push(`${Math.abs(days)}d`);
if (km != null && km < 0) parts.push(`${num(Math.abs(km))} km`);
label = parts.length ? `Overdue ${parts.join(" · ")}` : "Overdue";
if (days != null && days < 0) parts.push(t("status.days", { days: Math.abs(days) }));
if (km != null && km < 0) parts.push(t("status.km", { km: num(Math.abs(km)) }));
label = parts.length ? t("status.overdueBy", { parts: parts.join(" · ") }) : t("status.overdue");
} else {
// Lead with the trigger that is closest to firing.
const parts = [];
if (days != null && days >= 0) parts.push(days === 0 ? "today" : `${days}d`);
if (km != null && km >= 0) parts.push(`${num(km)} km`);
label = parts.length ? `Due in ${parts.join(" · ")}` : "Upcoming";
if (days != null && days >= 0) parts.push(days === 0 ? t("status.today") : t("status.days", { days }));
if (km != null && km >= 0) parts.push(t("status.km", { km: num(km) }));
label = parts.length ? t("status.dueIn", { parts: parts.join(" · ") }) : t("status.upcoming");
}
return { key: state, label, classes: REMINDER_STYLE[state] || REMINDER_STYLE.no_trigger };
}
+36 -35
View File
@@ -3,6 +3,7 @@ import { ref, computed, onMounted } from "vue";
import { api } from "../api";
import { state } from "../auth";
import { formatDate } from "../lib/format.js";
import { t } from "../i18n";
import Modal from "../components/Modal.vue";
const users = ref([]);
@@ -35,13 +36,13 @@ const assignableRoles = computed(() =>
// reason. These mirror the server's guards so the UI doesn't offer an action
// that is going to come back as a 400/403.
function deleteBlockedReason(u) {
if (u.id === myId) return "You can't delete your own account.";
if (u.role === "superadmin" && !isSuperadmin.value) return "Only a superadmin can delete a superadmin.";
if (u.id === myId) return t("admin.cantDeleteSelf");
if (u.role === "superadmin" && !isSuperadmin.value) return t("admin.onlySuperadminDeletes");
return "";
}
function roleLockReason(u) {
if (u.id === myId) return "You can't change your own role.";
if (u.role === "superadmin" && !isSuperadmin.value) return "Only a superadmin can edit a superadmin.";
if (u.id === myId) return t("admin.cantChangeOwnRole");
if (u.role === "superadmin" && !isSuperadmin.value) return t("admin.onlySuperadminEdits");
return "";
}
@@ -109,7 +110,7 @@ async function submitResetPassword() {
}
async function removeUser(u) {
if (!confirm(`Delete ${u.name || u.email}? This cannot be undone.`)) return;
if (!confirm(t("admin.confirmDelete", { name: u.name || u.email }))) return;
error.value = "";
try {
await api.deleteUser(u.id);
@@ -126,31 +127,31 @@ onMounted(load);
<div>
<div class="mb-6 flex items-end justify-between">
<div>
<p class="eyebrow">Admin</p>
<h1 class="text-3xl font-bold tracking-[-0.03em] text-strong">Users</h1>
<p class="eyebrow">{{ t("admin.eyebrow") }}</p>
<h1 class="text-3xl font-bold tracking-[-0.03em] text-strong">{{ t("admin.title") }}</h1>
<p class="mt-1 text-sm text-muted">
{{ isSuperadmin ? "Accounts across every organization." : "Accounts in your organization." }}
Organizations are assigned in the API panel.
{{ isSuperadmin ? t("admin.subtitleAll") : t("admin.subtitleOrg") }}
{{ t("admin.subtitleOrgsNote") }}
</p>
</div>
<button class="dh-btn dh-btn-primary" @click="showCreate = true">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" class="h-4 w-4"><path stroke-linecap="round" stroke-linejoin="round" d="M12 5v14M5 12h14" /></svg>
Add user
{{ t("admin.addUser") }}
</button>
</div>
<p v-if="error" class="mb-4 rounded-control bg-danger-soft px-4 py-3 text-sm font-medium text-danger">{{ error }}</p>
<p v-if="loading" class="text-muted">Loading</p>
<p v-if="loading" class="text-muted">{{ t("common.loading") }}</p>
<div v-else class="dh-card overflow-x-auto p-0">
<table class="min-w-full text-sm">
<thead class="bg-sunken text-left">
<tr class="[&>th]:eyebrow [&>th]:px-4 [&>th]:py-3">
<th>Email</th>
<th>Name</th>
<th>Organization</th>
<th>Role</th>
<th>Created</th>
<th>{{ t("admin.colEmail") }}</th>
<th>{{ t("admin.colName") }}</th>
<th>{{ t("admin.colOrganization") }}</th>
<th>{{ t("admin.colRole") }}</th>
<th>{{ t("admin.colCreated") }}</th>
<th></th>
</tr>
</thead>
@@ -158,10 +159,10 @@ onMounted(load);
<tr v-for="u in users" :key="u.id" class="transition-colors hover:bg-sunken">
<td class="px-4 py-3 font-medium text-strong">
{{ u.email }}
<span v-if="u.id === myId" class="ml-1 text-xs text-muted">(you)</span>
<span v-if="u.id === myId" class="ml-1 text-xs text-muted">{{ t("admin.you") }}</span>
</td>
<td class="px-4 py-3 text-body">{{ u.name || '—' }}</td>
<td class="px-4 py-3 text-body">{{ u.organizationName || '—' }}</td>
<td class="px-4 py-3 text-body">{{ u.name || t("common.empty") }}</td>
<td class="px-4 py-3 text-body">{{ u.organizationName || t("common.empty") }}</td>
<td class="px-4 py-3">
<select
:value="u.role"
@@ -170,21 +171,21 @@ onMounted(load);
class="dh-input w-auto !py-1 !text-xs disabled:opacity-60"
@change="changeRole(u, $event.target.value)"
>
<option v-for="r in assignableRoles" :key="r" :value="r">{{ r }}</option>
<option v-for="r in assignableRoles" :key="r" :value="r">{{ t(`admin.roles.${r}`) }}</option>
<!-- Keep the current role selectable even when this viewer can't assign it. -->
<option v-if="!assignableRoles.includes(u.role)" :value="u.role">{{ u.role }}</option>
<option v-if="!assignableRoles.includes(u.role)" :value="u.role">{{ t(`admin.roles.${u.role}`) }}</option>
</select>
</td>
<td class="px-4 py-3 data text-muted">{{ formatDate(u.created) }}</td>
<td class="whitespace-nowrap px-4 py-3 text-right">
<button class="text-xs font-medium text-brandtext hover:underline" @click="openResetPassword(u)">Reset password</button>
<button class="text-xs font-medium text-brandtext hover:underline" @click="openResetPassword(u)">{{ t("admin.resetPassword") }}</button>
<button
class="ml-3 text-xs font-medium text-danger hover:underline disabled:cursor-not-allowed disabled:text-muted disabled:no-underline"
:disabled="!!deleteBlockedReason(u)"
:title="deleteBlockedReason(u)"
@click="removeUser(u)"
>
Delete
{{ t("common.delete") }}
</button>
</td>
</tr>
@@ -193,50 +194,50 @@ onMounted(load);
</div>
<!-- Create user -->
<Modal v-if="showCreate" title="Add a user" @close="showCreate = false">
<Modal v-if="showCreate" :title="t('admin.createTitle')" @close="showCreate = false">
<p v-if="createError" class="mb-3 rounded-control bg-danger-soft px-3 py-2 text-sm font-medium text-danger">{{ createError }}</p>
<form class="space-y-3" @submit.prevent="submitCreate">
<div>
<label class="dh-label">Email *</label>
<label class="dh-label">{{ t("admin.emailRequired") }}</label>
<input v-model="createForm.email" type="email" required autocomplete="off" class="dh-input" />
</div>
<div>
<label class="dh-label">Name</label>
<label class="dh-label">{{ t("admin.colName") }}</label>
<input v-model="createForm.name" autocomplete="off" class="dh-input" />
</div>
<div class="grid grid-cols-2 gap-2">
<div>
<label class="dh-label">Password * <span class="text-muted">(min 8)</span></label>
<label class="dh-label">{{ t("admin.passwordRequired") }} <span class="text-muted">{{ t("admin.minChars") }}</span></label>
<input v-model="createForm.password" type="text" required minlength="8" autocomplete="new-password" class="dh-input data" />
</div>
<div>
<label class="dh-label">Role</label>
<label class="dh-label">{{ t("admin.colRole") }}</label>
<select v-model="createForm.role" class="dh-input">
<option v-for="r in assignableRoles" :key="r" :value="r">{{ r }}</option>
<option v-for="r in assignableRoles" :key="r" :value="r">{{ t(`admin.roles.${r}`) }}</option>
</select>
</div>
</div>
<div class="mt-4 flex justify-end gap-2">
<button type="button" class="dh-btn dh-btn-ghost" @click="showCreate = false">Cancel</button>
<button type="button" class="dh-btn dh-btn-ghost" @click="showCreate = false">{{ t("common.cancel") }}</button>
<button type="submit" :disabled="creating" class="dh-btn dh-btn-primary">
{{ creating ? "Creating" : "Create user" }}
{{ creating ? t("admin.creating") : t("admin.createUser") }}
</button>
</div>
</form>
</Modal>
<!-- Reset password -->
<Modal v-if="pwUser" :title="`Reset password — ${pwUser.email}`" @close="pwUser = null">
<Modal v-if="pwUser" :title="t('admin.resetTitle', { email: pwUser.email })" @close="pwUser = null">
<p v-if="pwError" class="mb-3 rounded-control bg-danger-soft px-3 py-2 text-sm font-medium text-danger">{{ pwError }}</p>
<form class="space-y-3" @submit.prevent="submitResetPassword">
<div>
<label class="dh-label">New password <span class="text-muted">(min 8)</span></label>
<label class="dh-label">{{ t("admin.newPassword") }} <span class="text-muted">{{ t("admin.minChars") }}</span></label>
<input v-model="newPassword" type="text" required minlength="8" autocomplete="new-password" class="dh-input data" />
</div>
<div class="mt-4 flex justify-end gap-2">
<button type="button" class="dh-btn dh-btn-ghost" @click="pwUser = null">Cancel</button>
<button type="button" class="dh-btn dh-btn-ghost" @click="pwUser = null">{{ t("common.cancel") }}</button>
<button type="submit" :disabled="savingPw" class="dh-btn dh-btn-primary">
{{ savingPw ? "Saving" : "Set password" }}
{{ savingPw ? t("common.saving") : t("admin.setPassword") }}
</button>
</div>
</form>
+216 -239
View File
@@ -13,6 +13,7 @@ import {
expiryStatus,
reminderStatus,
} from "../lib/format.js";
import { t, tSplit } from "../i18n";
import CarFormModal from "../components/CarFormModal.vue";
import ServiceFormModal from "../components/ServiceFormModal.vue";
import TechnicalCheckFormModal from "../components/TechnicalCheckFormModal.vue";
@@ -81,16 +82,18 @@ const dueReminders = computed(
() => reminders.value.filter((r) => r.status === "overdue" || r.status === "due_soon").length
);
const TABS = [
{ key: "info", label: "Information" },
{ key: "services", label: "Service history" },
{ key: "technical", label: "Technical check history" },
{ key: "maintenance", label: "Maintenance" },
{ key: "fuel", label: "Fuel" },
{ key: "documents", label: "Documents" },
{ key: "parts", label: "Parts catalog" },
{ key: "reminders", label: "Reminders" },
];
// Computed, not a plain array: t() reads the reactive locale, so the tab labels
// have to re-evaluate when the language changes.
const TABS = computed(() => [
{ key: "info", label: t("car.tabs.info") },
{ key: "services", label: t("car.tabs.services") },
{ key: "technical", label: t("car.tabs.technical") },
{ key: "maintenance", label: t("car.tabs.maintenance") },
{ key: "fuel", label: t("car.tabs.fuel") },
{ key: "documents", label: t("car.tabs.documents") },
{ key: "parts", label: t("car.tabs.parts") },
{ key: "reminders", label: t("car.tabs.reminders") },
]);
async function load() {
loading.value = true;
@@ -139,7 +142,7 @@ async function onServiceSaved() {
await load();
}
async function deleteService(id) {
if (!confirm("Delete this service record?")) return;
if (!confirm(t("car.services.confirmDelete"))) return;
try {
await api.deleteService(id);
await load();
@@ -163,7 +166,7 @@ async function onTechnicalCheckSaved() {
await load();
}
async function deleteTechnicalCheck(id) {
if (!confirm("Delete this technical check?")) return;
if (!confirm(t("car.technical.confirmDelete"))) return;
try {
await api.deleteTechnicalCheck(id);
await load();
@@ -186,7 +189,7 @@ async function onPartSaved() {
parts.value = await api.listCarParts(props.id);
}
async function deletePart(id) {
if (!confirm("Delete this part?")) return;
if (!confirm(t("car.parts.confirmDelete"))) return;
try {
await api.deletePart(id);
parts.value = await api.listCarParts(props.id);
@@ -221,7 +224,7 @@ async function onFuelSaved() {
await reloadFuel();
}
async function deleteFuel(id) {
if (!confirm("Delete this refill?")) return;
if (!confirm(t("car.fuel.confirmDelete"))) return;
try {
await api.deleteFuel(id);
await reloadFuel();
@@ -250,7 +253,7 @@ async function onMaintenanceSaved() {
]);
}
async function deleteMaintenance(id) {
if (!confirm("Delete this workshop visit?")) return;
if (!confirm(t("car.maintenance.confirmDelete"))) return;
try {
await api.deleteMaintenance(id);
maintenance.value = await api.listCarMaintenance(props.id);
@@ -278,7 +281,7 @@ async function onDocumentSaved() {
]);
}
async function deleteDocument(id) {
if (!confirm("Delete this document?")) return;
if (!confirm(t("car.documents.confirmDelete"))) return;
try {
await api.deleteDocument(id);
[documents.value, reminders.value] = await Promise.all([
@@ -347,7 +350,7 @@ async function reopenReminder(r) {
}
}
async function deleteReminder(id) {
if (!confirm("Delete this reminder?")) return;
if (!confirm(t("car.reminders.confirmDelete"))) return;
try {
await api.deleteReminder(id);
reminders.value = await api.listCarReminders(props.id);
@@ -385,60 +388,33 @@ async function confirmDeleteCar() {
}
function yn(v) {
return v ? "Yes" : "No";
return v ? t("common.yes") : t("common.no");
}
const FUEL_LABELS = {
petrol: "Petrol (gasoline)",
petrol_lpg: "Petrol (gasoline) + LPG",
diesel: "Diesel",
diesel_lpg: "Diesel + LPG",
hybrid: "Hybrid",
electric: "Electric",
hydrogen: "Hydrogen",
};
// Enum → localized label. Each of these mirrors a block in enums.* of the
// language files; an unknown value falls through to the raw code (or an em dash
// for fuel, which is the "none set" case) rather than a missing-key marker.
function fuelLabel(v) {
return FUEL_LABELS[v] || "—";
return v ? t(`enums.fuelType.${v}`) : t("common.empty");
}
function maintenanceTypeLabel(v) {
return v ? t(`enums.maintenanceType.${v}`) : v;
}
function maintenanceStatusLabel(v) {
return v ? t(`enums.maintenanceStatus.${v}`) : v;
}
function documentTypeLabel(v) {
return v ? t(`enums.documentType.${v}`) : v;
}
function reminderTypeLabel(v) {
return v ? t(`enums.reminderTypeShort.${v}`) : v;
}
const MAINTENANCE_LABELS = {
repair: "Repair",
inspection: "Inspection",
bodywork: "Bodywork",
tyres: "Tyres",
diagnostics: "Diagnostics",
recall: "Recall",
warranty: "Warranty work",
other: "Other",
};
const MAINTENANCE_STATUS = {
scheduled: "dh-badge dh-badge-warning",
in_progress: "dh-badge dh-badge-warning",
completed: "dh-badge dh-badge-success",
};
const MAINTENANCE_STATUS_LABELS = {
scheduled: "Scheduled",
in_progress: "In progress",
completed: "Completed",
};
const DOCUMENT_LABELS = {
insurance: "Insurance",
pollution: "Pollution certificate",
registration: "Registration",
inspection: "Inspection",
roadTax: "Road tax",
warranty: "Warranty",
other: "Other",
};
const REMINDER_LABELS = {
maintenance: "Maintenance",
document: "Document",
service: "Service",
inspection: "Inspection",
other: "Other",
};
onMounted(load);
</script>
@@ -446,11 +422,11 @@ onMounted(load);
<template>
<div>
<RouterLink to="/" class="mb-4 inline-flex items-center gap-1 text-sm font-medium text-brandtext hover:underline">
All cars
{{ t("car.allCars") }}
</RouterLink>
<p v-if="error" class="mb-4 rounded-control bg-danger-soft px-4 py-3 text-sm font-medium text-danger">{{ error }}</p>
<p v-if="loading" class="text-muted">Loading</p>
<p v-if="loading" class="text-muted">{{ t("common.loading") }}</p>
<template v-else-if="car">
<!-- Header -->
@@ -465,12 +441,12 @@ onMounted(load);
</div>
<div class="flex flex-wrap items-center gap-2">
<span v-if="!isOwner" class="dh-badge dh-badge-neutral">
Shared{{ isReadOnly ? ' · read-only' : '' }}
{{ isReadOnly ? t("car.sharedReadOnly") : t("car.shared") }}
</span>
<span :class="status.classes">{{ status.label }}</span>
<button v-if="isOwner" class="dh-btn dh-btn-ghost !px-3 !py-1.5" @click="showShare = true">Share</button>
<button v-if="canWrite" class="dh-btn dh-btn-ghost !px-3 !py-1.5" @click="showCarEdit = true">Edit</button>
<button v-if="isOwner" class="dh-btn !px-3 !py-1.5 border border-danger/30 text-danger hover:bg-danger-soft" @click="openDeleteCar">Delete</button>
<button v-if="isOwner" class="dh-btn dh-btn-ghost !px-3 !py-1.5" @click="showShare = true">{{ t("car.share") }}</button>
<button v-if="canWrite" class="dh-btn dh-btn-ghost !px-3 !py-1.5" @click="showCarEdit = true">{{ t("common.edit") }}</button>
<button v-if="isOwner" class="dh-btn !px-3 !py-1.5 border border-danger/30 text-danger hover:bg-danger-soft" @click="openDeleteCar">{{ t("common.delete") }}</button>
</div>
</div>
</div>
@@ -478,16 +454,16 @@ onMounted(load);
<!-- Tabs -->
<div class="mb-6 flex flex-wrap gap-1 border-b border-subtle">
<button
v-for="t in TABS"
:key="t.key"
v-for="tab in TABS"
:key="tab.key"
class="-mb-px flex items-center gap-1.5 border-b-2 px-4 py-2.5 text-sm font-semibold transition-colors"
:class="activeTab === t.key
:class="activeTab === tab.key
? 'border-accent text-brandtext'
: 'border-transparent text-muted hover:text-strong'"
@click="activeTab = t.key">
{{ t.label }}
@click="activeTab = tab.key">
{{ tab.label }}
<span
v-if="t.key === 'reminders' && dueReminders"
v-if="tab.key === 'reminders' && dueReminders"
class="rounded-full bg-danger px-1.5 py-0.5 text-[10px] font-bold leading-none text-white">
{{ dueReminders }}
</span>
@@ -498,20 +474,20 @@ onMounted(load);
<section v-if="activeTab === 'info'">
<div class="dh-card p-6">
<dl class="grid grid-cols-2 gap-4 text-sm sm:grid-cols-4">
<div><dt class="eyebrow">Engine oil spec</dt><dd class="mt-0.5 font-medium text-strong">{{ car.oilSpec || '—' }}</dd></div>
<div><dt class="eyebrow">Transmission oil</dt><dd class="mt-0.5 font-medium text-strong">{{ car.transmissionOilSpec || '—' }}</dd></div>
<div><dt class="eyebrow">Differential oil</dt><dd class="mt-0.5 font-medium text-strong">{{ car.differentialOilSpec || '—' }}</dd></div>
<div><dt class="eyebrow">Brake fluid</dt><dd class="mt-0.5 font-medium text-strong">{{ car.brakeFluidSpec || '—' }}</dd></div>
<div><dt class="eyebrow">Coolant</dt><dd class="mt-0.5 font-medium text-strong">{{ car.coolantSpec || '—' }}</dd></div>
<div><dt class="eyebrow">Odometer</dt><dd class="mt-0.5 data font-medium text-strong">{{ formatKm(car.currentKm) }}</dd></div>
<div><dt class="eyebrow">Service interval</dt><dd class="mt-0.5 data font-medium text-strong">{{ car.serviceIntervalDays }}d · {{ formatKm(car.serviceIntervalKm) }}</dd></div>
<div><dt class="eyebrow">Next due</dt><dd class="mt-0.5 data font-medium text-strong">{{ formatDate(latest?.nextServiceDate) }} · {{ formatKm(latest?.nextServiceKm) }}</dd></div>
<div><dt class="eyebrow">Registration plate</dt><dd class="mt-0.5 data font-medium text-strong">{{ car.registration || '—' }}</dd></div>
<div><dt class="eyebrow">Registration country</dt><dd class="mt-0.5 font-medium text-strong">{{ car.registrationCountry || '—' }}</dd></div>
<div><dt class="eyebrow">VIN</dt><dd class="mt-0.5 data font-medium text-strong">{{ car.vin || '—' }}</dd></div>
<div><dt class="eyebrow">Fuel type</dt><dd class="mt-0.5 font-medium text-strong">{{ fuelLabel(car.fuelType) }}</dd></div>
<div><dt class="eyebrow">Build date</dt><dd class="mt-0.5 data font-medium text-strong">{{ car.buildDate ? formatDate(car.buildDate) : '—' }}</dd></div>
<div><dt class="eyebrow">First registration</dt><dd class="mt-0.5 data font-medium text-strong">{{ car.firstRegistrationDate ? formatDate(car.firstRegistrationDate) : '—' }}</dd></div>
<div><dt class="eyebrow">{{ t("car.info.oilSpec") }}</dt><dd class="mt-0.5 font-medium text-strong">{{ car.oilSpec || t("common.empty") }}</dd></div>
<div><dt class="eyebrow">{{ t("car.info.transmissionOil") }}</dt><dd class="mt-0.5 font-medium text-strong">{{ car.transmissionOilSpec || t("common.empty") }}</dd></div>
<div><dt class="eyebrow">{{ t("car.info.differentialOil") }}</dt><dd class="mt-0.5 font-medium text-strong">{{ car.differentialOilSpec || t("common.empty") }}</dd></div>
<div><dt class="eyebrow">{{ t("car.info.brakeFluid") }}</dt><dd class="mt-0.5 font-medium text-strong">{{ car.brakeFluidSpec || t("common.empty") }}</dd></div>
<div><dt class="eyebrow">{{ t("car.info.coolant") }}</dt><dd class="mt-0.5 font-medium text-strong">{{ car.coolantSpec || t("common.empty") }}</dd></div>
<div><dt class="eyebrow">{{ t("car.info.odometer") }}</dt><dd class="mt-0.5 data font-medium text-strong">{{ formatKm(car.currentKm) }}</dd></div>
<div><dt class="eyebrow">{{ t("car.info.serviceInterval") }}</dt><dd class="mt-0.5 data font-medium text-strong">{{ car.serviceIntervalDays }}d · {{ formatKm(car.serviceIntervalKm) }}</dd></div>
<div><dt class="eyebrow">{{ t("car.info.nextDue") }}</dt><dd class="mt-0.5 data font-medium text-strong">{{ formatDate(latest?.nextServiceDate) }} · {{ formatKm(latest?.nextServiceKm) }}</dd></div>
<div><dt class="eyebrow">{{ t("car.info.registrationPlate") }}</dt><dd class="mt-0.5 data font-medium text-strong">{{ car.registration || t("common.empty") }}</dd></div>
<div><dt class="eyebrow">{{ t("car.info.registrationCountry") }}</dt><dd class="mt-0.5 font-medium text-strong">{{ car.registrationCountry || t("common.empty") }}</dd></div>
<div><dt class="eyebrow">{{ t("car.info.vin") }}</dt><dd class="mt-0.5 data font-medium text-strong">{{ car.vin || t("common.empty") }}</dd></div>
<div><dt class="eyebrow">{{ t("car.info.fuelType") }}</dt><dd class="mt-0.5 font-medium text-strong">{{ fuelLabel(car.fuelType) }}</dd></div>
<div><dt class="eyebrow">{{ t("car.info.buildDate") }}</dt><dd class="mt-0.5 data font-medium text-strong">{{ car.buildDate ? formatDate(car.buildDate) : t("common.empty") }}</dd></div>
<div><dt class="eyebrow">{{ t("car.info.firstRegistration") }}</dt><dd class="mt-0.5 data font-medium text-strong">{{ car.firstRegistrationDate ? formatDate(car.firstRegistrationDate) : t("common.empty") }}</dd></div>
</dl>
</div>
</section>
@@ -519,30 +495,30 @@ onMounted(load);
<!-- Service history -->
<section v-else-if="activeTab === 'services'">
<div class="mb-3 flex items-center justify-between">
<h2 class="text-lg font-bold tracking-[-0.02em] text-strong">Service history</h2>
<h2 class="text-lg font-bold tracking-[-0.02em] text-strong">{{ t("car.services.title") }}</h2>
<button v-if="canWrite" class="dh-btn dh-btn-primary !px-3 !py-1.5" @click="openAddService">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" class="h-4 w-4"><path stroke-linecap="round" stroke-linejoin="round" d="M12 5v14M5 12h14" /></svg>
Add service
{{ t("car.services.add") }}
</button>
</div>
<div v-if="services.length === 0" class="rounded-card border border-dashed border-default p-8 text-center text-muted">
No service records yet.
{{ t("car.services.empty") }}
</div>
<div v-else class="dh-card overflow-x-auto p-0">
<table class="min-w-full text-sm">
<thead class="bg-sunken text-left">
<tr class="[&>th]:eyebrow [&>th]:px-4 [&>th]:py-3">
<th>Date</th>
<th>Km</th>
<th>Next date</th>
<th>Next km</th>
<th class="!text-center">Oil &amp; Oil filter</th>
<th class="!text-center">Engine air filter</th>
<th class="!text-center">Cabin air filter</th>
<th>Notes</th>
<th>File</th>
<th>{{ t("car.services.colDate") }}</th>
<th>{{ t("car.services.colKm") }}</th>
<th>{{ t("car.services.colNextDate") }}</th>
<th>{{ t("car.services.colNextKm") }}</th>
<th class="!text-center">{{ t("car.services.colOil") }}</th>
<th class="!text-center">{{ t("car.services.colEngineFilter") }}</th>
<th class="!text-center">{{ t("car.services.colCabinFilter") }}</th>
<th>{{ t("car.services.colNotes") }}</th>
<th>{{ t("car.services.colFile") }}</th>
<th v-if="canWrite"></th>
</tr>
</thead>
@@ -555,16 +531,16 @@ onMounted(load);
<td class="px-4 py-3 text-center text-xs font-semibold" :class="s.changedOil ? 'text-success' : 'text-muted'">{{ yn(s.changedOil) }}</td>
<td class="px-4 py-3 text-center text-xs font-semibold" :class="s.changedEngineAirFilter ? 'text-success' : 'text-muted'">{{ yn(s.changedEngineAirFilter) }}</td>
<td class="px-4 py-3 text-center text-xs font-semibold" :class="s.changedCabinAirFilter ? 'text-success' : 'text-muted'">{{ yn(s.changedCabinAirFilter) }}</td>
<td class="px-4 py-3 text-body">{{ s.notes || '—' }}</td>
<td class="px-4 py-3 text-body">{{ s.notes || t("common.empty") }}</td>
<td class="whitespace-nowrap px-4 py-3">
<button v-if="s.hasFile" class="text-xs font-medium text-brandtext hover:underline" @click="downloadAttachment('services', s)">
Download
{{ t("common.download") }}
</button>
<span v-else class="text-xs text-muted"></span>
<span v-else class="text-xs text-muted">{{ t("common.empty") }}</span>
</td>
<td v-if="canWrite" class="whitespace-nowrap px-4 py-3 text-right">
<button class="text-xs font-medium text-brandtext hover:underline" @click="openEditService(s)">Edit</button>
<button class="ml-3 text-xs font-medium text-danger hover:underline" @click="deleteService(s.id)">Delete</button>
<button class="text-xs font-medium text-brandtext hover:underline" @click="openEditService(s)">{{ t("common.edit") }}</button>
<button class="ml-3 text-xs font-medium text-danger hover:underline" @click="deleteService(s.id)">{{ t("common.delete") }}</button>
</td>
</tr>
</tbody>
@@ -576,60 +552,58 @@ onMounted(load);
<section v-else-if="activeTab === 'technical'">
<div class="mb-3 flex items-center justify-between">
<div>
<h2 class="text-lg font-bold tracking-[-0.02em] text-strong">Technical check history</h2>
<p class="text-sm text-muted">
Mandatory roadworthiness inspections. Recurs on time alone, whatever the odometer reads.
</p>
<h2 class="text-lg font-bold tracking-[-0.02em] text-strong">{{ t("car.technical.title") }}</h2>
<p class="text-sm text-muted">{{ t("car.technical.subtitle") }}</p>
</div>
<button v-if="canWrite" class="dh-btn dh-btn-primary !px-3 !py-1.5" @click="openAddTechnicalCheck">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" class="h-4 w-4"><path stroke-linecap="round" stroke-linejoin="round" d="M12 5v14M5 12h14" /></svg>
Add check
{{ t("car.technical.add") }}
</button>
</div>
<div v-if="technicalChecks.length === 0" class="rounded-card border border-dashed border-default p-8 text-center text-muted">
No technical checks yet.
{{ t("car.technical.empty") }}
</div>
<div v-else class="dh-card overflow-x-auto p-0">
<table class="min-w-full text-sm">
<thead class="bg-sunken text-left">
<tr class="[&>th]:eyebrow [&>th]:px-4 [&>th]:py-3">
<th>Date</th>
<th>Result</th>
<th>Next check</th>
<th>Status</th>
<th>Station</th>
<th class="!text-right">Cost</th>
<th>Notes</th>
<th>File</th>
<th>{{ t("car.technical.colDate") }}</th>
<th>{{ t("car.technical.colResult") }}</th>
<th>{{ t("car.technical.colNextCheck") }}</th>
<th>{{ t("car.technical.colStatus") }}</th>
<th>{{ t("car.technical.colStation") }}</th>
<th class="!text-right">{{ t("car.technical.colCost") }}</th>
<th>{{ t("car.technical.colNotes") }}</th>
<th>{{ t("car.technical.colFile") }}</th>
<th v-if="canWrite"></th>
</tr>
</thead>
<tbody class="divide-y divide-subtle">
<tr v-for="t in technicalChecks" :key="t.id" class="transition-colors hover:bg-sunken">
<td class="whitespace-nowrap px-4 py-3 data font-medium text-strong">{{ formatDate(t.date) }}</td>
<tr v-for="tc in technicalChecks" :key="tc.id" class="transition-colors hover:bg-sunken">
<td class="whitespace-nowrap px-4 py-3 data font-medium text-strong">{{ formatDate(tc.date) }}</td>
<td class="whitespace-nowrap px-4 py-3">
<span :class="t.result === 'failed' ? 'dh-badge dh-badge-danger' : 'dh-badge dh-badge-success'">
{{ t.result === 'failed' ? 'Failed' : 'Passed' }}
<span :class="tc.result === 'failed' ? 'dh-badge dh-badge-danger' : 'dh-badge dh-badge-success'">
{{ tc.result === 'failed' ? t("car.technical.failed") : t("car.technical.passed") }}
</span>
</td>
<td class="whitespace-nowrap px-4 py-3 data text-muted">{{ formatDate(t.nextCheckDate) }}</td>
<td class="whitespace-nowrap px-4 py-3 data text-muted">{{ formatDate(tc.nextCheckDate) }}</td>
<td class="whitespace-nowrap px-4 py-3">
<span :class="expiryStatus(t).classes">{{ expiryStatus(t).label }}</span>
<span :class="expiryStatus(tc).classes">{{ expiryStatus(tc).label }}</span>
</td>
<td class="px-4 py-3 text-body">{{ t.station || '—' }}</td>
<td class="whitespace-nowrap px-4 py-3 text-right data text-body">{{ t.cost ? formatMoney(t.cost) : '—' }}</td>
<td class="px-4 py-3 text-body">{{ t.notes || '—' }}</td>
<td class="px-4 py-3 text-body">{{ tc.station || t("common.empty") }}</td>
<td class="whitespace-nowrap px-4 py-3 text-right data text-body">{{ tc.cost ? formatMoney(tc.cost) : t("common.empty") }}</td>
<td class="px-4 py-3 text-body">{{ tc.notes || t("common.empty") }}</td>
<td class="whitespace-nowrap px-4 py-3">
<button v-if="t.hasFile" class="text-xs font-medium text-brandtext hover:underline" @click="downloadAttachment('technical', t)">
Download
<button v-if="tc.hasFile" class="text-xs font-medium text-brandtext hover:underline" @click="downloadAttachment('technical', tc)">
{{ t("common.download") }}
</button>
<span v-else class="text-xs text-muted"></span>
<span v-else class="text-xs text-muted">{{ t("common.empty") }}</span>
</td>
<td v-if="canWrite" class="whitespace-nowrap px-4 py-3 text-right">
<button class="text-xs font-medium text-brandtext hover:underline" @click="openEditTechnicalCheck(t)">Edit</button>
<button class="ml-3 text-xs font-medium text-danger hover:underline" @click="deleteTechnicalCheck(t.id)">Delete</button>
<button class="text-xs font-medium text-brandtext hover:underline" @click="openEditTechnicalCheck(tc)">{{ t("common.edit") }}</button>
<button class="ml-3 text-xs font-medium text-danger hover:underline" @click="deleteTechnicalCheck(tc.id)">{{ t("common.delete") }}</button>
</td>
</tr>
</tbody>
@@ -641,31 +615,31 @@ onMounted(load);
<section v-else-if="activeTab === 'maintenance'">
<div class="mb-3 flex items-center justify-between">
<div>
<h2 class="text-lg font-bold tracking-[-0.02em] text-strong">Maintenance</h2>
<p class="text-sm text-muted">Workshop visits and repairs. Routine servicing lives under Service history.</p>
<h2 class="text-lg font-bold tracking-[-0.02em] text-strong">{{ t("car.maintenance.title") }}</h2>
<p class="text-sm text-muted">{{ t("car.maintenance.subtitle") }}</p>
</div>
<button v-if="canWrite" class="dh-btn dh-btn-primary !px-3 !py-1.5" @click="openAddMaintenance">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" class="h-4 w-4"><path stroke-linecap="round" stroke-linejoin="round" d="M12 5v14M5 12h14" /></svg>
Log visit
{{ t("car.maintenance.add") }}
</button>
</div>
<div v-if="maintenance.length === 0" class="rounded-card border border-dashed border-default p-8 text-center text-muted">
No workshop visits logged yet.
{{ t("car.maintenance.empty") }}
</div>
<div v-else class="dh-card overflow-x-auto p-0">
<table class="min-w-full text-sm">
<thead class="bg-sunken text-left">
<tr class="[&>th]:eyebrow [&>th]:px-4 [&>th]:py-3">
<th>Date</th>
<th>Km</th>
<th>Type</th>
<th>Work done</th>
<th>Workshop</th>
<th>Status</th>
<th class="!text-right">Cost</th>
<th>File</th>
<th>{{ t("car.maintenance.colDate") }}</th>
<th>{{ t("car.maintenance.colKm") }}</th>
<th>{{ t("car.maintenance.colType") }}</th>
<th>{{ t("car.maintenance.colWork") }}</th>
<th>{{ t("car.maintenance.colWorkshop") }}</th>
<th>{{ t("car.maintenance.colStatus") }}</th>
<th class="!text-right">{{ t("car.maintenance.colCost") }}</th>
<th>{{ t("car.maintenance.colFile") }}</th>
<th v-if="canWrite"></th>
</tr>
</thead>
@@ -673,35 +647,35 @@ onMounted(load);
<tr v-for="m in maintenance" :key="m.id" class="transition-colors hover:bg-sunken">
<td class="whitespace-nowrap px-4 py-3 data font-medium text-strong">{{ formatDate(m.date) }}</td>
<td class="whitespace-nowrap px-4 py-3 data text-body">{{ formatKm(m.km) }}</td>
<td class="whitespace-nowrap px-4 py-3 text-body">{{ MAINTENANCE_LABELS[m.type] || m.type }}</td>
<td class="whitespace-nowrap px-4 py-3 text-body">{{ maintenanceTypeLabel(m.type) }}</td>
<td class="px-4 py-3 text-body">
<div class="font-medium text-strong">{{ m.description }}</div>
<div v-if="m.partsUsed" class="text-xs text-muted">{{ m.partsUsed }}</div>
<div v-if="m.warrantyActive" class="mt-0.5 text-xs text-success">
Under warranty · {{ m.warrantyDaysLeft }}d left
{{ t("car.maintenance.underWarranty", { days: m.warrantyDaysLeft }) }}
</div>
</td>
<td class="px-4 py-3 text-body">
{{ m.workshop || '—' }}
{{ m.workshop || t("common.empty") }}
<div v-if="m.location" class="text-xs text-muted">{{ m.location }}</div>
</td>
<td class="whitespace-nowrap px-4 py-3">
<span :class="MAINTENANCE_STATUS[m.status] || 'dh-badge dh-badge-neutral'">
{{ MAINTENANCE_STATUS_LABELS[m.status] || m.status }}
{{ maintenanceStatusLabel(m.status) }}
</span>
</td>
<td class="whitespace-nowrap px-4 py-3 text-right data text-body">
{{ m.totalCost ? formatMoney(m.totalCost) : '—' }}
{{ m.totalCost ? formatMoney(m.totalCost) : t("common.empty") }}
</td>
<td class="whitespace-nowrap px-4 py-3">
<button v-if="m.hasFile" class="text-xs font-medium text-brandtext hover:underline" @click="downloadAttachment('maintenance', m)">
Download
{{ t("common.download") }}
</button>
<span v-else class="text-xs text-muted"></span>
<span v-else class="text-xs text-muted">{{ t("common.empty") }}</span>
</td>
<td v-if="canWrite" class="whitespace-nowrap px-4 py-3 text-right">
<button class="text-xs font-medium text-brandtext hover:underline" @click="openEditMaintenance(m)">Edit</button>
<button class="ml-3 text-xs font-medium text-danger hover:underline" @click="deleteMaintenance(m.id)">Delete</button>
<button class="text-xs font-medium text-brandtext hover:underline" @click="openEditMaintenance(m)">{{ t("common.edit") }}</button>
<button class="ml-3 text-xs font-medium text-danger hover:underline" @click="deleteMaintenance(m.id)">{{ t("common.delete") }}</button>
</td>
</tr>
</tbody>
@@ -713,12 +687,12 @@ onMounted(load);
<section v-else-if="activeTab === 'fuel'">
<div class="mb-3 flex items-center justify-between">
<div>
<h2 class="text-lg font-bold tracking-[-0.02em] text-strong">Fuel</h2>
<p class="text-sm text-muted">Consumption is measured between full tanks.</p>
<h2 class="text-lg font-bold tracking-[-0.02em] text-strong">{{ t("car.fuel.title") }}</h2>
<p class="text-sm text-muted">{{ t("car.fuel.subtitle") }}</p>
</div>
<button v-if="canWrite" class="dh-btn dh-btn-primary !px-3 !py-1.5" @click="openAddFuel">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" class="h-4 w-4"><path stroke-linecap="round" stroke-linejoin="round" d="M12 5v14M5 12h14" /></svg>
Log refill
{{ t("car.fuel.add") }}
</button>
</div>
@@ -726,62 +700,62 @@ onMounted(load);
<div v-if="fuelStats && fuelStats.entries > 0" class="dh-card mb-4 p-6">
<dl class="grid grid-cols-2 gap-4 text-sm sm:grid-cols-4">
<div>
<dt class="eyebrow">Average</dt>
<dt class="eyebrow">{{ t("car.fuel.average") }}</dt>
<dd class="mt-0.5 data text-lg font-bold text-strong">{{ formatConsumption(fuelStats.avgConsumptionL100) }}</dd>
<dd class="text-xs text-muted">{{ formatKmPerLiter(fuelStats.avgKmPerLiter) }}</dd>
</div>
<div>
<dt class="eyebrow">Best</dt>
<dt class="eyebrow">{{ t("car.fuel.best") }}</dt>
<dd class="mt-0.5 data font-medium text-success">{{ formatConsumption(fuelStats.bestConsumptionL100) }}</dd>
</div>
<div>
<dt class="eyebrow">Worst</dt>
<dt class="eyebrow">{{ t("car.fuel.worst") }}</dt>
<dd class="mt-0.5 data font-medium text-danger">{{ formatConsumption(fuelStats.worstConsumptionL100) }}</dd>
</div>
<div>
<dt class="eyebrow">Cost per km</dt>
<dt class="eyebrow">{{ t("car.fuel.costPerKm") }}</dt>
<dd class="mt-0.5 data font-medium text-strong">{{ formatMoney(fuelStats.costPerKm) }}</dd>
</div>
<div>
<dt class="eyebrow">Refills</dt>
<dt class="eyebrow">{{ t("car.fuel.refills") }}</dt>
<dd class="mt-0.5 data font-medium text-strong">{{ fuelStats.entries }}</dd>
</div>
<div>
<dt class="eyebrow">Total litres</dt>
<dt class="eyebrow">{{ t("car.fuel.totalLiters") }}</dt>
<dd class="mt-0.5 data font-medium text-strong">{{ formatLiters(fuelStats.totalLiters) }}</dd>
</div>
<div>
<dt class="eyebrow">Total spent</dt>
<dt class="eyebrow">{{ t("car.fuel.totalSpent") }}</dt>
<dd class="mt-0.5 data font-medium text-strong">{{ formatMoney(fuelStats.totalCost) }}</dd>
</div>
<div>
<dt class="eyebrow">Tracked distance</dt>
<dt class="eyebrow">{{ t("car.fuel.trackedDistance") }}</dt>
<dd class="mt-0.5 data font-medium text-strong">{{ formatKm(fuelStats.trackedDistanceKm) }}</dd>
<dd class="text-xs text-muted">Avg. price {{ formatMoney(fuelStats.avgPricePerLiter) }}/L</dd>
<dd class="text-xs text-muted">{{ t("car.fuel.avgPrice", { price: formatMoney(fuelStats.avgPricePerLiter) }) }}</dd>
</div>
</dl>
<p v-if="!fuelStats.avgConsumptionL100" class="mt-4 text-xs text-muted">
Log at least two full tanks to see consumption figures.
{{ t("car.fuel.needTwoTanks") }}
</p>
</div>
<div v-if="fuel.length === 0" class="rounded-card border border-dashed border-default p-8 text-center text-muted">
No refills logged yet.
{{ t("car.fuel.empty") }}
</div>
<div v-else class="dh-card overflow-x-auto p-0">
<table class="min-w-full text-sm">
<thead class="bg-sunken text-left">
<tr class="[&>th]:eyebrow [&>th]:px-4 [&>th]:py-3">
<th>Date</th>
<th>Km</th>
<th class="!text-right">Litres</th>
<th class="!text-right">Cost</th>
<th class="!text-right">Per litre</th>
<th class="!text-right">Distance</th>
<th class="!text-right">Consumption</th>
<th>Station</th>
<th>File</th>
<th>{{ t("car.fuel.colDate") }}</th>
<th>{{ t("car.fuel.colKm") }}</th>
<th class="!text-right">{{ t("car.fuel.colLiters") }}</th>
<th class="!text-right">{{ t("car.fuel.colCost") }}</th>
<th class="!text-right">{{ t("car.fuel.colPerLiter") }}</th>
<th class="!text-right">{{ t("car.fuel.colDistance") }}</th>
<th class="!text-right">{{ t("car.fuel.colConsumption") }}</th>
<th>{{ t("car.fuel.colStation") }}</th>
<th>{{ t("car.fuel.colFile") }}</th>
<th v-if="canWrite"></th>
</tr>
</thead>
@@ -789,30 +763,30 @@ onMounted(load);
<tr v-for="f in fuel" :key="f.id" class="transition-colors hover:bg-sunken">
<td class="whitespace-nowrap px-4 py-3 data font-medium text-strong">
{{ formatDate(f.date) }}
<span v-if="!f.fullTank" class="ml-1 text-xs font-normal text-muted">partial</span>
<span v-if="f.missedFill" class="ml-1 text-xs font-normal text-warning">gap</span>
<span v-if="!f.fullTank" class="ml-1 text-xs font-normal text-muted">{{ t("car.fuel.partial") }}</span>
<span v-if="f.missedFill" class="ml-1 text-xs font-normal text-warning">{{ t("car.fuel.gap") }}</span>
</td>
<td class="whitespace-nowrap px-4 py-3 data text-body">{{ formatKm(f.km) }}</td>
<td class="whitespace-nowrap px-4 py-3 text-right data text-body">{{ formatLiters(f.liters) }}</td>
<td class="whitespace-nowrap px-4 py-3 text-right data text-body">{{ f.cost ? formatMoney(f.cost) : '—' }}</td>
<td class="whitespace-nowrap px-4 py-3 text-right data text-body">{{ f.cost ? formatMoney(f.cost) : t("common.empty") }}</td>
<td class="whitespace-nowrap px-4 py-3 text-right data text-muted">{{ formatMoney(f.pricePerLiter) }}</td>
<td class="whitespace-nowrap px-4 py-3 text-right data text-muted">{{ f.distanceKm ? formatKm(f.distanceKm) : '—' }}</td>
<td class="whitespace-nowrap px-4 py-3 text-right data text-muted">{{ f.distanceKm ? formatKm(f.distanceKm) : t("common.empty") }}</td>
<td class="whitespace-nowrap px-4 py-3 text-right data font-medium" :class="f.consumptionL100 ? 'text-strong' : 'text-muted'">
{{ formatConsumption(f.consumptionL100) }}
</td>
<td class="px-4 py-3 text-body">
{{ f.station || '—' }}
{{ f.station || t("common.empty") }}
<div v-if="f.notes" class="text-xs text-muted">{{ f.notes }}</div>
</td>
<td class="whitespace-nowrap px-4 py-3">
<button v-if="f.hasFile" class="text-xs font-medium text-brandtext hover:underline" @click="downloadAttachment('fuel', f)">
Download
{{ t("common.download") }}
</button>
<span v-else class="text-xs text-muted"></span>
<span v-else class="text-xs text-muted">{{ t("common.empty") }}</span>
</td>
<td v-if="canWrite" class="whitespace-nowrap px-4 py-3 text-right">
<button class="text-xs font-medium text-brandtext hover:underline" @click="openEditFuel(f)">Edit</button>
<button class="ml-3 text-xs font-medium text-danger hover:underline" @click="deleteFuel(f.id)">Delete</button>
<button class="text-xs font-medium text-brandtext hover:underline" @click="openEditFuel(f)">{{ t("common.edit") }}</button>
<button class="ml-3 text-xs font-medium text-danger hover:underline" @click="deleteFuel(f.id)">{{ t("common.delete") }}</button>
</td>
</tr>
</tbody>
@@ -824,55 +798,55 @@ onMounted(load);
<section v-else-if="activeTab === 'documents'">
<div class="mb-3 flex items-center justify-between">
<div>
<h2 class="text-lg font-bold tracking-[-0.02em] text-strong">Documents</h2>
<p class="text-sm text-muted">Insurance, pollution certificates and other paperwork with renewal dates.</p>
<h2 class="text-lg font-bold tracking-[-0.02em] text-strong">{{ t("car.documents.title") }}</h2>
<p class="text-sm text-muted">{{ t("car.documents.subtitle") }}</p>
</div>
<button v-if="canWrite" class="dh-btn dh-btn-primary !px-3 !py-1.5" @click="openAddDocument">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" class="h-4 w-4"><path stroke-linecap="round" stroke-linejoin="round" d="M12 5v14M5 12h14" /></svg>
Add document
{{ t("car.documents.add") }}
</button>
</div>
<div v-if="documents.length === 0" class="rounded-card border border-dashed border-default p-8 text-center text-muted">
No documents yet.
{{ t("car.documents.empty") }}
</div>
<div v-else class="dh-card overflow-x-auto p-0">
<table class="min-w-full text-sm">
<thead class="bg-sunken text-left">
<tr class="[&>th]:eyebrow [&>th]:px-4 [&>th]:py-3">
<th>Type</th>
<th>Title</th>
<th>Provider</th>
<th>Issued</th>
<th>Renewal</th>
<th>Status</th>
<th>File</th>
<th>{{ t("car.documents.colType") }}</th>
<th>{{ t("car.documents.colTitle") }}</th>
<th>{{ t("car.documents.colProvider") }}</th>
<th>{{ t("car.documents.colIssued") }}</th>
<th>{{ t("car.documents.colRenewal") }}</th>
<th>{{ t("car.documents.colStatus") }}</th>
<th>{{ t("car.documents.colFile") }}</th>
<th v-if="canWrite"></th>
</tr>
</thead>
<tbody class="divide-y divide-subtle">
<tr v-for="d in documents" :key="d.id" class="transition-colors hover:bg-sunken">
<td class="whitespace-nowrap px-4 py-3 text-body">{{ DOCUMENT_LABELS[d.type] || d.type }}</td>
<td class="whitespace-nowrap px-4 py-3 text-body">{{ documentTypeLabel(d.type) }}</td>
<td class="px-4 py-3">
<div class="font-medium text-strong">{{ d.title }}</div>
<div v-if="d.reference" class="data text-xs text-muted">{{ d.reference }}</div>
</td>
<td class="px-4 py-3 text-body">{{ d.provider || '—' }}</td>
<td class="whitespace-nowrap px-4 py-3 data text-muted">{{ d.issueDate ? formatDate(d.issueDate) : '—' }}</td>
<td class="whitespace-nowrap px-4 py-3 data text-body">{{ d.expiryDate ? formatDate(d.expiryDate) : '—' }}</td>
<td class="px-4 py-3 text-body">{{ d.provider || t("common.empty") }}</td>
<td class="whitespace-nowrap px-4 py-3 data text-muted">{{ d.issueDate ? formatDate(d.issueDate) : t("common.empty") }}</td>
<td class="whitespace-nowrap px-4 py-3 data text-body">{{ d.expiryDate ? formatDate(d.expiryDate) : t("common.empty") }}</td>
<td class="whitespace-nowrap px-4 py-3">
<span :class="expiryStatus(d).classes">{{ expiryStatus(d).label }}</span>
</td>
<td class="whitespace-nowrap px-4 py-3">
<button v-if="d.hasFile" class="text-xs font-medium text-brandtext hover:underline" @click="downloadAttachment('documents', d)">
Download
{{ t("common.download") }}
</button>
<span v-else class="text-xs text-muted"></span>
<span v-else class="text-xs text-muted">{{ t("common.empty") }}</span>
</td>
<td v-if="canWrite" class="whitespace-nowrap px-4 py-3 text-right">
<button class="text-xs font-medium text-brandtext hover:underline" @click="openEditDocument(d)">Edit</button>
<button class="ml-3 text-xs font-medium text-danger hover:underline" @click="deleteDocument(d.id)">Delete</button>
<button class="text-xs font-medium text-brandtext hover:underline" @click="openEditDocument(d)">{{ t("common.edit") }}</button>
<button class="ml-3 text-xs font-medium text-danger hover:underline" @click="deleteDocument(d.id)">{{ t("common.delete") }}</button>
</td>
</tr>
</tbody>
@@ -884,17 +858,17 @@ onMounted(load);
<section v-else-if="activeTab === 'reminders'">
<div class="mb-3 flex items-center justify-between">
<div>
<h2 class="text-lg font-bold tracking-[-0.02em] text-strong">Reminders</h2>
<p class="text-sm text-muted">Renewal and service reminders are added automatically from your documents and service history.</p>
<h2 class="text-lg font-bold tracking-[-0.02em] text-strong">{{ t("car.reminders.title") }}</h2>
<p class="text-sm text-muted">{{ t("car.reminders.subtitle") }}</p>
</div>
<button v-if="canWrite" class="dh-btn dh-btn-primary !px-3 !py-1.5" @click="openAddReminder">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" class="h-4 w-4"><path stroke-linecap="round" stroke-linejoin="round" d="M12 5v14M5 12h14" /></svg>
Add reminder
{{ t("car.reminders.add") }}
</button>
</div>
<div v-if="reminders.length === 0" class="rounded-card border border-dashed border-default p-8 text-center text-muted">
Nothing to be reminded about yet.
{{ t("car.reminders.empty") }}
</div>
<ul v-else class="space-y-2">
@@ -906,14 +880,14 @@ onMounted(load);
<div class="min-w-0">
<div class="flex flex-wrap items-center gap-2">
<span class="font-medium text-strong" :class="r.done ? 'line-through' : ''">{{ r.title }}</span>
<span class="dh-badge dh-badge-neutral">{{ REMINDER_LABELS[r.type] || r.type }}</span>
<span v-if="r.auto" class="dh-badge dh-badge-neutral">Automatic</span>
<span v-if="r.repeatDays || r.repeatKm" class="dh-badge dh-badge-neutral">Repeats</span>
<span class="dh-badge dh-badge-neutral">{{ reminderTypeLabel(r.type) }}</span>
<span v-if="r.auto" class="dh-badge dh-badge-neutral">{{ t("car.reminders.automatic") }}</span>
<span v-if="r.repeatDays || r.repeatKm" class="dh-badge dh-badge-neutral">{{ t("car.reminders.repeats") }}</span>
</div>
<p class="mt-0.5 text-xs text-muted">
<span v-if="r.dueDate" class="data">{{ formatDate(r.dueDate) }}</span>
<span v-if="r.dueDate && r.dueKm"> · </span>
<span v-if="r.dueKm" class="data">at {{ formatKm(r.dueKm) }}</span>
<span v-if="r.dueKm" class="data">{{ t("car.reminders.at", { km: formatKm(r.dueKm) }) }}</span>
<span v-if="r.notes"> · {{ r.notes }}</span>
</p>
</div>
@@ -923,11 +897,11 @@ onMounted(load);
the document or logging the service they came from. -->
<template v-if="canWrite && !r.auto">
<button v-if="!r.done" class="text-xs font-medium text-success hover:underline" @click="completeReminder(r)">
{{ r.repeatDays || r.repeatKm ? 'Done · roll forward' : 'Mark done' }}
{{ r.repeatDays || r.repeatKm ? t("car.reminders.doneRollForward") : t("car.reminders.markDone") }}
</button>
<button v-else class="text-xs font-medium text-brandtext hover:underline" @click="reopenReminder(r)">Reopen</button>
<button class="text-xs font-medium text-brandtext hover:underline" @click="openEditReminder(r)">Edit</button>
<button class="text-xs font-medium text-danger hover:underline" @click="deleteReminder(r.id)">Delete</button>
<button v-else class="text-xs font-medium text-brandtext hover:underline" @click="reopenReminder(r)">{{ t("car.reminders.reopen") }}</button>
<button class="text-xs font-medium text-brandtext hover:underline" @click="openEditReminder(r)">{{ t("common.edit") }}</button>
<button class="text-xs font-medium text-danger hover:underline" @click="deleteReminder(r.id)">{{ t("common.delete") }}</button>
</template>
</div>
</li>
@@ -937,42 +911,42 @@ onMounted(load);
<!-- Parts catalog -->
<section v-else-if="activeTab === 'parts'">
<div class="mb-3 flex items-center justify-between">
<h2 class="text-lg font-bold tracking-[-0.02em] text-strong">Parts catalog</h2>
<h2 class="text-lg font-bold tracking-[-0.02em] text-strong">{{ t("car.parts.title") }}</h2>
<button v-if="canWrite" class="dh-btn dh-btn-primary !px-3 !py-1.5" @click="openAddPart">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" class="h-4 w-4"><path stroke-linecap="round" stroke-linejoin="round" d="M12 5v14M5 12h14" /></svg>
Add part
{{ t("car.parts.add") }}
</button>
</div>
<div v-if="parts.length === 0" class="rounded-card border border-dashed border-default p-8 text-center text-muted">
No parts yet.
{{ t("car.parts.empty") }}
</div>
<div v-else class="dh-card overflow-x-auto p-0">
<table class="min-w-full text-sm">
<thead class="bg-sunken text-left">
<tr class="[&>th]:eyebrow [&>th]:px-4 [&>th]:py-3">
<th>Part</th>
<th>Part number</th>
<th>Notes</th>
<th>File</th>
<th>{{ t("car.parts.colPart") }}</th>
<th>{{ t("car.parts.colPartNumber") }}</th>
<th>{{ t("car.parts.colNotes") }}</th>
<th>{{ t("car.parts.colFile") }}</th>
<th v-if="canWrite"></th>
</tr>
</thead>
<tbody class="divide-y divide-subtle">
<tr v-for="p in parts" :key="p.id" class="transition-colors hover:bg-sunken">
<td class="px-4 py-3 font-medium text-strong">{{ p.name }}</td>
<td class="px-4 py-3 data text-body">{{ p.partNumber || '—' }}</td>
<td class="px-4 py-3 text-body">{{ p.notes || '—' }}</td>
<td class="px-4 py-3 data text-body">{{ p.partNumber || t("common.empty") }}</td>
<td class="px-4 py-3 text-body">{{ p.notes || t("common.empty") }}</td>
<td class="whitespace-nowrap px-4 py-3">
<button v-if="p.hasFile" class="text-xs font-medium text-brandtext hover:underline" @click="downloadAttachment('parts', p)">
Download
{{ t("common.download") }}
</button>
<span v-else class="text-xs text-muted"></span>
<span v-else class="text-xs text-muted">{{ t("common.empty") }}</span>
</td>
<td v-if="canWrite" class="whitespace-nowrap px-4 py-3 text-right">
<button class="text-xs font-medium text-brandtext hover:underline" @click="openEditPart(p)">Edit</button>
<button class="ml-3 text-xs font-medium text-danger hover:underline" @click="deletePart(p.id)">Delete</button>
<button class="text-xs font-medium text-brandtext hover:underline" @click="openEditPart(p)">{{ t("common.edit") }}</button>
<button class="ml-3 text-xs font-medium text-danger hover:underline" @click="deletePart(p.id)">{{ t("common.delete") }}</button>
</td>
</tr>
</tbody>
@@ -1040,23 +1014,26 @@ onMounted(load);
<!-- Delete-car confirmation (type-to-confirm; cascade removes all data) -->
<div v-if="showDeleteCar && car" class="fixed inset-0 z-30 grid place-items-center bg-brand-900/40 p-4 backdrop-blur-sm" @click.self="showDeleteCar = false">
<div class="dh-card w-full max-w-md p-6 shadow-pop">
<h2 class="mb-2 text-lg font-bold tracking-[-0.02em] text-danger">Delete this car?</h2>
<h2 class="mb-2 text-lg font-bold tracking-[-0.02em] text-danger">{{ t("car.delete.title") }}</h2>
<p class="mb-4 text-sm text-body">
This permanently deletes <strong class="text-strong">{{ car.name }}</strong> and everything logged against it
<strong class="text-strong">{{ services.length }}</strong> service record{{ services.length === 1 ? '' : 's' }},
<strong class="text-strong">{{ maintenance.length }}</strong> workshop visit{{ maintenance.length === 1 ? '' : 's' }},
<strong class="text-strong">{{ fuel.length }}</strong> refill{{ fuel.length === 1 ? '' : 's' }},
<strong class="text-strong">{{ documents.length }}</strong> document{{ documents.length === 1 ? '' : 's' }} and
<strong class="text-strong">{{ parts.length }}</strong> part{{ parts.length === 1 ? '' : 's' }}. This cannot be undone.
{{ t("car.delete.body", {
name: car.name,
services: t("car.delete.services", { n: services.length }),
maintenance: t("car.delete.maintenance", { n: maintenance.length }),
fuel: t("car.delete.fuel", { n: fuel.length }),
documents: t("car.delete.documents", { n: documents.length }),
parts: t("car.delete.parts", { n: parts.length }),
}) }}
</p>
<label class="dh-label">
Type <span class="data text-strong">{{ car.name }}</span> to confirm
{{ tSplit("car.delete.typeToConfirm", "name").before
}}<span class="data text-strong">{{ car.name }}</span>{{ tSplit("car.delete.typeToConfirm", "name").after }}
</label>
<input v-model="deleteConfirmText" :placeholder="car.name" class="dh-input mb-4" />
<div class="flex justify-end gap-2">
<button type="button" class="dh-btn dh-btn-ghost" @click="showDeleteCar = false">Cancel</button>
<button type="button" class="dh-btn dh-btn-ghost" @click="showDeleteCar = false">{{ t("common.cancel") }}</button>
<button type="button" :disabled="!canDeleteCar || deletingCar" class="dh-btn dh-btn-danger" @click="confirmDeleteCar">
{{ deletingCar ? 'Deleting' : 'Delete permanently' }}
{{ deletingCar ? t("car.delete.deleting") : t("car.delete.confirm") }}
</button>
</div>
</div>
+15 -13
View File
@@ -3,6 +3,7 @@ import { ref, onMounted } from "vue";
import { useRouter } from "vue-router";
import { api } from "../api";
import { formatDate, formatKm, serviceStatus } from "../lib/format.js";
import { t, tSplit } from "../i18n";
import CarFormModal from "../components/CarFormModal.vue";
const router = useRouter();
@@ -61,21 +62,22 @@ onMounted(load);
<div>
<div class="mb-6 flex items-end justify-between gap-4">
<div>
<p class="eyebrow">Garage</p>
<h1 class="text-3xl font-bold tracking-[-0.03em] text-strong">Your cars</h1>
<p class="mt-1 text-sm text-muted">Maintenance overview and service history.</p>
<p class="eyebrow">{{ t("dashboard.eyebrow") }}</p>
<h1 class="text-3xl font-bold tracking-[-0.03em] text-strong">{{ t("dashboard.title") }}</h1>
<p class="mt-1 text-sm text-muted">{{ t("dashboard.subtitle") }}</p>
</div>
<button class="dh-btn dh-btn-primary" @click="showAdd = true">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" class="h-4 w-4"><path stroke-linecap="round" stroke-linejoin="round" d="M12 5v14M5 12h14" /></svg>
Add car
{{ t("dashboard.addCar") }}
</button>
</div>
<p v-if="error" class="mb-4 rounded-control bg-danger-soft px-4 py-3 text-sm font-medium text-danger">{{ error }}</p>
<p v-if="loading" class="text-muted">Loading</p>
<p v-if="loading" class="text-muted">{{ t("common.loading") }}</p>
<div v-else-if="cars.length === 0" class="rounded-card border border-dashed border-default p-12 text-center text-muted">
No cars yet. Click <strong class="text-strong">Add car</strong> to get started.
{{ tSplit("dashboard.empty", "action").before
}}<strong class="text-strong">{{ t("dashboard.addCar") }}</strong>{{ tSplit("dashboard.empty", "action").after }}
</div>
<div v-else class="grid gap-5 sm:grid-cols-2 lg:grid-cols-3">
@@ -99,13 +101,13 @@ onMounted(load);
v-if="car.access && car.access !== 'owner'"
class="dh-badge dh-badge-neutral mt-2"
>
Shared{{ car.access === 'read' ? ' · read-only' : '' }}
{{ car.access === 'read' ? t("dashboard.sharedReadOnly") : t("dashboard.shared") }}
</span>
<!-- Service-life bar: fraction of the km interval used up. -->
<div v-if="serviceLife(car)" class="mt-4">
<div class="mb-1.5 flex items-center justify-between">
<span class="eyebrow">Service life</span>
<span class="eyebrow">{{ t("dashboard.serviceLife") }}</span>
<span class="data text-xs font-medium text-strong">{{ serviceLife(car).pct }}%</span>
</div>
<div class="h-2 overflow-hidden rounded-pill bg-sunken">
@@ -115,25 +117,25 @@ onMounted(load);
<dl class="mt-4 space-y-2 text-sm">
<div class="flex items-center justify-between gap-2">
<dt class="eyebrow">Last service</dt>
<dt class="eyebrow">{{ t("dashboard.lastService") }}</dt>
<dd class="data font-medium text-strong">{{ formatDate(car.latest?.date) }}</dd>
</div>
<div class="flex items-center justify-between gap-2">
<dt class="eyebrow">Odometer</dt>
<dt class="eyebrow">{{ t("dashboard.odometer") }}</dt>
<dd class="data font-medium text-strong">{{ formatKm(car.currentKm) }}</dd>
</div>
<div class="flex items-center justify-between gap-2">
<dt class="eyebrow">Next due</dt>
<dt class="eyebrow">{{ t("dashboard.nextDue") }}</dt>
<dd class="data font-medium text-strong">{{ formatDate(car.latest?.nextServiceDate) }}</dd>
</div>
<div class="flex items-center justify-between gap-2">
<dt class="eyebrow">Next due km</dt>
<dt class="eyebrow">{{ t("dashboard.nextDueKm") }}</dt>
<dd class="data font-medium text-strong">{{ formatKm(car.latest?.nextServiceKm) }}</dd>
</div>
</dl>
<p class="mt-4 border-t border-subtle pt-3 text-xs text-muted">
{{ car.count }} service record{{ car.count === 1 ? '' : 's' }}
{{ t("dashboard.serviceRecords", { n: car.count }) }}
</p>
</RouterLink>
</div>
+15 -13
View File
@@ -3,6 +3,7 @@ import { ref } from "vue";
import { useRouter, useRoute } from "vue-router";
import { login } from "../auth";
import { getServerUrl, setServerUrl, DEFAULT_API_BASE } from "../api";
import { t, tSplit } from "../i18n";
import Logo from "../components/Logo.vue";
const router = useRouter();
@@ -41,7 +42,7 @@ async function submit() {
const redirect = typeof route.query.redirect === "string" ? route.query.redirect : "/";
router.replace(redirect);
} catch (e) {
error.value = e.message || "Login failed";
error.value = e.message || t("login.failed");
} finally {
loading.value = false;
}
@@ -53,23 +54,23 @@ async function submit() {
<div class="w-full max-w-sm">
<div class="mb-7 text-center">
<Logo class="mx-auto mb-4 h-10 w-auto" />
<h1 class="text-2xl font-bold tracking-[-0.02em] text-strong">Sign in</h1>
<p class="mt-1 text-sm text-muted">Your car, on track.</p>
<h1 class="text-2xl font-bold tracking-[-0.02em] text-strong">{{ t("login.title") }}</h1>
<p class="mt-1 text-sm text-muted">{{ t("login.tagline") }}</p>
</div>
<form class="dh-card space-y-4 p-6" @submit.prevent="submit">
<p v-if="error" class="rounded-control bg-danger-soft px-3 py-2 text-sm font-medium text-danger">{{ error }}</p>
<div>
<label class="dh-label">Email</label>
<label class="dh-label">{{ t("login.email") }}</label>
<input v-model="email" type="email" required autocomplete="username" class="dh-input" />
</div>
<div>
<label class="dh-label">Password</label>
<label class="dh-label">{{ t("login.password") }}</label>
<div class="relative">
<input v-model="password" :type="showPassword ? 'text' : 'password'" required autocomplete="current-password"
class="dh-input pr-10" />
<button type="button" @click="showPassword = !showPassword"
:aria-label="showPassword ? 'Hide password' : 'Show password'"
:aria-label="showPassword ? t('login.hidePassword') : t('login.showPassword')"
class="absolute inset-y-0 right-0 flex items-center px-3 text-muted transition-colors hover:text-strong">
<svg v-if="showPassword" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" class="h-5 w-5">
<path stroke-linecap="round" stroke-linejoin="round" d="M3.98 8.223A10.477 10.477 0 0 0 1.934 12C3.226 16.338 7.244 19.5 12 19.5c.993 0 1.953-.138 2.863-.395M6.228 6.228A10.451 10.451 0 0 1 12 4.5c4.756 0 8.773 3.162 10.065 7.498a10.522 10.522 0 0 1-4.293 5.774M6.228 6.228 3 3m3.228 3.228 3.65 3.65m7.894 7.894L21 21m-3.228-3.228-3.65-3.65m0 0a3 3 0 1 0-4.243-4.243m4.242 4.242L9.88 9.88" />
@@ -82,29 +83,30 @@ async function submit() {
</div>
</div>
<button type="submit" :disabled="loading" class="dh-btn dh-btn-primary w-full">
{{ loading ? "Signing in" : "Sign in" }}
{{ loading ? t("login.submitting") : t("login.submit") }}
</button>
<!-- Server settings: optional override of the API server address -->
<div class="border-t border-subtle pt-3">
<button type="button" @click="showServer = !showServer"
class="flex w-full items-center justify-between text-xs font-semibold text-muted transition-colors hover:text-strong">
<span>Server settings</span>
<span>{{ t("login.serverSettings") }}</span>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor"
class="h-4 w-4 transition-transform" :class="showServer ? 'rotate-180' : ''">
<path fill-rule="evenodd" d="M5.23 7.21a.75.75 0 0 1 1.06.02L10 11.168l3.71-3.938a.75.75 0 1 1 1.08 1.04l-4.25 4.5a.75.75 0 0 1-1.08 0l-4.25-4.5a.75.75 0 0 1 .02-1.06Z" clip-rule="evenodd" />
</svg>
</button>
<div v-if="showServer" class="mt-3 space-y-2">
<label class="eyebrow block">API server URL</label>
<label class="eyebrow block">{{ t("login.apiServerUrl") }}</label>
<input v-model="serverUrl" type="text" :placeholder="DEFAULT_API_BASE" autocomplete="off" class="dh-input data" />
<p class="text-xs text-muted">
Leave blank to use the default (<span class="data">{{ DEFAULT_API_BASE }}</span>).
{{ tSplit("login.leaveBlank", "url").before
}}<span class="data">{{ DEFAULT_API_BASE }}</span>{{ tSplit("login.leaveBlank", "url").after }}
</p>
<div class="flex items-center gap-2">
<button type="button" @click="saveServer" class="dh-btn dh-btn-ghost !px-3 !py-1.5 !text-xs">Save</button>
<button type="button" @click="resetServer" class="dh-btn !px-3 !py-1.5 !text-xs text-muted hover:bg-sunken">Reset to default</button>
<span v-if="serverSaved" class="text-xs font-medium text-success">Saved </span>
<button type="button" @click="saveServer" class="dh-btn dh-btn-ghost !px-3 !py-1.5 !text-xs">{{ t("common.save") }}</button>
<button type="button" @click="resetServer" class="dh-btn !px-3 !py-1.5 !text-xs text-muted hover:bg-sunken">{{ t("login.resetToDefault") }}</button>
<span v-if="serverSaved" class="text-xs font-medium text-success">{{ t("common.saved") }}</span>
</div>
</div>
</div>
+80 -87
View File
@@ -5,6 +5,7 @@ import { api } from "../api";
import { state, logout, refreshProfile } from "../auth";
import { prefs, applyProfilePrefs } from "../prefs";
import { formatDate, formatMoney } from "../lib/format.js";
import { t, tSplit, TRANSLATED_LANGUAGES } from "../i18n";
const router = useRouter();
@@ -89,11 +90,11 @@ const passwordMismatch = computed(
async function savePassword() {
passwordError.value = "";
if (newPassword.value.length < 8) {
passwordError.value = "New password must be at least 8 characters.";
passwordError.value = t("settings.account.tooShort");
return;
}
if (passwordMismatch.value) {
passwordError.value = "New password and confirmation don't match.";
passwordError.value = t("settings.account.mismatch");
return;
}
passwordSaving.value = true;
@@ -190,6 +191,12 @@ const CURRENCIES = computed(() => named(CURRENCY_CODES, "currency", true));
const language = computed(() => (prefs.locale || "en-US").split("-")[0]);
const region = computed(() => (prefs.locale || "en-US").split("-")[1] || "US");
// The picker offers every European language because the choice also drives date
// and number formatting, which Intl handles for all of them. Only a few have a
// translation file, though, so say so rather than letting someone pick Georgian
// and wonder why the buttons are still English.
const languageTranslated = computed(() => TRANSLATED_LANGUAGES.includes(language.value));
function saveLocale({ lang = language.value, reg = region.value }) {
return saveAppearance({ locale: `${lang}-${reg}` });
}
@@ -318,18 +325,14 @@ async function onImportFileChosen(e) {
try {
payload = JSON.parse(await file.text());
} catch {
importError.value = "That file isn't valid JSON.";
importError.value = t("settings.advanced.notJson");
return;
}
if (!Array.isArray(payload?.cars) || payload.cars.length === 0) {
importError.value = "That file doesn't look like a DriverVault export (missing a \"cars\" list).";
importError.value = t("settings.advanced.notExport");
return;
}
if (
!confirm(
`Import ${payload.cars.length} car(s) from this file? This adds new records — it does not merge with or overwrite existing cars.`
)
) {
if (!confirm(t("settings.advanced.confirmImport", { count: payload.cars.length }))) {
return;
}
@@ -390,7 +393,7 @@ async function cancelDeletion() {
}
async function finalizeDeletion() {
if (!confirm("This permanently deletes your account. This cannot be undone. Continue?")) return;
if (!confirm(t("settings.danger.confirmFinalize"))) return;
deleteError.value = "";
try {
await api.finalizeAccountDeletion();
@@ -414,38 +417,38 @@ onBeforeUnmount(() => {
<template>
<div class="mx-auto max-w-3xl">
<div class="mb-6">
<p class="eyebrow">Account</p>
<h1 class="text-3xl font-bold tracking-[-0.03em] text-strong">Settings</h1>
<p class="mt-1 text-sm text-muted">Manage your account, appearance, and data.</p>
<p class="eyebrow">{{ t("settings.eyebrow") }}</p>
<h1 class="text-3xl font-bold tracking-[-0.03em] text-strong">{{ t("settings.title") }}</h1>
<p class="mt-1 text-sm text-muted">{{ t("settings.subtitle") }}</p>
</div>
<p v-if="loadError" class="mb-4 rounded-control bg-danger-soft px-4 py-3 text-sm font-medium text-danger">{{ loadError }}</p>
<p v-if="loading" class="text-muted">Loading</p>
<p v-if="loading" class="text-muted">{{ t("common.loading") }}</p>
<div v-else-if="profile" class="space-y-6">
<!-- Account -->
<section class="dh-card p-6">
<h2 class="mb-4 text-lg font-bold tracking-[-0.02em] text-strong">Account</h2>
<h2 class="mb-4 text-lg font-bold tracking-[-0.02em] text-strong">{{ t("settings.account.title") }}</h2>
<div class="mb-5">
<label class="dh-label">Name</label>
<label class="dh-label">{{ t("settings.account.name") }}</label>
<div class="flex gap-2">
<input v-model="nameDraft" class="dh-input max-w-sm" />
<button class="dh-btn dh-btn-primary" :disabled="nameSaving || !nameDraft.trim()" @click="saveName">
{{ nameSaving ? "Saving" : nameSaved ? "Saved " : "Save" }}
{{ nameSaving ? t("common.saving") : nameSaved ? t("common.saved") : t("common.save") }}
</button>
</div>
<p v-if="nameError" class="mt-1 text-sm text-danger">{{ nameError }}</p>
</div>
<div class="mb-5">
<label class="dh-label">Email</label>
<label class="dh-label">{{ t("settings.account.email") }}</label>
<div class="flex flex-wrap items-center gap-2">
<span class="data rounded-control border border-subtle bg-sunken px-3 py-2 text-sm text-body">
{{ profile.email }}
</span>
<span class="dh-badge" :class="profile.verified ? 'dh-badge-success' : 'dh-badge-warning'">
{{ profile.verified ? "Verified" : "Not verified" }}
{{ profile.verified ? t("settings.account.verified") : t("settings.account.notVerified") }}
</span>
<button
v-if="!profile.verified && !verifySent"
@@ -453,101 +456,101 @@ onBeforeUnmount(() => {
:disabled="verifySending"
@click="sendVerification"
>
{{ verifySending ? "Sending" : "Resend verification email" }}
{{ verifySending ? t("settings.account.sending") : t("settings.account.resendVerification") }}
</button>
<span v-if="verifySent" class="text-sm text-muted">Verification email requested.</span>
<span v-if="verifySent" class="text-sm text-muted">{{ t("settings.account.verificationRequested") }}</span>
</div>
<p v-if="verifyError" class="mt-1 text-sm text-danger">{{ verifyError }}</p>
</div>
<div>
<h3 class="mb-2 text-sm font-semibold text-strong">Change password</h3>
<h3 class="mb-2 text-sm font-semibold text-strong">{{ t("settings.account.changePassword") }}</h3>
<div class="grid max-w-sm gap-2">
<input v-model="oldPassword" type="password" placeholder="Current password" autocomplete="current-password" class="dh-input" />
<input v-model="newPassword" type="password" placeholder="New password" autocomplete="new-password" class="dh-input" />
<input v-model="confirmPassword" type="password" placeholder="Confirm new password" autocomplete="new-password" class="dh-input" />
<input v-model="oldPassword" type="password" :placeholder="t('settings.account.currentPassword')" autocomplete="current-password" class="dh-input" />
<input v-model="newPassword" type="password" :placeholder="t('settings.account.newPassword')" autocomplete="new-password" class="dh-input" />
<input v-model="confirmPassword" type="password" :placeholder="t('settings.account.confirmNewPassword')" autocomplete="new-password" class="dh-input" />
</div>
<p v-if="passwordMismatch" class="mt-1 text-sm text-warning">Passwords don't match yet.</p>
<p v-if="passwordMismatch" class="mt-1 text-sm text-warning">{{ t("settings.account.mismatchYet") }}</p>
<p v-if="passwordError" class="mt-1 text-sm text-danger">{{ passwordError }}</p>
<button class="dh-btn dh-btn-ghost mt-2" :disabled="passwordSaving || !oldPassword || !newPassword" @click="savePassword">
{{ passwordSaving ? "Updating" : passwordSaved ? "Password updated " : "Update password" }}
{{ passwordSaving ? t("settings.account.updating") : passwordSaved ? t("settings.account.passwordUpdated") : t("settings.account.updatePassword") }}
</button>
</div>
</section>
<!-- Appearance -->
<section class="dh-card p-6">
<h2 class="mb-4 text-lg font-bold tracking-[-0.02em] text-strong">Appearance</h2>
<h2 class="mb-4 text-lg font-bold tracking-[-0.02em] text-strong">{{ t("settings.appearance.title") }}</h2>
<div class="mb-5">
<label class="dh-label">Theme</label>
<label class="dh-label">{{ t("settings.appearance.theme") }}</label>
<div class="flex gap-2">
<button
v-for="t in ['light', 'dark', 'system']"
:key="t"
class="rounded-control border px-3 py-1.5 text-sm font-medium capitalize transition-colors"
:class="prefs.theme === t
v-for="opt in ['light', 'dark', 'system']"
:key="opt"
class="rounded-control border px-3 py-1.5 text-sm font-medium transition-colors"
:class="prefs.theme === opt
? 'border-accent bg-accent text-white'
: 'border-subtle text-body hover:bg-sunken hover:text-strong'"
@click="saveAppearance({ theme: t })"
@click="saveAppearance({ theme: opt })"
>
{{ t }}
{{ t(`settings.appearance.theme${opt.charAt(0).toUpperCase() + opt.slice(1)}`) }}
</button>
</div>
</div>
<div class="mb-5 grid gap-4 sm:grid-cols-2">
<div>
<label class="dh-label">Language</label>
<label class="dh-label">{{ t("settings.appearance.language") }}</label>
<select :value="language" class="dh-input" @change="saveLocale({ lang: $event.target.value })">
<option v-for="l in LANGUAGES" :key="l.code" :value="l.code">{{ l.label }}</option>
</select>
<p class="mt-1 text-xs text-muted">Names of months and days.</p>
<p class="mt-1 text-xs" :class="languageTranslated ? 'text-muted' : 'text-warning'">
{{ languageTranslated ? t("settings.appearance.languageHint") : t("settings.appearance.languageFallbackHint") }}
</p>
</div>
<div>
<label class="dh-label">Region</label>
<label class="dh-label">{{ t("settings.appearance.region") }}</label>
<select :value="region" class="dh-input" @change="saveLocale({ reg: $event.target.value })">
<option v-for="r in REGIONS" :key="r.code" :value="r.code">{{ r.label }}</option>
</select>
<p class="mt-1 text-xs text-muted">Number and currency layout.</p>
<p class="mt-1 text-xs text-muted">{{ t("settings.appearance.regionHint") }}</p>
</div>
<div>
<label class="dh-label">Date format</label>
<label class="dh-label">{{ t("settings.appearance.dateFormat") }}</label>
<select :value="prefs.dateFormat" class="dh-input" @change="saveAppearance({ dateFormat: $event.target.value })">
<option value="YMD">YYYY-MM-DD</option>
<option value="DMY_NUM">DD-MM-YYYY</option>
<option value="DMY">DD Mon YYYY</option>
<option value="MDY">Mon DD, YYYY</option>
</select>
<p class="mt-1 text-xs text-muted">Example: <span class="data">{{ dateFormatExample }}</span></p>
<p class="mt-1 text-xs text-muted">{{ t("settings.appearance.dateExample", { example: dateFormatExample }) }}</p>
</div>
<div>
<label class="dh-label">Currency</label>
<label class="dh-label">{{ t("settings.appearance.currency") }}</label>
<select :value="prefs.currency" class="dh-input" @change="saveAppearance({ currency: $event.target.value })">
<option v-for="c in CURRENCIES" :key="c.code" :value="c.code">{{ c.label }}</option>
</select>
<p class="mt-1 text-xs text-muted">
Example: <span class="data">{{ currencyExample }}</span> display only, no amounts are converted.
</p>
<p class="mt-1 text-xs text-muted">{{ t("settings.appearance.currencyExample", { example: currencyExample }) }}</p>
</div>
</div>
<div>
<label class="dh-label">Font size</label>
<label class="dh-label">{{ t("settings.appearance.fontSize") }}</label>
<div class="flex gap-2">
<button
v-for="f in ['small', 'medium', 'large']"
:key="f"
class="rounded-control border px-3 py-1.5 text-sm font-medium capitalize transition-colors"
class="rounded-control border px-3 py-1.5 text-sm font-medium transition-colors"
:class="prefs.fontSize === f
? 'border-accent bg-accent text-white'
: 'border-subtle text-body hover:bg-sunken hover:text-strong'"
@click="saveAppearance({ fontSize: f })"
>
{{ f }}
{{ t(`settings.appearance.font${f.charAt(0).toUpperCase() + f.slice(1)}`) }}
</button>
</div>
</div>
@@ -557,19 +560,19 @@ onBeforeUnmount(() => {
<!-- Profile -->
<section class="dh-card p-6">
<h2 class="mb-4 text-lg font-bold tracking-[-0.02em] text-strong">Profile</h2>
<h2 class="mb-4 text-lg font-bold tracking-[-0.02em] text-strong">{{ t("settings.profile.title") }}</h2>
<div class="mb-5 flex items-center gap-4">
<img v-if="avatarUrl" :src="avatarUrl" alt="Avatar" class="h-16 w-16 rounded-full object-cover ring-1 ring-subtle" />
<img v-if="avatarUrl" :src="avatarUrl" :alt="t('settings.profile.avatarAlt')" class="h-16 w-16 rounded-full object-cover ring-1 ring-subtle" />
<div v-else class="grid h-16 w-16 place-items-center rounded-full bg-brand-100 text-xl font-bold text-brandtext">
{{ (profile.name || profile.email || "?").charAt(0).toUpperCase() }}
</div>
<div class="flex gap-2">
<button class="dh-btn dh-btn-ghost !px-3 !py-1.5" :disabled="avatarUploading" @click="pickAvatar">
{{ avatarUploading ? "Uploading" : "Upload photo" }}
{{ avatarUploading ? t("settings.profile.uploading") : t("settings.profile.uploadPhoto") }}
</button>
<button v-if="profile.hasAvatar" class="dh-btn dh-btn-ghost !px-3 !py-1.5" :disabled="avatarUploading" @click="removeAvatar">
Remove
{{ t("common.remove") }}
</button>
</div>
<input ref="fileInput" type="file" accept="image/png,image/jpeg,image/gif,image/webp,image/svg+xml" class="hidden" @change="onAvatarChosen" />
@@ -577,16 +580,16 @@ onBeforeUnmount(() => {
<p v-if="avatarError" class="mb-4 text-sm text-danger">{{ avatarError }}</p>
<div>
<label class="dh-label">Bio</label>
<label class="dh-label">{{ t("settings.profile.bio") }}</label>
<textarea
v-model="bioDraft"
rows="3"
placeholder="A short note visible to other people in your household."
:placeholder="t('settings.profile.bioPlaceholder')"
class="dh-input"
/>
<p v-if="bioError" class="mt-1 text-sm text-danger">{{ bioError }}</p>
<button class="dh-btn dh-btn-ghost mt-2" :disabled="bioSaving" @click="saveBio">
{{ bioSaving ? "Saving" : bioSaved ? "Saved " : "Save bio" }}
{{ bioSaving ? t("common.saving") : bioSaved ? t("common.saved") : t("settings.profile.saveBio") }}
</button>
</div>
</section>
@@ -594,73 +597,64 @@ onBeforeUnmount(() => {
<!-- Privacy & Security -->
<section class="dh-card p-6">
<div class="mb-4 flex items-center justify-between">
<h2 class="text-lg font-bold tracking-[-0.02em] text-strong">Privacy &amp; security</h2>
<h2 class="text-lg font-bold tracking-[-0.02em] text-strong">{{ t("settings.privacy.title") }}</h2>
<button class="text-sm font-medium text-danger hover:underline" @click="onLogout">
Sign out
{{ t("settings.privacy.signOut") }}
</button>
</div>
<p class="text-sm text-muted">
Two-factor authentication isn't available yet. Sessions are held as
server-issued tokens that expire on their own, so signing out here ends
this device's session only there's no per-device list to revoke from.
To lock out every device, change your password above.
</p>
<p class="text-sm text-muted">{{ t("settings.privacy.body") }}</p>
</section>
<!-- Advanced / Danger Zone -->
<section class="dh-card p-6">
<h2 class="mb-4 text-lg font-bold tracking-[-0.02em] text-strong">Advanced</h2>
<h2 class="mb-4 text-lg font-bold tracking-[-0.02em] text-strong">{{ t("settings.advanced.title") }}</h2>
<div class="flex items-center justify-between gap-3">
<div>
<p class="text-sm font-medium text-strong">Export your data</p>
<p class="text-xs text-muted">Download your profile and all cars, service records, and parts as JSON.</p>
<p class="text-sm font-medium text-strong">{{ t("settings.advanced.exportTitle") }}</p>
<p class="text-xs text-muted">{{ t("settings.advanced.exportBody") }}</p>
</div>
<button class="dh-btn dh-btn-ghost !px-3 !py-1.5 shrink-0" :disabled="exporting" @click="exportData">
{{ exporting ? "Preparing…" : "Export data" }}
{{ exporting ? t("settings.advanced.preparing") : t("settings.advanced.exportAction") }}
</button>
</div>
<p v-if="exportError" class="mt-2 text-sm text-danger">{{ exportError }}</p>
<div class="mt-4 flex items-center justify-between gap-3 border-t border-subtle pt-4">
<div>
<p class="text-sm font-medium text-strong">Import your data</p>
<p class="text-xs text-muted">
Add cars from a previously exported JSON file. This creates new records — it doesn't merge with or overwrite anything existing.
</p>
<p class="text-sm font-medium text-strong">{{ t("settings.advanced.importTitle") }}</p>
<p class="text-xs text-muted">{{ t("settings.advanced.importBody") }}</p>
</div>
<button class="dh-btn dh-btn-ghost !px-3 !py-1.5 shrink-0" :disabled="importing" @click="pickImportFile">
{{ importing ? "Importing" : "Import data" }}
{{ importing ? t("settings.advanced.importing") : t("settings.advanced.importAction") }}
</button>
<input ref="importFileInput" type="file" accept="application/json,.json" class="hidden" @change="onImportFileChosen" />
</div>
<p v-if="importResult" class="mt-2 text-sm font-medium text-success">
Imported {{ importResult.carsImported }} car(s), {{ importResult.servicesImported }} service record(s), {{ importResult.partsImported }} part(s).
{{ t("settings.advanced.imported", { cars: importResult.carsImported, services: importResult.servicesImported, parts: importResult.partsImported }) }}
</p>
<p v-if="importError" class="mt-2 text-sm text-danger">{{ importError }}</p>
</section>
<section class="rounded-card border border-danger/30 bg-danger-soft p-6">
<h2 class="mb-2 text-lg font-bold tracking-[-0.02em] text-danger">Danger zone</h2>
<h2 class="mb-2 text-lg font-bold tracking-[-0.02em] text-danger">{{ t("settings.danger.title") }}</h2>
<template v-if="!deletionPending">
<p class="mb-3 text-sm text-danger/90">
Deleting your account removes your login and profile. It does not delete your household's shared cars or
service history. There's a 3-day cooldown before the deletion is final, and you can cancel any time before then.
</p>
<p class="mb-3 text-sm text-danger/90">{{ t("settings.danger.body") }}</p>
<button class="dh-btn !border !border-danger/40 !bg-transparent !text-danger hover:!bg-danger/10" @click="showDeleteConfirm = true">
Delete my account
{{ t("settings.danger.deleteAccount") }}
</button>
<div v-if="showDeleteConfirm" class="mt-4 rounded-control border border-danger/30 bg-card p-4">
<label class="dh-label">
Type <span class="data text-strong">{{ profile.email }}</span> to confirm
{{ tSplit("settings.danger.typeToConfirm", "email").before
}}<span class="data text-strong">{{ profile.email }}</span>{{ tSplit("settings.danger.typeToConfirm", "email").after }}
</label>
<input v-model="deleteConfirmEmail" :placeholder="profile.email" class="dh-input mb-3 max-w-sm" />
<div class="flex gap-2">
<button class="dh-btn dh-btn-ghost" @click="showDeleteConfirm = false; deleteConfirmEmail = ''">Cancel</button>
<button class="dh-btn dh-btn-ghost" @click="showDeleteConfirm = false; deleteConfirmEmail = ''">{{ t("common.cancel") }}</button>
<button :disabled="!canRequestDelete || deleteRequesting" class="dh-btn dh-btn-danger" @click="requestDeletion">
{{ deleteRequesting ? "Requesting" : "Request deletion" }}
{{ deleteRequesting ? t("settings.danger.requesting") : t("settings.danger.requestDeletion") }}
</button>
</div>
</div>
@@ -668,14 +662,13 @@ onBeforeUnmount(() => {
<template v-else>
<p class="mb-3 text-sm text-danger/90">
Account deletion requested on {{ formatDate(profile.deletionRequestedAt) }}.
<template v-if="!cooldownElapsed">You can still cancel it becomes permanent after the 3-day cooldown.</template>
<template v-else>The cooldown has passed. You can now finalize the deletion.</template>
{{ t("settings.danger.requestedOn", { date: formatDate(profile.deletionRequestedAt) }) }}
{{ cooldownElapsed ? t("settings.danger.cooldownPassed") : t("settings.danger.canStillCancel") }}
</p>
<div class="flex gap-2">
<button class="dh-btn dh-btn-ghost !bg-card" @click="cancelDeletion">Cancel deletion request</button>
<button class="dh-btn dh-btn-ghost !bg-card" @click="cancelDeletion">{{ t("settings.danger.cancelRequest") }}</button>
<button v-if="cooldownElapsed" class="dh-btn dh-btn-danger" @click="finalizeDeletion">
Permanently delete my account
{{ t("settings.danger.finalize") }}
</button>
</div>
</template>