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 daf31e0a..2c4db6ac 100644 --- a/client/src/app.jsx +++ b/client/src/app.jsx @@ -67,7 +67,19 @@ 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 { dropNotice, emptyNotices, pushUndo, showMessage, shownNotice } from './utils/undoNotices.js'; +import { MODE_DIM_LEVEL } from './utils/displayModes.js'; +import { firstOpenSpot, unstackSpots } 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'; @@ -79,7 +91,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 loadPinModal = () => import('./components/PinModal.jsx'); @@ -172,6 +186,13 @@ const ADMIN_PIN_EXISTS_RECOVERY_INTERVAL_MS = 5 * 60 * 1000; // How often every display rereads the household settings, so a change made on // another display (vacation, appearance) arrives without a reload. const HOUSEHOLD_SETTINGS_REFRESH_MS = 5 * 60 * 1000; +// Moving, removing and putting back widgets: a server that hangs gets a +// "didn't work" notice and a fresh look at the tabs, rather than nothing. +const WRITE_TIMEOUT_MS = 20 * 1000; +// Taps on Undo this soon after a notice comes up are ignored (a double tap). +const UNDO_TAP_GUARD_MS = 600; +// A Stack repair that failed is tried again after this long. +const STACK_REPAIR_RETRY_MS = 60 * 1000; /** * Control Limits ids are namespaced in storage (`plugin::`) @@ -258,6 +279,9 @@ const App = () => { // It is never written to localStorage (see deviceName.js). if (getMirrorTarget() !== mirrorTarget) setSessionMirrorTarget(mirrorTarget); const API_DEVICE_URL = getDeviceApiBase(API_BASE_URL); + // The device this session means at the moment of the call: mirror mode can + // change it between a render and an awaited write (see fetchWidgetAssignments). + const deviceApi = () => getDeviceApiBase(API_BASE_URL); const isMobile = useIsMobile(); const [theme, setTheme] = useState(readLocalTheme); // Rendered until both the household and this display's settings have loaded. @@ -451,6 +475,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`); @@ -745,6 +791,29 @@ const App = () => { void seedMobileDevice(); }, [isMobile, demoStatus.demo, isFirstRunClient, deviceSettingsLoaded, mirrorTarget]); + // Positions the grid just saved, put into the dashboard's copy right away: + // the grid redraws a tab from this copy when it comes back to it. + const applySavedLayouts = useCallback((rows) => { + setWidgetAssignments((current) => { + let changed = false; + const next = { ...current }; + rows.forEach((row) => { + const list = next[row.widget_name]; + if (!Array.isArray(list)) return; + const index = list.findIndex((item) => item.tabNumber === row.tabNumber); + if (index < 0) return; + const item = list[index]; + if (item.layout_x === row.layout_x && item.layout_y === row.layout_y + && item.layout_w === row.layout_w && item.layout_h === row.layout_h) return; + const copy = [...list]; + copy[index] = { ...item, layout_x: row.layout_x, layout_y: row.layout_y, layout_w: row.layout_w, layout_h: row.layout_h }; + next[row.widget_name] = copy; + changed = true; + }); + return changed ? next : current; + }); + }, []); + // NOTE (mirror stale-closure trap): this fetch MUST resolve the device at // call time, not close over the render's API_DEVICE_URL. The household- // changes subscription below is created once — its effect depends only on @@ -772,6 +841,8 @@ const App = () => { layout_y: assignment.layout_y, layout_w: assignment.layout_w, layout_h: assignment.layout_h, + // Shown inside that Stack on this tab (utils/widgetStacks.js). + inStack: assignment.in_stack || null, }); }); @@ -1165,6 +1236,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. @@ -1546,12 +1663,387 @@ 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. + // Where `name` goes on `toTab`: `cell` ({x,y,w,h}) when it was let go + // there, else the first open spot, at the size it had on `fromTab`. + const spotOnTab = (name, fromTab, toTab, cell) => { + if (cell) return cell; + const size = getWidgetLayoutForTab(name, fromTab) || { w: 6, h: 4 }; + return firstOpenSpot({ w: size.w, h: size.h }, takenOnTab(toTab, [name])); + }; + + // A Stack going to another tab takes its widgets' entries along (moved, or + // copied for a duplicate), still marked as in it: one write on the server, + // so no display (this one included) ever sees the Stack without them. + // `merge`: the Stack is already on that tab, so only this side goes. + const stackTransfer = (name, fromTab, toTab, { copy = false, layout = null, merge = false } = {}) => axios.post( + `${deviceApi()}/widget-assignments/transfer`, + { widget_name: name, fromTab, toTab, copy, layout, merge, members: stackFor(name)?.widgets || [] }, + { timeout: WRITE_TIMEOUT_MS }, + ); + const layoutFields = (spot) => ({ layout_x: spot.x, layout_y: spot.y, layout_w: spot.w, layout_h: spot.h }); + + // Move a widget to another tab (or within this one, to `cell`). Between tabs + // it's one write on the server (widget-assignments/transfer): the widget is + // never left on no tab, and keeps its per-tab settings. + const moveWidgetToTab = async (name, fromTab, toTab, cell = null) => { + if (!name || (fromTab === toTab && !cell)) return; + // Shown in a Stack there already: nothing to move. + if (fromTab !== toTab && hiddenInStack(name, toTab)) { + setWidgetNotice({ message: i18n.t('common:widgetMenu.alreadyOn', { tab: tabLabelFor(toTab) }) }); + return; + } + try { + const spot = spotOnTab(name, fromTab, toTab, cell); + if (fromTab !== toTab && isStackName(name)) { + await stackTransfer(name, fromTab, toTab, { + layout: layoutFields(spot), + merge: isWidgetAssignedToTab(name, toTab), + }); + } else if (fromTab === toTab || isWidgetAssignedToTab(name, toTab)) { + // Already there: place it, and take it off the tab it came from. + await axios.patch(`${deviceApi()}/widget-assignments/layout/bulk`, { + layouts: [{ widget_name: name, tabNumber: toTab, ...layoutFields(spot) }], + }, { timeout: WRITE_TIMEOUT_MS }); + if (fromTab !== toTab) { + await axios.delete(`${deviceApi()}/widget-assignments/${encodeURIComponent(`${fromTab}::${name}`)}`, { timeout: WRITE_TIMEOUT_MS }); + } + } else { + await axios.post(`${deviceApi()}/widget-assignments/transfer`, { + widget_name: name, fromTab, toTab, layout: layoutFields(spot), + }, { timeout: WRITE_TIMEOUT_MS }); + } + await fetchWidgetAssignments(); + setActiveTab(toTab); + } catch (error) { + console.error('Error moving the widget to another tab:', error); + setWidgetNotice({ message: i18n.t('common:widgetMenu.moveFailed') }); + 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 { + const layout = layoutFields(spotOnTab(name, fromTab, toTab, null)); + if (isStackName(name)) { + await stackTransfer(name, fromTab, toTab, { copy: true, layout }); + } else { + await axios.post(`${deviceApi()}/widget-assignments/transfer`, { + widget_name: name, fromTab, toTab, copy: true, layout, + }, { timeout: WRITE_TIMEOUT_MS }); + } + await fetchWidgetAssignments(); + setActiveTab(toTab); + } catch (error) { + console.error('Error duplicating the widget:', error); + setWidgetNotice({ message: i18n.t('common:widgetMenu.moveFailed') }); + await fetchWidgetAssignments(); + } + }; + + 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); + // Notices at the top (components/WidgetRemoveUndo.jsx): a plain message, + // and the Undos still waiting (utils/undoNotices.js). Another notice never + // takes an Undo away: it waits, and shows again once that one has gone. + const [notices, setNotices] = useState(emptyNotices); + const setWidgetNotice = useCallback((notice) => { + setNotices((current) => (notice?.undo ? pushUndo(current, notice) : showMessage(current, notice?.message || null))); + }, []); + const widgetNotice = shownNotice(notices); + // When the notice now showing came up, and whether an Undo is under way: + // a quick double tap on Undo is one Undo, not that one and the next. + const noticeShownAtRef = useRef(0); + const undoBusyRef = useRef(false); + useEffect(() => { noticeShownAtRef.current = Date.now(); }, [widgetNotice?.key]); + + const enabledSettingsFor = (name) => { + 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); + return { key, field, current }; + }; + const isWidgetEnabled = (name) => { + const { key, current } = enabledSettingsFor(name); + return current?.[key]?.enabled !== false; + }; + const setWidgetEnabled = async (name, enabled) => { + const { key, field, current } = enabledSettingsFor(name); + const saved = await axios.patch(`${deviceApi()}/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; + // The tab as saved now (settings and Stack marks since the last load), so + // Undo puts back exactly what was there. + let tab = tabsRef.current.find((item) => item.number === request.fromTab); + try { + const { data } = await axios.get(`${deviceApi()}/tabs`); + tab = (Array.isArray(data) ? data : []).find((item) => item.number === request.fromTab) || tab; + } catch { /* the copy loaded earlier */ } + let map = {}; + try { + map = (typeof tab?.config_json === 'string' ? JSON.parse(tab.config_json || '{}') : tab?.config_json) || {}; + } catch { /* no per-tab settings */ } + const name = request.widgetName; + const entry = { ...(map[name] || {}) }; + 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 }); + } + // A Stack takes the entries of the widgets shown in it here along, so + // they don't pop back up on their own; Undo puts them all back. + const stackId = stackIdOf(name); + const members = stackId + ? Object.entries(map).filter(([, value]) => value?.in_stack === stackId).map(([member, value]) => ({ name: member, entry: { ...value } })) + : []; + let turnOff = !isStackName(name) && (widgetAssignments[name] || []).length <= 1 && !inPlacedStack(name); + // Off in Settings before this removal: Undo leaves it off. + const wasOn = isStackName(name) || isWidgetEnabled(name); + const localEntries = { ...Object.fromEntries(members.map((member) => [member.name, member.entry])), [name]: entry }; + let entries; + try { + // A Stack and the widgets in it go in one write (?members=1), and the + // server hands back exactly what it took, for Undo. + const { data } = await axios.delete( + `${deviceApi()}/widget-assignments/${encodeURIComponent(`${request.fromTab}::${name}`)}${stackId ? '?members=1' : ''}`, + { timeout: WRITE_TIMEOUT_MS }, + ); + const taken = data?.removed && typeof data.removed === 'object' ? data.removed : {}; + entries = { ...localEntries, ...taken, [name]: entry }; + } catch (error) { + console.error('Error removing widget:', error); + // No answer (timed out, connection dropped): the server may have done + // it anyway. If it's gone from the tab, it was removed, and Undo still + // has what was there. + let gone = false; + if (!error?.response) { + try { + const { data } = await axios.get(`${deviceApi()}/tabs`, { timeout: WRITE_TIMEOUT_MS }); + const now = (Array.isArray(data) ? data : []).find((item) => item.number === request.fromTab); + const nowMap = typeof now?.config_json === 'string' ? JSON.parse(now.config_json || '{}') : (now?.config_json || {}); + gone = Boolean(now) && !(name in nowMap); + } catch { /* can't tell: treated as not removed */ } + } + if (!gone) { + setWidgetNotice({ message: i18n.t('common:widgetMenu.removeFailed') }); + await Promise.all([fetchTabs(), fetchWidgetAssignments()]); + return; + } + entries = localEntries; + } + // Off the tab now; turning it off is extra. If that fails it's simply + // left on (Settings shows it on no tab), and Undo still works. + if (turnOff) { + try { + await setWidgetEnabled(name, false); + } catch (error) { + console.error('Error turning the removed widget off:', error); + turnOff = 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, entries, turnOff, wasOn }, + }); + }; + // Whether every entry an Undo puts back is on its tab now. + const entriesAreBack = async (undo) => { + try { + const { data } = await axios.get(`${deviceApi()}/tabs`, { timeout: WRITE_TIMEOUT_MS }); + const tab = (Array.isArray(data) ? data : []).find((item) => item.number === undo.tab); + const map = typeof tab?.config_json === 'string' ? JSON.parse(tab.config_json || '{}') : (tab?.config_json || {}); + return Boolean(tab) && Object.keys(undo.entries || {}).every((name) => name in map); + } catch { + return false; + } + }; + const undoRemoveWidget = async () => { + const shown = widgetNotice; + const undo = shown?.undo; + if (!undo) return; + // One at a time, and not a tap meant for the notice that was just there. + if (undoBusyRef.current || Date.now() - noticeShownAtRef.current < UNDO_TAP_GUARD_MS) return; + undoBusyRef.current = true; + setNotices((current) => dropNotice(current, shown.key)); + try { + // Every entry back in one write: nothing half-restored. Only what isn't + // on the tab again since (onlyMissing), so a late Undo never overwrites + // a newer change. + await axios.put(`${deviceApi()}/widget-assignments/entry`, { + tabNumber: undo.tab, entries: undo.entries, onlyMissing: true, + }, { timeout: WRITE_TIMEOUT_MS }); + // Back on a tab means shown: turned on if it's off now, whichever + // removal turned it off (but not one that was off before this one). + if (!isStackName(undo.name) && undo.wasOn !== false && !isWidgetEnabled(undo.name)) { + await setWidgetEnabled(undo.name, true).catch((error) => { + console.error('Error turning the widget back on:', error); + setWidgetNotice({ message: i18n.t('common:widgetMenu.turnOnFailed') }); + }); + } + await Promise.all([fetchTabs(), fetchWidgetAssignments()]); + setActiveTab(undo.tab); + } catch (error) { + console.error('Error restoring widget:', error); + // No answer (timed out, connection dropped): the server may have put it + // all back anyway. If so, finish the job (turn it on, show the tab) + // rather than saying it failed. + if (!error?.response && await entriesAreBack(undo)) { + if (!isStackName(undo.name) && undo.wasOn !== false && !isWidgetEnabled(undo.name)) { + await setWidgetEnabled(undo.name, true).catch(() => setWidgetNotice({ message: i18n.t('common:widgetMenu.turnOnFailed') })); + } + await Promise.all([fetchTabs(), fetchWidgetAssignments()]); + setActiveTab(undo.tab); + return; + } + // Still there to try again. + setNotices((current) => pushUndo(current, shown)); + setWidgetNotice({ message: i18n.t('common:widgetMenu.undoFailed') }); + await Promise.all([fetchTabs(), fetchWidgetAssignments()]); + } finally { + undoBusyRef.current = false; + } + }; + + // 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 { + // Back on their own: the ones that showed in the Stack get placed; their + // entries (and settings) were kept, so they're only unmarked. The Stack + // leaving, the unmarking and the places are one write on the server. + const back = stack.widgets.filter((widget) => !visibleOnTab(widget, tab)); + const spot = place || getWidgetLayoutForTab(name, tab); + const layouts = unstackSpots( + back.map((widget) => ({ name: widget, kept: getWidgetLayoutForTab(widget, tab) })), + spot, + takenOnTab(tab, [name, ...back]), + ).map((item) => ({ widget_name: item.name, layout_x: item.x, layout_y: item.y, layout_w: item.w, layout_h: item.h })); + await axios.post(`${deviceApi()}/widget-assignments/unstack`, { + tabNumber: tab, stack: stack.id, members: stack.widgets, layouts, + }, { timeout: WRITE_TIMEOUT_MS }); + if ((widgetAssignments[name] || []).length <= 1) { + const saved = await axios.patch(`${deviceApi()}/settings`, { + [WIDGET_STACKS_KEY]: { stacks: widgetStacks.filter((item) => item.id !== stack.id) }, + }); + hydrateFromDeviceSettings(saved.data || {}); + } + await fetchWidgetAssignments(); + } catch (error) { + console.error('Error unstacking:', error); + setWidgetNotice({ message: i18n.t('common:widgetMenu.unstackFailed') }); + 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; @@ -1577,6 +2069,72 @@ const App = () => { return assignments.some(a => a.tabNumber === tabNumber); }; + // ---- Widgets in a Stack keep their tab entries ---- + // Stacking marks a widget's entry on the tab (in_stack: the Stack's id) + // instead of deleting it: the entry holds the widget's per-tab settings + // (Weather's location and units, the calendar's view), so they stay with + // it inside the Stack and come back when it's unstacked, and a build + // without Stacks still shows the widget. + const entryOnTab = (name, tabNumber) => (widgetAssignments[name] || []).find((a) => a.tabNumber === tabNumber) || null; + // Hidden on its own because it shows in that Stack, which is on this tab. + const hiddenInStack = (name, tabNumber) => { + const stackId = entryOnTab(name, tabNumber)?.inStack; + if (!stackId) return false; + return isWidgetAssignedToTab(stackName(stackId), tabNumber) + && widgetStacks.some((stack) => stack.id === stackId && stack.widgets.includes(name)); + }; + const visibleOnTab = (name, tabNumber) => isWidgetAssignedToTab(name, tabNumber) && !hiddenInStack(name, tabNumber); + // The spots taken on a tab by what shows there, past `except`. + const takenOnTab = (tabNumber, except = []) => Object.keys(widgetAssignments) + .filter((other) => !except.includes(other) && visibleOnTab(other, tabNumber)) + .map((other) => getWidgetLayoutForTab(other, tabNumber)) + .filter(Boolean); + // Mark widgets' entries on a tab as in a Stack (or not: stack null). With + // create, a widget not on that tab yet gets an entry there. + const markInStack = (tabNumber, widgetNames, stackId, create = false) => (widgetNames.length + ? axios.patch(`${deviceApi()}/widget-assignments/in-stack`, { tabNumber, stack: stackId, widgets: widgetNames, create }) + : Promise.resolve()); + // A Stack's widgets on a tab where it's placed should each have an entry + // there marked as in it. Repaired, once: Stacks made before entries were + // kept (no entry), and a widget on the tab on its own as well (a deleted + // tab's Stack landing on Home where it already was, an Admin save that + // didn't finish), which would otherwise show twice. Its entry is kept, + // settings and all, just marked. One marked for another Stack is left be. + const stackRepairRef = useRef(new Set()); + useEffect(() => { + if (mirrorTarget || !Object.keys(widgetAssignments).length) return; + const jobs = []; + widgetStacks.forEach((stack) => { + (widgetAssignments[stackName(stack.id)] || []).forEach(({ tabNumber }) => { + // Marked for another Stack that's real and holds it: left be. A mark + // for a Stack that's gone, or doesn't hold it, doesn't count. + const markedElsewhere = (member) => { + const other = entryOnTab(member, tabNumber)?.inStack; + return Boolean(other) && widgetStacks.some((item) => item.id === other && item.widgets.includes(member)); + }; + const missing = stack.widgets.filter((member) => entryOnTab(member, tabNumber)?.inStack !== stack.id && !markedElsewhere(member)); + const key = `${tabNumber}:${stack.id}:${missing.join(',')}`; + if (!missing.length || stackRepairRef.current.has(key)) return; + stackRepairRef.current.add(key); + // Failed (server away or refusing): tried again a while later, not on + // every reload, so a server that keeps failing isn't hammered. + jobs.push(markInStack(tabNumber, missing, stack.id, true).then(() => true, () => { + setTimeout(() => { + stackRepairRef.current.delete(key); + void fetchWidgetAssignments(); + }, STACK_REPAIR_RETRY_MS); + return false; + })); + }); + }); + if (jobs.length) { + void Promise.all(jobs).then((done) => { + if (done.some(Boolean)) void fetchWidgetAssignments(); + }); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [widgetAssignments, widgetStacks, mirrorTarget]); + const getWidgetLayoutForTab = (widgetName, tabNumber) => { const assignments = widgetAssignments[widgetName]; if (!assignments) return null; @@ -1618,11 +2176,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) => visibleOnTab(name, activeTab)) + .map((name) => ({ name, size: getWidgetLayoutForTab(name, activeTab) || { w: 4, h: 3 } })); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [enabledWidgetNames, widgetAssignments, activeTab, widgetStacks]); + + 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), @@ -1635,16 +2221,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), @@ -1658,8 +2244,8 @@ const App = () => { tab.number === activeTab)?.config_json || null} + activeTab={tabNumber} + activeTabConfigJson={tabs.find((tab) => tab.number === tabNumber)?.config_json || null} allTabConfigs={tabs} /> @@ -1667,8 +2253,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), @@ -1685,8 +2271,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), @@ -1708,9 +2294,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), @@ -1736,8 +2322,282 @@ 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); + }; + // Stacking takes a deliberate hold: the finger resting (within + // STACK_STILL_PX) on the middle of another widget for STACK_HOLD_MS. Just + // passing over one, or pausing near its edge, never stacks. + const STACK_HOLD_MS = 700; + const STACK_STILL_PX = 14; + const STACK_MIDDLE = 0.5; // the middle half of the widget, each way + const stackTargetAt = (dragged, x, y) => { + // A Stack isn't put inside another, and over the dock the finger is on + // the dock (a tab, or +), never on a widget underneath it. + if (isStackName(dragged) || dockTargetAt(x, y) != null) return null; + const draggedSelector = `[data-widget-name="${CSS.escape(dragged)}"]`; + // The topmost thing under the finger, past the widget being dragged: it + // has to be another widget, not the dock or anything else on top. + for (const el of document.elementsFromPoint(x, y)) { + // The dragged widget itself and the grid's drop shadow don't count. + if (el.closest?.(draggedSelector) || el.classList?.contains('react-grid-placeholder')) continue; + const holder = el.closest?.('[data-widget-name]'); + const name = holder?.getAttribute('data-widget-name'); + if (!name || name === dragged) return null; + const rect = holder.getBoundingClientRect(); + const insetX = (rect.width * (1 - STACK_MIDDLE)) / 2; + const insetY = (rect.height * (1 - STACK_MIDDLE)) / 2; + const middle = x >= rect.left + insetX && x <= rect.right - insetX && y >= rect.top + insetY && y <= rect.bottom - insetY; + return { name, el: holder, middle }; + } + return null; + }; + const handleWidgetDrag = (name, x, y) => { + carry.onDrag(name, x, y); + if (carry.carrying) { + clearStackDwell(); + return; + } + const hit = stackTargetAt(name, x, y); + const shown = stackDropRef.current; + if (shown && hit?.name === shown.target) { + // Shown: it stays while the finger is anywhere on the target. Keep the + // outline on it. + 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; + } + if (!hit?.middle) { + if (shown || stackDwellRef.current.target) clearStackDwell(); + return; + } + const dwell = stackDwellRef.current; + // Still resting on the same widget: let the hold run. + if (dwell.target === hit.name && Math.hypot(x - dwell.x, y - dwell.y) <= STACK_STILL_PX) return; + // Moved on, or started: the hold starts again from here. + if (shown) setStackDrop(null); + clearTimeout(dwell.timer); + const target = hit.name; + stackDwellRef.current = { + target, + x, + y, + timer: setTimeout(() => { + const el = document.querySelector(`[data-widget-name="${CSS.escape(target)}"]`); + if (!el) return; + navigator.vibrate?.(10); + setStackDrop({ target, rect: el.getBoundingClientRect(), adding: isStackName(target) }); + }, STACK_HOLD_MS), + }; + }; + const handleWidgetDragStop = (name, x, y) => { + const drop = stackDropRef.current; + clearStackDwell(); + // Let go over the dock: that's a tab (or +), whatever was shown before. + if (drop && name && dockTargetAt(x, y) == null) { + void stackWidgets(name, drop.target); + return true; + } + return carry.onDragStop(name, x, y); + }; + + // While "Stack with…" shows, the grid's own drop outline hides, so there's + // one outline on screen, not two (index.css .hg-stacking). + useEffect(() => { + document.body.classList.toggle('hg-stacking', Boolean(stackDrop)); + return () => document.body.classList.remove('hg-stacking'); + }, [stackDrop]); + + // A widget carried off to another tab ends the grid's drag without a + // drop: don't leave "Stack with…" on the screen. + useEffect(() => { + clearStackDwell(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [carriedName, activeTab]); + + // Put `dragged` with `target` on the open tab: a new Stack where the target + // was, or one more widget in the target Stack. + // Whether `widget` is on `tab` marked as in that Stack, and the Stack placed + // there: a placing write that got no answer did go through. + const stackPlaced = async (tab, stackId, widget) => { + try { + const { data } = await axios.get(`${deviceApi()}/tabs`, { timeout: WRITE_TIMEOUT_MS }); + const row = (Array.isArray(data) ? data : []).find((item) => item.number === tab); + const map = typeof row?.config_json === 'string' ? JSON.parse(row.config_json || '{}') : (row?.config_json || {}); + return stackName(stackId) in map && map[widget]?.in_stack === stackId; + } catch { + return false; + } + }; + 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); + if (!stack) { + // Full: say so, rather than marking the widget into a Stack it isn't in. + setWidgetNotice({ message: i18n.t('common:widgetMenu.stackFull') }); + return; + } + const stacks = existing + ? widgetStacks.map((item) => (item.id === existing.id ? stack : item)) + : [...widgetStacks, stack]; + let savedDefinition = false; + try { + const saved = await axios.patch(`${deviceApi()}/settings`, { [WIDGET_STACKS_KEY]: { stacks } }, { timeout: WRITE_TIMEOUT_MS }); + hydrateFromDeviceSettings(saved.data || {}); + savedDefinition = true; + // Their entries stay, marked as in the Stack (settings and all); a new + // Stack goes on the tab in the target's spot in the same write. + const spot = existing ? null : (getWidgetLayoutForTab(target, tab) || { x: 0, y: 0, w: 12, h: 4 }); + await axios.patch(`${deviceApi()}/widget-assignments/in-stack`, { + tabNumber: tab, + stack: stack.id, + widgets: existing ? [dragged] : [target, dragged], + create: true, + ...(spot ? { place: { layout_x: spot.x, layout_y: spot.y, layout_w: spot.w, layout_h: spot.h } } : {}), + }, { timeout: WRITE_TIMEOUT_MS }); + } catch (error) { + console.error('Error stacking widgets:', error); + setWidgetNotice({ message: i18n.t('common:widgetMenu.stackFailed') }); + // The Stack was saved but couldn't be placed: undo just that, so no + // empty Stack is left behind (and none piles up from trying again). + // No answer may still mean it went through: then it's kept. + if (savedDefinition && !(!error?.response && await stackPlaced(tab, stack.id, dragged))) { + try { + // From the Stacks as saved now, so a change made meanwhile isn't + // lost: the new Stack taken out, or the one added to as it was. + const { data: current } = await axios.get(`${deviceApi()}/settings`, { timeout: WRITE_TIMEOUT_MS }); + const now = normalizeStacks(current?.[WIDGET_STACKS_KEY]).stacks; + const back = existing + ? now.map((item) => (item.id === existing.id ? existing : item)) + : now.filter((item) => item.id !== stack.id); + const saved = await axios.patch(`${deviceApi()}/settings`, { [WIDGET_STACKS_KEY]: { stacks: back } }, { timeout: WRITE_TIMEOUT_MS }); + hydrateFromDeviceSettings(saved.data || {}); + } catch (rollbackError) { + console.error('Error putting the Stacks back:', rollbackError); + } + } + } + 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) + // A widget shown in a Stack here isn't shown on its own as well. + && visibleOnTab(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. @@ -1840,6 +2700,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 && } @@ -1877,8 +2747,65 @@ const App = () => { {!mirrorTarget && isMobile && mobileWidgets.length > 0 && ( )} + {carry.overlay} + setRemoveRequest(null)} + undo={widgetNotice ? { key: widgetNotice.key, message: widgetNotice.message, canUndo: Boolean(widgetNotice.undo) } : null} + onUndo={undoRemoveWidget} + onUndoClose={(key) => setNotices((current) => dropNotice(current, key))} + /> + {stackDrop && ( + + )} + {!screensaverActive && ( + makeWidgetList(activeTab, (n) => n === name, () => ({ x: 0, y: 0, w: 12, h: 6 }))[0]?.content || null} + nameFor={takeoverNameFor} + onDismiss={takeovers.dismiss} + /> + )} {!mirrorTarget && !isMobile && widgets.length > 0 && ( + { isActive={widgetsActive} expandSettings={screensaverSettings} onExpandedSizeChange={setExpandedWidgetSize} + onWidgetDrag={handleWidgetDrag} + onWidgetDragStop={handleWidgetDragStop} + widgetMenu={widgetMenu} + onLayoutsSaved={applySavedLayouts} /> + )} {shouldRunWeatherBackgroundPrefetch && ( @@ -1995,6 +2927,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} @@ -2027,7 +2962,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 fbec03ee..1a8735f5 100644 --- a/client/src/components/AdminPanel.jsx +++ b/client/src/components/AdminPanel.jsx @@ -73,7 +73,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, getLocalDeviceName, getLocalDeviceApiBase, getMirrorTarget, setDeviceName, isValidDeviceName } from '../utils/deviceName.js'; import { buildMirrorHash } from '../utils/mirrorMode.js'; import { getDeviceRowState } from '../utils/mirrorAdmin.js'; @@ -99,11 +99,14 @@ 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'; import useIsMobile from '../hooks/useIsMobile.js'; -import { syncWidgetAssignments } from '../utils/assignmentSync.js'; +import { isHiddenInStack, syncWidgetAssignments } from '../utils/assignmentSync.js'; import { normalizeWidgetSettings as normalizeSharedWidgetSettings } from '../utils/widgetSettings.js'; import { stackableTableSx } from '../utils/responsiveTable.js'; import { @@ -307,6 +310,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. @@ -327,6 +338,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), @@ -645,6 +659,8 @@ const AdminPanel = ({ setWidgetSettings, onPluginsChanged, onTabsChanged, onRequ const coreAssignments = {}; const pluginAssign = {}; assignments.forEach(assignment => { + // Shown in a Stack there, not on that tab on its own. + if (isHiddenInStack(assignment, assignments)) return; if (assignment.widget_name.startsWith('plugin:')) { if (!pluginAssign[assignment.widget_name]) { pluginAssign[assignment.widget_name] = []; @@ -660,6 +676,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({}); @@ -901,7 +927,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); }); }; @@ -919,7 +945,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); } @@ -2053,7 +2079,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={( @@ -2130,6 +2157,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..21750e05 --- /dev/null +++ b/client/src/components/StackWidget.jsx @@ -0,0 +1,284 @@ +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'; +import { ownsSideways } from '../utils/flickTarget.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. + // Then as soon as they've left it be (it may never go back on its own, with + // no return time, so it's checked again then rather than missed). + useEffect(() => { + if (!attention) return undefined; + const quietMs = Math.max(returnSeconds, 15) * 1000; + let timer = null; + const check = () => { + const left = quietMs - (Date.now() - lastTouchRef.current); + if (left <= 0) setIndex(homeIndex()); + else timer = setTimeout(check, left + 50); + }; + check(); + return () => clearTimeout(timer); + }, [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; + } + // A slider, a sideways list, a graph you scrub: the flick is theirs. + startRef.current = ownsSideways(event.target, rootRef.current) + ? null + : { 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..2c15c1b8 --- /dev/null +++ b/client/src/components/StacksSettings.jsx @@ -0,0 +1,555 @@ +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 { unstackSpots } from '../utils/gridPlacement.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); + + // Failures are not hidden: the save stops, says so, and reloads what's + // really saved, rather than flashing "saved" over a half-done change. + const assign = (name, tab) => axios.post(`${apiDeviceUrl}/widget-assignments`, { widget_name: name, tabNumber: tab }); + // Widgets in a Stack keep their entry on the tab, marked with the Stack's + // id (in_stack), so their per-tab settings stay with them. `create` gives + // a widget not on that tab yet an entry there. + const mark = (tab, widgets, stackId, create) => (widgets.length + ? axios.patch(`${apiDeviceUrl}/widget-assignments/in-stack`, { tabNumber: tab, stack: stackId, widgets, create }) + : Promise.resolve()); + // Out of the Stack on that tab: a widget whose entry holds a spot or its + // settings comes back on its own; one made only for the Stack goes. Nothing + // the widget had is deleted (server: utils/stackEntries.js). + const release = (tab, widgets, stackId) => (widgets.length + ? axios.patch(`${apiDeviceUrl}/widget-assignments/in-stack`, { tabNumber: tab, release: stackId, widgets }) + : Promise.resolve()); + const rowsOn = (tab) => assignments.filter((row) => row.tab_number === Number(tab)); + const spotOf = (row) => (row && row.layout_w != null && row.layout_x != null + ? { x: row.layout_x, y: row.layout_y, w: row.layout_w, h: row.layout_h } + : null); + const markedOn = (stack, tab) => rowsOn(tab).filter((row) => row.in_stack === stack.id).map((row) => row.widget_name); + + 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, its widgets show in it rather + // than on their own; taken off a tab, they go with it. + // `before` is the Stack as it was saved (for an edit), to follow its widgets. + const placeOn = async (stack, numbers, before = null) => { + const name = stackName(stack.id); + const now = tabsOf(stack); + for (const tab of numbers.filter((n) => !now.includes(n))) { + // It takes the spot of the first of its widgets showing there. + const here = rowsOn(tab).find((row) => stack.widgets.includes(row.widget_name) && !row.in_stack && spotOf(row)); + await assign(name, tab); + if (here) { + const spot = spotOf(here); + await axios.patch(`${apiDeviceUrl}/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 mark(tab, stack.widgets, stack.id, true); + } + for (const tab of now.filter((n) => numbers.includes(n))) { + // Widgets added since: in it here too. Widgets taken out: back on their + // own if they had a spot or settings here, else off this tab. + const added = stack.widgets.filter((widget) => !(before?.widgets || []).includes(widget)); + await mark(tab, added, stack.id, true); + const gone = markedOn(stack, tab).filter((widget) => !stack.widgets.includes(widget)); + await release(tab, gone, stack.id); + } + for (const tab of now.filter((n) => !numbers.includes(n))) { + // Off this tab, in one write: widgets with a spot or settings of their + // own here come back on their own; the rest go with it. + await axios.post(`${apiDeviceUrl}/widget-assignments/unstack`, { tabNumber: tab, stack: stack.id, dropBlank: true }); + } + }; + const onPlace = async (stack, numbers) => { + setSaving(true); + try { + await placeOn(stack, numbers, stack); + await done(t('admin:stacks.placed')); + } catch (error) { + console.error('Error placing stack:', error); + await load(); + flash('error', error?.response?.data?.error || t('admin:stacks.saveFailed')); + } 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; + const before = isNew ? null : stacks.find((stack) => stack.id === draft.id) || null; + try { + // By id, new or not: a retry after a save that stopped half way finds + // the Stack already saved, and must replace it, not add a second. + const next = stacks.some((stack) => stack.id === draft.id) + ? stacks.map((stack) => (stack.id === draft.id ? draft : stack)) + : [...stacks, draft]; + await saveStacks(next); + await placeOn(draft, numbers, before); + setEditing(null); + await done(t('admin:stacks.saved', { name: draft.name || t('admin:stacks.untitled') })); + } catch (error) { + console.error('Error saving stack:', error); + await load(); + flash('error', error?.response?.data?.error || t('admin:stacks.saveFailed')); + } finally { + setSaving(false); + } + }; + + // Unstack: on each tab the Stack was on, its widgets go back on their own + // with the settings they had: the first in its spot, the rest in the first + // open spots. + 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 here = new Map(rowsOn(tab).map((item) => [item.widget_name, item])); + // On their own here already: left where they are. + const back = stack.widgets.filter((widget) => !here.has(widget) || here.get(widget).in_stack === stack.id); + const taken = rowsOn(tab) + .filter((item) => item.widget_name !== name && !back.includes(item.widget_name) && !item.in_stack && item.layout_w != null) + .map(spotOf); + const layouts = unstackSpots( + back.map((widget) => ({ name: widget, kept: here.has(widget) ? spotOf(here.get(widget)) : null })), + spotOf(row), + taken, + ).map((item) => ({ widget_name: item.name, layout_x: item.x, layout_y: item.y, layout_w: item.w, layout_h: item.h })); + // The Stack leaving, its widgets unmarked and placed: one write. + await axios.post(`${apiDeviceUrl}/widget-assignments/unstack`, { tabNumber: tab, stack: stack.id, members: stack.widgets, layouts }); + } + await saveStacks(stacks.filter((item) => item.id !== stack.id)); + setEditing(null); + await done(t('admin:stacks.unstacked')); + } catch (error) { + console.error('Error unstacking:', error); + await load(); + 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 ( + <> +