Speak the editor in each person's Home Assistant language: every text in the editor section of en.json, the mockup in the screens' language, and one Language & region card for all screens
- vue-i18n 11 with the Composition API: English comes with the page, any other language loads the first time it is needed from screen_manager/translations, and a text a language lacks shows in English. The build keeps only _meta and editor of each file (vite.config.ts), never the screens' or the add-on's texts. - The editor follows the language of the user's Home Assistant profile (<html lang> of Home Assistant's page), else the browser's; plural forms follow the file's _meta.plural, with the screens' rules. - Every request tells the add-on the editor's language (X-ESP-Screens-Language), so its own words can follow it. - The mockup draws the screens' words (Unavailable, Page 2 ›, the date, "5 min ago") in the screens' language, with their clock and number format. - Settings → Language & region: the screen language, time format and number format for every screen, with the user's own profile offered when it differs. The per-screen clock choice goes; the top bar points there. A screen that still needs the new language says so in its update badge. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,17 +1,18 @@
|
||||
<script setup lang="ts">
|
||||
// Everything around the screens: firmware updates, alerts, Claude.
|
||||
// Everything around the screens: firmware updates, language and region, alerts, Claude.
|
||||
import { computed, ref } from "vue";
|
||||
import { anyUpdating, go, installClaudeSkill, runUpdateAll, setAutoUpdate, state, updateProgress } from "../store";
|
||||
import { haProfile, matchLanguage, numberText, type NumberStyle, t } from "../i18n";
|
||||
import { anyUpdating, go, installClaudeSkill, runUpdateAll, saveLanguage, setAutoUpdate, state, updateProgress } from "../store";
|
||||
|
||||
const u = computed(() => state.inventory.updates);
|
||||
const outdated = computed(() => state.inventory.screens.filter((s) => s.update?.available).length);
|
||||
const updatesHint = computed(() => !u.value ? "" : u.value.busy
|
||||
? `Updating to firmware ${u.value.target}…`
|
||||
? t("editor.settings.updates.busy", { version: u.value.target })
|
||||
: !state.inventory.screens.length
|
||||
? `No screen paired yet. New screens get firmware ${u.value.target}.`
|
||||
? t("editor.settings.updates.no_screens", { version: u.value.target })
|
||||
: outdated.value
|
||||
? `Firmware ${u.value.target} is available for ${outdated.value} screen${outdated.value === 1 ? "" : "s"}.`
|
||||
: `All screens have firmware ${u.value.target}.`);
|
||||
? t("editor.settings.updates.available", { version: u.value.target }, outdated.value)
|
||||
: t("editor.settings.updates.current", { version: u.value.target }));
|
||||
const running = computed(() => state.inventory.screens.find((s) => s.update?.state === "running" || state.updating.includes(s.id)));
|
||||
const progress = computed(() => (running.value ? updateProgress(running.value) : null));
|
||||
const logTail = computed(() => (state.firmwareJob?.logs || []).slice(-12).join("\n"));
|
||||
@@ -27,66 +28,145 @@ async function install() {
|
||||
installing.value = true;
|
||||
try { await installClaudeSkill(); } finally { installing.value = false; }
|
||||
}
|
||||
|
||||
// ---- Language & region (app 0.2.90): one place for every screen ----
|
||||
// The screen language ("auto" is Home Assistant's), the time format and the number format. An add-on without them
|
||||
// shows no card.
|
||||
const GUIDE = "https://github.com/MaxGramser/homeassistant_espscreen/blob/main/docs/TRANSLATING.md";
|
||||
const STYLES: NumberStyle[] = ["point", "comma", "space"];
|
||||
const lang = computed(() => state.inventory.language);
|
||||
// Home Assistant's language as the list has it: the same one, else its base language.
|
||||
const haName = computed(() => {
|
||||
const l = lang.value, code = l?.ha ? matchLanguage(l.ha, l.languages.map((own) => own.code)) : undefined;
|
||||
return l?.languages.find((own) => own.code === code)?.name;
|
||||
});
|
||||
const unchecked = computed(() => lang.value?.languages.find((own) => own.code === lang.value!.effective && !own.checked));
|
||||
const clockName = (clock: string | undefined) => t(clock === "12" ? "editor.settings.language.clock_12" : "editor.settings.language.clock_24");
|
||||
const example = (style: NumberStyle) => numberText("1234.5", style);
|
||||
// The user's own Home Assistant profile, where it names a clock or a number format that the screens don't use.
|
||||
const profile = haProfile();
|
||||
const different = computed(() => {
|
||||
const l = lang.value;
|
||||
if (!l) return {};
|
||||
return {
|
||||
...(profile.clock && profile.clock !== (l.clock_effective || "24") ? { clock: profile.clock } : {}),
|
||||
...(profile.numbers && profile.numbers !== (l.numbers_effective || "point") ? { numbers: profile.numbers } : {}),
|
||||
};
|
||||
});
|
||||
const profileText = computed(() => [different.value.clock && clockName(different.value.clock), different.value.numbers && example(different.value.numbers)]
|
||||
.filter(Boolean).join(" · "));
|
||||
const saving = ref(false);
|
||||
async function choose(field: "setting" | "clock" | "numbers", event: Event) {
|
||||
const select = event.target as HTMLSelectElement;
|
||||
saving.value = true;
|
||||
const saved = await saveLanguage({ [field]: select.value });
|
||||
saving.value = false;
|
||||
// A refused change shows the stored choice again.
|
||||
if (!saved) select.value = String(lang.value?.[field] ?? "auto");
|
||||
}
|
||||
async function useProfile() {
|
||||
saving.value = true;
|
||||
await saveLanguage(different.value);
|
||||
saving.value = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="panel" id="settings-view">
|
||||
<div class="panel-head">
|
||||
<div class="tx">
|
||||
<span class="eyebrow">Settings</span>
|
||||
<h1 id="settings-title">Everything around your screens.</h1>
|
||||
<span class="eyebrow">{{ t("editor.nav.settings") }}</span>
|
||||
<h1 id="settings-title">{{ t("editor.settings.title") }}</h1>
|
||||
</div>
|
||||
<button type="button" class="btn quiet" id="close-settings" @click="go('')">← My screens</button>
|
||||
<button type="button" class="btn quiet" id="close-settings" @click="go('')">{{ t("editor.settings.back") }}</button>
|
||||
</div>
|
||||
<div class="card-grid">
|
||||
<section class="card">
|
||||
<h2>Screens</h2>
|
||||
<p>Add a screen, or build and reinstall the firmware of one you already have.</p>
|
||||
<h2>{{ t("editor.settings.screens.title") }}</h2>
|
||||
<p>{{ t("editor.settings.screens.text") }}</p>
|
||||
<div class="tools">
|
||||
<button type="button" class="tool" @click="go('#new-screen')"><span class="tool-icon">+</span><span class="tx"><b>New screen</b><small>Connect and install</small></span></button>
|
||||
<button type="button" class="tool" @click="go('#firmware')"><span class="tool-icon">⇪</span><span class="tx"><b>Firmware & USB</b><small>Build and install via USB or Wi-Fi</small></span></button>
|
||||
<button type="button" class="tool" @click="go('#new-screen')"><span class="tool-icon">+</span><span class="tx"><b>{{ t("editor.nav.new_screen") }}</b><small>{{ t("editor.nav.new_screen_detail") }}</small></span></button>
|
||||
<button type="button" class="tool" @click="go('#firmware')"><span class="tool-icon">⇪</span><span class="tx"><b>{{ t("editor.nav.firmware") }}</b><small>{{ t("editor.settings.screens.firmware_detail") }}</small></span></button>
|
||||
</div>
|
||||
</section>
|
||||
<section v-if="u" class="card updates" id="updates">
|
||||
<h2>Firmware updates</h2>
|
||||
<h2>{{ t("editor.settings.updates.title") }}</h2>
|
||||
<p id="updates-hint">{{ updatesHint }}</p>
|
||||
<template v-if="running && progress">
|
||||
<div class="progress" role="progressbar" :aria-valuenow="progress.percent" aria-valuemin="0" aria-valuemax="100"><i :style="{ width: progress.percent + '%' }"></i></div>
|
||||
<div class="progress-text"><span>{{ running.name }} · {{ progress.percent }} %</span><span>{{ progress.text }}</span></div>
|
||||
<pre v-if="logTail" class="log-lines">{{ logTail }}</pre>
|
||||
</template>
|
||||
<button v-if="u.pending && !u.busy && u.pending >= 2" id="update-all" type="button" class="btn primary" @click="runUpdateAll">Update all {{ u.pending }} screens</button>
|
||||
<button v-if="u.pending && !u.busy && u.pending >= 2" id="update-all" type="button" class="btn primary" @click="runUpdateAll">{{ t("editor.settings.updates.all", u.pending) }}</button>
|
||||
<details v-if="targetNotes.length && !anyUpdating()" class="whatsnew">
|
||||
<summary>What's new in firmware {{ u.target }}</summary>
|
||||
<summary>{{ t("editor.settings.updates.whats_new", { version: u.target }) }}</summary>
|
||||
<ul><li v-for="line in targetNotes" :key="line">{{ line }}</li></ul>
|
||||
</details>
|
||||
<label class="check">
|
||||
<input type="checkbox" id="auto-update" :checked="Boolean(u.auto)" @change="setAutoUpdate(($event.target as HTMLInputElement).checked)" />
|
||||
<span>Update automatically every night<small>Between 03:00 and 06:00, one screen at a time. Stops on an error and reports it in Home Assistant.</small></span>
|
||||
<span>{{ t("editor.settings.updates.auto_label") }}<small>{{ t("editor.settings.updates.auto_hint") }}</small></span>
|
||||
</label>
|
||||
</section>
|
||||
<section v-if="lang" class="card" id="language">
|
||||
<h2>{{ t("editor.settings.language.title") }}</h2>
|
||||
<div class="field">
|
||||
<label class="f-label" for="screen-language">{{ t("editor.settings.language.screen_language") }}</label>
|
||||
<select id="screen-language" :value="lang.setting" :disabled="saving" @change="choose('setting', $event)">
|
||||
<option value="auto">{{ haName ? t("editor.settings.language.auto", { name: haName }) : t("editor.settings.language.auto_plain") }}</option>
|
||||
<option v-for="own in lang.languages" :key="own.code" :value="own.code">{{ own.checked ? own.name : t("editor.settings.language.unchecked", { name: own.name }) }}</option>
|
||||
</select>
|
||||
<small v-if="lang.setting === 'auto' && !haName" id="language-ha-missing">{{ t("editor.settings.language.ha_missing") }}</small>
|
||||
<i18n-t v-if="unchecked" keypath="editor.settings.language.help" tag="small" id="language-help" scope="global">
|
||||
<template #name>{{ unchecked.name }}</template>
|
||||
<template #guide><a :href="GUIDE" target="_blank" rel="noopener">{{ t("editor.settings.language.guide") }}</a></template>
|
||||
</i18n-t>
|
||||
<small>{{ t("editor.settings.language.update_hint") }}</small>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="f-label" for="time-format">{{ t("editor.settings.language.time") }}</label>
|
||||
<select id="time-format" :value="lang.clock || 'auto'" :disabled="saving" @change="choose('clock', $event)">
|
||||
<option value="auto">{{ t("editor.settings.language.time_auto", { clock: clockName(lang.clock_effective) }) }}</option>
|
||||
<option value="24">{{ clockName("24") }}</option>
|
||||
<option value="12">{{ clockName("12") }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="f-label" for="number-format">{{ t("editor.settings.language.numbers") }}</label>
|
||||
<select id="number-format" :value="lang.numbers || 'auto'" :disabled="saving" @change="choose('numbers', $event)">
|
||||
<option value="auto">{{ t("editor.settings.language.numbers_auto", { example: example(lang.numbers_effective || "point") }) }}</option>
|
||||
<option v-for="style in STYLES" :key="style" :value="style">{{ example(style) }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div v-if="profileText" class="actions" id="language-profile">
|
||||
<small>{{ t("editor.settings.language.profile", { formats: profileText }) }}</small>
|
||||
<button type="button" class="btn quiet mini" :disabled="saving" @click="useProfile">{{ t("editor.settings.language.use_profile") }}</button>
|
||||
</div>
|
||||
<small>{{ t("editor.settings.language.editor_hint") }}</small>
|
||||
</section>
|
||||
<section class="card">
|
||||
<h2>Alerts</h2>
|
||||
<p>A card over the whole screen from an automation, on one screen or on all of them. The cheatsheet has the action names, fields, icons, and colors, and a form to try one.</p>
|
||||
<h2>{{ t("editor.nav.alerts") }}</h2>
|
||||
<p>{{ t("editor.settings.alerts.text") }}</p>
|
||||
<div class="tools">
|
||||
<button type="button" class="tool" @click="go('#alerts')"><span class="tool-icon">!</span><span class="tx"><b>Alerts</b><small>Full-screen notifications, from an automation</small></span></button>
|
||||
<button type="button" class="tool" @click="go('#alerts')"><span class="tool-icon">!</span><span class="tx"><b>{{ t("editor.nav.alerts") }}</b><small>{{ t("editor.settings.alerts.detail") }}</small></span></button>
|
||||
</div>
|
||||
</section>
|
||||
<section class="card" id="claude">
|
||||
<h2>Claude</h2>
|
||||
<p>Teach Claude about ESP Screens, then ask: "Show an alert on all my screens when the mailbox is full." Claude knows the alert events, every field, color, and icon.</p>
|
||||
<p>{{ t("editor.settings.claude.text") }}</p>
|
||||
<div class="actions">
|
||||
<button type="button" id="claude-install" class="btn" :class="skill?.installed && skill.current ? 'quiet' : 'primary'" :disabled="!skill || installing" @click="install">
|
||||
{{ !skill || !skill.installed ? "Install for Claude Code" : skill.current ? "Install again" : "Update the skill" }}
|
||||
{{ !skill || !skill.installed ? t("editor.settings.claude.install") : skill.current ? t("editor.settings.claude.install_again") : t("editor.settings.claude.update") }}
|
||||
</button>
|
||||
<span id="claude-status" class="chip" :class="!skill ? '' : !skill.installed ? '' : skill.current ? 'good' : 'update'" role="status">
|
||||
{{ !skill ? "Loading…" : !skill.installed ? "Not installed" : skill.current ? "● Installed" : "Installed · a newer version is ready" }}
|
||||
{{ !skill ? t("editor.common.loading") : !skill.installed ? t("editor.settings.claude.not_installed") : skill.current ? t("editor.settings.claude.installed_chip") : t("editor.settings.claude.newer") }}
|
||||
</span>
|
||||
</div>
|
||||
<small>For Claude Code in Home Assistant. Writes one file to <code id="claude-path">{{ skill?.path || "/homeassistant/.claude/skills/esp-screens" }}</code>; installing again replaces it.</small>
|
||||
<i18n-t keypath="editor.settings.claude.writes" tag="small" scope="global">
|
||||
<template #path><code id="claude-path">{{ skill?.path || "/homeassistant/.claude/skills/esp-screens" }}</code></template>
|
||||
</i18n-t>
|
||||
<div class="actions">
|
||||
<a class="btn quiet" id="claude-download" href="api/claude-skill.zip" download="esp-screens.zip">Download for claude.ai</a>
|
||||
<small>Upload the zip in Claude under Customize → Skills.</small>
|
||||
<a class="btn quiet" id="claude-download" href="api/claude-skill.zip" download="esp-screens.zip">{{ t("editor.settings.claude.download") }}</a>
|
||||
<small>{{ t("editor.settings.claude.upload") }}</small>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user