Updated pet calcs to be more accurate Fixed some layout issues Updated profile popup
398 lines
20 KiB
JavaScript
398 lines
20 KiB
JavaScript
// ------------------------------------------------------------------------
|
||
// 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`<span class="config-item-main-badge" title="Main selection — this is what drives the day tab's hi/lo readout">${mainLabel} Config:</span>`;
|
||
|
||
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`
|
||
<span class="config-item-label">SunSoak</span>
|
||
<${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`
|
||
<span class="config-item-label">Vehicle</span>
|
||
<span class="col-toggle-group col-toggle-group--expanded col-toggle-group--triple">
|
||
<${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."
|
||
/>
|
||
</span>` },
|
||
{ key: 'indoor', show: showIndoor, node: html`
|
||
<span class="config-item-label">Indoors</span>
|
||
<span class="col-toggle-group col-toggle-group--expanded">
|
||
<${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"
|
||
/>
|
||
</span>` },
|
||
{ key: 'fur', show: showFur, node: html`
|
||
<span class="config-item-label">Fur Colour</span>
|
||
<${CustomSelect}
|
||
value=${furColor} isOn=${true} noHide=${true} grpClass="grp-surface"
|
||
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`
|
||
<span class="config-item-label">Burn</span>
|
||
<${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`
|
||
<span class="config-item-label">Pollen</span>
|
||
<${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}>
|
||
<div class=${`profile-dropdown-overlay${open ? ' is-open' : ''}`} onClick=${onClose}></div>
|
||
<div ref=${dropdownRef} class=${`profile-dropdown${open ? ' is-open' : ''}`} role="dialog" aria-modal="true" aria-label="Profile and configuration" aria-hidden=${!open}>
|
||
<button class="profile-dropdown-close" aria-label="Close" onClick=${onClose}>×</button>
|
||
<div class="profile-dropdown-body">
|
||
|
||
<div class="filter-profiles">
|
||
<div class="profile-tabs">
|
||
${[['common','Common'],['places','Places'],['activities','Activities'],['work','Work']].map(([key, label]) => html`
|
||
<button
|
||
key=${key}
|
||
class=${`profile-tab${activeTab === key ? ' active' : ''}`}
|
||
onClick=${() => setActiveTab(key)}
|
||
>${label}</button>
|
||
`)}
|
||
</div>
|
||
|
||
<!-- Invisible, always-rendered copy of the Activities cards. It has no
|
||
visual presence (height:0, hidden) but stays in normal flow so its
|
||
natural width feeds the dropdown's fit-content sizing — this keeps
|
||
the popup a constant width across all four tabs instead of
|
||
resizing as the user switches tabs, using Activities (the widest
|
||
tab) as that fixed reference size. -->
|
||
<div class="profile-scroll-sizer" aria-hidden="true">
|
||
<div class="profile-scroll">
|
||
${activityOptions.map((opt) => html`
|
||
<span key=${'sizer-' + opt.value} class="profile-btn" tabIndex="-1">
|
||
<span class="profile-btn-scene"></span>
|
||
<span class="profile-btn-label">${opt.name}</span>
|
||
</span>
|
||
`)}
|
||
</div>
|
||
</div>
|
||
|
||
<div class="profile-scroll-wrap" ref=${profileWrapRef}>
|
||
<span class="profile-scroll-chevron left" aria-hidden="true">‹</span>
|
||
<span class="profile-scroll-chevron right" aria-hidden="true">›</span>
|
||
<div class="profile-scroll" ref=${profileScrollRef}>
|
||
|
||
${activeTab === 'common' && profileButtonOrder.map((key) => {
|
||
const profile = FILTER_PROFILES[key];
|
||
const locked = profile.proOnly && !isPro;
|
||
return html`
|
||
<button
|
||
key=${key}
|
||
class=${`profile-btn${activeProfile === key ? ' active' : ''}${locked ? ' locked' : ''}`}
|
||
style=${{ cursor: 'pointer', position: 'relative' }}
|
||
title=${locked ? `Profile ${profile.label} is part of SunScope Extra` : ''}
|
||
onClick=${() => {
|
||
if (locked) { setProPromptSource(`profile:${key}`); setProPromptDay(0); return; }
|
||
activateProfile(key);
|
||
}}
|
||
>
|
||
<span class="profile-btn-scene" style=${{ background: profile.scene, filter: locked ? 'grayscale(100%)' : 'none' }}>${profile.scene.includes('url(') ? null : profile.icon}</span>
|
||
<span class="profile-btn-label">${locked ? '🔒 ' : ''}${profile.label}</span>
|
||
${locked && html`<span class="profile-lock-badge">🔒</span>`}
|
||
</button>`;
|
||
})}
|
||
|
||
${activeTab === 'places' && placeOptions.map((opt) => html`
|
||
<button
|
||
key=${opt.value}
|
||
class=${`profile-btn${placeValue === opt.value ? ' active' : ''}${opt.disabled ? ' locked' : ''}`}
|
||
style=${{ cursor: 'pointer', position: 'relative' }}
|
||
onClick=${() => {
|
||
if (opt.disabled) { setProPromptSource(`variant:${opt.value}`); setProPromptDay(0); return; }
|
||
try { localStorage.setItem('sunscope_profile', 'outdoors'); } catch(e) {}
|
||
setActiveProfile('outdoors');
|
||
setOutdoorsVariantAndSave(opt.value);
|
||
setVisibleCols({ ...OUTDOORS_VARIANTS[opt.value].cols });
|
||
setIndoorMode('off');
|
||
setIndoorManaged(false);
|
||
}}
|
||
>
|
||
<span class="profile-btn-scene" style=${{ background: opt.scene, filter: opt.disabled ? 'grayscale(100%)' : 'none' }}></span>
|
||
<span class="profile-btn-label">${opt.disabled ? '🔒 ' : ''}${opt.name}</span>
|
||
${opt.disabled && html`<span class="profile-lock-badge">🔒</span>`}
|
||
</button>
|
||
`)}
|
||
|
||
${activeTab === 'activities' && activityOptions.map((opt) => html`
|
||
<button
|
||
key=${opt.value}
|
||
class=${`profile-btn${activityValue === opt.value ? ' active' : ''}${opt.disabled ? ' locked' : ''}`}
|
||
style=${{ cursor: 'pointer', position: 'relative' }}
|
||
onClick=${() => {
|
||
if (opt.disabled) { setProPromptSource(`variant:${opt.value}`); setProPromptDay(0); return; }
|
||
try { localStorage.setItem('sunscope_profile', 'outdoors'); } catch(e) {}
|
||
setActiveProfile('outdoors');
|
||
setOutdoorsVariantAndSave(opt.value);
|
||
setVisibleCols({ ...OUTDOORS_VARIANTS[opt.value].cols });
|
||
setIndoorMode('off');
|
||
setIndoorManaged(false);
|
||
}}
|
||
>
|
||
<span class="profile-btn-scene" style=${{ background: opt.scene, filter: opt.disabled ? 'grayscale(100%)' : 'none' }}></span>
|
||
<span class="profile-btn-label">${opt.disabled ? '🔒 ' : ''}${opt.name}</span>
|
||
${opt.disabled && html`<span class="profile-lock-badge">🔒</span>`}
|
||
</button>
|
||
`)}
|
||
|
||
${activeTab === 'work' && workOptions.map((opt) => html`
|
||
<button
|
||
key=${opt.value}
|
||
class=${`profile-btn${workValue === opt.value ? ' active' : ''}${opt.disabled ? ' locked' : ''}`}
|
||
style=${{ cursor: 'pointer', position: 'relative' }}
|
||
onClick=${() => {
|
||
if (opt.disabled) { setProPromptSource(`variant:${opt.value}`); setProPromptDay(0); return; }
|
||
if (opt.value === 'farming') { activateProfile('farming'); return; }
|
||
try { localStorage.setItem('sunscope_profile', 'outdoors'); } catch(e) {}
|
||
setActiveProfile('outdoors');
|
||
setOutdoorsVariantAndSave(opt.value);
|
||
setVisibleCols({ ...OUTDOORS_VARIANTS[opt.value].cols });
|
||
if (opt.value === 'office') { setBuildingType('office'); setIndoorMode('on'); setIndoorManaged(false); }
|
||
else { setIndoorMode('off'); setIndoorManaged(false); }
|
||
}}
|
||
>
|
||
<span class="profile-btn-scene" style=${{ background: opt.scene, filter: opt.disabled ? 'grayscale(100%)' : 'none' }}></span>
|
||
<span class="profile-btn-label">${opt.disabled ? '🔒 ' : ''}${opt.name}</span>
|
||
${opt.disabled && html`<span class="profile-lock-badge">🔒</span>`}
|
||
</button>
|
||
`)}
|
||
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
${configItems.length > 0 && html`
|
||
<div class="col-toggles-config">
|
||
${configItems.map((item) => html`
|
||
<${Fragment} key=${item.key}>
|
||
${item.key === mainConfigKey && mainBadge}
|
||
<span class=${`col-toggles-config-item${item.key === mainConfigKey ? ' col-toggles-config-item--main' : ''}`}>
|
||
${item.node}
|
||
</span>
|
||
</${Fragment}>`)}
|
||
</div>`}
|
||
|
||
</div>
|
||
</div>
|
||
</${Fragment}>`;
|
||
}
|