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

@@ -16,12 +16,12 @@ LED controller web app for managing profiles, tabs, presets, and colour palettes
## Profiles ## Profiles
- Applying a profile updates session scope and refreshes the active tab content. - Applying a profile updates session scope and refreshes the active zone content.
- In **Run mode**, Profiles supports apply-only behavior (no create/clone/delete). - In **Run mode**, Profiles supports apply-only behavior (no create/clone/delete).
- In **Edit mode**, Profiles supports create/clone/delete. - In **Edit mode**, Profiles supports create/clone/delete.
- Creating a profile always creates a populated `default` tab (starter presets). - Creating a profile always creates a populated `default` zone (starter presets).
- Optional **DJ tab** seeding creates: - Optional **DJ zone** seeding creates:
- `dj` tab bound to device name `dj` - `dj` zone bound to device name `dj`
- starter DJ presets (rainbow, single colour, transition) - starter DJ presets (rainbow, single colour, transition)
## Preset colours and palette linking ## Preset colours and palette linking

View File

@@ -1 +1 @@
{"aabbccddeeff": {"id": "aabbccddeeff", "name": "one", "type": "led", "transport": "espnow", "address": "aabbccddeeff", "default_pattern": null, "tabs": []}, "f0f5bdfd78b8": {"id": "f0f5bdfd78b8", "name": "a", "type": "led", "transport": "wifi", "address": "10.1.1.215", "default_pattern": null, "tabs": []}} {"aabbccddeeff": {"id": "aabbccddeeff", "name": "one", "type": "led", "transport": "espnow", "address": "aabbccddeeff", "default_pattern": null, "zones": []}, "f0f5bdfd78b8": {"id": "f0f5bdfd78b8", "name": "a", "type": "led", "transport": "wifi", "address": "10.1.1.215", "default_pattern": null, "zones": []}}

View File

@@ -1 +1 @@
{"1": {"name": "default", "type": "tabs", "tabs": ["1", "8"], "scenes": [], "palette_id": "1"}, "2": {"name": "test", "type": "tabs", "tabs": ["6", "7"], "scenes": [], "palette_id": "12"}} {"1": {"name": "default", "type": "zones", "zones": ["1", "8"], "scenes": [], "palette_id": "1"}, "2": {"name": "test", "type": "zones", "zones": ["6", "7"], "scenes": [], "palette_id": "12"}}

View File

@@ -1 +0,0 @@
{"1": {"name": "default", "names": ["1", "2", "3", "4", "5", "6", "7", "8", "0", "a"], "presets": [["4", "2", "7"], ["15", "3", "14"], ["5", "8", "10"], ["11", "9", "12"], ["1", "13", "37"]], "presets_flat": ["4", "2", "7", "15", "3", "14", "5", "8", "10", "11", "9", "12", "1", "13", "37"], "default_preset": "15"}, "2": {"name": "default", "names": ["1", "2", "3", "4", "5", "6", "7", "8", "0", "a"], "presets": [["16", "17", "18"], ["19", "20", "21"], ["22", "23", "24"], ["25", "26", "27"], ["28", "29", "30"]], "presets_flat": ["16", "17", "18", "19", "20", "21", "22", "23", "24", "25", "26", "27", "28", "29", "30"]}, "3": {"name": "default", "names": ["1"], "presets": [], "default_preset": null}, "4": {"name": "default", "names": ["1"], "presets": [], "default_preset": null}, "5": {"name": "dj", "names": ["dj"], "presets": [["31", "32", "33"]], "default_preset": "31", "presets_flat": ["31", "32", "33"]}, "6": {"name": "default", "names": ["1"], "presets": [], "default_preset": null}, "7": {"name": "dj", "names": ["dj"], "presets": [["34", "35", "36"]], "default_preset": "34", "presets_flat": ["34", "35", "36"]}, "8": {"name": "test", "names": ["11"], "presets": [["1", "2", "3"], ["4", "5"]], "default_preset": "1", "presets_flat": ["1", "2", "3", "4", "5"]}}

1
db/zone.json Normal file
View File

@@ -0,0 +1 @@
{"1": {"name": "default", "names": ["e", "c", "d", "a"], "presets": [["4", "2", "7"], ["3", "14", "5"], ["8", "10", "11"], ["9", "12", "1"], ["13", "37", "6"]], "presets_flat": ["4", "2", "7", "3", "14", "5", "8", "10", "11", "9", "12", "1", "13", "37", "6"], "default_preset": "15"}, "2": {"name": "default", "names": ["1", "2", "3", "4", "5", "6", "7", "8", "0", "a"], "presets": [["16", "17", "18"], ["19", "20", "21"], ["22", "23", "24"], ["25", "26", "27"], ["28", "29", "30"]], "presets_flat": ["16", "17", "18", "19", "20", "21", "22", "23", "24", "25", "26", "27", "28", "29", "30"]}, "3": {"name": "default", "names": ["1"], "presets": [], "default_preset": null}, "4": {"name": "default", "names": ["1"], "presets": [], "default_preset": null}, "5": {"name": "dj", "names": ["dj"], "presets": [["31", "32", "33"]], "default_preset": "31", "presets_flat": ["31", "32", "33"]}, "6": {"name": "default", "names": ["1"], "presets": [], "default_preset": null}, "7": {"name": "dj", "names": ["dj"], "presets": [["34", "35", "36"]], "default_preset": "34", "presets_flat": ["34", "35", "36"]}, "8": {"name": "test", "names": ["11"], "presets": [["1", "2", "3"], ["4", "5"]], "default_preset": "1", "presets_flat": ["1", "2", "3", "4", "5"]}}

View File

