Rebuild the editor as a Vue app: sidebar, pages side by side, tile settings in a drawer (0.2.74)
The page is now web/ (Vue 3 + Vite + TypeScript), built into screen_manager/app/static with hashed file names; server.py serves index.html as built and /assets/. The Python API, the data and the firmware are unchanged. Your screens sit in a sidebar, the pages of a screen stand next to each other at the screen's own shape, the library sits on the right, and a tile's settings slide in as a drawer while the mockup stays in view. New screen, Firmware & USB, Alerts, Override YAML and Settings are pages of their own. Fonts moved to web/src/assets; the tests read the source through tests/editor_sources.py. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
node_modules
|
||||
dist
|
||||
Vendored
+6
@@ -0,0 +1,6 @@
|
||||
/// <reference types="vite/client" />
|
||||
declare module "*.vue" {
|
||||
import type { DefineComponent } from "vue";
|
||||
const component: DefineComponent<{}, {}, any>;
|
||||
export default component;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<meta name="color-scheme" content="light dark" />
|
||||
<title>ESP Screens</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+1456
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "esp-screens-editor",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "rm -rf ../screen_manager/app/static/assets && vite build",
|
||||
"check": "vue-tsc --noEmit",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"vue": "^3.5.18"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^6.0.1",
|
||||
"typescript": "^5.9.2",
|
||||
"vite": "^7.1.5",
|
||||
"vue-tsc": "^3.0.6"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
import Sidebar from "./components/Sidebar.vue";
|
||||
import Toast from "./components/Toast.vue";
|
||||
import ScreenView from "./components/ScreenView.vue";
|
||||
import EmptyState from "./components/EmptyState.vue";
|
||||
import AppSettingsView from "./components/AppSettingsView.vue";
|
||||
import InstallerView from "./components/InstallerView.vue";
|
||||
import FirmwareView from "./components/FirmwareView.vue";
|
||||
import AlertsView from "./components/AlertsView.vue";
|
||||
import OverrideView from "./components/OverrideView.vue";
|
||||
import { currentScreen, route, state } from "./store";
|
||||
|
||||
const view = computed(() => {
|
||||
if (route.value === "#settings") return AppSettingsView;
|
||||
if (route.value === "#new-screen") return InstallerView;
|
||||
if (route.value === "#firmware") return FirmwareView;
|
||||
if (route.value === "#alerts") return AlertsView;
|
||||
if (route.value === "#override") return OverrideView;
|
||||
return currentScreen.value && state.layout ? ScreenView : EmptyState;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="app" :class="{ dragging: state.drag.active }">
|
||||
<Sidebar />
|
||||
<main class="main">
|
||||
<component :is="view" />
|
||||
</main>
|
||||
<Toast />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,33 @@
|
||||
// Relative URLs on purpose: the page lives under Home Assistant's ingress path
|
||||
// (/api/hassio_ingress/<token>/) and on http://127.0.0.1:8099 in SCREEN_DEV; `api/...`
|
||||
// resolves against the page in both. Writes carry the CSRF token the inventory handed out.
|
||||
let csrf = "";
|
||||
export function setCsrf(token: string) {
|
||||
csrf = token || "";
|
||||
}
|
||||
|
||||
export async function api(path: string, options: RequestInit = {}): Promise<Response> {
|
||||
const response = await fetch(`api/${path}`, {
|
||||
...options,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-Screen-CSRF": csrf,
|
||||
...(options.headers as Record<string, string> | undefined),
|
||||
},
|
||||
});
|
||||
if (!response.ok) {
|
||||
let message = "That didn't work. Refresh the page and try again.";
|
||||
try {
|
||||
message = (await response.json()).error || message;
|
||||
} catch {}
|
||||
throw new Error(message);
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
export const getJson = async <T = any>(path: string): Promise<T> => (await api(path)).json();
|
||||
export const send = async <T = any>(path: string, method: string, body?: unknown, extra: RequestInit = {}): Promise<T> => {
|
||||
const response = await api(path, { method, body: body === undefined ? undefined : JSON.stringify(body), ...extra });
|
||||
const text = await response.text();
|
||||
return (text ? JSON.parse(text) : null) as T;
|
||||
};
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,117 @@
|
||||
<script setup lang="ts">
|
||||
// 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 { loadEntityActions, markDirty, state, supports } from "../store";
|
||||
import type { EntityAction, Tile } from "../types";
|
||||
import Segmented from "./Segmented.vue";
|
||||
|
||||
const props = defineProps<{ tile: Tile }>();
|
||||
const list = computed(() => state.entityActions[props.tile.entity]);
|
||||
const chosen = computed(() => props.tile.options?.action);
|
||||
const entry = computed(() => list.value?.find((a) => a.action === chosen.value?.action));
|
||||
const open = computed(() => state.actionPickerOpen || !chosen.value);
|
||||
const rows = computed(() => {
|
||||
const q = state.actionSearch.trim().toLocaleLowerCase();
|
||||
return (list.value || []).filter((a) => `${a.name} ${a.action} ${a.description}`.toLocaleLowerCase().includes(q));
|
||||
});
|
||||
if (list.value === undefined) loadEntityActions(props.tile.entity);
|
||||
|
||||
function pick(action: EntityAction) {
|
||||
const same = action.action === chosen.value?.action;
|
||||
props.tile.options = { ...props.tile.options, action: same ? chosen.value : { action: action.action } };
|
||||
state.actionPickerOpen = false;
|
||||
markDirty();
|
||||
}
|
||||
function setField(key: string, value: unknown) {
|
||||
const data: Record<string, unknown> = { ...(props.tile.options?.action?.data || {}) };
|
||||
if (value === undefined) delete data[key];
|
||||
else data[key] = value;
|
||||
props.tile.options = { ...props.tile.options, action: { action: chosen.value!.action, ...(Object.keys(data).length ? { data } : {}) } };
|
||||
markDirty();
|
||||
}
|
||||
const missing = computed(() => {
|
||||
if (!entry.value) return [];
|
||||
const data = props.tile.options?.action?.data || {};
|
||||
return entry.value.fields.filter((f) => f.required && data[f.key] === undefined).map((f) => f.name);
|
||||
});
|
||||
type Field = EntityAction["fields"][number];
|
||||
const kindOf = (field: Field) => Object.keys(field.selector || {})[0] || "text";
|
||||
const configOf = (field: Field) => (field.selector || {})[kindOf(field)] || {};
|
||||
const exampleOf = (field: Field) => (field.example === undefined ? "" : typeof field.example === "string" ? field.example : JSON.stringify(field.example));
|
||||
// A select's own options, or the values this entity has where Home Assistant asks for one of them (a source, an effect).
|
||||
function choicesOf(field: Field): [string, string][] | null {
|
||||
const config = configOf(field), kind = kindOf(field);
|
||||
if (Array.isArray(field.options)) return field.options.map((o) => [o, o]);
|
||||
if (kind === "select" && Array.isArray(config.options) && !config.multiple)
|
||||
return config.options.map((o: any) => (o && typeof o === "object" ? [String(o.value), String(o.label ?? o.value)] : [String(o), String(o)]));
|
||||
return null;
|
||||
}
|
||||
const hex = (rgb: number[]) => "#" + rgb.map((c) => Math.max(0, Math.min(255, Number(c) || 0)).toString(16).padStart(2, "0")).join("");
|
||||
const colorValue = (value: unknown) => (Array.isArray(value) && value.length === 3 ? hex(value as number[]) : "#ffffff");
|
||||
const textValue = (value: unknown) => (value === undefined ? "" : typeof value === "string" ? value : JSON.stringify(value));
|
||||
function onText(field: Field, raw: string) {
|
||||
const text = raw.trim();
|
||||
if (!text) return setField(field.key, undefined);
|
||||
if (kindOf(field) === "text") return setField(field.key, raw);
|
||||
try { setField(field.key, JSON.parse(text)); } catch { setField(field.key, raw); }
|
||||
}
|
||||
function onNumber(field: Field, raw: string) {
|
||||
setField(field.key, raw === "" || !Number.isFinite(Number(raw)) ? undefined : Number(raw));
|
||||
}
|
||||
const unitOf = (field: Field) => configOf(field).unit_of_measurement || (kindOf(field) === "color_temp" ? configOf(field).unit || "" : "");
|
||||
</script>
|
||||
|
||||
<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>
|
||||
<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>
|
||||
<small v-if="chosen" class="mono">{{ chosen.action }}</small>
|
||||
</span>
|
||||
<span class="link">{{ open ? "Close" : "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" />
|
||||
<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>
|
||||
</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>
|
||||
<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])"
|
||||
@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])"
|
||||
@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 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>
|
||||
</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"
|
||||
:value="chosen?.data?.[field.key] ?? ''" :placeholder="exampleOf(field)" @input="onNumber(field, ($event.target as HTMLInputElement).value)" />
|
||||
<span v-if="unitOf(field)">{{ unitOf(field) }}</span>
|
||||
</div>
|
||||
<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>
|
||||
</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>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,231 @@
|
||||
<script setup lang="ts">
|
||||
// Alerts: the cheatsheet for esphome.<node>_show_alert, built from the inventory.
|
||||
import { computed, ref } from "vue";
|
||||
import { versionAtLeast } from "../model/layout";
|
||||
import { glyph } from "../model/topbar";
|
||||
import { copyText, go, state } from "../store";
|
||||
|
||||
const alerts = computed(() => state.inventory.alerts);
|
||||
const icons = computed(() => state.inventory.icons);
|
||||
const exampleAction = ref("");
|
||||
const iconQuery = ref("");
|
||||
const yamlString = (text: unknown) => `"${String(text).replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
|
||||
const fieldValue = (f: any) => f.type === "string" ? (/^[a-z][a-z0-9-]*$/.test(f.example) ? f.example : yamlString(f.example)) : f.example === true ? "true" : f.example === false ? "false" : String(f.example);
|
||||
const screensWithAction = computed(() => state.inventory.screens.filter((s) => s.alert_action));
|
||||
const chosenAction = computed(() => exampleAction.value || screensWithAction.value[0]?.alert_action || "");
|
||||
const exampleYaml = computed(() => {
|
||||
const lines = (alerts.value?.fields || []).map((f: any) => ` ${f.name}: ${fieldValue(f)}`);
|
||||
return `action: ${chosenAction.value || "esphome.<device_name>_show_alert"}\ndata:\n${lines.join("\n")}`;
|
||||
});
|
||||
const waitYaml = computed(() => [
|
||||
`# Doorbell: show the alert and wait until someone presses the button.`,
|
||||
`actions:`,
|
||||
` - action: ${chosenAction.value || "esphome.<device_name>_show_alert"}`,
|
||||
` data:`,
|
||||
` title: "Someone is at the door"`,
|
||||
` subtitle: "Door 3, back"`,
|
||||
` icon: doorbell`,
|
||||
` color: orange`,
|
||||
` button_text: "Coming"`,
|
||||
` timeout: 0`,
|
||||
` flash: true`,
|
||||
` - wait_for_trigger:`,
|
||||
` - trigger: event`,
|
||||
` event_type: ${alerts.value?.event || "esphome.screen_alert"}`,
|
||||
` event_data:`,
|
||||
` action: ok`,
|
||||
` timeout: "00:05:00"`,
|
||||
` - if:`,
|
||||
` - condition: template`,
|
||||
` value_template: "{{ wait.trigger is not none }}"`,
|
||||
` then:`,
|
||||
` - action: notify.notify`,
|
||||
` data:`,
|
||||
` message: "Someone is coming to the door."`,
|
||||
].join("\n"));
|
||||
// One event for every screen (app 0.2.45): an action for "Edit in YAML" of the Event action.
|
||||
const allYaml = computed(() => {
|
||||
const lines = (alerts.value?.fields || []).map((f: any) => ` ${f.name}: ${fieldValue(f)}`);
|
||||
const camera = alerts.value?.camera;
|
||||
if (camera) lines.push(` # ${camera.name}: ${camera.example} # a Guition shows its picture on the card`);
|
||||
return `event: ${alerts.value?.broadcast?.show || "esp_screens_show_alert"}\nevent_data:\n${lines.join("\n")}`;
|
||||
});
|
||||
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 }];
|
||||
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" };
|
||||
function jump(id: string) { document.getElementById(id)?.scrollIntoView({ behavior: "smooth", block: "start" }); }
|
||||
const sections = [["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"]];
|
||||
</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>
|
||||
</div>
|
||||
<button type="button" class="btn quiet" id="close-alerts" @click="go('')">← Back</button>
|
||||
</div>
|
||||
<p v-if="!alerts" class="hint">The cheatsheet is still loading; one moment.</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>
|
||||
</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>
|
||||
</div>
|
||||
<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>
|
||||
<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>
|
||||
<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(screen.firmware, alerts.min_firmware) && screen.alert_action ? 'good' : 'update'">
|
||||
{{ versionAtLeast(screen.firmware, alerts.min_firmware) && screen.alert_action ? `● firmware ${screen.firmware}` : screen.alert_action ? `Update needed · firmware ${screen.firmware || "unknown"}` : "Device name unknown · update the screen" }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-for="[label, action] in [['Show alert', screen.alert_action], ['Dismiss alert', 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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section id="alerts-howto" class="card">
|
||||
<h2>In an automation</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>
|
||||
</ol>
|
||||
<div class="field">
|
||||
<label class="f-label" for="alerts-example-screen">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>
|
||||
</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>
|
||||
</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>
|
||||
<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>
|
||||
</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>
|
||||
<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 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>{{ 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>
|
||||
</tr>
|
||||
<tr v-if="alerts.camera">
|
||||
<td><code>{{ alerts.camera.name }}</code><small>{{ alerts.camera.label }}</small></td>
|
||||
<td>entity</td>
|
||||
<td>{{ alerts.camera.help }}</td>
|
||||
<td><code>{{ alerts.camera.example }}</code></td>
|
||||
<td>Guition, firmware 0.2.57+</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>
|
||||
<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>
|
||||
</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" />
|
||||
</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')">
|
||||
<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>
|
||||
</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>
|
||||
<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>
|
||||
</div>
|
||||
</section>
|
||||
<section id="alerts-behaviour" class="card">
|
||||
<h2>Behavior</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>
|
||||
</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>
|
||||
<div class="table-scroll">
|
||||
<table id="alerts-ending-table">
|
||||
<tr><th>action</th><th>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>
|
||||
<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>
|
||||
</div>
|
||||
</section>
|
||||
<section id="alerts-tips" class="card">
|
||||
<h2>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>
|
||||
</ul>
|
||||
</section>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,76 @@
|
||||
<script setup lang="ts">
|
||||
// Everything around the screens: firmware updates, alerts, Claude.
|
||||
import { computed, ref } from "vue";
|
||||
import { go, installClaudeSkill, runUpdateAll, setAutoUpdate, state } 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}…`
|
||||
: !state.inventory.screens.length
|
||||
? `No screen paired yet. New screens get firmware ${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}.`);
|
||||
const skill = computed(() => state.inventory.claude_skill);
|
||||
const installing = ref(false);
|
||||
async function install() {
|
||||
installing.value = true;
|
||||
try { await installClaudeSkill(); } finally { installing.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>
|
||||
</div>
|
||||
<button type="button" class="btn quiet" id="close-settings" @click="go('')">← My screens</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>
|
||||
<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>
|
||||
</div>
|
||||
</section>
|
||||
<section v-if="u" class="card updates" id="updates">
|
||||
<h2>Firmware updates</h2>
|
||||
<p id="updates-hint">{{ updatesHint }}</p>
|
||||
<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>
|
||||
<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>
|
||||
</label>
|
||||
</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.</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>
|
||||
</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>
|
||||
<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" }}
|
||||
</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" }}
|
||||
</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>
|
||||
<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>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,48 @@
|
||||
<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 { isWide, pageOf, SLOTS_PER_PAGE } from "../model/layout";
|
||||
import { isGuition, openBar, removePage, state } from "../store";
|
||||
import type { Tile } from "../types";
|
||||
import TileCard from "./TileCard.vue";
|
||||
import TopbarSvg from "./TopbarSvg.vue";
|
||||
|
||||
const props = defineProps<{ page: number; entries: { tile: Tile; slot: number }[]; pages: number; moving: Tile | null }>();
|
||||
const bySlot = computed(() => new Map(props.entries.map((e) => [e.slot, e])));
|
||||
const covered = computed(() => new Set(props.entries.filter((e) => isWide(e.tile)).map((e) => e.slot + 1)));
|
||||
const cells = computed(() => Array.from({ length: SLOTS_PER_PAGE }, (_, cell) => props.page * SLOTS_PER_PAGE + cell).filter((slot) => !covered.value.has(slot)));
|
||||
const empty = computed(() => props.pages > 1 && !props.entries.some((e) => pageOf(e.slot) === props.page));
|
||||
const barSelected = computed(() => state.inspector?.kind === "bar" || state.inspector?.kind === "bar-add");
|
||||
const filled = computed(() => props.entries.filter((e) => pageOf(e.slot) === props.page).reduce((n, e) => n + (isWide(e.tile) ? 2 : 1), 0));
|
||||
function pickCell(slot: number) {
|
||||
const marked = state.insertAt === slot;
|
||||
state.insertAt = marked ? -1 : slot;
|
||||
if (state.insertAt >= 0) document.querySelector<HTMLInputElement>("#search")?.focus();
|
||||
}
|
||||
</script>
|
||||
|
||||
<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 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"
|
||||
@click="openBar(0)" @keydown.enter.prevent="openBar(0)">
|
||||
<TopbarSvg />
|
||||
</div>
|
||||
<div class="tiles">
|
||||
<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>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,22 @@
|
||||
<script setup lang="ts">
|
||||
// The inspector slides in from the right and leaves the mockup visible; its content follows what is selected.
|
||||
import { computed } from "vue";
|
||||
import { currentTile, state } from "../store";
|
||||
import TileInspector from "./TileInspector.vue";
|
||||
import TopbarInspector from "./TopbarInspector.vue";
|
||||
import TopbarAdd from "./TopbarAdd.vue";
|
||||
import InspectPanel from "./InspectPanel.vue";
|
||||
|
||||
const open = computed(() => Boolean(state.inspector && (state.inspector.kind !== "tile" || currentTile.value)));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<aside class="drawer" id="tile-sheet" :class="{ open }" :aria-hidden="open ? 'false' : 'true'" @click.stop>
|
||||
<template v-if="state.inspector">
|
||||
<TileInspector v-if="state.inspector.kind === 'tile' && currentTile" :tile="currentTile" />
|
||||
<TopbarInspector v-else-if="state.inspector.kind === 'bar'" :index="state.inspector.index" />
|
||||
<TopbarAdd v-else-if="state.inspector.kind === 'bar-add'" />
|
||||
<InspectPanel v-else-if="state.inspector.kind === 'inspect'" :entity="state.inspector.entity" />
|
||||
</template>
|
||||
</aside>
|
||||
</template>
|
||||
@@ -0,0 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
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>
|
||||
</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>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,100 @@
|
||||
<script setup lang="ts">
|
||||
// Shared firmware workspace; always a concrete profile and upload target.
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from "vue";
|
||||
import { getJson, send } from "../api";
|
||||
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");
|
||||
const host = ref("");
|
||||
let timer = 0;
|
||||
async function refreshFirmware(initial = false) {
|
||||
try {
|
||||
const next = await getJson("firmware");
|
||||
data.value = next;
|
||||
if (initial || !file.value) file.value = next.profiles[0]?.file || "";
|
||||
// USB stays chosen while the board is replugged; a port that went away falls back to the first one.
|
||||
const ports: string[] = next.ports || [];
|
||||
if (target.value.startsWith("/") && !ports.includes(target.value)) target.value = ports[0] || "usb";
|
||||
if (target.value === "usb" && ports.length) target.value = ports[0];
|
||||
} catch (e: any) {
|
||||
toast(e.message);
|
||||
}
|
||||
}
|
||||
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."
|
||||
: 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.");
|
||||
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") {
|
||||
try {
|
||||
await send("firmware/jobs", "POST", {
|
||||
file: file.value,
|
||||
action: action === "validate" ? "validate" : action === "build" ? "build" : target.value === "download" ? "download" : "install",
|
||||
target: target.value === "ota" ? host.value.trim() : target.value === "download" ? "" : target.value,
|
||||
});
|
||||
await refreshFirmware();
|
||||
} catch (e: any) {
|
||||
toast(e.message);
|
||||
}
|
||||
}
|
||||
onMounted(() => { refreshFirmware(true); timer = window.setInterval(() => refreshFirmware(), 3000); });
|
||||
onBeforeUnmount(() => clearInterval(timer));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<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>
|
||||
</div>
|
||||
<button type="button" class="btn quiet" id="close-firmware" @click="go('')">← Back</button>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-grid">
|
||||
<div class="field">
|
||||
<label class="f-label" for="firmware-file">ESPHome 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>
|
||||
<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 v-for="p in ports" :key="p" :value="p">{{ p }}</option>
|
||||
<option value="download">{{ 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>
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
<pre id="firmware-log" class="log">{{ (data?.logs || []).join("\n") }}</pre>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,52 @@
|
||||
<script setup lang="ts">
|
||||
// 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 { glyph } from "../model/topbar";
|
||||
import { iconNamed, state } from "../store";
|
||||
|
||||
const props = defineProps<{ selected: string; automatic: string; autoLabel: string; allowNone?: boolean; note?: string }>();
|
||||
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 groups = computed(() => {
|
||||
const q = query.value.trim().toLocaleLowerCase();
|
||||
return (state.inventory.icons?.groups || []).map((group) => ({
|
||||
label: group.label,
|
||||
icons: group.icons.filter((i) => !q || `${i.label} ${i.name.replaceAll("-", " ")} ${group.label}`.toLocaleLowerCase().includes(q)),
|
||||
})).filter((g) => g.icons.length);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="f">
|
||||
<span class="f-label">Icon</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>
|
||||
</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" />
|
||||
<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>
|
||||
<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}`"
|
||||
: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>
|
||||
</div>
|
||||
</div>
|
||||
<small v-if="note">{{ note }}</small>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,58 @@
|
||||
<script setup lang="ts">
|
||||
// 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 } 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 error = ref("");
|
||||
async function load() {
|
||||
if (!state.selected) return;
|
||||
summary.value = null;
|
||||
error.value = "";
|
||||
raw.value = "Fetching current HA status...";
|
||||
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;
|
||||
summary.value = tiles;
|
||||
raw.value = JSON.stringify(props.entity ? tiles[0] : data, null, 2);
|
||||
} catch (e: any) {
|
||||
error.value = e.message;
|
||||
raw.value = "";
|
||||
toast(e.message);
|
||||
}
|
||||
}
|
||||
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} · Width: ${options.size === "wide" ? "double" : "normal"} · Control: ${controlsLabel({ entity, name: "", slot: 0, options }, state.inventory)} · Background: ${state.inventory.backgrounds?.[options.background || "auto"]?.label || "Default"}`;
|
||||
};
|
||||
onMounted(load);
|
||||
watch(() => props.entity, load);
|
||||
</script>
|
||||
|
||||
<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>
|
||||
</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>
|
||||
<article v-for="tile in summary || []" :key="tile.entity" class="inspection-tile">
|
||||
<strong>{{ tile.entity }}</strong>
|
||||
<span>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>
|
||||
<span class="spacer"></span>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,269 @@
|
||||
<script setup lang="ts">
|
||||
// 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 { 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: "",
|
||||
apiKey: null as string | null, nodeEdited: false, jobState: null as string | null, picked: false, action: null as string | null,
|
||||
});
|
||||
const nodeVisible = ref(false);
|
||||
const data = ref<any>(null);
|
||||
const job = ref<any>(null);
|
||||
const logs = ref<string[]>([]);
|
||||
const note = ref("");
|
||||
const status = ref("");
|
||||
const submitting = ref(false);
|
||||
const logOpen = ref(false);
|
||||
const keyBox = ref<HTMLElement | null>(null);
|
||||
let poll = 0;
|
||||
|
||||
// ESPHome's node-name rule: lowercase ASCII, digits and dashes, starting with a letter.
|
||||
function slug(text: string) {
|
||||
const clean = text.toLowerCase().normalize("NFD").replace(/[̀-ͯ]/g, "")
|
||||
.replace(/[^a-z0-9]+/g, "-").replace(/^[^a-z]+/, "").slice(0, 30).replace(/-+$/, "");
|
||||
return clean || "screen";
|
||||
}
|
||||
function portLabel(port: string) {
|
||||
const id = port.replace(/^\/dev\/serial\/by-id\/usb-/, "").replace(/-if\d+(-port\d+)?$/, "").replace(/_/g, " ");
|
||||
return `USB · ${id === port ? port.replace(/^\/dev\//, "") : id}`;
|
||||
}
|
||||
watch(() => form.friendly_name, () => { if (!installer.nodeEdited) form.name = slug(form.friendly_name); });
|
||||
const nodePreview = computed(() => form.name || "…");
|
||||
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 wifiNote = computed(() => wifi.value?.state === "ready"
|
||||
? "Wi-Fi comes from your ESPHome secrets.yaml."
|
||||
: wifi.value?.state === "invalid"
|
||||
? "secrets.yaml in the ESPHome folder isn't valid YAML. Fix the file first; it won't be overwritten."
|
||||
: "");
|
||||
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."
|
||||
: 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."
|
||||
: !form.target
|
||||
? "The profile goes into the ESPHome folder. You can install later via Firmware & USB, or from ESPHome Device Builder."
|
||||
: 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"));
|
||||
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)));
|
||||
// USB on the Home Assistant machine is always listed first, also before a board is plugged in, so nobody
|
||||
// concludes it isn't possible; "usb" stands for that port until one shows up.
|
||||
function syncTarget() {
|
||||
const current = form.target;
|
||||
const kept = current === "download" || current === "" ? installer.picked : ports.value.includes(current);
|
||||
if (!kept) form.target = ports.value[0] || "usb";
|
||||
}
|
||||
async function installerRefresh() {
|
||||
let next: any;
|
||||
try {
|
||||
next = await getJson("firmware");
|
||||
} catch (e: any) {
|
||||
note.value = e.message;
|
||||
return;
|
||||
}
|
||||
data.value = next;
|
||||
const current = next.job;
|
||||
const ours = current && installer.file && current.file === installer.file;
|
||||
if (ours) installer.jobState = current.state;
|
||||
if (installer.view === "setup") {
|
||||
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.`
|
||||
: !next.available && form.target
|
||||
? "The ESPHome CLI is missing from this installation; only saving the profile is possible."
|
||||
: wifiNote.value;
|
||||
} else if (installer.view === "progress" && ours) {
|
||||
job.value = current;
|
||||
logs.value = next.logs || [];
|
||||
if (current.state !== "running" && current.state !== "success") logOpen.value = true;
|
||||
}
|
||||
}
|
||||
function showProgress(current: any, lines: string[]) {
|
||||
installer.view = "progress";
|
||||
installer.jobState = current.state;
|
||||
installer.action = current.action;
|
||||
job.value = current;
|
||||
logs.value = lines;
|
||||
}
|
||||
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 progressTitle = computed(() => installer.view === "done"
|
||||
? `${installer.file} is in the ESPHome folder`
|
||||
: running.value
|
||||
? job.value?.stage === "upload" ? `Writing firmware to ${installer.friendly}…` : "Building firmware…"
|
||||
: ok.value
|
||||
? download.value ? `Firmware for ${installer.friendly} is ready` : `Firmware is on ${installer.friendly}`
|
||||
: download.value ? "Build failed" : "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:"
|
||||
: 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.`
|
||||
: 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.");
|
||||
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);
|
||||
if (!element.reportValidity()) return;
|
||||
submitting.value = true;
|
||||
status.value = "";
|
||||
try {
|
||||
const payload: Record<string, string> = { board: form.board, friendly_name: form.friendly_name, name: form.name, target: form.target };
|
||||
if (askWifi.value) { if (wifiMissing.value.includes("wifi_ssid")) payload.wifi_ssid = form.wifi_ssid; if (wifiMissing.value.includes("wifi_password")) payload.wifi_password = form.wifi_password; }
|
||||
const result = await send("firmware/profiles", "POST", payload);
|
||||
Object.assign(installer, { file: result.file, apiKey: result.api_key, friendly: form.friendly_name.trim(), board: form.board, target: form.target });
|
||||
form.wifi_password = "";
|
||||
if (result.job) showProgress(result.job, []);
|
||||
else installer.view = "done";
|
||||
} catch (err: any) {
|
||||
status.value = err.message;
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
}
|
||||
async function retry() {
|
||||
try {
|
||||
if (!download.value) {
|
||||
const { ports: fresh } = await getJson("firmware");
|
||||
// The board may have been replugged; a single visible port is unambiguous.
|
||||
if (!fresh.includes(installer.target) && fresh.length === 1) installer.target = fresh[0];
|
||||
}
|
||||
const next = await send("firmware/jobs", "POST", download.value
|
||||
? { file: installer.file, action: "download" }
|
||||
: { file: installer.file, action: "install", target: installer.target });
|
||||
showProgress(next, []);
|
||||
} catch (err: any) {
|
||||
toast(err.message);
|
||||
}
|
||||
}
|
||||
function reset() {
|
||||
Object.assign(installer, { view: "setup", file: null, apiKey: null, nodeEdited: false, jobState: null, target: "", picked: false, action: null });
|
||||
Object.assign(form, { board: "cyd", friendly_name: "", name: "", wifi_ssid: "", wifi_password: "", target: "" });
|
||||
nodeVisible.value = false; job.value = null; logs.value = []; status.value = ""; note.value = ""; logOpen.value = false;
|
||||
installerRefresh();
|
||||
}
|
||||
function close() {
|
||||
if (installer.view === "progress" && installer.jobState !== "running") installer.view = "done";
|
||||
go("");
|
||||
}
|
||||
onMounted(() => { installerRefresh(); poll = window.setInterval(installerRefresh, 3000); });
|
||||
onBeforeUnmount(() => clearInterval(poll));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<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>
|
||||
</div>
|
||||
<button type="button" class="btn quiet" id="close-install" aria-label="Close" @click="close">← 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>
|
||||
<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>
|
||||
</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>
|
||||
</div>
|
||||
<div v-if="nodeVisible" class="field" id="node-label">
|
||||
<label class="f-label" for="node-name">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>
|
||||
</div>
|
||||
<fieldset v-if="askWifi" id="wifi-fields" class="wifi">
|
||||
<legend>Wi-Fi</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>
|
||||
</fieldset>
|
||||
<div class="field">
|
||||
<label class="f-label" for="install-target">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-for="p in ports" :key="p" :value="p">{{ portLabel(p) }}</option>
|
||||
<option value="download">{{ DOWNLOAD_TARGET }}</option>
|
||||
<option value="">Later · save profile only</option>
|
||||
</select>
|
||||
<small id="target-hint">{{ targetHint }}</small>
|
||||
</div>
|
||||
<p v-if="note" class="hint" id="install-note">{{ note }}</p>
|
||||
<div class="actions">
|
||||
<button type="submit" class="btn primary" id="install-go" :disabled="goDisabled">{{ goLabel }}</button>
|
||||
<span id="install-status" class="status-line error" role="status">{{ status }}</span>
|
||||
</div>
|
||||
</form>
|
||||
<div v-else id="install-progress" class="card">
|
||||
<div class="progress-head">
|
||||
<span v-if="running" class="spin" id="progress-spin"></span>
|
||||
<span v-else class="outcome" :class="ok ? 'ok' : 'bad'" id="progress-mark">{{ ok ? "✓" : "✕" }}</span>
|
||||
<strong id="progress-title">{{ progressTitle }}</strong>
|
||||
</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>
|
||||
<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>
|
||||
</ol>
|
||||
<small>The file holds your Wi-Fi password and the screen's keys: keep it to yourself.</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>
|
||||
</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>
|
||||
</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>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,43 @@
|
||||
<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 { entriesOf, hasGaps, MAX_PAGES, pageCount } from "../model/layout";
|
||||
import { addPage, closeInspector, currentScreen, pagesShown, state, supports, tileLimit } from "../store";
|
||||
import DevicePage from "./DevicePage.vue";
|
||||
import Library from "./Library.vue";
|
||||
|
||||
const layout = computed(() => state.layout!);
|
||||
const entries = computed(() => state.drag.preview || entriesOf(layout.value));
|
||||
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.`
|
||||
: "");
|
||||
function onCanvasClick(e: MouseEvent) {
|
||||
// A click beside the pages closes the drawer; the cards and the bar handle their own clicks.
|
||||
if ((e.target as HTMLElement).closest(".device, .page-label, .canvas-head")) return;
|
||||
if (state.inspector) closeInspector();
|
||||
}
|
||||
</script>
|
||||
|
||||
<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>
|
||||
<span v-if="positionsHint" id="positions-hint" class="warn">{{ positionsHint }}</span>
|
||||
</div>
|
||||
<div class="pages" id="layout-preview" aria-label="Screen layout">
|
||||
<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="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` }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Library />
|
||||
</template>
|
||||
@@ -0,0 +1,56 @@
|
||||
<script setup lang="ts">
|
||||
// The entities a tile can show, with a search and one filter per domain. A click adds the entity to the
|
||||
// marked empty cell or the first free one; a drag puts it exactly where it lands.
|
||||
import { computed } from "vue";
|
||||
import { vDrag } from "../drag";
|
||||
import { domainInfo } from "../model/layout";
|
||||
import { glyph } from "../model/topbar";
|
||||
import { addTile, automaticIcon, isGuition, 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", "Clock"],
|
||||
];
|
||||
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) || []));
|
||||
const matches = computed(() => {
|
||||
const query = state.search.toLocaleLowerCase(), filter = state.filter;
|
||||
// The picker offers what a tile can show; camera images need a Guition (app 0.2.66).
|
||||
return [...(state.inventory.builtin || []), ...state.inventory.entities].filter((e) =>
|
||||
e.tile !== false &&
|
||||
(isGuition.value || !["camera", "image"].includes(e.id.split(".")[0])) &&
|
||||
(!filter || e.id.startsWith(filter + ".") || ALIAS[filter] === e.id.split(".")[0]) &&
|
||||
`${e.name} ${e.id} ${e.device || ""} ${e.area || ""}`.toLocaleLowerCase().includes(query));
|
||||
});
|
||||
const full = computed(() => (state.layout?.tiles.length || 0) >= tileLimit.value);
|
||||
const count = computed(() => state.inventory.entities.length);
|
||||
</script>
|
||||
|
||||
<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="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>
|
||||
</div>
|
||||
</div>
|
||||
<div class="lib-list" id="results" aria-live="polite">
|
||||
<button v-for="entity in matches.slice(0, 80)" :key="entity.id" type="button" class="ent" :title="entity.id"
|
||||
:disabled="chosen.has(entity.id) || full" v-drag="{ kind: 'entity', id: entity.id }" @click="addTile(entity.id)">
|
||||
<span class="av mdi" :style="{ color: domainInfo(entity.id)[2], background: domainInfo(entity.id)[3] }">{{ state.inventory.icons ? glyph(automaticIcon(entity.id)) : domainInfo(entity.id)[1] }}</span>
|
||||
<span class="tx">
|
||||
<b>{{ entity.name }}</b>
|
||||
<small>{{ [domainInfo(entity.id)[0], entity.area, entity.device].filter(Boolean).join(" · ") }}</small>
|
||||
</span>
|
||||
<span class="add" :class="{ done: chosen.has(entity.id) }">{{ chosen.has(entity.id) ? "✓" : "+" }}</span>
|
||||
</button>
|
||||
<p v-if="!matches.length" class="hint">No entities found. Try a different name.</p>
|
||||
<p v-else-if="matches.length > 80" class="hint">{{ matches.length }} results. Keep typing to narrow it down.</p>
|
||||
</div>
|
||||
<div class="lib-foot">{{ full ? `This screen holds ${tileLimit} tiles${tileLimit === 10 ? "; update its firmware for 20" : ""}.` : "Drag onto a page, or tap + to add to the first free slot." }}</div>
|
||||
</aside>
|
||||
</template>
|
||||
@@ -0,0 +1,157 @@
|
||||
<script setup lang="ts">
|
||||
// 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 { glyph } from "../model/topbar";
|
||||
import { go, state, toast } from "../store";
|
||||
|
||||
const OVERRIDE_EXAMPLE = `# Hardware-specific changes for this screen.
|
||||
# This file is kept when the shared firmware package updates.
|
||||
# Do not add esphome:, api:, ota:, wifi: or packages: here.
|
||||
|
||||
# Example: a CYD with the ST7789V display controller.
|
||||
# "!extend" changes the display the shared package already defines;
|
||||
# a bare id would add a second, incomplete display and fail the build.
|
||||
display:
|
||||
- id: !extend my_display
|
||||
model: ST7789V
|
||||
`;
|
||||
const profile = computed(() => state.overrideProfile);
|
||||
const content = ref("");
|
||||
const file = ref("");
|
||||
const attached = ref(false);
|
||||
const status = ref("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"}`);
|
||||
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…");
|
||||
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.");
|
||||
} catch (error: any) {
|
||||
setStatus(error.message, "error");
|
||||
}
|
||||
}
|
||||
async function saveOverride(runCheck = false) {
|
||||
if (!profile.value || busy.value) return;
|
||||
busy.value = true;
|
||||
setStatus("Checking and 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.");
|
||||
return;
|
||||
}
|
||||
setStatus("Saved. ESPHome is checking the complete profile…");
|
||||
await send("firmware/jobs", "POST", { file: profile.value, action: "validate" });
|
||||
pollCheck();
|
||||
} catch (error: any) {
|
||||
setStatus(error.message, "error");
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
function pollCheck() {
|
||||
const started = Date.now(), checked = profile.value;
|
||||
const poll = async () => {
|
||||
if (!alive || profile.value !== checked) return; // the page closed or shows another screen
|
||||
try {
|
||||
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());
|
||||
} else if (current && current.file === checked && current.state === "success") {
|
||||
setStatus("The complete profile is valid. Safe to build and install.", "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");
|
||||
return;
|
||||
}
|
||||
if (Date.now() - started < 7200000) setTimeout(poll, 1200);
|
||||
} catch (error: any) {
|
||||
setStatus(error.message, "error");
|
||||
}
|
||||
};
|
||||
poll();
|
||||
}
|
||||
function useExample() {
|
||||
if (!content.value.trim() || confirm("Replace the current text with the example?")) {
|
||||
content.value = OVERRIDE_EXAMPLE;
|
||||
setStatus("Example loaded. Save it when you are ready.");
|
||||
editor.value?.focus();
|
||||
}
|
||||
}
|
||||
function clear() {
|
||||
if (!content.value.trim() || confirm("Clear the local override?")) {
|
||||
content.value = "";
|
||||
setStatus("The override will be empty after you save.");
|
||||
editor.value?.focus();
|
||||
}
|
||||
}
|
||||
function onKey(event: KeyboardEvent) {
|
||||
const area = editor.value!;
|
||||
if (event.key === "Tab") {
|
||||
event.preventDefault();
|
||||
const start = area.selectionStart, end = area.selectionEnd;
|
||||
area.setRangeText(" ", start, end, "end");
|
||||
content.value = area.value;
|
||||
} else if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "s") {
|
||||
event.preventDefault();
|
||||
saveOverride(false);
|
||||
}
|
||||
}
|
||||
function syncScroll() { if (gutter.value && editor.value) gutter.value.scrollTop = editor.value.scrollTop; }
|
||||
onMounted(load);
|
||||
onBeforeUnmount(() => { alive = false; });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<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>
|
||||
</div>
|
||||
<button type="button" class="btn quiet" id="close-override" @click="go('')">← 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>
|
||||
<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>
|
||||
</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>
|
||||
</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>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,69 @@
|
||||
<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 } from "vue";
|
||||
import { closeInspector, currentScreen, go, save, state } from "../store";
|
||||
import LayoutView from "./LayoutView.vue";
|
||||
import SettingsTab from "./SettingsTab.vue";
|
||||
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);
|
||||
function openOverride() {
|
||||
state.menuOpen = false;
|
||||
state.overrideProfile = screen.value.update?.profile || null;
|
||||
state.overrideFriendly = screen.value.name;
|
||||
go("#override");
|
||||
}
|
||||
function inspectAll() {
|
||||
state.menuOpen = false;
|
||||
state.selectedTile = null;
|
||||
state.inspector = { kind: "inspect" };
|
||||
}
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") {
|
||||
if (state.menuOpen) state.menuOpen = false;
|
||||
else if (state.inspector && !(e.target as HTMLElement)?.closest?.(".picker")) closeInspector();
|
||||
}
|
||||
}
|
||||
function onDocClick(e: MouseEvent) {
|
||||
if (state.menuOpen && !(e.target as HTMLElement).closest(".head-right .menu, #more")) state.menuOpen = false;
|
||||
}
|
||||
onMounted(() => { document.addEventListener("keydown", onKey); document.addEventListener("click", onDocClick); });
|
||||
onBeforeUnmount(() => { document.removeEventListener("keydown", onKey); document.removeEventListener("click", onDocClick); });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header class="main-head">
|
||||
<div>
|
||||
<span class="eyebrow" id="screen-name">{{ screen.name }}</span>
|
||||
<h1>{{ state.layout?.title || "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>
|
||||
</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>
|
||||
<button v-if="state.dirty" id="save" type="button" class="btn primary" :disabled="state.busy" @click="save()">
|
||||
<span v-if="state.busy" class="spin small"></span>{{ state.busy ? "Saving…" : "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">···</button>
|
||||
<div v-if="state.menuOpen" class="menu" role="menu">
|
||||
<button type="button" role="menuitem" id="inspect" @click="inspectAll">Read current data <small>from Home Assistant</small></button>
|
||||
<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="state.menuOpen = false; go('#firmware')">Firmware & USB</button>
|
||||
<button v-if="updateReady" type="button" role="menuitem" @click="state.menuOpen = false; $emit('update')">Update firmware <small>{{ screen.update?.target }}</small></button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<div class="body" id="body">
|
||||
<LayoutView v-if="state.tab === 'layout'" />
|
||||
<SettingsTab v-else />
|
||||
<Drawer />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{ choices: readonly (readonly [unknown, string])[]; value: unknown; disabled?: boolean }>();
|
||||
const emit = defineEmits<{ (e: "pick", value: any): void }>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="seg wrap" role="group">
|
||||
<button v-for="[key, text] in choices" :key="String(key)" type="button" :disabled="disabled"
|
||||
:aria-pressed="String(key) === String(value) ? 'true' : 'false'" @click="emit('pick', key)">{{ text }}</button>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,84 @@
|
||||
<script setup lang="ts">
|
||||
// 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 { glyph } from "../model/topbar";
|
||||
import { currentScreen, SETTING_GROUPS, setSetting, settingText, settingValues, settingsView, state, steppedSetting, type SettingRow } from "../store";
|
||||
|
||||
const view = computed(() => settingsView());
|
||||
const values = computed(() => settingValues());
|
||||
const offline = computed(() => view.value?.owner === "screen" && !currentScreen.value?.online);
|
||||
const groups = computed(() => SETTING_GROUPS.map((group) => ({ ...group, rows: (group.rows as readonly SettingRow[]).filter((row) => view.value?.keys.includes(row.key)) })).filter((g) => g.rows.length));
|
||||
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."
|
||||
: state.settingPending
|
||||
? "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.");
|
||||
const stepDisabled = (row: SettingRow, direction: number) => {
|
||||
const value = values.value[row.key];
|
||||
if (unavailable(row) || !needs(row) || value === null || value === undefined) return true;
|
||||
return row.kind !== "moment" && steppedSetting(row, value, direction, false, values.value) === value;
|
||||
};
|
||||
// A key held down steps again and again, faster after a moment, like the -/+ keys on the screen.
|
||||
const holds = new WeakMap<HTMLElement, { timer: number; held: boolean }>();
|
||||
function step(row: SettingRow, direction: number, held: boolean) {
|
||||
const v = settingValues();
|
||||
const next = steppedSetting(row, v[row.key], direction, held, v);
|
||||
if (next !== v[row.key]) setSetting(row.key, next, 600);
|
||||
}
|
||||
function down(e: PointerEvent, row: SettingRow, direction: number) {
|
||||
const button = e.currentTarget as HTMLButtonElement;
|
||||
if (button.disabled || e.button !== 0) return;
|
||||
const hold = { timer: 0, held: false };
|
||||
holds.set(button, hold);
|
||||
let repeats = 0;
|
||||
hold.timer = window.setTimeout(function repeat() {
|
||||
hold.held = true;
|
||||
repeats += 1;
|
||||
step(row, direction, repeats > 5);
|
||||
hold.timer = window.setTimeout(repeat, 180);
|
||||
}, 450);
|
||||
}
|
||||
function up(e: PointerEvent) {
|
||||
const hold = holds.get(e.currentTarget as HTMLElement);
|
||||
if (hold) clearTimeout(hold.timer);
|
||||
}
|
||||
function click(e: MouseEvent, row: SettingRow, direction: number) {
|
||||
const hold = holds.get(e.currentTarget as HTMLElement);
|
||||
if (hold?.held) { hold.held = false; return; }
|
||||
step(row, direction, false);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<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>
|
||||
<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.' : ''"
|
||||
@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>
|
||||
<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>
|
||||
</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>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,94 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
import { glyph } from "../model/topbar";
|
||||
import { copyText, go, openIntegrations, PHASES, refresh, route, select, startUpdate, state } from "../store";
|
||||
import type { Screen } from "../types";
|
||||
|
||||
const hostFor = ref<string | null>(null);
|
||||
const host = ref("");
|
||||
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.result && Date.now() / 1000 - u.result.time < 86400) return { kind: u.result.state === "failed" ? "failed" : "done", text: u.result.message };
|
||||
return null;
|
||||
}
|
||||
function update(screen: Screen) {
|
||||
const u = screen.update || {};
|
||||
if (u.host && u.profile) startUpdate(screen);
|
||||
else if (u.profile) { hostFor.value = screen.id; host.value = ""; }
|
||||
}
|
||||
function startWithHost(screen: Screen) {
|
||||
const address = host.value.trim();
|
||||
if (!address) return;
|
||||
hostFor.value = null;
|
||||
startUpdate(screen, address);
|
||||
}
|
||||
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."
|
||||
: 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}.`;
|
||||
</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…" }}
|
||||
</span>
|
||||
<div class="label">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>
|
||||
</span>
|
||||
<span v-if="updateState(screen)?.kind === 'available'" class="pill">Update</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>
|
||||
<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>
|
||||
</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>
|
||||
</template>
|
||||
</template>
|
||||
<small v-else :class="{ failed: updateState(screen)!.kind === 'failed' }">{{ updateState(screen)!.text }}</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="pending">
|
||||
<div v-for="p in state.inventory.pending || []" :key="p.file" class="pending">
|
||||
<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>
|
||||
</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>
|
||||
</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>
|
||||
</aside>
|
||||
</template>
|
||||
@@ -0,0 +1,87 @@
|
||||
<script setup lang="ts">
|
||||
// A card on the mockup. A placeholder is the tile being dragged, drawn where it will land.
|
||||
import { computed, nextTick } from "vue";
|
||||
import { vDrag } from "../drag";
|
||||
import { displayNames, effectiveControls, isWide, pageOf, SLOTS_PER_PAGE } from "../model/layout";
|
||||
import { glyph } from "../model/topbar";
|
||||
import { entityName, openTile, placeTile, removeTile, state, tileIconCp } from "../store";
|
||||
import type { Tile } from "../types";
|
||||
|
||||
const props = defineProps<{ tile: Tile; slot: number; placeholder?: boolean }>();
|
||||
const name = computed(() => props.tile.name || entityName(props.tile.entity));
|
||||
const wide = computed(() => isWide(props.tile));
|
||||
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 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(() => state.selectedTile === props.tile.entity && 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 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);
|
||||
|
||||
async function onKey(e: KeyboardEvent) {
|
||||
if (e.key === "Enter" || e.key === " ") { e.preventDefault(); openTile(props.tile.entity); return; }
|
||||
const step = ({ ArrowLeft: -1, ArrowRight: 1, ArrowUp: -2, ArrowDown: 2 } as Record<string, number>)[e.key];
|
||||
if (!step) return;
|
||||
e.preventDefault();
|
||||
// A wide card owns its row: left and right mean the row above and below.
|
||||
if (placeTile(props.tile, props.tile.slot + (wide.value ? Math.sign(step) * 2 : step))) {
|
||||
await nextTick();
|
||||
document.querySelector<HTMLElement>(`.pages [data-slot="${props.tile.slot}"]`)?.focus();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="tile" :class="{ wide, bare, placeholder: placeholder || !live, chosen }" :data-slot="slot"
|
||||
:style="background && !bare ? { backgroundColor: background } : undefined"
|
||||
:tabindex="live ? 0 : -1" :role="live ? 'button' : undefined" :aria-label="live ? label : undefined"
|
||||
v-drag="{ kind: 'tile', tile }" @click="live && openTile(tile.entity)" @keydown="live && onKey($event)">
|
||||
<template v-if="display === 'analog'">
|
||||
<svg class="clockface" viewBox="0 0 60 60" aria-hidden="true">
|
||||
<circle cx="30" cy="30" r="27" fill="#fff" stroke="#c9ccd1" />
|
||||
<line v-for="a in [0, 90, 180, 270]" :key="a" x1="30" y1="5" x2="30" y2="9" stroke="#1b1b1b" stroke-width="1.5" :transform="`rotate(${a} 30 30)`" />
|
||||
<line x1="30" y1="30" x2="30" y2="16" stroke="#1b1b1b" stroke-width="2.4" stroke-linecap="round" :transform="`rotate(${hourAngle} 30 30)`" />
|
||||
<line x1="30" y1="30" x2="30" y2="11" stroke="#1b1b1b" stroke-width="1.6" stroke-linecap="round" :transform="`rotate(${minuteAngle} 30 30)`" />
|
||||
<circle cx="30" cy="30" r="1.8" fill="#1b1b1b" />
|
||||
</svg>
|
||||
<span v-if="wide" class="lead"><span class="tx"><span class="nm">{{ name }}</span><span class="st">{{ note }}</span></span></span>
|
||||
</template>
|
||||
<template v-else-if="wide">
|
||||
<span class="lead">
|
||||
<span class="ic mdi">{{ glyph(tileIconCp(tile)) }}</span>
|
||||
<span class="tx"><span class="nm">{{ name }}</span><span v-if="note" class="st">{{ note }}</span></span>
|
||||
</span>
|
||||
<span v-if="tile.options?.inline === 'slider'" class="mini-slider"></span>
|
||||
<span v-if="controls" class="ctl">
|
||||
<span v-if="controls === 'toggle'" class="tog"></span>
|
||||
<span v-else-if="controls === 'setpoint'" class="stp"><span class="mdi">{{ key("minus") || "−" }}</span><b>20°</b><span class="mdi">{{ key("plus") || "+" }}</span></span>
|
||||
<template v-else-if="controls === 'stepper' && domain.endsWith('select')"><span class="key mdi">{{ key("chevron-left") }}</span><span class="key mdi">{{ key("chevron-right") }}</span></template>
|
||||
<span v-else-if="controls === 'stepper'" class="stp"><span class="mdi">{{ key("minus") || "−" }}</span><b>50</b><span class="mdi">{{ key("plus") || "+" }}</span></span>
|
||||
<template v-else-if="controls === 'mode'"><span class="key mdi">{{ key("power") }}</span><span class="key mdi">{{ key("fire") }}</span><span class="key mdi">{{ key("snowflake") }}</span></template>
|
||||
<template v-else-if="controls === 'volume'"><span class="range"></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("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>
|
||||
<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 class="range"></span>
|
||||
</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span class="ic mdi">{{ glyph(tileIconCp(tile)) }}</span>
|
||||
<span class="lead">
|
||||
<span class="nm">{{ name }}</span>
|
||||
<span v-if="note" class="st">{{ note }}</span>
|
||||
<span v-if="tile.options?.inline === 'slider'" class="mini-slider"></span>
|
||||
</span>
|
||||
</template>
|
||||
<button v-if="live" type="button" class="remove" title="Remove tile" :aria-label="`Remove ${name}`" @click.stop="removeTile(tile)">✕</button>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,137 @@
|
||||
<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 } from "vue";
|
||||
import { domainInfo, SLIDER_DOMAINS, TOGGLE_BEFORE } from "../model/layout";
|
||||
import { glyph } from "../model/topbar";
|
||||
import { automaticIcon, closeInspector, entityName, markDirty, removeTile, setTileOption, state, supports, tileIconCp } from "../store";
|
||||
import type { Tile } from "../types";
|
||||
import ActionPicker from "./ActionPicker.vue";
|
||||
import IconPicker from "./IconPicker.vue";
|
||||
import Segmented from "./Segmented.vue";
|
||||
|
||||
const props = defineProps<{ tile: Tile }>();
|
||||
const domain = computed(() => props.tile.entity.split(".")[0]);
|
||||
const name = computed(() => entityName(props.tile.entity));
|
||||
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 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;
|
||||
});
|
||||
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.";
|
||||
return "";
|
||||
});
|
||||
const size = computed(() => current("size", "single") as string);
|
||||
const catalogue = computed(() => state.inventory.controls?.[domain.value]);
|
||||
const controls = computed(() => current("controls", catalogue.value?.default) as string);
|
||||
const controlChoices = computed(() => {
|
||||
const c = caps.value;
|
||||
return (catalogue.value?.choices || []).filter((ch) => !c || ch.key === "none" || ch.key === controls.value || c.controls.includes(ch.key)).map((ch) => [ch.key, ch.label] as [string, string]);
|
||||
});
|
||||
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 };
|
||||
return { text: supports(0, 2, 19)
|
||||
? "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 };
|
||||
});
|
||||
const tap = computed(() => current("tap", "auto") as string);
|
||||
const taps = computed(() => {
|
||||
const list: [string, string][] = [["auto", "Automatic"], ["detail", "Open control"], ["none", "View only"]];
|
||||
// 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;
|
||||
});
|
||||
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 };
|
||||
return null;
|
||||
});
|
||||
const inline = computed(() => current("inline", "none") as string);
|
||||
const showSlider = computed(() => SLIDER_DOMAINS.includes(domain.value) && (!caps.value || caps.value.inline || inline.value === "slider"));
|
||||
const sliderWarn = computed(() => inline.value === "slider" && caps.value && !caps.value.inline);
|
||||
const history = computed(() => current("history_hours", 24) as number);
|
||||
const backgrounds = computed(() => Object.entries(state.inventory.backgrounds || {}));
|
||||
const fromHA = computed(() => Boolean(state.inventory.entities.find((e) => e.id === props.tile.entity)?.icon));
|
||||
const showIcon = computed(() => Boolean(state.inventory.icons) && domain.value !== "screen" && !["forecast", "sunpath"].includes(display.value));
|
||||
function rename(value: string) {
|
||||
props.tile.name = value;
|
||||
markDirty();
|
||||
}
|
||||
function inspect() {
|
||||
state.inspector = { kind: "inspect", entity: props.tile.entity };
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<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>
|
||||
</div>
|
||||
<div class="dr-body">
|
||||
<div class="f">
|
||||
<label class="f-label" for="tile-name">Name on the screen</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.'"
|
||||
@pick="(n) => setTileOption(tile, 'icon', n)" />
|
||||
<div class="f">
|
||||
<span class="f-label">Display</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">Width</span>
|
||||
<Segmented :choices="[['single', 'Normal'], ['wide', 'Double-width']]" :value="size" @pick="(v) => setTileOption(tile, 'size', v)" />
|
||||
</div>
|
||||
<div v-if="catalogue && size === 'wide'" class="f">
|
||||
<span class="f-label">Direct control on the tile</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'" class="f">
|
||||
<span class="f-label">On tap</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' && tap === 'action'" :tile="tile" />
|
||||
<div v-if="showSlider" 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>
|
||||
</div>
|
||||
<div v-if="domain === 'sensor'" 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))" />
|
||||
</div>
|
||||
<div class="f">
|
||||
<span class="f-label">Pastel background</span>
|
||||
<div class="sw">
|
||||
<button v-for="[key, choice] in backgrounds" :key="key" type="button" :aria-label="`Background: ${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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="dr-foot">
|
||||
<button type="button" class="btn danger" @click="removeTile(tile)">Remove</button>
|
||||
<span class="spacer"></span>
|
||||
<button v-if="domain !== 'screen'" type="button" class="btn quiet" @click="inspect">Read current data</button>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import { dismissToast, state } from "../store";
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="state.toast" id="toast" class="toast" role="status">
|
||||
<span>{{ state.toast.message }}</span>
|
||||
<button v-if="state.toast.action" type="button" @click="state.toast.action.run(); dismissToast()">{{ state.toast.action.label }}</button>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,62 @@
|
||||
<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 { BUILTIN_ICONS, clockText, dateText, glyph, itemKey } from "../model/topbar";
|
||||
import { addTopbarItem, automaticIcon, closeInspector, iconNamed, settingValues, 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 suggested = computed(() => state.inventory.header?.suggestions?.[state.selected || ""] || []);
|
||||
const matches = computed(() => {
|
||||
const q = query.value.trim().toLocaleLowerCase();
|
||||
return state.inventory.entities.filter((e) => `${e.name} ${e.id} ${e.area || ""} ${e.device || ""}`.toLocaleLowerCase().includes(q));
|
||||
});
|
||||
const entityItem = (id: string): HeaderItem => ({ type: "entity", entity: id, content: "state", icon: "auto", show: "always" });
|
||||
</script>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
<div class="dr-body">
|
||||
<div class="f">
|
||||
<span class="f-label">From the screen itself</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>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="suggested.length" class="f">
|
||||
<span class="f-label">Suggestions from Home Assistant</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>
|
||||
</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" />
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="dr-foot">
|
||||
<span class="spacer"></span>
|
||||
<button type="button" class="btn quiet" @click="closeInspector">Cancel</button>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,189 @@
|
||||
<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 { 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,
|
||||
} 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 || "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.`);
|
||||
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;
|
||||
};
|
||||
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 ? "Hidden right now: not active" : overflow.value.has(props.index) ? "Doesn't currently fit next to the name" : "How it looks on the screen";
|
||||
});
|
||||
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>));
|
||||
|
||||
// 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)) : "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>
|
||||
</span>
|
||||
<button type="button" class="icon-btn" aria-label="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)" />
|
||||
</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>
|
||||
<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`"
|
||||
@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>
|
||||
</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>
|
||||
</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">What to show</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>
|
||||
</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 })" />
|
||||
<div class="f">
|
||||
<span class="f-label">Show</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>
|
||||
</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>
|
||||
</template>
|
||||
</div>
|
||||
<div v-if="item" class="dr-foot">
|
||||
<button type="button" class="btn danger" @click="removeTopbarItem(index)">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>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,61 @@
|
||||
<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 { barLayout, dotted, type BarPart } from "../model/topbar";
|
||||
import { barMetrics, state, topbarItems, topbarView } from "../store";
|
||||
|
||||
const props = defineProps<{ items?: any[]; nameText?: string; single?: boolean }>();
|
||||
|
||||
const lay = computed(() => {
|
||||
void state.fontsVersion;
|
||||
void state.now;
|
||||
void state.topbarPreviews;
|
||||
const items = props.items || topbarItems();
|
||||
return barLayout(items, barMetrics.value, props.nameText ?? (state.layout?.title || "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)));
|
||||
const baseline = computed(() => (props.single ? Math.round(m.value.text * 1.05) : m.value.top));
|
||||
const parts = computed<BarPart[]>(() => {
|
||||
if (!props.single) return lay.value.placed;
|
||||
const part = lay.value.parts[0];
|
||||
if (part) part.x = 0;
|
||||
return part ? [part] : [];
|
||||
});
|
||||
const viewBox = computed(() => (props.single
|
||||
? `-2 0 ${Math.max(1, parts.value[0]?.width || 1) + 4} ${height.value}`
|
||||
: `0 0 ${m.value.width} ${height.value}`));
|
||||
const capMiddle = computed(() => baseline.value + (lay.value.zero.top + lay.value.zero.bottom) / 2);
|
||||
const name = computed(() => dotted(lay.value.nameText, lay.value.fonts.name, Math.min(lay.value.natural, lay.value.nameRoom)));
|
||||
const now = computed(() => new Date(state.now));
|
||||
function hands(d: number) {
|
||||
const n = now.value;
|
||||
return [[(n.getHours() % 12 + n.getMinutes() / 60) * 30, d * 0.24], [n.getMinutes() * 6, d * 0.34]].map(([angle, length]) => {
|
||||
const rad = (angle * Math.PI) / 180;
|
||||
return { x2: length * Math.sin(rad), y2: -length * Math.cos(rad) };
|
||||
});
|
||||
}
|
||||
const iconX = (p: BarPart) => p.x! - p.icon!.ink.left;
|
||||
const textX = (p: BarPart) => (p.icon ? p.x! + (p.icon.ink.right - p.icon.ink.left) + lay.value.gaps.icon : p.x!) - p.text!.ink.left;
|
||||
// `font` goes through CSSOM: the add-on's CSP blocks style attributes, and Vue sets styles through the CSSOM.
|
||||
const singleWidth = computed(() => `${(Math.max(1, parts.value[0]?.width || 1) + 4) * (26 / m.value.icon)}px`);
|
||||
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">
|
||||
<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">
|
||||
<template v-if="p.dial">
|
||||
<circle :cx="p.x! + p.dial / 2" :cy="capMiddle" :r="p.dial / 2 - Math.max(1, Math.round(p.dial / 10)) / 2" fill="none" :stroke="p.view.color || '#46525e'" :stroke-width="Math.max(1, Math.round(p.dial / 10))" />
|
||||
<line v-for="(h, i) in hands(p.dial)" :key="i" :x1="p.x! + p.dial / 2" :y1="capMiddle" :x2="p.x! + p.dial / 2 + h.x2" :y2="capMiddle + h.y2" :stroke="p.view.color || '#46525e'" :stroke-width="Math.max(1, Math.round(p.dial * 0.075))" stroke-linecap="round" />
|
||||
</template>
|
||||
<template v-else>
|
||||
<text v-if="p.icon" :x="iconX(p)" :y="capMiddle - (p.icon.ink.top + p.icon.ink.bottom) / 2" :fill="p.view.color || '#46525e'" :style="{ font: lay.fonts.icon }">{{ p.icon.glyph }}</text>
|
||||
<text v-if="p.text" :x="textX(p)" :y="baseline" fill="#46525e" :style="{ font: lay.fonts.text }">{{ p.text.value }}</text>
|
||||
</template>
|
||||
</template>
|
||||
</g>
|
||||
</svg>
|
||||
</template>
|
||||
+133
@@ -0,0 +1,133 @@
|
||||
// Pointer-based drag & drop, mouse and touch, from the library into the mockup and between
|
||||
// cells. Touch starts after a short hold so the page still scrolls. While dragging, the
|
||||
// mockup already shows where everything ends up; the drop confirms exactly that, and a
|
||||
// drop off the grid changes nothing. A finished drag never doubles as a click.
|
||||
import type { Directive } from "vue";
|
||||
import { arrange, newTile } from "./model/layout";
|
||||
import { markDirty, placeTile, state } from "./store";
|
||||
import type { Tile } from "./types";
|
||||
|
||||
export type DragSource = { kind: "tile"; tile: Tile } | { kind: "entity"; id: string };
|
||||
type Drag = {
|
||||
source: DragSource | null; element: HTMLElement | null; ghost: HTMLElement | null; timer: number;
|
||||
start: { x: number; y: number } | null; offset: { x: number; y: number }; pointerId: number | null;
|
||||
suppressUntil: number; last: { x: number; y: number } | null; scroller: number; target: number | null;
|
||||
};
|
||||
const drag: Drag = { source: null, element: null, ghost: null, timer: 0, start: null, offset: { x: 0, y: 0 }, pointerId: null, suppressUntil: 0, last: null, scroller: 0, target: null };
|
||||
|
||||
export const vDrag: Directive<HTMLElement, DragSource> = {
|
||||
mounted(element, binding) {
|
||||
(element as any).__dragSource = binding.value;
|
||||
element.addEventListener("pointerdown", (e: PointerEvent) => {
|
||||
const source = (element as any).__dragSource as DragSource;
|
||||
if (e.button !== 0 || (element as HTMLButtonElement).disabled || (e.target as HTMLElement).closest(".remove")) return;
|
||||
// No text selection while the mouse drags; touch keeps its default so the page can scroll.
|
||||
if (e.pointerType !== "touch") e.preventDefault();
|
||||
Object.assign(drag, { source, element, start: { x: e.clientX, y: e.clientY }, pointerId: e.pointerId });
|
||||
// A fast flick may leave the card before its first move event: keep the pointer until the drag begins.
|
||||
try { element.setPointerCapture(e.pointerId); } catch {}
|
||||
clearTimeout(drag.timer);
|
||||
if (e.pointerType === "touch") drag.timer = window.setTimeout(() => beginDrag(e), 260);
|
||||
});
|
||||
element.addEventListener("pointermove", (e: PointerEvent) => {
|
||||
if (state.drag.active || !drag.start || drag.element !== element) return;
|
||||
const distance = Math.hypot(e.clientX - drag.start.x, e.clientY - drag.start.y);
|
||||
if (e.pointerType === "touch") { if (distance > 10) { clearTimeout(drag.timer); drag.start = null; } return; }
|
||||
if (distance >= 6) beginDrag(e);
|
||||
});
|
||||
const cancel = () => { if (drag.element === element && !state.drag.active) { clearTimeout(drag.timer); drag.start = null; } };
|
||||
element.addEventListener("pointerup", cancel);
|
||||
element.addEventListener("pointercancel", cancel);
|
||||
},
|
||||
updated(element, binding) {
|
||||
(element as any).__dragSource = binding.value;
|
||||
},
|
||||
};
|
||||
|
||||
function beginDrag(e: PointerEvent) {
|
||||
if (state.drag.active || !drag.start || !drag.source || !drag.element) return;
|
||||
state.drag.active = true;
|
||||
state.drag.moving = drag.source.kind === "tile" ? drag.source.tile : newTile(drag.source.id);
|
||||
state.drag.preview = null;
|
||||
drag.target = null;
|
||||
getSelection()?.removeAllRanges();
|
||||
const rect = drag.element.getBoundingClientRect();
|
||||
const ghost = drag.element.cloneNode(true) as HTMLElement;
|
||||
ghost.classList.add("drag-ghost");
|
||||
ghost.style.width = `${rect.width}px`;
|
||||
ghost.style.height = `${rect.height}px`;
|
||||
drag.offset = { x: e.clientX - rect.left, y: e.clientY - rect.top };
|
||||
document.body.append(ghost);
|
||||
drag.ghost = ghost;
|
||||
// The mockup re-renders while hovering, so the pointer is followed on the document, not the card.
|
||||
document.addEventListener("pointermove", moveDrag);
|
||||
document.addEventListener("pointerup", finishDrag);
|
||||
document.addEventListener("pointercancel", finishDrag);
|
||||
try { drag.element.releasePointerCapture(drag.pointerId!); } catch {}
|
||||
try { document.documentElement.setPointerCapture(drag.pointerId!); } catch {}
|
||||
document.addEventListener("touchmove", blockScroll, { passive: false });
|
||||
// Near the edges of the canvas the pages scroll along, so every page can be reached.
|
||||
drag.scroller = window.setInterval(() => {
|
||||
if (!drag.last) return;
|
||||
const canvas = document.querySelector<HTMLElement>(".canvas");
|
||||
if (!canvas) return;
|
||||
const r = canvas.getBoundingClientRect();
|
||||
const dx = drag.last.x < r.left + 60 ? -12 : drag.last.x > r.right - 60 ? 12 : 0;
|
||||
const dy = drag.last.y < r.top + 60 ? -12 : drag.last.y > r.bottom - 60 ? 12 : 0;
|
||||
if (dx || dy) { canvas.scrollBy(dx, dy); setTarget(slotAt(drag.last.x, drag.last.y)); }
|
||||
}, 16);
|
||||
setTarget(-1);
|
||||
moveDrag(e);
|
||||
}
|
||||
function blockScroll(e: TouchEvent) { if (state.drag.active) e.preventDefault(); }
|
||||
function finishDrag(e: PointerEvent) { if (e.pointerId === drag.pointerId) endDrag(e.type === "pointerup"); }
|
||||
function moveDrag(e: PointerEvent) {
|
||||
if (!drag.ghost || e.pointerId !== drag.pointerId) return;
|
||||
drag.last = { x: e.clientX, y: e.clientY };
|
||||
drag.ghost.style.transform = `translate(${e.clientX - drag.offset.x}px, ${e.clientY - drag.offset.y}px)`;
|
||||
setTarget(slotAt(e.clientX, e.clientY));
|
||||
}
|
||||
// The cell under the pointer: the nearest card or empty cell (the gaps between them count
|
||||
// too); on a wide card the left or right half decides. -1 away from the mockup.
|
||||
function slotAt(x: number, y: number) {
|
||||
let best: { cell: HTMLElement; r: DOMRect } | null = null, nearest = Infinity;
|
||||
for (const cell of document.querySelectorAll<HTMLElement>(".pages [data-slot]")) {
|
||||
const r = cell.getBoundingClientRect();
|
||||
const distance = Math.hypot(Math.max(r.left - x, 0, x - r.right), Math.max(r.top - y, 0, y - r.bottom));
|
||||
if (distance < nearest) { nearest = distance; best = { cell, r }; }
|
||||
}
|
||||
if (!best || nearest > 16) return -1;
|
||||
let slot = Number(best.cell.dataset.slot);
|
||||
if (best.cell.classList.contains("wide") && x > (best.r.left + best.r.right) / 2) slot += 1;
|
||||
return slot;
|
||||
}
|
||||
function setTarget(slot: number) {
|
||||
if (drag.target === slot || !state.layout || !state.drag.moving) return;
|
||||
drag.target = slot;
|
||||
// Off the grid: a tile from the grid shows where it came from; a new one shows nowhere yet.
|
||||
state.drag.preview = slot >= 0 ? arrange(state.layout.tiles, state.drag.moving, slot) : null;
|
||||
}
|
||||
function endDrag(drop: boolean) {
|
||||
const preview = state.drag.preview, moving = state.drag.moving;
|
||||
document.removeEventListener("pointermove", moveDrag);
|
||||
document.removeEventListener("pointerup", finishDrag);
|
||||
document.removeEventListener("pointercancel", finishDrag);
|
||||
document.removeEventListener("touchmove", blockScroll);
|
||||
try { document.documentElement.releasePointerCapture(drag.pointerId!); } catch {}
|
||||
drag.ghost?.remove();
|
||||
Object.assign(drag, { ghost: null, suppressUntil: Date.now() + 400, last: null, start: null, target: null, element: null, source: null });
|
||||
clearInterval(drag.scroller);
|
||||
state.drag.active = false;
|
||||
state.drag.preview = null;
|
||||
state.drag.moving = null;
|
||||
if (drop && preview && moving && state.layout) {
|
||||
const before = state.layout.tiles.map((t) => `${t.entity}@${t.slot}`).join();
|
||||
for (const { tile, slot } of preview) { tile.slot = slot; if (!state.layout.tiles.includes(tile)) state.layout.tiles.push(tile); }
|
||||
state.layout.tiles.sort((a, b) => a.slot - b.slot);
|
||||
if (state.layout.tiles.map((t) => `${t.entity}@${t.slot}`).join() !== before) markDirty();
|
||||
// Placing goes through the same rules as a click; a new tile also asks for its capabilities.
|
||||
if (!before.includes(`${moving.entity}@`)) placeTile(moving, moving.slot);
|
||||
}
|
||||
}
|
||||
window.addEventListener("click", (e) => { if (Date.now() < drag.suppressUntil) { e.stopPropagation(); e.preventDefault(); } }, true);
|
||||
export const dragSuppressed = () => Date.now() < drag.suppressUntil;
|
||||
@@ -0,0 +1,8 @@
|
||||
import { createApp } from "vue";
|
||||
import App from "./App.vue";
|
||||
import "./styles/tokens.css";
|
||||
import "./styles/app.css";
|
||||
import { boot } from "./store";
|
||||
|
||||
createApp(App).mount("#app");
|
||||
boot();
|
||||
@@ -0,0 +1,162 @@
|
||||
// ---- Grid positions ----
|
||||
// Two columns, three rows per page, at most eight pages. A tile's `slot` is its absolute
|
||||
// cell (page * 6 + row * 2 + column); a wide tile starts in the left column and also covers
|
||||
// the cell to its right. Empty cells are allowed and stay exactly where they are.
|
||||
import type { Inventory, Layout, Tile } from "../types";
|
||||
|
||||
export const SLOTS_PER_PAGE = 6;
|
||||
export const MAX_PAGES = 8;
|
||||
export const MAX_SLOTS = MAX_PAGES * SLOTS_PER_PAGE;
|
||||
|
||||
export type Entry = { tile: Tile; slot: number };
|
||||
|
||||
export const isWide = (tile: Tile) => tile.options?.size === "wide";
|
||||
export const cellsOf = (slot: number, wide: boolean) => (wide ? [slot, slot + 1] : [slot]);
|
||||
export const rowStart = (slot: number) => slot - (slot % 2);
|
||||
export const pageOf = (slot: number) => Math.floor(slot / SLOTS_PER_PAGE);
|
||||
export const entriesOf = (layout: Layout): Entry[] => layout.tiles.map((tile) => ({ tile, slot: tile.slot }));
|
||||
|
||||
// In-order packing: the rule before positions existed, and what firmware below 0.2.26 still draws.
|
||||
export function packSlots(tiles: Tile[]) {
|
||||
let position = 0;
|
||||
return tiles.map((tile) => {
|
||||
const wide = isWide(tile);
|
||||
if (wide && position % 2 === 1) position++;
|
||||
const slot = position;
|
||||
position += wide ? 2 : 1;
|
||||
return slot;
|
||||
});
|
||||
}
|
||||
export function hasGaps(tiles: Tile[]) {
|
||||
const packed = packSlots(tiles);
|
||||
return tiles.some((tile, i) => tile.slot !== packed[i]);
|
||||
}
|
||||
// Every tile gets a position (older layouts pack in order) and the list stays in reading order.
|
||||
export function normalize(layout: Layout) {
|
||||
if (layout.tiles.some((t) => !Number.isInteger(t.slot))) {
|
||||
const packed = packSlots(layout.tiles);
|
||||
layout.tiles.forEach((t, i) => (t.slot = packed[i]));
|
||||
}
|
||||
layout.tiles.sort((a, b) => a.slot - b.slot);
|
||||
}
|
||||
export function occupied(entries: Entry[]) {
|
||||
const taken = new Set<number>();
|
||||
for (const { tile, slot } of entries) for (const cell of cellsOf(slot, isWide(tile))) taken.add(cell);
|
||||
return taken;
|
||||
}
|
||||
export const fits = (taken: Set<number>, slot: number, wide: boolean) =>
|
||||
Number.isInteger(slot) && slot >= 0 && slot + (wide ? 1 : 0) < MAX_SLOTS && !(wide && slot % 2) && cellsOf(slot, wide).every((c) => !taken.has(c));
|
||||
export function firstFree(taken: Set<number>, wide: boolean) {
|
||||
for (let slot = 0; slot < MAX_SLOTS; slot++) if (fits(taken, slot, wide)) return slot;
|
||||
return -1;
|
||||
}
|
||||
// The free position closest to `origin`; on a tie the later one, so a nudged tile moves down, not up.
|
||||
export function nearestFree(taken: Set<number>, wide: boolean, origin: number) {
|
||||
let best = -1;
|
||||
for (let slot = 0; slot < MAX_SLOTS; slot++)
|
||||
if (fits(taken, slot, wide) && (best < 0 || Math.abs(slot - origin) <= Math.abs(best - origin))) best = slot;
|
||||
return best;
|
||||
}
|
||||
// The arrangement after putting `moving` (a tile on the grid, or a new one) at `target`:
|
||||
// it lands exactly there; tiles in its way take the cells it left (a swap) or else the
|
||||
// nearest free cell; everything else stays put. Null when the target is off the grid.
|
||||
export function arrange(tiles: Tile[], moving: Tile, target: number): Entry[] | null {
|
||||
const wide = isWide(moving);
|
||||
if (wide) target = rowStart(target);
|
||||
if (!fits(new Set(), target, wide)) return null;
|
||||
const footprint = cellsOf(target, wide);
|
||||
const vacated = tiles.includes(moving) ? cellsOf(moving.slot, wide) : [];
|
||||
const result: Entry[] = [{ tile: moving, slot: target }];
|
||||
const displaced: Tile[] = [];
|
||||
for (const tile of tiles) {
|
||||
if (tile === moving) continue;
|
||||
if (cellsOf(tile.slot, isWide(tile)).some((c) => footprint.includes(c))) displaced.push(tile);
|
||||
else result.push({ tile, slot: tile.slot });
|
||||
}
|
||||
for (const tile of displaced) {
|
||||
const w = isWide(tile), taken = occupied(result);
|
||||
let slot = vacated.map((c) => (w ? rowStart(c) : c)).find((c) => fits(taken, c, w));
|
||||
if (slot === undefined) slot = nearestFree(taken, w, tile.slot);
|
||||
if (slot < 0) return null;
|
||||
result.push({ tile, slot });
|
||||
}
|
||||
return result.sort((a, b) => a.slot - b.slot);
|
||||
}
|
||||
// Pages the tiles need, or more when the user keeps empty pages on purpose (`layout.pages`).
|
||||
export function pageCount(entries: Entry[], wanted = 1) {
|
||||
const last = Math.max(0, ...entries.map(({ tile, slot }) => slot + (isWide(tile) ? 2 : 1)));
|
||||
return Math.min(MAX_PAGES, Math.max(1, Math.ceil(last / SLOTS_PER_PAGE), wanted || 1));
|
||||
}
|
||||
// New tiles start with the card that shows the entity best.
|
||||
export function defaultOptions(id: string): Partial<Tile> {
|
||||
const domain = id.split(".")[0];
|
||||
if (domain === "sun") return { options: { display: "sunpath", size: "wide" } };
|
||||
if (domain === "weather") return { options: { display: "forecast", size: "wide" } };
|
||||
if (domain === "screen") return { options: { display: "digital", size: "wide" } };
|
||||
return {};
|
||||
}
|
||||
export const newTile = (id: string): Tile => ({ entity: id, name: "", slot: -1, ...defaultOptions(id) } as Tile);
|
||||
|
||||
// Same rule as the add-on: only a wide card in the standard layout shows direct controls;
|
||||
// without a choice the domain's first control set applies.
|
||||
export function effectiveControls(tile: Tile, inventory: Inventory): string | null {
|
||||
const domain = tile.entity.split(".")[0], catalogue = inventory.controls?.[domain], o = tile.options || {};
|
||||
if (!catalogue || o.size !== "wide" || (o.display || "standard") !== "standard" || o.inline === "slider") return null;
|
||||
const choice = o.controls ?? catalogue.default;
|
||||
return choice === "none" ? null : choice;
|
||||
}
|
||||
export function controlsLabel(tile: Tile, inventory: Inventory) {
|
||||
const key = effectiveControls(tile, inventory);
|
||||
if (!key) return "none";
|
||||
return inventory.controls?.[tile.entity.split(".")[0]]?.choices.find((c) => c.key === key)?.label.toLocaleLowerCase() || key;
|
||||
}
|
||||
|
||||
export const parseVersion = (v: string | undefined) => (/^(\d+)\.(\d+)\.(\d+)$/.exec(v || "") || []).slice(1).map(Number);
|
||||
export function versionAtLeast(version: string | undefined, minimum: string) {
|
||||
const [a, b] = [parseVersion(version), parseVersion(minimum)];
|
||||
if (a.length !== 3 || b.length !== 3) return false;
|
||||
for (let i = 0; i < 3; i++) if (a[i] !== b[i]) return a[i] > b[i];
|
||||
return true;
|
||||
}
|
||||
export const supportsFirmware = (firmware: string | undefined, major: number, minor: number, patch: number) =>
|
||||
versionAtLeast(firmware, `${major}.${minor}.${patch}`);
|
||||
export function tileLimit(firmware: string | undefined) {
|
||||
const match = parseVersion(firmware);
|
||||
if (match.length !== 3) return 10;
|
||||
return match[0] > 0 || match[1] > 2 || (match[1] === 2 && match[2] >= 7) ? 20 : 10;
|
||||
}
|
||||
export const displayNames: Record<string, string> = {
|
||||
standard: "standard", watch: "large value", forecast: "weather forecast", graph: "graph",
|
||||
digital: "digital clock", analog: "analog clock", sunpath: "sun path",
|
||||
};
|
||||
export const TOGGLE_BEFORE = ["light", "switch", "input_boolean", "fan", "media_player", "climate"];
|
||||
export const SLIDER_DOMAINS = ["light", "fan", "cover", "number", "input_number", "media_player"];
|
||||
|
||||
export const domains: Record<string, [string, string, string, string]> = {
|
||||
light: ["Light", "☀", "#ad7600", "#fff3d3"],
|
||||
climate: ["Climate", "❄", "#c86620", "#ffebdc"],
|
||||
vacuum: ["Vacuum", "◉", "#008577", "#def3ed"],
|
||||
fan: ["Fan", "✣", "#008aab", "#def5fa"],
|
||||
cover: ["Cover", "▤", "#8053af", "#eee5f8"],
|
||||
media_player: ["Media", "▶", "#007cad", "#def2fc"],
|
||||
sensor: ["Sensor", "⌁", "#3476b1", "#e5effa"],
|
||||
binary_sensor: ["Status", "◈", "#ad7600", "#fff3d3"],
|
||||
switch: ["Switch", "⏻", "#ad7600", "#fff3d3"],
|
||||
input_boolean: ["Switch", "⏻", "#ad7600", "#fff3d3"],
|
||||
scene: ["Scene", "✦", "#8053af", "#eee5f8"],
|
||||
script: ["Script", "▷", "#8053af", "#eee5f8"],
|
||||
weather: ["Weather", "☁", "#007cad", "#def2fc"],
|
||||
number: ["Value", "±", "#008577", "#def3ed"],
|
||||
input_number: ["Value", "±", "#008577", "#def3ed"],
|
||||
select: ["Select", "≡", "#5862af", "#eaecfa"],
|
||||
input_select: ["Select", "≡", "#5862af", "#eaecfa"],
|
||||
button: ["Action", "↗", "#5862af", "#eaecfa"],
|
||||
input_button: ["Action", "↗", "#5862af", "#eaecfa"],
|
||||
screen: ["Clock", "◷", "#25282c", "#e9ecf1"],
|
||||
sun: ["Sun", "☼", "#c86620", "#ffebdc"],
|
||||
timer: ["Timer", "⏱", "#008577", "#def3ed"],
|
||||
person: ["Person", "☺", "#2f7d32", "#e1f2e2"],
|
||||
camera: ["Camera", "◧", "#3d4a57", "#e6ebf0"],
|
||||
image: ["Image", "◧", "#3d4a57", "#e6ebf0"],
|
||||
};
|
||||
export const domainInfo = (id: string) => domains[id.split(".")[0]] || (["Entity", "◇", "#637184", "#edf0f4"] as [string, string, string, string]);
|
||||
@@ -0,0 +1,117 @@
|
||||
// ---- The top bar as the screen draws it ----
|
||||
// One rule set with header_bar.h in the firmware, in screen pixels of the board: every value (the
|
||||
// time too) in the same 400 Roboto on the name's baseline; icons and the dial centred on the
|
||||
// height of the digits; the gaps measured between what you see (glyph ink), not between boxes, so
|
||||
// an icon with side bearings sits exactly as close to its value as one without.
|
||||
import type { HeaderItem } from "../types";
|
||||
|
||||
export const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
|
||||
export const WEEKDAYS = ["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"];
|
||||
export const BUILTIN_ICONS: Record<string, string> = { clock: "clock-outline", analog: "clock-outline", date: "calendar" };
|
||||
export const itemKey = (item: HeaderItem) => JSON.stringify(item);
|
||||
export const glyph = (cp: string) => String.fromCodePoint(parseInt(cp, 16));
|
||||
|
||||
export function clockText(clock24: boolean, now = new Date()) {
|
||||
let hours = now.getHours();
|
||||
// The screen formats with %I:%M when the 24-hour clock is off.
|
||||
if (!clock24) hours = hours % 12 || 12;
|
||||
return `${String(hours).padStart(2, "0")}:${String(now.getMinutes()).padStart(2, "0")}`;
|
||||
}
|
||||
export const dateText = (now = new Date()) => `${WEEKDAYS[now.getDay()]} ${now.getDate()} ${MONTHS[now.getMonth()]}`;
|
||||
// Same wording and thresholds as header_bar::ago_text() in the firmware.
|
||||
export function agoText(then: number, now = Math.floor(Date.now() / 1000)) {
|
||||
const seconds = now - then, span = Math.abs(seconds), per = (unit: number) => Math.floor(span / unit);
|
||||
if (seconds < 0) {
|
||||
if (span < 3600) return `In ${Math.max(1, per(60))} min`;
|
||||
if (span < 86400) return per(3600) === 1 ? "In 1 hour" : `In ${per(3600)} hours`;
|
||||
if (span < 172800) return "Tomorrow";
|
||||
return `In ${per(86400)} days`;
|
||||
}
|
||||
if (span < 60) return "Just now";
|
||||
if (span < 3600) return `${per(60)} min ago`;
|
||||
if (span < 86400) return per(3600) === 1 ? "1 hour ago" : `${per(3600)} hours ago`;
|
||||
if (span < 172800) return "Yesterday";
|
||||
if (span < 604800) return `${per(86400)} days ago`;
|
||||
if (span < 2592000) return per(604800) === 1 ? "1 week ago" : `${per(604800)} weeks ago`;
|
||||
if (span < 31536000) return per(2592000) === 1 ? "1 month ago" : `${per(2592000)} months ago`;
|
||||
return per(31536000) === 1 ? "1 year ago" : `${per(31536000)} years ago`;
|
||||
}
|
||||
|
||||
export type BarMetrics = { width: number; top: number; name: number; text: number; icon: number };
|
||||
export const BAR_METRICS: Record<string, BarMetrics> = {
|
||||
guition: { width: 448, top: 36, name: 27, text: 21, icon: 26 },
|
||||
cyd: { width: 298, top: 24, name: 18, text: 14, icon: 18 },
|
||||
};
|
||||
export type ItemView = { icon?: string | null; text?: string; color?: string | null; shown: boolean; analog?: boolean; loading?: boolean };
|
||||
type Ink = { left: number; right: number; top: number; bottom: number; advance: number };
|
||||
|
||||
const measure = document.createElement("canvas").getContext("2d")!;
|
||||
const inkCache = new Map<string, Ink>();
|
||||
export const clearInkCache = () => inkCache.clear();
|
||||
// Ink box of a string relative to its origin on the baseline: left/right, and top (negative, up)/bottom.
|
||||
export function inkOf(text: string, font: string): Ink {
|
||||
const key = `${font}|${text}`;
|
||||
let ink = inkCache.get(key);
|
||||
if (!ink) {
|
||||
measure.font = font;
|
||||
const m = measure.measureText(text);
|
||||
ink = { left: -m.actualBoundingBoxLeft, right: m.actualBoundingBoxRight, top: -m.actualBoundingBoxAscent, bottom: m.actualBoundingBoxDescent, advance: m.width };
|
||||
inkCache.set(key, ink);
|
||||
}
|
||||
return ink;
|
||||
}
|
||||
export const barFonts = (m: BarMetrics) => ({ name: `500 ${m.name}px "Bar Roboto"`, text: `400 ${m.text}px "Bar Roboto"`, icon: `${m.icon}px "Tile Icons"` });
|
||||
// Same integer arithmetic as header_bar::gaps() in the firmware, from the digit height in pixels.
|
||||
export function barGaps(cap: number) {
|
||||
return { icon: Math.max(2, Math.floor((cap * 4 + 5) / 10)), item: Math.max(6, Math.floor((cap * 125 + 50) / 100)), name: Math.max(8, Math.floor((cap * 16 + 5) / 10)) };
|
||||
}
|
||||
export type BarPart = {
|
||||
index: number; item: HeaderItem; view: ItemView; shown: boolean; width: number; x?: number;
|
||||
dial?: number; icon?: { glyph: string; ink: Ink }; text?: { value: string; ink: Ink };
|
||||
};
|
||||
export type BarLayout = ReturnType<typeof barLayout>;
|
||||
// The parts per item with their ink widths, the placement, and which items fall off.
|
||||
export function barLayout(items: HeaderItem[], metrics: BarMetrics, nameText: string, viewOf: (item: HeaderItem) => ItemView) {
|
||||
const fonts = barFonts(metrics);
|
||||
// The firmware reads the digit height as a whole number of pixels (the glyph box of "0").
|
||||
const zero = inkOf("0", fonts.text), cap = Math.round(zero.bottom - zero.top), gaps = barGaps(cap);
|
||||
const dialInk = inkOf(String.fromCodePoint(0xf0150), fonts.icon), dial = Math.round(dialInk.bottom - dialInk.top);
|
||||
const parts: BarPart[] = items.map((item, index) => {
|
||||
const view = viewOf(item);
|
||||
const part: BarPart = { index, item, view, shown: view.shown, width: 0 };
|
||||
if (view.analog) { part.dial = dial; part.width = dial; return part; }
|
||||
if (view.icon) { part.icon = { glyph: glyph(view.icon), ink: inkOf(glyph(view.icon), fonts.icon) }; part.width += part.icon.ink.right - part.icon.ink.left; }
|
||||
if (view.text) {
|
||||
part.text = { value: view.text, ink: inkOf(view.text, fonts.text) };
|
||||
part.width += (part.icon ? gaps.icon : 0) + part.text.ink.right - part.text.ink.left;
|
||||
}
|
||||
return part;
|
||||
});
|
||||
const shown = parts.filter((p) => p.shown);
|
||||
const natural = inkOf(nameText, fonts.name).advance;
|
||||
const minName = Math.min(natural, Math.floor((metrics.width * 35) / 100));
|
||||
const total = (list: BarPart[]) => list.reduce((sum, p) => sum + p.width, 0) + Math.max(0, list.length - 1) * gaps.item;
|
||||
let first = 0;
|
||||
while (first < shown.length && total(shown.slice(first)) + gaps.name + minName > metrics.width) first++;
|
||||
const placed = shown.slice(first), dropped = new Set(shown.slice(0, first).map((p) => p.index));
|
||||
let x = metrics.width - total(placed);
|
||||
for (const p of placed) { p.x = x; x += p.width + gaps.item; }
|
||||
const nameRoom = placed.length ? placed[0].x! - gaps.name : metrics.width;
|
||||
return { metrics, fonts, cap, zero, gaps, parts, placed, dropped, nameText, natural, nameRoom };
|
||||
}
|
||||
// LVGL's LV_LABEL_LONG_DOT: the longest start that fits with "..." after it.
|
||||
export function dotted(text: string, font: string, room: number) {
|
||||
if (inkOf(text, font).advance <= room) return text;
|
||||
const chars = [...text];
|
||||
while (chars.length && inkOf(chars.join("") + "...", font).advance > room) chars.pop();
|
||||
return chars.join("") + "...";
|
||||
}
|
||||
// The fonts load on first use; measurements before that are wrong, so draw again once they are in.
|
||||
export function whenBarFontsLoad(then: () => void) {
|
||||
if (!("fonts" in document)) return;
|
||||
Promise.all([
|
||||
document.fonts.load('500 27px "Bar Roboto"', "Studio 0"),
|
||||
document.fonts.load('400 21px "Bar Roboto"', "Away 0"),
|
||||
document.fonts.load('26px "Tile Icons"', String.fromCodePoint(0xf0150)),
|
||||
]).then(() => { clearInkCache(); then(); }).catch(() => {});
|
||||
}
|
||||
@@ -0,0 +1,623 @@
|
||||
// One reactive state for the whole editor. The Python API (server.py) is unchanged: this file is the
|
||||
// former app.js state and its calls, with the DOM work moved into the components.
|
||||
import { computed, reactive } from "vue";
|
||||
import { api, getJson, send, setCsrf } from "./api";
|
||||
import {
|
||||
arrange, entriesOf, firstFree, fits, isWide, MAX_PAGES, nearestFree, newTile, normalize, occupied, pageCount, pageOf,
|
||||
rowStart, SLOTS_PER_PAGE, supportsFirmware as supportsVersion, tileLimit as limitFor,
|
||||
} from "./model/layout";
|
||||
import { agoText, BAR_METRICS, clockText, dateText, itemKey, type ItemView, whenBarFontsLoad } from "./model/topbar";
|
||||
import type { Capability, EntityAction, HeaderItem, Inventory, Layout, Screen, Tile } from "./types";
|
||||
|
||||
export type Inspector =
|
||||
| { kind: "tile" }
|
||||
| { kind: "bar"; index: number }
|
||||
| { kind: "bar-add" }
|
||||
| { kind: "inspect"; entity?: string };
|
||||
export type DragState = { active: boolean; moving: Tile | null; preview: { tile: Tile; slot: number }[] | null };
|
||||
|
||||
export const state = reactive({
|
||||
inventory: { screens: [], entities: [] } as Inventory,
|
||||
connected: false,
|
||||
reachable: true,
|
||||
selected: null as string | null,
|
||||
layout: null as Layout | null,
|
||||
dirty: false,
|
||||
busy: false,
|
||||
saved: 0,
|
||||
tab: "layout" as "layout" | "settings",
|
||||
selectedTile: null as string | null,
|
||||
inspector: null as Inspector | null,
|
||||
iconPickerOpen: false,
|
||||
actionPickerOpen: false,
|
||||
actionSearch: "",
|
||||
insertAt: -1,
|
||||
filter: "",
|
||||
search: "",
|
||||
capabilities: {} as Record<string, Capability | null>,
|
||||
entityActions: {} as Record<string, EntityAction[] | null | undefined>,
|
||||
topbarPreviews: {} as Record<string, any>,
|
||||
topbarAdded: null as null | { key: string; time: number },
|
||||
topbarOverflow: [] as number[],
|
||||
settingEdits: {} as Record<string, { value: any; at: number }>,
|
||||
settingPending: false,
|
||||
updating: [] as string[],
|
||||
toast: null as null | { message: string; action?: { label: string; run: () => void } },
|
||||
now: Date.now(),
|
||||
fontsVersion: 0,
|
||||
route: location.hash,
|
||||
overrideProfile: null as string | null,
|
||||
overrideFriendly: "",
|
||||
drag: { active: false, moving: null, preview: null } as DragState,
|
||||
menuOpen: false,
|
||||
});
|
||||
|
||||
export const currentScreen = computed<Screen | undefined>(() => state.inventory.screens.find((s) => s.id === state.selected));
|
||||
export const firmwareOf = computed(() => currentScreen.value?.firmware || "");
|
||||
export const supports = (major: number, minor: number, patch: number) => supportsVersion(firmwareOf.value, major, minor, patch);
|
||||
export const tileLimit = computed(() => limitFor(firmwareOf.value));
|
||||
export const isGuition = computed(() => currentScreen.value?.board === "guition");
|
||||
export const barMetrics = computed(() => BAR_METRICS[isGuition.value ? "guition" : "cyd"]);
|
||||
export const currentTile = computed<Tile | undefined>(() =>
|
||||
state.selectedTile && state.layout ? state.layout.tiles.find((t) => t.entity === state.selectedTile) : undefined);
|
||||
|
||||
// ---- Toasts ----
|
||||
let toastTimer = 0;
|
||||
export function toast(message: string, action?: { label: string; run: () => void }) {
|
||||
state.toast = { message, action };
|
||||
clearTimeout(toastTimer);
|
||||
toastTimer = window.setTimeout(() => (state.toast = null), action ? 8000 : 5000);
|
||||
}
|
||||
export function dismissToast() {
|
||||
state.toast = null;
|
||||
}
|
||||
export async function copyText(text: string, element?: Element | null, what = "API key") {
|
||||
try {
|
||||
if (!navigator.clipboard || !window.isSecureContext) throw new Error();
|
||||
await navigator.clipboard.writeText(text);
|
||||
toast(`${what} copied.`);
|
||||
} catch {
|
||||
if (element) {
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(element);
|
||||
const selection = window.getSelection();
|
||||
selection?.removeAllRanges();
|
||||
selection?.addRange(range);
|
||||
}
|
||||
toast(document.execCommand("copy") ? `${what} copied.` : `${what} is selected. Copy with Ctrl+C or Command+C.`);
|
||||
}
|
||||
}
|
||||
export function openIntegrations() {
|
||||
// Pairing happens in Home Assistant itself. This page lives in HA's ingress iframe,
|
||||
// so send the top window to Devices & services (same origin); elsewhere open a tab.
|
||||
const path = "/config/integrations/dashboard";
|
||||
try {
|
||||
window.top!.location.assign(path);
|
||||
} catch {
|
||||
window.open(path, "_blank");
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Routes: the hash keeps a view open across a reload (#settings did before) ----
|
||||
export const routes = ["", "#settings", "#new-screen", "#firmware", "#alerts", "#override"] as const;
|
||||
export type Route = (typeof routes)[number];
|
||||
export const route = computed<Route>(() => (routes.includes(state.route as Route) ? (state.route as Route) : ""));
|
||||
export function go(target: Route) {
|
||||
if (location.hash === target) { state.route = target; return; }
|
||||
location.hash = target;
|
||||
}
|
||||
window.addEventListener("hashchange", () => { state.route = location.hash; window.scrollTo(0, 0); });
|
||||
|
||||
// ---- Names and icons ----
|
||||
export function entityName(id: string) {
|
||||
return state.inventory.entities.find((e) => e.id === id)?.name || state.inventory.builtin?.find((e) => e.id === id)?.name || id;
|
||||
}
|
||||
let iconIndex: { source: unknown; byName: Record<string, { name: string; cp: string; label: string }> } = { source: null, byName: {} };
|
||||
export function iconNamed(name: string | undefined) {
|
||||
if (iconIndex.source !== state.inventory.icons)
|
||||
iconIndex = { source: state.inventory.icons, byName: Object.fromEntries((state.inventory.icons?.groups || []).flatMap((g) => g.icons.map((i) => [i.name, i]))) };
|
||||
return name ? iconIndex.byName[name] : undefined;
|
||||
}
|
||||
// What the firmware draws without a choice: Home Assistant's own icon, else the domain icon.
|
||||
export function automaticIcon(id: string): string {
|
||||
const icons = state.inventory.icons;
|
||||
if (!icons) return "F0335";
|
||||
const entity = state.inventory.entities.find((e) => e.id === id), domain = id.split(".")[0];
|
||||
if (icons.builtin?.[id]) return icons.builtin[id];
|
||||
if (entity?.icon) return entity.icon;
|
||||
if (domain === "weather") return icons.weather[entity?.state || ""] || icons.weather.partlycloudy;
|
||||
if (domain === "sun") return icons.sun[entity?.state || ""] || icons.sun.below_horizon;
|
||||
return icons.defaults[domain] || icons.fallback;
|
||||
}
|
||||
export const tileIconCp = (tile: Tile) => iconNamed(tile.options?.icon)?.cp || automaticIcon(tile.entity);
|
||||
|
||||
// ---- Capabilities and actions from Home Assistant ----
|
||||
const askedCapabilities = new Set<string>();
|
||||
export async function loadCapabilities(entities: string[]) {
|
||||
const wanted = [...new Set(entities)].filter((id) => !askedCapabilities.has(id) && !id.startsWith("screen."));
|
||||
if (!wanted.length) return;
|
||||
wanted.forEach((id) => askedCapabilities.add(id));
|
||||
try {
|
||||
for (let i = 0; i < wanted.length; i += 40) {
|
||||
const query = wanted.slice(i, i + 40).map((id) => `entity=${encodeURIComponent(id)}`).join("&");
|
||||
Object.assign(state.capabilities, (await getJson(`capabilities?${query}`)).capabilities || {});
|
||||
}
|
||||
} catch {
|
||||
wanted.forEach((id) => askedCapabilities.delete(id));
|
||||
}
|
||||
}
|
||||
const askedActions = new Set<string>();
|
||||
export async function loadEntityActions(entity: string) {
|
||||
if (askedActions.has(entity)) return;
|
||||
askedActions.add(entity);
|
||||
try {
|
||||
state.entityActions[entity] = (await getJson(`entity-actions?entity=${encodeURIComponent(entity)}`)).actions;
|
||||
} catch {
|
||||
askedActions.delete(entity);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Selecting a screen and editing its layout ----
|
||||
export function markDirty() {
|
||||
state.dirty = true;
|
||||
state.saved = 0;
|
||||
}
|
||||
export function select(id: string | null) {
|
||||
if (id !== state.selected && state.dirty && !confirm("You have unsaved changes. Open a different screen anyway?")) return;
|
||||
if (id !== state.selected) {
|
||||
flushSettings();
|
||||
state.settingEdits = {};
|
||||
}
|
||||
state.selected = id;
|
||||
state.selectedTile = null;
|
||||
state.inspector = null;
|
||||
state.tab = "layout";
|
||||
state.menuOpen = false;
|
||||
const screen = state.inventory.screens.find((s) => s.id === id);
|
||||
if (!screen) { state.layout = null; return; }
|
||||
// A plain copy: the inventory is reactive, and structuredClone refuses a proxy.
|
||||
state.layout = JSON.parse(JSON.stringify(screen.layout));
|
||||
normalize(state.layout);
|
||||
state.layout.pages = pageCount(entriesOf(state.layout), state.layout.pages);
|
||||
state.insertAt = -1;
|
||||
state.dirty = false;
|
||||
state.saved = 0;
|
||||
loadTopbarPreview(0);
|
||||
loadCapabilities(state.layout.tiles.map((t) => t.entity));
|
||||
go("");
|
||||
}
|
||||
export const liveEntries = () => (state.layout ? entriesOf(state.layout) : []);
|
||||
// Apply an arrangement; a new tile joins the layout. True when anything changed.
|
||||
function commit(result: { tile: Tile; slot: number }[]) {
|
||||
const layout = state.layout!;
|
||||
const before = layout.tiles.map((t) => `${t.entity}@${t.slot}`).join();
|
||||
for (const { tile, slot } of result) { tile.slot = slot; if (!layout.tiles.includes(tile)) layout.tiles.push(tile); }
|
||||
normalize(layout);
|
||||
layout.pages = pageCount(entriesOf(layout), layout.pages);
|
||||
const changed = layout.tiles.map((t) => `${t.entity}@${t.slot}`).join() !== before;
|
||||
if (changed) markDirty();
|
||||
return changed;
|
||||
}
|
||||
export function placeTile(tile: Tile, target: number) {
|
||||
if (!state.layout) return false;
|
||||
loadCapabilities([tile.entity]);
|
||||
const result = arrange(state.layout.tiles, tile, target);
|
||||
return result ? commit(result) : false;
|
||||
}
|
||||
// A click in the picker: the marked empty cell, else the first free cell.
|
||||
export function addTile(id: string) {
|
||||
const layout = state.layout;
|
||||
if (!layout || layout.tiles.some((t) => t.entity === id) || layout.tiles.length >= tileLimit.value) return;
|
||||
const tile = newTile(id);
|
||||
const slot = state.insertAt >= 0 ? state.insertAt : firstFree(occupied(entriesOf(layout)), isWide(tile));
|
||||
state.insertAt = -1;
|
||||
if (slot >= 0 && placeTile(tile, slot)) openTile(id);
|
||||
}
|
||||
export function removeTile(tile: Tile) {
|
||||
const layout = state.layout;
|
||||
if (!layout) return;
|
||||
const index = layout.tiles.indexOf(tile);
|
||||
if (index < 0) return;
|
||||
layout.tiles.splice(index, 1);
|
||||
if (state.selectedTile === tile.entity) closeInspector();
|
||||
markDirty();
|
||||
layout.pages = pageCount(entriesOf(layout), layout.pages);
|
||||
toast(`${tile.name || entityName(tile.entity)} removed`, { label: "Undo", run: () => placeTile(tile, tile.slot) });
|
||||
}
|
||||
export function addPage() {
|
||||
const layout = state.layout;
|
||||
if (!layout) return;
|
||||
layout.pages = Math.min(MAX_PAGES, pageCount(entriesOf(layout), layout.pages) + 1);
|
||||
markDirty();
|
||||
}
|
||||
// An empty page goes; the pages after it move up.
|
||||
export function removePage(page: number) {
|
||||
const layout = state.layout;
|
||||
if (!layout) return;
|
||||
for (const tile of layout.tiles) if (pageOf(tile.slot) > page) tile.slot -= SLOTS_PER_PAGE;
|
||||
layout.pages = Math.max(1, pageCount(entriesOf(layout), layout.pages) - 1);
|
||||
markDirty();
|
||||
}
|
||||
export function pagesShown() {
|
||||
const layout = state.layout;
|
||||
if (!layout) return 1;
|
||||
const entries = state.drag.preview || entriesOf(layout);
|
||||
const pages = pageCount(entries, layout.pages);
|
||||
// While dragging, one more page waits after the last one.
|
||||
return state.drag.active && pages < MAX_PAGES ? pages + 1 : pages;
|
||||
}
|
||||
// The tile's options change live; a card that becomes double-wide keeps its row when the cell beside it is
|
||||
// free, else it takes the nearest free row (below first); every other tile stays where it is.
|
||||
export function setTileOption(tile: Tile, key: string, value: unknown) {
|
||||
const domain = tile.entity.split(".")[0], caps = state.capabilities[tile.entity], wasWide = isWide(tile);
|
||||
tile.options = { ...tile.options, [key]: value };
|
||||
// Direct controls need the standard layout without a mini slider, and vice versa.
|
||||
if (key === "display" && value === "watch") { tile.options.inline = "none"; if (state.inventory.controls?.[domain]) tile.options.controls = "none"; }
|
||||
if (key === "display" && ["forecast", "sunpath"].includes(value as string)) tile.options.size = "wide";
|
||||
if (key === "inline" && value === "slider") { tile.options.display = "standard"; if (state.inventory.controls?.[domain]) tile.options.controls = "none"; }
|
||||
if (key === "controls" && value !== "none") { tile.options.display = "standard"; tile.options.inline = "none"; }
|
||||
// A card that becomes wide gets the first direct control Home Assistant offers when the usual one isn't there.
|
||||
const catalogue = state.inventory.controls?.[domain];
|
||||
if (key === "size" && value === "wide" && caps && catalogue && !("controls" in tile.options) && !caps.controls.includes(catalogue.default))
|
||||
tile.options.controls = catalogue.choices.find((c) => c.key !== "none" && caps.controls.includes(c.key))?.key || "none";
|
||||
markDirty();
|
||||
if (state.layout && isWide(tile) && !wasWide) {
|
||||
const taken = occupied(entriesOf(state.layout).filter((e) => e.tile !== tile)), own = rowStart(tile.slot);
|
||||
const slot = fits(taken, own, true) ? own : nearestFree(taken, true, own);
|
||||
if (slot >= 0) tile.slot = slot;
|
||||
normalize(state.layout);
|
||||
state.layout.pages = pageCount(entriesOf(state.layout), state.layout.pages);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Inspector (the drawer) ----
|
||||
export function openTile(entity: string) {
|
||||
if (state.selectedTile !== entity) { state.iconPickerOpen = false; state.actionPickerOpen = false; state.actionSearch = ""; }
|
||||
state.selectedTile = entity;
|
||||
state.inspector = { kind: "tile" };
|
||||
loadCapabilities([entity]);
|
||||
}
|
||||
export function openBar(index: number) {
|
||||
if (!(state.inspector?.kind === "bar" && state.inspector.index === index)) state.iconPickerOpen = false;
|
||||
state.selectedTile = null;
|
||||
state.inspector = { kind: "bar", index };
|
||||
}
|
||||
export function openBarAdd() {
|
||||
state.selectedTile = null;
|
||||
state.inspector = { kind: "bar-add" };
|
||||
}
|
||||
export function closeInspector() {
|
||||
state.inspector = null;
|
||||
state.selectedTile = null;
|
||||
}
|
||||
|
||||
// ---- Save ----
|
||||
export async function save() {
|
||||
if (state.busy || !state.layout || !state.selected) return;
|
||||
state.busy = true;
|
||||
try {
|
||||
// Screen settings apply on their own (flushSettings); the stored ones stay as they are.
|
||||
const { settings: _settings, ...tiles } = state.layout;
|
||||
await send(`screens/${encodeURIComponent(state.selected)}`, "PUT", tiles);
|
||||
state.dirty = false;
|
||||
state.saved = Date.now();
|
||||
toast("Saved. Your screen is being updated.");
|
||||
await refresh();
|
||||
} catch (e: any) {
|
||||
toast(e.message);
|
||||
} finally {
|
||||
state.busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Top bar ----
|
||||
// Without a stored top bar the screen shows what it always did: the clock of show_clock.
|
||||
export const topbarItems = (): HeaderItem[] =>
|
||||
state.layout?.header?.items ?? ((state.layout?.settings?.show_clock ?? true) ? [{ type: "clock" }] : []);
|
||||
export const topbarMax = () => state.inventory.header?.max_items || 6;
|
||||
export function setTopbarItems(items: HeaderItem[]) {
|
||||
if (!state.layout) return;
|
||||
state.layout.header = { items };
|
||||
markDirty();
|
||||
loadTopbarPreview();
|
||||
}
|
||||
let topbarTimer = 0;
|
||||
// Entity text as the screen will show it, for the items not previewed yet.
|
||||
export function loadTopbarPreview(delay = 150) {
|
||||
clearTimeout(topbarTimer);
|
||||
topbarTimer = window.setTimeout(async () => {
|
||||
const items = topbarItems();
|
||||
if (!items.some((item) => item.type === "entity")) return;
|
||||
try {
|
||||
const data = await send("header-preview", "POST", { header: { items } });
|
||||
items.forEach((item, i) => (state.topbarPreviews[itemKey(item)] = data.items[i]));
|
||||
} catch {
|
||||
// Keep the last preview; the next edit or refresh tries again.
|
||||
}
|
||||
}, delay);
|
||||
}
|
||||
export function topbarLabel(item: HeaderItem) {
|
||||
if (item.type === "entity") return entityName(item.entity!);
|
||||
return state.inventory.header?.builtin.find((b) => b.type === item.type)?.label || item.type;
|
||||
}
|
||||
// What the item shows right now: { icon, text, color, shown }. Entities wait for the add-on's preview.
|
||||
export function topbarView(item: HeaderItem): ItemView {
|
||||
const now = new Date(state.now);
|
||||
if (item.type === "clock") return { text: clockText(settingValues().clock_24h !== false, now), shown: true };
|
||||
if (item.type === "date") return { text: dateText(now), shown: true };
|
||||
if (item.type === "analog") return { analog: true, shown: true };
|
||||
const p = state.topbarPreviews[itemKey(item)];
|
||||
if (!p) return { icon: item.icon === "none" ? null : iconNamed(item.icon)?.cp || automaticIcon(item.entity!), text: "…", shown: true, loading: true };
|
||||
return { icon: p.i || null, text: p.k === "ago" ? agoText(p.e, Math.floor(state.now / 1000)) : p.t, color: p.c ? `#${p.c}` : null, shown: p.shown };
|
||||
}
|
||||
export function moveTopbarItem(from: number, to: number) {
|
||||
const items = [...topbarItems()];
|
||||
if (to < 0 || to >= items.length || from === to) return false;
|
||||
items.splice(to, 0, ...items.splice(from, 1));
|
||||
setTopbarItems(items);
|
||||
return true;
|
||||
}
|
||||
export function removeTopbarItem(index: number) {
|
||||
const items = [...topbarItems()];
|
||||
const [item] = items.splice(index, 1);
|
||||
if (!item) return;
|
||||
if (state.inspector?.kind === "bar") closeInspector();
|
||||
setTopbarItems(items);
|
||||
toast(`${topbarLabel(item)} removed from the top bar`, {
|
||||
label: "Undo",
|
||||
run: () => { const back = [...topbarItems()]; back.splice(Math.min(index, back.length), 0, item); setTopbarItems(back); },
|
||||
});
|
||||
}
|
||||
export function addTopbarItem(item: HeaderItem) {
|
||||
const items = topbarItems();
|
||||
if (items.length >= topbarMax()) return toast(`The top bar has room for ${topbarMax()} items.`);
|
||||
if (items.some((other) => itemKey(other) === itemKey(item))) return toast("This is already in the top bar.");
|
||||
// The new chip lights up briefly so the eye finds it.
|
||||
state.topbarAdded = { key: itemKey(item), time: Date.now() };
|
||||
setTopbarItems([...items, item]);
|
||||
openBar(items.length);
|
||||
}
|
||||
|
||||
// ---- 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. A screen with firmware 0.2.49+ owns its
|
||||
// settings and ESP Screens changes them through its entities in Home Assistant.
|
||||
export const SETTING_GROUPS = [
|
||||
{ title: "Brightness", icon: "F0599", rows: [
|
||||
{ key: "brightness", label: "Brightness", kind: "number", min: 5, max: 100, step: 5, unit: "%" },
|
||||
{ key: "dark_mode", label: "Dark mode", kind: "toggle" },
|
||||
{ key: "standby_enabled", label: "Auto standby", kind: "toggle" },
|
||||
{ key: "standby_seconds", label: "Standby after", kind: "duration", min: 60, max: 86400, needs: "standby_enabled" },
|
||||
{ key: "standby_brightness", label: "Standby brightness", kind: "number", min: 0, max: 100, step: 5, unit: "%", needs: "standby_enabled", cap: "brightness" },
|
||||
] },
|
||||
{ title: "Night", icon: "F0594", rows: [
|
||||
{ key: "night_enabled", label: "Night mode", kind: "toggle" },
|
||||
{ key: "night_start", label: "Starts", kind: "moment", needs: "night_enabled" },
|
||||
{ key: "night_end", label: "Ends", kind: "moment", needs: "night_enabled" },
|
||||
{ key: "night_brightness", label: "Night brightness", kind: "number", min: 0, max: 100, step: 5, unit: "%", needs: "night_enabled", cap: "brightness" },
|
||||
] },
|
||||
{ title: "Screen", icon: "F0379", rows: [
|
||||
{ key: "clock_24h", label: "Clock", kind: "choice", options: [[false, "12 hour"], [true, "24 hour"]] },
|
||||
{ key: "auto_home", label: "Back to page 1", kind: "toggle" },
|
||||
{ key: "auto_home_seconds", label: "After", kind: "duration", min: 30, max: 3600, needs: "auto_home" },
|
||||
{ key: "home_on_standby", label: "Also on standby", kind: "toggle" },
|
||||
{ key: "swipe_pages", label: "Swipe between pages", kind: "toggle" },
|
||||
{ key: "rotation", label: "Rotation", kind: "choice", options: [[0, "0°"], [90, "90°"], [180, "180°"], [270, "270°"]] },
|
||||
] },
|
||||
] as const;
|
||||
export type SettingRow = (typeof SETTING_GROUPS)[number]["rows"][number] & { min?: number; max?: number; step?: number; unit?: string; needs?: string; cap?: string; options?: readonly (readonly [unknown, string])[] };
|
||||
// Changes made here that the screen has not reported back yet win over what Home Assistant still shows for a
|
||||
// few seconds, so a value never flicks back while it travels.
|
||||
const SETTING_EDIT_MS = 4000;
|
||||
let settingQueue: Record<string, any> = {}, settingTarget: string | null = null, settingTimer = 0, settingFlight: Promise<Response> | null = null;
|
||||
export const settingsView = () => currentScreen.value?.settings;
|
||||
export function settingValues(): Record<string, any> {
|
||||
const view = settingsView(), values = { ...(view?.values || {}) };
|
||||
for (const [key, edit] of Object.entries(state.settingEdits)) values[key] = edit.value;
|
||||
return values;
|
||||
}
|
||||
// The same steps as settings_screen.h: seconds low down, quarters of an hour up top; times by the quarter,
|
||||
// whole hours while held.
|
||||
export const ladderStep = (seconds: number) => (seconds < 300 ? 30 : seconds < 900 ? 60 : seconds < 3600 ? 300 : seconds < 7200 ? 900 : 1800);
|
||||
export function steppedSetting(row: SettingRow, value: number, direction: number, held: boolean, values: Record<string, any>) {
|
||||
if (row.kind === "moment") {
|
||||
let next = held && value % 60 ? Math.floor(value / 60) * 60 + (direction > 0 ? 60 : 0) : value + direction * (held ? 60 : 15);
|
||||
next %= 1440;
|
||||
return next < 0 ? next + 1440 : next;
|
||||
}
|
||||
const step = row.kind === "duration" ? ladderStep(direction < 0 ? value - 1 : value) : row.step!;
|
||||
const max = row.cap ? Math.min(row.max!, values[row.cap]) : row.max!;
|
||||
return Math.min(max, Math.max(row.min!, value + direction * step));
|
||||
}
|
||||
export function durationText(seconds: number) {
|
||||
if (seconds < 60) return `${seconds} sec`;
|
||||
if (seconds < 3600) return `${Math.floor(seconds / 60)} min`;
|
||||
const hours = Math.floor(seconds / 3600), minutes = Math.floor((seconds % 3600) / 60);
|
||||
return minutes ? `${hours} h ${String(minutes).padStart(2, "0")}` : `${hours} h`;
|
||||
}
|
||||
export function momentText(minutes: number, clock24: boolean) {
|
||||
const hour = Math.floor(minutes / 60), minute = String(minutes % 60).padStart(2, "0");
|
||||
return clock24 ? `${String(hour).padStart(2, "0")}:${minute}` : `${hour % 12 || 12}:${minute} ${hour < 12 ? "AM" : "PM"}`;
|
||||
}
|
||||
export function settingText(row: SettingRow, values: Record<string, any>) {
|
||||
const value = values[row.key];
|
||||
// Home Assistant has no value while the screen is offline or the entity is off.
|
||||
if (value === null || value === undefined) return "—";
|
||||
if (row.kind === "number") return `${value}${row.unit || ""}`;
|
||||
if (row.kind === "duration") return durationText(value);
|
||||
if (row.kind === "moment") return momentText(value, values.clock_24h !== false);
|
||||
return "";
|
||||
}
|
||||
export function setSetting(key: string, value: any, delay: number) {
|
||||
// One screen's changes at a time: the ones for the screen shown before go out first.
|
||||
if (settingTarget && settingTarget !== state.selected && Object.keys(settingQueue).length) {
|
||||
flushSettings();
|
||||
toast("Still saving the other screen's settings. Try again in a moment.");
|
||||
return;
|
||||
}
|
||||
settingTarget = state.selected;
|
||||
const values = settingValues();
|
||||
state.settingEdits[key] = { value, at: Date.now() };
|
||||
settingQueue[key] = value;
|
||||
// A lower brightness pulls both dim levels down with it, as on the screen.
|
||||
if (key === "brightness")
|
||||
for (const dim of ["standby_brightness", "night_brightness"])
|
||||
if (values[dim] > value) state.settingEdits[dim] = { value, at: Date.now() };
|
||||
state.settingPending = true;
|
||||
clearTimeout(settingTimer);
|
||||
settingTimer = window.setTimeout(() => flushSettings(), delay);
|
||||
}
|
||||
export async function flushSettings(unloading = false) {
|
||||
clearTimeout(settingTimer);
|
||||
if (settingFlight || !Object.keys(settingQueue).length || !settingTarget) return;
|
||||
const screen = settingTarget, changes = settingQueue;
|
||||
settingQueue = {};
|
||||
const request = api(`screens/${encodeURIComponent(screen)}/settings`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ settings: changes }),
|
||||
keepalive: unloading,
|
||||
});
|
||||
settingFlight = request;
|
||||
try {
|
||||
const view = await (await request).json();
|
||||
const current = state.inventory.screens.find((s) => s.id === screen);
|
||||
if (current) current.settings = view;
|
||||
} catch (e: any) {
|
||||
toast(e.message);
|
||||
// What did not arrive is not kept: the panel shows the screen's own values again.
|
||||
if (screen === state.selected) for (const key of Object.keys(changes)) delete state.settingEdits[key];
|
||||
if (screen === state.selected && changes.brightness !== undefined) for (const dim of ["standby_brightness", "night_brightness"]) delete state.settingEdits[dim];
|
||||
} finally {
|
||||
settingFlight = null;
|
||||
if (Object.keys(settingQueue).length) settingTimer = window.setTimeout(() => flushSettings(), 150);
|
||||
else settingTarget = null;
|
||||
state.settingPending = Boolean(Object.keys(settingQueue).length);
|
||||
if (screen === state.selected) settleSettings();
|
||||
// A value the screen refused or clamped comes back without a live update: look again once edits expire.
|
||||
setTimeout(() => { if (screen === state.selected) settleSettings(); }, SETTING_EDIT_MS + 100);
|
||||
}
|
||||
}
|
||||
// Values Home Assistant reports take over again once they match a change made here, or after a few seconds
|
||||
// (the screen refused or clamped it).
|
||||
export function settleSettings() {
|
||||
const view = settingsView();
|
||||
for (const [key, edit] of Object.entries(state.settingEdits)) {
|
||||
if (settingQueue[key] !== undefined || settingFlight) continue;
|
||||
if ((view && view.values[key] === edit.value) || Date.now() - edit.at > SETTING_EDIT_MS) delete state.settingEdits[key];
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Updates ----
|
||||
export const PHASES: Record<string, string> = {
|
||||
install: "Building and installing…",
|
||||
verify: "Waiting for the screen to come back…",
|
||||
settle: "Checking that it stays stable…",
|
||||
};
|
||||
export async function startUpdate(screen: Screen, host?: string) {
|
||||
state.updating.push(screen.id);
|
||||
try {
|
||||
await send(`screens/${encodeURIComponent(screen.id)}/update`, "POST", host ? { host } : {});
|
||||
await refresh();
|
||||
} catch (e: any) {
|
||||
state.updating = state.updating.filter((id) => id !== screen.id);
|
||||
toast(e.message);
|
||||
}
|
||||
}
|
||||
export async function runUpdateAll() {
|
||||
try {
|
||||
await send("updates/run", "POST");
|
||||
await refresh();
|
||||
} catch (e: any) {
|
||||
toast(e.message);
|
||||
}
|
||||
}
|
||||
export async function setAutoUpdate(auto: boolean) {
|
||||
try {
|
||||
await send("updates", "PUT", { auto });
|
||||
if (state.inventory.updates) state.inventory.updates.auto = auto;
|
||||
toast(auto ? "Screens will now update automatically at night." : "Automatic updates are off.");
|
||||
} catch (e: any) {
|
||||
toast(e.message);
|
||||
}
|
||||
}
|
||||
export async function installClaudeSkill() {
|
||||
try {
|
||||
state.inventory.claude_skill = await send("claude-skill", "POST");
|
||||
toast(state.inventory.claude_skill?.restart
|
||||
? "Skill installed. Restart Claude Code once so it finds the new skills folder."
|
||||
: "Skill installed. Claude Code picks it up right away.");
|
||||
} catch (e: any) {
|
||||
toast(e.message);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Inventory: full catalogue, light polls, and the live stream ----
|
||||
export async function refresh(full = true) {
|
||||
try {
|
||||
const data = await getJson(full ? "inventory" : "inventory?light=1");
|
||||
// A light poll carries only screens and update status; keep the catalogues we have.
|
||||
state.inventory = full ? data : { ...state.inventory, ...data };
|
||||
if (data.csrf) setCsrf(data.csrf);
|
||||
state.connected = Boolean(state.inventory.connected);
|
||||
state.reachable = true;
|
||||
for (const screen of state.inventory.screens) if (screen.update?.state === "running") state.updating = state.updating.filter((id) => id !== screen.id);
|
||||
if (state.selected) settleSettings();
|
||||
} catch {
|
||||
state.reachable = false;
|
||||
}
|
||||
}
|
||||
function applyLive(data: Partial<Inventory>) {
|
||||
state.inventory = { ...state.inventory, ...data } as Inventory;
|
||||
state.connected = Boolean(state.inventory.connected);
|
||||
for (const screen of state.inventory.screens) if (screen.update?.state === "running") state.updating = state.updating.filter((id) => id !== screen.id);
|
||||
if (state.selected) settleSettings();
|
||||
}
|
||||
let pollTimer = 0, lastFull = Date.now(), live = false, stream: EventSource | null = null;
|
||||
function listen() {
|
||||
if (stream || typeof EventSource === "undefined") return;
|
||||
stream = new EventSource("api/events");
|
||||
stream.onopen = () => { live = true; poll(); };
|
||||
stream.onmessage = (e) => { if (!document.hidden) applyLive(JSON.parse(e.data)); };
|
||||
stream.onerror = () => { live = false; poll(); };
|
||||
}
|
||||
// Poll only while the tab is visible; a hidden tab would otherwise keep the add-on busy.
|
||||
// Live updates arrive over server-sent events; polling is the fallback while the stream is down,
|
||||
// plus a full catalogue refresh every 5 minutes.
|
||||
function poll() {
|
||||
clearTimeout(pollTimer);
|
||||
const wait = live ? 60000 : state.inventory.updates?.busy ? 3000 : 10000;
|
||||
pollTimer = window.setTimeout(async () => {
|
||||
if (!document.hidden) {
|
||||
const full = Date.now() - lastFull >= 300000;
|
||||
if (full) lastFull = Date.now();
|
||||
if (full || !live) await refresh(full);
|
||||
}
|
||||
poll();
|
||||
}, wait);
|
||||
}
|
||||
let booted = false;
|
||||
export function boot() {
|
||||
if (booted) return;
|
||||
booted = true;
|
||||
refresh();
|
||||
listen();
|
||||
poll();
|
||||
whenBarFontsLoad(() => state.fontsVersion++);
|
||||
// The mockup's clocks tick and entity values in the top bar follow Home Assistant while the page is open.
|
||||
setInterval(() => {
|
||||
if (!state.layout || document.hidden || state.drag.active) return;
|
||||
state.now = Date.now();
|
||||
loadTopbarPreview(0);
|
||||
}, 30000);
|
||||
document.addEventListener("visibilitychange", async () => {
|
||||
if (document.hidden) return;
|
||||
lastFull = Date.now();
|
||||
state.now = Date.now();
|
||||
await refresh();
|
||||
poll();
|
||||
});
|
||||
// A change still waiting for its short pause goes out when the page closes.
|
||||
window.addEventListener("pagehide", () => flushSettings(true));
|
||||
window.addEventListener("beforeunload", (e) => {
|
||||
if (state.dirty) { e.preventDefault(); e.returnValue = ""; }
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,425 @@
|
||||
/* ESP Screens editor. Hairlines instead of shadows, one accent for the action of the moment,
|
||||
13 px UI text on an 8 px grid. The device mockup keeps the screen's own light palette. */
|
||||
* { box-sizing: border-box; }
|
||||
html, body, #app { height: 100%; }
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--ground);
|
||||
color: var(--ink);
|
||||
font: 13px/1.45 var(--ui-font);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
button, input, select, textarea { font: inherit; color: inherit; }
|
||||
button { background: none; border: 0; padding: 0; cursor: pointer; border-radius: 8px; }
|
||||
button:disabled { opacity: 0.45; cursor: default; }
|
||||
button:focus-visible, input:focus-visible, select:focus-visible, textarea:focus-visible, [tabindex]:focus-visible, summary:focus-visible {
|
||||
outline: 2px solid var(--accent); outline-offset: 2px;
|
||||
}
|
||||
input, select, textarea {
|
||||
background: var(--surface); border: 1px solid var(--line-strong); border-radius: 8px; padding: 7px 10px; width: 100%; color: var(--ink);
|
||||
}
|
||||
input::placeholder, textarea::placeholder { color: var(--muted); }
|
||||
select { appearance: auto; }
|
||||
h1, h2, h3, h4 { margin: 0; font-weight: 600; letter-spacing: -0.01em; }
|
||||
h1 { font-size: 20px; letter-spacing: -0.02em; }
|
||||
h2 { font-size: 15px; }
|
||||
h3 { font-size: 13px; }
|
||||
h4 { font-size: 13px; }
|
||||
p { margin: 0; }
|
||||
small, .hint { display: block; color: var(--muted); font-size: 11.5px; font-weight: 400; line-height: 1.4; }
|
||||
.hint.warn, small.warn { color: var(--warn); }
|
||||
code { font-family: var(--mono); font-size: 0.92em; background: var(--code-bg); padding: 1px 6px; border-radius: 5px; overflow-wrap: anywhere; }
|
||||
pre { margin: 0; padding: 12px 14px; background: var(--code-bg); border: 1px solid var(--line); border-radius: 10px; overflow: auto; font: 12px/1.5 var(--mono); color: var(--ink); white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||
a { color: var(--accent); text-decoration: none; }
|
||||
a:hover { text-decoration: underline; }
|
||||
.mdi { font-family: "Tile Icons"; font-style: normal; font-weight: normal; line-height: 1; }
|
||||
[hidden] { display: none !important; }
|
||||
|
||||
/* ---- Shell ---- */
|
||||
.app { display: grid; grid-template-columns: 232px minmax(0, 1fr); height: 100%; min-height: 100vh; }
|
||||
.app.dragging { user-select: none; -webkit-user-select: none; cursor: grabbing; }
|
||||
.main { display: flex; flex-direction: column; min-width: 0; min-height: 0; }
|
||||
|
||||
/* Sidebar */
|
||||
.side { background: var(--surface); border-right: 1px solid var(--line); display: flex; flex-direction: column; padding: 14px 10px; gap: 4px; min-height: 0; overflow: auto; position: sticky; top: 0; height: 100vh; }
|
||||
.brand { display: flex; align-items: center; gap: 9px; padding: 4px 8px 6px; font-weight: 600; font-size: 13px; }
|
||||
.brand .mark { width: 22px; height: 22px; border-radius: 6px; background: var(--ink); color: var(--surface); display: grid; place-items: center; font-size: 14px; line-height: 1; }
|
||||
.conn { display: block; padding: 0 8px 8px; font-size: 11px; color: var(--muted); }
|
||||
.conn::before { content: "●"; margin-right: 6px; color: var(--warn); }
|
||||
.conn.online::before { color: var(--good); }
|
||||
.side .label { font-size: 10.5px; font-weight: 600; letter-spacing: 0.08em; text-transform: uppercase; color: var(--muted); padding: 10px 8px 4px; }
|
||||
.nav-item { display: flex; align-items: center; gap: 9px; width: 100%; padding: 7px 8px; border-radius: 8px; text-align: left; color: var(--ink-2); }
|
||||
.nav-item:hover { background: var(--surface-2); color: var(--ink); }
|
||||
.nav-item[aria-current="true"] { background: var(--seg); color: var(--ink); font-weight: 500; }
|
||||
.nav-item .dot { width: 7px; height: 7px; border-radius: 50%; background: var(--good); flex: none; }
|
||||
.nav-item .dot.off { background: var(--line-strong); }
|
||||
.nav-item .txt { display: grid; min-width: 0; flex: 1; }
|
||||
.nav-item .txt small { font-size: 11px; color: var(--muted); font-weight: 400; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.nav-item .mdi { font-size: 16px; width: 18px; text-align: center; color: var(--muted); }
|
||||
.nav-item .plus { width: 18px; text-align: center; font-size: 14px; }
|
||||
.nav-item.ghost { color: var(--muted); }
|
||||
.nav-item.ghost:hover { color: var(--accent); background: var(--accent-soft); }
|
||||
.pill { font-size: 10.5px; font-weight: 600; padding: 2px 7px; border-radius: 999px; background: var(--warn-soft); color: var(--warn); flex: none; }
|
||||
.side .spacer { flex: 1; }
|
||||
.screen-item { display: grid; gap: 4px; }
|
||||
.screen-update { display: grid; gap: 6px; padding: 2px 8px 8px 24px; }
|
||||
.screen-update.available { grid-template-columns: 1fr auto; align-items: center; }
|
||||
.screen-update.available:has(.screen-host) { grid-template-columns: 1fr; }
|
||||
.screen-update small.failed { color: var(--danger); }
|
||||
.screen-host { display: flex; gap: 6px; align-items: center; }
|
||||
.screen-host input { padding: 5px 8px; font-size: 12px; }
|
||||
.pending { margin: 4px 0; padding: 10px; border: 1px dashed var(--line-strong); border-radius: 10px; display: grid; gap: 6px; }
|
||||
.pending strong { font-size: 12.5px; }
|
||||
.pending-actions { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||
|
||||
/* Buttons and chips */
|
||||
.btn { display: inline-flex; align-items: center; justify-content: center; gap: 6px; padding: 6px 12px; border-radius: 8px; font-weight: 600; font-size: 12.5px; white-space: nowrap; }
|
||||
.btn.primary { background: var(--accent); color: var(--accent-ink); }
|
||||
.btn.primary:hover:not(:disabled) { filter: brightness(1.06); }
|
||||
.btn.quiet { color: var(--ink-2); border: 1px solid var(--line-strong); background: var(--surface); }
|
||||
.btn.quiet:hover:not(:disabled) { background: var(--surface-2); color: var(--ink); }
|
||||
.btn.danger { color: var(--danger); }
|
||||
.btn.danger:hover:not(:disabled) { background: var(--danger-soft); }
|
||||
.btn.mini { padding: 4px 9px; font-size: 11.5px; }
|
||||
.btn.link { color: var(--accent); padding: 0; font-weight: 600; }
|
||||
.icon-btn { width: 30px; height: 30px; display: grid; place-items: center; border-radius: 8px; color: var(--ink-2); font-size: 15px; flex: none; }
|
||||
.icon-btn:hover { background: var(--seg); color: var(--ink); }
|
||||
.chip { display: inline-flex; align-items: center; gap: 6px; padding: 3px 9px; border-radius: 999px; font-size: 11.5px; font-weight: 500; color: var(--ink-2); background: var(--surface-2); border: 1px solid var(--line); white-space: nowrap; }
|
||||
.chip .dot { width: 6px; height: 6px; border-radius: 50%; background: var(--good); flex: none; }
|
||||
.chip.off .dot { background: var(--line-strong); }
|
||||
.chip.dirty { color: var(--warn); background: var(--warn-soft); border-color: transparent; }
|
||||
.chip.sent, .chip.good { color: var(--good); background: var(--good-soft); border-color: transparent; }
|
||||
.chip.update { color: var(--warn); background: var(--warn-soft); border-color: transparent; }
|
||||
.seg { display: inline-flex; padding: 2px; background: var(--seg); border-radius: 8px; gap: 2px; }
|
||||
.seg button { padding: 4px 11px; border-radius: 6px; font-size: 12px; font-weight: 500; color: var(--ink-2); white-space: nowrap; }
|
||||
.seg button[aria-pressed="true"] { background: var(--surface); color: var(--ink); box-shadow: 0 1px 2px rgba(20, 24, 40, 0.14); }
|
||||
.seg.wrap { flex-wrap: wrap; }
|
||||
.seg button:hover:not(:disabled):not([aria-pressed="true"]) { color: var(--ink); }
|
||||
.spin { display: inline-block; width: 14px; height: 14px; border: 2px solid var(--line-strong); border-top-color: var(--accent); border-radius: 50%; animation: spin 0.8s linear infinite; flex: none; }
|
||||
.spin.small { width: 11px; height: 11px; border-width: 1.5px; }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
.switch { width: 34px; height: 20px; border-radius: 10px; background: var(--line-strong); position: relative; flex: none; padding: 0; }
|
||||
.switch::after { content: ""; position: absolute; top: 2px; left: 2px; width: 16px; height: 16px; border-radius: 50%; background: #fff; box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2); transition: left 0.12s; }
|
||||
.switch[aria-checked="true"] { background: var(--accent); }
|
||||
.switch[aria-checked="true"]::after { left: 16px; }
|
||||
.switch.unknown::after { left: 9px; opacity: 0.6; }
|
||||
@media (prefers-reduced-motion: reduce) { .switch::after, .drawer, .spin { transition: none; animation: none; } }
|
||||
|
||||
/* Main head */
|
||||
.main-head { display: flex; align-items: center; gap: 12px; padding: 0 18px; min-height: 52px; border-bottom: 1px solid var(--line); background: var(--surface); position: sticky; top: 0; z-index: 4; }
|
||||
.main-head h1 { font-size: 15px; }
|
||||
.main-head .eyebrow { font-size: 10.5px; font-weight: 600; letter-spacing: 0.08em; text-transform: uppercase; color: var(--muted); }
|
||||
.head-right { margin-left: auto; display: flex; align-items: center; gap: 8px; position: relative; }
|
||||
.menu { position: absolute; top: 38px; right: 0; min-width: 210px; background: var(--surface); border: 1px solid var(--line); border-radius: 10px; box-shadow: var(--shadow); padding: 6px; display: grid; z-index: 6; }
|
||||
.menu button { text-align: left; padding: 7px 10px; border-radius: 7px; color: var(--ink); display: flex; justify-content: space-between; gap: 12px; width: 100%; }
|
||||
.menu button small { color: var(--muted); display: inline; }
|
||||
.menu button:hover:not(:disabled) { background: var(--surface-2); }
|
||||
|
||||
/* Body: canvas + library, drawer over the right side */
|
||||
.body { position: relative; display: grid; grid-template-columns: minmax(0, 1fr) 300px; flex: 1; min-height: 0; overflow: hidden; }
|
||||
.canvas { overflow: auto; padding: 22px 22px 26px; min-width: 0; }
|
||||
.canvas-head { display: flex; align-items: baseline; gap: 12px; margin-bottom: 14px; color: var(--muted); font-size: 12px; flex-wrap: wrap; }
|
||||
.canvas-head b { color: var(--ink); font-weight: 600; font-size: 13px; }
|
||||
.canvas-head .warn { color: var(--warn); }
|
||||
.pages { display: flex; gap: 18px; align-items: flex-start; }
|
||||
.page { flex: 0 0 292px; display: grid; gap: 8px; }
|
||||
.page-label { display: flex; justify-content: space-between; align-items: center; font-size: 11.5px; color: var(--muted); padding: 0 4px; min-height: 22px; }
|
||||
.page-label .btn.mini { color: var(--muted); }
|
||||
.page-label .btn.mini:hover { color: var(--danger); background: var(--danger-soft); }
|
||||
.page.ghost .device { display: grid; place-items: center; border: 1.5px dashed var(--line-strong); background: transparent; color: var(--muted); font-weight: 500; cursor: pointer; }
|
||||
.page.ghost .device:hover, .page.ghost .device:focus-visible { color: var(--accent); border-color: var(--accent); }
|
||||
.page.ghost .device.drop { color: var(--accent); border-color: var(--accent); background: var(--accent-soft); }
|
||||
.page.ghost.disabled .device { opacity: 0.5; cursor: default; }
|
||||
|
||||
/* The device mock keeps the Guition light palette on purpose: it is the screen, not the page. */
|
||||
.device { background: #e7e7e7; border-radius: 18px; padding: 10px; display: grid; grid-template-rows: auto minmax(0, 1fr); gap: 8px; color: #1b1b1b; aspect-ratio: 1 / 1; }
|
||||
.device.cyd { aspect-ratio: 4 / 3; }
|
||||
.bar-wrap { cursor: pointer; border-radius: 8px; padding: 0 2px; }
|
||||
.bar-wrap:hover { background: #dedede; }
|
||||
.bar-wrap.selected { outline: 2px solid var(--accent); outline-offset: 1px; }
|
||||
.bar-wrap svg { display: block; width: 100%; height: auto; }
|
||||
.tiles { display: grid; grid-template-columns: 1fr 1fr; grid-auto-rows: minmax(0, 1fr); gap: 8px; min-height: 0; }
|
||||
.tile { position: relative; background: #fff; border: 1px solid #dcdcdc; border-radius: 12px; padding: 8px 9px; display: flex; flex-direction: column; justify-content: space-between; gap: 4px; min-height: 0; cursor: pointer; text-align: left; overflow: hidden; color: #1b1b1b; touch-action: pan-y; }
|
||||
.tile:hover { border-color: #b9c4d3; }
|
||||
.tile.chosen { outline: 2px solid var(--accent); outline-offset: 1px; }
|
||||
.tile.wide { grid-column: span 2; flex-direction: row; align-items: center; gap: 8px; }
|
||||
.tile.bare { background: transparent; border-style: dashed; border-color: #c3c9d0; }
|
||||
.tile.placeholder { opacity: 0.55; border: 2px dashed var(--accent); background: var(--accent-soft); cursor: default; }
|
||||
.tile .ic { font-size: 18px; line-height: 1; color: #1b1b1b; flex: none; }
|
||||
.tile .lead { display: grid; gap: 2px; min-width: 0; flex: 1; align-content: end; }
|
||||
.tile.wide .lead { display: flex; align-items: center; gap: 8px; align-content: initial; }
|
||||
.tile.wide .lead .tx { display: grid; min-width: 0; gap: 1px; }
|
||||
.tile .nm { font-size: 11.5px; font-weight: 600; line-height: 1.2; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.tile .st { font-size: 10.5px; color: #5a5f66; line-height: 1.2; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.device.cyd .tile { padding: 6px 7px; }
|
||||
.device.cyd .tile .nm { font-size: 10.5px; }
|
||||
.device.cyd .tile .ic { font-size: 15px; }
|
||||
.tile .mini-slider { height: 10px; border-radius: 5px; background: linear-gradient(to right, #ffbf38 60%, #fff1d3 60%); flex: none; width: 100%; }
|
||||
.tile.wide .mini-slider { width: 70px; }
|
||||
.tile .ctl { flex: none; display: flex; align-items: center; gap: 4px; color: #1b1b1b; }
|
||||
.tile .tog { width: 30px; height: 17px; border-radius: 9px; background: #2196f3; position: relative; }
|
||||
.tile .tog::after { content: ""; position: absolute; top: 2px; right: 2px; width: 13px; height: 13px; border-radius: 50%; background: #fff; }
|
||||
.tile .range { width: 56px; height: 14px; border-radius: 7px; background: linear-gradient(to right, #2196f3 35%, #d3e8fb 35%); }
|
||||
.tile .key { height: 20px; min-width: 22px; padding: 0 5px; border-radius: 6px; background: #eceef1; display: grid; place-items: center; font-size: 12px; }
|
||||
.tile .stp { display: flex; align-items: center; gap: 5px; height: 20px; padding: 0 7px; border-radius: 10px; background: #eceef1; font-size: 11px; font-weight: 600; }
|
||||
.tile .stp .mdi { font-size: 12px; }
|
||||
.tile .run { height: 20px; padding: 0 9px; border-radius: 10px; background: #eceef1; font-size: 11px; font-weight: 600; line-height: 20px; }
|
||||
.tile .remove { position: absolute; top: 5px; right: 5px; width: 22px; height: 22px; border-radius: 50%; background: #ffffffd9; color: #5a5f66; border: 1px solid #dcdcdc; font-size: 10px; line-height: 1; display: grid; place-items: center; opacity: 0; transition: opacity 0.12s; }
|
||||
.tile:hover .remove, .tile:focus-within .remove { opacity: 1; }
|
||||
.tile .remove:hover { color: var(--danger); border-color: #e7b4b0; background: #fff; }
|
||||
@media (hover: none) { .tile .remove { opacity: 1; } }
|
||||
.tile .clockface { width: 46px; height: 46px; margin: auto; }
|
||||
.tile.wide .clockface { margin: 0; flex: none; }
|
||||
.cell { min-height: 0; border: 1px dashed #cfd4da; border-radius: 12px; background: transparent; color: #a3abb5; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 1px; font-weight: 500; cursor: pointer; padding: 6px; }
|
||||
.cell span { font-size: 18px; line-height: 1; }
|
||||
.cell small { font-size: 10.5px; color: inherit; }
|
||||
.cell:hover, .cell:focus-visible { border-color: #9eb6cf; color: #4b5563; background: #f6f7f9; }
|
||||
.cell.insert-here { border: 2px dashed var(--accent); color: var(--accent); background: var(--accent-soft); }
|
||||
.drag-ghost { position: fixed; left: 0; top: 0; pointer-events: none; z-index: 50; opacity: 0.92; box-shadow: var(--shadow); border-radius: 12px; }
|
||||
|
||||
/* Library */
|
||||
.library { border-left: 1px solid var(--line); background: var(--surface); display: flex; flex-direction: column; min-height: 0; }
|
||||
.lib-head { padding: 14px 14px 10px; display: grid; gap: 10px; border-bottom: 1px solid var(--line); }
|
||||
.lib-title { display: flex; align-items: baseline; justify-content: space-between; font-weight: 600; gap: 8px; }
|
||||
.lib-title small { color: var(--muted); font-weight: 400; font-size: 11.5px; }
|
||||
.filters { display: flex; gap: 6px; overflow-x: auto; scrollbar-width: none; padding-bottom: 2px; }
|
||||
.filters::-webkit-scrollbar { display: none; }
|
||||
.filters button { flex: none; display: inline-flex; align-items: center; gap: 5px; padding: 4px 10px; border-radius: 999px; font-size: 11.5px; font-weight: 500; color: var(--ink-2); background: var(--surface-2); border: 1px solid var(--line); }
|
||||
.filters button[aria-pressed="true"] { background: var(--ink); color: var(--surface); border-color: var(--ink); }
|
||||
.filters .domain-icon { width: 16px; height: 16px; font-size: 11px; border-radius: 50%; display: grid; place-items: center; }
|
||||
.lib-list { overflow: auto; padding: 8px; display: grid; gap: 2px; align-content: start; flex: 1; }
|
||||
.ent { display: flex; align-items: center; gap: 10px; padding: 8px; border-radius: 9px; width: 100%; text-align: left; cursor: grab; touch-action: pan-y; }
|
||||
.ent:hover:not(:disabled) { background: var(--surface-2); }
|
||||
.ent:disabled { opacity: 0.55; cursor: default; }
|
||||
.ent .av { width: 30px; height: 30px; border-radius: 9px; display: grid; place-items: center; font-size: 17px; flex: none; }
|
||||
.ent .tx { display: grid; min-width: 0; flex: 1; }
|
||||
.ent .tx b { font-weight: 500; font-size: 12.5px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.ent .tx small { font-size: 11px; color: var(--muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.ent .add { width: 24px; height: 24px; border-radius: 50%; display: grid; place-items: center; color: var(--accent); background: var(--accent-soft); font-size: 15px; font-weight: 600; flex: none; }
|
||||
.ent .add.done { color: var(--good); background: var(--good-soft); font-size: 12px; }
|
||||
.lib-foot { padding: 10px 14px; border-top: 1px solid var(--line); font-size: 11.5px; color: var(--muted); }
|
||||
.domain-icon { display: inline-grid; place-items: center; border-radius: 50%; font-weight: 500; }
|
||||
|
||||
/* Drawer */
|
||||
.drawer { position: absolute; top: 0; right: 0; bottom: 0; width: 360px; max-width: 100%; background: var(--surface); border-left: 1px solid var(--line); box-shadow: var(--shadow); transform: translateX(102%); transition: transform 0.18s cubic-bezier(0.2, 0.7, 0.2, 1); display: flex; flex-direction: column; min-height: 0; z-index: 5; }
|
||||
.drawer.open { transform: none; }
|
||||
.dr-head { display: flex; align-items: center; gap: 10px; padding: 12px 12px 12px 16px; border-bottom: 1px solid var(--line); }
|
||||
.dr-head .av { width: 34px; height: 34px; border-radius: 10px; display: grid; place-items: center; font-size: 20px; flex: none; }
|
||||
.dr-head .tx { flex: 1; min-width: 0; display: grid; }
|
||||
.dr-head .tx b { font-weight: 600; font-size: 14px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.dr-head .tx small { color: var(--muted); font-size: 11.5px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.dr-head .tx small.mono { font-family: var(--mono); }
|
||||
.dr-body { overflow: auto; padding: 14px 16px 18px; display: grid; gap: 16px; align-content: start; flex: 1; }
|
||||
.f { display: grid; gap: 7px; }
|
||||
.f > .f-label { font-size: 11px; font-weight: 600; letter-spacing: 0.06em; text-transform: uppercase; color: var(--muted); }
|
||||
.f input, .f select { font-size: 13px; }
|
||||
.row { display: flex; align-items: center; gap: 10px; padding: 7px 10px 7px 8px; border: 1px solid var(--line-strong); border-radius: 9px; background: var(--surface); width: 100%; text-align: left; }
|
||||
.row .av { width: 30px; height: 30px; border-radius: 8px; background: var(--surface-2); display: grid; place-items: center; font-size: 18px; flex: none; }
|
||||
.row .tx { flex: 1; min-width: 0; display: grid; }
|
||||
.row .tx b { font-weight: 500; }
|
||||
.row .tx small { font-size: 11px; }
|
||||
.row .link { color: var(--accent); font-weight: 600; font-size: 12px; flex: none; }
|
||||
.sw { display: grid; grid-template-columns: repeat(4, 1fr); gap: 6px; }
|
||||
.sw button { display: grid; justify-items: center; gap: 4px; padding: 6px 4px 5px; border-radius: 9px; border: 1.5px solid transparent; font-size: 10.5px; color: var(--ink-2); }
|
||||
.sw button i { width: 30px; height: 22px; border-radius: 6px; border: 1px solid rgba(0, 0, 0, 0.08); display: block; }
|
||||
.sw button i.auto { background: linear-gradient(135deg, #fff 50%, #e5e7eb 50%); }
|
||||
.sw button i.none { background: linear-gradient(135deg, transparent 46%, #8b95a1 46%, #8b95a1 54%, transparent 54%), #e7e7e7; border-style: dashed; }
|
||||
.sw button[aria-pressed="true"] { border-color: var(--accent); background: var(--accent-soft); color: var(--ink); }
|
||||
.picker { display: grid; gap: 10px; padding: 10px; border: 1px solid var(--line); border-radius: 10px; background: var(--surface-2); }
|
||||
.picker input { background: var(--surface); }
|
||||
.icon-list { display: grid; gap: 12px; max-height: 300px; overflow: auto; padding-right: 2px; }
|
||||
.icon-list small { margin-bottom: 6px; }
|
||||
.icon-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(40px, 1fr)); gap: 6px; }
|
||||
.icon-choice { display: grid; place-items: center; height: 40px; background: var(--surface); color: var(--ink); border: 1px solid var(--line); border-radius: 9px; }
|
||||
.icon-choice .mdi { font-size: 22px; }
|
||||
.icon-choice[aria-pressed="true"] { background: var(--accent-soft); color: var(--accent); border-color: var(--accent); }
|
||||
.icon-choice.icon-auto { display: flex; justify-content: flex-start; gap: 10px; padding: 0 12px; font-size: 12.5px; font-weight: 500; height: 38px; }
|
||||
.action-list { display: grid; gap: 6px; max-height: 300px; overflow: auto; padding-right: 2px; }
|
||||
.action-choice { display: grid; gap: 2px; padding: 8px 12px; background: var(--surface); color: var(--ink); border: 1px solid var(--line); border-radius: 9px; font-size: 12.5px; text-align: left; }
|
||||
.action-choice strong { font-weight: 600; }
|
||||
.action-choice[aria-pressed="true"] { background: var(--accent-soft); border-color: var(--accent); }
|
||||
.action-number { display: flex; align-items: center; gap: 8px; }
|
||||
.action-number input { flex: 1; min-width: 0; }
|
||||
.items { display: grid; gap: 6px; }
|
||||
.item { display: flex; align-items: center; gap: 8px; padding: 7px 8px; border: 1px solid var(--line-strong); border-radius: 9px; background: var(--surface); width: 100%; text-align: left; cursor: pointer; touch-action: pan-y; }
|
||||
.item:hover { border-color: var(--accent); }
|
||||
.item.selected { border-color: var(--accent); background: var(--accent-soft); }
|
||||
.item.is-hidden { border-style: dashed; }
|
||||
.item.is-hidden .av, .item.is-hidden b { opacity: 0.55; }
|
||||
.item.is-overflow { border-style: dashed; border-color: var(--warn); }
|
||||
.item.is-overflow small { color: var(--warn); }
|
||||
.item.dragging-chip { border: 2px dashed var(--accent); background: var(--accent-soft); cursor: grabbing; }
|
||||
.item.just-added { animation: chip-added 1.2s ease-out; }
|
||||
@keyframes chip-added { from { background: var(--accent-soft); } to { background: var(--surface); } }
|
||||
.item .grip { color: var(--line-strong); font-size: 13px; cursor: grab; letter-spacing: -2px; flex: none; }
|
||||
.item .av { width: 26px; height: 26px; border-radius: 7px; background: var(--surface-2); display: grid; place-items: center; font-size: 15px; flex: none; }
|
||||
.item .tx { flex: 1; min-width: 0; display: grid; }
|
||||
.item .tx b { font-weight: 500; font-size: 12.5px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.item .tx small { color: var(--muted); font-size: 11px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.item .x { color: var(--muted); width: 22px; height: 22px; display: grid; place-items: center; border-radius: 6px; flex: none; font-size: 11px; }
|
||||
.item .x:hover { color: var(--danger); background: var(--danger-soft); }
|
||||
.ghost-btn { border: 1.5px dashed var(--line-strong); border-radius: 9px; padding: 8px; color: var(--accent); font-weight: 600; width: 100%; background: transparent; }
|
||||
.ghost-btn:hover:not(:disabled) { border-color: var(--accent); background: var(--accent-soft); }
|
||||
.dr-foot { margin-top: auto; display: flex; align-items: center; gap: 8px; padding: 10px 12px; border-top: 1px solid var(--line); background: var(--surface-2); flex-wrap: wrap; }
|
||||
.dr-foot .spacer { flex: 1; }
|
||||
.live { display: grid; gap: 6px; padding: 10px; border: 1px solid var(--line); border-radius: 10px; background: #e7e7e7; color: #1b1b1b; justify-items: start; }
|
||||
.live small { color: #5a5f66; }
|
||||
.live svg { display: block; height: 28px; }
|
||||
.option { display: flex; align-items: center; gap: 10px; padding: 8px 10px; border: 1px solid var(--line); border-radius: 9px; background: var(--surface); width: 100%; text-align: left; }
|
||||
.option:hover:not(:disabled) { border-color: var(--accent); }
|
||||
.option .mdi { font-size: 20px; width: 26px; text-align: center; color: var(--ink-2); flex: none; }
|
||||
.option .tx { display: grid; min-width: 0; }
|
||||
.option .tx strong { font-weight: 500; font-size: 12.5px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.option .tx small { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.options { display: grid; gap: 6px; }
|
||||
.inspection-tile { display: grid; gap: 3px; padding: 8px 10px; border: 1px solid var(--line); border-radius: 9px; margin-bottom: 6px; }
|
||||
.inspection-tile strong { font-size: 12.5px; }
|
||||
|
||||
/* Screen settings tab */
|
||||
.settings { padding: 22px; overflow: auto; display: grid; gap: 14px; align-content: start; grid-column: 1 / -1; }
|
||||
.settings .status { font-size: 12px; color: var(--muted); }
|
||||
.set-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); gap: 14px; }
|
||||
.set-card { background: var(--surface); border: 1px solid var(--line); border-radius: 12px; padding: 6px 14px 8px; }
|
||||
.set-card h4 { display: flex; align-items: center; gap: 8px; padding: 10px 0 8px; }
|
||||
.set-card h4 .mdi { color: var(--muted); font-size: 16px; }
|
||||
.srow { display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 8px 0; border-top: 1px solid var(--line); }
|
||||
.srow > .s-label { color: var(--ink-2); }
|
||||
.srow.inactive .s-label, .srow.inactive .s-control { opacity: 0.45; }
|
||||
.settings.offline .set-grid { opacity: 0.6; }
|
||||
.step { display: inline-flex; align-items: center; gap: 2px; }
|
||||
.step button { width: 26px; height: 26px; border-radius: 6px; color: var(--ink-2); background: var(--seg); display: grid; place-items: center; }
|
||||
.step button .mdi { font-size: 15px; }
|
||||
.step button:hover:not(:disabled) { color: var(--ink); background: var(--line-strong); }
|
||||
.step output { min-width: 56px; text-align: center; font-weight: 500; }
|
||||
|
||||
/* Pages that replace the old dialogs: new screen, firmware, alerts, override, settings */
|
||||
.panel { padding: 22px 26px 40px; overflow: auto; display: grid; gap: 18px; align-content: start; max-width: 960px; width: 100%; }
|
||||
.panel.wide { max-width: 1120px; }
|
||||
.panel-head { display: flex; align-items: flex-start; gap: 12px; }
|
||||
.panel-head .tx { display: grid; gap: 2px; flex: 1; min-width: 0; }
|
||||
.panel-head h1 { font-size: 20px; }
|
||||
.panel-head p { color: var(--muted); font-size: 13px; }
|
||||
.panel p, .panel li { color: var(--ink-2); font-size: 13px; }
|
||||
.card { background: var(--surface); border: 1px solid var(--line); border-radius: 12px; padding: 16px 18px; display: grid; gap: 12px; align-content: start; }
|
||||
.card h2 { font-size: 14px; }
|
||||
.card-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 14px; }
|
||||
.field { display: grid; gap: 6px; }
|
||||
.field > .f-label, fieldset legend { font-size: 11px; font-weight: 600; letter-spacing: 0.06em; text-transform: uppercase; color: var(--muted); }
|
||||
fieldset { border: 0; padding: 0; margin: 0; display: grid; gap: 8px; min-width: 0; }
|
||||
fieldset:disabled { opacity: 0.6; }
|
||||
.boards { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 8px; }
|
||||
.board { display: flex; align-items: center; gap: 10px; padding: 10px 12px; border: 1px solid var(--line-strong); border-radius: 10px; cursor: pointer; }
|
||||
.board:has(input:checked) { border-color: var(--accent); background: var(--accent-soft); }
|
||||
.board input { width: auto; margin: 0; accent-color: var(--accent); }
|
||||
.board b { display: block; font-weight: 600; }
|
||||
.actions { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||||
.status-line { font-size: 12.5px; color: var(--ink-2); }
|
||||
.status-line.ok { color: var(--good); }
|
||||
.status-line.error { color: var(--danger); }
|
||||
.progress-head { display: flex; align-items: center; gap: 10px; font-weight: 600; font-size: 14px; }
|
||||
.outcome { width: 22px; height: 22px; border-radius: 50%; display: grid; place-items: center; color: #fff; font-size: 12px; font-weight: 700; }
|
||||
.outcome.ok { background: var(--good); }
|
||||
.outcome.bad { background: var(--danger); }
|
||||
.steps { margin: 0; padding-left: 20px; display: grid; gap: 8px; }
|
||||
.steps li b { color: var(--ink); }
|
||||
.key-box { display: flex; align-items: center; gap: 10px; padding: 10px 12px; border: 1px solid var(--line); border-radius: 10px; background: var(--surface-2); flex-wrap: wrap; }
|
||||
.key-box code { flex: 1; min-width: 0; background: none; padding: 0; font-size: 12px; }
|
||||
.log { max-height: 320px; }
|
||||
details.log-wrap summary { cursor: pointer; font-weight: 500; color: var(--ink-2); font-size: 12.5px; }
|
||||
details.log-wrap pre { margin-top: 8px; }
|
||||
.check { display: flex; align-items: flex-start; gap: 10px; cursor: pointer; }
|
||||
.check input { width: 18px; height: 18px; margin: 1px 0 0; accent-color: var(--accent); }
|
||||
.check span { display: grid; gap: 2px; font-weight: 500; }
|
||||
.node-line { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
|
||||
.tools { display: flex; gap: 10px; flex-wrap: wrap; }
|
||||
.tool { display: flex; align-items: center; gap: 10px; padding: 10px 12px; border: 1px solid var(--line); border-radius: 10px; background: var(--surface); text-align: left; min-width: 220px; }
|
||||
.tool:hover { border-color: var(--accent); }
|
||||
.tool .tool-icon { width: 30px; height: 30px; border-radius: 8px; background: var(--seg); display: grid; place-items: center; font-size: 15px; color: var(--ink-2); flex: none; }
|
||||
.tool .tx { display: grid; }
|
||||
.tool .tx b { font-weight: 600; }
|
||||
|
||||
/* Override YAML editor */
|
||||
.yaml-editor { display: grid; grid-template-columns: 44px minmax(0, 1fr); border: 1px solid var(--line-strong); border-radius: 10px; overflow: hidden; background: var(--code-bg); min-height: 280px; max-height: 60vh; }
|
||||
.yaml-gutter { padding: 12px 8px; text-align: right; color: var(--muted); font: 12px/1.5 var(--mono); white-space: pre; user-select: none; overflow: hidden; border-right: 1px solid var(--line); }
|
||||
.yaml-editor textarea { border: 0; border-radius: 0; background: transparent; resize: none; font: 12px/1.5 var(--mono); padding: 12px; white-space: pre; overflow: auto; min-height: 280px; tab-size: 2; }
|
||||
.file-row { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
|
||||
.file-row code { font-size: 12px; }
|
||||
.notice { display: flex; gap: 12px; padding: 12px 14px; border: 1px solid var(--line); border-radius: 10px; background: var(--surface-2); }
|
||||
.notice .mdi { font-size: 20px; color: var(--ink-2); }
|
||||
.notice strong { display: block; font-weight: 600; margin-bottom: 2px; }
|
||||
|
||||
/* Alerts cheatsheet */
|
||||
.alerts section { display: grid; gap: 10px; }
|
||||
.alerts h2 { font-size: 15px; }
|
||||
.alerts-nav { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||
.alerts-nav button { background: var(--accent-soft); color: var(--accent); padding: 5px 11px; font-size: 12px; border-radius: 20px; font-weight: 500; }
|
||||
.alert-hero { display: grid; grid-template-columns: minmax(0, 300px) 1fr; gap: 20px; align-items: center; }
|
||||
.alert-mock { background: #101820; border-radius: 16px; padding: 22px 18px; display: grid; place-items: center; }
|
||||
.alert-mock-card { position: relative; width: 100%; background: #ffe1c6; border: 1px solid #0001; border-radius: 14px; padding: 12px 12px 48px 52px; color: #1b1b1b; display: grid; gap: 2px; }
|
||||
.alert-mock-icon { position: absolute; left: 12px; top: 12px; font-size: 30px; }
|
||||
.alert-mock-card strong { font-size: 15px; line-height: 1.2; }
|
||||
.alert-mock-card > span { font-size: 12px; color: #3a3a3c; }
|
||||
.alert-mock-card .fake { position: absolute; right: 10px; bottom: 10px; padding: 6px 16px; border-radius: 999px; background: #1b1b1b; color: white; font-size: 13px; }
|
||||
.alert-screen { border: 1px solid var(--line); border-radius: 10px; padding: 10px 12px; display: grid; gap: 8px; background: var(--surface); }
|
||||
.alert-screen-head { display: flex; align-items: center; justify-content: space-between; gap: 10px; flex-wrap: wrap; }
|
||||
.copy-line { display: flex; align-items: flex-start; gap: 8px; flex-wrap: wrap; }
|
||||
.copy-line .copy-label { flex: none; width: 96px; font-size: 12px; color: var(--muted); padding-top: 2px; }
|
||||
.copy-line > code { flex: 1; min-width: 0; padding: 4px 8px; }
|
||||
.copy-line pre { flex: 1; min-width: 0; }
|
||||
.table-scroll { overflow-x: auto; }
|
||||
table { width: 100%; border-collapse: collapse; font-size: 12.5px; min-width: 560px; }
|
||||
th, td { text-align: left; vertical-align: top; padding: 8px 12px 8px 0; border-bottom: 1px solid var(--line); }
|
||||
th { font-size: 10.5px; letter-spacing: 0.08em; text-transform: uppercase; color: var(--muted); font-weight: 600; }
|
||||
td code { white-space: nowrap; }
|
||||
.chips { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||
.chips .chip .mdi { font-size: 16px; }
|
||||
.chips .chip code { background: none; padding: 0; }
|
||||
.alert-icon-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(100px, 1fr)); gap: 8px; }
|
||||
.alert-icon { display: grid; justify-items: center; align-content: start; gap: 4px; min-height: 86px; padding: 10px 6px 8px; background: var(--surface); color: var(--ink); border: 1px solid var(--line); border-radius: 10px; font-weight: 500; }
|
||||
.alert-icon:hover { border-color: var(--accent); }
|
||||
.alert-icon .mdi { font-size: 26px; }
|
||||
.alert-icon code { background: none; padding: 0; font-size: 11px; color: var(--accent); text-align: center; white-space: normal; }
|
||||
.alert-icon small { font-size: 11px; text-align: center; }
|
||||
.swatches { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 8px; }
|
||||
.swatch { display: flex; align-items: center; gap: 10px; padding: 7px; border: 1px solid var(--line); border-radius: 10px; background: var(--surface); color: var(--ink); font-size: 12px; text-align: left; font-weight: 500; }
|
||||
.swatch:hover { border-color: var(--accent); }
|
||||
.swatch i { width: 32px; height: 32px; border-radius: 8px; border: 1px solid #0001; flex: none; display: block; }
|
||||
.swatch span { display: grid; gap: 2px; min-width: 0; }
|
||||
.swatch code { background: none; padding: 0; }
|
||||
.alerts-list { padding-left: 18px; margin: 0; display: grid; gap: 8px; }
|
||||
.alerts-list b { color: var(--ink); }
|
||||
.subhead { font-size: 11px; font-weight: 600; letter-spacing: 0.08em; text-transform: uppercase; color: var(--muted); margin-top: 6px; }
|
||||
|
||||
/* Empty states */
|
||||
.empty { margin: auto; max-width: 420px; text-align: center; display: grid; gap: 10px; justify-items: center; padding: 60px 20px; }
|
||||
.empty-icon { width: 48px; height: 48px; border-radius: 14px; background: var(--seg); color: var(--muted); display: grid; place-items: center; font-size: 26px; }
|
||||
.empty h2 { font-size: 18px; }
|
||||
.empty p { color: var(--muted); }
|
||||
|
||||
/* Toast */
|
||||
.toast { position: fixed; left: 50%; bottom: 20px; transform: translateX(-50%); display: flex; align-items: center; gap: 14px; padding: 10px 16px; background: var(--ink); color: var(--surface); border-radius: 10px; box-shadow: var(--shadow); font-size: 13px; z-index: 60; max-width: min(92vw, 560px); }
|
||||
.toast button { color: #9fc4ff; font-weight: 650; }
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 960px) {
|
||||
.app { grid-template-columns: 1fr; min-height: 100vh; }
|
||||
.side { position: static; height: auto; flex-direction: row; flex-wrap: wrap; align-items: center; border-right: 0; border-bottom: 1px solid var(--line); padding: 10px; gap: 4px 6px; }
|
||||
.side .label, .side .spacer, .conn { display: none; }
|
||||
.side #screens { display: contents; }
|
||||
.screen-item { display: contents; }
|
||||
.screen-update { display: none; }
|
||||
.side #pending { flex-basis: 100%; }
|
||||
.nav-item { width: auto; }
|
||||
.more { display: contents; }
|
||||
.main-head { flex-wrap: wrap; padding: 10px 14px; gap: 8px; position: static; }
|
||||
.head-right { margin-left: 0; width: 100%; }
|
||||
.body { grid-template-columns: 1fr; overflow: visible; }
|
||||
.canvas { padding: 16px; overflow: visible; }
|
||||
.pages { overflow-x: auto; padding-bottom: 8px; }
|
||||
.library { border-left: 0; border-top: 1px solid var(--line); }
|
||||
.lib-list { flex: none; max-height: 360px; }
|
||||
.drawer { position: fixed; top: 0; width: 100%; }
|
||||
.panel { padding: 16px; }
|
||||
.alert-hero { grid-template-columns: 1fr; }
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
/* Design tokens. Light is the default; dark follows the browser (Home Assistant's "auto" theme does too). */
|
||||
:root {
|
||||
color-scheme: light dark;
|
||||
--ground: #f5f6f8;
|
||||
--surface: #ffffff;
|
||||
--surface-2: #f9fafb;
|
||||
--line: #e4e7ec;
|
||||
--line-strong: #d3d8e0;
|
||||
--ink: #16181d;
|
||||
--ink-2: #565d6b;
|
||||
--muted: #7a8290;
|
||||
--accent: #2f63f0;
|
||||
--accent-ink: #ffffff;
|
||||
--accent-soft: #edf2ff;
|
||||
--good: #1e9a5f;
|
||||
--good-soft: #e6f6ee;
|
||||
--warn: #b56d00;
|
||||
--warn-soft: #fff3df;
|
||||
--danger: #c6372e;
|
||||
--danger-soft: #fdecea;
|
||||
--seg: #eef0f3;
|
||||
--shadow: 0 12px 40px rgba(20, 24, 40, 0.12), 0 1px 2px rgba(20, 24, 40, 0.06);
|
||||
--code-bg: #f1f3f6;
|
||||
--ui-font: -apple-system, BlinkMacSystemFont, system-ui, "SF Pro Text", "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
--mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--ground: #0e1014;
|
||||
--surface: #161a21;
|
||||
--surface-2: #1b2029;
|
||||
--line: #262c37;
|
||||
--line-strong: #333b49;
|
||||
--ink: #e9ecf1;
|
||||
--ink-2: #b4bbc7;
|
||||
--muted: #8a93a3;
|
||||
--accent: #6d93ff;
|
||||
--accent-ink: #0b1633;
|
||||
--accent-soft: #1b2540;
|
||||
--good: #3fbf7f;
|
||||
--good-soft: #14302a;
|
||||
--warn: #e0a03a;
|
||||
--warn-soft: #302610;
|
||||
--danger: #f07167;
|
||||
--danger-soft: #3a1b19;
|
||||
--seg: #222834;
|
||||
--shadow: 0 16px 48px rgba(0, 0, 0, 0.55), 0 1px 2px rgba(0, 0, 0, 0.4);
|
||||
--code-bg: #12151b;
|
||||
}
|
||||
}
|
||||
/* The screens draw Material Design Icons; tile-icons.woff holds the same glyphs. The top bar
|
||||
mockup measures text in the firmware's Roboto. */
|
||||
@font-face {
|
||||
font-family: "Tile Icons";
|
||||
src: url("../assets/tile-icons.woff") format("woff");
|
||||
font-display: block;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Bar Roboto";
|
||||
font-weight: 400;
|
||||
src: url("../assets/bar-roboto-400.woff") format("woff");
|
||||
font-display: block;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Bar Roboto";
|
||||
font-weight: 500;
|
||||
src: url("../assets/bar-roboto-500.woff") format("woff");
|
||||
font-display: block;
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
export type TileOptions = {
|
||||
display?: string;
|
||||
size?: string;
|
||||
controls?: string;
|
||||
inline?: string;
|
||||
tap?: string;
|
||||
icon?: string;
|
||||
background?: string;
|
||||
history_hours?: number;
|
||||
action?: { action: string; data?: Record<string, unknown> };
|
||||
[key: string]: unknown;
|
||||
};
|
||||
export type Tile = { entity: string; name: string; slot: number; options?: TileOptions };
|
||||
export type HeaderItem = { type: string; entity?: string; content?: string; icon?: string; show?: string };
|
||||
export type Layout = {
|
||||
title: string;
|
||||
tiles: Tile[];
|
||||
pages?: number;
|
||||
header?: { items: HeaderItem[] };
|
||||
settings?: Record<string, any>;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
export type UpdateInfo = {
|
||||
available?: boolean; target?: string; state?: string; phase?: string; host?: string; profile?: string;
|
||||
result?: { state: string; message: string; time: number };
|
||||
};
|
||||
export type SettingsView = { owner: string; keys: string[]; values: Record<string, any>; unavailable: string[] };
|
||||
export type Screen = {
|
||||
id: string; name: string; online: boolean; area?: string; firmware?: string; board?: string;
|
||||
layout: Layout; update?: UpdateInfo; settings?: SettingsView; delivery?: string; status?: string;
|
||||
alert_action?: string; dismiss_action?: string;
|
||||
};
|
||||
export type Entity = { id: string; name: string; area?: string; device?: string; icon?: string; state?: string; tile?: boolean };
|
||||
export type IconInfo = { name: string; cp: string; label: string };
|
||||
export type Inventory = {
|
||||
csrf?: string;
|
||||
connected?: boolean;
|
||||
screens: Screen[];
|
||||
entities: Entity[];
|
||||
builtin?: Entity[];
|
||||
pending?: { friendly: string; file: string; installed?: boolean; downloaded?: boolean; api_key?: string }[];
|
||||
updates?: { target: string; busy?: boolean; pending?: number; auto?: boolean };
|
||||
claude_skill?: { path: string; installed: boolean; current: boolean; restart?: boolean };
|
||||
icons?: {
|
||||
groups: { label: string; icons: IconInfo[] }[];
|
||||
builtin?: Record<string, string>; weather: Record<string, string>; sun: Record<string, string>;
|
||||
defaults: Record<string, string>; fallback: string; controls?: Record<string, string>;
|
||||
};
|
||||
backgrounds?: Record<string, { label: string; color?: string }>;
|
||||
controls?: Record<string, { default: string; choices: { key: string; label: string }[] }>;
|
||||
header?: {
|
||||
max_items?: number; min_firmware?: string;
|
||||
builtin: { type: string; label: string }[];
|
||||
contents: { key: string; label: string }[];
|
||||
shows: { key: string; label: string }[];
|
||||
suggestions?: Record<string, { item: HeaderItem; label: string; name?: string; area?: string; icon?: string }[]>;
|
||||
};
|
||||
alerts?: any;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
export type Capability = { toggle: boolean; inline: boolean; controls: string[]; displays: string[] };
|
||||
export type EntityAction = {
|
||||
action: string; name: string; description: string;
|
||||
fields: { key: string; name: string; required?: boolean; description?: string; example?: unknown; selector?: Record<string, any>; options?: string[] }[];
|
||||
};
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"lib": ["ES2023", "DOM", "DOM.Iterable"],
|
||||
"strict": true,
|
||||
"noImplicitAny": false,
|
||||
"noUnusedLocals": false,
|
||||
"skipLibCheck": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"types": ["vite/client"],
|
||||
"baseUrl": ".",
|
||||
"paths": { "@/*": ["src/*"] }
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.vue", "env.d.ts"]
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import vue from "@vitejs/plugin-vue";
|
||||
import { defineConfig } from "vite";
|
||||
|
||||
// The add-on serves the built page from screen_manager/app/static behind Home Assistant ingress,
|
||||
// which lives on a sub path with a token. Everything is relative so the page works there and on
|
||||
// a bare http://127.0.0.1:8099 (SCREEN_DEV). The Python server keeps serving /api; only the static
|
||||
// files change. `npm run dev` proxies /api to a running server (the demo home or SCREEN_DEV).
|
||||
export default defineConfig({
|
||||
base: "./",
|
||||
plugins: [vue()],
|
||||
build: {
|
||||
outDir: "../screen_manager/app/static",
|
||||
emptyOutDir: false,
|
||||
target: "es2022",
|
||||
},
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: { "/api": { target: "http://127.0.0.1:8099", changeOrigin: false } },
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user