feat(zones): rename tabs to zones across api, ui, and storage

Made-with: Cursor
This commit is contained in:
pi
2026-04-06 18:22:03 +12:00
parent d1ffb857c8
commit fd618d7714
35 changed files with 1347 additions and 1303 deletions

View File

@@ -134,17 +134,17 @@ async def create_device(request):
}
), 400, {"Content-Type": "application/json"}
default_pattern = data.get("default_pattern")
tabs = data.get("tabs")
if isinstance(tabs, list):
tabs = [str(t) for t in tabs]
zl = data.get("zones")
if isinstance(zl, list):
zl = [str(t) for t in zl]
else:
tabs = []
zl = []
dev_id = devices.create(
name=name,
address=address,
mac=mac,
default_pattern=default_pattern,
tabs=tabs,
zones=zl,
device_type=device_type,
transport=transport,
)
@@ -178,8 +178,8 @@ async def update_device(request, id):
data["type"] = validate_device_type(data.get("type"))
if "transport" in data:
data["transport"] = validate_device_transport(data.get("transport"))
if "tabs" in data and isinstance(data["tabs"], list):
data["tabs"] = [str(t) for t in data["tabs"]]
if "zones" in data and isinstance(data["zones"], list):
data["zones"] = [str(t) for t in data["zones"]]
if devices.update(id, data):
return json.dumps(devices.read(id)), 200, {"Content-Type": "application/json"}
return json.dumps({"error": "Device not found"}), 404, {

View File

@@ -1,13 +1,13 @@
from microdot import Microdot
from microdot.session import with_session
from models.profile import Profile
from models.tab import Tab
from models.zone import Zone
from models.preset import Preset
import json
controller = Microdot()
profiles = Profile()
tabs = Tab()
zones = Zone()
presets = Preset()
@controller.get('')
@@ -83,20 +83,20 @@ async def create_profile(request):
try:
data = dict(request.json or {})
name = data.get("name", "")
seed_raw = data.get("seed_dj_tab", False)
seed_raw = data.get("seed_dj_zone", False)
if isinstance(seed_raw, str):
seed_dj_tab = seed_raw.strip().lower() in ("1", "true", "yes", "on")
seed_dj_zone = seed_raw.strip().lower() in ("1", "true", "yes", "on")
else:
seed_dj_tab = bool(seed_raw)
seed_dj_zone = bool(seed_raw)
# Request-only flag: do not persist on profile records.
data.pop("seed_dj_tab", None)
data.pop("seed_dj_zone", None)
profile_id = profiles.create(name)
# Avoid persisting request-only fields.
data.pop("name", None)
if data:
profiles.update(profile_id, data)
# New profiles always start with a default tab pre-populated with starter presets.
# New profiles always start with a default zone pre-populated with starter presets.
default_preset_ids = []
default_preset_defs = [
{
@@ -139,18 +139,18 @@ async def create_profile(request):
presets.update(pid, preset_data)
default_preset_ids.append(str(pid))
default_tab_id = tabs.create(name="default", names=["1"], presets=[default_preset_ids])
tabs.update(default_tab_id, {
default_tab_id = zones.create(name="default", names=["1"], presets=[default_preset_ids])
zones.update(default_tab_id, {
"presets_flat": default_preset_ids,
"default_preset": default_preset_ids[0] if default_preset_ids else None,
})
profile = profiles.read(profile_id) or {}
profile_tabs = profile.get("tabs", []) if isinstance(profile.get("tabs", []), list) else []
profile_tabs = profile.get("zones", []) if isinstance(profile.get("zones", []), list) else []
profile_tabs.append(str(default_tab_id))
if seed_dj_tab:
# Seed a DJ-focused tab with three starter presets.
if seed_dj_zone:
# Seed a DJ-focused zone with three starter presets.
seeded_preset_ids = []
preset_defs = [
{
@@ -182,15 +182,15 @@ async def create_profile(request):
presets.update(pid, preset_data)
seeded_preset_ids.append(str(pid))
dj_tab_id = tabs.create(name="dj", names=["dj"], presets=[seeded_preset_ids])
tabs.update(dj_tab_id, {
dj_tab_id = zones.create(name="dj", names=["dj"], presets=[seeded_preset_ids])
zones.update(dj_tab_id, {
"presets_flat": seeded_preset_ids,
"default_preset": seeded_preset_ids[0] if seeded_preset_ids else None,
})
profile_tabs.append(str(dj_tab_id))
profiles.update(profile_id, {"tabs": profile_tabs})
profiles.update(profile_id, {"zones": profile_tabs})
profile_data = profiles.read(profile_id)
return json.dumps({profile_id: profile_data}), 201, {'Content-Type': 'application/json'}
@@ -208,7 +208,7 @@ async def clone_profile(request, id):
data = request.json or {}
source_name = source.get("name") or f"Profile {id}"
new_name = data.get("name") or source_name
profile_type = source.get("type", "tabs")
profile_type = source.get("type", "zones")
def allocate_id(model, cache):
if "next" not in cache:
@@ -255,28 +255,28 @@ async def clone_profile(request, id):
palette_colors = []
# Clone tabs and presets used by those tabs
source_tabs = source.get("tabs")
source_tabs = source.get("zones")
if not isinstance(source_tabs, list) or len(source_tabs) == 0:
source_tabs = source.get("tab_order", [])
source_tabs = source.get("zone_order", [])
source_tabs = source_tabs or []
cloned_tab_ids = []
preset_id_map = {}
new_tabs = {}
new_presets = {}
for tab_id in source_tabs:
tab = tabs.read(tab_id)
if not tab:
for zone_id in source_tabs:
zone = zones.read(zone_id)
if not zone:
continue
tab_name = tab.get("name") or f"Tab {tab_id}"
tab_name = zone.get("name") or f"Zone {zone_id}"
clone_name = tab_name
mapped_presets = map_preset_container(tab.get("presets"), preset_id_map, preset_cache, new_profile_id, new_presets)
clone_id = allocate_id(tabs, tab_cache)
mapped_presets = map_preset_container(zone.get("presets"), preset_id_map, preset_cache, new_profile_id, new_presets)
clone_id = allocate_id(zones, tab_cache)
clone_data = {
"name": clone_name,
"names": tab.get("names") or [],
"names": zone.get("names") or [],
"presets": mapped_presets if mapped_presets is not None else []
}
extra = {k: v for k, v in tab.items() if k not in ("name", "names", "presets")}
extra = {k: v for k, v in zone.items() if k not in ("name", "names", "presets")}
if "presets_flat" in extra:
extra["presets_flat"] = map_preset_container(extra.get("presets_flat"), preset_id_map, preset_cache, new_profile_id, new_presets)
if extra:
@@ -287,7 +287,7 @@ async def clone_profile(request, id):
new_profile_data = {
"name": new_name,
"type": profile_type,
"tabs": cloned_tab_ids,
"zones": cloned_tab_ids,
"scenes": list(source.get("scenes", [])) if isinstance(source.get("scenes", []), list) else [],
"palette_id": str(new_palette_id),
}
@@ -297,12 +297,12 @@ async def clone_profile(request, id):
for pid, pdata in new_presets.items():
presets[pid] = pdata
for tid, tdata in new_tabs.items():
tabs[tid] = tdata
zones[tid] = tdata
profiles[str(new_profile_id)] = new_profile_data
profiles._palette_model.save()
presets.save()
tabs.save()
zones.save()
profiles.save()
return json.dumps({new_profile_id: new_profile_data}), 201, {'Content-Type': 'application/json'}

View File

@@ -1,346 +0,0 @@
from microdot import Microdot, send_file
from microdot.session import with_session
from models.tab import Tab
from models.profile import Profile
import json
import os
import time
controller = Microdot()
tabs = Tab()
profiles = Profile()
def get_current_profile_id(session=None):
"""Get the current active profile ID from session or fallback to first."""
profile_list = profiles.list()
session_profile = None
if session is not None:
session_profile = session.get('current_profile')
if session_profile and session_profile in profile_list:
return session_profile
if profile_list:
return profile_list[0]
return None
def get_profile_tab_order(profile_id):
"""Get the tab order for a profile."""
if not profile_id:
return []
profile = profiles.read(profile_id)
if profile:
# Support both "tab_order" (old) and "tabs" (new) format
return profile.get("tabs", profile.get("tab_order", []))
return []
def get_current_tab_id(request, session=None):
"""Get the current tab ID from cookie."""
# Read from cookie first
current_tab = request.cookies.get('current_tab')
if current_tab:
return current_tab
# Fallback to first tab in current profile
profile_id = get_current_profile_id(session)
if profile_id:
profile = profiles.read(profile_id)
if profile:
# Support both "tabs" (new) and "tab_order" (old) format
tabs_list = profile.get("tabs", profile.get("tab_order", []))
if tabs_list:
return tabs_list[0]
return None
def _render_tabs_list_fragment(request, session):
"""Helper function to render tabs list HTML fragment."""
profile_id = get_current_profile_id(session)
# #region agent log
try:
os.makedirs('/home/pi/led-controller/.cursor', exist_ok=True)
with open('/home/pi/led-controller/.cursor/debug.log', 'a') as _log:
_log.write(json.dumps({
"sessionId": "debug-session",
"runId": "tabs-pre-fix",
"hypothesisId": "H1",
"location": "src/controllers/tab.py:_render_tabs_list_fragment",
"message": "tabs list fragment",
"data": {
"profile_id": profile_id,
"profile_count": len(profiles.list())
},
"timestamp": int(time.time() * 1000)
}) + "\n")
except Exception:
pass
# #endregion
if not profile_id:
return '<div class="tabs-list">No profile selected</div>', 200, {'Content-Type': 'text/html'}
tab_order = get_profile_tab_order(profile_id)
current_tab_id = get_current_tab_id(request, session)
html = '<div class="tabs-list">'
for tab_id in tab_order:
tab_data = tabs.read(tab_id)
if tab_data:
active_class = 'active' if str(tab_id) == str(current_tab_id) else ''
tab_name = tab_data.get('name', 'Tab ' + str(tab_id))
html += (
'<button class="tab-button ' + active_class + '" '
'hx-get="/tabs/' + str(tab_id) + '/content-fragment" '
'hx-target="#tab-content" '
'hx-swap="innerHTML" '
'hx-push-url="true" '
'hx-trigger="click" '
'onclick="document.querySelectorAll(\'.tab-button\').forEach(b => b.classList.remove(\'active\')); this.classList.add(\'active\');">'
+ tab_name +
'</button>'
)
html += '</div>'
return html, 200, {'Content-Type': 'text/html'}
def _render_tab_content_fragment(request, session, id):
"""Helper function to render tab content HTML fragment."""
# Handle 'current' as a special case
if id == 'current':
current_tab_id = get_current_tab_id(request, session)
if not current_tab_id:
accept_header = request.headers.get('Accept', '')
wants_html = 'text/html' in accept_header
if wants_html:
return '<div class="error">No current tab set</div>', 404, {'Content-Type': 'text/html'}
return json.dumps({"error": "No current tab set"}), 404
id = current_tab_id
tab = tabs.read(id)
if not tab:
return '<div>Tab not found</div>', 404, {'Content-Type': 'text/html'}
# Set this tab as the current tab in session
session['current_tab'] = str(id)
session.save()
# If this is a direct page load (not HTMX), return full UI so CSS loads.
if not request.headers.get('HX-Request'):
return send_file('templates/index.html')
tab_name = tab.get('name', 'Tab ' + str(id))
html = (
'<div class="presets-section" data-tab-id="' + str(id) + '">'
'<h3>Presets</h3>'
'<div class="profiles-actions" style="margin-bottom: 1rem;"></div>'
'<div id="presets-list-tab" class="presets-list">'
'<!-- Presets will be loaded here -->'
'</div>'
'</div>'
)
return html, 200, {'Content-Type': 'text/html'}
@controller.get('')
@with_session
async def list_tabs(request, session):
"""List all tabs with current tab info."""
profile_id = get_current_profile_id(session)
current_tab_id = get_current_tab_id(request, session)
# Get tab order for current profile
tab_order = get_profile_tab_order(profile_id) if profile_id else []
# Build tabs list with metadata
tabs_data = {}
for tab_id in tabs.list():
tab_data = tabs.read(tab_id)
if tab_data:
tabs_data[tab_id] = tab_data
return json.dumps({
"tabs": tabs_data,
"tab_order": tab_order,
"current_tab_id": current_tab_id,
"profile_id": profile_id
}), 200, {'Content-Type': 'application/json'}
# Get current tab - returns JSON with tab data and content info
@controller.get('/current')
@with_session
async def get_current_tab(request, session):
"""Get the current tab from session."""
current_tab_id = get_current_tab_id(request, session)
if not current_tab_id:
return json.dumps({"error": "No current tab set", "tab": None, "tab_id": None}), 404
tab = tabs.read(current_tab_id)
if tab:
return json.dumps({
"tab": tab,
"tab_id": current_tab_id
}), 200, {'Content-Type': 'application/json'}
return json.dumps({"error": "Tab not found", "tab": None, "tab_id": None}), 404
@controller.post('/<id>/set-current')
async def set_current_tab(request, id):
"""Set a tab as the current tab in cookie."""
tab = tabs.read(id)
if not tab:
return json.dumps({"error": "Tab not found"}), 404
# Set cookie with current tab
response_data = json.dumps({"message": "Current tab set", "tab_id": id})
response = response_data, 200, {
'Content-Type': 'application/json',
'Set-Cookie': f'current_tab={id}; Path=/; Max-Age=31536000' # 1 year expiry
}
return response
@controller.get('/<id>')
async def get_tab(request, id):
"""Get a specific tab by ID."""
tab = tabs.read(id)
if tab:
return json.dumps(tab), 200, {'Content-Type': 'application/json'}
return json.dumps({"error": "Tab not found"}), 404
@controller.put('/<id>')
async def update_tab(request, id):
"""Update an existing tab."""
try:
data = request.json
if tabs.update(id, data):
return json.dumps(tabs.read(id)), 200, {'Content-Type': 'application/json'}
return json.dumps({"error": "Tab not found"}), 404
except Exception as e:
return json.dumps({"error": str(e)}), 400
@controller.delete('/<id>')
@with_session
async def delete_tab(request, session, id):
"""Delete a tab."""
try:
# Handle 'current' tab ID
if id == 'current':
current_tab_id = get_current_tab_id(request, session)
if current_tab_id:
id = current_tab_id
else:
return json.dumps({"error": "No current tab to delete"}), 404
if tabs.delete(id):
# Remove from profile's tabs
profile_id = get_current_profile_id(session)
if profile_id:
profile = profiles.read(profile_id)
if profile:
# Support both "tabs" (new) and "tab_order" (old) format
tabs_list = profile.get('tabs', profile.get('tab_order', []))
if id in tabs_list:
tabs_list.remove(id)
profile['tabs'] = tabs_list
# Remove old tab_order if it exists
if 'tab_order' in profile:
del profile['tab_order']
profiles.update(profile_id, profile)
# Clear cookie if the deleted tab was the current tab
current_tab_id = get_current_tab_id(request, session)
if current_tab_id == id:
response_data = json.dumps({"message": "Tab deleted successfully"})
response = response_data, 200, {
'Content-Type': 'application/json',
'Set-Cookie': 'current_tab=; Path=/; Max-Age=0' # Clear cookie
}
return response
return json.dumps({"message": "Tab deleted successfully"}), 200, {'Content-Type': 'application/json'}
return json.dumps({"error": "Tab not found"}), 404
except Exception as e:
import sys
try:
sys.print_exception(e)
except:
pass
return json.dumps({"error": str(e)}), 500, {'Content-Type': 'application/json'}
@controller.post('')
@with_session
async def create_tab(request, session):
"""Create a new tab."""
try:
# Handle form data or JSON
if request.form:
name = request.form.get('name', '').strip()
ids_str = request.form.get('ids', '1').strip()
names = [id.strip() for id in ids_str.split(',') if id.strip()]
preset_ids = None
else:
data = request.json or {}
name = data.get("name", "")
names = data.get("names", None)
preset_ids = data.get("presets", None)
if not name:
return json.dumps({"error": "Tab name cannot be empty"}), 400
tab_id = tabs.create(name, names, preset_ids)
# Add to current profile's tabs
profile_id = get_current_profile_id(session)
if profile_id:
profile = profiles.read(profile_id)
if profile:
# Support both "tabs" (new) and "tab_order" (old) format
tabs_list = profile.get('tabs', profile.get('tab_order', []))
if tab_id not in tabs_list:
tabs_list.append(tab_id)
profile['tabs'] = tabs_list
# Remove old tab_order if it exists
if 'tab_order' in profile:
del profile['tab_order']
profiles.update(profile_id, profile)
# Return JSON response with tab ID
tab_data = tabs.read(tab_id)
return json.dumps({tab_id: tab_data}), 201, {'Content-Type': 'application/json'}
except Exception as e:
import sys
sys.print_exception(e)
return json.dumps({"error": str(e)}), 400
@controller.post('/<id>/clone')
@with_session
async def clone_tab(request, session, id):
"""Clone an existing tab and add it to the current profile."""
try:
source = tabs.read(id)
if not source:
return json.dumps({"error": "Tab not found"}), 404
data = request.json or {}
source_name = source.get("name") or f"Tab {id}"
new_name = data.get("name") or f"{source_name} Copy"
clone_id = tabs.create(new_name, source.get("names"), source.get("presets"))
extra = {k: v for k, v in source.items() if k not in ("name", "names", "presets")}
if extra:
tabs.update(clone_id, extra)
profile_id = get_current_profile_id(session)
if profile_id:
profile = profiles.read(profile_id)
if profile:
tabs_list = profile.get('tabs', profile.get('tab_order', []))
if clone_id not in tabs_list:
tabs_list.append(clone_id)
profile['tabs'] = tabs_list
if 'tab_order' in profile:
del profile['tab_order']
profiles.update(profile_id, profile)
tab_data = tabs.read(clone_id)
return json.dumps({clone_id: tab_data}), 201, {'Content-Type': 'application/json'}
except Exception as e:
import sys
try:
sys.print_exception(e)
except:
pass
return json.dumps({"error": str(e)}), 400

361
src/controllers/zone.py Normal file
View File

@@ -0,0 +1,361 @@
from microdot import Microdot, send_file
from microdot.session import with_session
from models.zone import Zone
from models.profile import Profile
import json
controller = Microdot()
zones = Zone()
profiles = Profile()
def get_current_profile_id(session=None):
"""Get the current active profile ID from session or fallback to first."""
profile_list = profiles.list()
session_profile = None
if session is not None:
session_profile = session.get("current_profile")
if session_profile and session_profile in profile_list:
return session_profile
if profile_list:
return profile_list[0]
return None
def _profile_zone_id_list(profile):
"""Ordered zone ids for a profile (``zones``, legacy ``tabs``, or ``zone_order``)."""
if not profile or not isinstance(profile, dict):
return []
z = profile.get("zones")
if isinstance(z, list) and z:
return list(z)
t = profile.get("zones")
if isinstance(t, list) and t:
return list(t)
o = profile.get("zone_order")
if isinstance(o, list) and o:
return list(o)
return []
def get_profile_zone_order(profile_id):
if not profile_id:
return []
profile = profiles.read(profile_id)
return _profile_zone_id_list(profile)
def _set_profile_zone_order(profile, ids):
profile["zones"] = list(ids)
profile.pop("tabs", None)
profile.pop("zone_order", None)
def get_current_zone_id(request, session=None):
"""Cookie ``current_zone``, legacy ``current_zone``, then first zone in profile."""
z = request.cookies.get("current_zone") or request.cookies.get("current_zone")
if z:
return z
profile_id = get_current_profile_id(session)
if profile_id:
profile = profiles.read(profile_id)
order = _profile_zone_id_list(profile)
if order:
return order[0]
return None
def _render_zones_list_fragment(request, session):
"""Render zone strip HTML for HTMX / JS."""
profile_id = get_current_profile_id(session)
if not profile_id:
return (
'<div class="zones-list">No profile selected</div>',
200,
{"Content-Type": "text/html"},
)
zone_order = get_profile_zone_order(profile_id)
current_zone_id = get_current_zone_id(request, session)
html = '<div class="zones-list">'
for zid in zone_order:
zdata = zones.read(zid)
if zdata:
active_class = "active" if str(zid) == str(current_zone_id) else ""
zname = zdata.get("name", "Zone " + str(zid))
html += (
'<button class="zone-button ' + active_class + '" '
'hx-get="/zones/' + str(zid) + '/content-fragment" '
'hx-target="#zone-content" '
'hx-swap="innerHTML" '
'hx-push-url="true" '
'hx-trigger="click" '
'onclick="document.querySelectorAll(\'.zone-button\').forEach(b => b.classList.remove(\'active\')); this.classList.add(\'active\');">'
+ zname
+ "</button>"
)
html += "</div>"
return html, 200, {"Content-Type": "text/html"}
def _render_zone_content_fragment(request, session, id):
if id == "current":
current_zone_id = get_current_zone_id(request, session)
if not current_zone_id:
accept_header = request.headers.get("Accept", "")
wants_html = "text/html" in accept_header
if wants_html:
return (
'<div class="error">No current zone set</div>',
404,
{"Content-Type": "text/html"},
)
return json.dumps({"error": "No current zone set"}), 404
id = current_zone_id
z = zones.read(id)
if not z:
return '<div>Zone not found</div>', 404, {"Content-Type": "text/html"}
session["current_zone"] = str(id)
session.save()
if not request.headers.get("HX-Request"):
return send_file("templates/index.html")
html = (
'<div class="presets-section" data-zone-id="' + str(id) + '">'
"<h3>Presets</h3>"
'<div class="profiles-actions" style="margin-bottom: 1rem;"></div>'
'<div id="presets-list-zone" class="presets-list">'
"<!-- Presets will be loaded here -->"
"</div>"
"</div>"
)
return html, 200, {"Content-Type": "text/html"}
@controller.get("/<id>/content-fragment")
@with_session
async def zone_content_fragment(request, session, id):
return _render_zone_content_fragment(request, session, id)
@controller.get("")
@with_session
async def list_zones(request, session):
profile_id = get_current_profile_id(session)
current_zone_id = get_current_zone_id(request, session)
zone_order = get_profile_zone_order(profile_id) if profile_id else []
zones_data = {}
for zid in zones.list():
zdata = zones.read(zid)
if zdata:
zones_data[zid] = zdata
return (
json.dumps(
{
"zones": zones_data,
"zone_order": zone_order,
"current_zone_id": current_zone_id,
"profile_id": profile_id,
}
),
200,
{"Content-Type": "application/json"},
)
@controller.get("/current")
@with_session
async def get_current_zone(request, session):
current_zone_id = get_current_zone_id(request, session)
if not current_zone_id:
return (
json.dumps({"error": "No current zone set", "zone": None, "zone_id": None}),
404,
)
z = zones.read(current_zone_id)
if z:
return (
json.dumps({"zone": z, "zone_id": current_zone_id}),
200,
{"Content-Type": "application/json"},
)
return (
json.dumps({"error": "Zone not found", "zone": None, "zone_id": None}),
404,
)
@controller.post("/<id>/set-current")
async def set_current_zone(request, id):
z = zones.read(id)
if not z:
return json.dumps({"error": "Zone not found"}), 404
response_data = json.dumps({"message": "Current zone set", "zone_id": id})
return (
response_data,
200,
{
"Content-Type": "application/json",
"Set-Cookie": (
f"current_zone={id}; Path=/; Max-Age=31536000; SameSite=Lax"
),
},
)
@controller.get("/<id>")
async def get_zone(request, id):
z = zones.read(id)
if z:
return json.dumps(z), 200, {"Content-Type": "application/json"}
return json.dumps({"error": "Zone not found"}), 404
@controller.put("/<id>")
async def update_zone(request, id):
try:
data = request.json
if zones.update(id, data):
return json.dumps(zones.read(id)), 200, {"Content-Type": "application/json"}
return json.dumps({"error": "Zone not found"}), 404
except Exception as e:
return json.dumps({"error": str(e)}), 400
@controller.delete("/<id>")
@with_session
async def delete_zone(request, session, id):
try:
if id == "current":
current_zone_id = get_current_zone_id(request, session)
if current_zone_id:
id = current_zone_id
else:
return json.dumps({"error": "No current zone to delete"}), 404
if zones.delete(id):
profile_id = get_current_profile_id(session)
if profile_id:
profile = profiles.read(profile_id)
if profile:
zlist = _profile_zone_id_list(profile)
if id in zlist:
zlist.remove(id)
_set_profile_zone_order(profile, zlist)
profiles.update(profile_id, profile)
current_zone_id = get_current_zone_id(request, session)
if current_zone_id == id:
response_data = json.dumps({"message": "Zone deleted successfully"})
return (
response_data,
200,
{
"Content-Type": "application/json",
"Set-Cookie": (
"current_zone=; Path=/; Max-Age=0; SameSite=Lax"
),
},
)
return json.dumps({"message": "Zone deleted successfully"}), 200, {
"Content-Type": "application/json"
}
return json.dumps({"error": "Zone not found"}), 404
except Exception as e:
import sys
try:
sys.print_exception(e)
except Exception:
pass
return json.dumps({"error": str(e)}), 500, {"Content-Type": "application/json"}
@controller.post("")
@with_session
async def create_zone(request, session):
try:
if request.form:
name = request.form.get("name", "").strip()
ids_str = request.form.get("ids", "1").strip()
names = [i.strip() for i in ids_str.split(",") if i.strip()]
preset_ids = None
else:
data = request.json or {}
name = data.get("name", "")
names = data.get("names")
if names is None:
names = data.get("ids")
preset_ids = data.get("presets", None)
if not name:
return json.dumps({"error": "Zone name cannot be empty"}), 400
zid = zones.create(name, names, preset_ids)
profile_id = get_current_profile_id(session)
if profile_id:
profile = profiles.read(profile_id)
if profile:
zlist = _profile_zone_id_list(profile)
if zid not in zlist:
zlist.append(zid)
_set_profile_zone_order(profile, zlist)
profiles.update(profile_id, profile)
zdata = zones.read(zid)
return json.dumps({zid: zdata}), 201, {"Content-Type": "application/json"}
except Exception as e:
import sys
sys.print_exception(e)
return json.dumps({"error": str(e)}), 400
@controller.post("/<id>/clone")
@with_session
async def clone_zone(request, session, id):
try:
source = zones.read(id)
if not source:
return json.dumps({"error": "Zone not found"}), 404
data = request.json or {}
source_name = source.get("name") or f"Zone {id}"
new_name = data.get("name") or f"{source_name} Copy"
clone_id = zones.create(new_name, source.get("names"), source.get("presets"))
extra = {k: v for k, v in source.items() if k not in ("name", "names", "presets")}
if extra:
zones.update(clone_id, extra)
profile_id = get_current_profile_id(session)
if profile_id:
profile = profiles.read(profile_id)
if profile:
zlist = _profile_zone_id_list(profile)
if clone_id not in zlist:
zlist.append(clone_id)
_set_profile_zone_order(profile, zlist)
profiles.update(profile_id, profile)
zdata = zones.read(clone_id)
return json.dumps({clone_id: zdata}), 201, {"Content-Type": "application/json"}
except Exception as e:
import sys
try:
sys.print_exception(e)
except Exception:
pass
return json.dumps({"error": str(e)}), 400

View File

@@ -14,7 +14,7 @@ import controllers.preset as preset
import controllers.profile as profile
import controllers.group as group
import controllers.sequence as sequence
import controllers.tab as tab
import controllers.zone as zone
import controllers.palette as palette
import controllers.scene as scene
import controllers.pattern as pattern
@@ -262,7 +262,7 @@ async def main(port=80):
('/profiles', profile, 'profile'),
('/groups', group, 'group'),
('/sequences', sequence, 'sequence'),
('/tabs', tab, 'tab'),
('/zones', zone, 'zone'),
('/palettes', palette, 'palette'),
('/scenes', scene, 'scene'),
]
@@ -272,7 +272,7 @@ async def main(port=80):
app.mount(profile.controller, '/profiles')
app.mount(group.controller, '/groups')
app.mount(sequence.controller, '/sequences')
app.mount(tab.controller, '/tabs')
app.mount(zone.controller, '/zones')
app.mount(palette.controller, '/palettes')
app.mount(scene.controller, '/scenes')
app.mount(pattern.controller, '/patterns')

View File

@@ -2,7 +2,7 @@
LED driver registry persisted in ``db/device.json``.
Storage key and **id** field are the device **MAC**: 12 lowercase hex characters
(no colons). **name** is for ``select`` / tabs (not unique). **address** is the
(no colons). **name** is for ``select`` / zones (not unique). **address** is the
reachability hint: same as MAC for ESP-NOW, or IP/hostname for Wi-Fi.
"""
@@ -160,7 +160,7 @@ class Device(Model):
address=None,
mac=None,
default_pattern=None,
tabs=None,
zones=None,
device_type="led",
transport="espnow",
):
@@ -183,7 +183,7 @@ class Device(Model):
"transport": tr,
"address": addr,
"default_pattern": default_pattern if default_pattern else None,
"tabs": list(tabs) if tabs else [],
"zones": list(zones) if zones else [],
}
self.save()
return mac_hex
@@ -273,7 +273,7 @@ class Device(Model):
"transport": "wifi",
"address": ip,
"default_pattern": None,
"tabs": [],
"zones": [],
}
self.save()
return mac_hex

View File

@@ -26,18 +26,18 @@ class Profile(Model):
if changed:
self.save()
def create(self, name="", profile_type="tabs"):
def create(self, name="", profile_type="zones"):
"""Create a new profile and its own empty palette.
profile_type: "tabs" or "scenes" (ignoring scenes for now)
profile_type: "zones" or "scenes" (ignoring scenes for now)
"""
next_id = self.get_next_id()
# Create a unique palette for this profile.
palette_id = self._palette_model.create(colors=[])
self[next_id] = {
"name": name,
"type": profile_type, # "tabs" or "scenes"
"tabs": [], # Array of tab IDs
"type": profile_type, # "zones" or "scenes"
"zones": [], # Array of zone IDs
"scenes": [], # Array of scene IDs (for future use)
"palette_id": str(palette_id),
}

View File

@@ -1,39 +0,0 @@
from models.model import Model
class Tab(Model):
def __init__(self):
super().__init__()
def create(self, name="", names=None, presets=None):
next_id = self.get_next_id()
self[next_id] = {
"name": name,
"names": names if names else [],
"presets": presets if presets else [],
"default_preset": None
}
self.save()
return next_id
def read(self, id):
id_str = str(id)
return self.get(id_str, None)
def update(self, id, data):
id_str = str(id)
if id_str not in self:
return False
self[id_str].update(data)
self.save()
return True
def delete(self, id):
id_str = str(id)
if id_str not in self:
return False
self.pop(id_str)
self.save()
return True
def list(self):
return list(self.keys())

62
src/models/zone.py Normal file
View File

@@ -0,0 +1,62 @@
import os
import shutil
from models.model import Model
def _maybe_migrate_tab_json_to_zone():
"""One-time copy ``db/tab.json`` → ``db/zone.json`` when upgrading."""
try:
base = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
db_dir = os.path.join(base, "db")
zone_path = os.path.join(db_dir, "zone.json")
tab_path = os.path.join(db_dir, "tab.json")
if not os.path.exists(zone_path) and os.path.exists(tab_path):
shutil.copy2(tab_path, zone_path)
print("Migrated db/tab.json -> db/zone.json")
except OSError:
pass
class Zone(Model):
"""Preset layout row (stored in ``db/zone.json``); legacy storage was ``tab.json`` / Tab."""
def __init__(self):
if not getattr(Zone, "_migration_checked", False):
_maybe_migrate_tab_json_to_zone()
Zone._migration_checked = True
super().__init__()
def create(self, name="", names=None, presets=None):
next_id = self.get_next_id()
self[next_id] = {
"name": name,
"names": names if names else [],
"presets": presets if presets else [],
"default_preset": None,
}
self.save()
return next_id
def read(self, id):
id_str = str(id)
return self.get(id_str, None)
def update(self, id, data):
id_str = str(id)
if id_str not in self:
return False
self[id_str].update(data)
self.save()
return True
def delete(self, id):
id_str = str(id)
if id_str not in self:
return False
self.pop(id_str)
self.save()
return True
def list(self):
return list(self.keys())

View File

@@ -5,7 +5,7 @@ class LightingController {
this.state = {
lights: {},
patterns: {},
tab_order: [],
zone_order: [],
presets: {}
};
this.selectedColorIndex = 0;
@@ -19,8 +19,8 @@ class LightingController {
await this.loadState();
this.setupEventListeners();
this.renderTabs();
if (this.state.tab_order.length > 0) {
this.selectTab(this.state.tab_order[0]);
if (this.state.zone_order.length > 0) {
this.selectTab(this.state.zone_order[0]);
}
}
@@ -62,19 +62,19 @@ class LightingController {
}
setupEventListeners() {
// Tab management
document.getElementById('add-tab-btn').addEventListener('click', () => this.showAddTabModal());
document.getElementById('edit-tab-btn').addEventListener('click', () => this.showEditTabModal());
document.getElementById('delete-tab-btn').addEventListener('click', () => this.deleteCurrentTab());
// Zone management
document.getElementById('add-zone-btn').addEventListener('click', () => this.showAddTabModal());
document.getElementById('edit-zone-btn').addEventListener('click', () => this.showEditTabModal());
document.getElementById('delete-zone-btn').addEventListener('click', () => this.deleteCurrentTab());
document.getElementById('color-palette-btn').addEventListener('click', () => this.showColorPalette());
document.getElementById('presets-btn').addEventListener('click', () => this.showPresets());
document.getElementById('profiles-btn').addEventListener('click', () => this.showProfiles());
// Modal actions
document.getElementById('add-tab-confirm').addEventListener('click', () => this.createTab());
document.getElementById('add-tab-cancel').addEventListener('click', () => this.hideModal('add-tab-modal'));
document.getElementById('edit-tab-confirm').addEventListener('click', () => this.updateTab());
document.getElementById('edit-tab-cancel').addEventListener('click', () => this.hideModal('edit-tab-modal'));
document.getElementById('add-zone-confirm').addEventListener('click', () => this.createTab());
document.getElementById('add-zone-cancel').addEventListener('click', () => this.hideModal('add-zone-modal'));
document.getElementById('edit-zone-confirm').addEventListener('click', () => this.updateTab());
document.getElementById('edit-zone-cancel').addEventListener('click', () => this.hideModal('edit-zone-modal'));
document.getElementById('profiles-close-btn').addEventListener('click', () => this.hideModal('profiles-modal'));
document.getElementById('color-palette-close-btn').addEventListener('click', () => this.hideModal('color-palette-modal'));
document.getElementById('presets-close-btn').addEventListener('click', () => this.hideModal('presets-modal'));
@@ -125,12 +125,12 @@ class LightingController {
}
renderTabs() {
const tabsList = document.getElementById('tabs-list');
const tabsList = document.getElementById('zones-list');
tabsList.innerHTML = '';
this.state.tab_order.forEach(tabName => {
this.state.zone_order.forEach(tabName => {
const tabButton = document.createElement('button');
tabButton.className = 'tab-button';
tabButton.className = 'zone-button';
tabButton.textContent = tabName;
tabButton.addEventListener('click', () => this.selectTab(tabName));
if (tabName === this.currentTab) {
@@ -217,13 +217,13 @@ class LightingController {
}
renderPresets(tabName) {
const presetsList = document.getElementById('presets-list-tab');
const presetsList = document.getElementById('presets-list-zone');
presetsList.innerHTML = '';
const presets = this.state.presets || {};
const presetNames = Object.keys(presets);
// Get current tab's settings for comparison
// Get current zone's settings for comparison
const currentSettings = this.getCurrentTabSettings(tabName);
// Always include "on" and "off" presets
@@ -267,7 +267,7 @@ class LightingController {
const presetButton = document.createElement('button');
presetButton.className = 'pattern-button';
// Check if this preset matches the current tab's settings
// Check if this preset matches the current zone's settings
const isActive = this.presetMatchesSettings(preset, currentSettings);
if (isActive) {
presetButton.classList.add('active');
@@ -344,7 +344,7 @@ class LightingController {
})
});
// Reload state and tab content
// Reload state and zone content
await this.loadState();
await this.loadTabContent(tabName);
} else {
@@ -591,7 +591,7 @@ class LightingController {
}
// Reload state from server to ensure consistency
await this.loadState();
// Reload tab content to update UI
// Reload zone content to update UI
await this.loadTabContent(tabName);
} else {
const errorText = await response.text();
@@ -769,23 +769,23 @@ class LightingController {
}
showAddTabModal() {
document.getElementById('new-tab-name').value = '';
document.getElementById('new-tab-ids').value = '1';
document.getElementById('add-tab-modal').classList.add('active');
document.getElementById('new-zone-name').value = '';
document.getElementById('new-zone-ids').value = '1';
document.getElementById('add-zone-modal').classList.add('active');
}
async createTab() {
const name = document.getElementById('new-tab-name').value.trim();
const idsStr = document.getElementById('new-tab-ids').value.trim();
const name = document.getElementById('new-zone-name').value.trim();
const idsStr = document.getElementById('new-zone-ids').value.trim();
const ids = idsStr.split(',').map(id => id.trim()).filter(id => id);
if (!name) {
alert('Tab name cannot be empty');
alert('Zone name cannot be empty');
return;
}
try {
const response = await fetch('/tabs', {
const response = await fetch('/zones', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, ids })
@@ -795,41 +795,41 @@ class LightingController {
await this.loadState();
this.renderTabs();
this.selectTab(name);
this.hideModal('add-tab-modal');
this.hideModal('add-zone-modal');
} else {
const error = await response.json();
alert(error.error || 'Failed to create tab');
alert(error.error || 'Failed to create zone');
}
} catch (error) {
console.error('Failed to create tab:', error);
alert('Failed to create tab');
console.error('Failed to create zone:', error);
alert('Failed to create zone');
}
}
showEditTabModal() {
if (!this.currentTab) {
alert('Please select a tab first');
alert('Please select a zone first');
return;
}
const light = this.state.lights[this.currentTab];
document.getElementById('edit-tab-name').value = this.currentTab;
document.getElementById('edit-tab-ids').value = light.names.join(', ');
document.getElementById('edit-tab-modal').classList.add('active');
document.getElementById('edit-zone-name').value = this.currentTab;
document.getElementById('edit-zone-ids').value = light.names.join(', ');
document.getElementById('edit-zone-modal').classList.add('active');
}
async updateTab() {
const newName = document.getElementById('edit-tab-name').value.trim();
const idsStr = document.getElementById('edit-tab-ids').value.trim();
const newName = document.getElementById('edit-zone-name').value.trim();
const idsStr = document.getElementById('edit-zone-ids').value.trim();
const ids = idsStr.split(',').map(id => id.trim()).filter(id => id);
if (!newName) {
alert('Tab name cannot be empty');
alert('Zone name cannot be empty');
return;
}
try {
const response = await fetch(`/tabs/${this.currentTab}`, {
const response = await fetch(`/zones/${this.currentTab}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: newName, ids })
@@ -839,45 +839,45 @@ class LightingController {
await this.loadState();
this.renderTabs();
this.selectTab(newName);
this.hideModal('edit-tab-modal');
this.hideModal('edit-zone-modal');
} else {
const error = await response.json();
alert(error.error || 'Failed to update tab');
alert(error.error || 'Failed to update zone');
}
} catch (error) {
console.error('Failed to update tab:', error);
alert('Failed to update tab');
console.error('Failed to update zone:', error);
alert('Failed to update zone');
}
}
async deleteCurrentTab() {
if (!this.currentTab) {
alert('Please select a tab first');
alert('Please select a zone first');
return;
}
if (!confirm(`Are you sure you want to delete the tab '${this.currentTab}'?`)) {
if (!confirm(`Are you sure you want to delete the zone '${this.currentTab}'?`)) {
return;
}
try {
const response = await fetch(`/tabs/${this.currentTab}`, {
const response = await fetch(`/zones/${this.currentTab}`, {
method: 'DELETE'
});
if (response.ok) {
await this.loadState();
this.renderTabs();
if (this.state.tab_order.length > 0) {
this.selectTab(this.state.tab_order[0]);
if (this.state.zone_order.length > 0) {
this.selectTab(this.state.zone_order[0]);
} else {
this.currentTab = null;
document.getElementById('tab-content').innerHTML = '<p>No tabs available. Create a new tab to get started.</p>';
document.getElementById('zone-content').innerHTML = '<p>No tabs available. Create a new zone to get started.</p>';
}
}
} catch (error) {
console.error('Failed to delete tab:', error);
alert('Failed to delete tab');
console.error('Failed to delete zone:', error);
alert('Failed to delete zone');
}
}
@@ -1008,9 +1008,9 @@ class LightingController {
if (this.state.current_profile === profileName) {
this.state.current_profile = '';
this.state.lights = {};
this.state.tab_order = [];
this.state.zone_order = [];
this.renderTabs();
document.getElementById('tab-content').innerHTML = '<p>No tabs available. Create a new tab to get started.</p>';
document.getElementById('zone-content').innerHTML = '<p>No tabs available. Create a new zone to get started.</p>';
this.updateCurrentProfileDisplay();
}
} else {
@@ -1032,8 +1032,8 @@ class LightingController {
if (response.ok) {
await this.loadState();
this.renderTabs();
if (this.state.tab_order.length > 0) {
this.selectTab(this.state.tab_order[0]);
if (this.state.zone_order.length > 0) {
this.selectTab(this.state.zone_order[0]);
} else {
this.currentTab = null;
}
@@ -1129,7 +1129,7 @@ class LightingController {
swatch.style.cssText = 'width: 40px; height: 40px; background-color: ' + color + '; border: 2px solid #4a4a4a; border-radius: 4px; cursor: pointer; position: relative;';
swatch.title = `Click to apply ${color} to selected color`;
// Click to apply color to currently selected color in active tab
// Click to apply color to currently selected color in active zone
swatch.addEventListener('click', (e) => {
// Only apply if not clicking the remove button
if (e.target === swatch || !e.target.closest('button')) {
@@ -1151,7 +1151,7 @@ class LightingController {
applyPaletteColorToSelected(paletteColor) {
if (!this.currentTab) {
alert('No tab selected. Please select a tab first.');
alert('No zone selected. Please select a zone first.');
return;
}
@@ -1439,7 +1439,7 @@ class LightingController {
async applyPreset(presetName) {
if (!this.currentTab) {
alert('Please select a tab first');
alert('Please select a zone first');
return;
}
@@ -1621,7 +1621,7 @@ class LightingController {
loadCurrentTabToPresetEditor() {
if (!this.currentTab || !this.state.lights[this.currentTab]) {
alert('Please select a tab first');
alert('Please select a zone first');
return;
}

View File

@@ -19,34 +19,34 @@ const numTabs = 3;
// Select the container for tabs and content
const tabsContainer = document.querySelector(".tabs");
const tabContentContainer = document.querySelector(".tab-content");
const tabContentContainer = document.querySelector(".zone-content");
// Create tabs dynamically
for (let i = 1; i <= numTabs; i++) {
// Create the tab button
// Create the zone button
const tabButton = document.createElement("button");
tabButton.classList.add("tab");
tabButton.id = `tab${i}`;
tabButton.textContent = `Tab ${i}`;
tabButton.classList.add("zone");
tabButton.id = `zone${i}`;
tabButton.textContent = `Zone ${i}`;
// Add the tab button to the container
// Add the zone button to the container
tabsContainer.appendChild(tabButton);
// Create the corresponding tab content (RGB slider)
// Create the corresponding zone content (RGB slider)
const tabContent = document.createElement("div");
tabContent.classList.add("tab-pane");
tabContent.classList.add("zone-pane");
tabContent.id = `content${i}`;
const slider = document.createElement("rgb-slider");
slider.id = i;
tabContent.appendChild(slider);
// Add the tab content to the container
// Add the zone content to the container
tabContentContainer.appendChild(tabContent);
// Listen for color change on each RGB slider
slider.addEventListener("color-change", (e) => {
const { r, g, b } = e.detail;
console.log(`Color changed in tab ${i}:`, e.detail);
console.log(`Color changed in zone ${i}:`, e.detail);
// Send RGB data to WebSocket server
if (ws.readyState === WebSocket.OPEN) {
const colorData = { r, g, b };
@@ -56,26 +56,26 @@ for (let i = 1; i <= numTabs; i++) {
}
// Function to switch tabs
function switchTab(tabId) {
const tabs = document.querySelectorAll(".tab");
const tabContents = document.querySelectorAll(".tab-pane");
function switchTab(zoneId) {
const tabs = document.querySelectorAll(".zone");
const tabContents = document.querySelectorAll(".zone-pane");
tabs.forEach((tab) => tab.classList.remove("active"));
zones.forEach((zone) => zone.classList.remove("active"));
tabContents.forEach((content) => content.classList.remove("active"));
// Activate the clicked tab and corresponding content
document.getElementById(tabId).classList.add("active");
// Activate the clicked zone and corresponding content
document.getElementById(zoneId).classList.add("active");
document
.getElementById("content" + tabId.replace("tab", ""))
.getElementById("content" + zoneId.replace("zone", ""))
.classList.add("active");
}
// Add event listeners to tabs
tabsContainer.addEventListener("click", (e) => {
if (e.target.classList.contains("tab")) {
if (e.target.classList.contains("zone")) {
switchTab(e.target.id);
}
});
// Initially set the first tab as active
// Initially set the first zone as active
switchTab("tab1");

View File

@@ -175,9 +175,9 @@ async function postDriverSequence(sequence, targetMacs, delayS) {
return res.json().catch(() => ({}));
}
// Send a select message for a preset to all devices on the current tab (ESP-NOW or Wi-Fi).
// Send a select message for a preset to all devices on the current zone (ESP-NOW or Wi-Fi).
const sendSelectForCurrentTabDevices = async (presetId, sectionEl) => {
const section = sectionEl || document.querySelector('.presets-section[data-tab-id]');
const section = sectionEl || document.querySelector('.presets-section[data-zone-id]');
if (!section || !presetId) {
return;
}
@@ -223,7 +223,7 @@ document.addEventListener('DOMContentLoaded', () => {
const presetBrightnessInput = document.getElementById('preset-brightness-input');
const presetDelayInput = document.getElementById('preset-delay-input');
const presetDefaultButton = document.getElementById('preset-default-btn');
const presetRemoveFromTabButton = document.getElementById('preset-remove-from-tab-btn');
const presetRemoveFromTabButton = document.getElementById('preset-remove-from-zone-btn');
const presetSaveButton = document.getElementById('preset-save-btn');
const presetAddFromPaletteButton = document.getElementById('preset-add-from-palette-btn');
@@ -623,8 +623,8 @@ document.addEventListener('DOMContentLoaded', () => {
if (currentEditTabId) {
return currentEditTabId;
}
const section = document.querySelector('.presets-section[data-tab-id]');
return section ? section.dataset.tabId : null;
const section = document.querySelector('.presets-section[data-zone-id]');
return section ? section.dataset.zoneId : null;
};
const updatePresetEditorTabActionsVisibility = () => {
@@ -634,12 +634,12 @@ document.addEventListener('DOMContentLoaded', () => {
};
const updateTabDefaultPreset = async (presetId) => {
const tabId = getActiveTabId();
if (!tabId) {
const zoneId = getActiveTabId();
if (!zoneId) {
return;
}
try {
const tabResponse = await fetch(`/tabs/${tabId}`, {
const tabResponse = await fetch(`/zones/${zoneId}`, {
headers: { Accept: 'application/json' },
});
if (!tabResponse.ok) {
@@ -647,13 +647,13 @@ document.addEventListener('DOMContentLoaded', () => {
}
const tabData = await tabResponse.json();
tabData.default_preset = presetId;
await fetch(`/tabs/${tabId}`, {
await fetch(`/zones/${zoneId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(tabData),
});
} catch (error) {
console.warn('Failed to save tab default preset:', error);
console.warn('Failed to save zone default preset:', error);
}
};
@@ -950,22 +950,22 @@ document.addEventListener('DOMContentLoaded', () => {
}
const showAddPresetToTabModal = async (optionalTabId) => {
let tabId = optionalTabId;
if (!tabId) {
// Get current tab ID from the presets section
const leftPanel = document.querySelector('.presets-section[data-tab-id]');
tabId = leftPanel ? leftPanel.dataset.tabId : null;
let zoneId = optionalTabId;
if (!zoneId) {
// Get current zone ID from the presets section
const leftPanel = document.querySelector('.presets-section[data-zone-id]');
zoneId = leftPanel ? leftPanel.dataset.zoneId : null;
}
if (!tabId) {
if (!zoneId) {
// Fallback: try to get from URL
const pathParts = window.location.pathname.split('/');
const tabIndex = pathParts.indexOf('tabs');
const tabIndex = pathParts.indexOf('zones');
if (tabIndex !== -1 && tabIndex + 1 < pathParts.length) {
tabId = pathParts[tabIndex + 1];
zoneId = pathParts[tabIndex + 1];
}
}
if (!tabId) {
alert('Could not determine current tab.');
if (!zoneId) {
alert('Could not determine current zone.');
return;
}
@@ -980,10 +980,10 @@ document.addEventListener('DOMContentLoaded', () => {
const allPresetsRaw = await response.json();
const allPresets = await filterPresetsForCurrentProfile(allPresetsRaw);
// Load only the current tab's presets so we can avoid duplicates within this tab.
// Load only the current zone's presets so we can avoid duplicates within this zone.
let currentTabPresets = [];
try {
const tabResponse = await fetch(`/tabs/${tabId}`, {
const tabResponse = await fetch(`/zones/${zoneId}`, {
headers: { Accept: 'application/json' },
});
if (tabResponse.ok) {
@@ -999,19 +999,19 @@ document.addEventListener('DOMContentLoaded', () => {
}
}
} catch (e) {
console.warn('Could not load current tab presets:', e);
console.warn('Could not load current zone presets:', e);
}
// Create modal
const modal = document.createElement('div');
modal.className = 'modal active';
modal.id = 'add-preset-to-tab-modal';
modal.id = 'add-preset-to-zone-modal';
modal.innerHTML = `
<div class="modal-content">
<h2>Add Preset to Tab</h2>
<h2>Add Preset to Zone</h2>
<div id="add-preset-list" class="profiles-list" style="max-height: 400px; overflow-y: auto;"></div>
<div class="modal-actions">
<button class="btn btn-secondary" id="add-preset-to-tab-close-btn">Close</button>
<button class="btn btn-secondary" id="add-preset-to-zone-close-btn">Close</button>
</div>
</div>
`;
@@ -1023,7 +1023,7 @@ document.addEventListener('DOMContentLoaded', () => {
const availableToAdd = presetNames.filter(presetId => !currentTabPresets.includes(presetId));
if (availableToAdd.length === 0) {
listContainer.innerHTML = '<p class="muted-text">No presets to add. All presets are already in this tab, or create a preset first.</p>';
listContainer.innerHTML = '<p class="muted-text">No presets to add. All presets are already in this zone, or create a preset first.</p>';
} else {
availableToAdd.forEach(presetId => {
const preset = allPresets[presetId];
@@ -1042,7 +1042,7 @@ document.addEventListener('DOMContentLoaded', () => {
addButton.className = 'btn btn-primary btn-small';
addButton.textContent = 'Add';
addButton.addEventListener('click', async () => {
await addPresetToTab(presetId, tabId);
await addPresetToTab(presetId, zoneId);
modal.remove();
});
@@ -1054,7 +1054,7 @@ document.addEventListener('DOMContentLoaded', () => {
}
// Close button handler
document.getElementById('add-preset-to-tab-close-btn').addEventListener('click', () => {
document.getElementById('add-preset-to-zone-close-btn').addEventListener('click', () => {
modal.remove();
});
@@ -1067,34 +1067,34 @@ document.addEventListener('DOMContentLoaded', () => {
window.showAddPresetToTabModal = showAddPresetToTabModal;
} catch (e) {}
const addPresetToTab = async (presetId, tabId) => {
if (!tabId) {
// Try to get tab ID from the left-panel
const leftPanel = document.querySelector('.presets-section[data-tab-id]');
tabId = leftPanel ? leftPanel.dataset.tabId : null;
const addPresetToTab = async (presetId, zoneId) => {
if (!zoneId) {
// Try to get zone ID from the left-panel
const leftPanel = document.querySelector('.presets-section[data-zone-id]');
zoneId = leftPanel ? leftPanel.dataset.zoneId : null;
if (!tabId) {
if (!zoneId) {
// Fallback: try to get from URL
const pathParts = window.location.pathname.split('/');
const tabIndex = pathParts.indexOf('tabs');
const tabIndex = pathParts.indexOf('zones');
if (tabIndex !== -1 && tabIndex + 1 < pathParts.length) {
tabId = pathParts[tabIndex + 1];
zoneId = pathParts[tabIndex + 1];
}
}
}
if (!tabId) {
alert('Could not determine current tab.');
if (!zoneId) {
alert('Could not determine current zone.');
return;
}
try {
// Get current tab data
const tabResponse = await fetch(`/tabs/${tabId}`, {
// Get current zone data
const tabResponse = await fetch(`/zones/${zoneId}`, {
headers: { Accept: 'application/json' },
});
if (!tabResponse.ok) {
throw new Error('Failed to load tab');
throw new Error('Failed to load zone');
}
const tabData = await tabResponse.json();
@@ -1111,7 +1111,7 @@ document.addEventListener('DOMContentLoaded', () => {
}
if (flat.includes(presetId)) {
alert('Preset is already added to this tab.');
alert('Preset is already added to this zone.');
return;
}
@@ -1120,23 +1120,23 @@ document.addEventListener('DOMContentLoaded', () => {
tabData.presets = newGrid;
tabData.presets_flat = flat;
// Update tab
const updateResponse = await fetch(`/tabs/${tabId}`, {
// Update zone
const updateResponse = await fetch(`/zones/${zoneId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(tabData),
});
if (!updateResponse.ok) {
throw new Error('Failed to update tab');
throw new Error('Failed to update zone');
}
// Reload the tab content to show the new preset
// Reload the zone content to show the new preset
if (typeof renderTabPresets === 'function') {
await renderTabPresets(tabId);
await renderTabPresets(zoneId);
} else if (window.htmx) {
htmx.ajax('GET', `/tabs/${tabId}/content-fragment`, {
target: '#tab-content',
htmx.ajax('GET', `/zones/${zoneId}/content-fragment`, {
target: '#zone-content',
swap: 'innerHTML'
});
} else {
@@ -1144,8 +1144,8 @@ document.addEventListener('DOMContentLoaded', () => {
window.location.reload();
}
} catch (error) {
console.error('Failed to add preset to tab:', error);
alert('Failed to add preset to tab.');
console.error('Failed to add preset to zone:', error);
alert('Failed to add preset to zone.');
}
};
try {
@@ -1269,8 +1269,8 @@ document.addEventListener('DOMContentLoaded', () => {
alert('Preset name is required to send.');
return;
}
// Send current editor values and then select on all devices in the current tab (if any)
const section = document.querySelector('.presets-section[data-tab-id]');
// Send current editor values and then select on all devices in the current zone (if any)
const section = document.querySelector('.presets-section[data-zone-id]');
const deviceNames = tabDeviceNamesFromSection(section);
// Work out the preset ID: for existing presets use currentEditId, otherwise fall back to name
const presetId = currentEditId || payload.name;
@@ -1286,7 +1286,7 @@ document.addEventListener('DOMContentLoaded', () => {
alert('Preset name is required.');
return;
}
const section = document.querySelector('.presets-section[data-tab-id]');
const section = document.querySelector('.presets-section[data-zone-id]');
const deviceNames = tabDeviceNamesFromSection(section);
const presetId = currentEditId || payload.name;
await updateTabDefaultPreset(presetId);
@@ -1297,7 +1297,7 @@ document.addEventListener('DOMContentLoaded', () => {
if (presetRemoveFromTabButton) {
presetRemoveFromTabButton.addEventListener('click', async () => {
if (!currentEditTabId || !currentEditId) return;
if (!window.confirm('Remove this preset from this tab?')) return;
if (!window.confirm('Remove this preset from this zone?')) return;
await removePresetFromTab(currentEditTabId, currentEditId);
clearForm();
closeEditor();
@@ -1348,12 +1348,12 @@ document.addEventListener('DOMContentLoaded', () => {
clearForm();
closeEditor();
// Reload tab presets if we're in a tab view
const leftPanel = document.querySelector('.presets-section[data-tab-id]');
// Reload zone presets if we're in a zone view
const leftPanel = document.querySelector('.presets-section[data-zone-id]');
if (leftPanel) {
const tabId = leftPanel.dataset.tabId;
if (tabId && typeof renderTabPresets !== 'undefined') {
renderTabPresets(tabId);
const zoneId = leftPanel.dataset.zoneId;
if (zoneId && typeof renderTabPresets !== 'undefined') {
renderTabPresets(zoneId);
}
}
} catch (error) {
@@ -1362,11 +1362,11 @@ document.addEventListener('DOMContentLoaded', () => {
}
});
// Listen for edit preset events from tab preset buttons
// Listen for edit preset events from zone preset buttons
document.addEventListener('editPreset', async (event) => {
const { presetId, preset, tabId } = event.detail;
const { presetId, preset, zoneId } = event.detail;
currentEditId = presetId;
currentEditTabId = tabId || null;
currentEditTabId = zoneId || null;
await loadPatterns();
const paletteColors = await getCurrentProfilePaletteColors();
setFormValues({
@@ -1478,11 +1478,11 @@ const sendDefaultPreset = async (presetId, deviceNames) => {
}
};
// Expose for other scripts (tabs.js) so they can reuse the shared WebSocket.
// Expose for other scripts (zones.js) so they can reuse the shared WebSocket.
try {
window.sendPresetViaEspNow = sendPresetViaEspNow;
window.postDriverSequence = postDriverSequence;
// Expose a generic ESPNow sender so other scripts (tabs.js) can send
// Expose a generic ESPNow sender so other scripts (zones.js) can send
// non-preset messages such as global brightness.
window.sendEspnowRaw = sendEspnowMessage;
window.getEspnowSocket = getEspnowSocket;
@@ -1490,9 +1490,9 @@ try {
// window may not exist in some environments; ignore.
}
// Store selected preset per tab
// Store selected preset per zone
const selectedPresets = {};
// Run vs Edit for tab preset strip (in-memory only — each full page load starts in run mode)
// Run vs Edit for zone preset strip (in-memory only — each full page load starts in run mode)
let presetUiMode = 'run';
const getPresetUiMode = () => (presetUiMode === 'edit' ? 'edit' : 'run');
@@ -1559,15 +1559,15 @@ const arrayToGrid = (presetIds, columns = 3) => {
return grid;
};
// Function to save preset grid for a tab
const savePresetGrid = async (tabId, presetGrid) => {
// Function to save preset grid for a zone
const savePresetGrid = async (zoneId, presetGrid) => {
try {
// Get current tab data
const tabResponse = await fetch(`/tabs/${tabId}`, {
// Get current zone data
const tabResponse = await fetch(`/zones/${zoneId}`, {
headers: { Accept: 'application/json' },
});
if (!tabResponse.ok) {
throw new Error('Failed to load tab');
throw new Error('Failed to load zone');
}
const tabData = await tabResponse.json();
@@ -1576,8 +1576,8 @@ const savePresetGrid = async (tabId, presetGrid) => {
// Also store as flat array for backward compatibility
tabData.presets_flat = presetGrid.flat();
// Save updated tab
const updateResponse = await fetch(`/tabs/${tabId}`, {
// Save updated zone
const updateResponse = await fetch(`/zones/${zoneId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(tabData),
@@ -1631,18 +1631,18 @@ const insertDraggingOntoTarget = (presetsList, dragging, dropTarget) => {
}
};
// Function to render presets for a specific tab in 2D grid
const renderTabPresets = async (tabId) => {
const presetsList = document.getElementById('presets-list-tab');
// Function to render presets for a specific zone in 2D grid
const renderTabPresets = async (zoneId) => {
const presetsList = document.getElementById('presets-list-zone');
if (!presetsList) return;
try {
// Get tab data to see which presets are associated
const tabResponse = await fetch(`/tabs/${tabId}`, {
// Get zone data to see which presets are associated
const tabResponse = await fetch(`/zones/${zoneId}`, {
headers: { Accept: 'application/json' },
});
if (!tabResponse.ok) {
throw new Error('Failed to load tab');
throw new Error('Failed to load zone');
}
const tabData = await tabResponse.json();
@@ -1669,7 +1669,7 @@ const renderTabPresets = async (tabId) => {
const paletteColors = await getCurrentProfilePaletteColors();
presetsList.innerHTML = '';
presetsList.dataset.reorderTabId = tabId;
presetsList.dataset.reorderTabId = zoneId;
// Drag-and-drop on the list (wire once — re-render would duplicate listeners otherwise)
if (!presetsList.dataset.dragWired) {
@@ -1719,7 +1719,7 @@ const renderTabPresets = async (tabId) => {
try {
if (!saveId) {
console.warn('No tab id for preset reorder save');
console.warn('No zone id for preset reorder save');
return;
}
await savePresetGrid(saveId, newGrid);
@@ -1733,19 +1733,19 @@ const renderTabPresets = async (tabId) => {
});
}
// Get the currently selected preset for this tab
const selectedPresetId = selectedPresets[tabId];
// Get the currently selected preset for this zone
const selectedPresetId = selectedPresets[zoneId];
// Render presets in grid layout
// Flatten the grid and render all presets (grid CSS will handle layout)
const flatPresets = presetGrid.flat().filter(id => id);
if (flatPresets.length === 0) {
// Show empty message if this tab has no presets
// Show empty message if this zone has no presets
const empty = document.createElement('p');
empty.className = 'muted-text';
empty.style.gridColumn = '1 / -1'; // Span all columns
empty.textContent = 'No presets added to this tab. Open the tab\'s Edit menu and click "Add Preset" to add one.';
empty.textContent = 'No presets added to this zone. Open the zone\'s Edit menu and click "Add Preset" to add one.';
presetsList.appendChild(empty);
} else {
flatPresets.forEach((presetId) => {
@@ -1756,18 +1756,18 @@ const renderTabPresets = async (tabId) => {
...preset,
colors: resolveColorsWithPaletteRefs(preset.colors, preset.palette_refs, paletteColors),
};
const wrapper = createPresetButton(presetId, displayPreset, tabId, isSelected);
const wrapper = createPresetButton(presetId, displayPreset, zoneId, isSelected);
presetsList.appendChild(wrapper);
}
});
}
} catch (error) {
console.error('Failed to render tab presets:', error);
console.error('Failed to render zone presets:', error);
presetsList.innerHTML = '<p class="muted-text">Failed to load presets.</p>';
}
};
const createPresetButton = (presetId, preset, tabId, isSelected = false) => {
const createPresetButton = (presetId, preset, zoneId, isSelected = false) => {
const uiMode = getPresetUiMode();
const row = document.createElement('div');
@@ -1806,12 +1806,12 @@ const createPresetButton = (presetId, preset, tabId, isSelected = false) => {
button.addEventListener('click', () => {
if (isDraggingPreset) return;
const presetsListEl = document.getElementById('presets-list-tab');
const presetsListEl = document.getElementById('presets-list-zone');
if (presetsListEl) {
presetsListEl.querySelectorAll('.pattern-button').forEach((btn) => btn.classList.remove('active'));
}
button.classList.add('active');
selectedPresets[tabId] = presetId;
selectedPresets[zoneId] = presetId;
const section = row.closest('.presets-section');
sendSelectForCurrentTabDevices(presetId, section).catch((err) => {
console.error(err);
@@ -1828,7 +1828,7 @@ const createPresetButton = (presetId, preset, tabId, isSelected = false) => {
row.addEventListener('dragend', () => {
row.classList.remove('dragging');
const presetsListEl = document.getElementById('presets-list-tab');
const presetsListEl = document.getElementById('presets-list-zone');
if (presetsListEl) {
delete presetsListEl.dataset.dropTargetId;
}
@@ -1854,7 +1854,7 @@ const createPresetButton = (presetId, preset, tabId, isSelected = false) => {
e.preventDefault();
e.stopPropagation();
if (isDraggingPreset) return;
editPresetFromTab(presetId, tabId, preset);
editPresetFromTab(presetId, zoneId, preset);
});
actions.appendChild(editBtn);
@@ -1864,7 +1864,7 @@ const createPresetButton = (presetId, preset, tabId, isSelected = false) => {
return row;
};
const editPresetFromTab = async (presetId, tabId, existingPreset) => {
const editPresetFromTab = async (presetId, zoneId, existingPreset) => {
try {
let preset = existingPreset;
if (!preset) {
@@ -1880,7 +1880,7 @@ const editPresetFromTab = async (presetId, tabId, existingPreset) => {
// Dispatch a custom event to trigger the edit in the DOMContentLoaded scope
const editEvent = new CustomEvent('editPreset', {
detail: { presetId, preset, tabId }
detail: { presetId, preset, zoneId }
});
document.dispatchEvent(editEvent);
} catch (error) {
@@ -1889,36 +1889,36 @@ const editPresetFromTab = async (presetId, tabId, existingPreset) => {
}
};
// Remove a preset from a specific tab (does not delete the preset itself)
// Expected call style: removePresetFromTab(tabId, presetId)
const removePresetFromTab = async (tabId, presetId) => {
if (!tabId) {
// Try to get tab ID from the left-panel
const leftPanel = document.querySelector('.presets-section[data-tab-id]');
tabId = leftPanel ? leftPanel.dataset.tabId : null;
// Remove a preset from a specific zone (does not delete the preset itself)
// Expected call style: removePresetFromTab(zoneId, presetId)
const removePresetFromTab = async (zoneId, presetId) => {
if (!zoneId) {
// Try to get zone ID from the left-panel
const leftPanel = document.querySelector('.presets-section[data-zone-id]');
zoneId = leftPanel ? leftPanel.dataset.zoneId : null;
if (!tabId) {
if (!zoneId) {
// Fallback: try to get from URL
const pathParts = window.location.pathname.split('/');
const tabIndex = pathParts.indexOf('tabs');
const tabIndex = pathParts.indexOf('zones');
if (tabIndex !== -1 && tabIndex + 1 < pathParts.length) {
tabId = pathParts[tabIndex + 1];
zoneId = pathParts[tabIndex + 1];
}
}
}
if (!tabId) {
alert('Could not determine current tab.');
if (!zoneId) {
alert('Could not determine current zone.');
return;
}
try {
// Get current tab data
const tabResponse = await fetch(`/tabs/${tabId}`, {
// Get current zone data
const tabResponse = await fetch(`/zones/${zoneId}`, {
headers: { Accept: 'application/json' },
});
if (!tabResponse.ok) {
throw new Error('Failed to load tab');
throw new Error('Failed to load zone');
}
const tabData = await tabResponse.json();
@@ -1937,7 +1937,7 @@ const removePresetFromTab = async (tabId, presetId) => {
const beforeLen = flat.length;
flat = flat.filter(id => String(id) !== String(presetId));
if (flat.length === beforeLen) {
alert('Preset is not in this tab.');
alert('Preset is not in this zone.');
return;
}
@@ -1945,19 +1945,19 @@ const removePresetFromTab = async (tabId, presetId) => {
tabData.presets = newGrid;
tabData.presets_flat = flat;
const updateResponse = await fetch(`/tabs/${tabId}`, {
const updateResponse = await fetch(`/zones/${zoneId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(tabData),
});
if (!updateResponse.ok) {
throw new Error('Failed to update tab presets');
throw new Error('Failed to update zone presets');
}
await renderTabPresets(tabId);
await renderTabPresets(zoneId);
} catch (error) {
console.error('Failed to remove preset from tab:', error);
alert('Failed to remove preset from tab.');
console.error('Failed to remove preset from zone:', error);
alert('Failed to remove preset from zone.');
}
};
try {
@@ -1966,13 +1966,13 @@ try {
// Listen for HTMX swaps to render presets
document.body.addEventListener('htmx:afterSwap', (event) => {
if (event.target && event.target.id === 'tab-content') {
// Get tab ID from the left-panel
const leftPanel = document.querySelector('.presets-section[data-tab-id]');
if (event.target && event.target.id === 'zone-content') {
// Get zone ID from the left-panel
const leftPanel = document.querySelector('.presets-section[data-zone-id]');
if (leftPanel) {
const tabId = leftPanel.dataset.tabId;
if (tabId) {
renderTabPresets(tabId);
const zoneId = leftPanel.dataset.zoneId;
if (zoneId) {
renderTabPresets(zoneId);
}
}
}
@@ -1993,9 +1993,9 @@ document.addEventListener('DOMContentLoaded', () => {
}
const mainMenu = document.getElementById('main-menu-dropdown');
if (mainMenu) mainMenu.classList.remove('open');
const leftPanel = document.querySelector('.presets-section[data-tab-id]');
const leftPanel = document.querySelector('.presets-section[data-zone-id]');
if (leftPanel) {
renderTabPresets(leftPanel.dataset.tabId);
renderTabPresets(leftPanel.dataset.zoneId);
}
});
});

View File

@@ -35,8 +35,8 @@ document.addEventListener("DOMContentLoaded", () => {
};
const refreshTabsForActiveProfile = async () => {
// Clear stale current tab so tab controller falls back to first tab of applied profile.
document.cookie = "current_tab=; path=/; max-age=0";
// Clear stale current zone so zone controller falls back to first zone of applied profile.
document.cookie = "current_zone=; path=/; max-age=0";
if (window.tabsManager && typeof window.tabsManager.loadTabs === "function") {
await window.tabsManager.loadTabs();
@@ -231,7 +231,7 @@ document.addEventListener("DOMContentLoaded", () => {
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name,
seed_dj_tab: !!(newProfileSeedDjInput && newProfileSeedDjInput.checked),
seed_dj_zone: !!(newProfileSeedDjInput && newProfileSeedDjInput.checked),
}),
});
if (!response.ok) {

View File

@@ -203,7 +203,7 @@ body.preset-ui-run .edit-mode-only {
overflow: hidden;
}
.tabs-container {
.zones-container {
background-color: transparent;
padding: 0.5rem 0;
flex: 1;
@@ -213,7 +213,7 @@ body.preset-ui-run .edit-mode-only {
align-items: center;
}
.tabs-list {
.zones-list {
display: flex;
gap: 0.5rem;
overflow-x: auto;
@@ -222,7 +222,7 @@ body.preset-ui-run .edit-mode-only {
min-width: 0;
}
.tab-button {
.zone-button {
padding: 0.5rem 1rem;
background-color: #3a3a3a;
color: white;
@@ -234,16 +234,16 @@ body.preset-ui-run .edit-mode-only {
transition: background-color 0.2s;
}
.tab-button:hover {
.zone-button:hover {
background-color: #4a4a4a;
}
.tab-button.active {
.zone-button.active {
background-color: #6a5acd;
color: white;
}
.tab-content {
.zone-content {
flex: 1;
display: block;
overflow-y: auto;
@@ -255,7 +255,7 @@ body.preset-ui-run .edit-mode-only {
align-items: center;
}
.tab-brightness-group {
.zone-brightness-group {
display: flex;
flex-direction: column;
align-items: stretch;
@@ -263,7 +263,7 @@ body.preset-ui-run .edit-mode-only {
margin-left: auto;
}
.tab-brightness-group label {
.zone-brightness-group label {
white-space: nowrap;
font-size: 0.85rem;
}
@@ -509,8 +509,8 @@ body.preset-ui-run .edit-mode-only {
padding: 0;
}
/* Tab preset selecting area: 3 columns, vertical scroll only */
#presets-list-tab {
/* Zone preset selecting area: 3 columns, vertical scroll only */
#presets-list-zone {
flex: 1;
min-height: 0;
overflow-y: auto;
@@ -750,8 +750,8 @@ body.preset-ui-run .edit-mode-only {
background-color: #5a4f9f;
}
/* Preset select buttons inside the tab grid */
#presets-list-tab .pattern-button {
/* Preset select buttons inside the zone grid */
#presets-list-zone .pattern-button {
display: flex;
}
.pattern-button .pattern-button-label {
@@ -966,12 +966,12 @@ body.preset-ui-run .edit-mode-only {
padding: 0.4rem 0.7rem;
}
.tabs-container {
.zones-container {
padding: 0.5rem 0;
border-bottom: none;
}
.tab-content {
.zone-content {
padding: 0.5rem;
}
@@ -1064,24 +1064,24 @@ body.preset-ui-run .edit-mode-only {
border-radius: 4px;
}
.tab-modal-create-row {
.zone-modal-create-row {
flex-wrap: wrap;
align-items: center;
}
.tab-modal-create-row input[type="text"] {
.zone-modal-create-row input[type="text"] {
flex: 1;
min-width: 8rem;
}
.tab-devices-label {
.zone-devices-label {
display: block;
margin-top: 0.75rem;
margin-bottom: 0.35rem;
font-weight: 600;
}
.tab-devices-editor {
.zone-devices-editor {
display: flex;
flex-direction: column;
gap: 0.5rem;
@@ -1090,12 +1090,12 @@ body.preset-ui-run .edit-mode-only {
overflow-y: auto;
}
.tab-device-row-label {
.zone-device-row-label {
flex: 1;
min-width: 0;
}
.tab-device-add-select {
.zone-device-add-select {
flex: 1;
min-width: 10rem;
padding: 0.5rem;
@@ -1105,19 +1105,19 @@ body.preset-ui-run .edit-mode-only {
color: white;
}
.tab-devices-add {
.zone-devices-add {
margin-top: 0;
flex-wrap: wrap;
}
.tab-presets-section-label {
.zone-presets-section-label {
display: block;
margin-top: 1rem;
margin-bottom: 0.35rem;
font-weight: 600;
}
.edit-tab-presets-scroll {
.edit-zone-presets-scroll {
max-height: 200px;
overflow-y: auto;
margin-bottom: 1rem;
@@ -1195,7 +1195,7 @@ body.preset-ui-run .edit-mode-only {
}
/* Presets list: 3 columns and vertical scroll (defined above); mobile same */
@media (max-width: 800px) {
#presets-list-tab {
#presets-list-zone {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
@@ -1234,8 +1234,8 @@ body.preset-ui-run .edit-mode-only {
font-size: 0.9rem;
}
/* Tab content placeholder (no tab selected) */
.tab-content-placeholder {
/* Zone content placeholder (no zone selected) */
.zone-content-placeholder {
padding: 2rem;
text-align: center;
color: #aaa;

View File

@@ -1,11 +1,11 @@
/* General tab styles */
/* General zone styles */
.tabs {
display: flex;
justify-content: center;
margin-bottom: 20px;
}
.tab {
.zone {
padding: 10px 20px;
margin: 0 10px;
cursor: pointer;
@@ -15,23 +15,23 @@
transition: background-color 0.3s ease;
}
.tab:hover {
.zone:hover {
background-color: #ddd;
}
.tab.active {
.zone.active {
background-color: #ccc;
}
.tab-content {
.zone-content {
display: flex;
justify-content: center;
}
.tab-pane {
.zone-pane {
display: none;
}
.tab-pane.active {
.zone-pane.active {
display: block;
}

View File

@@ -1,24 +1,24 @@
document.addEventListener('DOMContentLoaded', () => {
let selectedIndex = null;
const getTab = async (tabId) => {
const response = await fetch(`/tabs/${tabId}`, {
const getTab = async (zoneId) => {
const response = await fetch(`/zones/${zoneId}`, {
headers: { Accept: 'application/json' },
});
if (!response.ok) {
throw new Error('No tab found');
throw new Error('No zone found');
}
return response.json();
};
const saveTabColors = async (tabId, colors) => {
const response = await fetch(`/tabs/${tabId}`, {
const saveTabColors = async (zoneId, colors) => {
const response = await fetch(`/zones/${zoneId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ colors }),
});
if (!response.ok) {
throw new Error('Failed to save tab colors');
throw new Error('Failed to save zone colors');
}
return response.json();
};
@@ -101,23 +101,23 @@ document.addEventListener('DOMContentLoaded', () => {
const initTabPalette = async () => {
const paletteContainer = document.getElementById('color-palette');
const addButton = document.getElementById('tab-color-add-btn');
const addFromPaletteButton = document.getElementById('tab-color-add-from-palette-btn');
const colorInput = document.getElementById('tab-color-input');
const addButton = document.getElementById('zone-color-add-btn');
const addFromPaletteButton = document.getElementById('zone-color-add-from-palette-btn');
const colorInput = document.getElementById('zone-color-input');
if (!paletteContainer || !addButton || !colorInput) {
return;
}
const tabId = paletteContainer.dataset.tabId;
if (!tabId) {
const zoneId = paletteContainer.dataset.zoneId;
if (!zoneId) {
renderPalette(paletteContainer, []);
return;
}
let tabData;
try {
tabData = await getTab(tabId);
tabData = await getTab(zoneId);
} catch (error) {
renderPalette(paletteContainer, []);
return;
@@ -134,7 +134,7 @@ document.addEventListener('DOMContentLoaded', () => {
}
try {
const updated = colors.filter((_, i) => i !== index);
const saved = await saveTabColors(tabId, updated);
const saved = await saveTabColors(zoneId, updated);
colors = saved.colors || updated;
selectedIndex = null;
renderPalette(paletteContainer, colors, onColorChange, onRemoveColor, onReorder);
@@ -152,7 +152,7 @@ document.addEventListener('DOMContentLoaded', () => {
const updated = [...colors];
const [moved] = updated.splice(fromIndex, 1);
updated.splice(toIndex, 0, moved);
const saved = await saveTabColors(tabId, updated);
const saved = await saveTabColors(zoneId, updated);
colors = saved.colors || updated;
selectedIndex = toIndex;
renderPalette(paletteContainer, colors, onColorChange, onRemoveColor, onReorder);
@@ -169,7 +169,7 @@ document.addEventListener('DOMContentLoaded', () => {
try {
const updated = [...colors];
updated[index] = newColor;
const saved = await saveTabColors(tabId, updated);
const saved = await saveTabColors(zoneId, updated);
colors = saved.colors || updated;
selectedIndex = index;
renderPalette(paletteContainer, colors, onColorChange, onRemoveColor, onReorder);
@@ -192,7 +192,7 @@ document.addEventListener('DOMContentLoaded', () => {
}
try {
const updated = [...colors, newColor];
const saved = await saveTabColors(tabId, updated);
const saved = await saveTabColors(zoneId, updated);
colors = saved.colors || updated;
selectedIndex = colors.length - 1;
renderPalette(paletteContainer, colors, onColorChange, onRemoveColor, onReorder);
@@ -229,7 +229,7 @@ document.addEventListener('DOMContentLoaded', () => {
try {
if (!colors.includes(picked)) {
const updated = [...colors, picked];
const saved = await saveTabColors(tabId, updated);
const saved = await saveTabColors(zoneId, updated);
colors = saved.colors || updated;
selectedIndex = colors.indexOf(picked);
renderPalette(paletteContainer, colors, onColorChange, onRemoveColor, onReorder);
@@ -252,7 +252,7 @@ document.addEventListener('DOMContentLoaded', () => {
};
document.body.addEventListener('htmx:afterSwap', (event) => {
if (event.target && event.target.id === 'tab-content') {
if (event.target && event.target.id === 'zone-content') {
selectedIndex = null;
initTabPalette();
}

File diff suppressed because it is too large Load Diff

View File

@@ -3,21 +3,21 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>LED Controller - Tab Mode</title>
<title>LED Controller - Zone Mode</title>
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<div class="app-container">
<header>
<div class="tabs-container">
<div id="tabs-list">
Loading tabs...
<div class="zones-container">
<div id="zones-list">
Loading zones...
</div>
</div>
<div class="header-actions">
<button class="btn btn-secondary" id="profiles-btn">Profiles</button>
<button class="btn btn-secondary edit-mode-only" id="devices-btn">Devices</button>
<button class="btn btn-secondary edit-mode-only" id="tabs-btn">Tabs</button>
<button class="btn btn-secondary edit-mode-only" id="zones-btn">Zones</button>
<button class="btn btn-secondary edit-mode-only" id="presets-btn">Presets</button>
<button class="btn btn-secondary edit-mode-only" id="patterns-btn">Patterns</button>
<button class="btn btn-secondary edit-mode-only" id="color-palette-btn">Colour Palette</button>
@@ -31,7 +31,7 @@
<button type="button" class="ui-mode-toggle" id="ui-mode-toggle-mobile" aria-pressed="false">Edit mode</button>
<button type="button" data-target="profiles-btn">Profiles</button>
<button type="button" class="edit-mode-only" data-target="devices-btn">Devices</button>
<button type="button" class="edit-mode-only" data-target="tabs-btn">Tabs</button>
<button type="button" class="edit-mode-only" data-target="zones-btn">Tabs</button>
<button type="button" class="edit-mode-only" data-target="presets-btn">Presets</button>
<button type="button" class="edit-mode-only" data-target="patterns-btn">Patterns</button>
<button type="button" class="edit-mode-only" data-target="color-palette-btn">Colour Palette</button>
@@ -42,47 +42,47 @@
</header>
<div class="main-content">
<div id="tab-content" class="tab-content">
<div class="tab-content-placeholder">
Select a tab to get started
<div id="zone-content" class="zone-content">
<div class="zone-content-placeholder">
Select a zone to get started
</div>
</div>
</div>
</div>
<!-- Tabs Modal -->
<div id="tabs-modal" class="modal">
<div id="zones-modal" class="modal">
<div class="modal-content">
<h2>Tabs</h2>
<div class="profiles-actions tab-modal-create-row">
<input type="text" id="new-tab-name" placeholder="Tab name">
<button class="btn btn-primary" id="create-tab-btn">Create</button>
<div class="profiles-actions zone-modal-create-row">
<input type="text" id="new-zone-name" placeholder="Zone name">
<button class="btn btn-primary" id="create-zone-btn">Create</button>
</div>
<div id="tabs-list-modal" class="profiles-list"></div>
<div id="zones-list-modal" class="profiles-list"></div>
<div class="modal-actions">
<button class="btn btn-secondary" id="tabs-close-btn">Close</button>
<button class="btn btn-secondary" id="zones-close-btn">Close</button>
</div>
</div>
</div>
<!-- Edit Tab Modal -->
<div id="edit-tab-modal" class="modal">
<!-- Edit Zone Modal -->
<div id="edit-zone-modal" class="modal">
<div class="modal-content">
<h2>Edit Tab</h2>
<form id="edit-tab-form">
<input type="hidden" id="edit-tab-id">
<h2>Edit Zone</h2>
<form id="edit-zone-form">
<input type="hidden" id="edit-zone-id">
<div class="modal-actions" style="margin-bottom: 1rem;">
<button type="submit" class="btn btn-primary">Save</button>
<button type="button" class="btn btn-secondary" onclick="document.getElementById('edit-tab-modal').classList.remove('active')">Close</button>
<button type="button" class="btn btn-secondary" onclick="document.getElementById('edit-zone-modal').classList.remove('active')">Close</button>
</div>
<label>Tab Name:</label>
<input type="text" id="edit-tab-name" placeholder="Enter tab name" required>
<label class="tab-devices-label">Devices in this tab</label>
<div id="edit-tab-devices-editor" class="tab-devices-editor"></div>
<label class="tab-presets-section-label">Presets on this tab</label>
<div id="edit-tab-presets-current" class="profiles-list edit-tab-presets-scroll"></div>
<label class="tab-presets-section-label">Add presets to this tab</label>
<div id="edit-tab-presets-list" class="profiles-list edit-tab-presets-scroll"></div>
<label>Zone Name:</label>
<input type="text" id="edit-zone-name" placeholder="Enter zone name" required>
<label class="zone-devices-label">Devices in this zone</label>
<div id="edit-zone-devices-editor" class="zone-devices-editor"></div>
<label class="zone-presets-section-label">Presets on this zone</label>
<div id="edit-zone-presets-current" class="profiles-list edit-zone-presets-scroll"></div>
<label class="zone-presets-section-label">Add presets to this zone</label>
<div id="edit-zone-presets-list" class="profiles-list edit-zone-presets-scroll"></div>
</form>
</div>
</div>
@@ -98,7 +98,7 @@
<div class="form-group" style="margin-top: 0.5rem; margin-bottom: 0;">
<label style="display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0;">
<input type="checkbox" id="new-profile-seed-dj">
DJ tab
DJ zone
</label>
</div>
<div id="profiles-list" class="profiles-list"></div>
@@ -229,7 +229,7 @@
<div class="modal-actions preset-editor-modal-actions">
<button class="btn btn-secondary" id="preset-send-btn">Try</button>
<button class="btn btn-secondary" id="preset-default-btn">Default</button>
<button type="button" class="btn btn-danger" id="preset-remove-from-tab-btn" hidden>Remove from tab</button>
<button type="button" class="btn btn-danger" id="preset-remove-from-zone-btn" hidden>Remove from zone</button>
<button class="btn btn-primary" id="preset-save-btn">Save &amp; Send</button>
<button class="btn btn-secondary" id="preset-editor-close-btn">Close</button>
</div>
@@ -270,11 +270,11 @@
<h3>Run mode</h3>
<ul>
<li><strong>Select tab</strong>: left-click a tab button in the top bar.</li>
<li><strong>Select preset</strong>: left-click a preset tile to send a <code>select</code> message to all devices in the tab.</li>
<li><strong>Select zone</strong>: left-click a zone button in the top bar.</li>
<li><strong>Select preset</strong>: left-click a preset tile to send a <code>select</code> message to all devices in the zone.</li>
<li><strong>Profiles</strong>: open <strong>Profiles</strong> to apply a profile. Profile editing actions are hidden in Run mode.</li>
<li><strong>Devices</strong>: open <strong>Devices</strong> to see drivers (Wi-Fi clients appear when they connect); edit or remove rows as needed.</li>
<li><strong>Send all presets</strong>: this action is available in <strong>Edit mode</strong> and pushes every preset used in the current tab to all tab devices.</li>
<li><strong>Send all presets</strong>: this action is available in <strong>Edit mode</strong> and pushes every preset used in the current zone to all zone devices.</li>
<li><strong>Switch modes</strong>: use the mode button in the menu. The button label shows the mode you will switch to.</li>
</ul>
@@ -283,8 +283,8 @@
<li><strong>Tabs</strong>: create, edit, and manage tabs and device assignments.</li>
<li><strong>Presets</strong>: create/manage reusable presets and edit preset details.</li>
<li><strong>Preset tiles</strong>: each tile shows <strong>Edit</strong> and <strong>Remove</strong> controls in Edit mode.</li>
<li><strong>Reorder presets</strong>: drag and drop preset tiles to save tab order.</li>
<li><strong>Profiles</strong>: create/clone/delete profiles. New profiles get a populated <strong>default</strong> tab and can optionally seed a <strong>DJ tab</strong>.</li>
<li><strong>Reorder presets</strong>: drag and drop preset tiles to save zone order.</li>
<li><strong>Profiles</strong>: create/clone/delete profiles. New profiles get a populated <strong>default</strong> zone and can optionally seed a <strong>DJ zone</strong>.</li>
<li><strong>Devices</strong>: view, edit, or remove registry entries (tabs use <strong>names</strong>; each row is keyed by <strong>MAC</strong>).</li>
<li><strong>Colour Palette</strong>: build profile colours and use <strong>From Palette</strong> in preset editor to add linked colours (badge <strong>P</strong>) that update when palette colours change.</li>
</ul>
@@ -364,11 +364,11 @@
</div>
<!-- Styles moved to /static/style.css -->
<script src="/static/tabs.js"></script>
<script src="/static/zones.js"></script>
<script src="/static/help.js"></script>
<script src="/static/color_palette.js"></script>
<script src="/static/profiles.js"></script>
<script src="/static/tab_palette.js"></script>
<script src="/static/zone_palette.js"></script>
<script src="/static/patterns.js"></script>
<script src="/static/presets.js"></script>
<script src="/static/devices.js"></script>