// ------------------------------------------------------------------------ // components/ConfigPanel.js - Dropdown holding the profile picker and the // context-sensitive config strip, opened from the profile trigger embedded // in the middle of the (permanently floating) top nav. // // Lifted out of DayTabs.js so the profile/config UI no longer occupies a // permanent full-width band above the forecast — it now expands downward // from the nav, directly below it, keeping the original full-width // horizontal layout (profile tabs, horizontally-scrolling card row, config // strip) intact rather than squeezing it into a side drawer. // // Purely presentational - all state lives in useAppState. Dismisses on the // × button, backdrop click, or Esc. // // Props: // onClose - called on dismiss (×, backdrop, Esc) // isPro - boolean Pro status // activeProfile - current filter profile key // activateProfile - function to switch profile // activeCols - effective column set for current profile // activityOptions/placeOptions/workOptions - dropdown options // activityValue/placeValue/workValue - current variant values // outdoorsVariant, setOutdoorsVariantAndSave // setActiveProfile, setVisibleCols, setIndoorMode // setProPromptSource, setProPromptDay - Pro upsell triggers // indoorManaged/setIndoorManaged, buildingType/setBuildingType // utciEnv/setUtciEnv, vehicleType/setVehicleType, // vehicleSpeed/setVehicleSpeed, vehicleVent/setVehicleVent, // furColor/setFurColor, skinType/setSkinType, pollenType/setPollenTypeAndSave // ------------------------------------------------------------------------ import { h, Fragment } from '../../vendor/preact.js'; import { useRef, useEffect, useState } from '../../vendor/preact-hooks.js'; import htm from '../../vendor/htm.js'; import { SKIN_TYPES, VEHICLE_TYPES, VEHICLE_SPEEDS, BUILDING_TYPES, FUR_COLORS } from '../utils.js'; import { FILTER_PROFILES, OUTDOORS_VARIANTS, profileButtonOrder, activityVariantKeys, placeVariantKeys, workVariantKeys, POLLEN_TYPES, UTCI_ENVIRONMENTS, deriveProfileMain } from '../config.js'; import { CustomSelect, VentPill } from '../components.js'; const html = htm.bind(h); export function ConfigPanel({ open, onClose, isPro, activeProfile, activateProfile, activeCols, activityOptions, placeOptions, workOptions, activityValue, placeValue, workValue, outdoorsVariant, setOutdoorsVariantAndSave, setActiveProfile, setVisibleCols, setIndoorMode, setProPromptSource, setProPromptDay, indoorManaged, setIndoorManaged, buildingType, setBuildingType, utciEnv, setUtciEnv, vehicleType, setVehicleType, vehicleSpeed, setVehicleSpeed, vehicleVent, setVehicleVent, furColor, setFurColor, skinType, setSkinType, pollenType, setPollenTypeAndSave, }) { const profileScrollRef = useRef(null); const profileWrapRef = useRef(null); const dropdownRef = useRef(null); const { mainConfigKey, mainLabel } = deriveProfileMain(activeProfile, outdoorsVariant); // Active tab: common - places - activities - work. // Auto-derived from the current profile on mount and on change. const getTab = () => { if (activeProfile === 'outdoors' && placeVariantKeys.includes(outdoorsVariant)) return 'places'; if (activeProfile === 'outdoors' && activityVariantKeys.includes(outdoorsVariant)) return 'activities'; if (activeProfile === 'farming' || (activeProfile === 'outdoors' && workVariantKeys.includes(outdoorsVariant))) return 'work'; return 'common'; }; const [activeTab, setActiveTab] = useState(getTab); useEffect(() => { setActiveTab(getTab()); }, [activeProfile, outdoorsVariant]); // Dismiss on Esc (component stays mounted while closed so the close // transition can play, so this only acts while actually open). useEffect(() => { const onKey = (e) => { if (open && e.key === 'Escape') onClose(); }; document.addEventListener('keydown', onKey); return () => document.removeEventListener('keydown', onKey); }, [open, onClose]); // From tablet up the panel drops *out of* the active-profile-row rather // than sliding up from the floating button (which is mobile-only), so it // has to be anchored to that row's live bottom edge — it's sticky on // desktop and scrolls away on tablet, hence remeasuring on scroll/resize. // Mobile (<=640px) ignores the var entirely and keeps the bottom sheet. useEffect(() => { const el = dropdownRef.current; if (!el) return; const measure = () => { const row = document.querySelector('.active-profile-row'); const top = row ? Math.max(0, row.getBoundingClientRect().bottom) : 0; el.style.setProperty('--profile-anchor-top', `${Math.round(top)}px`); // Not enough room below the row for the panel's content? Fall back to // the mobile bottom-sheet layout, which gets the full viewport height. const body = el.querySelector('.profile-dropdown-body'); const content = body ? body.scrollHeight : 0; el.classList.toggle('is-bottom-sheet', content > window.innerHeight - top - 16); }; measure(); if (!open) return; window.addEventListener('scroll', measure, true); window.addEventListener('resize', measure); return () => { window.removeEventListener('scroll', measure, true); window.removeEventListener('resize', measure); }; }, [open, activeTab, activeProfile, outdoorsVariant]); // Drag-to-scroll + fade edges for the horizontal profile card row. useEffect(() => { const el = profileScrollRef.current; const wrap = profileWrapRef.current; if (!el) return; let isDown = false, startX = 0, startScroll = 0, hasDragged = false; const updateFades = () => { if (!wrap) return; wrap.classList.toggle('fade-left', el.scrollLeft > 1); wrap.classList.toggle('fade-right', el.scrollLeft + el.clientWidth < el.scrollWidth - 1); }; const onMouseDown = (e) => { if (!el.contains(e.target) || e.button !== 0) return; isDown = true; hasDragged = false; startX = e.clientX; startScroll = el.scrollLeft; document.body.style.userSelect = 'none'; document.body.style.webkitUserSelect = 'none'; }; const onMouseMove = (e) => { if (!isDown) return; const dx = e.clientX - startX; if (Math.abs(dx) > 5) { hasDragged = true; el.style.cursor = 'grabbing'; el.scrollLeft = startScroll - dx; } }; const onMouseUp = () => { if (!isDown) return; isDown = false; el.style.cursor = ''; document.body.style.userSelect = ''; document.body.style.webkitUserSelect = ''; }; const onClickCapture = (e) => { if (hasDragged) { e.stopPropagation(); e.preventDefault(); hasDragged = false; } }; el.addEventListener('scroll', updateFades); updateFades(); // set initial fade state document.addEventListener('mousedown', onMouseDown); document.addEventListener('mousemove', onMouseMove); document.addEventListener('mouseup', onMouseUp); el.addEventListener('click', onClickCapture, true); return () => { el.removeEventListener('scroll', updateFades); document.removeEventListener('mousedown', onMouseDown); document.removeEventListener('mousemove', onMouseMove); document.removeEventListener('mouseup', onMouseUp); el.removeEventListener('click', onClickCapture, true); }; }, []); const mainBadge = html`${mainLabel} Config:`; const showSolar = isPro ? (activeProfile === 'custom' || activeProfile === 'showall' || activeCols['utciP']) : activeCols['utciP']; const showVehicle = isPro ? (activeProfile === 'custom' || activeProfile === 'showall' || activeCols['vehicleT']) : activeCols['vehicleT']; const showIndoor = isPro ? (activeProfile === 'custom' || activeProfile === 'showall' || activeCols['indoorT'] || activeCols['managedT']) : (activeCols['indoorT'] || activeCols['managedT']); const showFur = isPro ? (activeProfile === 'custom' || activeProfile === 'showall' || activeCols['furSurfaceT']) : activeCols['furSurfaceT']; const showBurn = isPro ? (activeProfile === 'custom' || activeProfile === 'showall' || activeCols['burn']) : activeCols['burn']; const showPollen = isPro ? (activeProfile === 'custom' || activeProfile === 'showall' || activeCols['pollen']) : activeCols['pollen']; const configItems = [ { key: 'solar', show: showSolar, node: html` SunSoak <${CustomSelect} value=${utciEnv} isOn=${true} noHide=${true} grpClass="grp-felt" hideLabel="SunSoak" options=${Object.entries(UTCI_ENVIRONMENTS).map(([k, v]) => ({ value: k, label: v.label }))} onChange=${(v) => setUtciEnv(v)} />` }, { key: 'vehicle', show: showVehicle, node: html` Vehicle <${CustomSelect} value=${vehicleType} isOn=${true} noHide=${true} grpClass="grp-felt" hideLabel="Vehicle" groupedLeft=${true} isLastChild=${false} options=${Object.entries(VEHICLE_TYPES).map(([k, v]) => ({ value: k, label: v.name }))} onChange=${(v) => setVehicleType(v)} /> <${CustomSelect} value=${vehicleSpeed} isOn=${true} noHide=${true} grpClass="grp-felt" buttonLabel=${(VEHICLE_SPEEDS[vehicleSpeed] ?? VEHICLE_SPEEDS.static).name} groupedLeft=${true} isLastChild=${false} options=${Object.entries(VEHICLE_SPEEDS).map(([k, v]) => ({ value: k, label: v.name }))} onChange=${(v) => setVehicleSpeed(v)} /> <${VentPill} checked=${vehicleVent} onChange=${() => setVehicleVent(v => !v)} grpClass="grp-felt" label="Ventilation" title="Ventilation — open windows significantly reduce cabin heat build-up. Speed sets cabin airflow from forward motion." /> ` }, { key: 'indoor', show: showIndoor, node: html` Indoors <${CustomSelect} value=${buildingType} isOn=${true} noHide=${true} grpClass="grp-felt" hideLabel="Indoors" groupedLeft=${true} isLastChild=${false} options=${Object.entries(BUILDING_TYPES).map(([k, v]) => ({ value: k, label: v.name }))} onChange=${(v) => setBuildingType(v)} /> <${VentPill} checked=${indoorManaged} onChange=${() => setIndoorManaged(v => !v)} grpClass="grp-felt" label="Managed" title="Managed: curtains closed by day, windows open when cooler outside" /> ` }, { key: 'fur', show: showFur, node: html` Fur Colour <${CustomSelect} value=${furColor} isOn=${true} noHide=${true} grpClass="grp-pets" hideLabel="Fur Colour" options=${Object.entries(FUR_COLORS).map(([k, v]) => ({ value: k, label: v.name }))} onChange=${(v) => setFurColor(v)} />` }, { key: 'burn', show: showBurn, node: html` Burn <${CustomSelect} value=${skinType} isOn=${true} noHide=${true} grpClass="grp-felt" hideLabel="Burn" options=${Object.entries(SKIN_TYPES).map(([k, v]) => ({ value: k, label: v.name.split(' · ')[1] + ' skin' }))} onChange=${(v) => setSkinType(v)} />` }, { key: 'pollen', show: showPollen, node: html` Pollen <${CustomSelect} value=${pollenType} isOn=${true} noHide=${true} grpClass="grp-airqual" hideLabel="Pollen" options=${Object.entries(POLLEN_TYPES).flatMap(([k, v], i) => [ { value: k, label: v.name }, ...(i === 0 ? [{ value: '_div', divider: true }] : []), ])} onChange=${(v) => setPollenTypeAndSave(v)} />` }, ].filter((item) => item.show); configItems.sort((a, b) => (a.key === mainConfigKey ? -1 : b.key === mainConfigKey ? 1 : 0)); return html` <${Fragment}>
`; }