diff --git a/assets/app.css b/assets/app.css index dab8623..46564ab 100755 --- a/assets/app.css +++ b/assets/app.css @@ -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; + } } diff --git a/assets/app.js b/assets/app.js index a858d35..180cc53 100755 --- a/assets/app.js +++ b/assets/app.js @@ -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,22 +2464,29 @@ const rail = document.createElement('div'); rail.className = 'cover-card__rail'; - 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%'); - progress.appendChild(bar); + if (hasVisiblePosition) { + const progress = document.createElement('div'); + progress.className = 'cover-progress'; + const bar = document.createElement('div'); + bar.className = 'cover-progress__value'; + const pos = Number.isFinite(currentPosition) ? currentPosition : 100; + bar.style.width = `${Math.max(0, Math.min(100, pos))}%`; + progress.appendChild(bar); - if (!options.isMain) { - rail.append(progress); - inner.append(left, rail); + if (!options.isMain) { + rail.append(progress); + inner.append(left, rail); + if (state.editMode) { + inner.appendChild(renderEditActions(entity)); + } + } else { + inner.append(left, progress); + } + } else { + inner.append(left); if (state.editMode) { inner.appendChild(renderEditActions(entity)); } - } else { - inner.append(left, progress); } card.appendChild(inner); @@ -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(); diff --git a/config/config.json b/config/config.json index 70bbdd4..2794586 100755 --- a/config/config.json +++ b/config/config.json @@ -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": [ diff --git a/index.php b/index.php index 4a35a5a..ccfefe7 100755 --- a/index.php +++ b/index.php @@ -23,8 +23,8 @@ $appTitle = htmlspecialchars((string)($config['app']['title'] ?? 'Wall Panel'), - - + +
@@ -53,6 +53,9 @@ $appTitle = htmlspecialchars((string)($config['app']['title'] ?? 'Wall Panel'),
+

Загрузка