From dda4d4cd28fffee162cf260efaf8fc0eb20517ef Mon Sep 17 00:00:00 2001 From: jasonstantler Date: Tue, 3 Nov 2026 22:41:09 +0000 Subject: [PATCH 01/11] feat: Stacks, display Modes, takeovers, and moving widgets between tabs Stacks: several widgets in one spot, swiped between, while the rest of the dashboard stays put (like an iPhone Smart Stack). - Make one on the wall (Move/Resize: hold a widget over another) or under Admin -> Widgets -> Stacks, which also sets the tabs it shows on. - A quick sideways flick turns it, also inside plugin frames (the frame's touch script relays a flick only while the plugin is in a Stack). Dots and arrows show on touch, then fade. - Goes back to its first widget after a while untouched, and "Bring to front when..." shows a widget while a Home Assistant entity is (not) in a state (GET /api/devices/:name/stacks/ha). - Widgets shown in a placed Stack no longer count as "enabled but on no tab" in Admin, so saving doesn't ask to add them again. Move/Resize: - Drag a widget onto a dock tab, or hold it at the screen edge, to carry it to another tab (phone style), or onto + for a new tab. - Long-press a widget: Move to tab, Duplicate to tab (or a new tab), Widget settings, Remove from dashboard (asks, then Undo). On a Stack also: the showing widget's settings, the others', Stack settings, Unstack. PUT /widget-assignments/entry restores a removed entry. Display Modes (Admin -> Displays -> Modes): a wall display changes through the day without anything moving. A mode turns on by schedule, or from Home Assistant (a phone's next alarm, an entity's state, only while someone is home); it can start on a tab, dim the screen to off, and change what each Stack shows (which widgets, which first, go-back time, cycle through widgets, pause Bring to front). A mode label beside the dock pins or switches modes. HomeGlow can make a dropdown helper in Home Assistant (Automatic, Normal and the modes) kept in step both ways, and report the mode showing as a sensor. Takeovers: a widget can take over the top or bottom half, or the whole screen, while a Home Assistant rule is on, then hand the wall back. Two can share the screen; plugins can raise or close their own with HomeGlow.attention(). Docs: README features, frontend reference, plugin guide (takeovers). Tests: client (Vitest) and server routes for both Home Assistant reads, the restore-entry route and the flick relay. --- README.md | 5 + client/src/app.jsx | 651 ++++++++++++- client/src/components/AdminPanel.jsx | 54 +- client/src/components/HaEntityPicker.jsx | 99 ++ client/src/components/ModeBadge.jsx | 100 ++ client/src/components/ModesHaSettings.jsx | 213 +++++ client/src/components/ModesSettings.jsx | 879 ++++++++++++++++++ client/src/components/PluginWidgetWrapper.jsx | 30 +- client/src/components/StackModeFields.jsx | 191 ++++ client/src/components/StackWidget.jsx | 271 ++++++ client/src/components/StacksSettings.jsx | 498 ++++++++++ client/src/components/TabBar.jsx | 63 +- client/src/components/TakeoverLayer.jsx | 161 ++++ client/src/components/WidgetContainer.jsx | 166 +++- client/src/components/WidgetLongPressMenu.jsx | 201 ++++ client/src/components/WidgetRemoveUndo.jsx | 75 ++ client/src/hooks/useCarryToTab.jsx | 563 +++++++++++ client/src/hooks/useCarryToTab.test.js | 31 + client/src/hooks/useDisplayModes.js | 289 ++++++ client/src/hooks/useStackStates.js | 53 ++ client/src/hooks/useTakeovers.js | 163 ++++ client/src/i18n/locales/en/admin.json | 256 ++++- client/src/i18n/locales/en/common.json | 61 ++ client/src/i18n/locales/es/admin.json | 256 ++++- client/src/i18n/locales/es/common.json | 61 ++ client/src/utils/adminNavigation.js | 2 +- client/src/utils/displayDim.js | 31 +- client/src/utils/displayDim.test.js | 15 + client/src/utils/displayModes.js | 336 +++++++ client/src/utils/displayModes.test.js | 155 +++ client/src/utils/gridPlacement.js | 16 + client/src/utils/gridPlacement.test.js | 11 +- client/src/utils/haConditions.js | 11 + client/src/utils/haConditions.test.js | 16 + client/src/utils/pluginAccordion.js | 4 + client/src/utils/takeovers.js | 115 +++ client/src/utils/takeovers.test.js | 58 ++ client/src/utils/twoFingerSwipe.js | 7 +- client/src/utils/twoFingerSwipe.test.js | 2 + client/src/utils/widgetStacks.js | 155 +++ client/src/utils/widgetStacks.test.js | 93 ++ docs/guides/plugin-development.md | 28 + docs/reference/frontend.md | 11 + server/index.js | 89 ++ server/plugin-sdk/v1.js | 16 + server/routes/displayModesHa.js | 166 ++++ server/routes/haPanels.js | 7 + server/routes/widgetStacksHa.js | 31 + server/services/displayModesHa.js | 109 +++ server/services/haEntities.js | 58 ++ server/services/widgetStacksHa.js | 16 + server/tests/apiEndpoints.test.js | 34 + server/tests/displayModesHaRoutes.test.js | 163 ++++ server/tests/helpers/fakeHomeAssistant.js | 36 +- server/tests/pluginFrameTouch.test.js | 19 + server/tests/widgetStacksHaRoutes.test.js | 115 +++ server/utils/pluginFrameTouch.js | 13 +- 57 files changed, 7270 insertions(+), 58 deletions(-) create mode 100644 client/src/components/HaEntityPicker.jsx create mode 100644 client/src/components/ModeBadge.jsx create mode 100644 client/src/components/ModesHaSettings.jsx create mode 100644 client/src/components/ModesSettings.jsx create mode 100644 client/src/components/StackModeFields.jsx create mode 100644 client/src/components/StackWidget.jsx create mode 100644 client/src/components/StacksSettings.jsx create mode 100644 client/src/components/TakeoverLayer.jsx create mode 100644 client/src/components/WidgetLongPressMenu.jsx create mode 100644 client/src/components/WidgetRemoveUndo.jsx create mode 100644 client/src/hooks/useCarryToTab.jsx create mode 100644 client/src/hooks/useCarryToTab.test.js create mode 100644 client/src/hooks/useDisplayModes.js create mode 100644 client/src/hooks/useStackStates.js create mode 100644 client/src/hooks/useTakeovers.js create mode 100644 client/src/utils/displayModes.js create mode 100644 client/src/utils/displayModes.test.js create mode 100644 client/src/utils/haConditions.js create mode 100644 client/src/utils/haConditions.test.js create mode 100644 client/src/utils/takeovers.js create mode 100644 client/src/utils/takeovers.test.js create mode 100644 client/src/utils/widgetStacks.js create mode 100644 client/src/utils/widgetStacks.test.js create mode 100644 server/routes/displayModesHa.js create mode 100644 server/routes/widgetStacksHa.js create mode 100644 server/services/displayModesHa.js create mode 100644 server/services/haEntities.js create mode 100644 server/services/widgetStacksHa.js create mode 100644 server/tests/displayModesHaRoutes.test.js create mode 100644 server/tests/widgetStacksHaRoutes.test.js diff --git a/README.md b/README.md index 47a64bfa..953d814d 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,11 @@ HomeGlow is built with a focus on modern aesthetics, user experience, and practi - **Grid-Based System**: Responsive 12-column grid that adapts to mobile, tablet, and desktop screens - **Persistent Layouts**: Your custom arrangement is automatically saved and restored - **Touch & Mouse Support**: Full support for both touch gestures and traditional mouse interactions + - **Move Between Tabs**: In Move/Resize, drag a widget onto a tab in the dock, or hold it at the side of the screen to carry it to the next tab, like moving an app on a phone + - **Long-Press Menu**: In Move/Resize, hold a widget to move or duplicate it to another tab (or a new one), open its settings, or remove it from the tab (with Undo) + - **Stacks**: Several widgets in one spot, swiped between, while the rest of the dashboard stays put. Make one by holding a widget over another in Move/Resize, or under Admin → Widgets → Stacks. A Stack goes back to its first widget after a while, and can bring a widget to the front when Home Assistant says something needs attention (the grill card while the grill is on) +- **Display Modes**: A wall display can change through the day without anything moving: Morning, Dinner, Night and so on, each on a schedule or turned on from Home Assistant (a phone alarm going off, an entity's state, only while someone is home). A mode can start on a tab, dim the screen to off, and change what each Stack shows. HomeGlow can make a dropdown helper in Home Assistant to switch modes from automations, and reports which mode is showing. Set up under Admin → Displays → Modes +- **Takeovers**: A widget can take over the top or bottom half (or the whole screen) when Home Assistant says it needs you, then hand the wall back. Plugins can raise or close their own takeover with `HomeGlow.attention()` ### Enhanced Widgets - **Widget Refresh System**: Configure auto-refresh rates for each widget independently (5, 15, 30 minutes, or 1-6 hours) diff --git a/client/src/app.jsx b/client/src/app.jsx index 41e495b9..b3f17837 100644 --- a/client/src/app.jsx +++ b/client/src/app.jsx @@ -60,7 +60,18 @@ import { import { personalizationTokens } from './utils/personalize.js'; import SaveLayoutButton from './components/SaveLayoutButton.jsx'; import { setHouseholdChangesMode, subscribeHouseholdChanges } from './utils/householdChanges.js'; -import { updateDisplayDimming, setDimmingPaused } from './utils/displayDim.js'; +import { updateDisplayDimming, setDimmingPaused, setDimmingHold } from './utils/displayDim.js'; +import useDisplayModes from './hooks/useDisplayModes.js'; +import ModeBadge from './components/ModeBadge.jsx'; +import useCarryToTab, { dockTargetFrom } from './hooks/useCarryToTab.jsx'; +import useTakeovers from './hooks/useTakeovers.js'; +import TakeoverLayer from './components/TakeoverLayer.jsx'; +import i18n from 'i18next'; +import StackWidget from './components/StackWidget.jsx'; +import useStackStates from './hooks/useStackStates.js'; +import { WIDGET_STACKS_KEY, attentionWidget, isStackName, normalizeStacks, stackIdOf, stackInMode, stackName, stackWith } from './utils/widgetStacks.js'; +import { MODE_DIM_LEVEL } from './utils/displayModes.js'; +import { firstOpenSpot } from './utils/gridPlacement.js'; import { getServerTimezoneSync } from './utils/timezone.js'; import { THEME_TOKENS_APPLIED_EVENT } from './utils/pluginThemeBridge.js'; import { loadInstalledThemes, useThemeRegistry } from './utils/installedThemes.js'; @@ -72,7 +83,9 @@ import CelebrationHost from './components/CelebrationHost.jsx'; import { normalizeWidgetSettings, BASE_WIDGET_SETTINGS } from './utils/widgetSettings.js'; import { buildMobileWidgetList } from './utils/mobileWidgets.js'; import { CORE_CONTROLS, resolveHiddenControls } from './utils/displayControls.js'; -import { parseAdminHash, clearAdminHash } from './utils/adminNavigation.js'; +import { parseAdminHash, clearAdminHash, setAdminHash } from './utils/adminNavigation.js'; +import WidgetRemoveUndo from './components/WidgetRemoveUndo.jsx'; +import { EXPAND_PLUGIN_EVENT } from './utils/pluginAccordion.js'; import './index.css'; const loadAdminPanel = () => import('./components/AdminPanel.jsx'); @@ -383,6 +396,28 @@ const App = () => { } }, [API_DEVICE_URL, hydrateFromDeviceSettings]); + // This display's modes (utils/displayModes.js): on a wall display only, + // once its settings have loaded. + // This display's Stacks (utils/widgetStacks.js): several widgets in one + // spot, swiped between. + const widgetStacks = useMemo( + () => normalizeStacks(rawDeviceSettings?.[WIDGET_STACKS_KEY]).stacks, + [rawDeviceSettings] + ); + const displayModes = useDisplayModes({ + deviceSettings: rawDeviceSettings, + apiDeviceUrl: API_DEVICE_URL, + onSaved: hydrateFromDeviceSettings, + enabled: !isMobile && deviceSettingsLoaded, + }); + const shownMode = displayModes.shownMode; + // What the Stacks' "Bring to front when…" rules read in Home Assistant. + const stackStates = useStackStates({ + apiDeviceUrl: API_DEVICE_URL, + stacks: widgetStacks, + enabled: !isMobile && deviceSettingsLoaded, + }); + const fetchHouseholdSettings = useCallback(async () => { try { const response = await axios.get(`${API_BASE_URL}/api/settings`); @@ -1032,6 +1067,52 @@ const App = () => { setDimmingPaused(showAdminPanel); }, [showAdminPanel]); + // ---- Display modes (utils/displayModes.js) ---- + const shownModeId = shownMode?.id ?? null; + const shownModeRef = useRef(shownMode); + shownModeRef.current = shownMode; + const modeSettings = displayModes.settings; + const lastShownModeIdRef = useRef(undefined); + + // When the mode changes: its starting tab (its dimming is below). + useEffect(() => { + const previous = lastShownModeIdRef.current; + lastShownModeIdRef.current = shownModeId; + if (previous === undefined && shownModeId === null) return; + const mode = shownModeRef.current; + if (mode?.startTab && tabsRef.current.some((tab) => tab.number === mode.startTab)) { + setActiveTab(mode.startTab); + } + }, [shownModeId]); + + // A mode can hold the screen dimmed almost to black; a touch still wakes it. + const modeDims = shownMode?.dim === true; + useEffect(() => { + setDimmingHold(modeDims ? MODE_DIM_LEVEL : null); + }, [modeDims]); + + // Back to the mode's starting tab once nobody has touched the screen for a + // while, so the wall comes back to what the mode is for. + useEffect(() => { + const minutes = modeSettings.returnMinutes; + const startTab = shownMode?.startTab; + if (!startTab || !minutes) return undefined; + const timer = setInterval(() => { + if (showAdminPanelRef.current || screensaverActiveRef.current) return; + if (Date.now() - displayModes.lastActivityRef.current < minutes * 60 * 1000) return; + if (!tabsRef.current.some((tab) => tab.number === startTab)) return; + setActiveTab((current) => (current === startTab ? current : startTab)); + }, 15 * 1000); + return () => clearInterval(timer); + }, [shownMode?.startTab, modeSettings.returnMinutes, displayModes.lastActivityRef]); + + const showModeBadge = !isMobile + && modeSettings.showLabel + && modeSettings.modes.length > 0 + && !screensaverActive + && !showAdminPanel + && expandedWidgetSize !== 'full'; + // Two fingers swiped sideways change the tab, and pinch zoom can be blocked // (utils/twoFingerSwipe.js). This display's choice, never on a phone, which // has no tabs. Plugin frames get the same settings from their wrappers. @@ -1231,12 +1312,248 @@ const App = () => { const response = await axios.post(`${API_DEVICE_URL}/tabs`, tabData); await fetchTabs(); setShowTabIconModal(false); + // A widget dropped on the dock's + goes into the new tab. + const pending = pendingTabMoveRef.current; + pendingTabMoveRef.current = null; + const newTab = Number(response.data?.number); + if (pending && Number.isInteger(newTab)) { + if (pending.copy) await duplicateWidgetToTab(pending.name, pending.fromTab, newTab); + else await moveWidgetToTab(pending.name, pending.fromTab, newTab); + } } catch (error) { console.error('Error creating tab:', error); alert('Failed to create tab. Please try again.'); } }; + // ---- Moving a dragged widget to another tab (Move/Resize) ---- + // Let go over a tab in the dock and it moves there; hold it over a tab or + // at the side of the screen and that tab opens with it in hand + // (hooks/useCarryToTab.jsx). Over +: a new tab is made (the usual new-tab + // dialog) and the widget moves into it. + const pendingTabMoveRef = useRef(null); + + // Generous: anywhere near the dock picks the closest tab. + const dockTargetAt = dockTargetFrom([ + { selector: '[data-dock-tab]', value: (el) => Number(el.getAttribute('data-dock-tab')) }, + { selector: '[data-dock-add]', value: () => 'add' }, + ]); + + // `cell` is where on the tab ({x,y,w,h}), or null for the first open spot. + const moveWidgetToTab = async (name, fromTab, toTab, cell = null) => { + if (!name || (fromTab === toTab && !cell)) return; + try { + if (fromTab !== toTab && !isWidgetAssignedToTab(name, toTab)) { + await axios.post(`${API_DEVICE_URL}/widget-assignments`, { widget_name: name, tabNumber: toTab }); + } + // Where it was let go, else the first open spot, at the size it had. + const size = getWidgetLayoutForTab(name, fromTab) || { w: 6, h: 4 }; + const taken = Object.keys(widgetAssignments) + .filter((other) => other !== name) + .map((other) => getWidgetLayoutForTab(other, toTab)) + .filter(Boolean); + const spot = cell || firstOpenSpot({ w: size.w, h: size.h }, taken); + await axios.patch(`${API_DEVICE_URL}/widget-assignments/layout/bulk`, { + layouts: [{ widget_name: name, tabNumber: toTab, layout_x: spot.x, layout_y: spot.y, layout_w: spot.w, layout_h: spot.h }], + }); + if (fromTab !== toTab) { + await axios.delete(`${API_DEVICE_URL}/widget-assignments/${encodeURIComponent(`${fromTab}::${name}`)}`); + } + await fetchWidgetAssignments(); + setActiveTab(toTab); + } catch (error) { + console.error('Error moving the widget to another tab:', error); + await fetchWidgetAssignments(); + } + }; + + // ---- The long-press menu (Move/Resize; components/WidgetLongPressMenu.jsx) ---- + // Also on that tab, at the same size, in its first open spot. + const duplicateWidgetToTab = async (name, fromTab, toTab) => { + if (!name || fromTab === toTab || isWidgetAssignedToTab(name, toTab)) return; + try { + await axios.post(`${API_DEVICE_URL}/widget-assignments`, { widget_name: name, tabNumber: toTab }); + const size = getWidgetLayoutForTab(name, fromTab) || { w: 6, h: 4 }; + const taken = Object.keys(widgetAssignments) + .filter((other) => other !== name) + .map((other) => getWidgetLayoutForTab(other, toTab)) + .filter(Boolean); + const spot = firstOpenSpot({ w: size.w, h: size.h }, taken); + await axios.patch(`${API_DEVICE_URL}/widget-assignments/layout/bulk`, { + layouts: [{ widget_name: name, tabNumber: toTab, layout_x: spot.x, layout_y: spot.y, layout_w: spot.w, layout_h: spot.h }], + }); + await fetchWidgetAssignments(); + setActiveTab(toTab); + } catch (error) { + console.error('Error duplicating the widget:', error); + setWidgetNotice({ message: i18n.t('common:widgetMenu.moveFailed') }); + } + }; + + const tabLabelFor = (number) => tabs.find((tab) => tab.number === number)?.label || i18n.t('common:widgetMenu.tabNumber', { number }); + const stackFor = (name) => widgetStacks.find((stack) => stackName(stack.id) === name) || null; + const menuTitleFor = (name) => { + if (!name) return ''; + if (isStackName(name)) { + const stack = stackFor(name); + return stack?.name || (stack ? stack.widgets.map(stackMemberLabel).join(' · ') : i18n.t('common:widgetMenu.stack')); + } + return stackMemberLabel(name); + }; + // In a Stack that's on some tab: shown there, so not "on no tab". + const inPlacedStack = (name) => widgetStacks.some((stack) => stack.widgets.includes(name) + && (widgetAssignments[stackName(stack.id)] || []).length > 0); + + const [removeRequest, setRemoveRequest] = useState(null); + const [widgetNotice, setWidgetNotice] = useState(null); // { message, undo? } + + const setWidgetEnabled = async (name, enabled) => { + const isPlugin = name.startsWith('plugin:'); + const key = isPlugin ? name.slice('plugin:'.length) : name; + const field = isPlugin ? 'pluginSettings' : 'widgetSettings'; + const current = (rawDeviceSettings?.[field] && typeof rawDeviceSettings[field] === 'object') + ? rawDeviceSettings[field] + : (isPlugin ? pluginSettings : widgetSettings); + const saved = await axios.patch(`${API_DEVICE_URL}/settings`, { [field]: { ...current, [key]: { ...(current[key] || {}), enabled } } }); + hydrateFromDeviceSettings(saved.data || {}); + }; + + // Off this tab only. A widget left on no tab (and in no Stack) is turned + // off, the way Settings shows one that's on no tab. Undo puts it all back. + const confirmRemoveWidget = async () => { + const request = removeRequest; + setRemoveRequest(null); + if (!request) return; + const tab = tabs.find((item) => item.number === request.fromTab); + let entry = {}; + try { + const map = typeof tab?.config_json === 'string' ? JSON.parse(tab.config_json || '{}') : (tab?.config_json || {}); + entry = { ...(map?.[request.widgetName] || {}) }; + } catch { /* no per-tab settings */ } + if (request.place) { + Object.assign(entry, { layout_x: request.place.x, layout_y: request.place.y, layout_w: request.place.w, layout_h: request.place.h }); + } + const name = request.widgetName; + const turnOff = !isStackName(name) && (widgetAssignments[name] || []).length <= 1 && !inPlacedStack(name); + try { + await axios.delete(`${API_DEVICE_URL}/widget-assignments/${encodeURIComponent(`${request.fromTab}::${name}`)}`); + if (turnOff) await setWidgetEnabled(name, false); + await Promise.all([fetchTabs(), fetchWidgetAssignments()]); + setWidgetNotice({ + message: i18n.t('common:widgetMenu.removed', { name: request.title, tab: tabLabelFor(request.fromTab) }), + undo: { name, tab: request.fromTab, entry, turnOff }, + }); + } catch (error) { + console.error('Error removing widget:', error); + setWidgetNotice({ message: i18n.t('common:widgetMenu.removeFailed') }); + } + }; + const undoRemoveWidget = async () => { + const undo = widgetNotice?.undo; + setWidgetNotice(null); + if (!undo) return; + try { + await axios.put(`${API_DEVICE_URL}/widget-assignments/entry`, { widget_name: undo.name, tabNumber: undo.tab, entry: undo.entry }); + if (undo.turnOff) await setWidgetEnabled(undo.name, true); + await Promise.all([fetchTabs(), fetchWidgetAssignments()]); + setActiveTab(undo.tab); + } catch (error) { + console.error('Error restoring widget:', error); + setWidgetNotice({ message: i18n.t('common:widgetMenu.undoFailed') }); + } + }; + + // Split a Stack on this tab back into its widgets: the first takes its + // spot, the rest go in the first open spots. The Stack itself is kept only + // while it's still on another tab. + const unstackOnTab = async ({ widgetName: name, fromTab: tab, place }) => { + const stack = stackFor(name); + if (!stack) return; + try { + await axios.delete(`${API_DEVICE_URL}/widget-assignments/${encodeURIComponent(`${tab}::${name}`)}`); + const back = stack.widgets.filter((widget) => !isWidgetAssignedToTab(widget, tab)); + const taken = Object.keys(widgetAssignments) + .filter((other) => other !== name && !back.includes(other)) + .map((other) => getWidgetLayoutForTab(other, tab)) + .filter(Boolean); + const layouts = []; + back.forEach((widget, i) => { + const size = i === 0 && place ? place : { w: place?.w || 6, h: place?.h || 4 }; + const spot = i === 0 && place ? place : firstOpenSpot({ w: size.w, h: size.h }, taken); + taken.push(spot); + layouts.push({ widget_name: widget, tabNumber: tab, layout_x: spot.x, layout_y: spot.y, layout_w: spot.w, layout_h: spot.h }); + }); + for (const widget of back) { + await axios.post(`${API_DEVICE_URL}/widget-assignments`, { widget_name: widget, tabNumber: tab }).catch(() => {}); + } + if (layouts.length) await axios.patch(`${API_DEVICE_URL}/widget-assignments/layout/bulk`, { layouts }); + if ((widgetAssignments[name] || []).length <= 1) { + const saved = await axios.patch(`${API_DEVICE_URL}/settings`, { + [WIDGET_STACKS_KEY]: { stacks: widgetStacks.filter((item) => item.id !== stack.id) }, + }); + hydrateFromDeviceSettings(saved.data || {}); + } + await fetchWidgetAssignments(); + } catch (error) { + console.error('Error unstacking:', error); + await fetchWidgetAssignments(); + } + }; + + // "Widget settings": Admin, at the widget (or the Stack) once it's there. + const revealTimerRef = useRef(null); + const openWidgetSettings = (name) => { + if (!name) return; + const isPlugin = name.startsWith('plugin:'); + setAdminHash({ tab: 'dashboard', section: isPlugin ? 'plugins' : 'widgets' }); + setShowAdminPanel(true); + const selector = isStackName(name) + ? `[data-stack-row="${CSS.escape(stackIdOf(name))}"]` + : `[data-tab-picker="${CSS.escape(name)}"]`; + clearInterval(revealTimerRef.current); + const started = Date.now(); + revealTimerRef.current = setInterval(() => { + const found = document.querySelector(selector); + // A plugin's settings sit in its row of Admin → Plugins: open the row. + if (!found && isPlugin) { + window.dispatchEvent(new CustomEvent(EXPAND_PLUGIN_EVENT, { detail: { filename: name.slice('plugin:'.length) } })); + } + const group = isStackName(name) ? found : (found?.closest?.('section') || found); + if (group) { + group.scrollIntoView({ behavior: 'smooth', block: 'start' }); + group.animate?.([{ boxShadow: '0 0 0 3px var(--accent)' }, { boxShadow: '0 0 0 3px transparent' }], { duration: 1600, easing: 'ease-out' }); + } + if (group || Date.now() - started > 60000) clearInterval(revealTimerRef.current); + }, 250); + }; + useEffect(() => () => clearInterval(revealTimerRef.current), []); + + const widgetMenu = { + titleFor: menuTitleFor, + isStack: isStackName, + // A Stack's widgets for its menu, the one showing now first. + membersOf: (name) => { + const stack = stackFor(name); + if (!stack) return []; + const shown = document.querySelector(`[data-widget-name="${CSS.escape(name)}"] [data-stack-current]`)?.getAttribute('data-stack-current'); + const members = stackInMode(stack, shownMode?.id || null).widgets.filter((member) => enabledWidgetNames.includes(member)); + const ordered = shown && members.includes(shown) ? [shown, ...members.filter((member) => member !== shown)] : members; + return ordered.map((member) => ({ name: member, label: stackMemberLabel(member) })); + }, + tabChoices: (name) => tabs + .filter((tab) => tab.number !== activeTab) + .map((tab) => ({ number: tab.number, label: tabLabelFor(tab.number), alreadyThere: isWidgetAssignedToTab(name, tab.number) })), + onMoveToTab: ({ widgetName, fromTab, toTab }) => { void moveWidgetToTab(widgetName, fromTab, toTab); }, + onDuplicateToTab: ({ widgetName, fromTab, toTab }) => { void duplicateWidgetToTab(widgetName, fromTab, toTab); }, + onNewTab: ({ widgetName, fromTab, mode }) => { + pendingTabMoveRef.current = { name: widgetName, fromTab, copy: mode === 'duplicate' }; + setShowTabIconModal(true); + }, + onOpenSettings: openWidgetSettings, + onUnstack: (request) => { void unstackOnTab(request); }, + onRemove: (request) => setRemoveRequest({ ...request, tabLabel: tabLabelFor(request.fromTab) }), + }; + const handleDeleteTab = async (tabNumber) => { if (!window.confirm('Are you sure you want to delete this tab? Widgets will be moved to the Home tab.')) { return; @@ -1303,11 +1620,39 @@ const App = () => { knownControlIds, }), [rawDeviceSettings, householdSettings, adminPinExists, knownControlIds]); - const widgets = useMemo(() => { + // Every widget turned on here, and the Stacks holding any of them. + const enabledWidgetNames = useMemo(() => { + const names = [ + ...['calendar', 'weather', 'chores', 'photos'].filter((name) => widgetSettings[name]?.enabled), + ...installedPlugins + .filter((plugin) => pluginSettings[plugin.filename]?.enabled) + .map((plugin) => `plugin:${plugin.filename}`), + ]; + const on = new Set(names); + // A Stack counts while any of its widgets is turned on. + widgetStacks.forEach((stack) => { + if (stack.widgets.some((name) => on.has(name))) names.push(stackName(stack.id)); + }); + return names; + }, [widgetSettings, pluginSettings, installedPlugins, widgetStacks]); + + const widgetsOnActiveTab = useMemo(() => { + return enabledWidgetNames + .filter((name) => isWidgetAssignedToTab(name, activeTab)) + .map((name) => ({ name, size: getWidgetLayoutForTab(name, activeTab) || { w: 4, h: 3 } })); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [enabledWidgetNames, widgetAssignments, activeTab]); + + const layoutOnActiveTab = (name) => getWidgetLayoutForTab(name, activeTab); + + // The widgets for one tab, as the grid takes them: `include(name)` says + // which to show and `layoutOf(name)` where. A Stack builds its widgets' + // content from this too. + const makeWidgetList = (tabNumber, include, layoutOf) => { const result = []; - if (widgetSettings.calendar.enabled && isWidgetAssignedToTab('calendar', activeTab)) { - const dbLayout = getWidgetLayoutForTab('calendar', activeTab); + if (widgetSettings.calendar.enabled && include('calendar')) { + const dbLayout = layoutOf('calendar'); result.push({ id: 'calendar-widget', transparent: Boolean(widgetSettings.calendar.transparent), @@ -1320,16 +1665,16 @@ const App = () => { }> tab.number === activeTab)?.config_json || null} + activeTab={tabNumber} + activeTabConfigJson={tabs.find((tab) => tab.number === tabNumber)?.config_json || null} /> ), }); } - if (widgetSettings.weather.enabled && isWidgetAssignedToTab('weather', activeTab)) { - const dbLayout = getWidgetLayoutForTab('weather', activeTab); + if (widgetSettings.weather.enabled && include('weather')) { + const dbLayout = layoutOf('weather'); result.push({ id: 'weather-widget', transparent: Boolean(widgetSettings.weather.transparent), @@ -1343,8 +1688,8 @@ const App = () => { tab.number === activeTab)?.config_json || null} + activeTab={tabNumber} + activeTabConfigJson={tabs.find((tab) => tab.number === tabNumber)?.config_json || null} allTabConfigs={tabs} /> @@ -1352,8 +1697,8 @@ const App = () => { }); } - if (widgetSettings.chores.enabled && isWidgetAssignedToTab('chores', activeTab)) { - const dbLayout = getWidgetLayoutForTab('chores', activeTab); + if (widgetSettings.chores.enabled && include('chores')) { + const dbLayout = layoutOf('chores'); result.push({ id: 'chores-widget', transparent: Boolean(widgetSettings.chores.transparent), @@ -1370,8 +1715,8 @@ const App = () => { }); } - if (widgetSettings.photos.enabled && isWidgetAssignedToTab('photos', activeTab)) { - const dbLayout = getWidgetLayoutForTab('photos', activeTab); + if (widgetSettings.photos.enabled && include('photos')) { + const dbLayout = layoutOf('photos'); result.push({ id: 'photos-widget', transparent: Boolean(widgetSettings.photos.transparent), @@ -1393,9 +1738,9 @@ const App = () => { if (!pSettings.enabled) return; const pluginWidgetName = `plugin:${plugin.filename}`; - if (!isWidgetAssignedToTab(pluginWidgetName, activeTab)) return; + if (!include(pluginWidgetName)) return; - const dbLayout = getWidgetLayoutForTab(pluginWidgetName, activeTab); + const dbLayout = layoutOf(pluginWidgetName); result.push({ id: `plugin-${plugin.filename}`, transparent: Boolean(pSettings.transparent), @@ -1421,8 +1766,198 @@ const App = () => { }); }); + // Stacks: their widgets, drawn as the grid would, in one spot. + widgetStacks.forEach((saved) => { + const name = stackName(saved.id); + if (!include(name)) return; + // As the mode on now has it (utils/widgetStacks.js stackInMode). + const stack = stackInMode(saved, shownMode?.id || null); + const items = stack.widgets + .filter((member) => enabledWidgetNames.includes(member) && !takeoverShowing?.has(member)) + .map((member) => ({ + name: member, + label: stackMemberLabel(member), + content: makeWidgetList(tabNumber, (n) => n === member, () => null)[0]?.content || null, + })) + .filter((item) => item.content); + if (items.length === 0) return; + result.push({ + id: `stack-${stack.id}`, + transparent: false, + defaultPosition: { x: 0, y: 0 }, + defaultSize: { width: 12, height: 4 }, + minWidth: 2, + minHeight: 2, + savedLayout: layoutOf(name), + content: ( + + ), + }); + }); + return result; - }, [widgetSettings, pluginSettings, activeTab, widgetAssignments, installedPlugins, displayTheme, themeColors, demoStatus.demo, hiddenControls, frameTouch]); + }; + + const stackMemberLabel = (name) => { + if (name.startsWith('plugin:')) { + const plugin = installedPlugins.find((item) => `plugin:${item.filename}` === name); + return plugin?.name || name.slice('plugin:'.length).replace(/\.html?$/i, ''); + } + return name.charAt(0).toUpperCase() + name.slice(1); + }; + + const widgetSizeOf = (name) => { + const placed = (widgetAssignments[name] || []).find((item) => item.layout_w); + if (placed) return { w: placed.layout_w, h: placed.layout_h }; + const plugin = installedPlugins.find((item) => `plugin:${item.filename}` === name); + if (plugin) { + const { defaultSize } = pluginGridSize(plugin); + return { w: defaultSize.width, h: defaultSize.height }; + } + return { w: 6, h: 4 }; + }; + const dashboardGridRef = useRef(null); + const carry = useCarryToTab({ + tabs, + activeTab, + setTab: setActiveTab, + dockTargetAt, + sizeOf: (name) => getWidgetLayoutForTab(name, activeTab) || widgetSizeOf(name), + placedOn: () => { + const placed = {}; + widgetsOnActiveTab.forEach(({ name }) => { + const item = getWidgetLayoutForTab(name, activeTab); + if (item) placed[name] = item; + }); + return placed; + }, + gridRoot: () => dashboardGridRef.current, + onDrop: ({ name, fromTab, toTab, cell }) => { void moveWidgetToTab(name, fromTab, toTab, cell); }, + onDropAdd: ({ name, fromTab }) => { + pendingTabMoveRef.current = { name, fromTab }; + setShowTabIconModal(true); + }, + }); + const carriedName = carry.carrying?.name || null; + + // ---- Stacking in Move/Resize: hold a widget over another to stack them ---- + const [stackDrop, setStackDrop] = useState(null); // { target, rect, adding } + const stackDropRef = useRef(null); + stackDropRef.current = stackDrop; + const stackDwellRef = useRef({ target: null, timer: null }); + const clearStackDwell = () => { + clearTimeout(stackDwellRef.current.timer); + stackDwellRef.current = { target: null, timer: null }; + setStackDrop(null); + }; + const stackTargetAt = (dragged, x, y) => { + // A Stack isn't put inside another. + if (isStackName(dragged)) return null; + for (const el of document.elementsFromPoint(x, y)) { + const holder = el.closest?.('[data-widget-name]'); + const name = holder?.getAttribute('data-widget-name'); + if (name && name !== dragged && !holder.closest('[data-widget-name="' + CSS.escape(dragged) + '"]')) { + return { name, el: holder }; + } + } + return null; + }; + const handleWidgetDrag = (name, x, y) => { + carry.onDrag(name, x, y); + if (carry.carrying) return; + const hit = stackTargetAt(name, x, y); + if ((hit?.name || null) === stackDwellRef.current.target) { + // The grid shuffles widgets while one is dragged: keep the outline on the target. + const shown = stackDropRef.current; + if (shown && hit) { + const rect = hit.el.getBoundingClientRect(); + if (rect.left !== shown.rect.left || rect.top !== shown.rect.top || rect.width !== shown.rect.width) setStackDrop({ ...shown, rect }); + } + return; + } + clearStackDwell(); + if (!hit) return; + stackDwellRef.current = { + target: hit.name, + timer: setTimeout(() => { + setStackDrop({ target: hit.name, rect: hit.el.getBoundingClientRect(), adding: isStackName(hit.name) }); + }, 550), + }; + }; + const handleWidgetDragStop = (name, x, y) => { + const drop = stackDropRef.current; + clearStackDwell(); + if (drop && name) { + void stackWidgets(name, drop.target); + return true; + } + return carry.onDragStop(name, x, y); + }; + + // Put `dragged` with `target` on the open tab: a new Stack where the target + // was, or one more widget in the target Stack. + const stackWidgets = async (dragged, target) => { + const tab = activeTab; + const existing = isStackName(target) ? widgetStacks.find((stack) => stackName(stack.id) === target) : null; + if (isStackName(target) && !existing) return; + const stack = stackWith(target, dragged, existing); + const stacks = existing + ? widgetStacks.map((item) => (item.id === existing.id ? stack : item)) + : [...widgetStacks, stack]; + try { + const saved = await axios.patch(`${API_DEVICE_URL}/settings`, { [WIDGET_STACKS_KEY]: { stacks } }); + hydrateFromDeviceSettings(saved.data || {}); + const remove = (name) => axios.delete(`${API_DEVICE_URL}/widget-assignments/${encodeURIComponent(`${tab}::${name}`)}`).catch(() => {}); + if (!existing) { + const spot = getWidgetLayoutForTab(target, tab) || { x: 0, y: 0, w: 12, h: 4 }; + const name = stackName(stack.id); + await axios.post(`${API_DEVICE_URL}/widget-assignments`, { widget_name: name, tabNumber: tab }); + await axios.patch(`${API_DEVICE_URL}/widget-assignments/layout/bulk`, { + layouts: [{ widget_name: name, tabNumber: tab, layout_x: spot.x, layout_y: spot.y, layout_w: spot.w, layout_h: spot.h }], + }); + await remove(target); + } + await remove(dragged); + } catch (error) { + console.error('Error stacking widgets:', error); + } + await fetchWidgetAssignments(); + }; + + // Takeovers (hooks/useTakeovers.js): widgets taking over part of the wall. + // One showing there is left out of the grid, so it never runs twice. + const takeovers = useTakeovers({ + rules: displayModes.settings.takeovers, + haStates: displayModes.haStates, + pinned: displayModes.pinned, + enabled: !isMobile && deviceSettingsLoaded, + }); + const takeoverShowing = takeovers.showing; + const takeoverNameFor = (name) => { + if (name.startsWith('plugin:')) { + const plugin = installedPlugins.find((item) => `plugin:${item.filename}` === name); + return plugin?.name || name.slice('plugin:'.length).replace(/\.html?$/i, ''); + } + return name.charAt(0).toUpperCase() + name.slice(1); + }; + + const widgets = useMemo( + () => makeWidgetList( + activeTab, + (name) => name !== carriedName + && !takeoverShowing.has(name) + && isWidgetAssignedToTab(name, activeTab), + layoutOnActiveTab + ), + // eslint-disable-next-line react-hooks/exhaustive-deps + [widgetSettings, pluginSettings, activeTab, widgetAssignments, installedPlugins, displayTheme, themeColors, demoStatus.demo, hiddenControls, frameTouch, carriedName, takeoverShowing, widgetStacks, stackStates, shownMode?.id] + ); // Mobile stack (issue #118): same widget content nodes, fixed order, photos // excluded, grid metadata ignored. @@ -1517,6 +2052,16 @@ const App = () => { 🏖️ Vacation Mode )} + {/* The mode label and its menu (utils/displayModes.js). */} + {showModeBadge && ( + + )} {/* Its corner is the Save Layout button's while the layout is unlocked, which is never for long. */} {widgetsLocked && } @@ -1529,8 +2074,64 @@ const App = () => { {isMobile && mobileWidgets.length > 0 && ( )} + {carry.overlay} + setRemoveRequest(null)} + undo={widgetNotice ? { message: widgetNotice.message, canUndo: Boolean(widgetNotice.undo) } : null} + onUndo={undoRemoveWidget} + onUndoClose={() => setWidgetNotice(null)} + /> + {stackDrop && ( + + )} + {!screensaverActive && ( + makeWidgetList(activeTab, (n) => n === name, () => ({ x: 0, y: 0, w: 12, h: 6 }))[0]?.content || null} + nameFor={takeoverNameFor} + onDismiss={takeovers.dismiss} + /> + )} {!isMobile && widgets.length > 0 && ( + { isActive={widgetsActive} expandSettings={screensaverSettings} onExpandedSizeChange={setExpandedWidgetSize} + onWidgetDrag={handleWidgetDrag} + onWidgetDragStop={handleWidgetDragStop} + widgetMenu={widgetMenu} /> + )} {shouldRunWeatherBackgroundPrefetch && ( @@ -1627,6 +2232,9 @@ const App = () => { covers the bottom action buttons of the panel's dialogs. */} {!(isMobile && showAdminPanel) && ( { onOpenSettings={toggleAdminPanel} onRefresh={handlePageRefresh} // A widget filling the whole page covers the dock too. - hidden={dockHidden || expandedWidgetSize === 'full'} + hidden={!carry.dragging && (dockHidden || expandedWidgetSize === 'full')} underDialogs={showAdminPanel} theme={displayTheme} themeMode={themeMode} @@ -1659,7 +2267,10 @@ const App = () => { setShowTabIconModal(false)} + onClose={() => { + pendingTabMoveRef.current = null; + setShowTabIconModal(false); + }} onSave={handleSaveTab} /> diff --git a/client/src/components/AdminPanel.jsx b/client/src/components/AdminPanel.jsx index f593a04a..e21ff2d6 100644 --- a/client/src/components/AdminPanel.jsx +++ b/client/src/components/AdminPanel.jsx @@ -72,7 +72,7 @@ import { API_BASE_URL } from '../utils/apiConfig.js'; import { editWholeNumber, savedWholeNumber } from '../utils/wholeNumberField.js'; import { pinErrorText } from '../utils/pinErrors.js'; import { revealTabPicker } from '../utils/revealTabPicker.js'; -import { nextExpandedPlugin, firstMissingPluginFilename } from '../utils/pluginAccordion.js'; +import { EXPAND_PLUGIN_EVENT, nextExpandedPlugin, firstMissingPluginFilename } from '../utils/pluginAccordion.js'; import { getDeviceApiBase, getDeviceName, setDeviceName, isValidDeviceName } from '../utils/deviceName.js'; import { fetchPinRemembered, setPinRemembered, forgetPinOnAllDevices, shouldPromptForPin } from '../utils/adminPinDevice.js'; import PinModal from './PinModal'; @@ -96,6 +96,9 @@ import { AdminFooter, AdminGroup, AdminNote, AdminRow } from './AdminRows.jsx'; import SoundPicker from './SoundPicker'; import ControlsOnDisplay from './ControlsOnDisplay'; import ExpandWidgetsSettings from './ExpandWidgetsSettings'; +import ModesSettings from './ModesSettings.jsx'; +import StacksSettings from './StacksSettings.jsx'; +import { WIDGET_STACKS_KEY, isStackName, normalizeStacks, stackName } from '../utils/widgetStacks.js'; import { frameNameFor } from '../utils/pluginLocalStore.js'; import useFetchTabs from '../hooks/useFetchTabs.js'; import { MAX_DOCK_HIDE_MINUTES, screensaverPreemptsDock } from '../utils/dockIdleHide.js'; @@ -282,6 +285,14 @@ const AdminPanel = ({ setWidgetSettings, onPluginsChanged, onTabsChanged, onRequ // expand button, so toggling it never expands the row. const [expandedPlugin, setExpandedPlugin] = useState(null); const toggleExpandedPlugin = (filename) => setExpandedPlugin((current) => nextExpandedPlugin(current, filename)); + // A widget menu on the dashboard asking for one plugin's settings. + useEffect(() => { + const expand = (event) => { + if (typeof event.detail?.filename === 'string') setExpandedPlugin(event.detail.filename); + }; + window.addEventListener(EXPAND_PLUGIN_EVENT, expand); + return () => window.removeEventListener(EXPAND_PLUGIN_EVENT, expand); + }, []); const [loadingGithub, setLoadingGithub] = useState(false); // The store list loads itself on opening Plugins (issue #273): whether it // has, and why not when it couldn't. @@ -302,6 +313,9 @@ const AdminPanel = ({ setWidgetSettings, onPluginsChanged, onTabsChanged, onRequ const [checkingPin, setCheckingPin] = useState(true); const { tabs, fetchTabs } = useFetchTabs(API_DEVICE_URL); const [widgetAssignments, setWidgetAssignments] = useState({}); + // Widgets shown inside a Stack that's on a tab: on the dashboard, so not + // "enabled but on no tab" (utils/widgetStacks.js). + const [stackedWidgets, setStackedWidgets] = useState(() => new Set()); const [pluginSettings, setPluginSettings] = useState({}); const [pluginAssignments, setPluginAssignments] = useState({}); // Values for settings a plugin declared in its manifest (issue #105 Phase 2), @@ -635,6 +649,16 @@ const AdminPanel = ({ setWidgetSettings, onPluginsChanged, onTabsChanged, onRequ setWidgetAssignments(coreAssignments); setPluginAssignments(pluginAssign); + try { + const { data } = await axios.get(`${API_DEVICE_URL}/settings`); + const placed = new Set(assignments.map((row) => row.widget_name).filter(isStackName)); + const members = normalizeStacks(data?.[WIDGET_STACKS_KEY]).stacks + .filter((stack) => placed.has(stackName(stack.id))) + .flatMap((stack) => stack.widgets); + setStackedWidgets(new Set(members)); + } catch { + setStackedWidgets(new Set()); + } } catch (error) { console.error('Error fetching widget assignments:', error); setWidgetAssignments({}); @@ -876,7 +900,7 @@ const AdminPanel = ({ setWidgetSettings, onPluginsChanged, onTabsChanged, onRequ return Object.keys(DEFAULT_WIDGET_SETTINGS).filter((widgetName) => { const isEnabled = Boolean(normalizedSettings?.[widgetName]?.enabled); const selectedTabNumbers = assignmentsToValidate?.[widgetName]; - return isEnabled && (!Array.isArray(selectedTabNumbers) || selectedTabNumbers.length === 0); + return isEnabled && !stackedWidgets.has(widgetName) && (!Array.isArray(selectedTabNumbers) || selectedTabNumbers.length === 0); }); }; @@ -894,7 +918,7 @@ const AdminPanel = ({ setWidgetSettings, onPluginsChanged, onTabsChanged, onRequ const pluginWidgetName = `plugin:${filename}`; const selectedTabNumbers = assignmentsToValidate?.[pluginWidgetName]; - if (!Array.isArray(selectedTabNumbers) || selectedTabNumbers.length === 0) { + if (!stackedWidgets.has(pluginWidgetName) && (!Array.isArray(selectedTabNumbers) || selectedTabNumbers.length === 0)) { missing.push(pluginWidgetName); } @@ -2028,7 +2052,8 @@ const AdminPanel = ({ setWidgetSettings, onPluginsChanged, onTabsChanged, onRequ {['chores', 'calendar', 'photos', 'weather'].filter((widget) => widgetSettings[widget]).map((widget) => { const config = widgetSettings[widget]; - const hasRequiredTabsError = Boolean(config.enabled) && (!Array.isArray(widgetAssignments[widget]) || widgetAssignments[widget].length === 0); + const inStack = stackedWidgets.has(widget); + const hasRequiredTabsError = Boolean(config.enabled) && !inStack && (!Array.isArray(widgetAssignments[widget]) || widgetAssignments[widget].length === 0); return ( Required: select at least one tab when this widget is enabled. - : 'Select which tabs this widget should appear on.'} + : (inStack ? t('admin:stacks.alsoInStack') : 'Select which tabs this widget should appear on.')} wide control={( @@ -2105,6 +2130,15 @@ const AdminPanel = ({ setWidgetSettings, onPluginsChanged, onTabsChanged, onRequ ); })} + {/* Stacks: several widgets in one spot (utils/widgetStacks.js). */} + navigate({ tab: 'system', section: 'connections' })} + onChanged={async () => { + await fetchWidgetAssignments(); + await onTabsChanged?.(); + }} + /> + + + + ); + } + + const helperState = ha.helper ? connection.states?.[ha.helper] : null; + const options = [HELPER_AUTOMATIC, HELPER_NORMAL, ...modes.modes.map((mode) => mode.name)]; + const unreachable = connection.reachable === false; + + return ( + + {unreachable && ( + + {t('admin:modes.ha.unreachable')} + + )} + {problem && ( + + setProblem(null)}>{problem} + + )} + + {/* The mode helper */} + {ha.helper ? ( + + + + + {helperState?.attributes?.friendly_name || ha.title || t('admin:modes.ha.helperFallback')} + + {ha.helper} + + + {options.map((option) => ( + + ))} + + + {t('admin:modes.ha.helperHow')} + + + + + ) : ( + + {t('admin:modes.ha.letHaSwitch')} + {t('admin:modes.ha.letHaSwitchHelp')} + {!choosing ? ( + + setTitle(e.target.value.slice(0, 60))} + sx={{ flex: '1 1 240px' }} + /> + + + + ) : ( + + { if (id) void run({ entity_id: id }, t('admin:modes.ha.connectedTo', { id })); }} + sx={{ flex: '1 1 300px' }} + /> + + + )} + + )} + + {ha.helper && ( + + {t('admin:modes.ha.reportHelp')} + {' '} + {sensorIdFor(ha.helper)} + + )} + control={( + onSaveHa({ report: e.target.checked }, t('admin:modes.savedBehavior'))} + slotProps={{ input: { 'aria-label': t('admin:modes.ha.report') } }} + /> + )} + /> + )} + + onSaveHa({ wake: ids }, t('admin:modes.savedBehavior'))} + sx={{ flex: 1, minWidth: 260 }} + /> + )} + /> + + ); +}; + +export default ModesHaSettings; diff --git a/client/src/components/ModesSettings.jsx b/client/src/components/ModesSettings.jsx new file mode 100644 index 00000000..2cf73a85 --- /dev/null +++ b/client/src/components/ModesSettings.jsx @@ -0,0 +1,879 @@ +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import axios from 'axios'; +import { + Alert, + Box, + Button, + ButtonBase, + Chip, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + MenuItem, + Stack, + Switch, + TextField, + ToggleButton, + ToggleButtonGroup, + Typography, +} from '@mui/material'; +import { + Add, + AlarmOutlined, + HomeOutlined, + Save, + ToggleOnOutlined, + VerticalAlignBottom, + VerticalAlignTop, + Fullscreen, + PictureInPictureAltOutlined, +} from '@mui/icons-material'; +import { DURATIONS, LEVELS, newTakeoverId } from '../utils/takeovers.js'; +import Autocomplete from '@mui/material/Autocomplete'; +import { useTranslation } from 'react-i18next'; +import { AdminFooter, AdminGroup, AdminNote, AdminRow } from './AdminRows.jsx'; +import ModesHaSettings from './ModesHaSettings.jsx'; +import { StackModeRows } from './StackModeFields.jsx'; +import { WIDGET_STACKS_KEY, normalizeStacks, withModeSetting } from '../utils/widgetStacks.js'; +import HaEntityPicker, { entitiesOfKind, statesFor } from './HaEntityPicker.jsx'; +import { API_BASE_URL } from '../utils/apiConfig.js'; +import { getDeviceApiBase } from '../utils/deviceName.js'; +import { editWholeNumber } from '../utils/wholeNumberField.js'; +import { + DISPLAY_MODES_KEY, + MINUTES_PER_DAY, + MODE_COLORS, + findOverlaps, + newModeId, + normalizeModesSettings, + scheduleDaysKey, +} from '../utils/displayModes.js'; + +const DEVICE_SETTINGS_UPDATED_EVENT = 'homeglow:device-settings-updated'; +const DAY_ORDER = [1, 2, 3, 4, 5, 6, 0]; +const CORE_WIDGETS = ['calendar', 'weather', 'chores', 'photos']; + +const emptyMode = () => ({ + id: newModeId(), + name: '', + color: MODE_COLORS[0], + schedule: { days: [1, 2, 3, 4, 5], start: '06:00', end: '09:00' }, + startTab: null, + dim: false, + alarm: null, + when: null, + onlyHome: [], +}); + +const formatClock = (minutes, locale) => { + const date = new Date(2000, 0, 1, Math.floor(minutes / 60), minutes % 60); + return date.toLocaleTimeString(locale, { hour: 'numeric', minute: '2-digit' }); +}; + +/** + * Admin → Displays → Modes: this display's modes (utils/displayModes.js). + * Modes save as they're added, changed or deleted; the behavior settings save + * with the section's Save button. Everything is this display's own, kept in + * its device settings. + */ +const ModesSettings = ({ onOpenConnections }) => { + const { t, i18n } = useTranslation(['admin', 'common']); + const apiDeviceUrl = getDeviceApiBase(API_BASE_URL); + const [stored, setStored] = useState(null); + const [deviceSettings, setDeviceSettings] = useState({}); + const [behavior, setBehavior] = useState(null); + const [tabs, setTabs] = useState([]); + const [editing, setEditing] = useState(null); + const [message, setMessage] = useState(null); + const [saving, setSaving] = useState(false); + // Home Assistant: what this display's modes read, and its entities for the + // pickers (loaded once it's known to be connected). + const [haConnection, setHaConnection] = useState(null); + const [entities, setEntities] = useState([]); + const [entitiesLoading, setEntitiesLoading] = useState(false); + const [plugins, setPlugins] = useState([]); + const [editingTakeover, setEditingTakeover] = useState(null); + + const flash = (type, text) => { + const next = { type, text }; + setMessage(next); + if (type === 'success') setTimeout(() => setMessage((current) => (current === next ? null : current)), 4000); + }; + + const load = useCallback(async () => { + try { + const [settingsResponse, tabsResponse] = await Promise.all([ + axios.get(`${apiDeviceUrl}/settings`), + axios.get(`${apiDeviceUrl}/tabs`), + ]); + const settings = settingsResponse.data || {}; + const modes = normalizeModesSettings(settings[DISPLAY_MODES_KEY]); + setDeviceSettings(settings); + setStored(modes); + setBehavior(modes); + setTabs(Array.isArray(tabsResponse.data) ? tabsResponse.data : []); + axios.get(`${API_BASE_URL}/api/widgets`) + .then(({ data }) => setPlugins(Array.isArray(data) ? data : [])) + .catch(() => setPlugins([])); + try { + const { data } = await axios.get(`${apiDeviceUrl}/modes/ha`); + setHaConnection({ ...data, configured: Boolean(data?.connected) }); + } catch { + setHaConnection({ configured: false, states: {} }); + } + } catch (error) { + console.error('Error loading modes:', error); + flash('error', t('admin:modes.loadFailed')); + } + }, [apiDeviceUrl, t]); + + useEffect(() => { void load(); }, [load]); + + const haConfigured = Boolean(haConnection?.configured); + useEffect(() => { + if (!haConfigured) return; + setEntitiesLoading(true); + axios.get(`${apiDeviceUrl}/modes/ha/entities`, { params: { kind: 'any' } }) + .then(({ data }) => setEntities(Array.isArray(data?.entities) ? data.entities : [])) + .catch(() => setEntities([])) + .finally(() => setEntitiesLoading(false)); + }, [haConfigured, apiDeviceUrl]); + // Names from the entity list, else from what the display last read (so they + // still show while Home Assistant is away). + const entityName = (id) => entities.find((e) => e.entity_id === id)?.name + || haConnection?.states?.[id]?.attributes?.friendly_name + || id; + + + const persist = async (next, successText) => { + setSaving(true); + try { + await axios.patch(`${apiDeviceUrl}/settings`, { [DISPLAY_MODES_KEY]: next }); + // Mode names are the helper's options: keep them in step. + if (next?.ha?.helper) await axios.post(`${apiDeviceUrl}/modes/ha/sync`, {}).catch(() => {}); + window.dispatchEvent(new Event(DEVICE_SETTINGS_UPDATED_EVENT)); + await load(); + flash('success', successText); + return true; + } catch (error) { + console.error('Error saving modes:', error); + flash('error', error?.response?.data?.error || t('admin:modes.saveFailed')); + return false; + } finally { + setSaving(false); + } + }; + + const tabName = (number) => { + const tab = tabs.find((item) => item.number === Number(number)); + return tab?.label || t('admin:modes.tabNumber', { number }); + }; + + const widgetLabel = (name) => (name.startsWith('plugin:') + ? (plugins.find((p) => `plugin:${p.filename}` === name)?.name || name.slice('plugin:'.length).replace(/\.html?$/i, '')) + : t(`admin:screensaver.expand.widgetNames.${name}`, { defaultValue: name })); + + // Widgets turned on here: what can take over. + const takeoverWidgets = [ + ...CORE_WIDGETS.filter((name) => deviceSettings.widgetSettings?.[name]?.enabled), + ...plugins + .filter((p) => deviceSettings.pluginSettings?.[p.filename]?.enabled) + .map((p) => `plugin:${p.filename}`), + ].map((name) => ({ name, label: widgetLabel(name) })); + + const scheduleText = (schedule) => { + if (!schedule) return t('admin:modes.noSchedule'); + const key = scheduleDaysKey(schedule.days); + const days = key + ? t(`admin:modes.days.${key}`) + : DAY_ORDER.filter((day) => schedule.days.includes(day)).map((day) => t(`admin:modes.dayShort.${day}`)).join(', '); + const [sh, sm] = schedule.start.split(':').map(Number); + const [eh, em] = schedule.end.split(':').map(Number); + return `${days} · ${formatClock(sh * 60 + sm, i18n.language)} – ${formatClock(eh * 60 + em, i18n.language)}`; + }; + + if (!stored || !behavior) { + return message ? {message.text} : null; + } + + // This display's Stacks, for the mode window's "Stacks in this mode". + const stacks = normalizeStacks(deviceSettings[WIDGET_STACKS_KEY]).stacks; + const saveStacks = (next) => axios.patch(`${apiDeviceUrl}/settings`, { [WIDGET_STACKS_KEY]: { stacks: next } }); + + // stackSettings: { stackId: setting | null } changed in the window. + const saveMode = async (mode, stackSettings = {}) => { + const changed = Object.keys(stackSettings); + if (changed.length) { + try { + await saveStacks(stacks.map((stack) => (changed.includes(stack.id) + ? withModeSetting(stack, mode.id, stackSettings[stack.id]) + : stack))); + } catch (error) { + console.error('Error saving stacks for the mode:', error); + flash('error', t('admin:modes.saveFailed')); + return; + } + } + const exists = stored.modes.some((item) => item.id === mode.id); + const modes = exists + ? stored.modes.map((item) => (item.id === mode.id ? mode : item)) + : [...stored.modes, mode]; + if (await persist({ ...stored, modes }, t('admin:modes.savedMode', { name: mode.name }))) setEditing(null); + }; + + const deleteMode = async (mode) => { + if (!window.confirm(t('admin:modes.confirmDelete', { name: mode.name }))) return; + const modes = stored.modes.filter((item) => item.id !== mode.id); + // Its Stack settings go with it. + if (stacks.some((stack) => stack.modes?.[mode.id])) { + await saveStacks(stacks.map((stack) => withModeSetting(stack, mode.id, null))).catch(() => {}); + } + if (await persist({ ...stored, modes }, t('admin:modes.deleted', { name: mode.name }))) setEditing(null); + }; + + const saveBehavior = () => persist( + normalizeModesSettings({ ...behavior, modes: stored.modes }), + t('admin:modes.savedBehavior') + ); + const setField = (patch) => setBehavior((prev) => ({ ...prev, ...patch })); + + return ( + <> + + {t('admin:modes.help')} + + + {message && ( + setMessage(null)} sx={{ mb: 2 }}>{message.text} + )} + + } onClick={() => setEditing(emptyMode())}> + {t('admin:modes.add')} + + )} + > + {stored.modes.length === 0 && {t('admin:modes.empty')}} + {stored.modes.map((mode) => { + return ( + + + + + {mode.name} + + + + {(mode.schedule || !(mode.alarm || mode.when)) && } + {mode.startTab && } + {mode.dim && } + {mode.alarm && ( + } + label={t('admin:modes.ha.alarmChip', { until: formatClock(Number(mode.alarm.until.slice(0, 2)) * 60 + Number(mode.alarm.until.slice(3)), i18n.language) })} + /> + )} + {mode.when && ( + } label={t('admin:modes.ha.whenChip', { name: entityName(mode.when.entity), state: mode.when.state })} /> + )} + {mode.onlyHome.length > 0 && ( + } label={t('admin:modes.ha.homeChip', { names: mode.onlyHome.map(entityName).join(', ') })} /> + )} + + + + ); + })} + + + { + await load(); + window.dispatchEvent(new Event(DEVICE_SETTINGS_UPDATED_EVENT)); + flash('success', text); + }} + onSaveHa={(patch, text) => persist({ ...stored, ha: { ...stored.ha, ...patch } }, text)} + onOpenConnections={onOpenConnections} + /> + + setEditingTakeover({ + id: newTakeoverId(), widget: takeoverWidgets[0]?.name || '', entity: '', op: 'is', state: '', level: 'high', place: 'bottom', seconds: 0, enabled: true, + })} + onEdit={setEditingTakeover} + onToggle={(rule, enabled) => persist( + { ...stored, takeovers: stored.takeovers.map((item) => (item.id === rule.id ? { ...item, enabled } : item)) }, + t('admin:modes.savedBehavior') + )} + onOpenConnections={onOpenConnections} + /> + + + setField({ showLabel: e.target.checked })} + slotProps={{ input: { 'aria-label': t('admin:modes.showLabel') } }} + /> + )} + /> + setField({ returnMinutes: editWholeNumber(e.target.value) })} + slotProps={{ htmlInput: { min: 0, max: 120, step: 1 } }} + sx={{ width: 130 }} + /> + )} + /> + + + + + + + {editingTakeover && ( + item.id === editingTakeover.id)} + widgets={takeoverWidgets} + entities={entities} + entitiesLoading={entitiesLoading} + saving={saving} + onCancel={() => setEditingTakeover(null)} + onSave={async (rule) => { + const exists = stored.takeovers.some((item) => item.id === rule.id); + const takeovers = exists + ? stored.takeovers.map((item) => (item.id === rule.id ? rule : item)) + : [...stored.takeovers, rule]; + if (await persist({ ...stored, takeovers }, t('admin:modes.takeovers.saved', { name: widgetLabel(rule.widget) }))) setEditingTakeover(null); + }} + onDelete={async (rule) => { + if (!window.confirm(t('admin:modes.takeovers.confirmDelete', { name: widgetLabel(rule.widget) }))) return; + const takeovers = stored.takeovers.filter((item) => item.id !== rule.id); + if (await persist({ ...stored, takeovers }, t('admin:modes.takeovers.deleted'))) setEditingTakeover(null); + }} + /> + )} + + {editing && ( + item.id === editing.id)} + otherModes={stored.modes.filter((item) => item.id !== editing.id)} + tabs={tabs} + tabName={tabName} + saving={saving} + onCancel={() => setEditing(null)} + onSave={saveMode} + onDelete={deleteMode} + scheduleText={scheduleText} + locale={i18n.language} + haConnected={haConfigured} + entities={entities} + entitiesLoading={entitiesLoading} + helperOn={Boolean(stored.ha.helper)} + stacks={stacks} + widgetLabel={widgetLabel} + /> + )} + + ); +}; + +const LEVEL_DOT = { urgent: 'var(--hg-error, #d32f2f)', high: '#E0A030', normal: 'var(--accent)' }; +const PLACE_ICON = { bottom: , top: , full: }; + +// Admin → Modes → Takeovers: widgets that take over part of the wall. +const TakeoversGroup = ({ rules, haConfigured, widgetLabel, entityName, onAdd, onEdit, onToggle, onOpenConnections }) => { + const { t } = useTranslation(['admin']); + const durationText = (seconds) => (seconds ? t('admin:modes.takeovers.for', { time: durationLabel(t, seconds) }) : t('admin:modes.takeovers.whileOn')); + return ( + } onClick={onAdd}>{t('admin:modes.takeovers.add')} + ) : null} + > + + {t('admin:modes.takeovers.help')} + + {!haConfigured && ( + + {t('admin:modes.takeovers.needsHa')} + + + )} + {haConfigured && rules.length === 0 && ( + + + {t('admin:modes.takeovers.empty')} + + )} + {rules.map((rule) => ( + + + + {widgetLabel(rule.widget)} + + {t(rule.op === 'is_not' ? 'admin:modes.takeovers.whenIsNot' : 'admin:modes.takeovers.whenIs', { name: entityName(rule.entity), state: rule.state })} + + + + + + + + onToggle(rule, e.target.checked)} + slotProps={{ input: { 'aria-label': t('admin:modes.takeovers.enabled', { name: widgetLabel(rule.widget) }) } }} + /> + + + ))} + + ); +}; + +const durationLabel = (t, seconds) => (seconds < 60 + ? t('admin:modes.takeovers.seconds', { count: seconds }) + : t('admin:modes.takeovers.minutes', { count: seconds / 60 })); + +const TakeoverDialog = ({ rule, isNew, widgets, entities, entitiesLoading, saving, onCancel, onSave, onDelete }) => { + const { t } = useTranslation(['admin']); + const [draft, setDraft] = useState(rule); + const set = (patch) => setDraft((prev) => ({ ...prev, ...patch })); + const entity = draft.entity ? entities.find((e) => e.entity_id === draft.entity) : null; + const isPlugin = draft.widget.startsWith('plugin:'); + const canSave = draft.widget && draft.entity && draft.state && !saving; + const toggleSx = { '& .MuiToggleButton-root': { textTransform: 'none', flex: 1, gap: 0.75, py: 1 } }; + + return ( + + {isNew ? t('admin:modes.takeovers.newTitle') : t('admin:modes.takeovers.editTitle')} + + + set({ widget: e.target.value })}> + {widgets.map((widget) => {widget.label})} + + + + {t('admin:modes.takeovers.when')} + + { + const picked = entities.find((e) => e.entity_id === id); + // Most "show it while something's on" rules read best as + // "is not off" for things that have more than on and off. + const suggestions = statesFor(picked); + const offish = suggestions.includes('off') ? 'off' : ''; + set({ entity: id || '', state: offish || suggestions[0] || '', op: offish && !['binary_sensor', 'input_boolean', 'switch'].includes((id || '').split('.')[0]) ? 'is_not' : 'is' }); + }} + label={t('admin:modes.takeovers.entity')} + /> + + set({ op: e.target.value })} sx={{ width: 150 }}> + {t('admin:modes.takeovers.ops.is')} + {t('admin:modes.takeovers.ops.is_not')} + + set({ state: value.slice(0, 100) })} + renderInput={(params) => ( + + )} + sx={{ flex: 1 }} + /> + + + + + + {t('admin:modes.takeovers.importance')} + level && set({ level })} sx={toggleSx}> + {LEVELS.map((level) => ( + + + {t(`admin:modes.takeovers.levels.${level}`)} + + ))} + + {t(`admin:modes.takeovers.levelHelp.${draft.level}`)} + + + + {t('admin:modes.takeovers.where')} + place && set({ place })} sx={toggleSx}> + {['bottom', 'top', 'full'].map((place) => ( + {PLACE_ICON[place]}{t(`admin:modes.takeovers.places.${place}`)} + ))} + + {t('admin:modes.takeovers.whereHelp')} + + + set({ seconds: Number(e.target.value) })}> + {DURATIONS.map((seconds) => ( + {seconds ? durationLabel(t, seconds) : t('admin:modes.takeovers.whileOn')} + ))} + + + {isPlugin && {t('admin:modes.takeovers.pluginNote')}} + + + + {!isNew ? : } + + + + + + + ); +}; + +// One "turn on from Home Assistant" option in the mode dialog: a switch, and +// its fields while it's on. +const TriggerRow = ({ icon, title, hint, checked, onToggle, children }) => ( + + + {icon} + + {title} + {hint} + + onToggle(e.target.checked)} slotProps={{ input: { 'aria-label': title } }} /> + + {checked && {children}} + +); + +const ModeDialog = ({ mode, isNew, otherModes, tabs, tabName, saving, onCancel, onSave, onDelete, locale, haConnected, entities, entitiesLoading, helperOn, stacks = [], widgetLabel }) => { + const { t } = useTranslation(['admin']); + const [draft, setDraft] = useState(mode); + // What each Stack does in this mode, changed here: { stackId: setting | null }. + const [stackChanges, setStackChanges] = useState({}); + const set = (patch) => setDraft((prev) => ({ ...prev, ...patch })); + const setSchedule = (patch) => setDraft((prev) => ({ ...prev, schedule: { ...prev.schedule, ...patch } })); + const scheduled = Boolean(draft.schedule); + + const overlaps = useMemo(() => { + if (!scheduled || draft.schedule.days.length === 0) return []; + return findOverlaps([draft, ...otherModes]).filter((hit) => hit.a === draft.id || hit.b === draft.id); + }, [draft, otherModes, scheduled]); + + const sameTimes = scheduled && draft.schedule.start === draft.schedule.end; + const noDays = scheduled && draft.schedule.days.length === 0; + const nameTaken = otherModes.some((item) => item.name.trim().toLowerCase() === draft.name.trim().toLowerCase()); + // Home Assistant options: switched on, each needs its entity. + const [homeOn, setHomeOn] = useState(mode.onlyHome.length > 0); + const alarmMissing = Boolean(draft.alarm) && !draft.alarm.entity; + const whenMissing = Boolean(draft.when) && (!draft.when.entity || !draft.when.state); + const homeMissing = homeOn && draft.onlyHome.length === 0; + const whenEntity = draft.when?.entity ? entities.find((e) => e.entity_id === draft.when.entity) : null; + const canSave = draft.name.trim() && !nameTaken && overlaps.length === 0 && !sameTimes && !noDays && !saving + && !alarmMissing && !whenMissing && !homeMissing; + + const overlapText = (hit) => { + const other = otherModes.find((item) => item.id === (hit.a === draft.id ? hit.b : hit.a)); + const day = Math.floor(hit.from / MINUTES_PER_DAY) % 7; + return t('admin:modes.overlap', { + name: other?.name || '', + day: t(`admin:modes.dayLong.${day}`), + from: formatClock(hit.from % MINUTES_PER_DAY, locale), + to: formatClock(hit.to % MINUTES_PER_DAY, locale), + }); + }; + + return ( + + {isNew ? t('admin:modes.newTitle') : t('admin:modes.editTitle', { name: mode.name })} + + + set({ name: e.target.value.slice(0, 40) })} + error={Boolean(nameTaken)} + helperText={nameTaken ? t('admin:modes.nameTaken') : t('admin:modes.nameHelp')} + autoFocus={isNew} + /> + + + {t('admin:modes.color')} + + {MODE_COLORS.map((color) => ( + set({ color })} + aria-label={color} + aria-pressed={draft.color === color} + sx={{ + width: 36, + height: 36, + borderRadius: '50%', + backgroundColor: color, + outline: draft.color === color ? '3px solid' : 'none', + outlineColor: 'text.primary', + outlineOffset: 2, + }} + /> + ))} + + + + + + {t('admin:modes.onSchedule')} + set({ schedule: e.target.checked ? emptyMode().schedule : null })} + slotProps={{ input: { 'aria-label': t('admin:modes.onSchedule') } }} + /> + + {!scheduled && ( + + {haConnected ? t('admin:modes.ha.byHandHelp') : t('admin:modes.byHandHelp')} + + )} + {scheduled && ( + + setSchedule({ days: [...days].sort() })} + aria-label={t('admin:modes.daysLabel')} + size="small" + sx={{ flexWrap: 'wrap', '& .MuiToggleButton-root': { textTransform: 'none', minWidth: 48 } }} + > + {DAY_ORDER.map((day) => ( + + {t(`admin:modes.dayShort.${day}`)} + + ))} + + + e.target.value && setSchedule({ start: e.target.value })} + slotProps={{ inputLabel: { shrink: true } }} + sx={{ flex: 1 }} + /> + e.target.value && setSchedule({ end: e.target.value })} + slotProps={{ inputLabel: { shrink: true } }} + helperText={draft.schedule.end < draft.schedule.start ? t('admin:modes.nextDay') : ' '} + sx={{ flex: 1 }} + /> + + {noDays && {t('admin:modes.pickDays')}} + {sameTimes && {t('admin:modes.sameTimes')}} + {overlaps.map((hit) => ( + {overlapText(hit)} + ))} + + )} + + + {haConnected && ( + + {t('admin:modes.ha.triggersHeading')} + + {helperOn ? t('admin:modes.ha.triggersHelpHelper', { name: draft.name.trim() || t('admin:modes.ha.thisMode') }) : t('admin:modes.ha.triggersHelp')} + + + } + title={t('admin:modes.ha.alarm')} + hint={t('admin:modes.ha.alarmHelp')} + checked={Boolean(draft.alarm)} + onToggle={(on) => set({ + alarm: on ? { entity: entitiesOfKind(entities, 'alarm')[0]?.entity_id || '', until: '09:00' } : null, + })} + > + + set({ alarm: { ...draft.alarm, entity: id || '' } })} + label={t('admin:modes.ha.alarmSensor')} + helperText={t('admin:modes.ha.alarmSensorHelp')} + error={alarmMissing} + sx={{ flex: 2 }} + /> + e.target.value && set({ alarm: { ...draft.alarm, until: e.target.value } })} + slotProps={{ inputLabel: { shrink: true } }} + sx={{ flex: 1 }} + /> + + + + } + title={t('admin:modes.ha.when')} + hint={t('admin:modes.ha.whenHelp')} + checked={Boolean(draft.when)} + onToggle={(on) => set({ when: on ? { entity: '', state: '' } : null })} + > + + { + const entity = entities.find((e) => e.entity_id === id); + const first = statesFor(entity)[0] || ''; + set({ when: { entity: id || '', state: id ? (draft.when?.entity === id ? draft.when.state : first) : '' } }); + }} + label={t('admin:modes.ha.whenEntity')} + error={whenMissing && !draft.when?.entity} + sx={{ flex: 2 }} + /> + set({ when: { ...draft.when, state: value.slice(0, 100) } })} + renderInput={(params) => ( + + )} + sx={{ flex: 1 }} + /> + + + + } + title={t('admin:modes.ha.home')} + hint={t('admin:modes.ha.homeHelp')} + checked={homeOn} + onToggle={(on) => { + setHomeOn(on); + set({ onlyHome: on ? entitiesOfKind(entities, 'people').filter((e) => e.entity_id.startsWith('person.')).map((e) => e.entity_id) : [] }); + }} + > + set({ onlyHome: ids })} + label={t('admin:modes.ha.homeWho')} + helperText={homeMissing ? t('admin:modes.ha.homePick') : t('admin:modes.ha.homeWhoHelp')} + error={homeMissing} + /> + + + + )} + + set({ startTab: e.target.value === '' ? null : Number(e.target.value) })} + helperText={t('admin:modes.startTabHelp')} + slotProps={{ select: { displayEmpty: true }, inputLabel: { shrink: true } }} + > + {t('admin:modes.startTabNone')} + {tabs.map((tab) => ( + {tab.label || tabName(tab.number)} + ))} + + + + + {t('admin:modes.dim')} + {t('admin:modes.dimHelp')} + + set({ dim: e.target.checked })} + slotProps={{ input: { 'aria-label': t('admin:modes.dim') } }} + /> + + + {stacks.length > 0 && ( + + {t('admin:stacks.modes.inThisMode')} + {t('admin:stacks.modes.inThisModeHelp')} + ({ + key: stack.id, + title: stack.name || stack.widgets.map(widgetLabel).join(' · '), + stack, + setting: stack.id in stackChanges ? stackChanges[stack.id] : (stack.modes?.[mode.id] || null), + }))} + widgetLabel={widgetLabel} + onChange={(stackId, setting) => setStackChanges((prev) => ({ ...prev, [stackId]: setting }))} + /> + + )} + + + + {!isNew ? ( + + ) : } + + + + + + + ); +}; + +export default ModesSettings; diff --git a/client/src/components/PluginWidgetWrapper.jsx b/client/src/components/PluginWidgetWrapper.jsx index 84a53e35..2d5afb74 100644 --- a/client/src/components/PluginWidgetWrapper.jsx +++ b/client/src/components/PluginWidgetWrapper.jsx @@ -24,6 +24,10 @@ export const PLUGIN_FRAME_SANDBOX = 'allow-scripts allow-forms allow-popups'; // browser still asks the household first. Scoped to the plugin's own address. export const PLUGIN_FRAME_ALLOW = 'geolocation'; const FRAME_ORIGIN = 'null'; +// A plugin asked for (or let go of) the wall's attention: hooks/useTakeovers.js. +export const PLUGIN_ATTENTION_EVENT = 'homeglow:plugin-attention'; +// A quick one-finger flick inside a plugin frame: { direction } (1 = next). +export const FRAME_FLICK_EVENT = 'homeglow:frame-flick'; const TO_FRAME = '*'; const PluginWidgetWrapper = ({ @@ -48,6 +52,8 @@ const PluginWidgetWrapper = ({ // so a two-finger swipe between tabs works over the plugin and a blocked // pinch stays blocked on it (utils/twoFingerSwipe.js). touch = null, + // Shown inside a Stack (components/StackWidget.jsx passes it). + inStack = false, // Expanding (utils/widgetExpand.js), handed in by the widget grid: how this // plugin expands on this display ('doubleTap', 'button' or false), and how // big it is shown now ('normal', 'half', 'full'), which the plugin is told. @@ -151,6 +157,24 @@ const PluginWidgetWrapper = ({ requestCelebration(`plugin:${filename}`); return; } + // HomeGlow.attention(): raise or close this plugin's takeover + // (utils/takeovers.js). Only from this wrapper's own frame. + if (frame && event.source === frame && event.origin === iframeOrigin && event.data?.type === 'homeglow:attention') { + const { level, size } = event.data; + if (level === null || ['normal', 'high', 'urgent'].includes(level)) { + window.dispatchEvent(new CustomEvent(PLUGIN_ATTENTION_EVENT, { + detail: { widget: `plugin:${filename}`, level, size: size === 'full' || size === 'half' ? size : null }, + })); + } + return; + } + // A one-finger flick inside the frame: raised on the frame element, so + // a Stack around it (components/StackWidget.jsx) can turn the page. + if (frame && event.source === frame && event.origin === iframeOrigin && event.data?.type === 'homeglow:flick') { + const direction = event.data.direction === 1 ? 1 : -1; + iframeRef.current?.dispatchEvent(new CustomEvent(FRAME_FLICK_EVENT, { bubbles: true, detail: { direction } })); + return; + } // A double-tap or a touch inside the frame, for expanding. Raised on // the frame element so the widget grid around it hears it. const expandReport = acceptFrameExpandMessage(event, frame, iframeOrigin); @@ -204,14 +228,16 @@ const PluginWidgetWrapper = ({ // frame, and again whenever they change while it is up. const swipeOn = touch?.swipe === true; const pinchBlocked = touch?.blockPinchZoom === true; + // In a Stack: flicks inside the frame turn it (components/StackWidget.jsx). + const flickOn = inStack === true; const postTouch = useCallback(() => { const target = iframeRef.current?.contentWindow; if (!target) return; target.postMessage( - buildFrameTouchSettingsMessage({ swipe: swipeOn, blockPinchZoom: pinchBlocked, expand: expandMode }), + buildFrameTouchSettingsMessage({ swipe: swipeOn, blockPinchZoom: pinchBlocked, expand: expandMode, flick: flickOn }), TO_FRAME ); - }, [swipeOn, pinchBlocked, expandMode]); + }, [swipeOn, pinchBlocked, expandMode, flickOn]); useEffect(() => { postTouch(); }, [postTouch]); diff --git a/client/src/components/StackModeFields.jsx b/client/src/components/StackModeFields.jsx new file mode 100644 index 00000000..ba27b161 --- /dev/null +++ b/client/src/components/StackModeFields.jsx @@ -0,0 +1,191 @@ +import React from 'react'; +import { + Accordion, + AccordionDetails, + AccordionSummary, + Box, + Button, + Chip, + MenuItem, + Stack, + Switch, + TextField, + Typography, +} from '@mui/material'; +import { ExpandMore } from '@mui/icons-material'; +import { useTranslation } from 'react-i18next'; +import { RETURN_CHOICES, ROTATE_CHOICES, normalizeModeSetting } from '../utils/widgetStacks.js'; + +// What a Stack does in one mode (utils/widgetStacks.js stackInMode), edited +// the same way from the Stack's window (one row per mode) and the mode's +// window (one row per Stack). + +const timeLabel = (t, seconds) => (seconds < 60 + ? t('admin:stacks.seconds', { count: seconds }) + : t('admin:stacks.minutes', { count: seconds / 60 })); + +/** One line saying what the mode changes, or "As usual". */ +export function stackModeSummary(t, stack, setting, widgetLabel) { + if (!setting) return t('admin:stacks.modes.asUsual'); + const parts = []; + const shown = setting.widgets?.length ? stack.widgets.filter((name) => setting.widgets.includes(name)) : stack.widgets; + const first = shown.includes(setting.first) ? setting.first : shown[0]; + if (first && first !== stack.widgets[0]) parts.push(t('admin:stacks.modes.showsFirst', { name: widgetLabel(first) })); + const left = stack.widgets.filter((name) => !shown.includes(name)); + if (left.length) parts.push(t('admin:stacks.modes.leavesOut', { names: left.map(widgetLabel).join(', ') })); + if (setting.returnSeconds != null) { + parts.push(setting.returnSeconds + ? t('admin:stacks.modes.backAfter', { time: timeLabel(t, setting.returnSeconds) }) + : t('admin:stacks.modes.neverBack')); + } + if (setting.rotateSeconds) parts.push(t('admin:stacks.modes.turnsEvery', { time: timeLabel(t, setting.rotateSeconds) })); + if (setting.noJump) parts.push(t('admin:stacks.modes.noJumpShort')); + return parts.length ? parts.join(' · ') : t('admin:stacks.modes.asUsual'); +} + +/** + * The fields for one Stack in one mode. + * - stack: { widgets, returnSeconds, attention } + * - setting: the mode's setting, or null for as usual + * - onChange(setting): an object, or null for as usual + */ +export const StackModeFields = ({ stack, setting, onChange, widgetLabel }) => { + const { t } = useTranslation(['admin']); + const value = setting || {}; + // Only what differs from usual is kept; nothing left is "as usual". + const set = (patch) => onChange(normalizeModeSetting({ ...value, ...patch }, stack.widgets)); + const included = value.widgets?.length ? stack.widgets.filter((name) => value.widgets.includes(name)) : stack.widgets; + const first = included.includes(value.first) ? value.first : included[0]; + + const toggle = (name) => { + const next = included.includes(name) ? included.filter((item) => item !== name) : stack.widgets.filter((item) => item === name || included.includes(item)); + if (next.length === 0) return; // A Stack always holds one. + set({ widgets: next.length === stack.widgets.length ? undefined : next }); + }; + + return ( + + + {t('admin:stacks.modes.holds')} + + {stack.widgets.map((name) => { + const on = included.includes(name); + return ( + toggle(name)} + aria-pressed={on} + sx={{ minHeight: 36 }} + /> + ); + })} + + + + + set({ first: e.target.value })} + sx={{ flex: 1 }} + > + {included.map((name) => {widgetLabel(name)})} + + set({ returnSeconds: e.target.value === '' ? undefined : Number(e.target.value) })} + slotProps={{ select: { displayEmpty: true }, inputLabel: { shrink: true } }} + sx={{ flex: 1 }} + > + + {t('admin:stacks.modes.usualTime', { time: stack.returnSeconds ? timeLabel(t, stack.returnSeconds) : t('admin:stacks.returnNever') })} + + {RETURN_CHOICES.map((seconds) => ( + {seconds ? timeLabel(t, seconds) : t('admin:stacks.returnNever')} + ))} + + + + set({ rotateSeconds: Number(e.target.value) || undefined })} + helperText={t('admin:stacks.modes.rotateHelp')} + disabled={included.length < 2} + > + {ROTATE_CHOICES.map((seconds) => ( + + {seconds ? t('admin:stacks.modes.every', { time: timeLabel(t, seconds) }) : t('admin:stacks.modes.rotateOff')} + + ))} + + + {stack.attention.length > 0 && ( + + + {t('admin:stacks.modes.noJump')} + {t('admin:stacks.modes.noJumpHelp')} + + set({ noJump: e.target.checked || undefined })} + slotProps={{ input: { 'aria-label': t('admin:stacks.modes.noJump') } }} + /> + + )} + + {setting && ( + + + + )} + + ); +}; + +/** + * A list of rows that open into StackModeFields: one per mode (in a Stack's + * window) or one per Stack (in a mode's window). + * - rows: [{ key, title, color?, stack, setting }] + * - onChange(key, setting) + */ +export const StackModeRows = ({ rows, onChange, widgetLabel }) => { + const { t } = useTranslation(['admin']); + return ( + + {rows.map((row) => ( + + } sx={{ minHeight: 56 }}> + + {row.color && } + + {row.title} + + {stackModeSummary(t, row.stack, row.setting, widgetLabel)} + + + + + + onChange(row.key, setting)} + /> + + + ))} + + ); +}; diff --git a/client/src/components/StackWidget.jsx b/client/src/components/StackWidget.jsx new file mode 100644 index 00000000..aa7d9739 --- /dev/null +++ b/client/src/components/StackWidget.jsx @@ -0,0 +1,271 @@ +import React, { Suspense, useCallback, useEffect, useRef, useState } from 'react'; +import { Box, ButtonBase, IconButton, Typography } from '@mui/material'; +import { ChevronLeft, ChevronRight } from '@mui/icons-material'; +import { useTranslation } from 'react-i18next'; +import { FRAME_FLICK_EVENT } from './PluginWidgetWrapper.jsx'; +import { flickDirection } from '../utils/widgetStacks.js'; + +// How long the dots stay after a touch, and how long a page turn takes. +const DOTS_MS = 2500; +const TURN_MS = 380; + +const withProps = (element, props) => { + if (React.isValidElement(element) && element.type === Suspense) { + return React.cloneElement(element, {}, React.cloneElement(element.props.children, props)); + } + return React.isValidElement(element) ? React.cloneElement(element, props) : element; +}; + +const reducedMotion = () => typeof window !== 'undefined' + && window.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches; + +/** + * A Stack (utils/widgetStacks.js): several widgets in one spot, a quick + * sideways flick turning between them. Dots show which one is up, then fade. + * It goes back to its home widget after `returnSeconds` untouched: the one + * needing attention (`attention`), else `home`. With `rotateSeconds` it + * turns to the next widget that often instead, while nobody touches it. + * + * - items: [{ name, label, content }] in order + * - home: the member it shows first (the mode can choose), else the first + * - returnSeconds: 0 never goes back on its own + * - rotateSeconds: 0 never turns on its own + * - attention: the member name to show now, or null + * - isActive, expandSize, widgetSize: from the grid, passed to the widget up + */ +const StackWidget = ({ items, home = null, returnSeconds = 60, rotateSeconds = 0, attention = null, isActive = true, expandSize, widgetSize, refreshNonce }) => { + const { t } = useTranslation('common'); + const [index, setIndex] = useState(() => Math.max(0, home ? items.findIndex((item) => item.name === home) : 0)); + const [dotsShown, setDotsShown] = useState(false); + const lastTouchRef = useRef(0); + const startRef = useRef(null); + const dotsTimer = useRef(null); + const rootRef = useRef(null); + const count = items.length; + const safeIndex = Math.min(index, Math.max(0, count - 1)); + const safeIndexRef = useRef(safeIndex); + safeIndexRef.current = safeIndex; + + const showDots = useCallback(() => { + setDotsShown(true); + clearTimeout(dotsTimer.current); + dotsTimer.current = setTimeout(() => setDotsShown(false), DOTS_MS); + }, []); + useEffect(() => () => clearTimeout(dotsTimer.current), []); + + const go = useCallback((next, { byHand = true } = {}) => { + if (count === 0) return; + const clamped = Math.max(0, Math.min(count - 1, next)); + if (byHand) { + lastTouchRef.current = Date.now(); + showDots(); + } + setIndex(clamped); + }, [count, showDots]); + + const homeIndex = useCallback(() => { + const i = attention ? items.findIndex((item) => item.name === attention) : -1; + if (i >= 0) return i; + const h = home ? items.findIndex((item) => item.name === home) : -1; + return h >= 0 ? h : 0; + }, [attention, home, items]); + + // The mode changed what it holds or shows first: go there, unless someone's + // using it right now. + const shapeKey = `${home || ''}|${items.map((item) => item.name).join(',')}`; + const shapeRef = useRef(shapeKey); + useEffect(() => { + if (shapeRef.current === shapeKey) return; + shapeRef.current = shapeKey; + if (Date.now() - lastTouchRef.current > 15000) setIndex(homeIndex()); + }, [shapeKey, homeIndex]); + + // Turning on its own: the next one every so often, while untouched and + // nothing needs attention. Back to the start after the last. + useEffect(() => { + if (!rotateSeconds || count < 2 || attention) return undefined; + const timer = setInterval(() => { + if (Date.now() - lastTouchRef.current < rotateSeconds * 1000) return; + setIndex((current) => (current + 1) % count); + }, rotateSeconds * 1000); + return () => clearInterval(timer); + }, [rotateSeconds, count, attention]); + + // Something needs attention: show it, unless someone's using the stack. + useEffect(() => { + if (!attention) return; + const busy = Date.now() - lastTouchRef.current < Math.max(returnSeconds, 15) * 1000; + if (!busy) setIndex(homeIndex()); + }, [attention, homeIndex, returnSeconds]); + + // Back home after a while untouched (not while it's turning on its own). + useEffect(() => { + if (!returnSeconds || (rotateSeconds && count > 1 && !attention)) return undefined; + const timer = setInterval(() => { + if (Date.now() - lastTouchRef.current < returnSeconds * 1000) return; + setIndex((current) => { + const home = homeIndex(); + return current === home ? current : home; + }); + }, 1000); + return () => clearInterval(timer); + }, [returnSeconds, homeIndex, rotateSeconds, count, attention]); + + // Flicks: on the page itself, and relayed from inside plugin frames. + useEffect(() => { + const el = rootRef.current; + if (!el) return undefined; + const onFrameFlick = (event) => { + if (event.target?.closest?.('.drag-handle')) return; + go(safeIndexRef.current + (event.detail?.direction === 1 ? 1 : -1)); + }; + el.addEventListener(FRAME_FLICK_EVENT, onFrameFlick); + return () => el.removeEventListener(FRAME_FLICK_EVENT, onFrameFlick); + }, [go]); + + const onPointerDown = (event) => { + lastTouchRef.current = Date.now(); + // Move/Resize: a selected widget is being dragged, not flicked. + if (event.target?.closest?.('.drag-handle, .resize-button')) { + startRef.current = null; + return; + } + startRef.current = { x: event.clientX, y: event.clientY, t: Date.now() }; + showDots(); + }; + const onPointerUp = (event) => { + const start = startRef.current; + startRef.current = null; + const direction = flickDirection(start, { x: event.clientX, y: event.clientY, t: Date.now() }); + if (direction) go(safeIndex + direction); + }; + + if (count === 0) return null; + const current = items[safeIndex]; + const motion = reducedMotion() ? 'none' : `transform ${TURN_MS}ms cubic-bezier(0.2, 0.8, 0.2, 1)`; + + return ( + { startRef.current = null; }} + // pan-y: sideways is ours (a flick), up and down still scroll. + sx={{ position: 'relative', width: '100%', height: '100%', overflow: 'hidden', touchAction: 'pan-y' }} + > + + {items.map((item, i) => ( + + {withProps(item.content, { + isActive: isActive && i === safeIndex, + expandSize, + widgetSize, + refreshNonce, + inStack: true, + })} + + ))} + + + {count > 0 && ( + <> + {[-1, 1].map((step) => { + const target = safeIndex + step; + if (target < 0 || target >= count) return null; + return ( + go(target)} + aria-label={t(step > 0 ? 'stacks.next' : 'stacks.previous', { name: items[target].label })} + sx={{ + position: 'absolute', + top: '50%', + [step > 0 ? 'right' : 'left']: 6, + transform: 'translateY(-50%)', + width: 40, + height: 40, + zIndex: 5, + backgroundColor: 'var(--card-bg)', + border: '1px solid var(--card-border)', + boxShadow: 'var(--shadow)', + color: 'var(--text)', + opacity: dotsShown ? 0.92 : 0, + pointerEvents: dotsShown ? 'auto' : 'none', + transition: 'opacity 250ms ease', + '&:hover': { backgroundColor: 'var(--card-bg)' }, + }} + > + {step > 0 ? : } + + ); + })} + + + {current.label} + + {items.map((item, i) => ( + go(i)} + sx={{ + width: i === safeIndex ? 18 : 8, + height: 8, + borderRadius: 999, + backgroundColor: i === safeIndex ? 'var(--accent)' : 'var(--text-secondary)', + opacity: i === safeIndex ? 1 : 0.45, + transition: 'width 200ms ease, opacity 200ms ease', + // A bigger target than the dot itself. + '&::after': { content: '""', position: 'absolute', inset: -10 }, + }} + /> + ))} + + + )} + + ); +}; + +export default StackWidget; diff --git a/client/src/components/StacksSettings.jsx b/client/src/components/StacksSettings.jsx new file mode 100644 index 00000000..dad215f3 --- /dev/null +++ b/client/src/components/StacksSettings.jsx @@ -0,0 +1,498 @@ +import React, { useCallback, useEffect, useState } from 'react'; +import axios from 'axios'; +import { + Alert, + Box, + Button, + Chip, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + IconButton, + MenuItem, + Stack, + TextField, + Typography, +} from '@mui/material'; +import { Add, ArrowDownward, ArrowUpward, Close, ViewCarouselOutlined } from '@mui/icons-material'; +import Autocomplete from '@mui/material/Autocomplete'; +import { useTranslation } from 'react-i18next'; +import { AdminGroup } from './AdminRows.jsx'; +import HaEntityPicker, { statesFor } from './HaEntityPicker.jsx'; +import { StackModeRows } from './StackModeFields.jsx'; +import { DISPLAY_MODES_KEY, normalizeModesSettings } from '../utils/displayModes.js'; +import { API_BASE_URL } from '../utils/apiConfig.js'; +import { getDeviceApiBase } from '../utils/deviceName.js'; +import { + RETURN_CHOICES, + WIDGET_STACKS_KEY, + newStackId, + normalizeStack, + normalizeStacks, + stackName, + withModeSetting, +} from '../utils/widgetStacks.js'; + +const DEVICE_SETTINGS_UPDATED_EVENT = 'homeglow:device-settings-updated'; +const CORE_WIDGETS = ['calendar', 'weather', 'chores', 'photos']; + +/** + * Admin → Dashboard → Widgets, under the widgets: this display's Stacks (utils/widgetStacks.js), + * several widgets in one spot, flicked between. A Stack can also be made on + * the wall: in Move/Resize, hold a widget over another. + */ +const StacksSettings = ({ onOpenConnections, onChanged }) => { + const { t } = useTranslation(['admin', 'common']); + const apiDeviceUrl = getDeviceApiBase(API_BASE_URL); + const [stacks, setStacks] = useState(null); + const [modes, setModes] = useState([]); + const [deviceSettings, setDeviceSettings] = useState({}); + const [tabs, setTabs] = useState([]); + const [assignments, setAssignments] = useState([]); + const [plugins, setPlugins] = useState([]); + const [haConfigured, setHaConfigured] = useState(false); + const [entities, setEntities] = useState([]); + const [entitiesLoading, setEntitiesLoading] = useState(false); + const [editing, setEditing] = useState(null); // { stack, isNew, tab } + const [saving, setSaving] = useState(false); + const [message, setMessage] = useState(null); + + const flash = (type, text) => { + const next = { type, text }; + setMessage(next); + if (type === 'success') setTimeout(() => setMessage((current) => (current === next ? null : current)), 4000); + }; + + const load = useCallback(async () => { + try { + const [settingsResponse, tabsResponse, assignmentsResponse] = await Promise.all([ + axios.get(`${apiDeviceUrl}/settings`), + axios.get(`${apiDeviceUrl}/tabs`), + axios.get(`${apiDeviceUrl}/widget-assignments`), + ]); + const settings = settingsResponse.data || {}; + setDeviceSettings(settings); + setStacks(normalizeStacks(settings[WIDGET_STACKS_KEY]).stacks); + setModes(normalizeModesSettings(settings[DISPLAY_MODES_KEY]).modes); + setTabs(Array.isArray(tabsResponse.data) ? tabsResponse.data : []); + setAssignments(Array.isArray(assignmentsResponse.data) ? assignmentsResponse.data : []); + axios.get(`${API_BASE_URL}/api/widgets`) + .then(({ data }) => setPlugins(Array.isArray(data) ? data : [])) + .catch(() => setPlugins([])); + axios.get(`${apiDeviceUrl}/stacks/ha`) + .then(({ data }) => setHaConfigured(Boolean(data?.connected))) + .catch(() => setHaConfigured(false)); + } catch (error) { + console.error('Error loading stacks:', error); + flash('error', t('admin:stacks.loadFailed')); + } + }, [apiDeviceUrl, t]); + + useEffect(() => { void load(); }, [load]); + useEffect(() => { + const reload = () => { void load(); }; + window.addEventListener(DEVICE_SETTINGS_UPDATED_EVENT, reload); + return () => window.removeEventListener(DEVICE_SETTINGS_UPDATED_EVENT, reload); + }, [load]); + + useEffect(() => { + if (!haConfigured) return; + setEntitiesLoading(true); + axios.get(`${apiDeviceUrl}/stacks/ha/entities`) + .then(({ data }) => setEntities(Array.isArray(data?.entities) ? data.entities : [])) + .catch(() => setEntities([])) + .finally(() => setEntitiesLoading(false)); + }, [haConfigured, apiDeviceUrl]); + + const widgetLabel = (name) => (name.startsWith('plugin:') + ? (plugins.find((p) => `plugin:${p.filename}` === name)?.name || name.slice('plugin:'.length).replace(/\.html?$/i, '')) + : t(`admin:screensaver.expand.widgetNames.${name}`, { defaultValue: name })); + const entityName = (id) => entities.find((e) => e.entity_id === id)?.name || id; + const tabName = (number) => tabs.find((tab) => tab.number === Number(number))?.label || t('admin:stacks.tabNumber', { number }); + + // Widgets turned on here: what a Stack can hold. + const available = [ + ...CORE_WIDGETS.filter((name) => deviceSettings.widgetSettings?.[name]?.enabled), + ...plugins.filter((p) => deviceSettings.pluginSettings?.[p.filename]?.enabled).map((p) => `plugin:${p.filename}`), + ]; + + const tabsOf = (stack) => assignments + .filter((row) => row.widget_name === stackName(stack.id)) + .map((row) => row.tab_number); + + const assign = (name, tab) => axios.post(`${apiDeviceUrl}/widget-assignments`, { widget_name: name, tabNumber: tab }).catch(() => {}); + const unassign = (name, tab) => axios.delete(`${apiDeviceUrl}/widget-assignments/${encodeURIComponent(`${tab}::${name}`)}`).catch(() => {}); + + const saveStacks = async (next) => { + await axios.patch(`${apiDeviceUrl}/settings`, { [WIDGET_STACKS_KEY]: { stacks: next } }); + }; + const done = async (text) => { + window.dispatchEvent(new Event(DEVICE_SETTINGS_UPDATED_EVENT)); + await load(); + await onChanged?.(); + flash('success', text); + }; + + // Put a Stack on these tabs. Where it goes, it takes its widgets' place. + const placeOn = async (stack, numbers) => { + const name = stackName(stack.id); + const now = tabsOf(stack); + for (const tab of numbers.filter((n) => !now.includes(n))) { + await assign(name, tab); + const there = new Set(assignments.filter((row) => row.tab_number === Number(tab)).map((row) => row.widget_name)); + await Promise.all(stack.widgets.filter((widget) => there.has(widget)).map((widget) => unassign(widget, tab))); + } + for (const tab of now.filter((n) => !numbers.includes(n))) await unassign(name, tab); + }; + const onPlace = async (stack, numbers) => { + setSaving(true); + try { + await placeOn(stack, numbers); + await done(t('admin:stacks.placed')); + } finally { + setSaving(false); + } + }; + + const onSave = async (raw, isNew, numbers) => { + setSaving(true); + // Mode settings for widgets no longer in it go. + const draft = normalizeStack(raw) || raw; + try { + const next = isNew ? [...stacks, draft] : stacks.map((stack) => (stack.id === draft.id ? draft : stack)); + await saveStacks(next); + await placeOn(draft, numbers); + setEditing(null); + await done(t('admin:stacks.saved', { name: draft.name || t('admin:stacks.untitled') })); + } catch (error) { + console.error('Error saving stack:', error); + flash('error', error?.response?.data?.error || t('admin:stacks.saveFailed')); + } finally { + setSaving(false); + } + }; + + // Unstack: the widgets go back on each tab the Stack was on, the first in its spot. + const onUnstack = async (stack) => { + if (!window.confirm(t('admin:stacks.confirmUnstack'))) return; + setSaving(true); + try { + const name = stackName(stack.id); + for (const row of assignments.filter((item) => item.widget_name === name)) { + const tab = row.tab_number; + const there = new Set(assignments.filter((item) => item.tab_number === tab).map((item) => item.widget_name)); + const back = stack.widgets.filter((widget) => !there.has(widget)); + await unassign(name, tab); + for (const widget of back) await assign(widget, tab); + if (back[0] && row.layout_w != null) { + await axios.patch(`${apiDeviceUrl}/widget-assignments/layout/bulk`, { + layouts: [{ widget_name: back[0], tabNumber: tab, layout_x: row.layout_x, layout_y: row.layout_y, layout_w: row.layout_w, layout_h: row.layout_h }], + }).catch(() => {}); + } + } + await saveStacks(stacks.filter((item) => item.id !== stack.id)); + setEditing(null); + await done(t('admin:stacks.unstacked')); + } catch (error) { + console.error('Error unstacking:', error); + flash('error', t('admin:stacks.saveFailed')); + } finally { + setSaving(false); + } + }; + + if (!stacks) return message ? {message.text} : null; + + return ( + + {message && setMessage(null)}>{message.text}} + } + disabled={available.length < 2} + onClick={() => setEditing({ + isNew: true, + stack: { id: newStackId(), name: '', widgets: available.slice(0, 2), returnSeconds: 60, attention: [], modes: {} }, + })} + > + {t('admin:stacks.add')} + + )} + > + + {t('admin:stacks.help')} + + {stacks.length === 0 && ( + + + {t('admin:stacks.empty')} + + )} + {stacks.map((stack) => { + const on = tabsOf(stack); + return ( + + + + + {stack.name || stack.widgets.map(widgetLabel).join(' · ')} + + {stack.widgets.map((widget, i) => ( + + ))} + + + + + onPlace(stack, numbers)} + helperText={on.length ? t('admin:stacks.placeOnHelp') : t('admin:stacks.notPlaced')} + sx={{ mt: 1.5, ml: { sm: 4.5 } }} + /> + + ); + })} + + + {editing && ( + setEditing(null)} + onSave={onSave} + onUnstack={() => onUnstack(editing.stack)} + /> + )} + + ); +}; + +const returnLabel = (t, seconds) => { + if (!seconds) return t('admin:stacks.returnNever'); + return seconds < 60 + ? t('admin:stacks.seconds', { count: seconds }) + : t('admin:stacks.minutes', { count: seconds / 60 }); +}; + +// "Show on tabs", like each widget above. +const TabsPicker = ({ tabs, value, onChange, disabled, helperText, sx }) => { + const { t } = useTranslation(['admin']); + return ( + option.label || t('admin:stacks.tabNumber', { number: option.number })} + isOptionEqualToValue={(a, b) => a.number === b.number} + value={tabs.filter((tab) => value.includes(tab.number))} + onChange={(_, picked) => onChange(picked.map((tab) => tab.number))} + renderInput={(params) => ( + + )} + sx={sx} + /> + ); +}; + +const StackDialog = ({ + stack, isNew, tabs, placedOn, modes, available, widgetLabel, entityName, haConfigured, entities, entitiesLoading, + saving, onOpenConnections, onCancel, onSave, onUnstack, +}) => { + const { t } = useTranslation(['admin']); + const [draft, setDraft] = useState(stack); + const [onTabs, setOnTabs] = useState(isNew ? (tabs[0] ? [tabs[0].number] : []) : placedOn); + const set = (patch) => setDraft((prev) => ({ ...prev, ...patch })); + const choices = [...new Set([...draft.widgets, ...available])]; + const addable = choices.filter((name) => !draft.widgets.includes(name)); + const ruleFor = (widget) => draft.attention.find((rule) => rule.widget === widget) || null; + + const move = (i, step) => { + const widgets = [...draft.widgets]; + [widgets[i], widgets[i + step]] = [widgets[i + step], widgets[i]]; + set({ widgets }); + }; + const remove = (widget) => set({ + widgets: draft.widgets.filter((name) => name !== widget), + attention: draft.attention.filter((rule) => rule.widget !== widget), + }); + const setRule = (widget, patch) => { + const current = ruleFor(widget); + const rest = draft.attention.filter((rule) => rule.widget !== widget); + if (patch === null) return set({ attention: rest }); + return set({ attention: [...rest, { widget, op: 'is', state: '', entity: '', ...current, ...patch }] }); + }; + const rulesOk = draft.attention.every((rule) => rule.entity && rule.state); + const canSave = draft.widgets.length >= 2 && rulesOk && !saving; + + return ( + + {isNew ? t('admin:stacks.newTitle') : t('admin:stacks.editTitle')} + + + set({ name: e.target.value.slice(0, 40) })} + helperText={t('admin:stacks.nameHelp')} + /> + + + {t('admin:stacks.widgets')} + {t('admin:stacks.widgetsHelp')} + + {draft.widgets.map((widget, i) => { + const rule = ruleFor(widget); + const entity = rule?.entity ? entities.find((e) => e.entity_id === rule.entity) : null; + return ( + + + {i + 1} + + {widgetLabel(widget)} + {i === 0 && {t('admin:stacks.first')}} + + move(i, -1)} aria-label={t('admin:stacks.moveUp', { name: widgetLabel(widget) })}> + move(i, 1)} aria-label={t('admin:stacks.moveDown', { name: widgetLabel(widget) })}> + remove(widget)} aria-label={t('admin:stacks.remove', { name: widgetLabel(widget) })}> + + {haConfigured && !rule && ( + + )} + {haConfigured && rule && ( + + + {t('admin:stacks.jumpWhen')} + + + { + const picked = entities.find((e) => e.entity_id === id); + const suggestions = statesFor(picked); + const offish = suggestions.includes('off') ? 'off' : ''; + setRule(widget, { + entity: id || '', + state: offish || suggestions[0] || '', + op: offish && !['binary_sensor', 'input_boolean', 'switch'].includes((id || '').split('.')[0]) ? 'is_not' : 'is', + }); + }} + label={t('admin:stacks.entity')} + /> + + setRule(widget, { op: e.target.value })} sx={{ width: 130 }}> + {t('admin:stacks.ops.is')} + {t('admin:stacks.ops.is_not')} + + setRule(widget, { state: value.slice(0, 100) })} + renderInput={(params) => } + sx={{ flex: 1 }} + /> + + {rule.entity && rule.state && ( + + {t(rule.op === 'is_not' ? 'admin:stacks.whenIsNot' : 'admin:stacks.whenIs', { name: entityName(rule.entity), state: rule.state })} + + )} + + )} + + ); + })} + + {addable.length > 0 && draft.widgets.length < 12 && ( + set({ widgets: [...draft.widgets, e.target.value] })} + sx={{ mt: 1.5, minWidth: 220 }} + > + {addable.map((name) => {widgetLabel(name)})} + + )} + {draft.widgets.length < 2 && ( + {t('admin:stacks.needTwo')} + )} + {!haConfigured && ( + + {t('admin:stacks.jumpNeedsHa')} + + + )} + + + set({ returnSeconds: Number(e.target.value) })} + helperText={t('admin:stacks.returnHelp')} + > + {[...new Set([...RETURN_CHOICES, draft.returnSeconds])].sort((a, b) => a - b).map((seconds) => ( + {returnLabel(t, seconds)} + ))} + + + + + {draft.widgets.length >= 2 && ( + + {t('admin:stacks.modes.heading')} + + {modes.length ? t('admin:stacks.modes.help') : t('admin:stacks.modes.none')} + + {modes.length > 0 && ( + ({ + key: mode.id, + title: mode.name, + color: mode.color, + stack: draft, + setting: draft.modes?.[mode.id] || null, + }))} + widgetLabel={widgetLabel} + onChange={(modeId, setting) => setDraft((prev) => withModeSetting(prev, modeId, setting))} + /> + )} + + )} + + + + {!isNew && } + + + + + ); +}; + +export default StacksSettings; diff --git a/client/src/components/TabBar.jsx b/client/src/components/TabBar.jsx index 9037b6ec..b0cf7e91 100644 --- a/client/src/components/TabBar.jsx +++ b/client/src/components/TabBar.jsx @@ -220,6 +220,29 @@ const MenuIcon = ({ name, size = 20, color = 'currentColor' }) => { return icons[name] || null; }; +// A tab a dragged widget is over: it grows and takes the accent outline. +export const dropTargetSx = { + transform: 'scale(1.18)', + border: '1.5px solid var(--accent)', + backgroundColor: 'rgba(var(--accent-rgb), 0.18)', + '&:hover': { + transform: 'scale(1.18)', + backgroundColor: 'rgba(var(--accent-rgb), 0.18)', + }, +}; + +// A tab about to open: a ring fills around it, then it bounces. +export const pendingTabSx = (ms = 500) => ({ + ...dropTargetSx, + animation: `hg-tab-wait ${ms}ms linear forwards`, + '@keyframes hg-tab-wait': { + '0%': { boxShadow: '0 0 0 0 rgba(var(--accent-rgb), 0.55)', transform: 'scale(1.18)' }, + '80%': { boxShadow: '0 0 0 7px rgba(var(--accent-rgb), 0.35)', transform: 'scale(1.22)' }, + '90%': { transform: 'scale(1.08)' }, + '100%': { boxShadow: '0 0 0 9px rgba(var(--accent-rgb), 0)', transform: 'scale(1.2)' }, + }, +}); + const TabBar = ({ tabs, activeTab, @@ -236,6 +259,13 @@ const TabBar = ({ theme, themeMode, screensaverCountdown, + // While a widget is dragged over the dock: the tab number it would move + // to, or 'add' for the + button (app.jsx). That one is highlighted. + dropTarget = null, + // The tab about to open under a dragged widget (it fills, then bounces), + // and whether a widget is being dragged at all (the dock lifts). + pendingTab = null, + dragging = false, }) => { const [menuOpen, setMenuOpen] = useState(false); const menuAnchorRef = useRef(null); @@ -313,9 +343,14 @@ const TabBar = ({ sx={{ display: 'flex', alignItems: 'center', - gap: 0.5, - px: 2, - py: 1, + // While a widget is dragged the dock lifts and spreads its tabs, + // so they're easier to aim at. + gap: dragging ? 2 : 0.5, + px: dragging ? 3 : 2, + py: dragging ? 1.5 : 1, + transform: dragging ? 'translateY(-8px) scale(1.12)' : 'none', + transformOrigin: 'bottom center', + transition: 'gap 200ms ease, padding 200ms ease, transform 200ms ease, box-shadow 200ms ease', borderRadius: 'var(--hg-radius-2xl)', backgroundColor: 'var(--dock-bg)', border: '1px solid var(--dock-border)', @@ -475,6 +510,7 @@ const TabBar = ({ )} onTabChange(tabNumber)} sx={{ width: 44, @@ -490,6 +526,8 @@ const TabBar = ({ backgroundImage: isActive ? 'var(--dock-active-image)' : 'none', border: isActive ? '1.5px solid var(--dock-active-border)' : '1.5px solid transparent', boxShadow: isActive ? '0 2px 8px var(--hg-black-20)' : 'none', + ...(dropTarget === tabNumber && dropTargetSx), + ...(pendingTab === tabNumber && pendingTabSx()), '&:hover': { backgroundColor: isActive ? 'var(--dock-active-bg)' : 'var(--hg-white-10)', transform: 'translateY(-2px)', @@ -524,19 +562,12 @@ const TabBar = ({ ); })} - {/* Add Tab Button (only in edit mode) */} + {/* Add Tab Button (only in edit mode), spaced like the tabs. */} {!widgetsLocked && ( <> - @@ -559,17 +591,17 @@ const TabBar = ({ )} - {/* Separator before screensaver */} + {/* Separator before screensaver; not beside the + button. */} {screensaverCountdown && ( <> - + />} {screensaverCountdown} @@ -580,4 +612,5 @@ const TabBar = ({ ); }; +export { TabIcon }; export default TabBar; diff --git a/client/src/components/TakeoverLayer.jsx b/client/src/components/TakeoverLayer.jsx new file mode 100644 index 00000000..51034142 --- /dev/null +++ b/client/src/components/TakeoverLayer.jsx @@ -0,0 +1,161 @@ +import React, { Suspense } from 'react'; +import { Box, IconButton, Typography } from '@mui/material'; +import { Close } from '@mui/icons-material'; +import { useTranslation } from 'react-i18next'; + +// Room kept clear for the dock at the bottom, and around the sheets. +const DOCK_CLEAR = 92; +const EDGE = 12; +const GAP = 12; +const HEADER = 52; + +const LEVEL_COLOR = { + urgent: 'var(--hg-error, #d32f2f)', + high: '#E0A030', + normal: 'var(--accent)', +}; + +const regionFor = (slot) => { + const half = `calc((100vh - ${EDGE + DOCK_CLEAR + GAP}px) / 2)`; + if (slot === 'full') return { top: EDGE, bottom: DOCK_CLEAR }; + if (slot === 'top') return { top: EDGE, height: half }; + return { bottom: DOCK_CLEAR, height: half }; +}; + +// The grid hands widgets their size this way (WidgetContainer); a takeover +// tells them they're shown big, so the calendar shows its month and plugins +// hear 'half' or 'full'. +const withSize = (element, props) => { + if (React.isValidElement(element) && element.type === Suspense) { + return React.cloneElement(element, {}, React.cloneElement(element.props.children, props)); + } + return React.isValidElement(element) ? React.cloneElement(element, props) : element; +}; + +/** + * The takeovers on the wall (hooks/useTakeovers.js): each a sheet over part of + * the page, sliding in from its side, with the widget's name, how urgent it + * is, and a ✕. The page behind stays usable; the dock stays in view. + * + * - slots: { full, top, bottom }, each { key, widget, level } or null + * - contentFor(widget): the widget's content node, as the grid would draw it + * - nameFor(widget): its name + * - onDismiss(key) + */ +const TakeoverLayer = ({ slots, contentFor, nameFor, onDismiss }) => { + const { t } = useTranslation('common'); + const shown = ['full', 'top', 'bottom'].map((slot) => ({ slot, item: slots[slot] })).filter((entry) => entry.item); + if (shown.length === 0) return null; + + return ( + <> +