UI Overhaul
Profiles now in popup
Current profile always on screen
Hidden columns only when edit neeeded
Add pulldowns in to profile config
Customised the day tabs for vehicle, indoor and pet
This commit is contained in:
fraxle
2026-07-18 15:45:20 +01:00
parent f92e191a44
commit 916efca55e
13 changed files with 1594 additions and 610 deletions
+350
View File
@@ -0,0 +1,350 @@
// ------------------------------------------------------------------------
// 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 { 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]);
// 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 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>
<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}>`;
}