@@ -15,7 +15,7 @@ All JSON APIs use `Content-Type: application/json` for bodies and responses unle
The main UI has two modes controlled by the mode toggle: The main UI has two modes controlled by the mode toggle:
- **Run mode**: optimized for operation (tab/preset selection and profile apply). - **Run mode**: optimized for operation (zone/preset selection and profile apply).
- **Edit mode**: shows editing/management controls (tabs, presets, patterns, colour palette, send presets, profile management actions, **Devices** registry for LED driver names/MACs, and related tools). - **Edit mode**: shows editing/management controls (tabs, presets, patterns, colour palette, send presets, profile management actions, **Devices** registry for LED driver names/MACs, and related tools).
Profiles are available in both modes, but behavior differs: Profiles are available in both modes, but behavior differs:
@@ -100,7 +100,7 @@ Existing records without `type` / `transport` / `id` are backfilled on load (`le
| GET | `/profiles` | `{"profiles": {...}, "current_profile_id": "<id>"}`. Ensures a default current profile when possible. | | GET | `/profiles` | `{"profiles": {...}, "current_profile_id": "<id>"}`. Ensures a default current profile when possible. |
| GET | `/profiles/current` | `{"id": "...", "profile": {...}}` | | GET | `/profiles/current` | `{"id": "...", "profile": {...}}` |
| GET | `/profiles/<id>` | Single profile. If `<id>` is `current`, same as `/profiles/current`. | | GET | `/profiles/<id>` | Single profile. If `<id>` is `current`, same as `/profiles/current`. |
| POST | `/profiles` | Create profile. Body may include `name` and other fields. Optional `seed_dj_tab` (request-only) seeds a DJ tab + presets. New profiles always get a populated `default` tab. Returns `{ "<id>": { ... } }` with status 201. | | POST | `/profiles` | Create profile. Body may include `name` and other fields. Optional `seed_dj_zone` (request-only) seeds a DJ zone + presets. New profiles always get a populated `default` zone. Returns `{ "<id>": { ... } }` with status 201. |
| POST | `/profiles/<id>/apply` | Sets session current profile to `<id>`. | | POST | `/profiles/<id>/apply` | Sets session current profile to `<id>`. |
| POST | `/profiles/<id>/clone` | Clone profile (tabs, palettes, presets). Body may include `name`. | | POST | `/profiles/<id>/clone` | Clone profile (tabs, palettes, presets). Body may include `name`. |
| PUT | `/profiles/current` | Update the current profile (from session). | | PUT | `/profiles/current` | Update the current profile (from session). |
@@ -143,18 +143,18 @@ Stored preset records can include:
- `colors`: resolved hex colours for editor/display. - `colors`: resolved hex colours for editor/display.
- `palette_refs`: optional array of palette indexes parallel to `colors`. If a slot contains an integer index, the colour is linked to the current profile palette at that index. - `palette_refs`: optional array of palette indexes parallel to `colors`. If a slot contains an integer index, the colour is linked to the current profile palette at that index.
### Tabs — `/tabs` ### Tabs — `/zones`
| Method | Path | Description | | Method | Path | Description |
|--------|------|-------------| |--------|------|-------------|
| GET | `/tabs` | `tabs`, `tab_order`, `current_tab_id`, `profile_id` for the session-backed profile. | | GET | `/zones` | `tabs`, `zone_order`, `current_zone_id`, `profile_id` for the session-backed profile. |
| GET | `/tabs/current` | Current tab from cookie/session. | | GET | `/zones/current` | Current zone from cookie/session. |
| POST | `/tabs` | Create tab; optional JSON `name`, `names`, `presets`; can append to current profiles tab list. | | POST | `/zones` | Create zone; optional JSON `name`, `names`, `presets`; can append to current profiles zone list. |
| GET | `/tabs/<id>` | Tab JSON. | | GET | `/zones/<id>` | Zone JSON. |
| PUT | `/tabs/<id>` | Update tab. | | PUT | `/zones/<id>` | Update zone. |
| DELETE | `/tabs/<id>` | Delete tab; can delete `current` to remove the active tab; updates profile tab list. | | DELETE | `/zones/<id>` | Delete zone; can delete `current` to remove the active zone; updates profile zone list. |
| POST | `/tabs/<id>/set-current` | Sets `current_tab` cookie. | | POST | `/zones/<id>/set-current` | Sets `current_zone` cookie. |
| POST | `/tabs/<id>/clone` | Clone tab into current profile. | | POST | `/zones/<id>/clone` | Clone zone into current profile. |
### Palettes — `/palettes` ### Palettes — `/palettes`

View File

@@ -351,9 +351,9 @@ Manage connected devices and create/manage device groups.
#### Layout #### Layout
- **Header:** Title with "Add Device" button - **Header:** Title with "Add Device" button
- **Tabs:** Devices and Groups tabs - **Tabs:** Devices and Groups tabs
- **Content Area:** Tab-specific content - **Content Area:** Zone-specific content
#### Devices Tab #### Devices Zone
**Device List** **Device List**
- **Display:** List of all known devices - **Display:** List of all known devices
@@ -375,7 +375,7 @@ Manage connected devices and create/manage device groups.
- **Actions:** Cancel, Save - **Actions:** Cancel, Save
- **Note:** Only one master device per system. Adding a new master will demote existing master to slave. - **Note:** Only one master device per system. Adding a new master will demote existing master to slave.
#### Groups Tab #### Groups Zone
**Group List** **Group List**
- **Display:** List of all device groups - **Display:** List of all device groups
@@ -397,7 +397,7 @@ Manage connected devices and create/manage device groups.
- **Actions:** Cancel, Create - **Actions:** Cancel, Create
#### Design Specifications #### Design Specifications
- **Tab Style:** Active tab has purple background, white text - **Zone Style:** Active zone has purple background, white text
- **List Items:** Bordered cards with hover effects - **List Items:** Bordered cards with hover effects
- **Modal:** Centered overlay with white card, shadow - **Modal:** Centered overlay with white card, shadow
- **Status Badges:** Colored pills (green for online, red for offline) - **Status Badges:** Colored pills (green for online, red for offline)
@@ -1495,7 +1495,7 @@ peak_mem = usqlite.mem_peak()
### Flow 2: Create Device Group ### Flow 2: Create Device Group
1. User navigates to Device Management → Groups tab 1. User navigates to Device Management → Groups zone
2. User clicks "Create Group", enters name, selects pattern/settings 2. User clicks "Create Group", enters name, selects pattern/settings
3. User selects devices to add (can include master), clicks "Create" 3. User selects devices to add (can include master), clicks "Create"
4. Group appears in list 4. Group appears in list

View File

@@ -12,13 +12,13 @@ Figures below are **schematic** (layout and ideas), not pixel-perfect screenshot
The header has a mode toggle (desktop and mobile menu). The **label on the button is the mode you switch to** when you press it. The header has a mode toggle (desktop and mobile menu). The **label on the button is the mode you switch to** when you press it.
![Schematic: tab buttons on the left; Profiles, Tabs, Presets, Patterns, and the mode toggle on the right (example shows Edit mode with “Run mode” on the button).](images/help/header-toolbar.svg) ![Schematic: zone buttons on the left; Profiles, Tabs, Presets, Patterns, and the mode toggle on the right (example shows Edit mode with “Run mode” on the button).](images/help/header-toolbar.svg)
*The active tab is highlighted. Extra management buttons appear only in Edit mode.* *The active zone is highlighted. Extra management buttons appear only in Edit mode.*
| Mode | Purpose | | Mode | Purpose |
|------|--------| |------|--------|
| **Run mode** | Day-to-day control: choose a tab, tap presets, apply profiles. Management buttons are hidden. | | **Run mode** | Day-to-day control: choose a zone, tap presets, apply profiles. Management buttons are hidden. |
| **Edit mode** | Full setup: tabs, presets, patterns, colour palette, **Send Presets**, profile create/clone/delete, preset reordering, and per-tile **Edit** on the strip. | | **Edit mode** | Full setup: tabs, presets, patterns, colour palette, **Send Presets**, profile create/clone/delete, preset reordering, and per-tile **Edit** on the strip. |
**Profiles** is available in both modes: in Run mode you can only **apply** a profile; in Edit mode you can also **create**, **clone**, and **delete** profiles. **Profiles** is available in both modes: in Run mode you can only **apply** a profile; in Edit mode you can also **create**, **clone**, and **delete** profiles.
@@ -27,23 +27,23 @@ The header has a mode toggle (desktop and mobile menu). The **label on the butto
## Tabs ## Tabs
- **Select a tab**: click its button in the top bar. The main area shows that tabs preset strip and controls. - **Select a zone**: click its button in the top bar. The main area shows that zones preset strip and controls.
- **Edit mode — open tab settings**: **right-click** a tab button to change its name, **device IDs** (comma-separated), and which presets appear on the tab. Device identifiers are matched to each devices **name** when the app builds `select` messages for the driver. - **Edit mode — open zone settings**: **right-click** a zone button to change its name, **device IDs** (comma-separated), and which presets appear on the zone. Device identifiers are matched to each devices **name** when the app builds `select` messages for the driver.
- **Tabs modal** (Edit mode): create new tabs from the header **Tabs** button. New tabs need a name and device ID list (defaults to `1` if you leave a simple placeholder). - **Tabs modal** (Edit mode): create new tabs from the header **Tabs** button. New tabs need a name and device ID list (defaults to `1` if you leave a simple placeholder).
- **Brightness slider** (per tab): adjusts **global** brightness sent to devices (`b` in the driver message), with a short debounce so small drags do not flood the link. - **Brightness slider** (per zone): adjusts **global** brightness sent to devices (`b` in the driver message), with a short debounce so small drags do not flood the link.
--- ---
## Presets on the tab strip ## Presets on the zone strip
- **Run and Edit mode**: click the **main part** of a preset tile to **select** that preset on all devices assigned to the current tab (same logical action as a `select` in the driver API). - **Run and Edit mode**: click the **main part** of a preset tile to **select** that preset on all devices assigned to the current zone (same logical action as a `select` in the driver API).
- **Edit mode only**: - **Edit mode only**:
- **Edit** beside a tile opens the preset editor for that preset, scoped to the current tab (so you can **Remove from tab** without deleting the preset from the profile). - **Edit** beside a tile opens the preset editor for that preset, scoped to the current zone (so you can **Remove from zone** without deleting the preset from the profile).
- **Drag and drop** tiles to reorder them; order is saved for that tab. - **Drag and drop** tiles to reorder them; order is saved for that zone.
![Schematic: tab title, brightness slider, and a row of preset tiles; Edit mode adds an Edit control and drag handles for reordering.](images/help/tab-preset-strip.svg) ![Schematic: zone title, brightness slider, and a row of preset tiles; Edit mode adds an Edit control and drag handles for reordering.](images/help/zone-preset-strip.svg)
*The slider controls global brightness for the tabs devices. Click the coloured area of a tile to select that preset.* *The slider controls global brightness for the zones devices. Click the coloured area of a tile to select that preset.*
The **Presets** header button (Edit mode) opens a **profile-wide** list: **Add** new presets, **Edit**, **Send** (push definition over the transport), and **Delete** (removes the preset from the profile entirely). The **Presets** header button (Edit mode) opens a **profile-wide** list: **Add** new presets, **Edit**, **Send** (push definition over the transport), and **Delete** (removes the preset from the profile entirely).
@@ -55,10 +55,10 @@ The **Presets** header button (Edit mode) opens a **profile-wide** list: **Add**
- **Colours**: choosing a value in the colour picker **adds** a swatch when the picker closes. Swatches can be **reordered** by dragging. Changing a swatch with the picker **clears** palette linkage for that slot. - **Colours**: choosing a value in the colour picker **adds** a swatch when the picker closes. Swatches can be **reordered** by dragging. Changing a swatch with the picker **clears** palette linkage for that slot.
- **From Palette**: inserts a colour **linked** to the current profiles palette. Linked slots show a **P** badge; if you change that palette entry later, presets using it update. - **From Palette**: inserts a colour **linked** to the current profiles palette. Linked slots show a **P** badge; if you change that palette entry later, presets using it update.
- **Brightness (0255)** and **Delay (ms)**: stored on the preset and sent with the compact preset payload. - **Brightness (0255)** and **Delay (ms)**: stored on the preset and sent with the compact preset payload.
- **Try**: sends the current form values to devices on the **current tab**, then selects that preset — **without** `save` on the device (good for auditioning). - **Try**: sends the current form values to devices on the **current zone**, then selects that preset — **without** `save` on the device (good for auditioning).
- **Default**: updates the tabs **default preset** and sends a **default** hint for those devices; it does not force the same live selection behaviour as clicking a tile. - **Default**: updates the zones **default preset** and sends a **default** hint for those devices; it does not force the same live selection behaviour as clicking a tile.
- **Save & Send**: writes the preset to the server, then pushes definitions with **save** so devices may persist them. It does **not** auto-select the preset on devices (use the strip or **Try** if you want that). - **Save & Send**: writes the preset to the server, then pushes definitions with **save** so devices may persist them. It does **not** auto-select the preset on devices (use the strip or **Try** if you want that).
- **Remove from tab** (when you opened the editor from a tab): removes the preset from **this tabs list only**; the preset remains in the profile for other tabs. - **Remove from zone** (when you opened the editor from a zone): removes the preset from **this zones list only**; the preset remains in the profile for other zones.
![Schematic: preset editor with name, pattern, colour swatches (one with a P badge for palette-linked), and action buttons.](images/help/preset-editor.svg) ![Schematic: preset editor with name, pattern, colour swatches (one with a P badge for palette-linked), and action buttons.](images/help/preset-editor.svg)
@@ -69,14 +69,14 @@ The **Presets** header button (Edit mode) opens a **profile-wide** list: **Add**
## Profiles ## Profiles
- **Apply**: sets the **current profile** in your session. Tabs and presets you see are scoped to that profile. - **Apply**: sets the **current profile** in your session. Tabs and presets you see are scoped to that profile.
- **Edit mode — Create**: new profiles always get a populated **default** tab. Optionally tick **DJ tab** to also create a `dj` tab (device name `dj`) with starter DJ-oriented presets. - **Edit mode — Create**: new profiles always get a populated **default** zone. Optionally tick **DJ zone** to also create a `dj` zone (device name `dj`) with starter DJ-oriented presets.
- **Clone** / **Delete**: available in Edit mode from the profile list. - **Clone** / **Delete**: available in Edit mode from the profile list.
--- ---
## Send Presets (Edit mode) ## Send Presets (Edit mode)
**Send Presets** walks **every tab** in the **current profile**, collects each tabs preset IDs, and calls **`POST /presets/send`** per tab (including each tabs **default** preset when set). Use this to bulk-push definitions to hardware after editing, without clicking **Send** on every preset individually. **Send Presets** walks **every zone** in the **current profile**, collects each zones preset IDs, and calls **`POST /presets/send`** per zone (including each zones **default** preset when set). Use this to bulk-push definitions to hardware after editing, without clicking **Send** on every preset individually.
--- ---
@@ -102,7 +102,7 @@ On narrow screens, use **Menu** to reach the same actions as the desktop header
![Schematic: narrow layout with Menu and the same header actions in a dropdown.](images/help/mobile-menu.svg) ![Schematic: narrow layout with Menu and the same header actions in a dropdown.](images/help/mobile-menu.svg)
*Preset tiles behave the same once a tab is selected.* *Preset tiles behave the same once a zone is selected.*
--- ---

View File

@@ -67,7 +67,7 @@
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
} }
.tab { .zone {
flex: 1; flex: 1;
padding: 12px 24px; padding: 12px 24px;
border: none; border: none;
@@ -78,16 +78,16 @@
transition: all 0.2s; transition: all 0.2s;
} }
.tab.active { .zone.active {
background: #667eea; background: #667eea;
color: white; color: white;
} }
.tab-content { .zone-content {
display: none; display: none;
} }
.tab-content.active { .zone-content.active {
display: block; display: block;
} }
@@ -249,12 +249,12 @@
</div> </div>
<div class="tabs"> <div class="tabs">
<button class="tab active" onclick="switchTab('devices')">Devices</button> <button class="zone active" onclick="switchTab('devices')">Devices</button>
<button class="tab" onclick="switchTab('groups')">Groups</button> <button class="zone" onclick="switchTab('groups')">Groups</button>
</div> </div>
<!-- Devices Tab --> <!-- Devices Zone -->
<div id="devices-tab" class="tab-content active"> <div id="devices-zone" class="zone-content active">
<div class="card"> <div class="card">
<h2>Connected Devices</h2> <h2>Connected Devices</h2>
<div class="device-item"> <div class="device-item">
@@ -313,8 +313,8 @@
</div> </div>
</div> </div>
<!-- Groups Tab --> <!-- Groups Zone -->
<div id="groups-tab" class="tab-content"> <div id="groups-zone" class="zone-content">
<div class="card"> <div class="card">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px;"> <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px;">
<h2>Groups</h2> <h2>Groups</h2>
@@ -386,12 +386,12 @@
</div> </div>
<script> <script>
function switchTab(tab) { function switchTab(zone) {
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active')); document.querySelectorAll('.zone').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active')); document.querySelectorAll('.zone-content').forEach(c => c.classList.remove('active'));
event.target.classList.add('active'); event.target.classList.add('active');
document.getElementById(tab + '-tab').classList.add('active'); document.getElementById(zone + '-zone').classList.add('active');
} }
function showAddDeviceModal() { function showAddDeviceModal() {

View File

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

View File

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

View File

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

View File

@@ -26,18 +26,18 @@ class Profile(Model):
if changed: if changed:
self.save() 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. """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() next_id = self.get_next_id()
# Create a unique palette for this profile. # Create a unique palette for this profile.
palette_id = self._palette_model.create(colors=[]) palette_id = self._palette_model.create(colors=[])
self[next_id] = { self[next_id] = {
"name": name, "name": name,
"type": profile_type, # "tabs" or "scenes" "type": profile_type, # "zones" or "scenes"
"tabs": [], # Array of tab IDs "zones": [], # Array of zone IDs
"scenes": [], # Array of scene IDs (for future use) "scenes": [], # Array of scene IDs (for future use)
"palette_id": str(palette_id), "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 = { this.state = {
lights: {}, lights: {},
patterns: {}, patterns: {},
tab_order: [], zone_order: [],
presets: {} presets: {}
}; };
this.selectedColorIndex = 0; this.selectedColorIndex = 0;
@@ -19,8 +19,8 @@ class LightingController {
await this.loadState(); await this.loadState();
this.setupEventListeners(); this.setupEventListeners();
this.renderTabs(); this.renderTabs();
if (this.state.tab_order.length > 0) { if (this.state.zone_order.length > 0) {
this.selectTab(this.state.tab_order[0]); this.selectTab(this.state.zone_order[0]);
} }
} }
@@ -62,19 +62,19 @@ class LightingController {
} }
setupEventListeners() { setupEventListeners() {
// Tab management // Zone management
document.getElementById('add-tab-btn').addEventListener('click', () => this.showAddTabModal()); document.getElementById('add-zone-btn').addEventListener('click', () => this.showAddTabModal());
document.getElementById('edit-tab-btn').addEventListener('click', () => this.showEditTabModal()); document.getElementById('edit-zone-btn').addEventListener('click', () => this.showEditTabModal());
document.getElementById('delete-tab-btn').addEventListener('click', () => this.deleteCurrentTab()); document.getElementById('delete-zone-btn').addEventListener('click', () => this.deleteCurrentTab());
document.getElementById('color-palette-btn').addEventListener('click', () => this.showColorPalette()); document.getElementById('color-palette-btn').addEventListener('click', () => this.showColorPalette());
document.getElementById('presets-btn').addEventListener('click', () => this.showPresets()); document.getElementById('presets-btn').addEventListener('click', () => this.showPresets());
document.getElementById('profiles-btn').addEventListener('click', () => this.showProfiles()); document.getElementById('profiles-btn').addEventListener('click', () => this.showProfiles());
// Modal actions // Modal actions
document.getElementById('add-tab-confirm').addEventListener('click', () => this.createTab()); document.getElementById('add-zone-confirm').addEventListener('click', () => this.createTab());
document.getElementById('add-tab-cancel').addEventListener('click', () => this.hideModal('add-tab-modal')); document.getElementById('add-zone-cancel').addEventListener('click', () => this.hideModal('add-zone-modal'));
document.getElementById('edit-tab-confirm').addEventListener('click', () => this.updateTab()); document.getElementById('edit-zone-confirm').addEventListener('click', () => this.updateTab());
document.getElementById('edit-tab-cancel').addEventListener('click', () => this.hideModal('edit-tab-modal')); 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('profiles-close-btn').addEventListener('click', () => this.hideModal('profiles-modal'));
document.getElementById('color-palette-close-btn').addEventListener('click', () => this.hideModal('color-palette-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')); document.getElementById('presets-close-btn').addEventListener('click', () => this.hideModal('presets-modal'));
@@ -125,12 +125,12 @@ class LightingController {
} }
renderTabs() { renderTabs() {
const tabsList = document.getElementById('tabs-list'); const tabsList = document.getElementById('zones-list');
tabsList.innerHTML = ''; tabsList.innerHTML = '';
this.state.tab_order.forEach(tabName => { this.state.zone_order.forEach(tabName => {
const tabButton = document.createElement('button'); const tabButton = document.createElement('button');
tabButton.className = 'tab-button'; tabButton.className = 'zone-button';
tabButton.textContent = tabName; tabButton.textContent = tabName;
tabButton.addEventListener('click', () => this.selectTab(tabName)); tabButton.addEventListener('click', () => this.selectTab(tabName));
if (tabName === this.currentTab) { if (tabName === this.currentTab) {
@@ -217,13 +217,13 @@ class LightingController {
} }
renderPresets(tabName) { renderPresets(tabName) {
const presetsList = document.getElementById('presets-list-tab'); const presetsList = document.getElementById('presets-list-zone');
presetsList.innerHTML = ''; presetsList.innerHTML = '';
const presets = this.state.presets || {}; const presets = this.state.presets || {};
const presetNames = Object.keys(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); const currentSettings = this.getCurrentTabSettings(tabName);
// Always include "on" and "off" presets // Always include "on" and "off" presets
@@ -267,7 +267,7 @@ class LightingController {
const presetButton = document.createElement('button'); const presetButton = document.createElement('button');
presetButton.className = 'pattern-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); const isActive = this.presetMatchesSettings(preset, currentSettings);
if (isActive) { if (isActive) {
presetButton.classList.add('active'); 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.loadState();
await this.loadTabContent(tabName); await this.loadTabContent(tabName);
} else { } else {
@@ -591,7 +591,7 @@ class LightingController {
} }
// Reload state from server to ensure consistency // Reload state from server to ensure consistency
await this.loadState(); await this.loadState();
// Reload tab content to update UI // Reload zone content to update UI
await this.loadTabContent(tabName); await this.loadTabContent(tabName);
} else { } else {
const errorText = await response.text(); const errorText = await response.text();
@@ -769,23 +769,23 @@ class LightingController {
} }
showAddTabModal() { showAddTabModal() {
document.getElementById('new-tab-name').value = ''; document.getElementById('new-zone-name').value = '';
document.getElementById('new-tab-ids').value = '1'; document.getElementById('new-zone-ids').value = '1';
document.getElementById('add-tab-modal').classList.add('active'); document.getElementById('add-zone-modal').classList.add('active');
} }
async createTab() { async createTab() {
const name = document.getElementById('new-tab-name').value.trim(); const name = document.getElementById('new-zone-name').value.trim();
const idsStr = document.getElementById('new-tab-ids').value.trim(); const idsStr = document.getElementById('new-zone-ids').value.trim();
const ids = idsStr.split(',').map(id => id.trim()).filter(id => id); const ids = idsStr.split(',').map(id => id.trim()).filter(id => id);
if (!name) { if (!name) {
alert('Tab name cannot be empty'); alert('Zone name cannot be empty');
return; return;
} }
try { try {
const response = await fetch('/tabs', { const response = await fetch('/zones', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, ids }) body: JSON.stringify({ name, ids })
@@ -795,41 +795,41 @@ class LightingController {
await this.loadState(); await this.loadState();
this.renderTabs(); this.renderTabs();
this.selectTab(name); this.selectTab(name);
this.hideModal('add-tab-modal'); this.hideModal('add-zone-modal');
} else { } else {
const error = await response.json(); const error = await response.json();
alert(error.error || 'Failed to create tab'); alert(error.error || 'Failed to create zone');
} }
} catch (error) { } catch (error) {
console.error('Failed to create tab:', error); console.error('Failed to create zone:', error);
alert('Failed to create tab'); alert('Failed to create zone');
} }
} }
showEditTabModal() { showEditTabModal() {
if (!this.currentTab) { if (!this.currentTab) {
alert('Please select a tab first'); alert('Please select a zone first');
return; return;
} }
const light = this.state.lights[this.currentTab]; const light = this.state.lights[this.currentTab];
document.getElementById('edit-tab-name').value = this.currentTab; document.getElementById('edit-zone-name').value = this.currentTab;
document.getElementById('edit-tab-ids').value = light.names.join(', '); document.getElementById('edit-zone-ids').value = light.names.join(', ');
document.getElementById('edit-tab-modal').classList.add('active'); document.getElementById('edit-zone-modal').classList.add('active');
} }
async updateTab() { async updateTab() {
const newName = document.getElementById('edit-tab-name').value.trim(); const newName = document.getElementById('edit-zone-name').value.trim();
const idsStr = document.getElementById('edit-tab-ids').value.trim(); const idsStr = document.getElementById('edit-zone-ids').value.trim();
const ids = idsStr.split(',').map(id => id.trim()).filter(id => id); const ids = idsStr.split(',').map(id => id.trim()).filter(id => id);
if (!newName) { if (!newName) {
alert('Tab name cannot be empty'); alert('Zone name cannot be empty');
return; return;
} }
try { try {
const response = await fetch(`/tabs/${this.currentTab}`, { const response = await fetch(`/zones/${this.currentTab}`, {
method: 'PUT', method: 'PUT',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: newName, ids }) body: JSON.stringify({ name: newName, ids })
@@ -839,45 +839,45 @@ class LightingController {
await this.loadState(); await this.loadState();
this.renderTabs(); this.renderTabs();
this.selectTab(newName); this.selectTab(newName);
this.hideModal('edit-tab-modal'); this.hideModal('edit-zone-modal');
} else { } else {
const error = await response.json(); const error = await response.json();
alert(error.error || 'Failed to update tab'); alert(error.error || 'Failed to update zone');
} }
} catch (error) { } catch (error) {
console.error('Failed to update tab:', error); console.error('Failed to update zone:', error);
alert('Failed to update tab'); alert('Failed to update zone');
} }
} }
async deleteCurrentTab() { async deleteCurrentTab() {
if (!this.currentTab) { if (!this.currentTab) {
alert('Please select a tab first'); alert('Please select a zone first');
return; 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; return;
} }
try { try {
const response = await fetch(`/tabs/${this.currentTab}`, { const response = await fetch(`/zones/${this.currentTab}`, {
method: 'DELETE' method: 'DELETE'
}); });
if (response.ok) { if (response.ok) {
await this.loadState(); await this.loadState();
this.renderTabs(); this.renderTabs();
if (this.state.tab_order.length > 0) { if (this.state.zone_order.length > 0) {
this.selectTab(this.state.tab_order[0]); this.selectTab(this.state.zone_order[0]);
} else { } else {
this.currentTab = null; 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) { } catch (error) {
console.error('Failed to delete tab:', error); console.error('Failed to delete zone:', error);
alert('Failed to delete tab'); alert('Failed to delete zone');
} }
} }
@@ -1008,9 +1008,9 @@ class LightingController {
if (this.state.current_profile === profileName) { if (this.state.current_profile === profileName) {
this.state.current_profile = ''; this.state.current_profile = '';
this.state.lights = {}; this.state.lights = {};
this.state.tab_order = []; this.state.zone_order = [];
this.renderTabs(); 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(); this.updateCurrentProfileDisplay();
} }
} else { } else {
@@ -1032,8 +1032,8 @@ class LightingController {
if (response.ok) { if (response.ok) {
await this.loadState(); await this.loadState();
this.renderTabs(); this.renderTabs();
if (this.state.tab_order.length > 0) { if (this.state.zone_order.length > 0) {
this.selectTab(this.state.tab_order[0]); this.selectTab(this.state.zone_order[0]);
} else { } else {
this.currentTab = null; 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.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`; 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) => { swatch.addEventListener('click', (e) => {
// Only apply if not clicking the remove button // Only apply if not clicking the remove button
if (e.target === swatch || !e.target.closest('button')) { if (e.target === swatch || !e.target.closest('button')) {
@@ -1151,7 +1151,7 @@ class LightingController {
applyPaletteColorToSelected(paletteColor) { applyPaletteColorToSelected(paletteColor) {
if (!this.currentTab) { if (!this.currentTab) {
alert('No tab selected. Please select a tab first.'); alert('No zone selected. Please select a zone first.');
return; return;
} }
@@ -1439,7 +1439,7 @@ class LightingController {
async applyPreset(presetName) { async applyPreset(presetName) {
if (!this.currentTab) { if (!this.currentTab) {
alert('Please select a tab first'); alert('Please select a zone first');
return; return;
} }
@@ -1621,7 +1621,7 @@ class LightingController {
loadCurrentTabToPresetEditor() { loadCurrentTabToPresetEditor() {
if (!this.currentTab || !this.state.lights[this.currentTab]) { if (!this.currentTab || !this.state.lights[this.currentTab]) {
alert('Please select a tab first'); alert('Please select a zone first');
return; return;
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -3,21 +3,21 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <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"> <link rel="stylesheet" href="/static/style.css">
</head> </head>
<body> <body>
<div class="app-container"> <div class="app-container">
<header> <header>
<div class="tabs-container"> <div class="zones-container">
<div id="tabs-list"> <div id="zones-list">
Loading tabs... Loading zones...
</div> </div>
</div> </div>
<div class="header-actions"> <div class="header-actions">
<button class="btn btn-secondary" id="profiles-btn">Profiles</button> <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="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="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="patterns-btn">Patterns</button>
<button class="btn btn-secondary edit-mode-only" id="color-palette-btn">Colour Palette</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" 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" 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="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="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="patterns-btn">Patterns</button>
<button type="button" class="edit-mode-only" data-target="color-palette-btn">Colour Palette</button> <button type="button" class="edit-mode-only" data-target="color-palette-btn">Colour Palette</button>
@@ -42,47 +42,47 @@
</header> </header>
<div class="main-content"> <div class="main-content">
<div id="tab-content" class="tab-content"> <div id="zone-content" class="zone-content">
<div class="tab-content-placeholder"> <div class="zone-content-placeholder">
Select a tab to get started Select a zone to get started
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<!-- Tabs Modal --> <!-- Tabs Modal -->
<div id="tabs-modal" class="modal"> <div id="zones-modal" class="modal">
<div class="modal-content"> <div class="modal-content">
<h2>Tabs</h2> <h2>Tabs</h2>
<div class="profiles-actions tab-modal-create-row"> <div class="profiles-actions zone-modal-create-row">
<input type="text" id="new-tab-name" placeholder="Tab name"> <input type="text" id="new-zone-name" placeholder="Zone name">
<button class="btn btn-primary" id="create-tab-btn">Create</button> <button class="btn btn-primary" id="create-zone-btn">Create</button>
</div> </div>
<div id="tabs-list-modal" class="profiles-list"></div> <div id="zones-list-modal" class="profiles-list"></div>
<div class="modal-actions"> <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> </div>
</div> </div>
<!-- Edit Tab Modal --> <!-- Edit Zone Modal -->
<div id="edit-tab-modal" class="modal"> <div id="edit-zone-modal" class="modal">
<div class="modal-content"> <div class="modal-content">
<h2>Edit Tab</h2> <h2>Edit Zone</h2>
<form id="edit-tab-form"> <form id="edit-zone-form">
<input type="hidden" id="edit-tab-id"> <input type="hidden" id="edit-zone-id">
<div class="modal-actions" style="margin-bottom: 1rem;"> <div class="modal-actions" style="margin-bottom: 1rem;">
<button type="submit" class="btn btn-primary">Save</button> <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> </div>
<label>Tab Name:</label> <label>Zone Name:</label>
<input type="text" id="edit-tab-name" placeholder="Enter tab name" required> <input type="text" id="edit-zone-name" placeholder="Enter zone name" required>
<label class="tab-devices-label">Devices in this tab</label> <label class="zone-devices-label">Devices in this zone</label>
<div id="edit-tab-devices-editor" class="tab-devices-editor"></div> <div id="edit-zone-devices-editor" class="zone-devices-editor"></div>
<label class="tab-presets-section-label">Presets on this tab</label> <label class="zone-presets-section-label">Presets on this zone</label>
<div id="edit-tab-presets-current" class="profiles-list edit-tab-presets-scroll"></div> <div id="edit-zone-presets-current" class="profiles-list edit-zone-presets-scroll"></div>
<label class="tab-presets-section-label">Add presets to this tab</label> <label class="zone-presets-section-label">Add presets to this zone</label>
<div id="edit-tab-presets-list" class="profiles-list edit-tab-presets-scroll"></div> <div id="edit-zone-presets-list" class="profiles-list edit-zone-presets-scroll"></div>
</form> </form>
</div> </div>
</div> </div>
@@ -98,7 +98,7 @@
<div class="form-group" style="margin-top: 0.5rem; margin-bottom: 0;"> <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;"> <label style="display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0;">
<input type="checkbox" id="new-profile-seed-dj"> <input type="checkbox" id="new-profile-seed-dj">
DJ tab DJ zone
</label> </label>
</div> </div>
<div id="profiles-list" class="profiles-list"></div> <div id="profiles-list" class="profiles-list"></div>
@@ -229,7 +229,7 @@
<div class="modal-actions preset-editor-modal-actions"> <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-send-btn">Try</button>
<button class="btn btn-secondary" id="preset-default-btn">Default</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-primary" id="preset-save-btn">Save &amp; Send</button>
<button class="btn btn-secondary" id="preset-editor-close-btn">Close</button> <button class="btn btn-secondary" id="preset-editor-close-btn">Close</button>
</div> </div>
@@ -270,11 +270,11 @@
<h3>Run mode</h3> <h3>Run mode</h3>
<ul> <ul>
<li><strong>Select tab</strong>: left-click a tab button in the top bar.</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 tab.</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>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>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> <li><strong>Switch modes</strong>: use the mode button in the menu. The button label shows the mode you will switch to.</li>
</ul> </ul>
@@ -283,8 +283,8 @@
<li><strong>Tabs</strong>: create, edit, and manage tabs and device assignments.</li> <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>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>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>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> tab and can optionally seed a <strong>DJ tab</strong>.</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>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> <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> </ul>
@@ -364,11 +364,11 @@
</div> </div>
<!-- Styles moved to /static/style.css --> <!-- 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/help.js"></script>
<script src="/static/color_palette.js"></script> <script src="/static/color_palette.js"></script>
<script src="/static/profiles.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/patterns.js"></script>
<script src="/static/presets.js"></script> <script src="/static/presets.js"></script>
<script src="/static/devices.js"></script> <script src="/static/devices.js"></script>

View File

@@ -10,7 +10,7 @@ from test_preset import test_preset
from test_profile import test_profile from test_profile import test_profile
from test_group import test_group from test_group import test_group
from test_sequence import test_sequence from test_sequence import test_sequence
from test_tab import test_tab from test_zone import test_zone
from test_palette import test_palette from test_palette import test_palette
from test_device import test_device from test_device import test_device
@@ -26,7 +26,7 @@ def run_all_tests():
("Profile", test_profile), ("Profile", test_profile),
("Group", test_group), ("Group", test_group),
("Sequence", test_sequence), ("Sequence", test_sequence),
("Tab", test_tab), ("Zone", test_zone),
("Palette", test_palette), ("Palette", test_palette),
("Device", test_device), ("Device", test_device),
] ]

View File

@@ -36,7 +36,7 @@ def test_device():
mac = "aabbccddeeff" mac = "aabbccddeeff"
print("Testing create device") print("Testing create device")
device_id = devices.create("Test Device", address="aa:bb:cc:dd:ee:ff", default_pattern="on", tabs=["1", "2"]) device_id = devices.create("Test Device", address="aa:bb:cc:dd:ee:ff", default_pattern="on", zones=["1", "2"])
print(f"Created device with ID: {device_id}") print(f"Created device with ID: {device_id}")
assert device_id == mac assert device_id == mac
assert device_id in devices assert device_id in devices
@@ -51,7 +51,7 @@ def test_device():
assert device["transport"] == "espnow" assert device["transport"] == "espnow"
assert device["address"] == mac assert device["address"] == mac
assert device["default_pattern"] == "on" assert device["default_pattern"] == "on"
assert device["tabs"] == ["1", "2"] assert device["zones"] == ["1", "2"]
print("\nTesting read by colon MAC") print("\nTesting read by colon MAC")
assert devices.read("aa:bb:cc:dd:ee:ff")["id"] == mac assert devices.read("aa:bb:cc:dd:ee:ff")["id"] == mac
@@ -65,14 +65,14 @@ def test_device():
update_data = { update_data = {
"name": "Updated Device", "name": "Updated Device",
"default_pattern": "rainbow", "default_pattern": "rainbow",
"tabs": ["1", "2", "3"], "zones": ["1", "2", "3"],
} }
result = devices.update(device_id, update_data) result = devices.update(device_id, update_data)
assert result is True assert result is True
updated = devices.read(device_id) updated = devices.read(device_id)
assert updated["name"] == "Updated Device" assert updated["name"] == "Updated Device"
assert updated["default_pattern"] == "rainbow" assert updated["default_pattern"] == "rainbow"
assert len(updated["tabs"]) == 3 assert len(updated["zones"]) == 3
print("\nTesting list devices") print("\nTesting list devices")
device_list = devices.list() device_list = devices.list()

View File

@@ -3,7 +3,7 @@ import os
def test_profile(): def test_profile():
"""Test Profile model CRUD operations. """Test Profile model CRUD operations.
Profile create() sets name, type, tabs (list of tab IDs), scenes, palette_id. Profile create() sets name, type, zones (list of zone IDs), scenes, palette_id.
""" """
# Clean up any existing test file (model uses db/profile.json from project root) # Clean up any existing test file (model uses db/profile.json from project root)
db_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), "db") db_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), "db")
@@ -24,20 +24,20 @@ def test_profile():
print(f"Read: {profile}") print(f"Read: {profile}")
assert profile is not None assert profile is not None
assert profile["name"] == "test_profile" assert profile["name"] == "test_profile"
assert "tabs" in profile assert "zones" in profile
assert "palette_id" in profile assert "palette_id" in profile
assert "type" in profile assert "type" in profile
print("\nTesting update profile") print("\nTesting update profile")
update_data = { update_data = {
"name": "updated_profile", "name": "updated_profile",
"tabs": ["tab1"], "zones": ["tab1"],
} }
result = profiles.update(profile_id, update_data) result = profiles.update(profile_id, update_data)
assert result is True assert result is True
updated = profiles.read(profile_id) updated = profiles.read(profile_id)
assert updated["name"] == "updated_profile" assert updated["name"] == "updated_profile"
assert "tab1" in updated["tabs"] assert "tab1" in updated["zones"]
print("\nTesting list profiles") print("\nTesting list profiles")
profile_list = profiles.list() profile_list = profiles.list()

View File

@@ -1,57 +0,0 @@
from models.tab import Tab
import os
def test_tab():
"""Test Tab model CRUD operations."""
# Clean up any existing test file
if os.path.exists("Tab.json"):
os.remove("Tab.json")
tabs = Tab()
print("Testing create tab")
tab_id = tabs.create("test_tab", ["1", "2", "3"], ["preset1", "preset2"])
print(f"Created tab with ID: {tab_id}")
assert tab_id is not None
assert tab_id in tabs
print("\nTesting read tab")
tab = tabs.read(tab_id)
print(f"Read: {tab}")
assert tab is not None
assert tab["name"] == "test_tab"
assert len(tab["names"]) == 3
assert len(tab["presets"]) == 2
print("\nTesting update tab")
update_data = {
"name": "updated_tab",
"names": ["4", "5"],
"presets": ["preset3"]
}
result = tabs.update(tab_id, update_data)
assert result is True
updated = tabs.read(tab_id)
assert updated["name"] == "updated_tab"
assert len(updated["names"]) == 2
assert len(updated["presets"]) == 1
print("\nTesting list tabs")
tab_list = tabs.list()
print(f"Tab list: {tab_list}")
assert tab_id in tab_list
print("\nTesting delete tab")
deleted = tabs.delete(tab_id)
assert deleted is True
assert tab_id not in tabs
print("\nTesting read after delete")
tab = tabs.read(tab_id)
assert tab is None
print("\nAll tab tests passed!")
if __name__ == '__main__':
test_tab()

57
tests/models/test_zone.py Normal file
View File

@@ -0,0 +1,57 @@
from models.zone import Zone
import os
def test_zone():
"""Test Zone model CRUD operations."""
if os.path.exists("Zone.json"):
os.remove("Zone.json")
zones = Zone()
print("Testing create zone")
zone_id = zones.create("test_zone", ["1", "2", "3"], ["preset1", "preset2"])
print(f"Created zone with ID: {zone_id}")
assert zone_id is not None
assert zone_id in zones
print("\nTesting read zone")
zone = zones.read(zone_id)
print(f"Read: {zone}")
assert zone is not None
assert zone["name"] == "test_zone"
assert len(zone["names"]) == 3
assert len(zone["presets"]) == 2
print("\nTesting update zone")
update_data = {
"name": "updated_zone",
"names": ["4", "5"],
"presets": ["preset3"],
}
result = zones.update(zone_id, update_data)
assert result is True
updated = zones.read(zone_id)
assert updated["name"] == "updated_zone"
assert len(updated["names"]) == 2
assert len(updated["presets"]) == 1
print("\nTesting list zones")
zone_list = zones.list()
print(f"Zone list: {zone_list}")
assert zone_id in zone_list
print("\nTesting delete zone")
deleted = zones.delete(zone_id)
assert deleted is True
assert zone_id not in zones
print("\nTesting read after delete")
zone = zones.read(zone_id)
assert zone is None
print("\nAll zone tests passed!")
if __name__ == "__main__":
test_zone()

View File

@@ -162,13 +162,13 @@ class BrowserTest:
print(f" ⚠ Failed to cleanup preset {preset_id}: {e}") print(f" ⚠ Failed to cleanup preset {preset_id}: {e}")
# Delete created tabs by ID # Delete created tabs by ID
for tab_id in self.created_tabs: for zone_id in self.created_tabs:
try: try:
response = session.delete(f"{self.base_url}/tabs/{tab_id}") response = session.delete(f"{self.base_url}/zones/{zone_id}")
if response.status_code == 200: if response.status_code == 200:
print(f" ✓ Cleaned up tab: {tab_id}") print(f" ✓ Cleaned up zone: {zone_id}")
except Exception as e: except Exception as e:
print(f" ⚠ Failed to cleanup tab {tab_id}: {e}") print(f" ⚠ Failed to cleanup zone {zone_id}: {e}")
# Delete created profiles by ID # Delete created profiles by ID
for profile_id in self.created_profiles: for profile_id in self.created_profiles:
@@ -180,20 +180,20 @@ class BrowserTest:
print(f" ⚠ Failed to cleanup profile {profile_id}: {e}") print(f" ⚠ Failed to cleanup profile {profile_id}: {e}")
# Also try to cleanup by name pattern (in case IDs weren't tracked) # Also try to cleanup by name pattern (in case IDs weren't tracked)
test_names = ['Browser Test Tab', 'Browser Test Profile', 'Browser Test Preset', test_names = ['Browser Test Zone', 'Browser Test Profile', 'Browser Test Preset',
'Preset 1', 'Preset 2', 'Preset 3', 'Edited Browser Tab'] 'Preset 1', 'Preset 2', 'Preset 3', 'Edited Browser Zone']
# Cleanup tabs by name # Cleanup tabs by name
try: try:
tabs_response = session.get(f"{self.base_url}/tabs") tabs_response = session.get(f"{self.base_url}/zones")
if tabs_response.status_code == 200: if tabs_response.status_code == 200:
tabs_data = tabs_response.json() tabs_data = tabs_response.json()
tabs = tabs_data.get('tabs', {}) tabs = tabs_data.get('zones', {})
for tab_id, tab_data in tabs.items(): for zone_id, tab_data in zones.items():
if isinstance(tab_data, dict) and tab_data.get('name') in test_names: if isinstance(tab_data, dict) and tab_data.get('name') in test_names:
try: try:
session.delete(f"{self.base_url}/tabs/{tab_id}") session.delete(f"{self.base_url}/zones/{zone_id}")
print(f" ✓ Cleaned up tab by name: {tab_data.get('name')}") print(f" ✓ Cleaned up zone by name: {tab_data.get('name')}")
except: except:
pass pass
except: except:
@@ -330,11 +330,11 @@ def test_tabs_ui(browser: BrowserTest) -> bool:
# Test 2: Open tabs modal # Test 2: Open tabs modal
total += 1 total += 1
if browser.click_element(By.ID, 'tabs-btn'): if browser.click_element(By.ID, 'zones-btn'):
print("✓ Clicked Tabs button") print("✓ Clicked Tabs button")
# Wait for modal to appear # Wait for modal to appear
time.sleep(0.5) time.sleep(0.5)
modal = browser.wait_for_element(By.ID, 'tabs-modal') modal = browser.wait_for_element(By.ID, 'zones-modal')
if modal and 'active' in modal.get_attribute('class'): if modal and 'active' in modal.get_attribute('class'):
print("✓ Tabs modal opened") print("✓ Tabs modal opened")
passed += 1 passed += 1
@@ -343,58 +343,58 @@ def test_tabs_ui(browser: BrowserTest) -> bool:
else: else:
print("✗ Failed to click Tabs button") print("✗ Failed to click Tabs button")
# Test 3: Create a tab via UI # Test 3: Create a zone via UI
total += 1 total += 1
try: try:
# Fill in tab name # Fill in zone name
if browser.fill_input(By.ID, 'new-tab-name', 'Browser Test Tab'): if browser.fill_input(By.ID, 'new-zone-name', 'Browser Test Zone'):
print(" ✓ Filled tab name") print(" ✓ Filled zone name")
# Devices default from registry or placeholder name "1" # Devices default from registry or placeholder name "1"
# Click create button # Click create button
if browser.click_element(By.ID, 'create-tab-btn'): if browser.click_element(By.ID, 'create-zone-btn'):
print(" ✓ Clicked create button") print(" ✓ Clicked create button")
time.sleep(1) # Wait for creation time.sleep(1) # Wait for creation
# Check if tab appears in list and extract ID # Check if zone appears in list and extract ID
tabs_list = browser.wait_for_element(By.ID, 'tabs-list-modal') tabs_list = browser.wait_for_element(By.ID, 'zones-list-modal')
if tabs_list: if tabs_list:
list_text = tabs_list.text list_text = tabs_list.text
if 'Browser Test Tab' in list_text: if 'Browser Test Zone' in list_text:
print("✓ Created tab via UI") print("✓ Created zone via UI")
# Try to extract tab ID from the list (look for data-tab-id attribute) # Try to extract zone ID from the list (look for data-zone-id attribute)
try: try:
tab_rows = browser.driver.find_elements(By.CSS_SELECTOR, '#tabs-list-modal .profiles-row') tab_rows = browser.driver.find_elements(By.CSS_SELECTOR, '#zones-list-modal .profiles-row')
for row in tab_rows: for row in tab_rows:
if 'Browser Test Tab' in row.text: if 'Browser Test Zone' in row.text:
tab_id = row.get_attribute('data-tab-id') zone_id = row.get_attribute('data-zone-id')
if tab_id: if zone_id:
browser.created_tabs.append(tab_id) browser.created_tabs.append(zone_id)
break break
except: except:
pass # If we can't extract ID, cleanup will try by name pass # If we can't extract ID, cleanup will try by name
passed += 1 passed += 1
else: else:
print("Tab not found in list after creation") print("Zone not found in list after creation")
else: else:
print("✗ Tabs list not found") print("✗ Tabs list not found")
else: else:
print("✗ Failed to click create button") print("✗ Failed to click create button")
except Exception as e: except Exception as e:
print(f"✗ Failed to create tab via UI: {e}") print(f"✗ Failed to create zone via UI: {e}")
# Test 4: Edit a tab via UI (right-click in Tabs list) # Test 4: Edit a zone via UI (right-click in Tabs list)
total += 1 total += 1
try: try:
# First, close and reopen modal to refresh # First, close and reopen modal to refresh
browser.click_element(By.ID, 'tabs-close-btn') browser.click_element(By.ID, 'zones-close-btn')
time.sleep(0.5) time.sleep(0.5)
browser.click_element(By.ID, 'tabs-btn') browser.click_element(By.ID, 'zones-btn')
time.sleep(0.5) time.sleep(0.5)
# Right-click the row corresponding to 'Browser Test Tab' # Right-click the row corresponding to 'Browser Test Zone'
try: try:
tab_row = browser.driver.find_element( tab_row = browser.driver.find_element(
By.XPATH, By.XPATH,
"//div[@id='tabs-list-modal']//div[contains(@class,'profiles-row')][.//span[contains(text(), 'Browser Test Tab')]]" "//div[@id='zones-list-modal']//div[contains(@class,'profiles-row')][.//span[contains(text(), 'Browser Test Zone')]]"
) )
except Exception: except Exception:
tab_row = None tab_row = None
@@ -405,14 +405,14 @@ def test_tabs_ui(browser: BrowserTest) -> bool:
time.sleep(0.5) time.sleep(0.5)
# Check if edit modal opened # Check if edit modal opened
edit_modal = browser.wait_for_element(By.ID, 'edit-tab-modal') edit_modal = browser.wait_for_element(By.ID, 'edit-zone-modal')
if edit_modal: if edit_modal:
print("✓ Edit modal opened via right-click") print("✓ Edit modal opened via right-click")
# Fill in new name # Fill in new name
if browser.fill_input(By.ID, 'edit-tab-name', 'Edited Browser Tab'): if browser.fill_input(By.ID, 'edit-zone-name', 'Edited Browser Zone'):
print(" ✓ Filled new tab name") print(" ✓ Filled new zone name")
# Submit form # Submit form
edit_form = browser.wait_for_element(By.ID, 'edit-tab-form') edit_form = browser.wait_for_element(By.ID, 'edit-zone-form')
if edit_form: if edit_form:
browser.driver.execute_script("arguments[0].submit();", edit_form) browser.driver.execute_script("arguments[0].submit();", edit_form)
time.sleep(1) # Wait for update time.sleep(1) # Wait for update
@@ -423,24 +423,24 @@ def test_tabs_ui(browser: BrowserTest) -> bool:
else: else:
print("✗ Edit modal didn't open after right-click") print("✗ Edit modal didn't open after right-click")
else: else:
print("✗ Could not find tab row for 'Browser Test Tab'") print("✗ Could not find zone row for 'Browser Test Zone'")
except Exception as e: except Exception as e:
print(f"✗ Failed to edit tab via UI: {e}") print(f"✗ Failed to edit zone via UI: {e}")
import traceback import traceback
traceback.print_exc() traceback.print_exc()
# Test 5: Check current tab cookie # Test 5: Check current zone cookie
total += 1 total += 1
cookie = browser.get_cookie('current_tab') cookie = browser.get_cookie('current_zone')
if cookie: if cookie:
print(f"✓ Found current_tab cookie: {cookie.get('value')}") print(f"✓ Found current_zone cookie: {cookie.get('value')}")
passed += 1 passed += 1
else: else:
print("⚠ No current_tab cookie found (might be normal if no tab selected)") print("⚠ No current_zone cookie found (might be normal if no zone selected)")
passed += 1 # Not a failure, just informational passed += 1 # Not a failure, just informational
# Close modal # Close modal
browser.click_element(By.ID, 'tabs-close-btn') browser.click_element(By.ID, 'zones-close-btn')
except Exception as e: except Exception as e:
print(f"✗ Browser test error: {e}") print(f"✗ Browser test error: {e}")
@@ -519,7 +519,7 @@ def test_profiles_ui(browser: BrowserTest) -> bool:
def test_mobile_tab_presets_two_columns(): def test_mobile_tab_presets_two_columns():
""" """
Verify that the tab preset selecting area shows roughly two preset tiles per row Verify that the zone preset selecting area shows roughly two preset tiles per row
on a phone-sized viewport. on a phone-sized viewport.
""" """
bt = BrowserTest(base_url=BASE_URL, headless=True) bt = BrowserTest(base_url=BASE_URL, headless=True)
@@ -531,18 +531,18 @@ def test_mobile_tab_presets_two_columns():
bt.driver.set_window_size(400, 800) bt.driver.set_window_size(400, 800)
assert bt.navigate('/'), "Failed to load main page" assert bt.navigate('/'), "Failed to load main page"
# Click the first tab button to load presets for that tab # Click the first zone button to load presets for that zone
first_tab = bt.wait_for_element(By.CSS_SELECTOR, '.tab-button', timeout=10) first_tab = bt.wait_for_element(By.CSS_SELECTOR, '.zone-button', timeout=10)
assert first_tab is not None, "No tab buttons found" assert first_tab is not None, "No zone buttons found"
first_tab.click() first_tab.click()
time.sleep(1) time.sleep(1)
container = bt.wait_for_element(By.ID, 'presets-list-tab', timeout=10) container = bt.wait_for_element(By.ID, 'presets-list-zone', timeout=10)
assert container is not None, "presets-list-tab not found" assert container is not None, "presets-list-zone not found"
tiles = bt.driver.find_elements(By.CSS_SELECTOR, '#presets-list-tab .preset-tile-row') tiles = bt.driver.find_elements(By.CSS_SELECTOR, '#presets-list-zone .preset-tile-row')
# Need at least 2 presets to make this meaningful # Need at least 2 presets to make this meaningful
assert len(tiles) >= 2, "Fewer than 2 presets found for tab" assert len(tiles) >= 2, "Fewer than 2 presets found for zone"
container_width = container.size['width'] container_width = container.size['width']
first_width = tiles[0].size['width'] first_width = tiles[0].size['width']
@@ -760,8 +760,8 @@ def test_color_palette_ui(browser: BrowserTest) -> bool:
return passed >= total - 1 # Allow one failure (alert handling might be flaky) return passed >= total - 1 # Allow one failure (alert handling might be flaky)
def test_preset_drag_and_drop(browser: BrowserTest) -> bool: def test_preset_drag_and_drop(browser: BrowserTest) -> bool:
"""Test dragging presets around in a tab.""" """Test dragging presets around in a zone."""
print("\n=== Testing Preset Drag and Drop in Tab ===") print("\n=== Testing Preset Drag and Drop in Zone ===")
passed = 0 passed = 0
total = 0 total = 0
@@ -769,7 +769,7 @@ def test_preset_drag_and_drop(browser: BrowserTest) -> bool:
return False return False
try: try:
# Test 1: Load page and ensure we have a tab # Test 1: Load page and ensure we have a zone
total += 1 total += 1
if browser.navigate('/'): if browser.navigate('/'):
print("✓ Loaded main page") print("✓ Loaded main page")
@@ -778,33 +778,33 @@ def test_preset_drag_and_drop(browser: BrowserTest) -> bool:
browser.teardown() browser.teardown()
return False return False
# Test 2: Open tabs modal and create/select a tab # Test 2: Open tabs modal and create/select a zone
total += 1 total += 1
browser.click_element(By.ID, 'tabs-btn') browser.click_element(By.ID, 'zones-btn')
time.sleep(0.5) time.sleep(0.5)
# Check if we have tabs, if not create one # Check if we have tabs, if not create one
tabs_list = browser.wait_for_element(By.ID, 'tabs-list-modal') tabs_list = browser.wait_for_element(By.ID, 'zones-list-modal')
if tabs_list and 'No tabs found' in tabs_list.text: if tabs_list and 'No tabs found' in tabs_list.text:
# Create a tab # Create a zone
browser.fill_input(By.ID, 'new-tab-name', 'Drag Test Tab') browser.fill_input(By.ID, 'new-zone-name', 'Drag Test Zone')
browser.click_element(By.ID, 'create-tab-btn') browser.click_element(By.ID, 'create-zone-btn')
time.sleep(1) time.sleep(1)
# Select first tab (or the one we just created) # Select first zone (or the one we just created)
select_buttons = browser.driver.find_elements(By.XPATH, "//button[contains(text(), 'Select')]") select_buttons = browser.driver.find_elements(By.XPATH, "//button[contains(text(), 'Select')]")
if select_buttons: if select_buttons:
select_buttons[0].click() select_buttons[0].click()
time.sleep(1) time.sleep(1)
print("✓ Selected a tab") print("✓ Selected a zone")
passed += 1 passed += 1
else: else:
print("✗ No tabs available to select") print("✗ No tabs available to select")
browser.click_element(By.ID, 'tabs-close-btn') browser.click_element(By.ID, 'zones-close-btn')
browser.teardown() browser.teardown()
return False return False
browser.click_element(By.ID, 'tabs-close-btn', use_js=True) browser.click_element(By.ID, 'zones-close-btn', use_js=True)
time.sleep(0.5) time.sleep(0.5)
# Test 3: Open presets modal and create presets # Test 3: Open presets modal and create presets
@@ -845,54 +845,54 @@ def test_preset_drag_and_drop(browser: BrowserTest) -> bool:
print("✓ Created 3 presets for drag test") print("✓ Created 3 presets for drag test")
passed += 1 passed += 1
# Test 4: Add presets to the tab (via Edit Tab modal Add buttons in list) # Test 4: Add presets to the zone (via Edit Zone modal Add buttons in list)
total += 1 total += 1
try: try:
tab_id = browser.driver.execute_script( zone_id = browser.driver.execute_script(
"return (window.tabsManager && window.tabsManager.getCurrentTabId && window.tabsManager.getCurrentTabId()) || null;" "return (window.tabsManager && window.tabsManager.getCurrentTabId && window.tabsManager.getCurrentTabId()) || null;"
) )
if not tab_id: if not zone_id:
print("✗ Could not get current tab id") print("✗ Could not get current zone id")
else: else:
browser.driver.execute_script( browser.driver.execute_script(
"if (window.tabsManager && window.tabsManager.openEditTabModal) { window.tabsManager.openEditTabModal(arguments[0], null); }", "if (window.tabsManager && window.tabsManager.openEditTabModal) { window.tabsManager.openEditTabModal(arguments[0], null); }",
tab_id zone_id
) )
time.sleep(1) time.sleep(1)
list_el = browser.wait_for_element(By.ID, 'edit-tab-presets-list', timeout=5) list_el = browser.wait_for_element(By.ID, 'edit-zone-presets-list', timeout=5)
if list_el: if list_el:
select_buttons = browser.driver.find_elements(By.XPATH, "//div[@id='edit-tab-presets-list']//button[text()='Add']") select_buttons = browser.driver.find_elements(By.XPATH, "//div[@id='edit-zone-presets-list']//button[text()='Add']")
if len(select_buttons) >= 2: if len(select_buttons) >= 2:
browser.driver.execute_script("arguments[0].click();", select_buttons[0]) browser.driver.execute_script("arguments[0].click();", select_buttons[0])
time.sleep(1.5) time.sleep(1.5)
browser.handle_alert(accept=True, timeout=1) browser.handle_alert(accept=True, timeout=1)
select_buttons = browser.driver.find_elements(By.XPATH, "//div[@id='edit-tab-presets-list']//button[text()='Add']") select_buttons = browser.driver.find_elements(By.XPATH, "//div[@id='edit-zone-presets-list']//button[text()='Add']")
if len(select_buttons) >= 1: if len(select_buttons) >= 1:
browser.driver.execute_script("arguments[0].click();", select_buttons[0]) browser.driver.execute_script("arguments[0].click();", select_buttons[0])
time.sleep(1.5) time.sleep(1.5)
browser.handle_alert(accept=True, timeout=1) browser.handle_alert(accept=True, timeout=1)
print(" ✓ Added 2 presets to tab") print(" ✓ Added 2 presets to zone")
passed += 1 passed += 1
elif len(select_buttons) == 1: elif len(select_buttons) == 1:
browser.driver.execute_script("arguments[0].click();", select_buttons[0]) browser.driver.execute_script("arguments[0].click();", select_buttons[0])
time.sleep(1.5) time.sleep(1.5)
browser.handle_alert(accept=True, timeout=1) browser.handle_alert(accept=True, timeout=1)
print(" ✓ Added 1 preset to tab") print(" ✓ Added 1 preset to zone")
passed += 1 passed += 1
else: else:
print(" ⚠ No presets available to add (all already in tab)") print(" ⚠ No presets available to add (all already in zone)")
else: else:
print("✗ Edit tab presets list not found") print("✗ Edit zone presets list not found")
except Exception as e: except Exception as e:
print(f"✗ Failed to add presets to tab: {e}") print(f"✗ Failed to add presets to zone: {e}")
import traceback import traceback
traceback.print_exc() traceback.print_exc()
# Test 5: Find presets in tab and test drag and drop (Edit mode only) # Test 5: Find presets in zone and test drag and drop (Edit mode only)
total += 1 total += 1
try: try:
# Wait for presets to load in the tab # Wait for presets to load in the zone
presets_list_tab = browser.wait_for_element(By.ID, 'presets-list-tab', timeout=5) presets_list_tab = browser.wait_for_element(By.ID, 'presets-list-zone', timeout=5)
if presets_list_tab: if presets_list_tab:
time.sleep(1) # Wait for presets to render time.sleep(1) # Wait for presets to render
@@ -904,7 +904,7 @@ def test_preset_drag_and_drop(browser: BrowserTest) -> bool:
# Find draggable preset elements - wait a bit more for rendering # Find draggable preset elements - wait a bit more for rendering
time.sleep(1) time.sleep(1)
draggable_presets = browser.driver.find_elements(By.CSS_SELECTOR, '#presets-list-tab .draggable-preset') draggable_presets = browser.driver.find_elements(By.CSS_SELECTOR, '#presets-list-zone .draggable-preset')
if len(draggable_presets) >= 2: if len(draggable_presets) >= 2:
print(f" ✓ Found {len(draggable_presets)} draggable presets") print(f" ✓ Found {len(draggable_presets)} draggable presets")
@@ -922,7 +922,7 @@ def test_preset_drag_and_drop(browser: BrowserTest) -> bool:
time.sleep(1) # Wait for reorder to complete time.sleep(1) # Wait for reorder to complete
# Check if order changed # Check if order changed
draggable_presets_after = browser.driver.find_elements(By.CSS_SELECTOR, '#presets-list-tab .draggable-preset') draggable_presets_after = browser.driver.find_elements(By.CSS_SELECTOR, '#presets-list-zone .draggable-preset')
if len(draggable_presets_after) >= 2: if len(draggable_presets_after) >= 2:
new_order = [p.text for p in draggable_presets_after] new_order = [p.text for p in draggable_presets_after]
print(f" New order: {new_order[:3]}") print(f" New order: {new_order[:3]}")
@@ -936,28 +936,28 @@ def test_preset_drag_and_drop(browser: BrowserTest) -> bool:
else: else:
print("✗ Presets disappeared after drag") print("✗ Presets disappeared after drag")
elif len(draggable_presets) == 1: elif len(draggable_presets) == 1:
print(f"⚠ Only 1 preset found in tab (need 2 for drag test). Preset: {draggable_presets[0].text}") print(f"⚠ Only 1 preset found in zone (need 2 for drag test). Preset: {draggable_presets[0].text}")
tab_id = browser.driver.execute_script( zone_id = browser.driver.execute_script(
"return (window.tabsManager && window.tabsManager.getCurrentTabId && window.tabsManager.getCurrentTabId()) || null;" "return (window.tabsManager && window.tabsManager.getCurrentTabId && window.tabsManager.getCurrentTabId()) || null;"
) )
if tab_id: if zone_id:
browser.driver.execute_script( browser.driver.execute_script(
"if (window.tabsManager && window.tabsManager.openEditTabModal) { window.tabsManager.openEditTabModal(arguments[0], null); }", "if (window.tabsManager && window.tabsManager.openEditTabModal) { window.tabsManager.openEditTabModal(arguments[0], null); }",
tab_id zone_id
) )
time.sleep(1) time.sleep(1)
select_buttons = browser.driver.find_elements(By.XPATH, "//div[@id='edit-tab-presets-list']//button[text()='Add']") select_buttons = browser.driver.find_elements(By.XPATH, "//div[@id='edit-zone-presets-list']//button[text()='Add']")
if select_buttons: if select_buttons:
print(" Attempting to add another preset...") print(" Attempting to add another preset...")
browser.driver.execute_script("arguments[0].click();", select_buttons[0]) browser.driver.execute_script("arguments[0].click();", select_buttons[0])
time.sleep(1.5) time.sleep(1.5)
browser.handle_alert(accept=True, timeout=1) browser.handle_alert(accept=True, timeout=1)
try: try:
browser.driver.execute_script("document.getElementById('edit-tab-modal').classList.remove('active');") browser.driver.execute_script("document.getElementById('edit-zone-modal').classList.remove('active');")
except Exception: except Exception:
pass pass
time.sleep(1) time.sleep(1)
draggable_presets = browser.driver.find_elements(By.CSS_SELECTOR, '#presets-list-tab .draggable-preset') draggable_presets = browser.driver.find_elements(By.CSS_SELECTOR, '#presets-list-zone .draggable-preset')
if len(draggable_presets) >= 2: if len(draggable_presets) >= 2:
print(" ✓ Added another preset, now testing drag...") print(" ✓ Added another preset, now testing drag...")
source = draggable_presets[0] source = draggable_presets[0]
@@ -970,11 +970,11 @@ def test_preset_drag_and_drop(browser: BrowserTest) -> bool:
else: else:
print(f" ✗ Still only {len(draggable_presets)} preset(s) after adding") print(f" ✗ Still only {len(draggable_presets)} preset(s) after adding")
else: else:
print(" ✗ No Add buttons found in Edit Tab modal") print(" ✗ No Add buttons found in Edit Zone modal")
else: else:
print(f"✗ No presets found in tab (found {len(draggable_presets)})") print(f"✗ No presets found in zone (found {len(draggable_presets)})")
else: else:
print("✗ Presets list in tab not found") print("✗ Presets list in zone not found")
except Exception as e: except Exception as e:
print(f"✗ Drag and drop test error: {e}") print(f"✗ Drag and drop test error: {e}")
import traceback import traceback

View File

@@ -91,115 +91,115 @@ def test_tabs(client: TestClient) -> bool:
# Test 1: List tabs # Test 1: List tabs
total += 1 total += 1
try: try:
response = client.get('/tabs') response = client.get('/zones')
if response.status_code == 200: if response.status_code == 200:
data = response.json() data = response.json()
print(f"✓ GET /tabs - Found {len(data.get('tabs', {}))} tabs") print(f"✓ GET /zones - Found {len(data.get('zones', {}))} tabs")
passed += 1 passed += 1
else: else:
print(f"✗ GET /tabs - Status: {response.status_code}") print(f"✗ GET /zones - Status: {response.status_code}")
except Exception as e: except Exception as e:
print(f"✗ GET /tabs - Error: {e}") print(f"✗ GET /zones - Error: {e}")
# Test 2: Create tab # Test 2: Create zone
total += 1 total += 1
try: try:
tab_data = { tab_data = {
"name": "Test Tab", "name": "Test Zone",
"names": ["1", "2"] "names": ["1", "2"]
} }
response = client.post('/tabs', json_data=tab_data) response = client.post('/zones', json_data=tab_data)
if response.status_code == 201: if response.status_code == 201:
created_tab = response.json() created_tab = response.json()
# Response format: {tab_id: {tab_data}} # Response format: {zone_id: {tab_data}}
if isinstance(created_tab, dict): if isinstance(created_tab, dict):
# Get the first key which should be the tab ID # Get the first key which should be the zone ID
tab_id = next(iter(created_tab.keys())) if created_tab else None zone_id = next(iter(created_tab.keys())) if created_tab else None
else: else:
tab_id = None zone_id = None
print(f"✓ POST /tabs - Created tab: {tab_id}") print(f"✓ POST /zones - Created zone: {zone_id}")
passed += 1 passed += 1
# Test 3: Get specific tab # Test 3: Get specific zone
if tab_id: if zone_id:
total += 1 total += 1
response = client.get(f'/tabs/{tab_id}') response = client.get(f'/zones/{zone_id}')
if response.status_code == 200: if response.status_code == 200:
print(f"✓ GET /tabs/{tab_id} - Retrieved tab") print(f"✓ GET /zones/{zone_id} - Retrieved zone")
passed += 1 passed += 1
else: else:
print(f"✗ GET /tabs/{tab_id} - Status: {response.status_code}") print(f"✗ GET /zones/{zone_id} - Status: {response.status_code}")
# Test 4: Set current tab # Test 4: Set current zone
total += 1 total += 1
response = client.post(f'/tabs/{tab_id}/set-current') response = client.post(f'/zones/{zone_id}/set-current')
if response.status_code == 200: if response.status_code == 200:
print(f"✓ POST /tabs/{tab_id}/set-current - Set current tab") print(f"✓ POST /zones/{zone_id}/set-current - Set current zone")
# Check cookie was set # Check cookie was set
cookie = client.get_cookie('current_tab') cookie = client.get_cookie('current_zone')
if cookie == tab_id: if cookie == zone_id:
print(f" ✓ Cookie 'current_tab' set to {tab_id}") print(f" ✓ Cookie 'current_zone' set to {zone_id}")
passed += 1 passed += 1
else: else:
print(f"✗ POST /tabs/{tab_id}/set-current - Status: {response.status_code}") print(f"✗ POST /zones/{zone_id}/set-current - Status: {response.status_code}")
# Test 5: Get current tab # Test 5: Get current zone
total += 1 total += 1
response = client.get('/tabs/current') response = client.get('/zones/current')
if response.status_code == 200: if response.status_code == 200:
data = response.json() data = response.json()
if data.get('tab_id') == tab_id: if data.get('zone_id') == zone_id:
print(f"✓ GET /tabs/current - Current tab is {tab_id}") print(f"✓ GET /zones/current - Current zone is {zone_id}")
passed += 1 passed += 1
else: else:
print(f"✗ GET /tabs/current - Wrong tab ID") print(f"✗ GET /zones/current - Wrong zone ID")
else: else:
print(f"✗ GET /tabs/current - Status: {response.status_code}") print(f"✗ GET /zones/current - Status: {response.status_code}")
# Test 6: Update tab (edit functionality) # Test 6: Update zone (edit functionality)
total += 1 total += 1
update_data = { update_data = {
"name": "Updated Test Tab", "name": "Updated Test Zone",
"names": ["1", "2", "3"] # Update device IDs too "names": ["1", "2", "3"] # Update device IDs too
} }
response = client.put(f'/tabs/{tab_id}', json_data=update_data) response = client.put(f'/zones/{zone_id}', json_data=update_data)
if response.status_code == 200: if response.status_code == 200:
updated = response.json() updated = response.json()
if updated.get('name') == "Updated Test Tab" and updated.get('names') == ["1", "2", "3"]: if updated.get('name') == "Updated Test Zone" and updated.get('names') == ["1", "2", "3"]:
print(f"✓ PUT /tabs/{tab_id} - Updated tab (name and device IDs)") print(f"✓ PUT /zones/{zone_id} - Updated zone (name and device IDs)")
passed += 1 passed += 1
else: else:
print(f"✗ PUT /tabs/{tab_id} - Update didn't work correctly") print(f"✗ PUT /zones/{zone_id} - Update didn't work correctly")
print(f" Expected name='Updated Test Tab', got '{updated.get('name')}'") print(f" Expected name='Updated Test Zone', got '{updated.get('name')}'")
print(f" Expected names=['1','2','3'], got {updated.get('names')}") print(f" Expected names=['1','2','3'], got {updated.get('names')}")
else: else:
print(f"✗ PUT /tabs/{tab_id} - Status: {response.status_code}, Response: {response.text}") print(f"✗ PUT /zones/{zone_id} - Status: {response.status_code}, Response: {response.text}")
# Test 6b: Verify update persisted # Test 6b: Verify update persisted
total += 1 total += 1
response = client.get(f'/tabs/{tab_id}') response = client.get(f'/zones/{zone_id}')
if response.status_code == 200: if response.status_code == 200:
verified = response.json() verified = response.json()
if verified.get('name') == "Updated Test Tab": if verified.get('name') == "Updated Test Zone":
print(f"✓ GET /tabs/{tab_id} - Verified update persisted") print(f"✓ GET /zones/{zone_id} - Verified update persisted")
passed += 1 passed += 1
else: else:
print(f"✗ GET /tabs/{tab_id} - Update didn't persist") print(f"✗ GET /zones/{zone_id} - Update didn't persist")
else: else:
print(f"✗ GET /tabs/{tab_id} - Status: {response.status_code}") print(f"✗ GET /zones/{zone_id} - Status: {response.status_code}")
# Test 7: Delete tab # Test 7: Delete zone
total += 1 total += 1
response = client.delete(f'/tabs/{tab_id}') response = client.delete(f'/zones/{zone_id}')
if response.status_code == 200: if response.status_code == 200:
print(f"✓ DELETE /tabs/{tab_id} - Deleted tab") print(f"✓ DELETE /zones/{zone_id} - Deleted zone")
passed += 1 passed += 1
else: else:
print(f"✗ DELETE /tabs/{tab_id} - Status: {response.status_code}") print(f"✗ DELETE /zones/{zone_id} - Status: {response.status_code}")
else: else:
print(f"✗ POST /tabs - Status: {response.status_code}, Response: {response.text}") print(f"✗ POST /zones - Status: {response.status_code}, Response: {response.text}")
except Exception as e: except Exception as e:
print(f"✗ POST /tabs - Error: {e}") print(f"✗ POST /zones - Error: {e}")
import traceback import traceback
traceback.print_exc() traceback.print_exc()
@@ -409,87 +409,87 @@ def test_patterns(client: TestClient) -> bool:
return passed == total return passed == total
def test_tab_edit_workflow(client: TestClient) -> bool: def test_tab_edit_workflow(client: TestClient) -> bool:
"""Test complete tab edit workflow like a browser would.""" """Test complete zone edit workflow like a browser would."""
print("\n=== Testing Tab Edit Workflow ===") print("\n=== Testing Zone Edit Workflow ===")
passed = 0 passed = 0
total = 0 total = 0
# Step 1: Create a tab to edit # Step 1: Create a zone to edit
total += 1 total += 1
try: try:
tab_data = { tab_data = {
"name": "Tab to Edit", "name": "Zone to Edit",
"names": ["1"] "names": ["1"]
} }
response = client.post('/tabs', json_data=tab_data) response = client.post('/zones', json_data=tab_data)
if response.status_code == 201: if response.status_code == 201:
created = response.json() created = response.json()
if isinstance(created, dict): if isinstance(created, dict):
tab_id = next(iter(created.keys())) if created else None zone_id = next(iter(created.keys())) if created else None
else: else:
tab_id = None zone_id = None
if tab_id: if zone_id:
print(f"✓ Created tab {tab_id} for editing") print(f"✓ Created zone {zone_id} for editing")
passed += 1 passed += 1
# Step 2: Get the tab to verify initial state # Step 2: Get the zone to verify initial state
total += 1 total += 1
response = client.get(f'/tabs/{tab_id}') response = client.get(f'/zones/{zone_id}')
if response.status_code == 200: if response.status_code == 200:
original_tab = response.json() original_tab = response.json()
print(f"✓ Retrieved tab - Name: '{original_tab.get('name')}', IDs: {original_tab.get('names')}") print(f"✓ Retrieved zone - Name: '{original_tab.get('name')}', IDs: {original_tab.get('names')}")
passed += 1 passed += 1
# Step 3: Edit the tab (simulate browser edit form submission) # Step 3: Edit the zone (simulate browser edit form submission)
total += 1 total += 1
edit_data = { edit_data = {
"name": "Edited Tab Name", "name": "Edited Zone Name",
"names": ["2", "3", "4"] "names": ["2", "3", "4"]
} }
response = client.put(f'/tabs/{tab_id}', json_data=edit_data) response = client.put(f'/zones/{zone_id}', json_data=edit_data)
if response.status_code == 200: if response.status_code == 200:
edited = response.json() edited = response.json()
if edited.get('name') == "Edited Tab Name" and edited.get('names') == ["2", "3", "4"]: if edited.get('name') == "Edited Zone Name" and edited.get('names') == ["2", "3", "4"]:
print(f"✓ PUT /tabs/{tab_id} - Successfully edited tab") print(f"✓ PUT /zones/{zone_id} - Successfully edited zone")
print(f" New name: '{edited.get('name')}'") print(f" New name: '{edited.get('name')}'")
print(f" New device IDs: {edited.get('names')}") print(f" New device IDs: {edited.get('names')}")
passed += 1 passed += 1
else: else:
print(f"✗ PUT /tabs/{tab_id} - Edit didn't work correctly") print(f"✗ PUT /zones/{zone_id} - Edit didn't work correctly")
print(f" Got: {edited}") print(f" Got: {edited}")
else: else:
print(f"✗ PUT /tabs/{tab_id} - Status: {response.status_code}, Response: {response.text}") print(f"✗ PUT /zones/{zone_id} - Status: {response.status_code}, Response: {response.text}")
# Step 4: Verify edit persisted by getting the tab again # Step 4: Verify edit persisted by getting the zone again
total += 1 total += 1
response = client.get(f'/tabs/{tab_id}') response = client.get(f'/zones/{zone_id}')
if response.status_code == 200: if response.status_code == 200:
verified = response.json() verified = response.json()
if verified.get('name') == "Edited Tab Name" and verified.get('names') == ["2", "3", "4"]: if verified.get('name') == "Edited Zone Name" and verified.get('names') == ["2", "3", "4"]:
print(f"✓ GET /tabs/{tab_id} - Verified edit persisted") print(f"✓ GET /zones/{zone_id} - Verified edit persisted")
passed += 1 passed += 1
else: else:
print(f"✗ GET /tabs/{tab_id} - Edit didn't persist") print(f"✗ GET /zones/{zone_id} - Edit didn't persist")
print(f" Expected name='Edited Tab Name', got '{verified.get('name')}'") print(f" Expected name='Edited Zone Name', got '{verified.get('name')}'")
print(f" Expected names=['2','3','4'], got {verified.get('names')}") print(f" Expected names=['2','3','4'], got {verified.get('names')}")
else: else:
print(f"✗ GET /tabs/{tab_id} - Status: {response.status_code}") print(f"✗ GET /zones/{zone_id} - Status: {response.status_code}")
# Step 5: Clean up - delete the test tab # Step 5: Clean up - delete the test zone
total += 1 total += 1
response = client.delete(f'/tabs/{tab_id}') response = client.delete(f'/zones/{zone_id}')
if response.status_code == 200: if response.status_code == 200:
print(f"✓ DELETE /tabs/{tab_id} - Cleaned up test tab") print(f"✓ DELETE /zones/{zone_id} - Cleaned up test zone")
passed += 1 passed += 1
else: else:
print(f"✗ DELETE /tabs/{tab_id} - Status: {response.status_code}") print(f"✗ DELETE /zones/{zone_id} - Status: {response.status_code}")
else: else:
print(f"✗ Failed to extract tab ID from create response") print(f"✗ Failed to extract zone ID from create response")
else: else:
print(f"✗ POST /tabs - Status: {response.status_code}, Response: {response.text}") print(f"✗ POST /zones - Status: {response.status_code}, Response: {response.text}")
except Exception as e: except Exception as e:
print(f"Tab edit workflow - Error: {e}") print(f"Zone edit workflow - Error: {e}")
import traceback import traceback
traceback.print_exc() traceback.print_exc()
@@ -505,7 +505,7 @@ def test_static_files(client: TestClient) -> bool:
static_files = [ static_files = [
'/static/style.css', '/static/style.css',
'/static/app.js', '/static/app.js',
'/static/tabs.js', '/static/zones.js',
'/static/presets.js', '/static/presets.js',
'/static/profiles.js', '/static/profiles.js',
'/static/devices.js', '/static/devices.js',
@@ -544,7 +544,7 @@ def main():
# Run all tests # Run all tests
results.append(("Tabs", test_tabs(client))) results.append(("Tabs", test_tabs(client)))
results.append(("Tab Edit Workflow", test_tab_edit_workflow(client))) results.append(("Zone Edit Workflow", test_tab_edit_workflow(client)))
results.append(("Profiles", test_profiles(client))) results.append(("Profiles", test_profiles(client)))
results.append(("Presets", test_presets(client))) results.append(("Presets", test_presets(client)))
results.append(("Patterns", test_patterns(client))) results.append(("Patterns", test_patterns(client)))

View File

@@ -119,7 +119,7 @@ def server(monkeypatch, tmp_path_factory):
import models.preset as models_preset # noqa: E402 import models.preset as models_preset # noqa: E402
import models.profile as models_profile # noqa: E402 import models.profile as models_profile # noqa: E402
import models.group as models_group # noqa: E402 import models.group as models_group # noqa: E402
import models.tab as models_tab # noqa: E402 import models.zone as models_tab # noqa: E402
import models.pallet as models_pallet # noqa: E402 import models.pallet as models_pallet # noqa: E402
import models.scene as models_scene # noqa: E402 import models.scene as models_scene # noqa: E402
import models.pattern as models_pattern # noqa: E402 import models.pattern as models_pattern # noqa: E402
@@ -130,7 +130,7 @@ def server(monkeypatch, tmp_path_factory):
models_preset.Preset, models_preset.Preset,
models_profile.Profile, models_profile.Profile,
models_group.Group, models_group.Group,
models_tab.Tab, models_tab.Zone,
models_pallet.Palette, models_pallet.Palette,
models_scene.Scene, models_scene.Scene,
models_pattern.Pattern, models_pattern.Pattern,
@@ -164,7 +164,7 @@ def server(monkeypatch, tmp_path_factory):
"controllers.profile", "controllers.profile",
"controllers.group", "controllers.group",
"controllers.sequence", "controllers.sequence",
"controllers.tab", "controllers.zone",
"controllers.palette", "controllers.palette",
"controllers.scene", "controllers.scene",
"controllers.pattern", "controllers.pattern",
@@ -178,7 +178,7 @@ def server(monkeypatch, tmp_path_factory):
import controllers.profile as profile_ctl # noqa: E402 import controllers.profile as profile_ctl # noqa: E402
import controllers.group as group_ctl # noqa: E402 import controllers.group as group_ctl # noqa: E402
import controllers.sequence as sequence_ctl # noqa: E402 import controllers.sequence as sequence_ctl # noqa: E402
import controllers.tab as tab_ctl # noqa: E402 import controllers.zone as zone_ctl # noqa: E402
import controllers.palette as palette_ctl # noqa: E402 import controllers.palette as palette_ctl # noqa: E402
import controllers.scene as scene_ctl # noqa: E402 import controllers.scene as scene_ctl # noqa: E402
import controllers.pattern as pattern_ctl # noqa: E402 import controllers.pattern as pattern_ctl # noqa: E402
@@ -205,7 +205,7 @@ def server(monkeypatch, tmp_path_factory):
app.mount(profile_ctl.controller, "/profiles") app.mount(profile_ctl.controller, "/profiles")
app.mount(group_ctl.controller, "/groups") app.mount(group_ctl.controller, "/groups")
app.mount(sequence_ctl.controller, "/sequences") app.mount(sequence_ctl.controller, "/sequences")
app.mount(tab_ctl.controller, "/tabs") app.mount(tab_ctl.controller, "/zones")
app.mount(palette_ctl.controller, "/palettes") app.mount(palette_ctl.controller, "/palettes")
app.mount(scene_ctl.controller, "/scenes") app.mount(scene_ctl.controller, "/scenes")
app.mount(pattern_ctl.controller, "/patterns") app.mount(pattern_ctl.controller, "/patterns")
@@ -424,45 +424,45 @@ def test_profiles_presets_tabs_endpoints(server, monkeypatch):
assert resp.status_code == 404 assert resp.status_code == 404
# Tabs CRUD (scoped to current profile session). # Tabs CRUD (scoped to current profile session).
unique_tab_name = f"pytest-tab-{uuid.uuid4().hex[:8]}" unique_tab_name = f"pytest-zone-{uuid.uuid4().hex[:8]}"
resp = c.post( resp = c.post(
f"{base_url}/tabs", f"{base_url}/zones",
json={"name": unique_tab_name, "names": ["1", "2"]}, json={"name": unique_tab_name, "names": ["1", "2"]},
) )
assert resp.status_code == 201 assert resp.status_code == 201
created_tabs = resp.json() created_tabs = resp.json()
tab_id = next(iter(created_tabs.keys())) zone_id = next(iter(created_tabs.keys()))
resp = c.get(f"{base_url}/tabs/{tab_id}") resp = c.get(f"{base_url}/zones/{zone_id}")
assert resp.status_code == 200 assert resp.status_code == 200
assert resp.json()["name"] == unique_tab_name assert resp.json()["name"] == unique_tab_name
resp = c.post(f"{base_url}/tabs/{tab_id}/set-current") resp = c.post(f"{base_url}/zones/{zone_id}/set-current")
assert resp.status_code == 200 assert resp.status_code == 200
resp = c.get(f"{base_url}/tabs/current") resp = c.get(f"{base_url}/zones/current")
assert resp.status_code == 200 assert resp.status_code == 200
assert resp.json()["tab_id"] == str(tab_id) assert resp.json()["zone_id"] == str(zone_id)
resp = c.put( resp = c.put(
f"{base_url}/tabs/{tab_id}", f"{base_url}/zones/{zone_id}",
json={"name": f"{unique_tab_name}-updated", "names": ["3"]}, json={"name": f"{unique_tab_name}-updated", "names": ["3"]},
) )
assert resp.status_code == 200 assert resp.status_code == 200
assert resp.json()["names"] == ["3"] assert resp.json()["names"] == ["3"]
resp = c.post(f"{base_url}/tabs/{tab_id}/clone", json={"name": "pytest-tab-clone"}) resp = c.post(f"{base_url}/zones/{zone_id}/clone", json={"name": "pytest-zone-clone"})
assert resp.status_code == 201 assert resp.status_code == 201
clone_payload = resp.json() clone_payload = resp.json()
clone_id = next(iter(clone_payload.keys())) clone_id = next(iter(clone_payload.keys()))
resp = c.get(f"{base_url}/tabs/{clone_id}") resp = c.get(f"{base_url}/zones/{clone_id}")
assert resp.status_code == 200 assert resp.status_code == 200
resp = c.delete(f"{base_url}/tabs/{clone_id}") resp = c.delete(f"{base_url}/zones/{clone_id}")
assert resp.status_code == 200 assert resp.status_code == 200
resp = c.delete(f"{base_url}/tabs/{tab_id}") resp = c.delete(f"{base_url}/zones/{zone_id}")
assert resp.status_code == 200 assert resp.status_code == 200
# Profile clone + update endpoints. # Profile clone + update endpoints.