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:
@@ -2,6 +2,7 @@
|
||||
// Perform action (app 0.2.67): the actions Home Assistant offers for a tile's entity, under its own names,
|
||||
// and one field per value Home Assistant asks for (its selector). An empty field is left out.
|
||||
import { computed } from "vue";
|
||||
import { t } from "../i18n";
|
||||
import { loadEntityActions, markDirty, state, supports } from "../store";
|
||||
import type { EntityAction, Tile } from "../types";
|
||||
import Segmented from "./Segmented.vue";
|
||||
@@ -64,42 +65,42 @@ const unitOf = (field: Field) => configOf(field).unit_of_measurement || (kindOf(
|
||||
|
||||
<template>
|
||||
<div class="f">
|
||||
<span class="f-label">Action</span>
|
||||
<small v-if="list === undefined">Asking Home Assistant which actions this entity has…</small>
|
||||
<small v-else-if="list === null" class="warn">Home Assistant isn't answering right now. Try again in a moment.</small>
|
||||
<span class="f-label">{{ t("editor.action.label") }}</span>
|
||||
<small v-if="list === undefined">{{ t("editor.action.asking") }}</small>
|
||||
<small v-else-if="list === null" class="warn">{{ t("editor.action.not_answering") }}</small>
|
||||
<template v-else>
|
||||
<button type="button" class="row" :aria-expanded="open ? 'true' : 'false'" @click="state.actionPickerOpen = !open">
|
||||
<span class="tx">
|
||||
<b>{{ entry ? entry.name : chosen ? chosen.action : "Choose an action" }}</b>
|
||||
<b>{{ entry ? entry.name : chosen ? chosen.action : t("editor.action.choose") }}</b>
|
||||
<small v-if="chosen" class="mono">{{ chosen.action }}</small>
|
||||
</span>
|
||||
<span class="link">{{ open ? "Close" : "Change" }}</span>
|
||||
<span class="link">{{ open ? t("editor.common.close") : t("editor.common.change") }}</span>
|
||||
</button>
|
||||
<div v-if="open" class="picker">
|
||||
<input v-model="state.actionSearch" type="search" placeholder="Search, for example toggle, position, or play" aria-label="Search for an action" />
|
||||
<input v-model="state.actionSearch" type="search" :placeholder="t('editor.action.search')" :aria-label="t('editor.action.search_label')" />
|
||||
<div class="action-list">
|
||||
<button v-for="action in rows" :key="action.action" type="button" class="action-choice" :aria-pressed="action.action === chosen?.action ? 'true' : 'false'" @click="pick(action)">
|
||||
<strong>{{ action.name }}</strong>
|
||||
<small>{{ action.description ? `${action.action} · ${action.description}` : action.action }}</small>
|
||||
</button>
|
||||
<p v-if="!rows.length" class="hint">No action found.</p>
|
||||
<p v-if="!rows.length" class="hint">{{ t("editor.action.none_found") }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<small v-if="chosen && !entry" class="warn">Home Assistant doesn't offer this action for this entity any more, so a tap does nothing. Choose another one.</small>
|
||||
<small v-if="chosen && !entry" class="warn">{{ t("editor.action.gone") }}</small>
|
||||
<template v-if="entry && !open">
|
||||
<div v-for="field in entry.fields" :key="field.key" class="f">
|
||||
<span class="f-label">{{ field.required ? field.name : `${field.name} (optional)` }}</span>
|
||||
<Segmented v-if="kindOf(field) === 'boolean'" :choices="[['', 'Not set'], ['true', 'On'], ['false', 'Off']]" :value="chosen?.data?.[field.key] === undefined ? '' : String(chosen?.data?.[field.key])"
|
||||
<span class="f-label">{{ field.required ? field.name : t("editor.action.optional", { name: field.name }) }}</span>
|
||||
<Segmented v-if="kindOf(field) === 'boolean'" :choices="[['', t('editor.action.not_set')], ['true', t('editor.action.on')], ['false', t('editor.action.off')]]" :value="chosen?.data?.[field.key] === undefined ? '' : String(chosen?.data?.[field.key])"
|
||||
@pick="(v) => setField(field.key, v === '' ? undefined : v === 'true')" />
|
||||
<Segmented v-else-if="choicesOf(field) && choicesOf(field)!.length <= 4" :choices="[['', 'Not set'], ...choicesOf(field)!]" :value="chosen?.data?.[field.key] === undefined ? '' : String(chosen?.data?.[field.key])"
|
||||
<Segmented v-else-if="choicesOf(field) && choicesOf(field)!.length <= 4" :choices="[['', t('editor.action.not_set')], ...choicesOf(field)!]" :value="chosen?.data?.[field.key] === undefined ? '' : String(chosen?.data?.[field.key])"
|
||||
@pick="(v) => setField(field.key, v === '' ? undefined : v)" />
|
||||
<select v-else-if="choicesOf(field)" :aria-label="field.name" :value="String(chosen?.data?.[field.key] ?? '')" @change="setField(field.key, ($event.target as HTMLSelectElement).value === '' ? undefined : ($event.target as HTMLSelectElement).value)">
|
||||
<option value="">Not set</option>
|
||||
<option value="">{{ t("editor.action.not_set") }}</option>
|
||||
<option v-for="[key, text] in choicesOf(field)!" :key="key" :value="key">{{ text }}</option>
|
||||
</select>
|
||||
<div v-else-if="kindOf(field) === 'color_rgb'" class="action-number">
|
||||
<input type="color" :aria-label="field.name" :value="colorValue(chosen?.data?.[field.key])" @input="setField(field.key, [1, 3, 5].map((i) => parseInt(($event.target as HTMLInputElement).value.slice(i, i + 2), 16)))" />
|
||||
<button type="button" class="btn quiet mini" @click="setField(field.key, undefined)">Not set</button>
|
||||
<button type="button" class="btn quiet mini" @click="setField(field.key, undefined)">{{ t("editor.action.not_set") }}</button>
|
||||
</div>
|
||||
<div v-else-if="kindOf(field) === 'number' || kindOf(field) === 'color_temp'" class="action-number">
|
||||
<input type="number" :aria-label="field.name" :min="configOf(field).min" :max="configOf(field).max" :step="configOf(field).step === 'any' ? 'any' : configOf(field).step"
|
||||
@@ -109,9 +110,9 @@ const unitOf = (field: Field) => configOf(field).unit_of_measurement || (kindOf(
|
||||
<input v-else type="text" :aria-label="field.name" :value="textValue(chosen?.data?.[field.key])" :placeholder="exampleOf(field)" @input="onText(field, ($event.target as HTMLInputElement).value)" />
|
||||
<small v-if="field.description">{{ field.description }}</small>
|
||||
</div>
|
||||
<small v-if="missing.length" class="warn">Home Assistant needs {{ missing.join(", ") }}.</small>
|
||||
<small v-if="missing.length" class="warn">{{ t("editor.action.needs", { fields: missing.join(", ") }) }}</small>
|
||||
</template>
|
||||
<small v-if="!supports(0, 2, 58)">The screen performs an action from firmware 0.2.58: press Update on the screen. Until then a tap works as Automatic.</small>
|
||||
<small v-if="!supports(0, 2, 58)">{{ t("editor.action.needs_firmware") }}</small>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
<script setup lang="ts">
|
||||
// Alerts: the cheatsheet for esphome.<node>_show_alert, built from the inventory.
|
||||
import { computed, reactive, ref } from "vue";
|
||||
import { t } from "../i18n";
|
||||
import { versionAtLeast } from "../model/layout";
|
||||
import { glyph } from "../model/topbar";
|
||||
import { canAlert, copyText, firmwareVersion, go, sendTestAlert, state } from "../store";
|
||||
|
||||
const alerts = computed(() => state.inventory.alerts);
|
||||
// Try it: the same seven fields an automation sends, to one screen or to all of them.
|
||||
const tryForm = reactive({ screen: "all", title: "Someone is at the door", subtitle: "Door 3, back", icon: "doorbell", color: "orange", button_text: "Coming", timeout: 30, flash: true });
|
||||
// Try it: the same seven fields an automation sends, to one screen or to all of them. The example is the doorbell, in
|
||||
// the editor's language; the YAML examples below stay as code.
|
||||
const tryForm = reactive({
|
||||
screen: "all", title: t("editor.alerts.example.title"), subtitle: t("editor.alerts.example.subtitle"), icon: "doorbell", color: "orange",
|
||||
button_text: t("editor.alerts.example.button"), timeout: 30, flash: true,
|
||||
});
|
||||
const trying = ref(false);
|
||||
const tryResult = ref("");
|
||||
const readyScreens = computed(() => state.inventory.screens.filter((s) => canAlert(s) && s.online));
|
||||
@@ -17,10 +22,14 @@ async function tryAlert() {
|
||||
try {
|
||||
const { screen, ...data } = tryForm;
|
||||
const result = await sendTestAlert(screen, data);
|
||||
const where = screen === "all" ? `${result.sent} screen${result.sent === 1 ? "" : "s"}` : state.inventory.screens.find((s) => s.id === screen)?.name || "the screen";
|
||||
const name = state.inventory.screens.find((s) => s.id === screen)?.name;
|
||||
const sent = screen === "all"
|
||||
? t("editor.alerts.try.sent_all", result.sent)
|
||||
: name ? t("editor.alerts.try.sent_to", { name }) : t("editor.alerts.try.sent_one");
|
||||
tryResult.value = result.sent
|
||||
? `Sent to ${where}.${result.skipped ? ` ${result.skipped} skipped (offline or old firmware).` : ""}${result.unusable?.length ? ` Left empty: ${result.unusable.join(", ")}.` : ""}`
|
||||
: "No screen could show it: check that a screen is online with firmware " + (alerts.value?.min_firmware || "0.2.31") + " or newer.";
|
||||
? [sent, result.skipped ? t("editor.alerts.try.skipped", result.skipped) : "",
|
||||
result.unusable?.length ? t("editor.alerts.try.left_empty", { fields: result.unusable.join(", ") }) : ""].filter(Boolean).join(" ")
|
||||
: t("editor.alerts.try.nothing", { version: alerts.value?.min_firmware || "0.2.31" });
|
||||
} catch (e: any) {
|
||||
tryResult.value = e.message;
|
||||
} finally {
|
||||
@@ -74,201 +83,269 @@ const allYaml = computed(() => {
|
||||
const iconGroups = computed(() => {
|
||||
if (!alerts.value || !icons.value) return [];
|
||||
const query = iconQuery.value.trim().toLowerCase();
|
||||
const all = [...icons.value.groups, { label: "Also available: control and weather icons", icons: alerts.value.extra_icons }];
|
||||
const all = [...icons.value.groups, { label: t("editor.alerts.icons.extra"), icons: alerts.value.extra_icons }];
|
||||
return all.map((group) => ({ label: group.label, icons: group.icons.filter((i: any) => !query || i.name.includes(query) || (i.label || "").toLowerCase().includes(query)) })).filter((g) => g.icons.length);
|
||||
});
|
||||
const doorbell = computed(() => alerts.value?.suggested_icons?.find((i: any) => i.name === "doorbell"));
|
||||
const orange = computed(() => alerts.value?.colors?.find((c: any) => c.name === "orange"));
|
||||
const types: Record<string, string> = { string: "text", int: "number", bool: "on / off" };
|
||||
// A field's type in words (editor.alerts.fields.types); another type shows as it is.
|
||||
const typeName = (type: string) => (["string", "int", "bool"].includes(type) ? t(`editor.alerts.fields.types.${type}`) : type);
|
||||
function jump(id: string) { document.getElementById(id)?.scrollIntoView({ behavior: "smooth", block: "start" }); }
|
||||
const sections = [["alerts-try", "Try it"], ["alerts-screens", "Your screens"], ["alerts-howto", "In an automation"], ["alerts-all", "All screens"], ["alerts-fields", "The fields"], ["alerts-icons", "Icons"], ["alerts-colors", "Colors"], ["alerts-behaviour", "Behavior"], ["alerts-events", "Events"], ["alerts-tips", "Tips"]];
|
||||
// The sections by id; each one's name in the bar is editor.alerts.nav.<the id without "alerts-">.
|
||||
const sections = ["alerts-try", "alerts-screens", "alerts-howto", "alerts-all", "alerts-fields", "alerts-icons", "alerts-colors", "alerts-behaviour", "alerts-events", "alerts-tips"];
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="panel wide alerts" id="alerts-dialog">
|
||||
<div class="panel-head">
|
||||
<div class="tx">
|
||||
<span class="eyebrow">Alerts · cheatsheet</span>
|
||||
<h1>A full-screen notification, from an automation.</h1>
|
||||
<p>Every screen has the <code>show_alert</code> action. It places a card over everything, wakes the screen, and keeps the backlight at normal brightness until someone taps the button or the timeout runs out. Home Assistant itself shows no explanation or dropdowns for ESPHome actions; everything you can fill in is listed here.</p>
|
||||
<span class="eyebrow">{{ t("editor.alerts.eyebrow") }}</span>
|
||||
<h1>{{ t("editor.alerts.title") }}</h1>
|
||||
<i18n-t keypath="editor.alerts.intro" tag="p" scope="global">
|
||||
<template #action><code>show_alert</code></template>
|
||||
</i18n-t>
|
||||
</div>
|
||||
<button type="button" class="btn quiet" id="close-alerts" @click="go('')">← Back</button>
|
||||
<button type="button" class="btn quiet" id="close-alerts" @click="go('')">{{ t("editor.common.back") }}</button>
|
||||
</div>
|
||||
<p v-if="!alerts" class="hint">The cheatsheet is still loading; one moment.</p>
|
||||
<p v-if="!alerts" class="hint">{{ t("editor.alerts.loading") }}</p>
|
||||
<template v-else>
|
||||
<div class="alert-hero">
|
||||
<div class="alert-mock" id="alert-mock" aria-hidden="true">
|
||||
<div class="alert-mock-card" :style="orange ? { background: orange.color } : undefined">
|
||||
<span class="mdi alert-mock-icon" id="alert-mock-icon">{{ doorbell ? glyph(doorbell.cp) : "" }}</span>
|
||||
<strong id="alert-mock-title">Someone is at the door</strong>
|
||||
<span id="alert-mock-subtitle">Door 3, back</span>
|
||||
<span class="fake" id="alert-mock-button">Coming</span>
|
||||
<strong id="alert-mock-title">{{ t("editor.alerts.example.title") }}</strong>
|
||||
<span id="alert-mock-subtitle">{{ t("editor.alerts.example.subtitle") }}</span>
|
||||
<span class="fake" id="alert-mock-button">{{ t("editor.alerts.example.button") }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<nav class="alerts-nav" aria-label="Sections">
|
||||
<button v-for="[id, label] in sections" :key="id" type="button" :data-jump="id" @click="jump(id)">{{ label }}</button>
|
||||
<nav class="alerts-nav" :aria-label="t('editor.alerts.sections')">
|
||||
<button v-for="id in sections" :key="id" type="button" :data-jump="id" @click="jump(id)">{{ t(`editor.alerts.nav.${id.slice("alerts-".length)}`) }}</button>
|
||||
</nav>
|
||||
</div>
|
||||
<section id="alerts-try" class="card">
|
||||
<h2>Try it</h2>
|
||||
<p>Send one alert now, with the same fields an automation sends. The screen shows the card and, with blinking on, flashes its backlight.</p>
|
||||
<h2>{{ t("editor.alerts.nav.try") }}</h2>
|
||||
<p>{{ t("editor.alerts.try.text") }}</p>
|
||||
<form class="try-grid" @submit.prevent="tryAlert">
|
||||
<div class="field"><label class="f-label" for="try-screen">Screen</label>
|
||||
<div class="field"><label class="f-label" for="try-screen">{{ t("editor.alerts.try.screen") }}</label>
|
||||
<select id="try-screen" v-model="tryForm.screen">
|
||||
<option value="all">All screens ({{ readyScreens.length }} ready)</option>
|
||||
<option v-for="screen in state.inventory.screens" :key="screen.id" :value="screen.id" :disabled="!canAlert(screen) || !screen.online">{{ screen.name }}{{ canAlert(screen) && screen.online ? "" : " · not ready" }}</option>
|
||||
<option value="all">{{ t("editor.alerts.try.all", { ready: readyScreens.length }) }}</option>
|
||||
<option v-for="screen in state.inventory.screens" :key="screen.id" :value="screen.id" :disabled="!canAlert(screen) || !screen.online">{{ canAlert(screen) && screen.online ? screen.name : t("editor.alerts.try.not_ready", { name: screen.name }) }}</option>
|
||||
</select></div>
|
||||
<div class="field"><label class="f-label" for="try-title">Title</label><input id="try-title" v-model="tryForm.title" maxlength="64" /></div>
|
||||
<div class="field"><label class="f-label" for="try-subtitle">Subtitle</label><input id="try-subtitle" v-model="tryForm.subtitle" maxlength="240" /></div>
|
||||
<div class="field"><label class="f-label" for="try-icon">Icon</label><input id="try-icon" v-model="tryForm.icon" list="try-icons" placeholder="doorbell" />
|
||||
<div class="field"><label class="f-label" for="try-title">{{ t("editor.alerts.try.title") }}</label><input id="try-title" v-model="tryForm.title" maxlength="64" /></div>
|
||||
<div class="field"><label class="f-label" for="try-subtitle">{{ t("editor.alerts.try.subtitle") }}</label><input id="try-subtitle" v-model="tryForm.subtitle" maxlength="240" /></div>
|
||||
<div class="field"><label class="f-label" for="try-icon">{{ t("editor.alerts.try.icon") }}</label><input id="try-icon" v-model="tryForm.icon" list="try-icons" placeholder="doorbell" />
|
||||
<datalist id="try-icons"><option v-for="icon in alerts.suggested_icons" :key="icon.name" :value="icon.name">{{ icon.label || icon.name }}</option></datalist></div>
|
||||
<div class="field"><label class="f-label" for="try-color">Color</label>
|
||||
<select id="try-color" v-model="tryForm.color"><option value="">White (default)</option><option v-for="colour in alerts.colors" :key="colour.name" :value="colour.name">{{ colour.label }}</option></select></div>
|
||||
<div class="field"><label class="f-label" for="try-button">Button text</label><input id="try-button" v-model="tryForm.button_text" maxlength="16" /></div>
|
||||
<div class="field"><label class="f-label" for="try-timeout">Timeout (seconds, 0 waits for the button)</label><input id="try-timeout" v-model.number="tryForm.timeout" type="number" min="0" max="86400" /></div>
|
||||
<label class="check field"><input type="checkbox" id="try-flash" v-model="tryForm.flash" /><span>Blink the backlight<small>Four times when the alert arrives.</small></span></label>
|
||||
<div class="field"><label class="f-label" for="try-color">{{ t("editor.alerts.try.color") }}</label>
|
||||
<select id="try-color" v-model="tryForm.color"><option value="">{{ t("editor.alerts.white") }}</option><option v-for="colour in alerts.colors" :key="colour.name" :value="colour.name">{{ colour.label }}</option></select></div>
|
||||
<div class="field"><label class="f-label" for="try-button">{{ t("editor.alerts.try.button") }}</label><input id="try-button" v-model="tryForm.button_text" maxlength="16" /></div>
|
||||
<div class="field"><label class="f-label" for="try-timeout">{{ t("editor.alerts.try.timeout") }}</label><input id="try-timeout" v-model.number="tryForm.timeout" type="number" min="0" max="86400" /></div>
|
||||
<label class="check field"><input type="checkbox" id="try-flash" v-model="tryForm.flash" /><span>{{ t("editor.alerts.try.flash") }}<small>{{ t("editor.alerts.try.flash_hint") }}</small></span></label>
|
||||
<div class="actions field wide">
|
||||
<button type="submit" class="btn primary" id="try-send" :disabled="trying || !readyScreens.length">{{ trying ? "Sending…" : "Send alert" }}</button>
|
||||
<button type="submit" class="btn primary" id="try-send" :disabled="trying || !readyScreens.length">{{ trying ? t("editor.alerts.try.sending") : t("editor.alerts.try.send") }}</button>
|
||||
<span class="status-line" id="try-result" role="status">{{ tryResult }}</span>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
<section id="alerts-screens" class="card">
|
||||
<h2>Your screens</h2>
|
||||
<p>Every screen has its own action, with the device name in it. From firmware <code id="alerts-min-firmware">{{ alerts.min_firmware }}</code>; update an older screen with its Update button under Screens.</p>
|
||||
<h2>{{ t("editor.alerts.nav.screens") }}</h2>
|
||||
<i18n-t keypath="editor.alerts.screens.text" tag="p" scope="global">
|
||||
<template #version><code id="alerts-min-firmware">{{ alerts.min_firmware }}</code></template>
|
||||
</i18n-t>
|
||||
<div id="alerts-screen-list" class="options">
|
||||
<p v-if="!state.inventory.screens.length" class="hint">No screen paired yet. Install one via New screen; the action appears once Home Assistant sees the screen.</p>
|
||||
<p v-if="!state.inventory.screens.length" class="hint">{{ t("editor.alerts.screens.none") }}</p>
|
||||
<div v-for="screen in state.inventory.screens" :key="screen.id" class="alert-screen">
|
||||
<div class="alert-screen-head">
|
||||
<strong>{{ screen.name }}</strong>
|
||||
<span class="chip" :class="versionAtLeast(firmwareVersion(screen), alerts.min_firmware) && screen.alert_action ? 'good' : 'update'">
|
||||
{{ versionAtLeast(firmwareVersion(screen), alerts.min_firmware) && screen.alert_action ? `● firmware ${screen.firmware}` : screen.alert_action ? `Update needed · firmware ${screen.firmware || "unknown"}` : "Device name unknown · update the screen" }}
|
||||
{{ versionAtLeast(firmwareVersion(screen), alerts.min_firmware) && screen.alert_action
|
||||
? t("editor.alerts.screens.ready", { version: screen.firmware })
|
||||
: screen.alert_action ? t("editor.alerts.screens.update", { version: screen.firmware || t("editor.common.unknown") }) : t("editor.alerts.screens.unknown") }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-for="[label, action] in [['Show alert', screen.alert_action], ['Dismiss alert', screen.dismiss_action]]" :key="label" class="copy-line">
|
||||
<div v-for="[label, action] in [[t('editor.alerts.screens.show'), screen.alert_action], [t('editor.alerts.screens.dismiss'), screen.dismiss_action]]" :key="label" class="copy-line">
|
||||
<span class="copy-label">{{ label }}</span><code>{{ action || "esphome.<device_name>_show_alert" }}</code>
|
||||
<button v-if="action" type="button" class="btn quiet mini" @click="copyText(action!, undefined, 'Action name')">Copy</button>
|
||||
<button v-if="action" type="button" class="btn quiet mini" @click="copyText(action!, undefined, 'action_name')">{{ t("editor.common.copy") }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section id="alerts-howto" class="card">
|
||||
<h2>In an automation</h2>
|
||||
<h2>{{ t("editor.alerts.nav.howto") }}</h2>
|
||||
<ol class="steps">
|
||||
<li><b>Add action</b> and search for <b>ESPHome</b>. Choose your screen's action: Home Assistant calls it "Performs the action show_alert of the node <i>device name</i>".</li>
|
||||
<li><b>Fill in the fields.</b> Home Assistant asks for all seven; leave what you don't use empty (<code>""</code>, <code>0</code>, off).</li>
|
||||
<li>Prefer YAML? Choose <b>Edit in YAML</b> on the action and paste the example below.</li>
|
||||
<i18n-t keypath="editor.alerts.howto.add" tag="li" scope="global">
|
||||
<template #add><b>{{ t("editor.alerts.howto.add_bold") }}</b></template>
|
||||
<template #esphome><b>ESPHome</b></template>
|
||||
<template #device><i>{{ t("editor.alerts.howto.device") }}</i></template>
|
||||
</i18n-t>
|
||||
<i18n-t keypath="editor.alerts.howto.fields" tag="li" scope="global">
|
||||
<template #bold><b>{{ t("editor.alerts.howto.fields_bold") }}</b></template>
|
||||
<template #empty><code>""</code></template>
|
||||
<template #zero><code>0</code></template>
|
||||
</i18n-t>
|
||||
<i18n-t keypath="editor.alerts.howto.yaml" tag="li" scope="global">
|
||||
<template #edit><b>{{ t("editor.alerts.edit_yaml") }}</b></template>
|
||||
</i18n-t>
|
||||
</ol>
|
||||
<div class="field">
|
||||
<label class="f-label" for="alerts-example-screen">Example for</label>
|
||||
<label class="f-label" for="alerts-example-screen">{{ t("editor.alerts.howto.example_for") }}</label>
|
||||
<select id="alerts-example-screen" v-model="exampleAction" style="max-width: 360px">
|
||||
<option v-for="screen in screensWithAction" :key="screen.id" :value="screen.alert_action">{{ screen.name }}</option>
|
||||
<option v-if="!screensWithAction.length" value="">a screen (not paired yet)</option>
|
||||
<option v-if="!screensWithAction.length" value="">{{ t("editor.alerts.howto.no_screen") }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="copy-line">
|
||||
<pre id="alerts-example">{{ exampleYaml }}</pre>
|
||||
<button type="button" class="btn quiet mini" id="alerts-example-copy" @click="copyText(exampleYaml, undefined, 'YAML')">Copy YAML</button>
|
||||
<button type="button" class="btn quiet mini" id="alerts-example-copy" @click="copyText(exampleYaml, undefined, 'yaml')">{{ t("editor.alerts.copy_yaml") }}</button>
|
||||
</div>
|
||||
</section>
|
||||
<section id="alerts-all" class="card">
|
||||
<h2>All screens at once</h2>
|
||||
<p>Fire the event <code id="alerts-all-event">{{ alerts.broadcast?.show || "esp_screens_show_alert" }}</code> with the same fields, and ESP Screens passes the alert on to every screen that is online, including screens you add later. <code id="alerts-all-dismiss">{{ alerts.broadcast?.dismiss || "esp_screens_dismiss_alert" }}</code> clears it everywhere. In an automation, add the <b>Event</b> action or paste the example under <b>Edit in YAML</b>. Leave out the fields you don't use. ESP Screens has to be running for this.</p>
|
||||
<h2>{{ t("editor.alerts.all.title") }}</h2>
|
||||
<i18n-t keypath="editor.alerts.all.text" tag="p" scope="global">
|
||||
<template #show><code id="alerts-all-event">{{ alerts.broadcast?.show || "esp_screens_show_alert" }}</code></template>
|
||||
<template #dismiss><code id="alerts-all-dismiss">{{ alerts.broadcast?.dismiss || "esp_screens_dismiss_alert" }}</code></template>
|
||||
<template #event><b>{{ t("editor.alerts.all.event") }}</b></template>
|
||||
<template #edit><b>{{ t("editor.alerts.edit_yaml") }}</b></template>
|
||||
</i18n-t>
|
||||
<div class="copy-line">
|
||||
<pre id="alerts-all-example">{{ allYaml }}</pre>
|
||||
<button type="button" class="btn quiet mini" id="alerts-all-copy" @click="copyText(allYaml, undefined, 'YAML')">Copy YAML</button>
|
||||
<button type="button" class="btn quiet mini" id="alerts-all-copy" @click="copyText(allYaml, undefined, 'yaml')">{{ t("editor.alerts.copy_yaml") }}</button>
|
||||
</div>
|
||||
</section>
|
||||
<section id="alerts-fields" class="card">
|
||||
<h2>The fields</h2>
|
||||
<p>Text is free-form; the limit is in bytes, and an accented letter counts as two. What doesn't fit gets an ellipsis (title) or is dropped (subtitle).</p>
|
||||
<h2>{{ t("editor.alerts.nav.fields") }}</h2>
|
||||
<p>{{ t("editor.alerts.fields.text") }}</p>
|
||||
<div class="table-scroll">
|
||||
<table id="alerts-field-table">
|
||||
<tr><th>Field</th><th>Type</th><th>What it does</th><th>Example</th><th>Limit</th></tr>
|
||||
<tr><th>{{ t("editor.alerts.fields.field") }}</th><th>{{ t("editor.alerts.fields.type") }}</th><th>{{ t("editor.alerts.fields.what") }}</th><th>{{ t("editor.alerts.fields.example") }}</th><th>{{ t("editor.alerts.fields.limit") }}</th></tr>
|
||||
<tr v-for="field in alerts.fields" :key="field.name">
|
||||
<td><code>{{ field.name }}</code><small>{{ field.label }}</small></td>
|
||||
<td>{{ types[field.type] || field.type }}</td>
|
||||
<td>{{ typeName(field.type) }}</td>
|
||||
<td>{{ field.help }}</td>
|
||||
<td><code>{{ typeof field.example === "string" ? field.example : String(field.example) }}</code></td>
|
||||
<td>{{ alerts.limits.cyd[field.name] ? `CYD ${alerts.limits.cyd[field.name]} · Guition ${alerts.limits.guition[field.name]} bytes` : field.type === "int" ? "0 to 86400 s" : "—" }}</td>
|
||||
<td>{{ alerts.limits.cyd[field.name] ? t("editor.alerts.fields.bytes", { cyd: alerts.limits.cyd[field.name], guition: alerts.limits.guition[field.name] }) : field.type === "int" ? t("editor.alerts.fields.seconds") : "—" }}</td>
|
||||
</tr>
|
||||
<tr v-if="alerts.camera">
|
||||
<td><code>{{ alerts.camera.name }}</code><small>{{ alerts.camera.label }}</small></td>
|
||||
<td>entity</td>
|
||||
<td>{{ t("editor.alerts.fields.types.entity") }}</td>
|
||||
<td>{{ alerts.camera.help }}</td>
|
||||
<td><code>{{ alerts.camera.example }}</code></td>
|
||||
<td>Guition, firmware 0.2.57+</td>
|
||||
<td>{{ t("editor.alerts.fields.camera_limit") }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
<section id="alerts-icons" class="card">
|
||||
<h2>Icons</h2>
|
||||
<p>Fill in the name in <code>icon</code>, for example <code>doorbell</code>. <code>mdi:doorbell</code> and the hex codepoint (<code>F12E6</code>) also work. Only these icons are included in the firmware; any other name shows the warning triangle. Tap an icon to copy its name.</p>
|
||||
<p class="subhead">Handy for notifications</p>
|
||||
<h2>{{ t("editor.alerts.nav.icons") }}</h2>
|
||||
<i18n-t keypath="editor.alerts.icons.text" tag="p" scope="global">
|
||||
<template #field><code>icon</code></template>
|
||||
<template #example><code>doorbell</code></template>
|
||||
<template #mdi><code>mdi:doorbell</code></template>
|
||||
<template #hex><code>F12E6</code></template>
|
||||
</i18n-t>
|
||||
<p class="subhead">{{ t("editor.alerts.icons.handy") }}</p>
|
||||
<div class="chips" id="alerts-suggested">
|
||||
<button v-for="icon in alerts.suggested_icons" :key="icon.name" type="button" class="chip" @click="copyText(icon.name, undefined, 'Icon name')"><span class="mdi">{{ glyph(icon.cp) }}</span><code>{{ icon.name }}</code></button>
|
||||
<button v-for="icon in alerts.suggested_icons" :key="icon.name" type="button" class="chip" @click="copyText(icon.name, undefined, 'icon_name')"><span class="mdi">{{ glyph(icon.cp) }}</span><code>{{ icon.name }}</code></button>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="f-label" for="alerts-icon-search">Search for an icon</label>
|
||||
<input id="alerts-icon-search" v-model="iconQuery" type="search" placeholder="Search by name or description…" autocomplete="off" />
|
||||
<label class="f-label" for="alerts-icon-search">{{ t("editor.alerts.icons.search_label") }}</label>
|
||||
<input id="alerts-icon-search" v-model="iconQuery" type="search" :placeholder="t('editor.alerts.icons.search')" autocomplete="off" />
|
||||
</div>
|
||||
<div id="alerts-icon-groups">
|
||||
<div v-for="group in iconGroups" :key="group.label" class="field">
|
||||
<p class="subhead">{{ group.label }} · {{ group.icons.length }}</p>
|
||||
<div class="alert-icon-grid">
|
||||
<button v-for="icon in group.icons" :key="icon.name" type="button" class="alert-icon" :title="`Copy ${icon.name}`" @click="copyText(icon.name, undefined, 'Icon name')">
|
||||
<button v-for="icon in group.icons" :key="icon.name" type="button" class="alert-icon" :title="t('editor.alerts.icons.copy', { name: icon.name })" @click="copyText(icon.name, undefined, 'icon_name')">
|
||||
<span class="mdi">{{ glyph(icon.cp) }}</span><code>{{ icon.name }}</code><small v-if="icon.label">{{ icon.label }}</small>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="!iconGroups.length" class="hint">No icon for "{{ iconQuery }}". Unknown names show the warning triangle ({{ alerts.fallback_icon }}).</p>
|
||||
<p v-if="!iconGroups.length" class="hint">{{ t("editor.alerts.icons.none", { query: iconQuery, fallback: alerts.fallback_icon }) }}</p>
|
||||
</div>
|
||||
</section>
|
||||
<section id="alerts-colors" class="card">
|
||||
<h2>Colors</h2>
|
||||
<p>Fill in the name in <code>color</code>. These are the pastel shades of the tiles, with dark text. Empty or unknown gives the white card. Tap to copy.</p>
|
||||
<h2>{{ t("editor.alerts.nav.colors") }}</h2>
|
||||
<i18n-t keypath="editor.alerts.colors.text" tag="p" scope="global">
|
||||
<template #field><code>color</code></template>
|
||||
</i18n-t>
|
||||
<div class="swatches" id="alerts-swatches">
|
||||
<button type="button" class="swatch" @click="copyText('', undefined, 'Empty color')"><i style="background: #ffffff"></i><span><code>empty</code><small>White (default)</small></span></button>
|
||||
<button v-for="colour in alerts.colors" :key="colour.name" type="button" class="swatch" @click="copyText(colour.name, undefined, 'Color name')"><i :style="{ background: colour.color }"></i><span><code>{{ colour.name }}</code><small>{{ colour.label }} · {{ colour.color }}</small></span></button>
|
||||
<button type="button" class="swatch" @click="copyText('', undefined, 'empty_color')"><i style="background: #ffffff"></i><span><code>empty</code><small>{{ t("editor.alerts.white") }}</small></span></button>
|
||||
<button v-for="colour in alerts.colors" :key="colour.name" type="button" class="swatch" @click="copyText(colour.name, undefined, 'color_name')"><i :style="{ background: colour.color }"></i><span><code>{{ colour.name }}</code><small>{{ colour.label }} · {{ colour.color }}</small></span></button>
|
||||
</div>
|
||||
</section>
|
||||
<section id="alerts-behaviour" class="card">
|
||||
<h2>Behavior</h2>
|
||||
<h2>{{ t("editor.alerts.nav.behaviour") }}</h2>
|
||||
<ul class="alerts-list">
|
||||
<li><b>Wakes the screen.</b> An alert immediately wakes a dimmed or dark screen and appears above every page, every open light card, and the standby layer.</li>
|
||||
<li><b>On until the button.</b> As long as the card is showing, the backlight stays at normal brightness. Standby and night hours wait. After the button, the regular standby time starts again.</li>
|
||||
<li><b>Timeout.</b> <code>timeout: 0</code> waits for the button, however long that takes. With <code>timeout: 60</code> the card disappears after a minute, but the button closes it sooner.</li>
|
||||
<li><b>Blinking.</b> <code>flash: true</code> blinks the backlight four times when the alert arrives (about a second) and leaves the screen on afterward.</li>
|
||||
<li><b>Replacing.</b> A new alert replaces the current one; there's no stack. The replaced alert reports itself as <code>replaced</code>.</li>
|
||||
<li><b>Closing remotely.</b> <code>dismiss_alert</code> clears the card, for example if the door is already open. Without an alert, that action does nothing.</li>
|
||||
<li><b>Tapping beside the card</b> does nothing; only the button closes it. That way a wake-up tap never accidentally dismisses the alert.</li>
|
||||
<i18n-t keypath="editor.alerts.behaviour.wakes" tag="li" scope="global">
|
||||
<template #bold><b>{{ t("editor.alerts.behaviour.wakes_bold") }}</b></template>
|
||||
</i18n-t>
|
||||
<i18n-t keypath="editor.alerts.behaviour.on" tag="li" scope="global">
|
||||
<template #bold><b>{{ t("editor.alerts.behaviour.on_bold") }}</b></template>
|
||||
</i18n-t>
|
||||
<i18n-t keypath="editor.alerts.behaviour.timeout" tag="li" scope="global">
|
||||
<template #bold><b>{{ t("editor.alerts.behaviour.timeout_bold") }}</b></template>
|
||||
<template #zero><code>timeout: 0</code></template>
|
||||
<template #minute><code>timeout: 60</code></template>
|
||||
</i18n-t>
|
||||
<i18n-t keypath="editor.alerts.behaviour.blinking" tag="li" scope="global">
|
||||
<template #bold><b>{{ t("editor.alerts.behaviour.blinking_bold") }}</b></template>
|
||||
<template #flash><code>flash: true</code></template>
|
||||
</i18n-t>
|
||||
<i18n-t keypath="editor.alerts.behaviour.replacing" tag="li" scope="global">
|
||||
<template #bold><b>{{ t("editor.alerts.behaviour.replacing_bold") }}</b></template>
|
||||
<template #replaced><code>replaced</code></template>
|
||||
</i18n-t>
|
||||
<i18n-t keypath="editor.alerts.behaviour.closing" tag="li" scope="global">
|
||||
<template #bold><b>{{ t("editor.alerts.behaviour.closing_bold") }}</b></template>
|
||||
<template #dismiss><code>dismiss_alert</code></template>
|
||||
</i18n-t>
|
||||
<i18n-t keypath="editor.alerts.behaviour.beside" tag="li" scope="global">
|
||||
<template #bold><b>{{ t("editor.alerts.behaviour.beside_bold") }}</b></template>
|
||||
</i18n-t>
|
||||
</ul>
|
||||
</section>
|
||||
<section id="alerts-events" class="card">
|
||||
<h2>Events</h2>
|
||||
<p>Every end fires the event <code id="alerts-event-name">{{ alerts.event }}</code> with <code>action</code>, <code>title</code>, <code>screen</code> (the device name) and the <code>device_id</code> that Home Assistant adds. Visible under Settings → Tools → Events.</p>
|
||||
<h2>{{ t("editor.alerts.nav.events") }}</h2>
|
||||
<i18n-t keypath="editor.alerts.events.text" tag="p" scope="global">
|
||||
<template #event><code id="alerts-event-name">{{ alerts.event }}</code></template>
|
||||
<template #action><code>action</code></template>
|
||||
<template #title><code>title</code></template>
|
||||
<template #screen><code>screen</code></template>
|
||||
<template #device_id><code>device_id</code></template>
|
||||
</i18n-t>
|
||||
<div class="table-scroll">
|
||||
<table id="alerts-ending-table">
|
||||
<tr><th>action</th><th>When</th></tr>
|
||||
<tr><th>action</th><th>{{ t("editor.alerts.events.when") }}</th></tr>
|
||||
<tr v-for="ending in alerts.endings" :key="ending.action"><td><code>{{ ending.action }}</code></td><td>{{ ending.label }}</td></tr>
|
||||
</table>
|
||||
</div>
|
||||
<p class="subhead">Waiting until someone presses the button</p>
|
||||
<p class="subhead">{{ t("editor.alerts.events.wait") }}</p>
|
||||
<div class="copy-line">
|
||||
<pre id="alerts-wait-example">{{ waitYaml }}</pre>
|
||||
<button type="button" class="btn quiet mini" id="alerts-wait-copy" @click="copyText(waitYaml, undefined, 'YAML')">Copy YAML</button>
|
||||
<button type="button" class="btn quiet mini" id="alerts-wait-copy" @click="copyText(waitYaml, undefined, 'yaml')">{{ t("editor.alerts.copy_yaml") }}</button>
|
||||
</div>
|
||||
</section>
|
||||
<section id="alerts-tips" class="card">
|
||||
<h2>Tips</h2>
|
||||
<h2>{{ t("editor.alerts.nav.tips") }}</h2>
|
||||
<ul class="alerts-list">
|
||||
<li>Use a <b>color per kind of notification</b>: orange for the door, red for smoke or water, green for "done". The text always stays dark and readable.</li>
|
||||
<li>Keep the title <b>short</b>; a screen shows about twenty characters on one line and ends a longer title with an ellipsis. Put details in the subtitle.</li>
|
||||
<li>A <b>custom button text</b> turns the notification into a question: "Coming", "Seen", "Turn off". The event then tells you who responded.</li>
|
||||
<li>For an alert that resolves itself (the door opens, the laundry gets taken out) close it in the same automation with <code>dismiss_alert</code>.</li>
|
||||
<li>Every screen? Fire <code>{{ alerts.broadcast?.show || "esp_screens_show_alert" }}</code> once (see All screens at once). The screens still work independently and each reports with its own <code>screen</code>.</li>
|
||||
<li>Rather <b>ask Claude</b> for the automation? Install the ESP Screens skill under Settings → Claude.</li>
|
||||
<i18n-t keypath="editor.alerts.tips.color" tag="li" scope="global">
|
||||
<template #bold><b>{{ t("editor.alerts.tips.color_bold") }}</b></template>
|
||||
</i18n-t>
|
||||
<i18n-t keypath="editor.alerts.tips.short" tag="li" scope="global">
|
||||
<template #bold><b>{{ t("editor.alerts.tips.short_bold") }}</b></template>
|
||||
</i18n-t>
|
||||
<i18n-t keypath="editor.alerts.tips.button" tag="li" scope="global">
|
||||
<template #bold><b>{{ t("editor.alerts.tips.button_bold") }}</b></template>
|
||||
</i18n-t>
|
||||
<i18n-t keypath="editor.alerts.tips.resolve" tag="li" scope="global">
|
||||
<template #dismiss><code>dismiss_alert</code></template>
|
||||
</i18n-t>
|
||||
<i18n-t keypath="editor.alerts.tips.every" tag="li" scope="global">
|
||||
<template #event><code>{{ alerts.broadcast?.show || "esp_screens_show_alert" }}</code></template>
|
||||
<template #screen><code>screen</code></template>
|
||||
</i18n-t>
|
||||
<i18n-t keypath="editor.alerts.tips.claude" tag="li" scope="global">
|
||||
<template #bold><b>{{ t("editor.alerts.tips.claude_bold") }}</b></template>
|
||||
</i18n-t>
|
||||
</ul>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
// ⌘K: screens, entities for the open screen, and the editor's actions, from one search field.
|
||||
import { computed, nextTick, ref, watch } from "vue";
|
||||
import { t } from "../i18n";
|
||||
import { domainInfo } from "../model/layout";
|
||||
import { glyph } from "../model/topbar";
|
||||
import { addTile, automaticIcon, canAlert, currentScreen, exportLayout, go, identify, save, select, state, tileLimit } from "../store";
|
||||
@@ -12,22 +13,23 @@ const input = ref<HTMLInputElement | null>(null);
|
||||
const items = computed<Item[]>(() => {
|
||||
const q = query.value.trim().toLocaleLowerCase();
|
||||
const list: Item[] = [];
|
||||
const screens = t("editor.palette.groups.screens"), actionsGroup = t("editor.palette.groups.actions");
|
||||
for (const screen of state.inventory.screens)
|
||||
list.push({ group: "Screens", label: screen.name, detail: `${screen.online ? "Online" : "Offline"} · ${screen.firmware || "unknown"}`, glyphText: "▦", run: () => select(screen.id) });
|
||||
list.push({ group: screens, label: screen.name, detail: `${screen.online ? t("editor.common.online") : t("editor.common.offline")} · ${screen.firmware || t("editor.common.unknown")}`, glyphText: "▦", run: () => select(screen.id) });
|
||||
const screen = currentScreen.value;
|
||||
const actions: Item[] = [
|
||||
{ group: "Actions", label: "New screen", detail: "Connect and install", glyphText: "+", run: () => go("#new-screen") },
|
||||
{ group: "Actions", label: "Firmware & USB", icon: "F0241", run: () => go("#firmware") },
|
||||
{ group: "Actions", label: "Alerts", detail: "Cheatsheet and a test alert", icon: "F0594", run: () => go("#alerts") },
|
||||
{ group: "Actions", label: "Settings", detail: "Updates, Claude", icon: "F0493", run: () => go("#settings") },
|
||||
{ group: actionsGroup, label: t("editor.nav.new_screen"), detail: t("editor.nav.new_screen_detail"), glyphText: "+", run: () => go("#new-screen") },
|
||||
{ group: actionsGroup, label: t("editor.nav.firmware"), icon: "F0241", run: () => go("#firmware") },
|
||||
{ group: actionsGroup, label: t("editor.nav.alerts"), detail: t("editor.palette.alerts_detail"), icon: "F0594", run: () => go("#alerts") },
|
||||
{ group: actionsGroup, label: t("editor.nav.settings"), detail: t("editor.palette.settings_detail"), icon: "F0493", run: () => go("#settings") },
|
||||
];
|
||||
if (screen && state.layout) {
|
||||
actions.unshift(
|
||||
{ group: "Actions", label: "Save & send", detail: state.dirty ? "Unsaved changes" : "Nothing to save", key: "⌘S", run: () => save() },
|
||||
{ group: "Actions", label: "Layout", detail: screen.name, run: () => { go(""); state.tab = "layout"; } },
|
||||
{ group: "Actions", label: "Screen settings", detail: screen.name, run: () => { go(""); state.tab = "settings"; } },
|
||||
{ group: "Actions", label: "Identify this screen", detail: canAlert(screen) ? "Blinks the backlight and shows a card" : "Needs a newer firmware", run: () => { if (canAlert(screen)) identify(screen); } },
|
||||
{ group: "Actions", label: "Export layout", detail: "Download and copy as JSON", run: exportLayout },
|
||||
{ group: actionsGroup, label: t("editor.common.save_send"), detail: state.dirty ? t("editor.common.unsaved") : t("editor.palette.nothing_to_save"), key: "⌘S", run: () => save() },
|
||||
{ group: actionsGroup, label: t("editor.screen_view.tabs.layout"), detail: screen.name, run: () => { go(""); state.tab = "layout"; } },
|
||||
{ group: actionsGroup, label: t("editor.screen_view.tabs.settings"), detail: screen.name, run: () => { go(""); state.tab = "settings"; } },
|
||||
{ group: actionsGroup, label: t("editor.palette.identify"), detail: canAlert(screen) ? t("editor.palette.identify_detail") : t("editor.palette.identify_needs"), run: () => { if (canAlert(screen)) identify(screen); } },
|
||||
{ group: actionsGroup, label: t("editor.palette.export"), detail: t("editor.palette.export_detail"), run: exportLayout },
|
||||
);
|
||||
}
|
||||
list.push(...actions);
|
||||
@@ -37,7 +39,7 @@ const items = computed<Item[]>(() => {
|
||||
for (const e of state.inventory.entities) {
|
||||
if (e.tile === false || chosen.has(e.id)) continue;
|
||||
if (!`${e.name} ${e.id} ${e.area || ""} ${e.device || ""}`.toLocaleLowerCase().includes(q)) continue;
|
||||
list.push({ group: "Add to this screen", label: e.name, detail: [domainInfo(e.id)[0], e.area].filter(Boolean).join(" · "), icon: state.inventory.icons ? automaticIcon(e.id) : undefined,
|
||||
list.push({ group: t("editor.palette.groups.add"), label: e.name, detail: [domainInfo(e.id)[0], e.area].filter(Boolean).join(" · "), icon: state.inventory.icons ? automaticIcon(e.id) : undefined,
|
||||
run: () => { if (!full) addTile(e.id); } });
|
||||
if (list.length > 60) break;
|
||||
}
|
||||
@@ -66,8 +68,8 @@ watch(() => state.palette, async (open) => { if (open) { query.value = ""; activ
|
||||
|
||||
<template>
|
||||
<div v-if="state.palette" class="palette-backdrop" @click="close">
|
||||
<div class="palette" role="dialog" aria-label="Search" @click.stop @keydown="onKey">
|
||||
<input ref="input" v-model="query" id="palette-input" placeholder="Search screens, entities, actions…" aria-label="Search" autocomplete="off" />
|
||||
<div class="palette" role="dialog" :aria-label="t('editor.sidebar.search')" @click.stop @keydown="onKey">
|
||||
<input ref="input" v-model="query" id="palette-input" :placeholder="t('editor.palette.placeholder')" :aria-label="t('editor.sidebar.search')" autocomplete="off" />
|
||||
<div class="palette-list">
|
||||
<template v-for="g in grouped" :key="g.group">
|
||||
<div class="palette-group">{{ g.group }}</div>
|
||||
@@ -78,7 +80,7 @@ watch(() => state.palette, async (open) => { if (open) { query.value = ""; activ
|
||||
<kbd v-if="item.key" class="hint-key">{{ item.key }}</kbd>
|
||||
</button>
|
||||
</template>
|
||||
<p v-if="!items.length" class="palette-empty">Nothing matches. Type part of a screen, entity or action.</p>
|
||||
<p v-if="!items.length" class="palette-empty">{{ t("editor.palette.empty") }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
// One page of the screen as the mockup draws it: the top bar and a 2 × 3 grid of cells.
|
||||
import { computed } from "vue";
|
||||
import { t } from "../i18n";
|
||||
import { cellsOf, pageOf, sizeOf, SLOTS_PER_PAGE, spanOf } from "../model/layout";
|
||||
import { isGuition, openBar, removePage, state } from "../store";
|
||||
import type { Tile } from "../types";
|
||||
@@ -24,12 +25,12 @@ function pickCell(slot: number) {
|
||||
<template>
|
||||
<div class="page">
|
||||
<div class="page-label">
|
||||
<span>Page {{ page + 1 }}</span>
|
||||
<button v-if="empty" type="button" class="btn mini" title="The pages after this one shift up one slot" @click="removePage(page)">Remove page</button>
|
||||
<span>{{ t("editor.page.label", { page: page + 1 }) }}</span>
|
||||
<button v-if="empty" type="button" class="btn mini" :title="t('editor.page.remove_title')" @click="removePage(page)">{{ t("editor.page.remove") }}</button>
|
||||
<span v-else>{{ filled }} / {{ SLOTS_PER_PAGE }}</span>
|
||||
</div>
|
||||
<div class="device" :class="{ cyd: !isGuition }">
|
||||
<div class="bar-wrap" :class="{ selected: barSelected }" title="Edit top bar" role="button" tabindex="0"
|
||||
<div class="bar-wrap" :class="{ selected: barSelected }" :title="t('editor.page.edit_bar')" role="button" tabindex="0"
|
||||
@click="openBar(0)" @keydown.enter.prevent="openBar(0)">
|
||||
<TopbarSvg />
|
||||
</div>
|
||||
@@ -37,9 +38,9 @@ function pickCell(slot: number) {
|
||||
<template v-for="slot in cells" :key="slot">
|
||||
<TileCard v-if="bySlot.get(slot)" :tile="bySlot.get(slot)!.tile" :slot="slot" :placeholder="bySlot.get(slot)!.tile === moving" />
|
||||
<button v-else type="button" class="cell" :class="{ 'insert-here': state.insertAt === slot }" :data-slot="slot"
|
||||
title="Empty slot. Click to add a tile here, or drag one over."
|
||||
:aria-label="`Empty slot ${(slot % SLOTS_PER_PAGE) + 1} on page ${page + 1}: add the next tile here`" @click="pickCell(slot)">
|
||||
<span>+</span><small>{{ state.insertAt === slot ? "Next tile goes here" : "Empty" }}</small>
|
||||
:title="t('editor.page.cell.title')"
|
||||
:aria-label="t('editor.page.cell.aria', { slot: (slot % SLOTS_PER_PAGE) + 1, page: page + 1 })" @click="pickCell(slot)">
|
||||
<span>+</span><small>{{ state.insertAt === slot ? t("editor.page.cell.next") : t("editor.page.cell.empty") }}</small>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from "../i18n";
|
||||
import { go, state } from "../store";
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section v-if="state.inventory.screens.length" id="choose" class="empty">
|
||||
<span class="empty-icon">▦</span>
|
||||
<h2>Choose a screen</h2>
|
||||
<p>Pick one of your screens on the left to change its tiles, top bar and settings.</p>
|
||||
<h2>{{ t("editor.empty.choose.title") }}</h2>
|
||||
<p>{{ t("editor.empty.choose.text") }}</p>
|
||||
</section>
|
||||
<section v-else id="empty" class="empty">
|
||||
<span class="empty-icon">▦</span>
|
||||
<h2>Start with a screen</h2>
|
||||
<p>Connect a CYD or Guition via USB to your Home Assistant machine. Profile, firmware, and the first flash happen in one go.</p>
|
||||
<button id="start" type="button" class="btn primary" @click="go('#new-screen')">Install new screen</button>
|
||||
<h2>{{ t("editor.empty.start.title") }}</h2>
|
||||
<p>{{ t("editor.empty.start.text") }}</p>
|
||||
<button id="start" type="button" class="btn primary" @click="go('#new-screen')">{{ t("editor.empty.start.button") }}</button>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
// Shared firmware workspace; always a concrete profile and upload target.
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from "vue";
|
||||
import { getJson, send } from "../api";
|
||||
import { t } from "../i18n";
|
||||
import { go, toast } from "../store";
|
||||
|
||||
const ESPHOME_WEB = "https://web.esphome.io/?dashboard_install";
|
||||
const DOWNLOAD_TARGET = "Download · flash from your own computer";
|
||||
const data = ref<any>(null);
|
||||
const file = ref("");
|
||||
const target = ref("ota");
|
||||
@@ -28,11 +28,11 @@ const running = computed(() => data.value?.job?.state === "running");
|
||||
const disabled = computed(() => !data.value || running.value || !data.value.available || !data.value.profiles?.length);
|
||||
const installDisabled = computed(() => disabled.value || target.value === "usb");
|
||||
const ports = computed<string[]>(() => data.value?.ports || []);
|
||||
const statusText = computed(() => !data.value ? "Loading…" : !data.value.available
|
||||
? "The ESPHome CLI is missing. Update the app to 0.2.0."
|
||||
const statusText = computed(() => !data.value ? t("editor.common.loading") : !data.value.available
|
||||
? t("editor.firmware.no_cli")
|
||||
: data.value.job
|
||||
? `${data.value.job.file} · ${data.value.job.action} · ${data.value.job.state}`
|
||||
: "Choose the intended profile, then a USB port, an IP address, or Download.");
|
||||
: t("editor.firmware.choose"));
|
||||
const downloadReady = computed(() => target.value === "download" && !!file.value && !!data.value?.downloads?.includes(file.value));
|
||||
const image = computed(() => ({ href: `api/firmware/profiles/${encodeURIComponent(file.value)}/download`, name: file.value.replace(/\.yaml$/, "") + ".factory.bin" }));
|
||||
async function run(action: "validate" | "build" | "install") {
|
||||
@@ -55,44 +55,52 @@ onBeforeUnmount(() => clearInterval(timer));
|
||||
<div class="panel" id="firmware-dialog">
|
||||
<div class="panel-head">
|
||||
<div class="tx">
|
||||
<span class="eyebrow">Firmware & USB</span>
|
||||
<h1>Build and install screen firmware</h1>
|
||||
<p>The built-in ESPHome CLI uses your own profile (YAML with keys) and secrets. Choose the screen's profile, then a USB port (screen connected to the Home Assistant machine), <b>Wi-Fi / OTA</b> with the hostname, or <b>Download</b> to put the firmware on the screen from your own computer. <b>Check</b> only validates, <b>Build only</b> compiles, <b>Build & install</b> puts the firmware on the screen. Your tiles stay as they are in ESP Screens. The first build takes a few minutes.</p>
|
||||
<span class="eyebrow">{{ t("editor.nav.firmware") }}</span>
|
||||
<h1>{{ t("editor.firmware.title") }}</h1>
|
||||
<i18n-t keypath="editor.firmware.intro" tag="p" scope="global">
|
||||
<template #ota><b>{{ t("editor.firmware.ota") }}</b></template>
|
||||
<template #download><b>{{ t("editor.firmware.download") }}</b></template>
|
||||
<template #check><b>{{ t("editor.firmware.check") }}</b></template>
|
||||
<template #build><b>{{ t("editor.firmware.build") }}</b></template>
|
||||
<template #install><b>{{ t("editor.firmware.install") }}</b></template>
|
||||
</i18n-t>
|
||||
</div>
|
||||
<button type="button" class="btn quiet" id="close-firmware" @click="go('')">← Back</button>
|
||||
<button type="button" class="btn quiet" id="close-firmware" @click="go('')">{{ t("editor.common.back") }}</button>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-grid">
|
||||
<div class="field">
|
||||
<label class="f-label" for="firmware-file">ESPHome profile</label>
|
||||
<label class="f-label" for="firmware-file">{{ t("editor.firmware.profile") }}</label>
|
||||
<select id="firmware-file" v-model="file">
|
||||
<option v-for="p in data?.profiles || []" :key="p.file" :value="p.file">{{ p.file }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="f-label" for="firmware-port">Install to</label>
|
||||
<label class="f-label" for="firmware-port">{{ t("editor.firmware.install_to") }}</label>
|
||||
<select id="firmware-port" v-model="target">
|
||||
<option value="ota">Wi-Fi / OTA</option>
|
||||
<option v-if="!ports.length" value="usb">USB · no board found on the Home Assistant machine yet</option>
|
||||
<option value="ota">{{ t("editor.firmware.ota") }}</option>
|
||||
<option v-if="!ports.length" value="usb">{{ t("editor.firmware.no_board") }}</option>
|
||||
<option v-for="p in ports" :key="p" :value="p">{{ p }}</option>
|
||||
<option value="download">{{ DOWNLOAD_TARGET }}</option>
|
||||
<option value="download">{{ t("editor.firmware.download_target") }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div v-if="target === 'ota'" class="field" id="firmware-host-label">
|
||||
<label class="f-label" for="firmware-host">IP address or hostname</label>
|
||||
<label class="f-label" for="firmware-host">{{ t("editor.firmware.host") }}</label>
|
||||
<input id="firmware-host" v-model="host" :placeholder="file ? file.replace(/\.yaml$/, '.local') : 'screen-livingroom.local'" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button type="button" class="btn quiet" id="firmware-validate" :disabled="disabled" @click="run('validate')">Check</button>
|
||||
<button type="button" class="btn quiet" id="firmware-build" :disabled="disabled" @click="run('build')">Build only</button>
|
||||
<button type="button" class="btn primary" id="firmware-install" :disabled="installDisabled" @click="run('install')">{{ target === "download" ? "Build & download" : "Build & install" }}</button>
|
||||
<button type="button" class="btn quiet" id="firmware-validate" :disabled="disabled" @click="run('validate')">{{ t("editor.firmware.check") }}</button>
|
||||
<button type="button" class="btn quiet" id="firmware-build" :disabled="disabled" @click="run('build')">{{ t("editor.firmware.build") }}</button>
|
||||
<button type="button" class="btn primary" id="firmware-install" :disabled="installDisabled" @click="run('install')">{{ target === "download" ? t("editor.firmware.build_download") : t("editor.firmware.install") }}</button>
|
||||
<span v-if="running" class="spin"></span>
|
||||
</div>
|
||||
<p id="firmware-status" class="status-line" role="status">{{ statusText }}</p>
|
||||
<div v-if="downloadReady" id="firmware-download" class="card" style="background: var(--surface-2)">
|
||||
<a class="btn primary" id="firmware-download-link" :href="image.href" :download="image.name">Download {{ image.name }}</a>
|
||||
<small>Put it on the screen with <a :href="ESPHOME_WEB" target="_blank" rel="noopener">ESPHome Web</a> in Chrome or Edge: Connect, choose the screen's USB port, Install, and select this file. It holds your Wi-Fi password and the screen's keys: keep it to yourself.</small>
|
||||
<a class="btn primary" id="firmware-download-link" :href="image.href" :download="image.name">{{ t("editor.firmware.download_file", { name: image.name }) }}</a>
|
||||
<i18n-t keypath="editor.firmware.download_how" tag="small" scope="global">
|
||||
<template #esphome_web><a :href="ESPHOME_WEB" target="_blank" rel="noopener">ESPHome Web</a></template>
|
||||
</i18n-t>
|
||||
</div>
|
||||
</div>
|
||||
<pre id="firmware-log" class="log">{{ (data?.logs || []).join("\n") }}</pre>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
// The icon choice for tiles and top bar items: automatic, optionally none, or one from the set.
|
||||
// A choice applies live; the open picker keeps its search and scroll position.
|
||||
import { computed, ref } from "vue";
|
||||
import { t } from "../i18n";
|
||||
import { glyph } from "../model/topbar";
|
||||
import { iconNamed, state } from "../store";
|
||||
|
||||
@@ -10,7 +11,7 @@ const emit = defineEmits<{ (e: "pick", name: string): void }>();
|
||||
const query = ref("");
|
||||
const chosen = computed(() => iconNamed(props.selected));
|
||||
const currentGlyph = computed(() => (props.selected === "none" ? "" : glyph(chosen.value?.cp || props.automatic)));
|
||||
const currentText = computed(() => (props.selected === "none" ? "No icon" : chosen.value?.label || props.autoLabel));
|
||||
const currentText = computed(() => (props.selected === "none" ? t("editor.icon.none") : chosen.value?.label || props.autoLabel));
|
||||
const groups = computed(() => {
|
||||
const q = query.value.trim().toLocaleLowerCase();
|
||||
return (state.inventory.icons?.groups || []).map((group) => ({
|
||||
@@ -22,29 +23,29 @@ const groups = computed(() => {
|
||||
|
||||
<template>
|
||||
<div class="f">
|
||||
<span class="f-label">Icon</span>
|
||||
<span class="f-label">{{ t("editor.icon.label") }}</span>
|
||||
<button type="button" class="row" :aria-expanded="state.iconPickerOpen ? 'true' : 'false'" @click="state.iconPickerOpen = !state.iconPickerOpen">
|
||||
<span class="av mdi">{{ currentGlyph }}</span>
|
||||
<span class="tx"><b>{{ currentText }}</b></span>
|
||||
<span class="link">{{ state.iconPickerOpen ? "Close" : "Change" }}</span>
|
||||
<span class="link">{{ state.iconPickerOpen ? t("editor.common.close") : t("editor.common.change") }}</span>
|
||||
</button>
|
||||
<div v-if="state.iconPickerOpen" class="picker">
|
||||
<input v-model="query" type="search" placeholder="Search, for example lamp, music, or door" aria-label="Search for an icon" />
|
||||
<input v-model="query" type="search" :placeholder="t('editor.icon.search')" :aria-label="t('editor.icon.search_label')" />
|
||||
<button type="button" class="icon-choice icon-auto" :aria-pressed="selected === 'auto' ? 'true' : 'false'" :title="autoLabel" @click="emit('pick', 'auto')">
|
||||
<span class="mdi">{{ glyph(automatic) }}</span><span>{{ autoLabel }}</span>
|
||||
</button>
|
||||
<button v-if="allowNone" type="button" class="icon-choice icon-auto" :aria-pressed="selected === 'none' ? 'true' : 'false'" title="No icon" @click="emit('pick', 'none')">
|
||||
<span class="mdi"></span><span>No icon, text only</span>
|
||||
<button v-if="allowNone" type="button" class="icon-choice icon-auto" :aria-pressed="selected === 'none' ? 'true' : 'false'" :title="t('editor.icon.none')" @click="emit('pick', 'none')">
|
||||
<span class="mdi"></span><span>{{ t("editor.icon.none_text_only") }}</span>
|
||||
</button>
|
||||
<div class="icon-list">
|
||||
<section v-for="group in groups" :key="group.label">
|
||||
<small>{{ group.label }}</small>
|
||||
<div class="icon-grid">
|
||||
<button v-for="icon in group.icons" :key="icon.name" type="button" class="icon-choice" :title="icon.label" :aria-label="`Icon: ${icon.label}`"
|
||||
<button v-for="icon in group.icons" :key="icon.name" type="button" class="icon-choice" :title="icon.label" :aria-label="t('editor.icon.aria', { name: icon.label })"
|
||||
:aria-pressed="selected === icon.name ? 'true' : 'false'" @click="emit('pick', icon.name)"><span class="mdi">{{ glyph(icon.cp) }}</span></button>
|
||||
</div>
|
||||
</section>
|
||||
<p v-if="!groups.length" class="hint">No icon found.</p>
|
||||
<p v-if="!groups.length" class="hint">{{ t("editor.icon.none_found") }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<small v-if="note">{{ note }}</small>
|
||||
|
||||
@@ -2,18 +2,19 @@
|
||||
// Read current data: what Home Assistant reports for the screen's tiles right now, and how each tile is set.
|
||||
import { onMounted, ref, watch } from "vue";
|
||||
import { getJson } from "../api";
|
||||
import { controlsLabel, displayNames, sizeNames } from "../model/layout";
|
||||
import { t } from "../i18n";
|
||||
import { controlsLabel, displayName, sizeName } from "../model/layout";
|
||||
import { closeInspector, state, toast } from "../store";
|
||||
|
||||
const props = defineProps<{ entity?: string }>();
|
||||
const summary = ref<any[] | null>(null);
|
||||
const raw = ref("Reading…");
|
||||
const raw = ref(t("editor.inspect.reading"));
|
||||
const error = ref("");
|
||||
async function load() {
|
||||
if (!state.selected) return;
|
||||
summary.value = null;
|
||||
error.value = "";
|
||||
raw.value = "Fetching current HA status...";
|
||||
raw.value = t("editor.inspect.fetching");
|
||||
try {
|
||||
const data = await getJson(`screens/${encodeURIComponent(state.selected)}/inspect`);
|
||||
const tiles = props.entity ? data.tiles.filter((t: any) => t.entity === props.entity) : data.tiles;
|
||||
@@ -27,7 +28,13 @@ async function load() {
|
||||
}
|
||||
const optionsText = (entity: string) => {
|
||||
const options = state.layout?.tiles.find((t) => t.entity === entity)?.options || {};
|
||||
return `Small slider: ${options.inline === "slider" ? "yes" : "no"} · Display: ${displayNames[options.display || "standard"] || options.display} · Size: ${sizeNames[options.size as string] || "normal"} · Control: ${controlsLabel({ entity, name: "", slot: 0, options }, state.inventory)} · Background: ${state.inventory.backgrounds?.[options.background || "auto"]?.label || "Default"}`;
|
||||
return t("editor.inspect.options", {
|
||||
slider: t(options.inline === "slider" ? "editor.inspect.yes" : "editor.inspect.no"),
|
||||
display: displayName(options.display || "standard"),
|
||||
size: sizeName(options.size as string),
|
||||
control: controlsLabel({ entity, name: "", slot: 0, options }, state.inventory),
|
||||
background: state.inventory.backgrounds?.[options.background || "auto"]?.label || t("editor.inspect.background_default"),
|
||||
});
|
||||
};
|
||||
onMounted(load);
|
||||
watch(() => props.entity, load);
|
||||
@@ -36,23 +43,23 @@ watch(() => props.entity, load);
|
||||
<template>
|
||||
<div class="dr-head">
|
||||
<span class="av" style="background: var(--seg); color: var(--ink-2); font-size: 16px">⌕</span>
|
||||
<span class="tx"><b>Current data</b><small :class="{ mono: entity }">{{ entity || "Connection, status, and supported properties" }}</small></span>
|
||||
<button type="button" class="icon-btn" aria-label="Close" @click="closeInspector">✕</button>
|
||||
<span class="tx"><b>{{ t("editor.inspect.title") }}</b><small :class="{ mono: entity }">{{ entity || t("editor.inspect.subtitle") }}</small></span>
|
||||
<button type="button" class="icon-btn" :aria-label="t('editor.common.close')" @click="closeInspector">✕</button>
|
||||
</div>
|
||||
<div class="dr-body" id="inspector-section">
|
||||
<div id="inspection-summary">
|
||||
<p v-if="error" class="hint warn">{{ error }}</p>
|
||||
<p v-else-if="!summary" class="hint">Fetching current HA status...</p>
|
||||
<p v-else-if="!summary" class="hint">{{ t("editor.inspect.fetching") }}</p>
|
||||
<article v-for="(tile, i) in summary || []" :key="i" class="inspection-tile">
|
||||
<strong>{{ tile.entity }}</strong>
|
||||
<span>Status: {{ tile.word || tile.state }}</span>
|
||||
<span>{{ t("editor.inspect.status", { status: tile.word || tile.state }) }}</span>
|
||||
<small>{{ optionsText(tile.entity) }}</small>
|
||||
</article>
|
||||
</div>
|
||||
<pre id="inspection">{{ raw }}</pre>
|
||||
</div>
|
||||
<div class="dr-foot">
|
||||
<button type="button" class="btn quiet" @click="load">Read again</button>
|
||||
<button type="button" class="btn quiet" @click="load">{{ t("editor.inspect.read_again") }}</button>
|
||||
<span class="spacer"></span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -2,12 +2,12 @@
|
||||
// New screen: profile, Wi-Fi, and the first flash in one go.
|
||||
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from "vue";
|
||||
import { getJson, send } from "../api";
|
||||
import { t } from "../i18n";
|
||||
import { copyText, go, openIntegrations, toast } from "../store";
|
||||
|
||||
// Download: ESP Screens builds, the owner flashes the file from their own computer. ESPHome Web is ESPHome's own
|
||||
// browser flasher; this address opens it with its hint for a downloaded project (as ESPHome Device Builder does).
|
||||
const ESPHOME_WEB = "https://web.esphome.io/?dashboard_install";
|
||||
const DOWNLOAD_TARGET = "Download · flash from your own computer";
|
||||
const form = reactive({ board: "cyd", friendly_name: "", name: "", wifi_ssid: "", wifi_password: "", target: "" });
|
||||
const installer = reactive({
|
||||
view: "setup" as "setup" | "progress" | "done", file: null as string | null, friendly: "", board: "cyd", target: "",
|
||||
@@ -40,24 +40,22 @@ const ports = computed<string[]>(() => data.value?.ports || []);
|
||||
const wifi = computed(() => data.value?.wifi);
|
||||
const askWifi = computed(() => wifi.value?.state === "new" || wifi.value?.state === "missing");
|
||||
const wifiMissing = computed<string[]>(() => wifi.value?.missing || []);
|
||||
const wifiStatus = computed(() => wifi.value?.state === "new"
|
||||
? "Fill in once: ESP Screens saves this in ESPHome secrets.yaml, later screens use it automatically."
|
||||
: "Your ESPHome secrets.yaml is still missing Wi-Fi details. ESP Screens only fills in the missing lines.");
|
||||
const wifiStatus = computed(() => t(wifi.value?.state === "new" ? "editor.installer.wifi.new" : "editor.installer.wifi.missing"));
|
||||
const wifiNote = computed(() => wifi.value?.state === "ready"
|
||||
? "Wi-Fi comes from your ESPHome secrets.yaml."
|
||||
? t("editor.installer.wifi.ready")
|
||||
: wifi.value?.state === "invalid"
|
||||
? "secrets.yaml in the ESPHome folder isn't valid YAML. Fix the file first; it won't be overwritten."
|
||||
? t("editor.installer.wifi.invalid")
|
||||
: "");
|
||||
const targetHint = computed(() => form.target === "usb"
|
||||
? "Connect the screen with a USB data cable to the Home Assistant machine; the list refreshes on its own. Or choose Download to use your own computer."
|
||||
const targetHint = computed(() => t(form.target === "usb"
|
||||
? "editor.installer.target.usb"
|
||||
: form.target === "download"
|
||||
? "Download the firmware and put it on the screen from your own computer, in Chrome or Edge. After that, updates go over Wi-Fi."
|
||||
? "editor.installer.target.download"
|
||||
: !form.target
|
||||
? "The profile goes into the ESPHome folder. You can install later via Firmware & USB, or from ESPHome Device Builder."
|
||||
? "editor.installer.target.later"
|
||||
: ports.value.length > 1
|
||||
? "More than one board connected: choose this screen's port."
|
||||
: "Once over USB; after that, everything is wireless.");
|
||||
const goLabel = computed(() => (form.target === "download" ? "Build & download" : form.target ? "Install" : "Save profile"));
|
||||
? "editor.installer.target.several"
|
||||
: "editor.installer.target.once"));
|
||||
const goLabel = computed(() => (form.target === "download" ? t("editor.firmware.build_download") : form.target ? t("editor.installer.install") : t("editor.installer.save_profile")));
|
||||
const busyElsewhere = computed(() => data.value?.job?.state === "running" && !(installer.file && data.value.job.file === installer.file));
|
||||
const goDisabled = computed(() => submitting.value || wifi.value?.state === "invalid" || form.target === "usb" ||
|
||||
(!!form.target && (busyElsewhere.value || !data.value?.available)));
|
||||
@@ -84,9 +82,9 @@ async function installerRefresh() {
|
||||
if (ours && current.state === "running") { showProgress(current, next.logs || []); return; }
|
||||
syncTarget();
|
||||
note.value = busyElsewhere.value
|
||||
? `A build or installation is already running (${current.file}). Wait for it to finish.`
|
||||
? t("editor.installer.busy", { file: current.file })
|
||||
: !next.available && form.target
|
||||
? "The ESPHome CLI is missing from this installation; only saving the profile is possible."
|
||||
? t("editor.installer.no_cli")
|
||||
: wifiNote.value;
|
||||
} else if (installer.view === "progress" && ours) {
|
||||
job.value = current;
|
||||
@@ -104,32 +102,28 @@ function showProgress(current: any, lines: string[]) {
|
||||
const running = computed(() => job.value?.state === "running");
|
||||
const ok = computed(() => installer.view === "done" || job.value?.state === "success");
|
||||
const download = computed(() => installer.action === "download");
|
||||
const title = computed(() => installer.view === "done" ? "Profile saved." : running.value ? "One moment…" : ok.value ? (download.value ? "Ready to download." : "Done.") : "That didn't work.");
|
||||
const title = computed(() => t(installer.view === "done"
|
||||
? "editor.installer.title.saved"
|
||||
: running.value ? "editor.installer.title.running" : ok.value ? (download.value ? "editor.installer.title.ready" : "editor.installer.title.done") : "editor.installer.title.failed"));
|
||||
const progressTitle = computed(() => installer.view === "done"
|
||||
? `${installer.file} is in the ESPHome folder`
|
||||
? t("editor.installer.progress.saved", { file: installer.file })
|
||||
: running.value
|
||||
? job.value?.stage === "upload" ? `Writing firmware to ${installer.friendly}…` : "Building firmware…"
|
||||
? job.value?.stage === "upload" ? t("editor.installer.progress.writing", { name: installer.friendly }) : t("editor.installer.progress.building")
|
||||
: ok.value
|
||||
? download.value ? `Firmware for ${installer.friendly} is ready` : `Firmware is on ${installer.friendly}`
|
||||
: download.value ? "Build failed" : "Install failed");
|
||||
? download.value ? t("editor.installer.progress.ready", { name: installer.friendly }) : t("editor.installer.progress.installed", { name: installer.friendly })
|
||||
: t(download.value ? "editor.installer.progress.build_failed" : "editor.installer.progress.install_failed"));
|
||||
const progressDetail = computed(() => installer.view === "done"
|
||||
? "Install it later from Firmware & USB: choose this profile, then the USB port of the Home Assistant machine, or Download to put the firmware on the screen from your own computer. Save the API key for pairing:"
|
||||
? t("editor.installer.detail.saved")
|
||||
: running.value
|
||||
? job.value?.stage === "upload"
|
||||
? "Don't disconnect the USB cable yet."
|
||||
: `A first build takes a few minutes on a Raspberry Pi. You can leave this page: the ${download.value ? "build" : "installation"} keeps running and you'll find it again under New screen.`
|
||||
? t("editor.installer.detail.uploading")
|
||||
: t(download.value ? "editor.installer.detail.building_download" : "editor.installer.detail.building")
|
||||
: ok.value
|
||||
? download.value
|
||||
? "Put it on the screen from your own computer, then pair the screen with Home Assistant."
|
||||
: `The screen boots up and connects to your Wi-Fi.${installer.board === "cyd" ? " The CYD first asks for a touch calibration: tap the crosshairs." : ""} Pair it with Home Assistant now:`
|
||||
: logs.value.filter((l) => /error/i.test(l)).pop() || logs.value.filter((l) => /failed/i.test(l)).pop() || "See the log below.");
|
||||
? t("editor.installer.detail.downloaded")
|
||||
: t(installer.board === "cyd" ? "editor.installer.detail.booted_cyd" : "editor.installer.detail.booted")
|
||||
: logs.value.filter((l) => /error/i.test(l)).pop() || logs.value.filter((l) => /failed/i.test(l)).pop() || t("editor.installer.detail.see_log"));
|
||||
const image = computed(() => ({ href: `api/firmware/profiles/${encodeURIComponent(installer.file || "")}/download`, name: (installer.file || "").replace(/\.yaml$/, "") + ".factory.bin" }));
|
||||
const pairingSteps = computed(() => [
|
||||
["Go to Home Assistant → Settings → Devices & services.", ` This happens outside ESP Screens. Home Assistant discovers ${installer.friendly} as an ESPHome device; click Add. Not discovered? Add ESPHome manually with the screen's IP address. `],
|
||||
["Paste the API key", " above when Home Assistant asks for an encryption key."],
|
||||
["Allow HA actions:", " ESPHome integration → Configure → “Allow the device to perform Home Assistant actions”. Without this, the screen sees everything but controls nothing."],
|
||||
["Choose your tiles.", " Back in ESP Screens, the screen appears in the list on the left within about thirty seconds; until then it's shown there as “not yet in Home Assistant”."],
|
||||
]);
|
||||
async function submit(event: Event) {
|
||||
const element = event.target as HTMLFormElement;
|
||||
if (!installer.nodeEdited) form.name = slug(form.friendly_name);
|
||||
@@ -183,44 +177,44 @@ onBeforeUnmount(() => clearInterval(poll));
|
||||
<div class="panel" id="installer">
|
||||
<div class="panel-head">
|
||||
<div class="tx">
|
||||
<span class="eyebrow">New screen</span>
|
||||
<h1 id="install-title">{{ installer.view === "setup" ? "Connect and install." : title }}</h1>
|
||||
<p v-if="installer.view === 'setup'">ESP Screens creates the screen's own profile with unique keys and builds the firmware. Connect the screen with a USB data cable to the machine running Home Assistant, or download the firmware and put it on the screen from your own computer.</p>
|
||||
<span class="eyebrow">{{ t("editor.nav.new_screen") }}</span>
|
||||
<h1 id="install-title">{{ installer.view === "setup" ? t("editor.installer.title.setup") : title }}</h1>
|
||||
<p v-if="installer.view === 'setup'">{{ t("editor.installer.intro") }}</p>
|
||||
</div>
|
||||
<button type="button" class="btn quiet" id="close-install" aria-label="Close" @click="close">← Back</button>
|
||||
<button type="button" class="btn quiet" id="close-install" :aria-label="t('editor.common.close')" @click="close">{{ t("editor.common.back") }}</button>
|
||||
</div>
|
||||
<form v-if="installer.view === 'setup'" id="install-form" class="card" @submit.prevent="submit">
|
||||
<fieldset>
|
||||
<legend>Which screen do you have?</legend>
|
||||
<legend>{{ t("editor.installer.board") }}</legend>
|
||||
<div class="boards">
|
||||
<label class="board"><input type="radio" name="board" value="cyd" v-model="form.board" /><span><b>CYD · 2.8 inch</b><small>ESP32-2432S028 · 320 × 240</small></span></label>
|
||||
<label class="board"><input type="radio" name="board" value="guition" v-model="form.board" /><span><b>Guition · 4 inch</b><small>ESP32-S3-4848S040 · 480 × 480 · GT911</small></span></label>
|
||||
<label class="board"><input type="radio" name="board" value="cyd" v-model="form.board" /><span><b>{{ t("editor.installer.board_cyd") }}</b><small>ESP32-2432S028 · 320 × 240</small></span></label>
|
||||
<label class="board"><input type="radio" name="board" value="guition" v-model="form.board" /><span><b>{{ t("editor.installer.board_guition") }}</b><small>ESP32-S3-4848S040 · 480 × 480 · GT911</small></span></label>
|
||||
</div>
|
||||
</fieldset>
|
||||
<div class="field">
|
||||
<label class="f-label" for="friendly_name">Name</label>
|
||||
<input id="friendly_name" name="friendly_name" v-model="form.friendly_name" required maxlength="60" placeholder="Living room screen" autocomplete="off" />
|
||||
<small class="node-line">Device name <code id="node-preview">{{ nodePreview }}</code><button type="button" class="btn link mini" id="edit-node" @click="installer.nodeEdited = true; nodeVisible = true">customize</button></small>
|
||||
<small>Every screen gets its own profile with its own keys. Four screens? Go through this four times with a different name.</small>
|
||||
<label class="f-label" for="friendly_name">{{ t("editor.installer.name") }}</label>
|
||||
<input id="friendly_name" name="friendly_name" v-model="form.friendly_name" required maxlength="60" :placeholder="t('editor.installer.name_placeholder')" autocomplete="off" />
|
||||
<small class="node-line">{{ t("editor.installer.device_name") }} <code id="node-preview">{{ nodePreview }}</code><button type="button" class="btn link mini" id="edit-node" @click="installer.nodeEdited = true; nodeVisible = true">{{ t("editor.installer.customize") }}</button></small>
|
||||
<small>{{ t("editor.installer.name_hint") }}</small>
|
||||
</div>
|
||||
<div v-if="nodeVisible" class="field" id="node-label">
|
||||
<label class="f-label" for="node-name">Device name</label>
|
||||
<label class="f-label" for="node-name">{{ t("editor.installer.device_name") }}</label>
|
||||
<input id="node-name" name="name" v-model="form.name" pattern="[a-z][a-z0-9\-]{0,29}" maxlength="30" autocomplete="off" @input="installer.nodeEdited = true" />
|
||||
<small>Lowercase letters, digits, and dashes. A different name for each screen.</small>
|
||||
<small>{{ t("editor.installer.device_name_hint") }}</small>
|
||||
</div>
|
||||
<fieldset v-if="askWifi" id="wifi-fields" class="wifi">
|
||||
<legend>Wi-Fi</legend>
|
||||
<legend>{{ t("editor.installer.wifi.title") }}</legend>
|
||||
<p class="hint" id="wifi-status">{{ wifiStatus }}</p>
|
||||
<div v-if="wifiMissing.includes('wifi_ssid')" class="field" id="wifi-ssid-label"><label class="f-label" for="wifi_ssid">Wi-Fi name (2.4 GHz)</label><input id="wifi_ssid" name="wifi_ssid" v-model="form.wifi_ssid" autocomplete="off" /></div>
|
||||
<div v-if="wifiMissing.includes('wifi_password')" class="field" id="wifi-password-label"><label class="f-label" for="wifi_password">Wi-Fi password</label><input id="wifi_password" name="wifi_password" type="password" v-model="form.wifi_password" autocomplete="new-password" /></div>
|
||||
<div v-if="wifiMissing.includes('wifi_ssid')" class="field" id="wifi-ssid-label"><label class="f-label" for="wifi_ssid">{{ t("editor.installer.wifi.ssid") }}</label><input id="wifi_ssid" name="wifi_ssid" v-model="form.wifi_ssid" autocomplete="off" /></div>
|
||||
<div v-if="wifiMissing.includes('wifi_password')" class="field" id="wifi-password-label"><label class="f-label" for="wifi_password">{{ t("editor.installer.wifi.password") }}</label><input id="wifi_password" name="wifi_password" type="password" v-model="form.wifi_password" autocomplete="new-password" /></div>
|
||||
</fieldset>
|
||||
<div class="field">
|
||||
<label class="f-label" for="install-target">Install via</label>
|
||||
<label class="f-label" for="install-target">{{ t("editor.installer.install_via") }}</label>
|
||||
<select id="install-target" name="target" v-model="form.target" @change="installer.picked = true; installerRefresh()">
|
||||
<option v-if="!ports.length" value="usb">USB · no board found on the Home Assistant machine yet</option>
|
||||
<option v-if="!ports.length" value="usb">{{ t("editor.firmware.no_board") }}</option>
|
||||
<option v-for="p in ports" :key="p" :value="p">{{ portLabel(p) }}</option>
|
||||
<option value="download">{{ DOWNLOAD_TARGET }}</option>
|
||||
<option value="">Later · save profile only</option>
|
||||
<option value="download">{{ t("editor.firmware.download_target") }}</option>
|
||||
<option value="">{{ t("editor.installer.later") }}</option>
|
||||
</select>
|
||||
<small id="target-hint">{{ targetHint }}</small>
|
||||
</div>
|
||||
@@ -238,31 +232,39 @@ onBeforeUnmount(() => clearInterval(poll));
|
||||
</div>
|
||||
<p id="progress-detail">{{ progressDetail }}</p>
|
||||
<div v-if="ok && download && installer.view !== 'done'" id="install-download" class="card" style="background: var(--surface-2)">
|
||||
<a class="btn primary" id="download-firmware" :href="image.href" :download="image.name">Download {{ image.name }}</a>
|
||||
<a class="btn primary" id="download-firmware" :href="image.href" :download="image.name">{{ t("editor.firmware.download_file", { name: image.name }) }}</a>
|
||||
<ol class="steps" id="download-steps">
|
||||
<li><b>Plug the screen into your computer</b> with a USB data cable.</li>
|
||||
<li><b>Open <a :href="ESPHOME_WEB" target="_blank" rel="noopener">ESPHome Web</a></b> in Chrome or Edge, click Connect and choose the screen's USB port.</li>
|
||||
<li><b>Click Install</b> and select {{ image.name }}. The screen restarts and joins your Wi-Fi.{{ installer.board === "cyd" ? " The CYD first asks for a touch calibration: tap the crosshairs." : "" }}</li>
|
||||
<li><i18n-t keypath="editor.installer.download_steps.plug" scope="global"><template #bold><b>{{ t("editor.installer.download_steps.plug_bold") }}</b></template></i18n-t></li>
|
||||
<li><i18n-t keypath="editor.installer.download_steps.open" scope="global">
|
||||
<template #bold><b><i18n-t keypath="editor.installer.download_steps.open_bold" scope="global"><template #esphome_web><a :href="ESPHOME_WEB" target="_blank" rel="noopener">ESPHome Web</a></template></i18n-t></b></template>
|
||||
</i18n-t></li>
|
||||
<li><i18n-t :keypath="installer.board === 'cyd' ? 'editor.installer.download_steps.install_cyd' : 'editor.installer.download_steps.install'" scope="global">
|
||||
<template #bold><b>{{ t("editor.installer.download_steps.install_bold") }}</b></template>
|
||||
<template #file>{{ image.name }}</template>
|
||||
</i18n-t></li>
|
||||
</ol>
|
||||
<small>The file holds your Wi-Fi password and the screen's keys: keep it to yourself.</small>
|
||||
<small>{{ t("editor.installer.download_keep") }}</small>
|
||||
</div>
|
||||
<div v-if="ok" id="install-result" class="field">
|
||||
<div class="key-box">
|
||||
<span>API key</span><code id="api-key" ref="keyBox">{{ installer.apiKey || "" }}</code>
|
||||
<button type="button" class="btn quiet mini" id="copy-key" @click="copyText(installer.apiKey || '', keyBox)">Copy</button>
|
||||
<span>{{ t("editor.installer.api_key") }}</span><code id="api-key" ref="keyBox">{{ installer.apiKey || "" }}</code>
|
||||
<button type="button" class="btn quiet mini" id="copy-key" @click="copyText(installer.apiKey || '', keyBox)">{{ t("editor.common.copy") }}</button>
|
||||
</div>
|
||||
<ol class="steps" id="install-steps">
|
||||
<li v-for="([b, t], i) in pairingSteps" :key="i"><b>{{ b }}</b>{{ t }}<button v-if="i === 0" type="button" class="btn quiet mini" @click="openIntegrations">Open Devices & services</button></li>
|
||||
<li><i18n-t keypath="editor.installer.pairing.ha" scope="global"><template #bold><b>{{ t("editor.installer.pairing.ha_bold") }}</b></template><template #name>{{ installer.friendly }}</template></i18n-t> <button type="button" class="btn quiet mini" @click="openIntegrations">{{ t("editor.common.open_integrations") }}</button></li>
|
||||
<li><i18n-t keypath="editor.installer.pairing.key" scope="global"><template #bold><b>{{ t("editor.installer.pairing.key_bold") }}</b></template></i18n-t></li>
|
||||
<li><i18n-t keypath="editor.installer.pairing.actions" scope="global"><template #bold><b>{{ t("editor.installer.pairing.actions_bold") }}</b></template></i18n-t></li>
|
||||
<li><i18n-t keypath="editor.installer.pairing.tiles" scope="global"><template #bold><b>{{ t("editor.installer.pairing.tiles_bold") }}</b></template></i18n-t></li>
|
||||
</ol>
|
||||
</div>
|
||||
<details v-if="installer.view !== 'done'" id="install-log-wrap" class="log-wrap" :open="logOpen" @toggle="logOpen = ($event.target as HTMLDetailsElement).open">
|
||||
<summary>ESPHome log</summary>
|
||||
<summary>{{ t("editor.installer.log") }}</summary>
|
||||
<pre id="install-log" class="log">{{ logs.join("\n") }}</pre>
|
||||
</details>
|
||||
<div class="actions">
|
||||
<button v-if="!running && !ok" type="button" class="btn primary" id="install-retry" @click="retry">Retry</button>
|
||||
<button type="button" class="btn quiet" id="install-close" @click="reset">{{ ok ? "Install another screen" : "Start over" }}</button>
|
||||
<button type="button" class="btn" :class="ok ? 'primary' : 'quiet'" @click="close">{{ ok ? "Done" : "Close" }}</button>
|
||||
<button v-if="!running && !ok" type="button" class="btn primary" id="install-retry" @click="retry">{{ t("editor.installer.retry") }}</button>
|
||||
<button type="button" class="btn quiet" id="install-close" @click="reset">{{ ok ? t("editor.installer.another") : t("editor.installer.start_over") }}</button>
|
||||
<button type="button" class="btn" :class="ok ? 'primary' : 'quiet'" @click="close">{{ ok ? t("editor.installer.done") : t("editor.common.close") }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
// The pages side by side, like swiping on the screen, and the library on the right.
|
||||
import { computed } from "vue";
|
||||
import { t } from "../i18n";
|
||||
import { entriesOf, hasGaps, MAX_PAGES, pageCount } from "../model/layout";
|
||||
import { addPage, closeInspector, currentScreen, pageReachWarning, pagesShown, state, supports, tileLimit } from "../store";
|
||||
import DevicePage from "./DevicePage.vue";
|
||||
@@ -12,7 +13,7 @@ const pages = computed(() => pageCount(entries.value, layout.value.pages));
|
||||
const shown = computed(() => pagesShown());
|
||||
const canAdd = computed(() => pages.value < MAX_PAGES);
|
||||
const positionsHint = computed(() => hasGaps(layout.value.tiles) && !supports(0, 2, 26)
|
||||
? `Empty slots and fixed positions work from firmware 0.2.26. This screen (firmware ${currentScreen.value?.firmware || "unknown"}) shifts the tiles up to the first free slot until that update.`
|
||||
? t("editor.layout.positions_hint", { firmware: currentScreen.value?.firmware || t("editor.common.unknown") })
|
||||
: "");
|
||||
// Page buttons and swiping off: a page no Go to page tile reaches, or one without a way back.
|
||||
const reachHint = computed(() => pageReachWarning(entries.value, pages.value));
|
||||
@@ -26,18 +27,18 @@ function onCanvasClick(e: MouseEvent) {
|
||||
<template>
|
||||
<div class="canvas" id="canvas" @click="onCanvasClick">
|
||||
<div class="canvas-head">
|
||||
<b id="count">{{ pages }} {{ pages === 1 ? "page" : "pages" }} · {{ layout.tiles.length }} / {{ tileLimit }} tiles</b>
|
||||
<span v-if="!layout.tiles.length" id="no-tiles">Add your first light, scene, or device from the library.</span>
|
||||
<span v-else>Tap the top bar or a tile to change it. Drag to move; drop on the next page for a new one.</span>
|
||||
<b id="count">{{ t("editor.layout.count", { tiles: layout.tiles.length, limit: tileLimit }, pages) }}</b>
|
||||
<span v-if="!layout.tiles.length" id="no-tiles">{{ t("editor.layout.no_tiles") }}</span>
|
||||
<span v-else>{{ t("editor.layout.how_to") }}</span>
|
||||
<span v-if="positionsHint" id="positions-hint" class="warn">{{ positionsHint }}</span>
|
||||
<span v-if="reachHint" id="page-reach-hint" class="warn">{{ reachHint }}</span>
|
||||
</div>
|
||||
<div class="pages" id="layout-preview" aria-label="Screen layout">
|
||||
<div class="pages" id="layout-preview" :aria-label="t('editor.layout.aria')">
|
||||
<DevicePage v-for="page in shown" :key="page" :page="page - 1" :entries="entries" :pages="pages" :moving="state.drag.moving" />
|
||||
<div class="page ghost" :class="{ disabled: !canAdd }">
|
||||
<div class="page-label"><span>Page {{ shown + 1 }}</span></div>
|
||||
<div class="page-label"><span>{{ t("editor.page.label", { page: shown + 1 }) }}</span></div>
|
||||
<div class="device" :class="{ cyd: currentScreen?.board !== 'guition' }" id="add-page" role="button" :tabindex="canAdd ? 0 : -1" @click="canAdd && addPage()" @keydown.enter.prevent="canAdd && addPage()">
|
||||
{{ canAdd ? "+ Add page" : `${MAX_PAGES} pages is the most` }}
|
||||
{{ canAdd ? t("editor.layout.add_page") : t("editor.layout.max_pages", MAX_PAGES) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -4,14 +4,15 @@
|
||||
// exactly where it lands.
|
||||
import { computed } from "vue";
|
||||
import { vDrag } from "../drag";
|
||||
import { t } from "../i18n";
|
||||
import { domainInfo } from "../model/layout";
|
||||
import { glyph } from "../model/topbar";
|
||||
import { addTile, automaticIcon, isGuition, repeatable, state, tileLimit } from "../store";
|
||||
|
||||
const FILTERS: [string, string][] = [
|
||||
["", "All"], ["light", "Lights"], ["climate", "Climate"], ["switch", "Switches"], ["binary_sensor", "Status"], ["button", "Actions"],
|
||||
["script", "Scripts"], ["fan", "Fans"], ["cover", "Covers"], ["scene", "Scenes"], ["vacuum", "Vacuum"], ["sensor", "Sensors"],
|
||||
["media_player", "Media"], ["weather", "Weather"], ["number", "Values"], ["select", "Selects"], ["person", "People"], ["timer", "Timers"], ["screen", "Screen"],
|
||||
// The domains to filter on; the label of each is editor.library.filters.<domain>, "all" for no filter.
|
||||
const FILTERS = [
|
||||
"", "light", "climate", "switch", "binary_sensor", "button", "script", "fan", "cover", "scene", "vacuum", "sensor",
|
||||
"media_player", "weather", "number", "select", "person", "timer", "screen",
|
||||
];
|
||||
const ALIAS: Record<string, string> = { switch: "input_boolean", number: "input_number", select: "input_select", weather: "sun", button: "input_button" };
|
||||
const chosen = computed(() => new Set(state.layout?.tiles.map((t) => t.entity) || []));
|
||||
@@ -43,19 +44,19 @@ const tone = (e: { id: string; state?: string }) => {
|
||||
<template>
|
||||
<aside class="library" id="library">
|
||||
<div class="lib-head">
|
||||
<div class="lib-title">Library <small>{{ count }} entities</small></div>
|
||||
<input id="search" v-model="state.search" type="search" placeholder="Search by name, device, or room…" autocomplete="off" aria-label="Add an entity" />
|
||||
<div class="lib-title">{{ t("editor.library.title") }} <small>{{ t("editor.library.entities", count) }}</small></div>
|
||||
<input id="search" v-model="state.search" type="search" :placeholder="t('editor.library.search')" autocomplete="off" :aria-label="t('editor.library.search_label')" />
|
||||
<div class="filters" id="filters">
|
||||
<button v-for="[value, label] in FILTERS" :key="value" type="button" :aria-pressed="state.filter === value ? 'true' : 'false'" @click="state.filter = value">
|
||||
<span v-if="value" class="domain-icon" :style="{ color: domainInfo(value + '.')[2], background: domainInfo(value + '.')[3] }" aria-hidden="true">{{ domainInfo(value + ".")[1] }}</span>{{ label }}
|
||||
<button v-for="value in FILTERS" :key="value" type="button" :aria-pressed="state.filter === value ? 'true' : 'false'" @click="state.filter = value">
|
||||
<span v-if="value" class="domain-icon" :style="{ color: domainInfo(value + '.')[2], background: domainInfo(value + '.')[3] }" aria-hidden="true">{{ domainInfo(value + ".")[1] }}</span>{{ t(`editor.library.filters.${value || "all"}`) }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="lib-row">
|
||||
<select id="room" v-model="state.room" aria-label="Room">
|
||||
<option value="">All rooms</option>
|
||||
<select id="room" v-model="state.room" :aria-label="t('editor.library.room')">
|
||||
<option value="">{{ t("editor.library.all_rooms") }}</option>
|
||||
<option v-for="room in rooms" :key="room" :value="room">{{ room }}</option>
|
||||
</select>
|
||||
<button type="button" class="chip-toggle" id="hide-placed" :aria-pressed="state.hidePlaced ? 'true' : 'false'" title="Hide what is already on this screen" @click="state.hidePlaced = !state.hidePlaced">Hide placed</button>
|
||||
<button type="button" class="chip-toggle" id="hide-placed" :aria-pressed="state.hidePlaced ? 'true' : 'false'" :title="t('editor.library.hide_placed_title')" @click="state.hidePlaced = !state.hidePlaced">{{ t("editor.library.hide_placed") }}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="lib-list" id="results" aria-live="polite">
|
||||
@@ -68,9 +69,9 @@ const tone = (e: { id: string; state?: string }) => {
|
||||
</span>
|
||||
<span class="add" :class="{ done: placed(entity.id) }">{{ placed(entity.id) ? "✓" : "+" }}</span>
|
||||
</button>
|
||||
<p v-if="!matches.length" class="hint">{{ state.hidePlaced && !state.search && !state.filter && !state.room ? "Everything here is already on this screen." : "No entities found. Try a different name, room or filter." }}</p>
|
||||
<p v-else-if="matches.length > 80" class="hint">{{ matches.length }} results. Keep typing to narrow it down.</p>
|
||||
<p v-if="!matches.length" class="hint">{{ state.hidePlaced && !state.search && !state.filter && !state.room ? t("editor.library.all_placed") : t("editor.library.none_found") }}</p>
|
||||
<p v-else-if="matches.length > 80" class="hint">{{ t("editor.common.results", matches.length) }}</p>
|
||||
</div>
|
||||
<div class="lib-foot">{{ full ? `This screen holds ${tileLimit} tiles${tileLimit < 48 ? "; update its firmware for 48" : ""}.` : "Drag onto a page, or tap + to add to the first free slot. ⌘K searches everything." }}</div>
|
||||
<div class="lib-foot">{{ full ? t(tileLimit < 48 ? "editor.library.full_update" : "editor.library.full", tileLimit) : t("editor.library.hint") }}</div>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
// Per-screen local YAML override: a small file of the owner's, loaded after the shared screen package.
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from "vue";
|
||||
import { getJson, send } from "../api";
|
||||
import { t } from "../i18n";
|
||||
import { glyph } from "../model/topbar";
|
||||
import { go, state, toast } from "../store";
|
||||
|
||||
@@ -20,25 +21,25 @@ const profile = computed(() => state.overrideProfile);
|
||||
const content = ref("");
|
||||
const file = ref("");
|
||||
const attached = ref(false);
|
||||
const status = ref("Loading…");
|
||||
const status = ref(t("editor.common.loading"));
|
||||
const kind = ref("");
|
||||
const busy = ref(false);
|
||||
const editor = ref<HTMLTextAreaElement | null>(null);
|
||||
const gutter = ref<HTMLDivElement | null>(null);
|
||||
const lines = computed(() => content.value.split("\n").length);
|
||||
const gutterText = computed(() => Array.from({ length: lines.value }, (_, i) => i + 1).join("\n"));
|
||||
const count = computed(() => `${content.value.length} characters · ${lines.value} line${lines.value === 1 ? "" : "s"}`);
|
||||
const count = computed(() => `${t("editor.override.characters", content.value.length)} · ${t("editor.override.lines", lines.value)}`);
|
||||
let alive = true;
|
||||
function setStatus(message: string, k = "") { status.value = message; kind.value = k; }
|
||||
async function load() {
|
||||
if (!profile.value) { setStatus("No ESPHome profile was found for this screen.", "error"); return; }
|
||||
setStatus("Loading…");
|
||||
if (!profile.value) { setStatus(t("editor.override.no_profile"), "error"); return; }
|
||||
setStatus(t("editor.common.loading"));
|
||||
try {
|
||||
const data = await getJson(`firmware/profiles/${encodeURIComponent(profile.value)}/override`);
|
||||
file.value = data.override_file;
|
||||
attached.value = Boolean(data.attached);
|
||||
content.value = data.exists && data.content !== "{}\n" ? data.content : "";
|
||||
setStatus(data.attached ? "Changes here apply on the next build." : "Save once to attach this file to the profile.");
|
||||
setStatus(t(data.attached ? "editor.override.attached" : "editor.override.not_attached"));
|
||||
} catch (error: any) {
|
||||
setStatus(error.message, "error");
|
||||
}
|
||||
@@ -46,17 +47,17 @@ async function load() {
|
||||
async function saveOverride(runCheck = false) {
|
||||
if (!profile.value || busy.value) return;
|
||||
busy.value = true;
|
||||
setStatus("Checking and saving…");
|
||||
setStatus(t("editor.override.saving"));
|
||||
try {
|
||||
const data = await send(`firmware/profiles/${encodeURIComponent(profile.value)}/override`, "PUT", { content: content.value });
|
||||
file.value = data.override_file;
|
||||
attached.value = true;
|
||||
if (!runCheck) {
|
||||
setStatus("Saved. The override is kept during firmware updates.", "ok");
|
||||
toast("Override YAML saved.");
|
||||
setStatus(t("editor.override.saved"), "ok");
|
||||
toast(t("editor.override.saved_toast"));
|
||||
return;
|
||||
}
|
||||
setStatus("Saved. ESPHome is checking the complete profile…");
|
||||
setStatus(t("editor.override.checking"));
|
||||
await send("firmware/jobs", "POST", { file: profile.value, action: "validate" });
|
||||
pollCheck();
|
||||
} catch (error: any) {
|
||||
@@ -73,13 +74,13 @@ function pollCheck() {
|
||||
const data = await getJson("firmware");
|
||||
const current = data.job;
|
||||
if (current && current.file === checked && current.state === "running") {
|
||||
setStatus(`ESPHome is checking the profile… ${current.stage || ""}`.trim());
|
||||
setStatus(current.stage ? t("editor.override.checking_stage", { stage: current.stage }) : t("editor.override.checking_profile"));
|
||||
} else if (current && current.file === checked && current.state === "success") {
|
||||
setStatus("The complete profile is valid. Safe to build and install.", "ok");
|
||||
setStatus(t("editor.override.valid"), "ok");
|
||||
return;
|
||||
} else if (current && current.file === checked && current.state === "failed") {
|
||||
const error = (data.logs || []).filter((line: string) => /error|failed/i.test(line)).pop();
|
||||
setStatus(error || "ESPHome rejected the complete profile. See Firmware & USB for the full log.", "error");
|
||||
setStatus(error || t("editor.override.rejected"), "error");
|
||||
return;
|
||||
}
|
||||
if (Date.now() - started < 7200000) setTimeout(poll, 1200);
|
||||
@@ -90,16 +91,16 @@ function pollCheck() {
|
||||
poll();
|
||||
}
|
||||
function useExample() {
|
||||
if (!content.value.trim() || confirm("Replace the current text with the example?")) {
|
||||
if (!content.value.trim() || confirm(t("editor.override.confirm_example"))) {
|
||||
content.value = OVERRIDE_EXAMPLE;
|
||||
setStatus("Example loaded. Save it when you are ready.");
|
||||
setStatus(t("editor.override.example_loaded"));
|
||||
editor.value?.focus();
|
||||
}
|
||||
}
|
||||
function clear() {
|
||||
if (!content.value.trim() || confirm("Clear the local override?")) {
|
||||
if (!content.value.trim() || confirm(t("editor.override.confirm_clear"))) {
|
||||
content.value = "";
|
||||
setStatus("The override will be empty after you save.");
|
||||
setStatus(t("editor.override.cleared"));
|
||||
editor.value?.focus();
|
||||
}
|
||||
}
|
||||
@@ -124,34 +125,34 @@ onBeforeUnmount(() => { alive = false; });
|
||||
<div class="panel" id="override-dialog">
|
||||
<div class="panel-head">
|
||||
<div class="tx">
|
||||
<span class="eyebrow">Advanced · local override</span>
|
||||
<h1 id="override-title">Override YAML{{ state.overrideFriendly ? ` · ${state.overrideFriendly}` : "" }}</h1>
|
||||
<p>This small file is yours. It is loaded after the shared screen package and stays in place when ESP Screen Manager updates. Change hardware-specific details here, such as a display model.</p>
|
||||
<span class="eyebrow">{{ t("editor.override.eyebrow") }}</span>
|
||||
<h1 id="override-title">{{ state.overrideFriendly ? t("editor.override.title_named", { name: state.overrideFriendly }) : t("editor.override.title") }}</h1>
|
||||
<p>{{ t("editor.override.intro") }}</p>
|
||||
</div>
|
||||
<button type="button" class="btn quiet" id="close-override" @click="go('')">← Back</button>
|
||||
<button type="button" class="btn quiet" id="close-override" @click="go('')">{{ t("editor.common.back") }}</button>
|
||||
</div>
|
||||
<div class="notice">
|
||||
<span class="mdi">{{ glyph("F0493") }}</span>
|
||||
<div><strong>What is safe to change?</strong><span>Display, touchscreen, substitutions, pins and other device details. Name, Wi-Fi, API, OTA and package links remain managed.</span></div>
|
||||
<div><strong>{{ t("editor.override.safe_title") }}</strong><span>{{ t("editor.override.safe_text") }}</span></div>
|
||||
</div>
|
||||
<div class="file-row">
|
||||
<code id="override-file">{{ file || profile || "" }}</code>
|
||||
<span id="override-state" class="chip" :class="{ good: attached }">{{ attached ? "Active" : "Ready to attach" }}</span>
|
||||
<span id="override-state" class="chip" :class="{ good: attached }">{{ attached ? t("editor.override.active") : t("editor.override.ready") }}</span>
|
||||
</div>
|
||||
<div class="yaml-editor" id="yaml-editor-wrap">
|
||||
<div ref="gutter" class="yaml-gutter" id="override-gutter" aria-hidden="true">{{ gutterText }}</div>
|
||||
<textarea ref="editor" id="override-editor" v-model="content" spellcheck="false" autocapitalize="off" autocomplete="off" autocorrect="off"
|
||||
aria-label="Local override YAML" placeholder="# Example display: - id: !extend my_display model: ST7789V" @keydown="onKey" @scroll="syncScroll"></textarea>
|
||||
:aria-label="t('editor.override.editor_label')" placeholder="# Example display: - id: !extend my_display model: ST7789V" @keydown="onKey" @scroll="syncScroll"></textarea>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button type="button" class="btn quiet mini" id="override-example" @click="useExample">Use example</button>
|
||||
<button type="button" class="btn quiet mini" id="override-empty" @click="clear">Clear</button>
|
||||
<button type="button" class="btn quiet mini" id="override-example" @click="useExample">{{ t("editor.override.use_example") }}</button>
|
||||
<button type="button" class="btn quiet mini" id="override-empty" @click="clear">{{ t("editor.override.clear") }}</button>
|
||||
<span id="override-count" class="hint">{{ count }}</span>
|
||||
</div>
|
||||
<p id="override-status" class="status-line" :class="kind" role="status">{{ status }}</p>
|
||||
<div class="actions">
|
||||
<button type="button" class="btn quiet" id="override-check" :disabled="busy || !profile" @click="saveOverride(true)">Save & check</button>
|
||||
<button type="button" class="btn primary" id="override-save" :disabled="busy || !profile" @click="saveOverride(false)">Save override</button>
|
||||
<button type="button" class="btn quiet" id="override-check" :disabled="busy || !profile" @click="saveOverride(true)">{{ t("editor.override.save_check") }}</button>
|
||||
<button type="button" class="btn primary" id="override-save" :disabled="busy || !profile" @click="saveOverride(false)">{{ t("editor.override.save") }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
// One screen: the head with its status, the Layout and Settings tabs, and the drawer over the right side.
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from "vue";
|
||||
import { canAlert, closeInspector, copyLayoutFrom, currentScreen, exportLayout, go, identify, importLayout, save, startUpdate, state } from "../store";
|
||||
import { t } from "../i18n";
|
||||
import {
|
||||
canAlert, closeInspector, copyLayoutFrom, currentScreen, exportLayout, go, identify, importLayout, needsUpdate, save, screenText, startUpdate, state,
|
||||
} from "../store";
|
||||
import LayoutView from "./LayoutView.vue";
|
||||
import SettingsTab from "./SettingsTab.vue";
|
||||
import Drawer from "./Drawer.vue";
|
||||
@@ -9,8 +12,8 @@ import Drawer from "./Drawer.vue";
|
||||
const screen = computed(() => currentScreen.value!);
|
||||
const statusText = computed(() => screen.value.online
|
||||
? `${screen.value.delivery} · ${screen.value.status}`
|
||||
: "Offline · changes are saved");
|
||||
const updateReady = computed(() => screen.value.update?.available && screen.value.online && screen.value.update?.profile && screen.value.update?.host);
|
||||
: t("editor.screen_view.offline"));
|
||||
const updateReady = computed(() => needsUpdate(screen.value) && screen.value.online && screen.value.update?.profile && screen.value.update?.host);
|
||||
const others = computed(() => state.inventory.screens.filter((s) => s.id !== screen.value.id && s.layout?.tiles?.length));
|
||||
const copyOpen = ref(false);
|
||||
const fileInput = ref<HTMLInputElement | null>(null);
|
||||
@@ -28,7 +31,7 @@ function inspectAll() {
|
||||
}
|
||||
function copyFrom(id: string) {
|
||||
closeMenu();
|
||||
if (state.dirty && !confirm("Replace the unsaved layout with a copy of the other screen's?")) return;
|
||||
if (state.dirty && !confirm(t("editor.screen_view.confirm.copy"))) return;
|
||||
copyLayoutFrom(id);
|
||||
}
|
||||
function pickFile() { closeMenu(); fileInput.value?.click(); }
|
||||
@@ -37,7 +40,7 @@ async function onFile(e: Event) {
|
||||
const file = input.files?.[0];
|
||||
input.value = "";
|
||||
if (!file) return;
|
||||
if (state.dirty && !confirm("Replace the unsaved layout with the imported one?")) return;
|
||||
if (state.dirty && !confirm(t("editor.screen_view.confirm.import"))) return;
|
||||
importLayout(await file.text());
|
||||
}
|
||||
function onKey(e: KeyboardEvent) {
|
||||
@@ -61,34 +64,34 @@ onBeforeUnmount(() => { document.removeEventListener("keydown", onKey); document
|
||||
<header class="main-head">
|
||||
<div>
|
||||
<span class="eyebrow" id="screen-name">{{ screen.name }}</span>
|
||||
<h1>{{ state.layout?.title || "Home" }}</h1>
|
||||
<h1>{{ state.layout?.title || screenText("editor.mockup.home") }}</h1>
|
||||
</div>
|
||||
<span id="delivery" class="chip" :class="{ off: !screen.online }" :title="statusText"><span class="dot"></span>{{ statusText }}</span>
|
||||
<div class="head-right">
|
||||
<div class="seg" role="tablist">
|
||||
<button type="button" id="tab-layout" role="tab" :aria-pressed="state.tab === 'layout' ? 'true' : 'false'" @click="state.tab = 'layout'">Layout</button>
|
||||
<button type="button" id="tab-settings" role="tab" :aria-pressed="state.tab === 'settings' ? 'true' : 'false'" @click="state.tab = 'settings'; closeInspector()">Screen settings</button>
|
||||
<button type="button" id="tab-layout" role="tab" :aria-pressed="state.tab === 'layout' ? 'true' : 'false'" @click="state.tab = 'layout'">{{ t("editor.screen_view.tabs.layout") }}</button>
|
||||
<button type="button" id="tab-settings" role="tab" :aria-pressed="state.tab === 'settings' ? 'true' : 'false'" @click="state.tab = 'settings'; closeInspector()">{{ t("editor.screen_view.tabs.settings") }}</button>
|
||||
</div>
|
||||
<span v-if="!state.dirty" id="dirty" class="chip" :class="{ sent: state.saved }">{{ state.saved ? "Saved · sent to screen" : "All saved" }}</span>
|
||||
<span v-else id="dirty" class="chip dirty">Unsaved changes</span>
|
||||
<span v-if="!state.dirty" id="dirty" class="chip" :class="{ sent: state.saved }">{{ state.saved ? t("editor.screen_view.sent") : t("editor.screen_view.all_saved") }}</span>
|
||||
<span v-else id="dirty" class="chip dirty">{{ t("editor.common.unsaved") }}</span>
|
||||
<button v-if="state.dirty" id="save" type="button" class="btn primary" :disabled="state.busy" title="⌘S" @click="save()">
|
||||
<span v-if="state.busy" class="spin small"></span>{{ state.busy ? "Saving…" : "Save & send" }}
|
||||
<span v-if="state.busy" class="spin small"></span>{{ state.busy ? t("editor.common.saving") : t("editor.common.save_send") }}
|
||||
</button>
|
||||
<button id="more" type="button" class="icon-btn" aria-label="More" aria-haspopup="menu" :aria-expanded="state.menuOpen ? 'true' : 'false'" @click.stop="state.menuOpen = !state.menuOpen; copyOpen = false">···</button>
|
||||
<button id="more" type="button" class="icon-btn" :aria-label="t('editor.screen_view.more')" aria-haspopup="menu" :aria-expanded="state.menuOpen ? 'true' : 'false'" @click.stop="state.menuOpen = !state.menuOpen; copyOpen = false">···</button>
|
||||
<div v-if="state.menuOpen" class="menu" role="menu">
|
||||
<button type="button" role="menuitem" id="identify" :disabled="!canAlert(screen) || !screen.online" :title="canAlert(screen) ? '' : 'Needs firmware 0.2.31 or newer'" @click="closeMenu(); identify(screen)">Identify <small>blink the screen</small></button>
|
||||
<button type="button" role="menuitem" id="inspect" @click="inspectAll">Read current data <small>from Home Assistant</small></button>
|
||||
<button type="button" role="menuitem" id="identify" :disabled="!canAlert(screen) || !screen.online" :title="canAlert(screen) ? '' : t('editor.screen_view.menu.identify_needs')" @click="closeMenu(); identify(screen)">{{ t("editor.screen_view.menu.identify") }} <small>{{ t("editor.screen_view.menu.identify_hint") }}</small></button>
|
||||
<button type="button" role="menuitem" id="inspect" @click="inspectAll">{{ t("editor.common.read_current_data") }} <small>{{ t("editor.screen_view.menu.inspect_hint") }}</small></button>
|
||||
<div class="sep"></div>
|
||||
<button type="button" role="menuitem" id="copy-layout" :disabled="!others.length" :aria-expanded="copyOpen ? 'true' : 'false'" @click.stop="copyOpen = !copyOpen">Copy layout from… <small>{{ others.length ? `${others.length} screen${others.length === 1 ? "" : "s"}` : "no other screen" }}</small></button>
|
||||
<button type="button" role="menuitem" id="copy-layout" :disabled="!others.length" :aria-expanded="copyOpen ? 'true' : 'false'" @click.stop="copyOpen = !copyOpen">{{ t("editor.screen_view.menu.copy") }} <small>{{ others.length ? t("editor.screen_view.menu.copy_screens", others.length) : t("editor.screen_view.menu.copy_none") }}</small></button>
|
||||
<div v-if="copyOpen" class="sub">
|
||||
<button v-for="other in others" :key="other.id" type="button" role="menuitem" @click="copyFrom(other.id)">{{ other.name }} <small>{{ other.layout.tiles.length }} tiles</small></button>
|
||||
<button v-for="other in others" :key="other.id" type="button" role="menuitem" @click="copyFrom(other.id)">{{ other.name }} <small>{{ t("editor.screen_view.menu.copy_tiles", other.layout.tiles.length) }}</small></button>
|
||||
</div>
|
||||
<button type="button" role="menuitem" id="export-layout" @click="closeMenu(); exportLayout()">Export layout <small>JSON</small></button>
|
||||
<button type="button" role="menuitem" id="import-layout" @click="pickFile">Import layout… <small>JSON</small></button>
|
||||
<button type="button" role="menuitem" id="export-layout" @click="closeMenu(); exportLayout()">{{ t("editor.screen_view.menu.export") }} <small>JSON</small></button>
|
||||
<button type="button" role="menuitem" id="import-layout" @click="pickFile">{{ t("editor.screen_view.menu.import") }} <small>JSON</small></button>
|
||||
<div class="sep"></div>
|
||||
<button type="button" role="menuitem" id="open-override" :disabled="!screen.update?.profile" :title="screen.update?.profile ? '' : 'No ESPHome profile found for this screen'" @click="openOverride">Override YAML <small>advanced</small></button>
|
||||
<button type="button" role="menuitem" @click="closeMenu(); go('#firmware')">Firmware & USB</button>
|
||||
<button v-if="updateReady" type="button" role="menuitem" @click="closeMenu(); startUpdate(screen)">Update firmware <small>{{ screen.update?.target }}</small></button>
|
||||
<button type="button" role="menuitem" id="open-override" :disabled="!screen.update?.profile" :title="screen.update?.profile ? '' : t('editor.screen_view.menu.override_none')" @click="openOverride">{{ t("editor.screen_view.menu.override") }} <small>{{ t("editor.screen_view.menu.override_hint") }}</small></button>
|
||||
<button type="button" role="menuitem" @click="closeMenu(); go('#firmware')">{{ t("editor.nav.firmware") }}</button>
|
||||
<button v-if="updateReady" type="button" role="menuitem" @click="closeMenu(); startUpdate(screen)">{{ t("editor.screen_view.menu.update") }} <small>{{ screen.update?.target }}</small></button>
|
||||
</div>
|
||||
<input ref="fileInput" type="file" accept="application/json,.json" hidden @change="onFile" />
|
||||
</div>
|
||||
|
||||
@@ -2,8 +2,12 @@
|
||||
// Screen settings: the same groups and rows as the settings page on the screen itself. Every change applies at
|
||||
// once, like on the screen; no Save needed.
|
||||
import { computed } from "vue";
|
||||
import { t } from "../i18n";
|
||||
import { glyph } from "../model/topbar";
|
||||
import { currentScreen, pageReachWarning, SETTING_GROUPS, setSetting, settingText, settingValues, settingsView, state, steppedSetting, type SettingRow } from "../store";
|
||||
import {
|
||||
choiceText, currentScreen, pageReachWarning, SETTING_GROUPS, setSetting, settingLabel, settingText, settingValues, settingsView, state, steppedSetting,
|
||||
type SettingRow,
|
||||
} from "../store";
|
||||
|
||||
const view = computed(() => settingsView());
|
||||
const values = computed(() => settingValues());
|
||||
@@ -14,12 +18,12 @@ const reachWarning = computed(() => pageReachWarning());
|
||||
const unavailable = (row: SettingRow) => offline.value || Boolean(view.value?.unavailable.includes(row.key));
|
||||
const needs = (row: SettingRow) => (row.needs ? Boolean(values.value[row.needs]) : true);
|
||||
const status = computed(() => offline.value
|
||||
? "This screen is offline. Its settings can change once it's back."
|
||||
? t("editor.screen_settings.status.offline")
|
||||
: state.settingPending
|
||||
? "Saving…"
|
||||
? t("editor.common.saving")
|
||||
: view.value?.owner === "screen"
|
||||
? "Changes apply on the screen at once, and show up here when they change there."
|
||||
: "Changes apply at once. Firmware 0.2.49 lets the screen keep them itself.");
|
||||
? t("editor.screen_settings.status.screen")
|
||||
: t("editor.screen_settings.status.app"));
|
||||
const stepDisabled = (row: SettingRow, direction: number) => {
|
||||
const value = values.value[row.key];
|
||||
if (unavailable(row) || !needs(row) || value === null || value === undefined) return true;
|
||||
@@ -60,23 +64,23 @@ function click(e: MouseEvent, row: SettingRow, direction: number) {
|
||||
<div class="settings" id="general-settings" :class="{ offline }">
|
||||
<span class="status" id="settings-status" role="status">{{ status }}</span>
|
||||
<div v-if="view" class="set-grid" id="settings-groups">
|
||||
<section v-for="group in groups" :key="group.title" class="set-card">
|
||||
<h4><span class="mdi">{{ glyph(group.icon) }}</span>{{ group.title }}</h4>
|
||||
<section v-for="group in groups" :key="group.group" class="set-card">
|
||||
<h4><span class="mdi">{{ glyph(group.icon) }}</span>{{ t(`editor.screen_settings.groups.${group.group}`) }}</h4>
|
||||
<div v-for="row in group.rows" :key="row.key" class="srow" :class="[`setting-${row.kind}`, { inactive: !needs(row) || unavailable(row) }]" :data-setting="row.key"
|
||||
:title="unavailable(row) && !offline ? 'This entity is off in Home Assistant, or the screen is restarting.' : ''"
|
||||
:title="unavailable(row) && !offline ? t('editor.screen_settings.unavailable') : ''"
|
||||
@click="row.kind === 'toggle' && ($event.target as HTMLElement).closest('.srow') === $event.currentTarget && !($event.target as HTMLElement).closest('button') && !unavailable(row) && setSetting(row.key, !values[row.key], 150)">
|
||||
<span class="s-label" :id="`setting-label-${row.key}`">{{ row.label }}</span>
|
||||
<span class="s-label" :id="`setting-label-${row.key}`">{{ settingLabel(row) }}</span>
|
||||
<div class="s-control">
|
||||
<button v-if="row.kind === 'toggle'" type="button" class="switch" :class="{ unknown: values[row.key] === null || values[row.key] === undefined }" role="switch"
|
||||
:id="`setting-${row.key}`" :aria-checked="Boolean(values[row.key]) ? 'true' : 'false'" :aria-labelledby="`setting-label-${row.key}`"
|
||||
:disabled="unavailable(row)" @click.stop="setSetting(row.key, !values[row.key], 150)"></button>
|
||||
<div v-else-if="row.kind === 'choice'" class="seg" role="group" :aria-labelledby="`setting-label-${row.key}`">
|
||||
<button v-for="[value, text] in row.options!" :key="String(value)" type="button" :aria-pressed="values[row.key] === value ? 'true' : 'false'" :disabled="unavailable(row)" @click="setSetting(row.key, value, 150)">{{ text }}</button>
|
||||
<button v-for="value in row.options!" :key="String(value)" type="button" :aria-pressed="values[row.key] === value ? 'true' : 'false'" :disabled="unavailable(row)" @click="setSetting(row.key, value, 150)">{{ choiceText(row, value) }}</button>
|
||||
</div>
|
||||
<div v-else class="step">
|
||||
<button type="button" :aria-label="`${row.label} lower`" :disabled="stepDisabled(row, -1)" @pointerdown="down($event, row, -1)" @pointerup="up" @pointercancel="up" @pointerleave="up" @click="click($event, row, -1)"><span class="mdi">{{ glyph("F0374") }}</span></button>
|
||||
<button type="button" :aria-label="t('editor.screen_settings.lower', { name: settingLabel(row) })" :disabled="stepDisabled(row, -1)" @pointerdown="down($event, row, -1)" @pointerup="up" @pointercancel="up" @pointerleave="up" @click="click($event, row, -1)"><span class="mdi">{{ glyph("F0374") }}</span></button>
|
||||
<output :id="`setting-${row.key}`" :aria-labelledby="`setting-label-${row.key}`">{{ settingText(row, values) }}</output>
|
||||
<button type="button" :aria-label="`${row.label} higher`" :disabled="stepDisabled(row, 1)" @pointerdown="down($event, row, 1)" @pointerup="up" @pointercancel="up" @pointerleave="up" @click="click($event, row, 1)"><span class="mdi">{{ glyph("F0415") }}</span></button>
|
||||
<button type="button" :aria-label="t('editor.screen_settings.higher', { name: settingLabel(row) })" :disabled="stepDisabled(row, 1)" @pointerdown="down($event, row, 1)" @pointerup="up" @pointercancel="up" @pointerleave="up" @click="click($event, row, 1)"><span class="mdi">{{ glyph("F0415") }}</span></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,19 +1,32 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
import { t } from "../i18n";
|
||||
import { versionAtLeast } from "../model/layout";
|
||||
import { glyph } from "../model/topbar";
|
||||
import { copyText, go, openIntegrations, PHASES, refresh, route, select, startUpdate, state, updateProgress, whatsNew } from "../store";
|
||||
import {
|
||||
copyText, firmwareVersion, go, needsUpdate, newLanguageText, openIntegrations, phaseText, refresh, route, select, startUpdate, state,
|
||||
updateProgress, whatsNew,
|
||||
} from "../store";
|
||||
import type { Screen } from "../types";
|
||||
|
||||
const hostFor = ref<string | null>(null);
|
||||
const host = ref("");
|
||||
// A screen that only needs the new language (app 0.2.90) says so instead of naming the version it already has.
|
||||
const languageOnly = (screen: Screen) => {
|
||||
const u = screen.update || {};
|
||||
return Boolean(u.language) && (!u.target || versionAtLeast(firmwareVersion(screen), u.target));
|
||||
};
|
||||
function updateState(screen: Screen) {
|
||||
const u = screen.update || {};
|
||||
if (u.state === "running" || state.updating.includes(screen.id)) return { kind: "running", text: PHASES[u.phase || ""] || "Starting update…" };
|
||||
if (u.state === "queued") return { kind: "queued", text: "Queued for the update" };
|
||||
if (u.available && screen.online) return { kind: "available", text: `Update ${u.target}` };
|
||||
if (u.state === "running" || state.updating.includes(screen.id)) return { kind: "running", text: phaseText(u.phase) };
|
||||
if (u.state === "queued") return { kind: "queued", text: t("editor.sidebar.update.queued") };
|
||||
if (needsUpdate(screen) && screen.online)
|
||||
return { kind: "available", text: languageOnly(screen) ? newLanguageText() : t("editor.sidebar.update.available", { version: u.target }) };
|
||||
if (u.result && Date.now() / 1000 - u.result.time < 86400) return { kind: u.result.state === "failed" ? "failed" : "done", text: u.result.message };
|
||||
return null;
|
||||
}
|
||||
// What the update brings: the new language first, when the version changes as well, then the firmware's notes.
|
||||
const notes = (screen: Screen) => [...(screen.update?.language && !languageOnly(screen) ? [newLanguageText()] : []), ...whatsNew(screen)];
|
||||
function update(screen: Screen) {
|
||||
const u = screen.update || {};
|
||||
if (u.host && u.profile) startUpdate(screen);
|
||||
@@ -30,56 +43,56 @@ const lastLog = () => {
|
||||
return lines.length ? lines[lines.length - 1] : "";
|
||||
};
|
||||
const pendingText = (p: { installed?: boolean; downloaded?: boolean; file: string }) => p.installed
|
||||
? "Installed, but not yet in Home Assistant. Add the discovered ESPHome device under Settings → Devices & services, paste the API key there, and turn on “Allow the device to perform Home Assistant actions” under Configure."
|
||||
? t("editor.sidebar.pending.installed")
|
||||
: p.downloaded
|
||||
? "Firmware downloaded, not yet in Home Assistant. Put it on the screen with ESPHome Web, then add the discovered ESPHome device under Settings → Devices & services, paste the API key there, and allow the Home Assistant actions under Configure."
|
||||
: `Not yet in Home Assistant. Already flashed? Add the ESPHome device under Settings → Devices & services, then allow the Home Assistant actions under Configure. Not flashed yet? Firmware & USB → ${p.file}.`;
|
||||
? t("editor.sidebar.pending.downloaded")
|
||||
: t("editor.sidebar.pending.not_flashed", { file: p.file });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<aside class="side">
|
||||
<div class="brand"><span class="mark">▦</span><span>ESP Screens</span></div>
|
||||
<span id="connection" class="conn" :class="{ online: state.connected }" role="status">
|
||||
{{ !state.reachable ? "Management page unreachable · retrying…" : state.connected ? "Home Assistant connected" : "Reconnecting to Home Assistant…" }}
|
||||
{{ !state.reachable ? t("editor.sidebar.connection.unreachable") : state.connected ? t("editor.sidebar.connection.connected") : t("editor.sidebar.connection.reconnecting") }}
|
||||
</span>
|
||||
<button type="button" class="search-btn" id="open-palette" @click="state.palette = true">⌕ Search<kbd>⌘K</kbd></button>
|
||||
<div class="label">Screens</div>
|
||||
<button type="button" class="search-btn" id="open-palette" @click="state.palette = true">⌕ {{ t("editor.sidebar.search") }}<kbd>⌘K</kbd></button>
|
||||
<div class="label">{{ t("editor.sidebar.screens") }}</div>
|
||||
<div id="screens">
|
||||
<div v-for="screen in state.inventory.screens" :key="screen.id" class="screen-item" :class="{ selected: screen.id === state.selected && route === '' }">
|
||||
<button type="button" class="nav-item" :aria-current="screen.id === state.selected && route === '' ? 'true' : 'false'" @click="select(screen.id)">
|
||||
<span class="dot" :class="{ off: !screen.online }"></span>
|
||||
<span class="txt">
|
||||
<span>{{ screen.name }}</span>
|
||||
<small>{{ screen.online ? "Online" : "Offline" }}{{ screen.area ? " · " + screen.area : "" }} · {{ screen.firmware || "unknown" }}</small>
|
||||
<small>{{ screen.online ? t("editor.common.online") : t("editor.common.offline") }}{{ screen.area ? " · " + screen.area : "" }} · {{ screen.firmware || t("editor.common.unknown") }}</small>
|
||||
</span>
|
||||
<span v-if="screen.id === state.selected && state.dirty" class="unsaved" role="img" aria-label="Unsaved changes" title="Unsaved changes"></span>
|
||||
<span v-if="updateState(screen)?.kind === 'available'" class="pill">Update</span>
|
||||
<span v-if="screen.id === state.selected && state.dirty" class="unsaved" role="img" :aria-label="t('editor.common.unsaved')" :title="t('editor.common.unsaved')"></span>
|
||||
<span v-if="updateState(screen)?.kind === 'available'" class="pill">{{ t("editor.sidebar.update.pill") }}</span>
|
||||
<span v-else-if="updateState(screen)?.kind === 'running'" class="spin small"></span>
|
||||
</button>
|
||||
<div v-if="updateState(screen)" class="screen-update" :class="updateState(screen)!.kind">
|
||||
<template v-if="updateState(screen)!.kind === 'available'">
|
||||
<template v-if="hostFor === screen.id">
|
||||
<small>The IP address, once; new firmware reports it itself.</small>
|
||||
<small>{{ t("editor.sidebar.host.hint") }}</small>
|
||||
<form class="screen-host" @submit.prevent="startWithHost(screen)">
|
||||
<input v-model="host" placeholder="IP address, e.g. 192.168.1.50" required pattern="[A-Za-z0-9][A-Za-z0-9.\-]*" aria-label="IP address" autofocus />
|
||||
<button type="submit" class="btn mini primary">Start</button>
|
||||
<button type="button" class="icon-btn" aria-label="Cancel" @click="hostFor = null">✕</button>
|
||||
<input v-model="host" :placeholder="t('editor.sidebar.host.placeholder')" required pattern="[A-Za-z0-9][A-Za-z0-9.\-]*" :aria-label="t('editor.sidebar.host.label')" autofocus />
|
||||
<button type="submit" class="btn mini primary">{{ t("editor.sidebar.host.start") }}</button>
|
||||
<button type="button" class="icon-btn" :aria-label="t('editor.common.cancel')" @click="hostFor = null">✕</button>
|
||||
</form>
|
||||
</template>
|
||||
<template v-else>
|
||||
<small>{{ updateState(screen)!.text }}</small>
|
||||
<button type="button" class="btn mini primary" :disabled="!screen.update?.profile" :title="screen.update?.profile ? '' : 'No ESPHome profile found with this device name.'" @click="update(screen)">Update</button>
|
||||
<button type="button" class="btn mini primary" :disabled="!screen.update?.profile" :title="screen.update?.profile ? '' : t('editor.sidebar.update.no_profile')" @click="update(screen)">{{ t("editor.sidebar.update.button") }}</button>
|
||||
</template>
|
||||
<details v-if="whatsNew(screen).length" class="whatsnew">
|
||||
<summary>What's new</summary>
|
||||
<ul><li v-for="line in whatsNew(screen).slice(0, 8)" :key="line">{{ line }}</li></ul>
|
||||
<details v-if="notes(screen).length" class="whatsnew">
|
||||
<summary>{{ t("editor.sidebar.update.whats_new") }}</summary>
|
||||
<ul><li v-for="line in notes(screen).slice(0, 8)" :key="line">{{ line }}</li></ul>
|
||||
</details>
|
||||
</template>
|
||||
<template v-else-if="updateState(screen)!.kind === 'running' && updateProgress(screen)">
|
||||
<div class="progress" role="progressbar" :aria-valuenow="updateProgress(screen)!.percent" aria-valuemin="0" aria-valuemax="100"><i :style="{ width: updateProgress(screen)!.percent + '%' }"></i></div>
|
||||
<div class="progress-text"><span>{{ updateProgress(screen)!.percent }} %</span><span :title="lastLog()">{{ updateProgress(screen)!.text }}</span></div>
|
||||
<small v-if="lastLog()" :title="lastLog()" style="white-space: nowrap; overflow: hidden; text-overflow: ellipsis">{{ lastLog() }}</small>
|
||||
<button type="button" class="btn link mini" style="justify-self: start" @click="go('#firmware')">Full log</button>
|
||||
<button type="button" class="btn link mini" style="justify-self: start" @click="go('#firmware')">{{ t("editor.sidebar.update.full_log") }}</button>
|
||||
</template>
|
||||
<small v-else :class="{ failed: updateState(screen)!.kind === 'failed' }">{{ updateState(screen)!.text }}</small>
|
||||
</div>
|
||||
@@ -90,21 +103,21 @@ const pendingText = (p: { installed?: boolean; downloaded?: boolean; file: strin
|
||||
<strong>{{ p.friendly }}</strong>
|
||||
<small>{{ pendingText(p) }}</small>
|
||||
<div class="pending-actions">
|
||||
<button type="button" class="btn mini quiet" @click="openIntegrations">Open Devices & services</button>
|
||||
<button v-if="p.api_key" type="button" class="btn mini quiet" @click="copyText(p.api_key!)">Copy API key</button>
|
||||
<button type="button" class="btn mini quiet" @click="openIntegrations">{{ t("editor.common.open_integrations") }}</button>
|
||||
<button v-if="p.api_key" type="button" class="btn mini quiet" @click="copyText(p.api_key!)">{{ t("editor.sidebar.copy_api_key") }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button id="new-screen" type="button" class="nav-item ghost" :aria-current="route === '#new-screen' ? 'true' : 'false'" @click="go('#new-screen')">
|
||||
<span class="plus">+</span><span class="txt">New screen</span>
|
||||
<span class="plus">+</span><span class="txt">{{ t("editor.nav.new_screen") }}</span>
|
||||
</button>
|
||||
<div class="spacer"></div>
|
||||
<div class="more">
|
||||
<div class="label">More</div>
|
||||
<button id="open-firmware" type="button" class="nav-item" :aria-current="route === '#firmware' ? 'true' : 'false'" @click="go('#firmware')"><span class="mdi">{{ glyph("F0241") }}</span><span class="txt">Firmware & USB</span></button>
|
||||
<button id="open-alerts" type="button" class="nav-item" :aria-current="route === '#alerts' ? 'true' : 'false'" @click="go('#alerts')"><span class="mdi">{{ glyph("F0594") }}</span><span class="txt">Alerts</span></button>
|
||||
<button id="open-settings" type="button" class="nav-item" :aria-current="route === '#settings' ? 'true' : 'false'" @click="go('#settings')"><span class="mdi">{{ glyph("F0493") }}</span><span class="txt">Settings</span></button>
|
||||
<button id="refresh" type="button" class="nav-item ghost" @click="refresh()"><span class="plus">↻</span><span class="txt">Refresh</span></button>
|
||||
<div class="label">{{ t("editor.sidebar.more") }}</div>
|
||||
<button id="open-firmware" type="button" class="nav-item" :aria-current="route === '#firmware' ? 'true' : 'false'" @click="go('#firmware')"><span class="mdi">{{ glyph("F0241") }}</span><span class="txt">{{ t("editor.nav.firmware") }}</span></button>
|
||||
<button id="open-alerts" type="button" class="nav-item" :aria-current="route === '#alerts' ? 'true' : 'false'" @click="go('#alerts')"><span class="mdi">{{ glyph("F0594") }}</span><span class="txt">{{ t("editor.nav.alerts") }}</span></button>
|
||||
<button id="open-settings" type="button" class="nav-item" :aria-current="route === '#settings' ? 'true' : 'false'" @click="go('#settings')"><span class="mdi">{{ glyph("F0493") }}</span><span class="txt">{{ t("editor.nav.settings") }}</span></button>
|
||||
<button id="refresh" type="button" class="nav-item ghost" @click="refresh()"><span class="plus">↻</span><span class="txt">{{ t("editor.sidebar.refresh") }}</span></button>
|
||||
</div>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
@@ -3,9 +3,10 @@
|
||||
// dragged, drawn where it will land.
|
||||
import { computed, nextTick } from "vue";
|
||||
import { vDrag } from "../drag";
|
||||
import { displayNames, effectiveControls, isFull, isWide, pageOf, pageTarget, SLOTS_PER_PAGE } from "../model/layout";
|
||||
import { glyph } from "../model/topbar";
|
||||
import { entityName, isSelected, liveOf, openTile, placeTile, removeTile, state, tileIconCp } from "../store";
|
||||
import { numberText, t } from "../i18n";
|
||||
import { displayName, effectiveControls, isFull, isWide, pageOf, pageTarget, SLOTS_PER_PAGE } from "../model/layout";
|
||||
import { clockText, glyph } from "../model/topbar";
|
||||
import { clock24, entityName, isSelected, liveOf, numberStyle, openTile, placeTile, removeTile, screenText, state, tileIconCp } from "../store";
|
||||
import type { Tile } from "../types";
|
||||
|
||||
const props = defineProps<{ tile: Tile; slot: number; placeholder?: boolean }>();
|
||||
@@ -16,14 +17,14 @@ const goesTo = computed(() => pageTarget(props.tile.entity));
|
||||
const background = computed(() => state.inventory.backgrounds?.[props.tile.options?.background || ""]?.color);
|
||||
const bare = computed(() => props.tile.options?.background === "none");
|
||||
const display = computed(() => props.tile.options?.display || "standard");
|
||||
const note = computed(() => (display.value !== "standard" ? displayNames[display.value] || display.value : ""));
|
||||
const note = computed(() => (display.value !== "standard" ? displayName(display.value) : ""));
|
||||
const controls = computed(() => effectiveControls(props.tile, state.inventory));
|
||||
const domain = computed(() => props.tile.entity.split(".")[0]);
|
||||
const cp = computed(() => state.inventory.icons?.controls || {});
|
||||
const key = (n: string) => (cp.value[n] ? glyph(cp.value[n]) : "");
|
||||
const chosen = computed(() => isSelected(props.tile) && state.inspector?.kind === "tile");
|
||||
const live = computed(() => !props.placeholder && state.layout?.tiles.includes(props.tile));
|
||||
const label = computed(() => `${name.value}, slot ${(props.slot % SLOTS_PER_PAGE) + 1} on page ${pageOf(props.slot) + 1}. Enter: configure, arrow keys: move`);
|
||||
const label = computed(() => t("editor.tile_card.label", { name: name.value, slot: (props.slot % SLOTS_PER_PAGE) + 1, page: pageOf(props.slot) + 1 }));
|
||||
const now = computed(() => new Date(state.now));
|
||||
const hourAngle = computed(() => (now.value.getHours() % 12 + now.value.getMinutes() / 60) * 30);
|
||||
const minuteAngle = computed(() => now.value.getMinutes() * 6);
|
||||
@@ -35,25 +36,28 @@ const on = computed(() => Boolean(current.value) && !gone.value && current.value
|
||||
const isOn = computed(() => ["light", "switch", "input_boolean", "fan"].includes(domain.value) && current.value?.state === "on");
|
||||
const unit = computed(() => current.value?.a?.unit_of_measurement as string | undefined);
|
||||
const capital = (text: string) => text.charAt(0).toUpperCase() + text.slice(1).replace(/_/g, " ");
|
||||
// Home Assistant's words for the weather states, for a Home Assistant that hands us none.
|
||||
const WEATHER_WORDS: Record<string, string> = { "clear-night": "Clear, night", cloudy: "Cloudy", exceptional: "Exceptional", fog: "Fog", hail: "Hail", lightning: "Lightning", "lightning-rainy": "Lightning, rainy", partlycloudy: "Partly cloudy", pouring: "Pouring", rainy: "Rainy", snowy: "Snowy", "snowy-rainy": "Snowy, rainy", sunny: "Sunny", windy: "Windy", "windy-variant": "Windy" };
|
||||
// Numbers as the screens write them, "1,234.5" or "1.234,5" (app 0.2.90).
|
||||
const num = (value: unknown) => numberText(value as string | number, numberStyle.value);
|
||||
// Home Assistant's words for the weather states, for a Home Assistant that hands us none (editor.mockup.weather).
|
||||
const WEATHER = ["clear-night", "cloudy", "exceptional", "fog", "hail", "lightning", "lightning-rainy", "partlycloudy", "pouring", "rainy", "snowy", "snowy-rainy", "sunny", "windy", "windy-variant"];
|
||||
const weatherWord = (value: string) => (WEATHER.includes(value) ? screenText(`editor.mockup.weather.${value.replace(/-/g, "_")}`) : "");
|
||||
// A scene, script or button has no state worth a word: its state is the moment it last ran.
|
||||
const NO_STATUS = ["scene", "script", "button", "input_button"];
|
||||
// The text under the name: Home Assistant's word where it has one, the value with its unit for a sensor.
|
||||
const status = computed(() => {
|
||||
const c = current.value;
|
||||
if (!c || NO_STATUS.includes(domain.value)) return note.value;
|
||||
if (gone.value) return c.state === "unknown" ? "Unknown" : "Unavailable";
|
||||
if (gone.value) return screenText(c.state === "unknown" ? "editor.mockup.unknown" : "editor.mockup.unavailable");
|
||||
const a = c.a || {};
|
||||
if (domain.value === "climate") return `${a.current_temperature !== undefined ? `${a.current_temperature}° · ` : ""}${c.word || capital(c.state)}`;
|
||||
if (domain.value === "weather") return `${c.word || WEATHER_WORDS[c.state] || capital(c.state)}${a.temperature !== undefined ? ` · ${a.temperature}°` : ""}`;
|
||||
if (domain.value === "climate") return `${a.current_temperature !== undefined ? `${num(a.current_temperature)}° · ` : ""}${c.word || capital(c.state)}`;
|
||||
if (domain.value === "weather") return `${c.word || weatherWord(c.state) || capital(c.state)}${a.temperature !== undefined ? ` · ${num(a.temperature)}°` : ""}`;
|
||||
if (domain.value === "cover" && a.current_position !== undefined && a.current_position > 0 && a.current_position < 100) return `${c.word || capital(c.state)} · ${a.current_position} %`;
|
||||
if (domain.value === "media_player" && a.media_title) return `${c.word || capital(c.state)} · ${a.media_title}`;
|
||||
if (domain.value === "sensor") return `${c.state}${unit.value ? ` ${unit.value}` : ""}`;
|
||||
if (domain.value === "sensor") return `${num(c.state)}${unit.value ? ` ${unit.value}` : ""}`;
|
||||
if (domain.value === "timer") return c.word || capital(c.state);
|
||||
return c.word || capital(c.state);
|
||||
});
|
||||
const bigValue = computed(() => (current.value && !gone.value ? current.value.state : "—"));
|
||||
const bigValue = computed(() => (current.value && !gone.value ? num(current.value.state) : "—"));
|
||||
// The small slider's fill, from what the entity reports; off is empty, like the screen's grey fill.
|
||||
const fill = computed(() => {
|
||||
const c = current.value;
|
||||
@@ -70,11 +74,13 @@ const fill = computed(() => {
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
// The key on a scene, script or button, as the screen labels it.
|
||||
const runText = computed(() => screenText(`editor.mockup.run.${["scene", "script"].includes(domain.value) ? domain.value : "press"}`));
|
||||
const sliderStyle = computed(() => ({ background: `linear-gradient(to right, ${fill.value ? "#ffbf38" : "#c9ccd1"} ${fill.value}%, ${fill.value ? "#fff1d3" : "#e6e8ec"} ${fill.value}%)` }));
|
||||
const volumeStyle = computed(() => ({ background: `linear-gradient(to right, #2196f3 ${fill.value}%, #d3e8fb ${fill.value}%)` }));
|
||||
const setpoint = computed(() => {
|
||||
const t = current.value?.a?.temperature;
|
||||
return t !== undefined && t !== null ? `${t}°` : "—";
|
||||
const temperature = current.value?.a?.temperature;
|
||||
return temperature !== undefined && temperature !== null ? `${num(temperature)}°` : "—";
|
||||
});
|
||||
|
||||
async function onKey(e: KeyboardEvent) {
|
||||
@@ -107,13 +113,13 @@ async function onKey(e: KeyboardEvent) {
|
||||
</template>
|
||||
<template v-else-if="display === 'digital' && domain === 'screen'">
|
||||
<span class="ic mdi">{{ glyph(tileIconCp(tile)) }}</span>
|
||||
<span class="lead"><span class="big">{{ String(now.getHours()).padStart(2, "0") }}:{{ String(now.getMinutes()).padStart(2, "0") }}</span><span class="nm">{{ name }}</span></span>
|
||||
<span class="lead"><span class="big">{{ clockText(clock24, now) }}</span><span class="nm">{{ name }}</span></span>
|
||||
</template>
|
||||
<template v-else-if="full">
|
||||
<span class="ic mdi" :class="{ lit: isOn }">{{ glyph(tileIconCp(tile)) }}</span>
|
||||
<span class="lead">
|
||||
<span class="nm">{{ name }}</span>
|
||||
<span v-if="goesTo" class="goto">Page {{ goesTo }} ›</span>
|
||||
<span v-if="goesTo" class="goto">{{ screenText("editor.mockup.page_link", { page: goesTo }) }}</span>
|
||||
<span v-else-if="display === 'watch'" class="big">{{ bigValue }}<small v-if="unit && !gone">{{ unit }}</small></span>
|
||||
<span v-else-if="status" class="st" :class="{ off: gone }">{{ status }}</span>
|
||||
</span>
|
||||
@@ -124,7 +130,7 @@ async function onKey(e: KeyboardEvent) {
|
||||
<template v-else-if="controls === 'volume'"><span class="range" :style="volumeStyle"></span><span class="key mdi">{{ key("volume-high") }}</span></template>
|
||||
<template v-else-if="controls === 'playback'"><span class="key mdi">{{ key("skip-previous") }}</span><span class="key mdi">{{ key(on ? "pause" : "play") || key("play") }}</span><span class="key mdi">{{ key("skip-next") }}</span></template>
|
||||
<template v-else-if="controls === 'buttons' && domain === 'cover'"><span class="key mdi">{{ key("arrow-expand-horizontal") }}</span><span class="key mdi">{{ key("stop") }}</span><span class="key mdi">{{ key("arrow-collapse-horizontal") }}</span></template>
|
||||
<span v-else-if="controls === 'run'" class="run">{{ ({ scene: "Activate", script: "Run" } as Record<string, string>)[domain] || "Press" }}</span>
|
||||
<span v-else-if="controls === 'run'" class="run">{{ runText }}</span>
|
||||
<span v-else class="range" :style="{ background: `linear-gradient(to right, #2196f3 ${fill}%, #d3e8fb ${fill}%)` }"></span>
|
||||
</span>
|
||||
</template>
|
||||
@@ -133,7 +139,7 @@ async function onKey(e: KeyboardEvent) {
|
||||
<span class="ic mdi" :class="{ lit: isOn }">{{ glyph(tileIconCp(tile)) }}</span>
|
||||
<span class="tx">
|
||||
<span class="nm">{{ name }}</span>
|
||||
<span v-if="goesTo" class="goto">Page {{ goesTo }} ›</span>
|
||||
<span v-if="goesTo" class="goto">{{ screenText("editor.mockup.page_link", { page: goesTo }) }}</span>
|
||||
<span v-else-if="display === 'watch'" class="big">{{ bigValue }}<small v-if="unit && !gone">{{ unit }}</small></span>
|
||||
<span v-else-if="status" class="st" :class="{ off: gone }">{{ status }}</span>
|
||||
</span>
|
||||
@@ -150,7 +156,7 @@ async function onKey(e: KeyboardEvent) {
|
||||
<template v-else-if="controls === 'buttons' && domain === 'cover'"><span class="key mdi">{{ key("arrow-expand-horizontal") }}</span><span class="key mdi">{{ key("stop") }}</span><span class="key mdi">{{ key("arrow-collapse-horizontal") }}</span></template>
|
||||
<template v-else-if="controls === 'buttons' && domain === 'vacuum'"><span class="key mdi">{{ key("play") }}</span><span class="key mdi">{{ key("stop") }}</span><span class="key mdi">{{ key("home-map-marker") }}</span></template>
|
||||
<template v-else-if="controls === 'buttons' && domain === 'timer'"><span class="key mdi">{{ key("play") }}</span><span class="key mdi">{{ key("close") }}</span></template>
|
||||
<span v-else-if="controls === 'run'" class="run">{{ ({ scene: "Activate", script: "Run" } as Record<string, string>)[domain] || "Press" }}</span>
|
||||
<span v-else-if="controls === 'run'" class="run">{{ runText }}</span>
|
||||
<span v-else class="range" :style="{ background: `linear-gradient(to right, #2196f3 ${fill}%, #d3e8fb ${fill}%)` }"></span>
|
||||
</span>
|
||||
</template>
|
||||
@@ -161,13 +167,13 @@ async function onKey(e: KeyboardEvent) {
|
||||
<span class="ic mdi" :class="{ lit: isOn }">{{ glyph(tileIconCp(tile)) }}</span>
|
||||
<span class="tx">
|
||||
<span class="nm">{{ name }}</span>
|
||||
<span v-if="goesTo" class="goto">Page {{ goesTo }} ›</span>
|
||||
<span v-if="goesTo" class="goto">{{ screenText("editor.mockup.page_link", { page: goesTo }) }}</span>
|
||||
<span v-else-if="display !== 'watch' && status" class="st" :class="{ off: gone }">{{ status }}</span>
|
||||
</span>
|
||||
</span>
|
||||
<span v-if="display === 'watch'" class="big">{{ bigValue }}<small v-if="unit && !gone">{{ unit }}</small></span>
|
||||
<span v-if="tile.options?.inline === 'slider'" class="mini-slider" :style="sliderStyle"></span>
|
||||
</template>
|
||||
<button v-if="live" type="button" class="remove" title="Remove tile" :aria-label="`Remove ${name}`" @click.stop="removeTile(tile)">✕</button>
|
||||
<button v-if="live" type="button" class="remove" :title="t('editor.tile_card.remove')" :aria-label="t('editor.tile_card.remove_named', { name })" @click.stop="removeTile(tile)">✕</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
// One tile's settings. Every change applies live, so the card on the mockup shows the result while you pick.
|
||||
import { computed, toRaw } from "vue";
|
||||
import { t } from "../i18n";
|
||||
import { domainInfo, entriesOf, MAX_PAGES, pageCount, pageOf, pageTarget, SLIDER_DOMAINS, TOGGLE_BEFORE } from "../model/layout";
|
||||
import { glyph } from "../model/topbar";
|
||||
import { automaticIcon, closeInspector, entityName, fullPage, markDirty, moveTileToPage, removeTile, retargetPageTile, setTileOption, state, supports, tileIconCp } from "../store";
|
||||
@@ -22,42 +23,38 @@ const emptyPage = (n: number) => !state.layout?.tiles.some((t) => pageOf(t.slot)
|
||||
const pages = computed(() => {
|
||||
const list = Array.from({ length: Math.min(MAX_PAGES, pageTotal.value + 1) }, (_, i) => i + 1);
|
||||
if (goesTo.value > list.length) list.push(goesTo.value);
|
||||
return list.map((n) => [n, emptyPage(n) ? `${n} (empty)` : String(n)] as [number, string]);
|
||||
return list.map((n) => [n, emptyPage(n) ? t("editor.tile.goes_to.empty", { page: n }) : String(n)] as [number, string]);
|
||||
});
|
||||
const goesToHint = computed(() => !fullPage.value
|
||||
? { text: "Navigation tiles need firmware 0.2.62: press Update on the screen first.", warn: false }
|
||||
? { text: t("editor.tile.goes_to.needs_firmware"), warn: false }
|
||||
: goesTo.value > pageTotal.value
|
||||
? { text: `This screen has no page ${goesTo.value}, so a tap opens its last page. Choose another page.`, warn: true }
|
||||
: { text: "A tap on the tile opens that page.", warn: false });
|
||||
? { text: t("editor.tile.goes_to.no_page", { page: goesTo.value }), warn: true }
|
||||
: { text: t("editor.tile.goes_to.hint"), warn: false });
|
||||
// Moving the tile without a drag (app 0.2.78): another page, or a new one after the last. A tile alone on the last page
|
||||
// gets no "New page", which would only leave an empty page behind; with nowhere to go the row stays hidden.
|
||||
const alone = computed(() => !state.layout?.tiles.some((t) => toRaw(t) !== toRaw(props.tile) && pageOf(t.slot) === pageHere.value - 1));
|
||||
const onPage = computed(() => {
|
||||
const list = Array.from({ length: pageTotal.value }, (_, i) => [i + 1, String(i + 1)] as [number, string]);
|
||||
if (pageTotal.value < MAX_PAGES && !(alone.value && pageHere.value === pageTotal.value)) list.push([pageTotal.value + 1, "New page"]);
|
||||
if (pageTotal.value < MAX_PAGES && !(alone.value && pageHere.value === pageTotal.value)) list.push([pageTotal.value + 1, t("editor.tile.page.new")]);
|
||||
return list;
|
||||
});
|
||||
const sizes = computed<[string, string][]>(() => goesTo.value ? [["single", "Normal"], ["wide", "Double-width"]] : [["single", "Normal"], ["wide", "Double-width"], ["full", "Full page"]]);
|
||||
const sizeHint = computed(() => goesTo.value ? "" : fullPage.value
|
||||
? "Full page: one big button that lights up while on; a slider, controls or a graph sit at the bottom of it."
|
||||
: "A full-page tile needs firmware 0.2.62: press Update on the screen first.");
|
||||
const sizes = computed<[string, string][]>(() => (goesTo.value ? ["single", "wide"] : ["single", "wide", "full"]).map((key) => [key, t(`editor.tile.size.${key}`)]));
|
||||
const sizeHint = computed(() => goesTo.value ? "" : fullPage.value ? t("editor.tile.size.full_hint") : t("editor.tile.size.needs_firmware"));
|
||||
const caps = computed(() => state.capabilities[props.tile.entity]);
|
||||
const current = (key: string, fallback: unknown) => props.tile.options?.[key] ?? fallback;
|
||||
const display = computed(() => current("display", domain.value === "screen" ? "digital" : "standard") as string);
|
||||
const displays = computed(() => {
|
||||
const list: [string, string][] = domain.value === "screen"
|
||||
? [["digital", "Digital clock"], ["analog", "Analog clock"]]
|
||||
: [["standard", "Name and status"], ["watch", "Large value"]];
|
||||
const keys = domain.value === "screen" ? ["digital", "analog"] : ["standard", "watch"];
|
||||
const c = caps.value;
|
||||
if (domain.value === "weather" && (!c || c.displays.includes("forecast") || display.value === "forecast")) list.push(["forecast", "Weather forecast"]);
|
||||
if (domain.value === "sensor" && (!c || c.displays.includes("graph") || display.value === "graph")) list.push(["graph", "Graph"]);
|
||||
if (domain.value === "sun") list.push(["sunpath", "Sun path"]);
|
||||
return list;
|
||||
if (domain.value === "weather" && (!c || c.displays.includes("forecast") || display.value === "forecast")) keys.push("forecast");
|
||||
if (domain.value === "sensor" && (!c || c.displays.includes("graph") || display.value === "graph")) keys.push("graph");
|
||||
if (domain.value === "sun") keys.push("sunpath");
|
||||
return keys.map((key) => [key, t(`editor.tile.display.${key}`)] as [string, string]);
|
||||
});
|
||||
const displayHint = computed(() => {
|
||||
const c = caps.value;
|
||||
if (c && display.value === "graph" && !c.displays.includes("graph")) return "Home Assistant has no numbers for this entity, so the graph stays empty. Choose another display.";
|
||||
if (c && display.value === "forecast" && !c.displays.includes("forecast")) return "This weather service has no daily forecast in Home Assistant. Choose another display.";
|
||||
if (c && display.value === "graph" && !c.displays.includes("graph")) return t("editor.tile.display.no_graph");
|
||||
if (c && display.value === "forecast" && !c.displays.includes("forecast")) return t("editor.tile.display.no_forecast");
|
||||
return "";
|
||||
});
|
||||
const size = computed(() => current("size", "single") as string);
|
||||
@@ -69,23 +66,23 @@ const controlChoices = computed(() => {
|
||||
});
|
||||
const controlHint = computed(() => {
|
||||
const c = caps.value;
|
||||
if (c && controls.value !== "none" && !c.controls.includes(controls.value)) return { text: "Home Assistant doesn't offer this control for this entity, so it stays empty on the screen. Choose another one.", warn: true };
|
||||
if (c && controls.value !== "none" && !c.controls.includes(controls.value)) return { text: t("editor.tile.controls.not_offered"), warn: true };
|
||||
return { text: supports(0, 2, 19)
|
||||
? (size.value === "full" ? "At the bottom of the full-page tile; a tap anywhere else works as set below." : "On the right of the double-width tile, like the rows in Home Assistant. Tapping the name works as set below.")
|
||||
: "The screen shows direct control from firmware 0.2.19; until then the tile stays as it was.", warn: false };
|
||||
? t(size.value === "full" ? "editor.tile.controls.full_hint" : "editor.tile.controls.wide_hint")
|
||||
: t("editor.tile.controls.needs_firmware"), warn: false };
|
||||
});
|
||||
const tap = computed(() => current("tap", "auto") as string);
|
||||
const taps = computed(() => {
|
||||
const list: [string, string][] = [["auto", "Automatic"], ["detail", "Open control"], ["none", "View only"]];
|
||||
const keys = ["auto", "detail", "none"];
|
||||
// On / off where Home Assistant can toggle the entity, such as a cover; a speaker without on and off gets none.
|
||||
if ((caps.value ? caps.value.toggle : TOGGLE_BEFORE.includes(domain.value)) || tap.value === "toggle") list.push(["toggle", "On / off"]);
|
||||
list.push(["action", "Perform action"]);
|
||||
return list;
|
||||
if ((caps.value ? caps.value.toggle : TOGGLE_BEFORE.includes(domain.value)) || tap.value === "toggle") keys.push("toggle");
|
||||
keys.push("action");
|
||||
return keys.map((key) => [key, t(`editor.tile.tap.${key}`)] as [string, string]);
|
||||
});
|
||||
const tapHint = computed(() => {
|
||||
if (tap.value === "toggle" && caps.value && !caps.value.toggle) return { text: "Home Assistant can't turn this on and off, so a tap does nothing. Choose another option.", warn: true };
|
||||
if (tap.value === "toggle" && !TOGGLE_BEFORE.includes(domain.value) && !supports(0, 2, 58)) return { text: "The screen switches this from firmware 0.2.58: press Update on the screen. Until then a tap opens its card.", warn: false };
|
||||
if (tap.value === "toggle") return { text: "Hold the tile to open its card.", warn: false };
|
||||
if (tap.value === "toggle" && caps.value && !caps.value.toggle) return { text: t("editor.tile.tap.no_toggle"), warn: true };
|
||||
if (tap.value === "toggle" && !TOGGLE_BEFORE.includes(domain.value) && !supports(0, 2, 58)) return { text: t("editor.tile.tap.toggle_needs_firmware"), warn: false };
|
||||
if (tap.value === "toggle") return { text: t("editor.tile.tap.hold"), warn: false };
|
||||
return null;
|
||||
});
|
||||
const inline = computed(() => current("inline", "none") as string);
|
||||
@@ -108,60 +105,60 @@ function inspect() {
|
||||
<div class="dr-head">
|
||||
<span class="av mdi" :style="{ color: domainInfo(tile.entity)[2], background: domainInfo(tile.entity)[3] }">{{ glyph(tileIconCp(tile)) }}</span>
|
||||
<span class="tx"><b>{{ tile.name || name }}</b><small class="mono">{{ tile.entity }}</small></span>
|
||||
<button type="button" class="icon-btn" aria-label="Close" @click="closeInspector">✕</button>
|
||||
<button type="button" class="icon-btn" :aria-label="t('editor.common.close')" @click="closeInspector">✕</button>
|
||||
</div>
|
||||
<div class="dr-body">
|
||||
<div class="f">
|
||||
<label class="f-label" for="tile-name">Name on the screen</label>
|
||||
<label class="f-label" for="tile-name">{{ t("editor.tile.name") }}</label>
|
||||
<input id="tile-name" :value="tile.name" :placeholder="name" maxlength="60" @input="rename(($event.target as HTMLInputElement).value)" />
|
||||
</div>
|
||||
<IconPicker v-if="showIcon" :selected="tile.options?.icon || 'auto'" :automatic="automaticIcon(tile.entity)"
|
||||
:auto-label="`Automatic (${fromHA ? 'from Home Assistant' : 'default'})`"
|
||||
:note="supports(0, 2, 18) ? '' : 'The screen shows a chosen icon from firmware 0.2.18.'"
|
||||
:auto-label="t(fromHA ? 'editor.tile.icon.auto_ha' : 'editor.tile.icon.auto_default')"
|
||||
:note="supports(0, 2, 18) ? '' : t('editor.tile.icon.needs_firmware')"
|
||||
@pick="(n) => setTileOption(tile, 'icon', n)" />
|
||||
<div v-if="goesTo" class="f">
|
||||
<span class="f-label">Goes to page</span>
|
||||
<span class="f-label">{{ t("editor.tile.goes_to.label") }}</span>
|
||||
<Segmented :choices="pages" :value="goesTo" @pick="(v) => retargetPageTile(tile, Number(v))" />
|
||||
<small :class="{ warn: goesToHint.warn }">{{ goesToHint.text }}</small>
|
||||
</div>
|
||||
<div v-else class="f">
|
||||
<span class="f-label">Display</span>
|
||||
<span class="f-label">{{ t("editor.tile.display.label") }}</span>
|
||||
<Segmented :choices="displays" :value="display" @pick="(v) => setTileOption(tile, 'display', v)" />
|
||||
<small v-if="displayHint" class="warn">{{ displayHint }}</small>
|
||||
</div>
|
||||
<div class="f">
|
||||
<span class="f-label">Size</span>
|
||||
<span class="f-label">{{ t("editor.tile.size.label") }}</span>
|
||||
<Segmented :choices="sizes" :value="size" @pick="(v) => setTileOption(tile, 'size', v)" />
|
||||
<small v-if="sizeHint">{{ sizeHint }}</small>
|
||||
</div>
|
||||
<div v-if="onPage.length > 1" class="f">
|
||||
<span class="f-label">Page</span>
|
||||
<span class="f-label">{{ t("editor.tile.page.label") }}</span>
|
||||
<Segmented :choices="onPage" :value="pageHere" @pick="(v) => moveTileToPage(tile, Number(v) - 1)" />
|
||||
</div>
|
||||
<div v-if="catalogue && size !== 'single' && !goesTo" class="f">
|
||||
<span class="f-label">Direct control on the tile</span>
|
||||
<span class="f-label">{{ t("editor.tile.controls.label") }}</span>
|
||||
<Segmented :choices="controlChoices" :value="controls" @pick="(v) => setTileOption(tile, 'controls', v)" />
|
||||
<small :class="{ warn: controlHint.warn }">{{ controlHint.text }}</small>
|
||||
</div>
|
||||
<div v-if="domain !== 'screen' && !goesTo" class="f">
|
||||
<span class="f-label">On tap</span>
|
||||
<span class="f-label">{{ t("editor.tile.tap.label") }}</span>
|
||||
<Segmented :choices="taps" :value="tap" @pick="(v) => setTileOption(tile, 'tap', v)" />
|
||||
<small v-if="tapHint" :class="{ warn: tapHint.warn }">{{ tapHint.text }}</small>
|
||||
</div>
|
||||
<ActionPicker v-if="domain !== 'screen' && !goesTo && tap === 'action'" :tile="tile" />
|
||||
<div v-if="showSlider && !goesTo" class="f">
|
||||
<span class="f-label">Small slider on the tile</span>
|
||||
<Segmented :choices="[['none', 'No'], ['slider', 'Yes, control directly']]" :value="inline" @pick="(v) => setTileOption(tile, 'inline', v)" />
|
||||
<small v-if="sliderWarn" class="warn">Home Assistant has nothing a slider can change for this entity. Choose No.</small>
|
||||
<span class="f-label">{{ t("editor.tile.slider.label") }}</span>
|
||||
<Segmented :choices="[['none', t('editor.tile.slider.no')], ['slider', t('editor.tile.slider.yes')]]" :value="inline" @pick="(v) => setTileOption(tile, 'inline', v)" />
|
||||
<small v-if="sliderWarn" class="warn">{{ t("editor.tile.slider.nothing") }}</small>
|
||||
</div>
|
||||
<div v-if="domain === 'sensor' && !goesTo" class="f">
|
||||
<span class="f-label">History</span>
|
||||
<Segmented :choices="[[1, '1 hour'], [6, '6 hours'], [24, '24 hours']]" :value="history" @pick="(v) => setTileOption(tile, 'history_hours', Number(v))" />
|
||||
<span class="f-label">{{ t("editor.tile.history.label") }}</span>
|
||||
<Segmented :choices="[1, 6, 24].map((hours) => [hours, t('editor.tile.history.hours', hours)] as [number, string])" :value="history" @pick="(v) => setTileOption(tile, 'history_hours', Number(v))" />
|
||||
</div>
|
||||
<div class="f">
|
||||
<span class="f-label">Pastel background</span>
|
||||
<span class="f-label">{{ t("editor.tile.background.label") }}</span>
|
||||
<div class="sw">
|
||||
<button v-for="[key, choice] in backgrounds" :key="key" type="button" :aria-label="`Background: ${choice.label}`"
|
||||
<button v-for="[key, choice] in backgrounds" :key="key" type="button" :aria-label="t('editor.tile.background.aria', { name: choice.label })"
|
||||
:aria-pressed="(tile.options?.background || 'auto') === key ? 'true' : 'false'" @click="setTileOption(tile, 'background', key)">
|
||||
<i :class="choice.color ? '' : key === 'none' ? 'none' : 'auto'" :style="choice.color ? { background: choice.color } : undefined"></i>{{ choice.label }}
|
||||
</button>
|
||||
@@ -169,8 +166,8 @@ function inspect() {
|
||||
</div>
|
||||
</div>
|
||||
<div class="dr-foot">
|
||||
<button type="button" class="btn danger" @click="removeTile(tile)">Remove</button>
|
||||
<button type="button" class="btn danger" @click="removeTile(tile)">{{ t("editor.common.remove") }}</button>
|
||||
<span class="spacer"></span>
|
||||
<button v-if="domain !== 'screen'" type="button" class="btn quiet" @click="inspect">Read current data</button>
|
||||
<button v-if="domain !== 'screen'" type="button" class="btn quiet" @click="inspect">{{ t("editor.common.read_current_data") }}</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
<script setup lang="ts">
|
||||
// Adding to the top bar: the screen's own items, Home Assistant's suggestions, or any entity.
|
||||
import { computed, ref } from "vue";
|
||||
import { t } from "../i18n";
|
||||
import { BUILTIN_ICONS, clockText, dateText, glyph, itemKey } from "../model/topbar";
|
||||
import { addTopbarItem, automaticIcon, closeInspector, iconNamed, settingValues, state, topbarItems, topbarMax } from "../store";
|
||||
import { addTopbarItem, automaticIcon, clock24, closeInspector, iconNamed, screenLanguage, state, topbarItems, topbarMax } from "../store";
|
||||
import type { HeaderItem } from "../types";
|
||||
|
||||
const query = ref("");
|
||||
const taken = computed(() => new Set(topbarItems().map(itemKey)));
|
||||
const clock24 = computed(() => settingValues().clock_24h !== false);
|
||||
const samples = computed(() => ({ clock: clockText(clock24.value, new Date(state.now)), analog: "Small dial with the time", date: dateText(new Date(state.now)) } as Record<string, string>));
|
||||
const samples = computed(() => ({
|
||||
clock: clockText(clock24.value, new Date(state.now)),
|
||||
analog: t("editor.topbar.analog_sample"),
|
||||
date: dateText(new Date(state.now), screenLanguage.value),
|
||||
} as Record<string, string>));
|
||||
const suggested = computed(() => state.inventory.header?.suggestions?.[state.selected || ""] || []);
|
||||
const matches = computed(() => {
|
||||
const q = query.value.trim().toLocaleLowerCase();
|
||||
@@ -20,43 +24,43 @@ const entityItem = (id: string): HeaderItem => ({ type: "entity", entity: id, co
|
||||
<template>
|
||||
<div class="dr-head">
|
||||
<span class="av" style="background: var(--seg); color: var(--ink-2); font-size: 18px">+</span>
|
||||
<span class="tx"><b>Add to the top bar</b><small>{{ topbarItems().length }} of {{ topbarMax() }} slots used</small></span>
|
||||
<button type="button" class="icon-btn" aria-label="Close" @click="closeInspector">✕</button>
|
||||
<span class="tx"><b>{{ t("editor.topbar.add.title") }}</b><small>{{ t("editor.topbar.add.slots", { used: topbarItems().length }, topbarMax()) }}</small></span>
|
||||
<button type="button" class="icon-btn" :aria-label="t('editor.common.close')" @click="closeInspector">✕</button>
|
||||
</div>
|
||||
<div class="dr-body">
|
||||
<div class="f">
|
||||
<span class="f-label">From the screen itself</span>
|
||||
<span class="f-label">{{ t("editor.topbar.add.builtin") }}</span>
|
||||
<div class="options">
|
||||
<button v-for="b in state.inventory.header?.builtin || []" :key="b.type" type="button" class="option" :disabled="taken.has(itemKey({ type: b.type }))" @click="addTopbarItem({ type: b.type })">
|
||||
<span class="mdi">{{ glyph(iconNamed(BUILTIN_ICONS[b.type])?.cp || "F0150") }}</span>
|
||||
<span class="tx"><strong>{{ b.label }}</strong><small>{{ taken.has(itemKey({ type: b.type })) ? "Already added" : samples[b.type] }}</small></span>
|
||||
<span class="tx"><strong>{{ b.label }}</strong><small>{{ taken.has(itemKey({ type: b.type })) ? t("editor.topbar.add.added") : samples[b.type] }}</small></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="suggested.length" class="f">
|
||||
<span class="f-label">Suggestions from Home Assistant</span>
|
||||
<span class="f-label">{{ t("editor.topbar.add.suggestions") }}</span>
|
||||
<div class="options">
|
||||
<button v-for="s in suggested" :key="itemKey(s.item)" type="button" class="option" :disabled="taken.has(itemKey(s.item))" @click="addTopbarItem(s.item)">
|
||||
<span class="mdi">{{ glyph(s.icon || automaticIcon(s.item.entity!)) }}</span>
|
||||
<span class="tx"><strong>{{ s.label }}</strong><small>{{ taken.has(itemKey(s.item)) ? "Already added" : [s.name, s.area].filter(Boolean).join(" · ") }}</small></span>
|
||||
<span class="tx"><strong>{{ s.label }}</strong><small>{{ taken.has(itemKey(s.item)) ? t("editor.topbar.add.added") : [s.name, s.area].filter(Boolean).join(" · ") }}</small></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="f">
|
||||
<label class="f-label" for="topbar-search">An entity</label>
|
||||
<input id="topbar-search" v-model="query" type="search" placeholder="Search by name, room, or entity, e.g. temperature or door" aria-label="Search for an entity for the top bar" />
|
||||
<label class="f-label" for="topbar-search">{{ t("editor.topbar.add.entity") }}</label>
|
||||
<input id="topbar-search" v-model="query" type="search" :placeholder="t('editor.topbar.add.search')" :aria-label="t('editor.topbar.add.search_label')" />
|
||||
<div class="options">
|
||||
<button v-for="e in matches.slice(0, 40)" :key="e.id" type="button" class="option" :disabled="taken.has(itemKey(entityItem(e.id)))" @click="addTopbarItem(entityItem(e.id))">
|
||||
<span class="mdi">{{ glyph(automaticIcon(e.id)) }}</span>
|
||||
<span class="tx"><strong>{{ e.name }}</strong><small>{{ [e.area, e.id].filter(Boolean).join(" · ") }}</small></span>
|
||||
</button>
|
||||
<p v-if="!matches.length" class="hint">No entities found.</p>
|
||||
<p v-else-if="matches.length > 40" class="hint">{{ matches.length }} results. Keep typing to narrow it down.</p>
|
||||
<p v-if="!matches.length" class="hint">{{ t("editor.topbar.add.none_found") }}</p>
|
||||
<p v-else-if="matches.length > 40" class="hint">{{ t("editor.common.results", matches.length) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="dr-foot">
|
||||
<span class="spacer"></span>
|
||||
<button type="button" class="btn quiet" @click="closeInspector">Cancel</button>
|
||||
<button type="button" class="btn quiet" @click="closeInspector">{{ t("editor.common.cancel") }}</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -2,10 +2,11 @@
|
||||
// The top bar: the name on the left; on the right up to six items: the time, an analog clock, the date, or an
|
||||
// entity's state or last change. Choices apply live; the bar on every page follows.
|
||||
import { computed, ref } from "vue";
|
||||
import { t } from "../i18n";
|
||||
import { barLayout, BUILTIN_ICONS, clockText, dateText, glyph, itemKey } from "../model/topbar";
|
||||
import {
|
||||
automaticIcon, barMetrics, closeInspector, entityName, iconNamed, markDirty, moveTopbarItem, openBar, openBarAdd,
|
||||
removeTopbarItem, setSetting, settingValues, setTopbarItems, state, supports, topbarItems, topbarLabel, topbarMax, topbarView,
|
||||
automaticIcon, barMetrics, clock24, closeInspector, entityName, iconNamed, markDirty, moveTopbarItem, openBar, openBarAdd,
|
||||
removeTopbarItem, screenLanguage, screenText, setTopbarItems, state, supports, topbarItems, topbarLabel, topbarMax, topbarView,
|
||||
} from "../store";
|
||||
import type { HeaderItem } from "../types";
|
||||
import IconPicker from "./IconPicker.vue";
|
||||
@@ -17,17 +18,17 @@ const items = computed(() => topbarItems());
|
||||
const item = computed<HeaderItem | undefined>(() => items.value[props.index]);
|
||||
const lay = computed(() => {
|
||||
void state.fontsVersion; void state.now; void state.topbarPreviews;
|
||||
return barLayout(items.value, barMetrics.value, state.layout?.title || "Home", topbarView);
|
||||
return barLayout(items.value, barMetrics.value, state.layout?.title || screenText("editor.mockup.home"), topbarView);
|
||||
});
|
||||
const overflow = computed(() => lay.value.dropped);
|
||||
const needed = computed(() => state.inventory.header?.min_firmware || "0.2.32");
|
||||
const supported = computed(() => { const [a, b, c] = needed.value.split(".").map(Number); return supports(a, b, c); });
|
||||
const hint = computed(() => supported.value
|
||||
? overflow.value.size ? "Not everything fits next to the name: the screen drops the dashed items. Remove one or choose a shorter name." : "Drag to reorder; tap an item to configure it."
|
||||
: `Sensors, the date, and the analog clock appear from firmware ${needed.value}; until that update, this screen shows the name and, if the time is in the bar, the clock.`);
|
||||
? t(overflow.value.size ? "editor.topbar.hint.overflow" : "editor.topbar.hint.reorder")
|
||||
: t("editor.topbar.hint.needs_firmware", { version: needed.value }));
|
||||
const detail = (it: HeaderItem, i: number) => {
|
||||
const view = topbarView(it);
|
||||
return !view.shown ? "Hidden: not active right now" : overflow.value.has(i) ? "Doesn't fit next to the name" : view.analog ? "Dial" : view.text;
|
||||
return !view.shown ? t("editor.topbar.detail.hidden") : overflow.value.has(i) ? t("editor.topbar.detail.overflow") : view.analog ? t("editor.topbar.detail.dial") : view.text;
|
||||
};
|
||||
const iconOf = (it: HeaderItem) => {
|
||||
const view = topbarView(it);
|
||||
@@ -47,10 +48,13 @@ function update(patch: Partial<HeaderItem>) {
|
||||
const liveNote = computed(() => {
|
||||
if (!item.value) return "";
|
||||
const view = topbarView(item.value);
|
||||
return !view.shown ? "Hidden right now: not active" : overflow.value.has(props.index) ? "Doesn't currently fit next to the name" : "How it looks on the screen";
|
||||
return !view.shown ? t("editor.topbar.live.hidden") : overflow.value.has(props.index) ? t("editor.topbar.live.overflow") : t("editor.topbar.live.looks");
|
||||
});
|
||||
const clock24 = computed(() => settingValues().clock_24h !== false);
|
||||
const samples = computed(() => ({ clock: clockText(clock24.value, new Date(state.now)), analog: "Small dial with the time", date: dateText(new Date(state.now)) } as Record<string, string>));
|
||||
const samples = computed(() => ({
|
||||
clock: clockText(clock24.value, new Date(state.now)),
|
||||
analog: t("editor.topbar.analog_sample"),
|
||||
date: dateText(new Date(state.now), screenLanguage.value),
|
||||
} as Record<string, string>));
|
||||
|
||||
// Pointer drag between the rows, mouse and touch (touch after a short hold, so the list still scrolls). The order
|
||||
// updates while dragging, the mockup follows, and a finished drag is not a click.
|
||||
@@ -128,29 +132,29 @@ function onKey(e: KeyboardEvent, i: number) {
|
||||
<div class="dr-head">
|
||||
<span class="av mdi" style="background: var(--seg); color: var(--ink-2)">{{ item ? glyph(iconOf(item) || "F0150") : glyph("F0150") }}</span>
|
||||
<span class="tx">
|
||||
<b>{{ item ? (item.type === "entity" ? entityName(item.entity!) : topbarLabel(item)) : "Top bar" }}</b>
|
||||
<small :class="{ mono: item?.type === 'entity' }">{{ item ? (item.type === "entity" ? item.entity : "From the screen itself, also works without Home Assistant") : `${items.length} of ${topbarMax()} items` }}</small>
|
||||
<b>{{ item ? (item.type === "entity" ? entityName(item.entity!) : topbarLabel(item)) : t("editor.topbar.title") }}</b>
|
||||
<small :class="{ mono: item?.type === 'entity' }">{{ item ? (item.type === "entity" ? item.entity : t("editor.topbar.builtin")) : t("editor.topbar.items", { used: items.length }, topbarMax()) }}</small>
|
||||
</span>
|
||||
<button type="button" class="icon-btn" aria-label="Close" @click="closeInspector">✕</button>
|
||||
<button type="button" class="icon-btn" :aria-label="t('editor.common.close')" @click="closeInspector">✕</button>
|
||||
</div>
|
||||
<div class="dr-body">
|
||||
<div class="f">
|
||||
<label class="f-label" for="title">Left: name</label>
|
||||
<input id="title" :value="state.layout?.title" maxlength="60" placeholder="For example: Living room" @input="rename(($event.target as HTMLInputElement).value)" />
|
||||
<label class="f-label" for="title">{{ t("editor.topbar.name") }}</label>
|
||||
<input id="title" :value="state.layout?.title" maxlength="60" :placeholder="t('editor.topbar.name_placeholder')" @input="rename(($event.target as HTMLInputElement).value)" />
|
||||
</div>
|
||||
<div class="f">
|
||||
<span class="f-label" id="topbar-caption">Right: time, sensors, or a clock <span style="text-transform: none; letter-spacing: 0; font-weight: 500"> · {{ items.length }} / {{ topbarMax() }}</span></span>
|
||||
<span class="f-label" id="topbar-caption">{{ t("editor.topbar.right") }} <span style="text-transform: none; letter-spacing: 0; font-weight: 500"> · {{ items.length }} / {{ topbarMax() }}</span></span>
|
||||
<div class="items" id="topbar-chips" role="list" aria-labelledby="topbar-caption">
|
||||
<div v-for="(it, i) in items" :key="itemKey(it) + i" class="item" role="listitem" tabindex="0" :data-index="i"
|
||||
:class="{ selected: i === index, 'is-hidden': !topbarView(it).shown, 'is-overflow': overflow.has(i), 'just-added': justAdded(it), 'dragging-chip': drag.active && drag.index === i }"
|
||||
:aria-label="`${topbarLabel(it)}, slot ${i + 1}. Enter: configure, arrows: move`"
|
||||
:aria-label="t('editor.topbar.item_label', { name: topbarLabel(it), slot: i + 1 })"
|
||||
@pointerdown="down($event, i)" @click="pick(i)" @keydown="onKey($event, i)">
|
||||
<span class="grip" aria-hidden="true">⋮⋮</span>
|
||||
<span class="av mdi" :style="topbarView(it).color ? { color: topbarView(it).color! } : undefined">{{ iconOf(it) ? glyph(iconOf(it)!) : "" }}</span>
|
||||
<span class="tx"><b>{{ topbarLabel(it) }}</b><small>{{ detail(it, i) }}</small></span>
|
||||
<button type="button" class="x" :aria-label="`Remove ${topbarLabel(it)} from the top bar`" @click.stop="removeTopbarItem(i)">✕</button>
|
||||
<button type="button" class="x" :aria-label="t('editor.topbar.remove_named', { name: topbarLabel(it) })" @click.stop="removeTopbarItem(i)">✕</button>
|
||||
</div>
|
||||
<button type="button" class="ghost-btn" id="topbar-add" :disabled="items.length >= topbarMax()" :title="items.length >= topbarMax() ? `Maximum ${topbarMax()} items` : 'Add the time, date, analog clock, or an entity'" @click="openBarAdd">+ Add time, date, or an entity</button>
|
||||
<button type="button" class="ghost-btn" id="topbar-add" :disabled="items.length >= topbarMax()" :title="items.length >= topbarMax() ? t('editor.topbar.max', topbarMax()) : t('editor.topbar.add_title')" @click="openBarAdd">{{ t("editor.topbar.add_button") }}</button>
|
||||
</div>
|
||||
<small id="topbar-hint" :class="{ warn: overflow.size > 0 && supported }">{{ hint }}</small>
|
||||
</div>
|
||||
@@ -161,29 +165,28 @@ function onKey(e: KeyboardEvent, i: number) {
|
||||
</div>
|
||||
<template v-if="item.type === 'entity'">
|
||||
<div class="f">
|
||||
<span class="f-label">What to show</span>
|
||||
<span class="f-label">{{ t("editor.topbar.content.label") }}</span>
|
||||
<Segmented :choices="(state.inventory.header?.contents || []).map((c) => [c.key, c.label] as [string, string])" :value="item.content" @pick="(v) => update({ content: v })" />
|
||||
<small>Last changed keeps counting on the screen itself: “Just now”, “5 min ago”, “Yesterday”.</small>
|
||||
<small>{{ t("editor.topbar.content.hint") }}</small>
|
||||
</div>
|
||||
<IconPicker :selected="item.icon || 'auto'" :automatic="state.topbarPreviews[itemKey(item)]?.auto_icon || automaticIcon(item.entity!)" auto-label="Automatic (like Home Assistant)" allow-none @pick="(n) => update({ icon: n })" />
|
||||
<IconPicker :selected="item.icon || 'auto'" :automatic="state.topbarPreviews[itemKey(item)]?.auto_icon || automaticIcon(item.entity!)" :auto-label="t('editor.topbar.auto_icon')" allow-none @pick="(n) => update({ icon: n })" />
|
||||
<div class="f">
|
||||
<span class="f-label">Show</span>
|
||||
<span class="f-label">{{ t("editor.topbar.show.label") }}</span>
|
||||
<Segmented :choices="(state.inventory.header?.shows || []).map((s) => [s.key, s.label] as [string, string])" :value="item.show" @pick="(v) => update({ show: v })" />
|
||||
<small>Only when active hides the item as long as it's off, closed, away, or 0. Handy for an open door, a running washing machine, or who's home.</small>
|
||||
<small>{{ t("editor.topbar.show.hint") }}</small>
|
||||
</div>
|
||||
</template>
|
||||
<div v-else-if="item.type !== 'date'" class="f">
|
||||
<span class="f-label">Format</span>
|
||||
<Segmented :choices="[['24', '24 hour'], ['12', '12 hour']]" :value="clock24 ? '24' : '12'" @pick="(v) => setSetting('clock_24h', v === '24', 150)" />
|
||||
<small>The screen's own clock setting: it applies at once to every clock on this screen, including the clock tiles.</small>
|
||||
</div>
|
||||
<small v-else>{{ samples.date }} today. The date follows the screen's own clock.</small>
|
||||
<!-- The clock's format is one choice for every screen, under Settings → Language & region (app 0.2.90). -->
|
||||
<i18n-t v-else-if="item.type !== 'date'" :keypath="clock24 ? 'editor.topbar.clock_24' : 'editor.topbar.clock_12'" tag="small" id="topbar-clock" scope="global">
|
||||
<template #settings><a href="#settings">{{ t("editor.topbar.clock_settings") }}</a></template>
|
||||
</i18n-t>
|
||||
<small v-else>{{ t("editor.topbar.date_hint", { date: samples.date }) }}</small>
|
||||
</template>
|
||||
</div>
|
||||
<div v-if="item" class="dr-foot">
|
||||
<button type="button" class="btn danger" @click="removeTopbarItem(index)">Remove</button>
|
||||
<button type="button" class="btn danger" @click="removeTopbarItem(index)">{{ t("editor.common.remove") }}</button>
|
||||
<span class="spacer"></span>
|
||||
<button type="button" class="btn quiet" :disabled="index === 0" @click="moveTopbarItem(index, index - 1) && openBar(index - 1)">↑ Up</button>
|
||||
<button type="button" class="btn quiet" :disabled="index >= items.length - 1" @click="moveTopbarItem(index, index + 1) && openBar(index + 1)">↓ Down</button>
|
||||
<button type="button" class="btn quiet" :disabled="index === 0" @click="moveTopbarItem(index, index - 1) && openBar(index - 1)">{{ t("editor.topbar.up") }}</button>
|
||||
<button type="button" class="btn quiet" :disabled="index >= items.length - 1" @click="moveTopbarItem(index, index + 1) && openBar(index + 1)">{{ t("editor.topbar.down") }}</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
// The bar at the top of a mockup page, drawn as the screen draws it: name left, items right.
|
||||
import { computed } from "vue";
|
||||
import { t } from "../i18n";
|
||||
import { barLayout, dotted, type BarPart } from "../model/topbar";
|
||||
import { barMetrics, state, topbarItems, topbarView } from "../store";
|
||||
import { barMetrics, screenText, state, topbarItems, topbarView } from "../store";
|
||||
|
||||
const props = defineProps<{ items?: any[]; nameText?: string; single?: boolean }>();
|
||||
|
||||
@@ -11,7 +12,7 @@ const lay = computed(() => {
|
||||
void state.now;
|
||||
void state.topbarPreviews;
|
||||
const items = props.items || topbarItems();
|
||||
return barLayout(items, barMetrics.value, props.nameText ?? (state.layout?.title || "Home"), topbarView);
|
||||
return barLayout(items, barMetrics.value, props.nameText ?? (state.layout?.title || screenText("editor.mockup.home")), topbarView);
|
||||
});
|
||||
const m = computed(() => lay.value.metrics);
|
||||
const height = computed(() => (props.single ? Math.round(m.value.text * 1.4) : m.value.top + Math.round(m.value.name * 0.45)));
|
||||
@@ -43,7 +44,7 @@ defineExpose({ lay });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<svg :viewBox="viewBox" role="img" :aria-label="single ? 'How it looks on the screen' : `Top bar: ${lay.nameText}`" :style="single ? { width: singleWidth } : undefined">
|
||||
<svg :viewBox="viewBox" role="img" :aria-label="single ? t('editor.topbar.live.looks') : t('editor.topbar.aria', { name: lay.nameText })" :style="single ? { width: singleWidth } : undefined">
|
||||
<text v-if="!single" x="0" :y="m.top" fill="#1b1b1b" :style="{ font: lay.fonts.name }">{{ name }}</text>
|
||||
<g>
|
||||
<template v-for="p in parts" :key="p.index">
|
||||
|
||||
Reference in New Issue
Block a user