1dc2eab0cf
- 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>
193 lines
11 KiB
Vue
193 lines
11 KiB
Vue
<script setup lang="ts">
|
|
// 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, 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";
|
|
import Segmented from "./Segmented.vue";
|
|
import TopbarSvg from "./TopbarSvg.vue";
|
|
|
|
const props = defineProps<{ index: number }>();
|
|
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 || 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
|
|
? 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 ? 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);
|
|
return view.analog || it.type !== "entity" ? iconNamed(BUILTIN_ICONS[it.type])?.cp : view.icon;
|
|
};
|
|
const justAdded = (it: HeaderItem) => state.topbarAdded?.key === itemKey(it) && Date.now() - state.topbarAdded.time < 1200;
|
|
function rename(value: string) {
|
|
if (!state.layout) return;
|
|
state.layout.title = value;
|
|
markDirty();
|
|
}
|
|
function update(patch: Partial<HeaderItem>) {
|
|
const list = [...items.value];
|
|
list[props.index] = { ...list[props.index], ...patch };
|
|
setTopbarItems(list);
|
|
}
|
|
const liveNote = computed(() => {
|
|
if (!item.value) return "";
|
|
const view = topbarView(item.value);
|
|
return !view.shown ? t("editor.topbar.live.hidden") : overflow.value.has(props.index) ? t("editor.topbar.live.overflow") : t("editor.topbar.live.looks");
|
|
});
|
|
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.
|
|
const drag = ref({ index: -1, active: false, moved: false });
|
|
let start: { x: number; y: number } | null = null, timer = 0, pointerId: number | null = null, suppressUntil = 0;
|
|
function down(e: PointerEvent, i: number) {
|
|
if (e.button !== 0 || (e.target as HTMLElement).closest(".x")) return;
|
|
if (e.pointerType !== "touch") e.preventDefault();
|
|
drag.value = { index: i, active: false, moved: false };
|
|
start = { x: e.clientX, y: e.clientY };
|
|
pointerId = e.pointerId;
|
|
clearTimeout(timer);
|
|
if (e.pointerType === "touch") timer = window.setTimeout(begin, 260);
|
|
document.addEventListener("pointermove", move);
|
|
document.addEventListener("pointerup", end);
|
|
document.addEventListener("pointercancel", end);
|
|
}
|
|
function begin() {
|
|
drag.value.active = true;
|
|
document.addEventListener("touchmove", block, { passive: false });
|
|
}
|
|
function block(e: TouchEvent) { if (drag.value.active) e.preventDefault(); }
|
|
function move(e: PointerEvent) {
|
|
if (e.pointerId !== pointerId || !start) return;
|
|
if (!drag.value.active) {
|
|
const distance = Math.hypot(e.clientX - start.x, e.clientY - start.y);
|
|
if (e.pointerType === "touch") { if (distance > 10) { clearTimeout(timer); start = null; } return; }
|
|
if (distance < 6) return;
|
|
begin();
|
|
}
|
|
// The target is the row whose middle the pointer passed.
|
|
const rows = [...document.querySelectorAll<HTMLElement>(".items .item[data-index]")];
|
|
let target = drag.value.index;
|
|
rows.forEach((row, i) => {
|
|
const r = row.getBoundingClientRect();
|
|
if (i < drag.value.index && e.clientY < r.top + r.height / 2) target = Math.min(target, i);
|
|
if (i > drag.value.index && e.clientY > r.top + r.height / 2) target = Math.max(target, i);
|
|
});
|
|
if (target !== drag.value.index && state.layout) {
|
|
const list = [...items.value];
|
|
list.splice(target, 0, ...list.splice(drag.value.index, 1));
|
|
state.layout.header = { items: list };
|
|
const selectedMoved = props.index === drag.value.index;
|
|
drag.value.index = target;
|
|
drag.value.moved = true;
|
|
if (selectedMoved) openBar(target);
|
|
}
|
|
}
|
|
function end(e: PointerEvent) {
|
|
if (e.pointerId !== pointerId) return;
|
|
clearTimeout(timer);
|
|
document.removeEventListener("pointermove", move);
|
|
document.removeEventListener("pointerup", end);
|
|
document.removeEventListener("pointercancel", end);
|
|
document.removeEventListener("touchmove", block);
|
|
if (drag.value.active) {
|
|
suppressUntil = Date.now() + 400;
|
|
if (drag.value.moved) markDirty();
|
|
}
|
|
drag.value = { index: -1, active: false, moved: false };
|
|
start = null; pointerId = null;
|
|
}
|
|
function pick(i: number) {
|
|
if (Date.now() < suppressUntil) return;
|
|
openBar(i);
|
|
}
|
|
function onKey(e: KeyboardEvent, i: number) {
|
|
if (e.key === "Enter" || e.key === " ") { e.preventDefault(); openBar(i); return; }
|
|
const step = ({ ArrowUp: -1, ArrowDown: 1 } as Record<string, number>)[e.key];
|
|
if (step && moveTopbarItem(i, i + step)) { if (props.index === i) openBar(i + step); e.preventDefault(); }
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<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)) : 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="t('editor.common.close')" @click="closeInspector">✕</button>
|
|
</div>
|
|
<div class="dr-body">
|
|
<div class="f">
|
|
<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">{{ 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="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="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() ? 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>
|
|
<template v-if="item">
|
|
<div class="live" id="topbar-live">
|
|
<small>{{ liveNote }}</small>
|
|
<TopbarSvg :items="[item]" single />
|
|
</div>
|
|
<template v-if="item.type === 'entity'">
|
|
<div class="f">
|
|
<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>{{ t("editor.topbar.content.hint") }}</small>
|
|
</div>
|
|
<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">{{ 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>{{ t("editor.topbar.show.hint") }}</small>
|
|
</div>
|
|
</template>
|
|
<!-- 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)">{{ 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)">{{ 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>
|