This commit is contained in:
Striker72rus 2026-03-19 22:33:31 +03:00
parent 0f7b410ede
commit 87670de0a7
4 changed files with 422 additions and 67 deletions

View File

@ -143,6 +143,15 @@ textarea {
align-items: flex-start;
}
.content-header__back {
display: none;
flex: 0 0 auto;
}
.content-header > div {
min-width: 0;
}
.content-header__actions {
display: flex;
align-items: center;
@ -1031,6 +1040,10 @@ textarea {
display: flex;
}
body.is-mobile-ui #camera-modal {
display: none !important;
}
.camera-modal {
position: relative;
width: calc(100vw - 24px);
@ -1219,9 +1232,22 @@ textarea {
gap: 16px;
}
.entity-modal__cover {
grid-template-columns: minmax(0, 1fr) 118px;
align-items: stretch;
}
.entity-modal__rail {
display: grid;
gap: 14px;
min-width: 0;
}
.entity-modal__rail--cover {
grid-template-rows: auto minmax(0, 1fr) auto;
justify-items: center;
align-content: stretch;
gap: 12px;
}
.entity-modal__cover-meta {
@ -1248,22 +1274,75 @@ textarea {
}
.entity-modal__cover-track {
height: 10px;
position: relative;
justify-self: center;
width: 56px;
min-height: 280px;
border-radius: 999px;
background: rgba(255,255,255,0.08);
background:
radial-gradient(circle at 50% 6%, rgba(255,255,255,0.10), transparent 28%),
linear-gradient(180deg, rgba(18, 20, 27, 0.96), rgba(10, 12, 17, 0.98));
overflow: hidden;
box-shadow:
inset 0 1px 0 rgba(255,255,255,0.06),
inset 0 -18px 28px rgba(0, 0, 0, 0.26);
touch-action: none;
user-select: none;
cursor: ns-resize;
}
.entity-modal__cover-fill {
height: 100%;
position: absolute;
left: 0;
right: 0;
top: 0;
border-radius: inherit;
background: linear-gradient(90deg, #67d6ff, #88f0c7);
width: 0%;
background:
linear-gradient(180deg, rgba(103, 214, 255, 0.96) 0%, rgba(126, 236, 220, 0.94) 58%, rgba(188, 255, 242, 0.96) 100%);
box-shadow:
0 10px 26px rgba(103, 214, 255, 0.14),
inset 0 1px 0 rgba(255,255,255,0.20);
width: 100%;
height: 0%;
}
.entity-modal__slider {
.entity-modal__cover-handle {
position: absolute;
left: 50%;
top: -10px;
width: 36px;
height: 20px;
border-radius: 999px;
border: 1px solid rgba(255,255,255,0.18);
background:
linear-gradient(180deg, rgba(255,255,255,0.98), rgba(228,236,244,0.9));
box-shadow:
0 10px 22px rgba(0, 0, 0, 0.26),
inset 0 1px 0 rgba(255,255,255,0.7);
transform: translateX(-50%);
z-index: 2;
pointer-events: none;
}
.entity-modal__actions--vertical {
display: grid;
grid-auto-rows: minmax(52px, auto);
gap: 10px;
align-content: center;
justify-self: stretch;
}
.entity-modal__actions--vertical .mushroom-button--square {
width: 100%;
accent-color: var(--accent);
min-height: 72px;
}
.entity-modal__actions--vertical .mushroom-button__body {
gap: 2px;
}
.entity-modal__actions--vertical .mushroom-button__icon {
font-size: 18px;
}
.entity-modal__actions {
@ -1281,6 +1360,16 @@ textarea {
line-height: 1.05;
}
.entity-modal__actions.entity-modal__actions--vertical {
grid-template-columns: 1fr;
grid-auto-rows: minmax(52px, auto);
align-content: center;
}
.entity-modal__actions.entity-modal__actions--vertical .mushroom-button--square {
min-height: 72px;
}
.entity-modal__climate-summary {
display: grid;
gap: 8px;
@ -1826,18 +1915,84 @@ textarea {
@media (max-width: 920px) {
body {
overflow: auto;
overflow: hidden;
}
.app-shell {
grid-template-columns: 1fr;
height: auto;
min-height: 100vh;
height: 100dvh;
min-height: 100dvh;
}
.sidebar {
.app-shell.is-mobile {
overflow: hidden;
}
.app-shell.is-mobile .sidebar,
.app-shell.is-mobile .content {
min-height: 0;
height: 100%;
}
.app-shell.is-mobile .sidebar {
overflow: auto;
border-right: 0;
border-bottom: 1px solid rgba(255,255,255,0.05);
padding: 18px 16px calc(18px + env(safe-area-inset-bottom));
}
.app-shell.is-mobile .content {
overflow: auto;
padding: 18px 16px calc(18px + env(safe-area-inset-bottom));
}
.app-shell.is-mobile.mobile-view-spaces .content {
display: none;
}
.app-shell.is-mobile.mobile-view-room .sidebar {
display: none;
}
.app-shell.is-mobile .content-header {
align-items: center;
gap: 12px;
min-height: 0;
margin-bottom: 14px;
}
.app-shell.is-mobile .content-header__back {
display: inline-flex;
}
.app-shell.is-mobile .content-header__title {
margin-top: 0;
font-size: clamp(26px, 7vw, 36px);
}
.app-shell.is-mobile .content-header__meta {
font-size: 13px;
}
.app-shell.is-mobile .room-item.is-selected {
background: linear-gradient(180deg, rgba(28, 31, 39, 0.92), rgba(20, 23, 30, 0.92));
border-color: var(--border);
box-shadow: inset 0 1px 0 rgba(255,255,255,0.03);
}
.app-shell.is-mobile .room-item.is-selected .room-item__icon {
background: rgba(255,255,255,0.04);
color: var(--accent);
--icon-node-img-filter: brightness(0) saturate(100%) invert(72%) sepia(45%) saturate(1190%) hue-rotate(165deg) brightness(102%) contrast(101%);
}
.app-shell.is-mobile .room-item.is-selected .room-item__count {
background: rgba(255,255,255,0.04);
color: var(--text-subtle);
}
.app-shell.is-mobile .content-top {
margin-top: -12px;
}
.main-dashboard__hero {
@ -1849,7 +2004,8 @@ textarea {
}
.main-dashboard__actions {
grid-template-columns: repeat(2, minmax(0, 1fr));
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px;
}
.main-quick-action {
@ -1860,16 +2016,26 @@ textarea {
grid-column: 1 / -1;
}
.grid-card--auto,
.grid-card--entity,
.grid-card--entity-wide,
.grid-card--cover,
.grid-card--climate {
grid-column: 1 / -1;
.main-dashboard__cards {
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.main-dashboard__actions {
grid-template-columns: 1fr;
.main-dashboard__cards .grid-card {
grid-column: span 1;
}
.room-entities-section__grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.room-entities-section__grid .grid-card--entity,
.room-entities-section__grid .grid-card--entity-wide,
.room-entities-section__grid .grid-card--cover,
.room-entities-section__grid .grid-card--climate,
.room-entities-section__grid .grid-card--auto {
grid-column: span 1;
}
.main-quick-action {
@ -1890,4 +2056,36 @@ textarea {
height: calc(100vh - 16px);
border-radius: 18px;
}
.entity-modal {
width: calc(100vw - 20px);
max-height: calc(100dvh - 20px);
border-radius: 24px;
}
.entity-modal__body {
padding: 16px;
gap: 14px;
}
.entity-modal__cover {
grid-template-columns: minmax(0, 1fr) 88px;
gap: 12px;
}
.entity-modal__cover-track {
width: 44px;
min-height: 240px;
border-radius: 999px;
}
.entity-modal__cover-handle {
width: 30px;
height: 18px;
}
.entity-modal__actions--vertical {
grid-auto-rows: minmax(48px, auto);
gap: 8px;
}
}

View File

@ -1,8 +1,11 @@
(function () {
const bootstrap = window.APP_BOOTSTRAP || {};
const MOBILE_BREAKPOINT = 920;
const state = {
snapshot: bootstrap,
selectedRoomId: 'main',
isMobileViewport: false,
mobileView: 'spaces',
editMode: Boolean(bootstrap?.settings?.edit_mode),
clockTimer: null,
hlsInstance: null,
@ -47,6 +50,48 @@
return Array.from(root.querySelectorAll(sel));
}
function mobileViewportQuery() {
return window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT}px)`);
}
function isMobileViewport() {
return Boolean(state.isMobileViewport);
}
function isMobileRoomView() {
return isMobileViewport() && state.mobileView === 'room';
}
function setMobileView(nextView) {
if (!isMobileViewport()) {
state.mobileView = 'room';
return;
}
state.mobileView = nextView === 'room' ? 'room' : 'spaces';
}
function syncViewportState() {
const query = mobileViewportQuery();
const nextIsMobile = Boolean(query.matches);
const changed = nextIsMobile !== state.isMobileViewport;
state.isMobileViewport = nextIsMobile;
if (nextIsMobile) {
state.mobileView = changed ? 'spaces' : (state.mobileView || 'spaces');
} else {
state.mobileView = 'room';
clearRoomAutoReturnTimer();
scheduleRoomAutoReturn(state.selectedRoomId || 'main');
}
if (nextIsMobile) {
clearRoomAutoReturnTimer();
}
return nextIsMobile;
}
function iconClass(icon) {
if (!icon) return 'mdi mdi-help-circle-outline';
return icon.startsWith('mdi:') ? `mdi ${icon.replace('mdi:', 'mdi-')}` : icon;
@ -402,12 +447,30 @@
});
}
function sortMainEntities(entities) {
return (Array.isArray(entities) ? entities : [])
.slice()
.sort((left, right) => {
const leftTime = entitySortTime(left);
const rightTime = entitySortTime(right);
if (leftTime !== rightTime) return leftTime - rightTime;
const leftOrder = Number(left?.order ?? 9999);
const rightOrder = Number(right?.order ?? 9999);
if (leftOrder !== rightOrder) return leftOrder - rightOrder;
return String(left?.name || '').localeCompare(String(right?.name || ''), 'ru');
});
}
function roomEntities(snapshot, roomId) {
return sortRoomEntities(roomEntityCollection(snapshot, roomId).filter((entity) => entity.visible !== false));
const collection = roomEntityCollection(snapshot, roomId).filter((entity) => entity.visible !== false);
return roomId === 'main' ? sortMainEntities(collection) : sortRoomEntities(collection);
}
function roomEntitiesIncludingHidden(snapshot, roomId) {
return sortRoomEntities(roomEntityCollection(snapshot, roomId));
const collection = roomEntityCollection(snapshot, roomId);
return roomId === 'main' ? sortMainEntities(collection) : sortRoomEntities(collection);
}
function entityKindLabel(entity) {
@ -520,6 +583,21 @@
return true;
}
function syncLayoutState() {
if (!els.appShell) return;
const mobile = isMobileViewport();
document.body.classList.toggle('is-mobile-ui', mobile);
els.appShell.classList.toggle('is-mobile', mobile);
els.appShell.classList.toggle('is-desktop', !mobile);
els.appShell.classList.toggle('mobile-view-spaces', mobile && state.mobileView !== 'room');
els.appShell.classList.toggle('mobile-view-room', mobile && state.mobileView === 'room');
if (els.selectedRoomBack) {
els.selectedRoomBack.hidden = !isMobileRoomView();
}
}
function normalizePositionValue(value) {
const next = Number(value);
if (!Number.isFinite(next)) return null;
@ -563,7 +641,8 @@
function sortMainCardsBySnapshot(container) {
const snapshot = state.snapshot || {};
const order = new Map((snapshot.main_entities || []).map((entity, index) => [entity.entity_id, index]));
const orderedMainIds = sortMainEntities(snapshot.main_entities || []).map((entity) => entity.entity_id);
const order = new Map(orderedMainIds.map((entityId, index) => [entityId, index]));
const cards = Array.from(container?.querySelectorAll('.grid-card[data-entity-id]') || []);
cards.sort((left, right) => {
const leftId = left.dataset.entityId || '';
@ -607,7 +686,7 @@
return true;
}
const orderedMainIds = (snapshot.main_entities || []).map((item) => item.entity_id);
const orderedMainIds = sortMainEntities(snapshot.main_entities || []).map((item) => item.entity_id);
const nextIndex = orderedMainIds.indexOf(entityId);
const cards = Array.from(container.querySelectorAll('.grid-card[data-entity-id]'));
for (const card of cards) {
@ -1172,7 +1251,7 @@
function scheduleRoomAutoReturn(roomId) {
const nextRoomId = roomId || 'main';
clearRoomAutoReturnTimer();
if (nextRoomId === 'main') {
if (nextRoomId === 'main' || isMobileViewport()) {
return;
}
@ -1189,6 +1268,9 @@
const token = ++state.roomSelectionToken;
clearRoomAutoReturnTimer();
patchSnapshotSelection(nextRoomId);
if (isMobileViewport()) {
setMobileView('room');
}
render();
scheduleRoomAutoReturn(nextRoomId);
try {
@ -1944,7 +2026,7 @@
wrap.className = 'entity-modal__cover';
const rail = document.createElement('div');
rail.className = 'entity-modal__rail';
rail.className = 'entity-modal__rail entity-modal__rail--cover';
const currentPosition = Number(entity.attributes?.current_position);
const initialValue = Number.isFinite(currentPosition)
@ -1968,43 +2050,71 @@
const fill = document.createElement('div');
fill.className = 'entity-modal__cover-fill';
fill.style.width = `${initialValue}%`;
fill.style.height = `${initialValue}%`;
fill.style.top = '0';
fill.style.width = '100%';
progress.appendChild(fill);
const slider = document.createElement('input');
slider.type = 'range';
slider.min = '0';
slider.max = '100';
slider.step = '1';
slider.value = String(initialValue);
slider.className = 'entity-modal__slider';
const handle = document.createElement('div');
handle.className = 'entity-modal__cover-handle';
progress.appendChild(handle);
const syncSlider = () => {
const nextValue = Number(slider.value || 0);
fill.style.width = `${nextValue}%`;
const syncValue = (nextValue) => {
fill.style.height = `${nextValue}%`;
handle.style.top = nextValue <= 0 ? 'calc(100% - 10px)' : `calc(${nextValue}% - 10px)`;
value.textContent = `${nextValue}%`;
};
slider.addEventListener('input', syncSlider);
slider.addEventListener('change', () => {
const nextValue = Math.max(0, Math.min(100, Number(slider.value || 0)));
syncValue(initialValue);
const updateFromPointer = (clientY) => {
const rect = progress.getBoundingClientRect();
const ratio = 1 - ((clientY - rect.top) / rect.height);
const nextValue = Math.max(0, Math.min(100, Math.round(ratio * 100)));
syncValue(nextValue);
handleCoverPosition(entity, nextValue);
};
let dragPointerId = null;
const onPointerMove = (event) => {
if (dragPointerId !== event.pointerId) return;
event.preventDefault();
updateFromPointer(event.clientY);
};
const onPointerUp = (event) => {
if (dragPointerId !== event.pointerId) return;
event.preventDefault();
progress.releasePointerCapture?.(dragPointerId);
dragPointerId = null;
window.removeEventListener('pointermove', onPointerMove);
window.removeEventListener('pointerup', onPointerUp);
window.removeEventListener('pointercancel', onPointerUp);
};
progress.addEventListener('pointerdown', (event) => {
if (event.button !== 0) return;
dragPointerId = event.pointerId;
progress.setPointerCapture?.(dragPointerId);
updateFromPointer(event.clientY);
window.addEventListener('pointermove', onPointerMove, { passive: false });
window.addEventListener('pointerup', onPointerUp, { passive: false });
window.addEventListener('pointercancel', onPointerUp, { passive: false });
});
rail.append(valueRow, progress, slider);
rail.append(valueRow, progress);
const buttons = document.createElement('div');
buttons.className = 'entity-modal__actions';
const closeBtn = createButton('Закрыть', null, 'mdi:arrow-down', 'mushroom-button mushroom-button--small mushroom-button--square');
closeBtn.addEventListener('click', () => handleEntityService(entity, 'close'));
const stopBtn = createButton('Стоп', null, 'mdi:stop', 'mushroom-button mushroom-button--small mushroom-button--square');
stopBtn.addEventListener('click', () => handleEntityService(entity, 'stop'));
buttons.className = 'entity-modal__actions entity-modal__actions--vertical';
const openBtn = createButton('Открыть', null, 'mdi:arrow-up', 'mushroom-button mushroom-button--small mushroom-button--square');
openBtn.addEventListener('click', () => handleEntityService(entity, 'open'));
buttons.append(closeBtn, stopBtn, openBtn);
const stopBtn = createButton('Стоп', null, 'mdi:stop', 'mushroom-button mushroom-button--small mushroom-button--square');
stopBtn.addEventListener('click', () => handleEntityService(entity, 'stop'));
const closeBtn = createButton('Закрыть', null, 'mdi:arrow-down', 'mushroom-button mushroom-button--small mushroom-button--square');
closeBtn.addEventListener('click', () => handleEntityService(entity, 'close'));
buttons.append(openBtn, stopBtn, closeBtn);
wrap.append(rail, buttons);
return wrap;
}
@ -2326,6 +2436,10 @@
function renderCoverCard(entity, options = {}) {
const card = document.createElement('article');
const isOpen = ['open', 'opening'].includes(String(entity.state).toLowerCase());
const currentPosition = Number(entity.attributes?.current_position);
const hasVisiblePosition = Number.isFinite(currentPosition)
? currentPosition > 0
: isOpen;
card.className = `grid-card grid-card--cover ${!options.isMain && isOpen ? 'is-active' : ''}`;
card.dataset.entityId = entity.entity_id;
card.tabIndex = 0;
@ -2350,12 +2464,13 @@
const rail = document.createElement('div');
rail.className = 'cover-card__rail';
if (hasVisiblePosition) {
const progress = document.createElement('div');
progress.className = 'cover-progress';
const bar = document.createElement('div');
bar.className = 'cover-progress__value';
const pos = Number(entity.attributes?.current_position);
bar.style.width = Number.isFinite(pos) ? `${Math.max(0, Math.min(100, pos))}%` : (isOpen ? '100%' : '0%');
const pos = Number.isFinite(currentPosition) ? currentPosition : 100;
bar.style.width = `${Math.max(0, Math.min(100, pos))}%`;
progress.appendChild(bar);
if (!options.isMain) {
@ -2367,6 +2482,12 @@
} else {
inner.append(left, progress);
}
} else {
inner.append(left);
if (state.editMode) {
inner.appendChild(renderEditActions(entity));
}
}
card.appendChild(inner);
card.addEventListener('click', async (event) => {
@ -2702,7 +2823,7 @@
els.contentTop.classList.toggle('is-main', room.id === 'main');
}
if (els.contentHeader) {
els.contentHeader.classList.toggle('hidden', room.id === 'main');
els.contentHeader.classList.toggle('hidden', room.id === 'main' && !isMobileRoomView());
}
updateMainPrintStrip(snapshot);
if (room.id !== 'main') {
@ -2808,6 +2929,11 @@
}
function renderPopup(snapshot) {
if (isMobileViewport()) {
hidePopup({ preserveSnapshot: true });
return;
}
const popup = mergePopupWithCamera(snapshot.popup || {});
const signature = JSON.stringify([
popup.active,
@ -2883,13 +3009,13 @@
}
function hidePopup(options = {}) {
const { suppressAutoOpen = false } = options;
const { suppressAutoOpen = false, preserveSnapshot = false } = options;
if (suppressAutoOpen) {
state.popupAutoOpenBlockedUntil = Date.now() + 60000;
}
state.lastPopupSignature = '';
state.snapshot = state.snapshot || bootstrap;
if (state.snapshot.popup) {
if (!preserveSnapshot && state.snapshot.popup) {
state.snapshot.popup = {
...state.snapshot.popup,
active: false,
@ -3052,6 +3178,7 @@
return;
}
syncLayoutState();
renderRoomButtons(snapshot.spaces || snapshot.rooms);
renderSelectedRoom(snapshot);
renderDashboard(snapshot);
@ -3067,6 +3194,7 @@
function renderDashboardOnly() {
const snapshot = state.snapshot || bootstrap;
if (!snapshot || !(snapshot.spaces || snapshot.rooms)) return;
syncLayoutState();
renderSelectedRoom(snapshot);
renderDashboard(snapshot);
renderPopup(snapshot);
@ -3119,6 +3247,7 @@
function renderSelectionOnly() {
const snapshot = state.snapshot || bootstrap;
if (!snapshot || !(snapshot.spaces || snapshot.rooms)) return;
syncLayoutState();
renderSelectedRoom(snapshot);
}
@ -3241,6 +3370,15 @@
}
function wireEvents() {
els.selectedRoomBack?.addEventListener('click', () => {
if (!isMobileViewport()) return;
closeEntityPopup();
setMobileView('spaces');
syncLayoutState();
renderSidebarOnly();
renderSelectionOnly();
});
els.cameraBackdrop.addEventListener('click', (event) => {
if (event.target === els.cameraBackdrop) {
apiPost('popup', { command: 'close' }).catch(() => {});
@ -3312,11 +3450,13 @@
}
function initRefs() {
els.appShell = q('.app-shell');
els.clockTime = $('clock-time');
els.clockDate = $('clock-date');
els.roomsCount = $('rooms-count');
els.roomList = $('room-list');
els.editModeToggle = $('edit-mode-toggle');
els.selectedRoomBack = $('selected-room-back');
els.contentTop = q('.content-top');
els.mainPrintStripSlot = $('main-print-strip-slot');
els.contentHeader = q('.content-header');
@ -3551,11 +3691,23 @@
async function start() {
initRefs();
syncViewportState();
updateClock();
clearInterval(state.clockTimer);
state.clockTimer = setInterval(updateClock, 1000);
wireEvents();
const viewportQuery = mobileViewportQuery();
const handleViewportChange = () => {
syncViewportState();
render();
};
if (typeof viewportQuery.addEventListener === 'function') {
viewportQuery.addEventListener('change', handleViewportChange);
} else if (typeof viewportQuery.addListener === 'function') {
viewportQuery.addListener(handleViewportChange);
}
const initial = window.APP_BOOTSTRAP || {};
state.snapshot = initial;
render();

View File

@ -83,7 +83,9 @@
"poster_url": "http://10.0.6.110:1984/api/frame.jpeg?src=doorbell_main",
"popup_timeout_minutes": 3,
"trigger_entities": [
"binary_sensor.doorbell_person_occupancy"
"binary_sensor.doorbell_person_occupancy",
"binary_sensor.barn_all_occupancy",
"binary_sensor.doorbell_all_occupancy"
]
},
"rooms": [

View File

@ -23,8 +23,8 @@ $appTitle = htmlspecialchars((string)($config['app']['title'] ?? 'Wall Panel'),
<script>
window.APP_BOOTSTRAP = <?= json_encode($bootstrap, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?>;
</script>
<link rel="stylesheet" href="assets/app.css?v=0.16">
<script src="assets/app.js?v=0.16" defer></script>
<link rel="stylesheet" href="assets/app.css?v=0.19">
<script src="assets/app.js?v=0.19" defer></script>
</head>
<body>
<div class="app-shell">
@ -53,6 +53,9 @@ $appTitle = htmlspecialchars((string)($config['app']['title'] ?? 'Wall Panel'),
<div class="main-print-strip-slot" id="main-print-strip-slot"></div>
</div>
<header class="content-header">
<button class="icon-button icon-button--ghost content-header__back" id="selected-room-back" type="button" aria-label="Back">
<i class="mdi mdi-arrow-left"></i>
</button>
<div>
<div class="content-header__eyebrow" id="selected-room-eyebrow"></div>
<h1 class="content-header__title" id="selected-room-title">Загрузка</h1>