feat(zones): rename tabs to zones across api, ui, and storage
Made-with: Cursor
This commit is contained in:
@@ -16,12 +16,12 @@ LED controller web app for managing profiles, tabs, presets, and colour palettes
|
||||
|
||||
## 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 **Edit mode**, Profiles supports create/clone/delete.
|
||||
- Creating a profile always creates a populated `default` tab (starter presets).
|
||||
- Optional **DJ tab** seeding creates:
|
||||
- `dj` tab bound to device name `dj`
|
||||
- Creating a profile always creates a populated `default` zone (starter presets).
|
||||
- Optional **DJ zone** seeding creates:
|
||||
- `dj` zone bound to device name `dj`
|
||||
- starter DJ presets (rainbow, single colour, transition)
|
||||
|
||||
## Preset colours and palette linking
|
||||
|
||||
@@ -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": []}}
|
||||
|
||||
@@ -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"}}
|
||||
@@ -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
1
db/zone.json
Normal 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"]}}
|
||||
22
docs/API.md
22
docs/API.md
@@ -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:
|
||||
|
||||
- **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).
|
||||
|
||||
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/current` | `{"id": "...", "profile": {...}}` |
|
||||
| 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>/clone` | Clone profile (tabs, palettes, presets). Body may include `name`. |
|
||||
| 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.
|
||||
- `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 |
|
||||
|--------|------|-------------|
|
||||
| GET | `/tabs` | `tabs`, `tab_order`, `current_tab_id`, `profile_id` for the session-backed profile. |
|
||||
| GET | `/tabs/current` | Current tab from cookie/session. |
|
||||
| POST | `/tabs` | Create tab; optional JSON `name`, `names`, `presets`; can append to current profile’s tab list. |
|
||||
| GET | `/tabs/<id>` | Tab JSON. |
|
||||
| PUT | `/tabs/<id>` | Update tab. |
|
||||
| DELETE | `/tabs/<id>` | Delete tab; can delete `current` to remove the active tab; updates profile tab list. |
|
||||
| POST | `/tabs/<id>/set-current` | Sets `current_tab` cookie. |
|
||||
| POST | `/tabs/<id>/clone` | Clone tab into current profile. |
|
||||
| GET | `/zones` | `tabs`, `zone_order`, `current_zone_id`, `profile_id` for the session-backed profile. |
|
||||
| GET | `/zones/current` | Current zone from cookie/session. |
|
||||
| POST | `/zones` | Create zone; optional JSON `name`, `names`, `presets`; can append to current profile’s zone list. |
|
||||
| GET | `/zones/<id>` | Zone JSON. |
|
||||
| PUT | `/zones/<id>` | Update zone. |
|
||||
| DELETE | `/zones/<id>` | Delete zone; can delete `current` to remove the active zone; updates profile zone list. |
|
||||
| POST | `/zones/<id>/set-current` | Sets `current_zone` cookie. |
|
||||
| POST | `/zones/<id>/clone` | Clone zone into current profile. |
|
||||
|
||||
### Palettes — `/palettes`
|
||||
|
||||
|
||||
@@ -351,9 +351,9 @@ Manage connected devices and create/manage device groups.
|
||||
#### Layout
|
||||
- **Header:** Title with "Add Device" button
|
||||
- **Tabs:** Devices and Groups tabs
|
||||
- **Content Area:** Tab-specific content
|
||||
- **Content Area:** Zone-specific content
|
||||
|
||||
#### Devices Tab
|
||||
#### Devices Zone
|
||||
|
||||
**Device List**
|
||||
- **Display:** List of all known devices
|
||||
@@ -375,7 +375,7 @@ Manage connected devices and create/manage device groups.
|
||||
- **Actions:** Cancel, Save
|
||||
- **Note:** Only one master device per system. Adding a new master will demote existing master to slave.
|
||||
|
||||
#### Groups Tab
|
||||
#### Groups Zone
|
||||
|
||||
**Group List**
|
||||
- **Display:** List of all device groups
|
||||
@@ -397,7 +397,7 @@ Manage connected devices and create/manage device groups.
|
||||
- **Actions:** Cancel, Create
|
||||
|
||||
#### 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
|
||||
- **Modal:** Centered overlay with white card, shadow
|
||||
- **Status Badges:** Colored pills (green for online, red for offline)
|
||||
@@ -1495,7 +1495,7 @@ peak_mem = usqlite.mem_peak()
|
||||
|
||||
### 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
|
||||
3. User selects devices to add (can include master), clicks "Create"
|
||||
4. Group appears in list
|
||||
|
||||
36
docs/help.md
36
docs/help.md
@@ -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 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 |
|
||||
|------|--------|
|
||||
| **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. |
|
||||
|
||||
**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
|
||||
|
||||
- **Select a tab**: click its button in the top bar. The main area shows that tab’s 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 device’s **name** when the app builds `select` messages for the driver.
|
||||
- **Select a zone**: click its button in the top bar. The main area shows that zone’s preset strip and controls.
|
||||
- **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 device’s **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).
|
||||
- **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** 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).
|
||||
- **Drag and drop** tiles to reorder them; order is saved for that tab.
|
||||
- **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 zone.
|
||||
|
||||

|
||||

|
||||
|
||||
*The slider controls global brightness for the tab’s devices. Click the coloured area of a tile to select that preset.*
|
||||
*The slider controls global brightness for the zone’s 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).
|
||||
|
||||
@@ -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.
|
||||
- **From Palette**: inserts a colour **linked** to the current profile’s palette. Linked slots show a **P** badge; if you change that palette entry later, presets using it update.
|
||||
- **Brightness (0–255)** 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).
|
||||
- **Default**: updates the tab’s **default preset** and sends a **default** hint for those devices; it does not force the same live selection behaviour as clicking a tile.
|
||||
- **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 zone’s **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).
|
||||
- **Remove from tab** (when you opened the editor from a tab): removes the preset from **this tab’s 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 zone’s list only**; the preset remains in the profile for other zones.
|
||||
|
||||

|
||||
|
||||
@@ -69,14 +69,14 @@ The **Presets** header button (Edit mode) opens a **profile-wide** list: **Add**
|
||||
## Profiles
|
||||
|
||||
- **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.
|
||||
|
||||
---
|
||||
|
||||
## Send Presets (Edit mode)
|
||||
|
||||
**Send Presets** walks **every tab** in the **current profile**, collects each tab’s preset IDs, and calls **`POST /presets/send`** per tab (including each tab’s **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 zone’s preset IDs, and calls **`POST /presets/send`** per zone (including each zone’s **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
|
||||
|
||||

|
||||
|
||||
*Preset tiles behave the same once a tab is selected.*
|
||||
*Preset tiles behave the same once a zone is selected.*
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -67,7 +67,7 @@
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.tab {
|
||||
.zone {
|
||||
flex: 1;
|
||||
padding: 12px 24px;
|
||||
border: none;
|
||||
@@ -78,16 +78,16 @@
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.tab.active {
|
||||
.zone.active {
|
||||
background: #667eea;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.tab-content {
|
||||
.zone-content {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.tab-content.active {
|
||||
.zone-content.active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
@@ -249,12 +249,12 @@
|
||||
</div>
|
||||
|
||||
<div class="tabs">
|
||||
<button class="tab active" onclick="switchTab('devices')">Devices</button>
|
||||
<button class="tab" onclick="switchTab('groups')">Groups</button>
|
||||
<button class="zone active" onclick="switchTab('devices')">Devices</button>
|
||||
<button class="zone" onclick="switchTab('groups')">Groups</button>
|
||||
</div>
|
||||
|
||||
<!-- Devices Tab -->
|
||||
<div id="devices-tab" class="tab-content active">
|
||||
<!-- Devices Zone -->
|
||||
<div id="devices-zone" class="zone-content active">
|
||||
<div class="card">
|
||||
<h2>Connected Devices</h2>
|
||||
<div class="device-item">
|
||||
@@ -313,8 +313,8 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Groups Tab -->
|
||||
<div id="groups-tab" class="tab-content">
|
||||
<!-- Groups Zone -->
|
||||
<div id="groups-zone" class="zone-content">
|
||||
<div class="card">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px;">
|
||||
<h2>Groups</h2>
|
||||
@@ -386,12 +386,12 @@
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function switchTab(tab) {
|
||||
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
|
||||
document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
|
||||
function switchTab(zone) {
|
||||
document.querySelectorAll('.zone').forEach(t => t.classList.remove('active'));
|
||||
document.querySelectorAll('.zone-content').forEach(c => c.classList.remove('active'));
|
||||
|
||||
event.target.classList.add('active');
|
||||
document.getElementById(tab + '-tab').classList.add('active');
|
||||
document.getElementById(zone + '-zone').classList.add('active');
|
||||
}
|
||||
|
||||
function showAddDeviceModal() {
|
||||
|
||||
@@ -134,17 +134,17 @@ async def create_device(request):
|
||||
}
|
||||
), 400, {"Content-Type": "application/json"}
|
||||
default_pattern = data.get("default_pattern")
|
||||
tabs = data.get("tabs")
|
||||
if isinstance(tabs, list):
|
||||
tabs = [str(t) for t in tabs]
|
||||
zl = data.get("zones")
|
||||
if isinstance(zl, list):
|
||||
zl = [str(t) for t in zl]
|
||||
else:
|
||||
tabs = []
|
||||
zl = []
|
||||
dev_id = devices.create(
|
||||
name=name,
|
||||
address=address,
|
||||
mac=mac,
|
||||
default_pattern=default_pattern,
|
||||
tabs=tabs,
|
||||
zones=zl,
|
||||
device_type=device_type,
|
||||
transport=transport,
|
||||
)
|
||||
@@ -178,8 +178,8 @@ async def update_device(request, id):
|
||||
data["type"] = validate_device_type(data.get("type"))
|
||||
if "transport" in data:
|
||||
data["transport"] = validate_device_transport(data.get("transport"))
|
||||
if "tabs" in data and isinstance(data["tabs"], list):
|
||||
data["tabs"] = [str(t) for t in data["tabs"]]
|
||||
if "zones" in data and isinstance(data["zones"], list):
|
||||
data["zones"] = [str(t) for t in data["zones"]]
|
||||
if devices.update(id, data):
|
||||
return json.dumps(devices.read(id)), 200, {"Content-Type": "application/json"}
|
||||
return json.dumps({"error": "Device not found"}), 404, {
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
from microdot import Microdot
|
||||
from microdot.session import with_session
|
||||
from models.profile import Profile
|
||||
from models.tab import Tab
|
||||
from models.zone import Zone
|
||||
from models.preset import Preset
|
||||
import json
|
||||
|
||||
controller = Microdot()
|
||||
profiles = Profile()
|
||||
tabs = Tab()
|
||||
zones = Zone()
|
||||
presets = Preset()
|
||||
|
||||
@controller.get('')
|
||||
@@ -83,20 +83,20 @@ async def create_profile(request):
|
||||
try:
|
||||
data = dict(request.json or {})
|
||||
name = data.get("name", "")
|
||||
seed_raw = data.get("seed_dj_tab", False)
|
||||
seed_raw = data.get("seed_dj_zone", False)
|
||||
if isinstance(seed_raw, str):
|
||||
seed_dj_tab = seed_raw.strip().lower() in ("1", "true", "yes", "on")
|
||||
seed_dj_zone = seed_raw.strip().lower() in ("1", "true", "yes", "on")
|
||||
else:
|
||||
seed_dj_tab = bool(seed_raw)
|
||||
seed_dj_zone = bool(seed_raw)
|
||||
# Request-only flag: do not persist on profile records.
|
||||
data.pop("seed_dj_tab", None)
|
||||
data.pop("seed_dj_zone", None)
|
||||
profile_id = profiles.create(name)
|
||||
# Avoid persisting request-only fields.
|
||||
data.pop("name", None)
|
||||
if data:
|
||||
profiles.update(profile_id, data)
|
||||
|
||||
# New profiles always start with a default tab pre-populated with starter presets.
|
||||
# New profiles always start with a default zone pre-populated with starter presets.
|
||||
default_preset_ids = []
|
||||
default_preset_defs = [
|
||||
{
|
||||
@@ -139,18 +139,18 @@ async def create_profile(request):
|
||||
presets.update(pid, preset_data)
|
||||
default_preset_ids.append(str(pid))
|
||||
|
||||
default_tab_id = tabs.create(name="default", names=["1"], presets=[default_preset_ids])
|
||||
tabs.update(default_tab_id, {
|
||||
default_tab_id = zones.create(name="default", names=["1"], presets=[default_preset_ids])
|
||||
zones.update(default_tab_id, {
|
||||
"presets_flat": default_preset_ids,
|
||||
"default_preset": default_preset_ids[0] if default_preset_ids else None,
|
||||
})
|
||||
|
||||
profile = profiles.read(profile_id) or {}
|
||||
profile_tabs = profile.get("tabs", []) if isinstance(profile.get("tabs", []), list) else []
|
||||
profile_tabs = profile.get("zones", []) if isinstance(profile.get("zones", []), list) else []
|
||||
profile_tabs.append(str(default_tab_id))
|
||||
|
||||
if seed_dj_tab:
|
||||
# Seed a DJ-focused tab with three starter presets.
|
||||
if seed_dj_zone:
|
||||
# Seed a DJ-focused zone with three starter presets.
|
||||
seeded_preset_ids = []
|
||||
preset_defs = [
|
||||
{
|
||||
@@ -182,15 +182,15 @@ async def create_profile(request):
|
||||
presets.update(pid, preset_data)
|
||||
seeded_preset_ids.append(str(pid))
|
||||
|
||||
dj_tab_id = tabs.create(name="dj", names=["dj"], presets=[seeded_preset_ids])
|
||||
tabs.update(dj_tab_id, {
|
||||
dj_tab_id = zones.create(name="dj", names=["dj"], presets=[seeded_preset_ids])
|
||||
zones.update(dj_tab_id, {
|
||||
"presets_flat": seeded_preset_ids,
|
||||
"default_preset": seeded_preset_ids[0] if seeded_preset_ids else None,
|
||||
})
|
||||
|
||||
profile_tabs.append(str(dj_tab_id))
|
||||
|
||||
profiles.update(profile_id, {"tabs": profile_tabs})
|
||||
profiles.update(profile_id, {"zones": profile_tabs})
|
||||
|
||||
profile_data = profiles.read(profile_id)
|
||||
return json.dumps({profile_id: profile_data}), 201, {'Content-Type': 'application/json'}
|
||||
@@ -208,7 +208,7 @@ async def clone_profile(request, id):
|
||||
data = request.json or {}
|
||||
source_name = source.get("name") or f"Profile {id}"
|
||||
new_name = data.get("name") or source_name
|
||||
profile_type = source.get("type", "tabs")
|
||||
profile_type = source.get("type", "zones")
|
||||
|
||||
def allocate_id(model, cache):
|
||||
if "next" not in cache:
|
||||
@@ -255,28 +255,28 @@ async def clone_profile(request, id):
|
||||
palette_colors = []
|
||||
|
||||
# Clone tabs and presets used by those tabs
|
||||
source_tabs = source.get("tabs")
|
||||
source_tabs = source.get("zones")
|
||||
if not isinstance(source_tabs, list) or len(source_tabs) == 0:
|
||||
source_tabs = source.get("tab_order", [])
|
||||
source_tabs = source.get("zone_order", [])
|
||||
source_tabs = source_tabs or []
|
||||
cloned_tab_ids = []
|
||||
preset_id_map = {}
|
||||
new_tabs = {}
|
||||
new_presets = {}
|
||||
for tab_id in source_tabs:
|
||||
tab = tabs.read(tab_id)
|
||||
if not tab:
|
||||
for zone_id in source_tabs:
|
||||
zone = zones.read(zone_id)
|
||||
if not zone:
|
||||
continue
|
||||
tab_name = tab.get("name") or f"Tab {tab_id}"
|
||||
tab_name = zone.get("name") or f"Zone {zone_id}"
|
||||
clone_name = tab_name
|
||||
mapped_presets = map_preset_container(tab.get("presets"), preset_id_map, preset_cache, new_profile_id, new_presets)
|
||||
clone_id = allocate_id(tabs, tab_cache)
|
||||
mapped_presets = map_preset_container(zone.get("presets"), preset_id_map, preset_cache, new_profile_id, new_presets)
|
||||
clone_id = allocate_id(zones, tab_cache)
|
||||
clone_data = {
|
||||
"name": clone_name,
|
||||
"names": tab.get("names") or [],
|
||||
"names": zone.get("names") or [],
|
||||
"presets": mapped_presets if mapped_presets is not None else []
|
||||
}
|
||||
extra = {k: v for k, v in tab.items() if k not in ("name", "names", "presets")}
|
||||
extra = {k: v for k, v in zone.items() if k not in ("name", "names", "presets")}
|
||||
if "presets_flat" in extra:
|
||||
extra["presets_flat"] = map_preset_container(extra.get("presets_flat"), preset_id_map, preset_cache, new_profile_id, new_presets)
|
||||
if extra:
|
||||
@@ -287,7 +287,7 @@ async def clone_profile(request, id):
|
||||
new_profile_data = {
|
||||
"name": new_name,
|
||||
"type": profile_type,
|
||||
"tabs": cloned_tab_ids,
|
||||
"zones": cloned_tab_ids,
|
||||
"scenes": list(source.get("scenes", [])) if isinstance(source.get("scenes", []), list) else [],
|
||||
"palette_id": str(new_palette_id),
|
||||
}
|
||||
@@ -297,12 +297,12 @@ async def clone_profile(request, id):
|
||||
for pid, pdata in new_presets.items():
|
||||
presets[pid] = pdata
|
||||
for tid, tdata in new_tabs.items():
|
||||
tabs[tid] = tdata
|
||||
zones[tid] = tdata
|
||||
profiles[str(new_profile_id)] = new_profile_data
|
||||
|
||||
profiles._palette_model.save()
|
||||
presets.save()
|
||||
tabs.save()
|
||||
zones.save()
|
||||
profiles.save()
|
||||
|
||||
return json.dumps({new_profile_id: new_profile_data}), 201, {'Content-Type': 'application/json'}
|
||||
|
||||
@@ -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
361
src/controllers/zone.py
Normal 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
|
||||
|
||||
@@ -14,7 +14,7 @@ import controllers.preset as preset
|
||||
import controllers.profile as profile
|
||||
import controllers.group as group
|
||||
import controllers.sequence as sequence
|
||||
import controllers.tab as tab
|
||||
import controllers.zone as zone
|
||||
import controllers.palette as palette
|
||||
import controllers.scene as scene
|
||||
import controllers.pattern as pattern
|
||||
@@ -262,7 +262,7 @@ async def main(port=80):
|
||||
('/profiles', profile, 'profile'),
|
||||
('/groups', group, 'group'),
|
||||
('/sequences', sequence, 'sequence'),
|
||||
('/tabs', tab, 'tab'),
|
||||
('/zones', zone, 'zone'),
|
||||
('/palettes', palette, 'palette'),
|
||||
('/scenes', scene, 'scene'),
|
||||
]
|
||||
@@ -272,7 +272,7 @@ async def main(port=80):
|
||||
app.mount(profile.controller, '/profiles')
|
||||
app.mount(group.controller, '/groups')
|
||||
app.mount(sequence.controller, '/sequences')
|
||||
app.mount(tab.controller, '/tabs')
|
||||
app.mount(zone.controller, '/zones')
|
||||
app.mount(palette.controller, '/palettes')
|
||||
app.mount(scene.controller, '/scenes')
|
||||
app.mount(pattern.controller, '/patterns')
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
LED driver registry persisted in ``db/device.json``.
|
||||
|
||||
Storage key and **id** field are the device **MAC**: 12 lowercase hex characters
|
||||
(no colons). **name** is for ``select`` / tabs (not unique). **address** is the
|
||||
(no colons). **name** is for ``select`` / zones (not unique). **address** is the
|
||||
reachability hint: same as MAC for ESP-NOW, or IP/hostname for Wi-Fi.
|
||||
"""
|
||||
|
||||
@@ -160,7 +160,7 @@ class Device(Model):
|
||||
address=None,
|
||||
mac=None,
|
||||
default_pattern=None,
|
||||
tabs=None,
|
||||
zones=None,
|
||||
device_type="led",
|
||||
transport="espnow",
|
||||
):
|
||||
@@ -183,7 +183,7 @@ class Device(Model):
|
||||
"transport": tr,
|
||||
"address": addr,
|
||||
"default_pattern": default_pattern if default_pattern else None,
|
||||
"tabs": list(tabs) if tabs else [],
|
||||
"zones": list(zones) if zones else [],
|
||||
}
|
||||
self.save()
|
||||
return mac_hex
|
||||
@@ -273,7 +273,7 @@ class Device(Model):
|
||||
"transport": "wifi",
|
||||
"address": ip,
|
||||
"default_pattern": None,
|
||||
"tabs": [],
|
||||
"zones": [],
|
||||
}
|
||||
self.save()
|
||||
return mac_hex
|
||||
|
||||
@@ -26,18 +26,18 @@ class Profile(Model):
|
||||
if changed:
|
||||
self.save()
|
||||
|
||||
def create(self, name="", profile_type="tabs"):
|
||||
def create(self, name="", profile_type="zones"):
|
||||
"""Create a new profile and its own empty palette.
|
||||
|
||||
profile_type: "tabs" or "scenes" (ignoring scenes for now)
|
||||
profile_type: "zones" or "scenes" (ignoring scenes for now)
|
||||
"""
|
||||
next_id = self.get_next_id()
|
||||
# Create a unique palette for this profile.
|
||||
palette_id = self._palette_model.create(colors=[])
|
||||
self[next_id] = {
|
||||
"name": name,
|
||||
"type": profile_type, # "tabs" or "scenes"
|
||||
"tabs": [], # Array of tab IDs
|
||||
"type": profile_type, # "zones" or "scenes"
|
||||
"zones": [], # Array of zone IDs
|
||||
"scenes": [], # Array of scene IDs (for future use)
|
||||
"palette_id": str(palette_id),
|
||||
}
|
||||
|
||||
@@ -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
62
src/models/zone.py
Normal 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())
|
||||
@@ -5,7 +5,7 @@ class LightingController {
|
||||
this.state = {
|
||||
lights: {},
|
||||
patterns: {},
|
||||
tab_order: [],
|
||||
zone_order: [],
|
||||
presets: {}
|
||||
};
|
||||
this.selectedColorIndex = 0;
|
||||
@@ -19,8 +19,8 @@ class LightingController {
|
||||
await this.loadState();
|
||||
this.setupEventListeners();
|
||||
this.renderTabs();
|
||||
if (this.state.tab_order.length > 0) {
|
||||
this.selectTab(this.state.tab_order[0]);
|
||||
if (this.state.zone_order.length > 0) {
|
||||
this.selectTab(this.state.zone_order[0]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,19 +62,19 @@ class LightingController {
|
||||
}
|
||||
|
||||
setupEventListeners() {
|
||||
// Tab management
|
||||
document.getElementById('add-tab-btn').addEventListener('click', () => this.showAddTabModal());
|
||||
document.getElementById('edit-tab-btn').addEventListener('click', () => this.showEditTabModal());
|
||||
document.getElementById('delete-tab-btn').addEventListener('click', () => this.deleteCurrentTab());
|
||||
// Zone management
|
||||
document.getElementById('add-zone-btn').addEventListener('click', () => this.showAddTabModal());
|
||||
document.getElementById('edit-zone-btn').addEventListener('click', () => this.showEditTabModal());
|
||||
document.getElementById('delete-zone-btn').addEventListener('click', () => this.deleteCurrentTab());
|
||||
document.getElementById('color-palette-btn').addEventListener('click', () => this.showColorPalette());
|
||||
document.getElementById('presets-btn').addEventListener('click', () => this.showPresets());
|
||||
document.getElementById('profiles-btn').addEventListener('click', () => this.showProfiles());
|
||||
|
||||
// Modal actions
|
||||
document.getElementById('add-tab-confirm').addEventListener('click', () => this.createTab());
|
||||
document.getElementById('add-tab-cancel').addEventListener('click', () => this.hideModal('add-tab-modal'));
|
||||
document.getElementById('edit-tab-confirm').addEventListener('click', () => this.updateTab());
|
||||
document.getElementById('edit-tab-cancel').addEventListener('click', () => this.hideModal('edit-tab-modal'));
|
||||
document.getElementById('add-zone-confirm').addEventListener('click', () => this.createTab());
|
||||
document.getElementById('add-zone-cancel').addEventListener('click', () => this.hideModal('add-zone-modal'));
|
||||
document.getElementById('edit-zone-confirm').addEventListener('click', () => this.updateTab());
|
||||
document.getElementById('edit-zone-cancel').addEventListener('click', () => this.hideModal('edit-zone-modal'));
|
||||
document.getElementById('profiles-close-btn').addEventListener('click', () => this.hideModal('profiles-modal'));
|
||||
document.getElementById('color-palette-close-btn').addEventListener('click', () => this.hideModal('color-palette-modal'));
|
||||
document.getElementById('presets-close-btn').addEventListener('click', () => this.hideModal('presets-modal'));
|
||||
@@ -125,12 +125,12 @@ class LightingController {
|
||||
}
|
||||
|
||||
renderTabs() {
|
||||
const tabsList = document.getElementById('tabs-list');
|
||||
const tabsList = document.getElementById('zones-list');
|
||||
tabsList.innerHTML = '';
|
||||
|
||||
this.state.tab_order.forEach(tabName => {
|
||||
this.state.zone_order.forEach(tabName => {
|
||||
const tabButton = document.createElement('button');
|
||||
tabButton.className = 'tab-button';
|
||||
tabButton.className = 'zone-button';
|
||||
tabButton.textContent = tabName;
|
||||
tabButton.addEventListener('click', () => this.selectTab(tabName));
|
||||
if (tabName === this.currentTab) {
|
||||
@@ -217,13 +217,13 @@ class LightingController {
|
||||
}
|
||||
|
||||
renderPresets(tabName) {
|
||||
const presetsList = document.getElementById('presets-list-tab');
|
||||
const presetsList = document.getElementById('presets-list-zone');
|
||||
presetsList.innerHTML = '';
|
||||
|
||||
const presets = this.state.presets || {};
|
||||
const presetNames = Object.keys(presets);
|
||||
|
||||
// Get current tab's settings for comparison
|
||||
// Get current zone's settings for comparison
|
||||
const currentSettings = this.getCurrentTabSettings(tabName);
|
||||
|
||||
// Always include "on" and "off" presets
|
||||
@@ -267,7 +267,7 @@ class LightingController {
|
||||
const presetButton = document.createElement('button');
|
||||
presetButton.className = 'pattern-button';
|
||||
|
||||
// Check if this preset matches the current tab's settings
|
||||
// Check if this preset matches the current zone's settings
|
||||
const isActive = this.presetMatchesSettings(preset, currentSettings);
|
||||
if (isActive) {
|
||||
presetButton.classList.add('active');
|
||||
@@ -344,7 +344,7 @@ class LightingController {
|
||||
})
|
||||
});
|
||||
|
||||
// Reload state and tab content
|
||||
// Reload state and zone content
|
||||
await this.loadState();
|
||||
await this.loadTabContent(tabName);
|
||||
} else {
|
||||
@@ -591,7 +591,7 @@ class LightingController {
|
||||
}
|
||||
// Reload state from server to ensure consistency
|
||||
await this.loadState();
|
||||
// Reload tab content to update UI
|
||||
// Reload zone content to update UI
|
||||
await this.loadTabContent(tabName);
|
||||
} else {
|
||||
const errorText = await response.text();
|
||||
@@ -769,23 +769,23 @@ class LightingController {
|
||||
}
|
||||
|
||||
showAddTabModal() {
|
||||
document.getElementById('new-tab-name').value = '';
|
||||
document.getElementById('new-tab-ids').value = '1';
|
||||
document.getElementById('add-tab-modal').classList.add('active');
|
||||
document.getElementById('new-zone-name').value = '';
|
||||
document.getElementById('new-zone-ids').value = '1';
|
||||
document.getElementById('add-zone-modal').classList.add('active');
|
||||
}
|
||||
|
||||
async createTab() {
|
||||
const name = document.getElementById('new-tab-name').value.trim();
|
||||
const idsStr = document.getElementById('new-tab-ids').value.trim();
|
||||
const name = document.getElementById('new-zone-name').value.trim();
|
||||
const idsStr = document.getElementById('new-zone-ids').value.trim();
|
||||
const ids = idsStr.split(',').map(id => id.trim()).filter(id => id);
|
||||
|
||||
if (!name) {
|
||||
alert('Tab name cannot be empty');
|
||||
alert('Zone name cannot be empty');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/tabs', {
|
||||
const response = await fetch('/zones', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name, ids })
|
||||
@@ -795,41 +795,41 @@ class LightingController {
|
||||
await this.loadState();
|
||||
this.renderTabs();
|
||||
this.selectTab(name);
|
||||
this.hideModal('add-tab-modal');
|
||||
this.hideModal('add-zone-modal');
|
||||
} else {
|
||||
const error = await response.json();
|
||||
alert(error.error || 'Failed to create tab');
|
||||
alert(error.error || 'Failed to create zone');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to create tab:', error);
|
||||
alert('Failed to create tab');
|
||||
console.error('Failed to create zone:', error);
|
||||
alert('Failed to create zone');
|
||||
}
|
||||
}
|
||||
|
||||
showEditTabModal() {
|
||||
if (!this.currentTab) {
|
||||
alert('Please select a tab first');
|
||||
alert('Please select a zone first');
|
||||
return;
|
||||
}
|
||||
|
||||
const light = this.state.lights[this.currentTab];
|
||||
document.getElementById('edit-tab-name').value = this.currentTab;
|
||||
document.getElementById('edit-tab-ids').value = light.names.join(', ');
|
||||
document.getElementById('edit-tab-modal').classList.add('active');
|
||||
document.getElementById('edit-zone-name').value = this.currentTab;
|
||||
document.getElementById('edit-zone-ids').value = light.names.join(', ');
|
||||
document.getElementById('edit-zone-modal').classList.add('active');
|
||||
}
|
||||
|
||||
async updateTab() {
|
||||
const newName = document.getElementById('edit-tab-name').value.trim();
|
||||
const idsStr = document.getElementById('edit-tab-ids').value.trim();
|
||||
const newName = document.getElementById('edit-zone-name').value.trim();
|
||||
const idsStr = document.getElementById('edit-zone-ids').value.trim();
|
||||
const ids = idsStr.split(',').map(id => id.trim()).filter(id => id);
|
||||
|
||||
if (!newName) {
|
||||
alert('Tab name cannot be empty');
|
||||
alert('Zone name cannot be empty');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/tabs/${this.currentTab}`, {
|
||||
const response = await fetch(`/zones/${this.currentTab}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: newName, ids })
|
||||
@@ -839,45 +839,45 @@ class LightingController {
|
||||
await this.loadState();
|
||||
this.renderTabs();
|
||||
this.selectTab(newName);
|
||||
this.hideModal('edit-tab-modal');
|
||||
this.hideModal('edit-zone-modal');
|
||||
} else {
|
||||
const error = await response.json();
|
||||
alert(error.error || 'Failed to update tab');
|
||||
alert(error.error || 'Failed to update zone');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to update tab:', error);
|
||||
alert('Failed to update tab');
|
||||
console.error('Failed to update zone:', error);
|
||||
alert('Failed to update zone');
|
||||
}
|
||||
}
|
||||
|
||||
async deleteCurrentTab() {
|
||||
if (!this.currentTab) {
|
||||
alert('Please select a tab first');
|
||||
alert('Please select a zone first');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!confirm(`Are you sure you want to delete the tab '${this.currentTab}'?`)) {
|
||||
if (!confirm(`Are you sure you want to delete the zone '${this.currentTab}'?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/tabs/${this.currentTab}`, {
|
||||
const response = await fetch(`/zones/${this.currentTab}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
await this.loadState();
|
||||
this.renderTabs();
|
||||
if (this.state.tab_order.length > 0) {
|
||||
this.selectTab(this.state.tab_order[0]);
|
||||
if (this.state.zone_order.length > 0) {
|
||||
this.selectTab(this.state.zone_order[0]);
|
||||
} else {
|
||||
this.currentTab = null;
|
||||
document.getElementById('tab-content').innerHTML = '<p>No tabs available. Create a new tab to get started.</p>';
|
||||
document.getElementById('zone-content').innerHTML = '<p>No tabs available. Create a new zone to get started.</p>';
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to delete tab:', error);
|
||||
alert('Failed to delete tab');
|
||||
console.error('Failed to delete zone:', error);
|
||||
alert('Failed to delete zone');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1008,9 +1008,9 @@ class LightingController {
|
||||
if (this.state.current_profile === profileName) {
|
||||
this.state.current_profile = '';
|
||||
this.state.lights = {};
|
||||
this.state.tab_order = [];
|
||||
this.state.zone_order = [];
|
||||
this.renderTabs();
|
||||
document.getElementById('tab-content').innerHTML = '<p>No tabs available. Create a new tab to get started.</p>';
|
||||
document.getElementById('zone-content').innerHTML = '<p>No tabs available. Create a new zone to get started.</p>';
|
||||
this.updateCurrentProfileDisplay();
|
||||
}
|
||||
} else {
|
||||
@@ -1032,8 +1032,8 @@ class LightingController {
|
||||
if (response.ok) {
|
||||
await this.loadState();
|
||||
this.renderTabs();
|
||||
if (this.state.tab_order.length > 0) {
|
||||
this.selectTab(this.state.tab_order[0]);
|
||||
if (this.state.zone_order.length > 0) {
|
||||
this.selectTab(this.state.zone_order[0]);
|
||||
} else {
|
||||
this.currentTab = null;
|
||||
}
|
||||
@@ -1129,7 +1129,7 @@ class LightingController {
|
||||
swatch.style.cssText = 'width: 40px; height: 40px; background-color: ' + color + '; border: 2px solid #4a4a4a; border-radius: 4px; cursor: pointer; position: relative;';
|
||||
swatch.title = `Click to apply ${color} to selected color`;
|
||||
|
||||
// Click to apply color to currently selected color in active tab
|
||||
// Click to apply color to currently selected color in active zone
|
||||
swatch.addEventListener('click', (e) => {
|
||||
// Only apply if not clicking the remove button
|
||||
if (e.target === swatch || !e.target.closest('button')) {
|
||||
@@ -1151,7 +1151,7 @@ class LightingController {
|
||||
|
||||
applyPaletteColorToSelected(paletteColor) {
|
||||
if (!this.currentTab) {
|
||||
alert('No tab selected. Please select a tab first.');
|
||||
alert('No zone selected. Please select a zone first.');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1439,7 +1439,7 @@ class LightingController {
|
||||
|
||||
async applyPreset(presetName) {
|
||||
if (!this.currentTab) {
|
||||
alert('Please select a tab first');
|
||||
alert('Please select a zone first');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1621,7 +1621,7 @@ class LightingController {
|
||||
|
||||
loadCurrentTabToPresetEditor() {
|
||||
if (!this.currentTab || !this.state.lights[this.currentTab]) {
|
||||
alert('Please select a tab first');
|
||||
alert('Please select a zone first');
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -19,34 +19,34 @@ const numTabs = 3;
|
||||
|
||||
// Select the container for tabs and content
|
||||
const tabsContainer = document.querySelector(".tabs");
|
||||
const tabContentContainer = document.querySelector(".tab-content");
|
||||
const tabContentContainer = document.querySelector(".zone-content");
|
||||
|
||||
// Create tabs dynamically
|
||||
for (let i = 1; i <= numTabs; i++) {
|
||||
// Create the tab button
|
||||
// Create the zone button
|
||||
const tabButton = document.createElement("button");
|
||||
tabButton.classList.add("tab");
|
||||
tabButton.id = `tab${i}`;
|
||||
tabButton.textContent = `Tab ${i}`;
|
||||
tabButton.classList.add("zone");
|
||||
tabButton.id = `zone${i}`;
|
||||
tabButton.textContent = `Zone ${i}`;
|
||||
|
||||
// Add the tab button to the container
|
||||
// Add the zone button to the container
|
||||
tabsContainer.appendChild(tabButton);
|
||||
|
||||
// Create the corresponding tab content (RGB slider)
|
||||
// Create the corresponding zone content (RGB slider)
|
||||
const tabContent = document.createElement("div");
|
||||
tabContent.classList.add("tab-pane");
|
||||
tabContent.classList.add("zone-pane");
|
||||
tabContent.id = `content${i}`;
|
||||
const slider = document.createElement("rgb-slider");
|
||||
slider.id = i;
|
||||
tabContent.appendChild(slider);
|
||||
|
||||
// Add the tab content to the container
|
||||
// Add the zone content to the container
|
||||
tabContentContainer.appendChild(tabContent);
|
||||
|
||||
// Listen for color change on each RGB slider
|
||||
slider.addEventListener("color-change", (e) => {
|
||||
const { r, g, b } = e.detail;
|
||||
console.log(`Color changed in tab ${i}:`, e.detail);
|
||||
console.log(`Color changed in zone ${i}:`, e.detail);
|
||||
// Send RGB data to WebSocket server
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
const colorData = { r, g, b };
|
||||
@@ -56,26 +56,26 @@ for (let i = 1; i <= numTabs; i++) {
|
||||
}
|
||||
|
||||
// Function to switch tabs
|
||||
function switchTab(tabId) {
|
||||
const tabs = document.querySelectorAll(".tab");
|
||||
const tabContents = document.querySelectorAll(".tab-pane");
|
||||
function switchTab(zoneId) {
|
||||
const tabs = document.querySelectorAll(".zone");
|
||||
const tabContents = document.querySelectorAll(".zone-pane");
|
||||
|
||||
tabs.forEach((tab) => tab.classList.remove("active"));
|
||||
zones.forEach((zone) => zone.classList.remove("active"));
|
||||
tabContents.forEach((content) => content.classList.remove("active"));
|
||||
|
||||
// Activate the clicked tab and corresponding content
|
||||
document.getElementById(tabId).classList.add("active");
|
||||
// Activate the clicked zone and corresponding content
|
||||
document.getElementById(zoneId).classList.add("active");
|
||||
document
|
||||
.getElementById("content" + tabId.replace("tab", ""))
|
||||
.getElementById("content" + zoneId.replace("zone", ""))
|
||||
.classList.add("active");
|
||||
}
|
||||
|
||||
// Add event listeners to tabs
|
||||
tabsContainer.addEventListener("click", (e) => {
|
||||
if (e.target.classList.contains("tab")) {
|
||||
if (e.target.classList.contains("zone")) {
|
||||
switchTab(e.target.id);
|
||||
}
|
||||
});
|
||||
|
||||
// Initially set the first tab as active
|
||||
// Initially set the first zone as active
|
||||
switchTab("tab1");
|
||||
|
||||
@@ -175,9 +175,9 @@ async function postDriverSequence(sequence, targetMacs, delayS) {
|
||||
return res.json().catch(() => ({}));
|
||||
}
|
||||
|
||||
// Send a select message for a preset to all devices on the current tab (ESP-NOW or Wi-Fi).
|
||||
// Send a select message for a preset to all devices on the current zone (ESP-NOW or Wi-Fi).
|
||||
const sendSelectForCurrentTabDevices = async (presetId, sectionEl) => {
|
||||
const section = sectionEl || document.querySelector('.presets-section[data-tab-id]');
|
||||
const section = sectionEl || document.querySelector('.presets-section[data-zone-id]');
|
||||
if (!section || !presetId) {
|
||||
return;
|
||||
}
|
||||
@@ -223,7 +223,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
const presetBrightnessInput = document.getElementById('preset-brightness-input');
|
||||
const presetDelayInput = document.getElementById('preset-delay-input');
|
||||
const presetDefaultButton = document.getElementById('preset-default-btn');
|
||||
const presetRemoveFromTabButton = document.getElementById('preset-remove-from-tab-btn');
|
||||
const presetRemoveFromTabButton = document.getElementById('preset-remove-from-zone-btn');
|
||||
const presetSaveButton = document.getElementById('preset-save-btn');
|
||||
const presetAddFromPaletteButton = document.getElementById('preset-add-from-palette-btn');
|
||||
|
||||
@@ -623,8 +623,8 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
if (currentEditTabId) {
|
||||
return currentEditTabId;
|
||||
}
|
||||
const section = document.querySelector('.presets-section[data-tab-id]');
|
||||
return section ? section.dataset.tabId : null;
|
||||
const section = document.querySelector('.presets-section[data-zone-id]');
|
||||
return section ? section.dataset.zoneId : null;
|
||||
};
|
||||
|
||||
const updatePresetEditorTabActionsVisibility = () => {
|
||||
@@ -634,12 +634,12 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
};
|
||||
|
||||
const updateTabDefaultPreset = async (presetId) => {
|
||||
const tabId = getActiveTabId();
|
||||
if (!tabId) {
|
||||
const zoneId = getActiveTabId();
|
||||
if (!zoneId) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const tabResponse = await fetch(`/tabs/${tabId}`, {
|
||||
const tabResponse = await fetch(`/zones/${zoneId}`, {
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
if (!tabResponse.ok) {
|
||||
@@ -647,13 +647,13 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
}
|
||||
const tabData = await tabResponse.json();
|
||||
tabData.default_preset = presetId;
|
||||
await fetch(`/tabs/${tabId}`, {
|
||||
await fetch(`/zones/${zoneId}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(tabData),
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn('Failed to save tab default preset:', error);
|
||||
console.warn('Failed to save zone default preset:', error);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -950,22 +950,22 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
}
|
||||
|
||||
const showAddPresetToTabModal = async (optionalTabId) => {
|
||||
let tabId = optionalTabId;
|
||||
if (!tabId) {
|
||||
// Get current tab ID from the presets section
|
||||
const leftPanel = document.querySelector('.presets-section[data-tab-id]');
|
||||
tabId = leftPanel ? leftPanel.dataset.tabId : null;
|
||||
let zoneId = optionalTabId;
|
||||
if (!zoneId) {
|
||||
// Get current zone ID from the presets section
|
||||
const leftPanel = document.querySelector('.presets-section[data-zone-id]');
|
||||
zoneId = leftPanel ? leftPanel.dataset.zoneId : null;
|
||||
}
|
||||
if (!tabId) {
|
||||
if (!zoneId) {
|
||||
// Fallback: try to get from URL
|
||||
const pathParts = window.location.pathname.split('/');
|
||||
const tabIndex = pathParts.indexOf('tabs');
|
||||
const tabIndex = pathParts.indexOf('zones');
|
||||
if (tabIndex !== -1 && tabIndex + 1 < pathParts.length) {
|
||||
tabId = pathParts[tabIndex + 1];
|
||||
zoneId = pathParts[tabIndex + 1];
|
||||
}
|
||||
}
|
||||
if (!tabId) {
|
||||
alert('Could not determine current tab.');
|
||||
if (!zoneId) {
|
||||
alert('Could not determine current zone.');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -980,10 +980,10 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
const allPresetsRaw = await response.json();
|
||||
const allPresets = await filterPresetsForCurrentProfile(allPresetsRaw);
|
||||
|
||||
// Load only the current tab's presets so we can avoid duplicates within this tab.
|
||||
// Load only the current zone's presets so we can avoid duplicates within this zone.
|
||||
let currentTabPresets = [];
|
||||
try {
|
||||
const tabResponse = await fetch(`/tabs/${tabId}`, {
|
||||
const tabResponse = await fetch(`/zones/${zoneId}`, {
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
if (tabResponse.ok) {
|
||||
@@ -999,19 +999,19 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Could not load current tab presets:', e);
|
||||
console.warn('Could not load current zone presets:', e);
|
||||
}
|
||||
|
||||
// Create modal
|
||||
const modal = document.createElement('div');
|
||||
modal.className = 'modal active';
|
||||
modal.id = 'add-preset-to-tab-modal';
|
||||
modal.id = 'add-preset-to-zone-modal';
|
||||
modal.innerHTML = `
|
||||
<div class="modal-content">
|
||||
<h2>Add Preset to Tab</h2>
|
||||
<h2>Add Preset to Zone</h2>
|
||||
<div id="add-preset-list" class="profiles-list" style="max-height: 400px; overflow-y: auto;"></div>
|
||||
<div class="modal-actions">
|
||||
<button class="btn btn-secondary" id="add-preset-to-tab-close-btn">Close</button>
|
||||
<button class="btn btn-secondary" id="add-preset-to-zone-close-btn">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -1023,7 +1023,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
|
||||
const availableToAdd = presetNames.filter(presetId => !currentTabPresets.includes(presetId));
|
||||
if (availableToAdd.length === 0) {
|
||||
listContainer.innerHTML = '<p class="muted-text">No presets to add. All presets are already in this tab, or create a preset first.</p>';
|
||||
listContainer.innerHTML = '<p class="muted-text">No presets to add. All presets are already in this zone, or create a preset first.</p>';
|
||||
} else {
|
||||
availableToAdd.forEach(presetId => {
|
||||
const preset = allPresets[presetId];
|
||||
@@ -1042,7 +1042,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
addButton.className = 'btn btn-primary btn-small';
|
||||
addButton.textContent = 'Add';
|
||||
addButton.addEventListener('click', async () => {
|
||||
await addPresetToTab(presetId, tabId);
|
||||
await addPresetToTab(presetId, zoneId);
|
||||
modal.remove();
|
||||
});
|
||||
|
||||
@@ -1054,7 +1054,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
}
|
||||
|
||||
// Close button handler
|
||||
document.getElementById('add-preset-to-tab-close-btn').addEventListener('click', () => {
|
||||
document.getElementById('add-preset-to-zone-close-btn').addEventListener('click', () => {
|
||||
modal.remove();
|
||||
});
|
||||
|
||||
@@ -1067,34 +1067,34 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
window.showAddPresetToTabModal = showAddPresetToTabModal;
|
||||
} catch (e) {}
|
||||
|
||||
const addPresetToTab = async (presetId, tabId) => {
|
||||
if (!tabId) {
|
||||
// Try to get tab ID from the left-panel
|
||||
const leftPanel = document.querySelector('.presets-section[data-tab-id]');
|
||||
tabId = leftPanel ? leftPanel.dataset.tabId : null;
|
||||
const addPresetToTab = async (presetId, zoneId) => {
|
||||
if (!zoneId) {
|
||||
// Try to get zone ID from the left-panel
|
||||
const leftPanel = document.querySelector('.presets-section[data-zone-id]');
|
||||
zoneId = leftPanel ? leftPanel.dataset.zoneId : null;
|
||||
|
||||
if (!tabId) {
|
||||
if (!zoneId) {
|
||||
// Fallback: try to get from URL
|
||||
const pathParts = window.location.pathname.split('/');
|
||||
const tabIndex = pathParts.indexOf('tabs');
|
||||
const tabIndex = pathParts.indexOf('zones');
|
||||
if (tabIndex !== -1 && tabIndex + 1 < pathParts.length) {
|
||||
tabId = pathParts[tabIndex + 1];
|
||||
zoneId = pathParts[tabIndex + 1];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!tabId) {
|
||||
alert('Could not determine current tab.');
|
||||
if (!zoneId) {
|
||||
alert('Could not determine current zone.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Get current tab data
|
||||
const tabResponse = await fetch(`/tabs/${tabId}`, {
|
||||
// Get current zone data
|
||||
const tabResponse = await fetch(`/zones/${zoneId}`, {
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
if (!tabResponse.ok) {
|
||||
throw new Error('Failed to load tab');
|
||||
throw new Error('Failed to load zone');
|
||||
}
|
||||
const tabData = await tabResponse.json();
|
||||
|
||||
@@ -1111,7 +1111,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
}
|
||||
|
||||
if (flat.includes(presetId)) {
|
||||
alert('Preset is already added to this tab.');
|
||||
alert('Preset is already added to this zone.');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1120,23 +1120,23 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
tabData.presets = newGrid;
|
||||
tabData.presets_flat = flat;
|
||||
|
||||
// Update tab
|
||||
const updateResponse = await fetch(`/tabs/${tabId}`, {
|
||||
// Update zone
|
||||
const updateResponse = await fetch(`/zones/${zoneId}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(tabData),
|
||||
});
|
||||
|
||||
if (!updateResponse.ok) {
|
||||
throw new Error('Failed to update tab');
|
||||
throw new Error('Failed to update zone');
|
||||
}
|
||||
|
||||
// Reload the tab content to show the new preset
|
||||
// Reload the zone content to show the new preset
|
||||
if (typeof renderTabPresets === 'function') {
|
||||
await renderTabPresets(tabId);
|
||||
await renderTabPresets(zoneId);
|
||||
} else if (window.htmx) {
|
||||
htmx.ajax('GET', `/tabs/${tabId}/content-fragment`, {
|
||||
target: '#tab-content',
|
||||
htmx.ajax('GET', `/zones/${zoneId}/content-fragment`, {
|
||||
target: '#zone-content',
|
||||
swap: 'innerHTML'
|
||||
});
|
||||
} else {
|
||||
@@ -1144,8 +1144,8 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
window.location.reload();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to add preset to tab:', error);
|
||||
alert('Failed to add preset to tab.');
|
||||
console.error('Failed to add preset to zone:', error);
|
||||
alert('Failed to add preset to zone.');
|
||||
}
|
||||
};
|
||||
try {
|
||||
@@ -1269,8 +1269,8 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
alert('Preset name is required to send.');
|
||||
return;
|
||||
}
|
||||
// Send current editor values and then select on all devices in the current tab (if any)
|
||||
const section = document.querySelector('.presets-section[data-tab-id]');
|
||||
// Send current editor values and then select on all devices in the current zone (if any)
|
||||
const section = document.querySelector('.presets-section[data-zone-id]');
|
||||
const deviceNames = tabDeviceNamesFromSection(section);
|
||||
// Work out the preset ID: for existing presets use currentEditId, otherwise fall back to name
|
||||
const presetId = currentEditId || payload.name;
|
||||
@@ -1286,7 +1286,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
alert('Preset name is required.');
|
||||
return;
|
||||
}
|
||||
const section = document.querySelector('.presets-section[data-tab-id]');
|
||||
const section = document.querySelector('.presets-section[data-zone-id]');
|
||||
const deviceNames = tabDeviceNamesFromSection(section);
|
||||
const presetId = currentEditId || payload.name;
|
||||
await updateTabDefaultPreset(presetId);
|
||||
@@ -1297,7 +1297,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
if (presetRemoveFromTabButton) {
|
||||
presetRemoveFromTabButton.addEventListener('click', async () => {
|
||||
if (!currentEditTabId || !currentEditId) return;
|
||||
if (!window.confirm('Remove this preset from this tab?')) return;
|
||||
if (!window.confirm('Remove this preset from this zone?')) return;
|
||||
await removePresetFromTab(currentEditTabId, currentEditId);
|
||||
clearForm();
|
||||
closeEditor();
|
||||
@@ -1348,12 +1348,12 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
clearForm();
|
||||
closeEditor();
|
||||
|
||||
// Reload tab presets if we're in a tab view
|
||||
const leftPanel = document.querySelector('.presets-section[data-tab-id]');
|
||||
// Reload zone presets if we're in a zone view
|
||||
const leftPanel = document.querySelector('.presets-section[data-zone-id]');
|
||||
if (leftPanel) {
|
||||
const tabId = leftPanel.dataset.tabId;
|
||||
if (tabId && typeof renderTabPresets !== 'undefined') {
|
||||
renderTabPresets(tabId);
|
||||
const zoneId = leftPanel.dataset.zoneId;
|
||||
if (zoneId && typeof renderTabPresets !== 'undefined') {
|
||||
renderTabPresets(zoneId);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -1362,11 +1362,11 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
}
|
||||
});
|
||||
|
||||
// Listen for edit preset events from tab preset buttons
|
||||
// Listen for edit preset events from zone preset buttons
|
||||
document.addEventListener('editPreset', async (event) => {
|
||||
const { presetId, preset, tabId } = event.detail;
|
||||
const { presetId, preset, zoneId } = event.detail;
|
||||
currentEditId = presetId;
|
||||
currentEditTabId = tabId || null;
|
||||
currentEditTabId = zoneId || null;
|
||||
await loadPatterns();
|
||||
const paletteColors = await getCurrentProfilePaletteColors();
|
||||
setFormValues({
|
||||
@@ -1478,11 +1478,11 @@ const sendDefaultPreset = async (presetId, deviceNames) => {
|
||||
}
|
||||
};
|
||||
|
||||
// Expose for other scripts (tabs.js) so they can reuse the shared WebSocket.
|
||||
// Expose for other scripts (zones.js) so they can reuse the shared WebSocket.
|
||||
try {
|
||||
window.sendPresetViaEspNow = sendPresetViaEspNow;
|
||||
window.postDriverSequence = postDriverSequence;
|
||||
// Expose a generic ESPNow sender so other scripts (tabs.js) can send
|
||||
// Expose a generic ESPNow sender so other scripts (zones.js) can send
|
||||
// non-preset messages such as global brightness.
|
||||
window.sendEspnowRaw = sendEspnowMessage;
|
||||
window.getEspnowSocket = getEspnowSocket;
|
||||
@@ -1490,9 +1490,9 @@ try {
|
||||
// window may not exist in some environments; ignore.
|
||||
}
|
||||
|
||||
// Store selected preset per tab
|
||||
// Store selected preset per zone
|
||||
const selectedPresets = {};
|
||||
// Run vs Edit for tab preset strip (in-memory only — each full page load starts in run mode)
|
||||
// Run vs Edit for zone preset strip (in-memory only — each full page load starts in run mode)
|
||||
let presetUiMode = 'run';
|
||||
|
||||
const getPresetUiMode = () => (presetUiMode === 'edit' ? 'edit' : 'run');
|
||||
@@ -1559,15 +1559,15 @@ const arrayToGrid = (presetIds, columns = 3) => {
|
||||
return grid;
|
||||
};
|
||||
|
||||
// Function to save preset grid for a tab
|
||||
const savePresetGrid = async (tabId, presetGrid) => {
|
||||
// Function to save preset grid for a zone
|
||||
const savePresetGrid = async (zoneId, presetGrid) => {
|
||||
try {
|
||||
// Get current tab data
|
||||
const tabResponse = await fetch(`/tabs/${tabId}`, {
|
||||
// Get current zone data
|
||||
const tabResponse = await fetch(`/zones/${zoneId}`, {
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
if (!tabResponse.ok) {
|
||||
throw new Error('Failed to load tab');
|
||||
throw new Error('Failed to load zone');
|
||||
}
|
||||
const tabData = await tabResponse.json();
|
||||
|
||||
@@ -1576,8 +1576,8 @@ const savePresetGrid = async (tabId, presetGrid) => {
|
||||
// Also store as flat array for backward compatibility
|
||||
tabData.presets_flat = presetGrid.flat();
|
||||
|
||||
// Save updated tab
|
||||
const updateResponse = await fetch(`/tabs/${tabId}`, {
|
||||
// Save updated zone
|
||||
const updateResponse = await fetch(`/zones/${zoneId}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(tabData),
|
||||
@@ -1631,18 +1631,18 @@ const insertDraggingOntoTarget = (presetsList, dragging, dropTarget) => {
|
||||
}
|
||||
};
|
||||
|
||||
// Function to render presets for a specific tab in 2D grid
|
||||
const renderTabPresets = async (tabId) => {
|
||||
const presetsList = document.getElementById('presets-list-tab');
|
||||
// Function to render presets for a specific zone in 2D grid
|
||||
const renderTabPresets = async (zoneId) => {
|
||||
const presetsList = document.getElementById('presets-list-zone');
|
||||
if (!presetsList) return;
|
||||
|
||||
try {
|
||||
// Get tab data to see which presets are associated
|
||||
const tabResponse = await fetch(`/tabs/${tabId}`, {
|
||||
// Get zone data to see which presets are associated
|
||||
const tabResponse = await fetch(`/zones/${zoneId}`, {
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
if (!tabResponse.ok) {
|
||||
throw new Error('Failed to load tab');
|
||||
throw new Error('Failed to load zone');
|
||||
}
|
||||
const tabData = await tabResponse.json();
|
||||
|
||||
@@ -1669,7 +1669,7 @@ const renderTabPresets = async (tabId) => {
|
||||
const paletteColors = await getCurrentProfilePaletteColors();
|
||||
|
||||
presetsList.innerHTML = '';
|
||||
presetsList.dataset.reorderTabId = tabId;
|
||||
presetsList.dataset.reorderTabId = zoneId;
|
||||
|
||||
// Drag-and-drop on the list (wire once — re-render would duplicate listeners otherwise)
|
||||
if (!presetsList.dataset.dragWired) {
|
||||
@@ -1719,7 +1719,7 @@ const renderTabPresets = async (tabId) => {
|
||||
|
||||
try {
|
||||
if (!saveId) {
|
||||
console.warn('No tab id for preset reorder save');
|
||||
console.warn('No zone id for preset reorder save');
|
||||
return;
|
||||
}
|
||||
await savePresetGrid(saveId, newGrid);
|
||||
@@ -1733,19 +1733,19 @@ const renderTabPresets = async (tabId) => {
|
||||
});
|
||||
}
|
||||
|
||||
// Get the currently selected preset for this tab
|
||||
const selectedPresetId = selectedPresets[tabId];
|
||||
// Get the currently selected preset for this zone
|
||||
const selectedPresetId = selectedPresets[zoneId];
|
||||
|
||||
// Render presets in grid layout
|
||||
// Flatten the grid and render all presets (grid CSS will handle layout)
|
||||
const flatPresets = presetGrid.flat().filter(id => id);
|
||||
|
||||
if (flatPresets.length === 0) {
|
||||
// Show empty message if this tab has no presets
|
||||
// Show empty message if this zone has no presets
|
||||
const empty = document.createElement('p');
|
||||
empty.className = 'muted-text';
|
||||
empty.style.gridColumn = '1 / -1'; // Span all columns
|
||||
empty.textContent = 'No presets added to this tab. Open the tab\'s Edit menu and click "Add Preset" to add one.';
|
||||
empty.textContent = 'No presets added to this zone. Open the zone\'s Edit menu and click "Add Preset" to add one.';
|
||||
presetsList.appendChild(empty);
|
||||
} else {
|
||||
flatPresets.forEach((presetId) => {
|
||||
@@ -1756,18 +1756,18 @@ const renderTabPresets = async (tabId) => {
|
||||
...preset,
|
||||
colors: resolveColorsWithPaletteRefs(preset.colors, preset.palette_refs, paletteColors),
|
||||
};
|
||||
const wrapper = createPresetButton(presetId, displayPreset, tabId, isSelected);
|
||||
const wrapper = createPresetButton(presetId, displayPreset, zoneId, isSelected);
|
||||
presetsList.appendChild(wrapper);
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to render tab presets:', error);
|
||||
console.error('Failed to render zone presets:', error);
|
||||
presetsList.innerHTML = '<p class="muted-text">Failed to load presets.</p>';
|
||||
}
|
||||
};
|
||||
|
||||
const createPresetButton = (presetId, preset, tabId, isSelected = false) => {
|
||||
const createPresetButton = (presetId, preset, zoneId, isSelected = false) => {
|
||||
const uiMode = getPresetUiMode();
|
||||
|
||||
const row = document.createElement('div');
|
||||
@@ -1806,12 +1806,12 @@ const createPresetButton = (presetId, preset, tabId, isSelected = false) => {
|
||||
|
||||
button.addEventListener('click', () => {
|
||||
if (isDraggingPreset) return;
|
||||
const presetsListEl = document.getElementById('presets-list-tab');
|
||||
const presetsListEl = document.getElementById('presets-list-zone');
|
||||
if (presetsListEl) {
|
||||
presetsListEl.querySelectorAll('.pattern-button').forEach((btn) => btn.classList.remove('active'));
|
||||
}
|
||||
button.classList.add('active');
|
||||
selectedPresets[tabId] = presetId;
|
||||
selectedPresets[zoneId] = presetId;
|
||||
const section = row.closest('.presets-section');
|
||||
sendSelectForCurrentTabDevices(presetId, section).catch((err) => {
|
||||
console.error(err);
|
||||
@@ -1828,7 +1828,7 @@ const createPresetButton = (presetId, preset, tabId, isSelected = false) => {
|
||||
|
||||
row.addEventListener('dragend', () => {
|
||||
row.classList.remove('dragging');
|
||||
const presetsListEl = document.getElementById('presets-list-tab');
|
||||
const presetsListEl = document.getElementById('presets-list-zone');
|
||||
if (presetsListEl) {
|
||||
delete presetsListEl.dataset.dropTargetId;
|
||||
}
|
||||
@@ -1854,7 +1854,7 @@ const createPresetButton = (presetId, preset, tabId, isSelected = false) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (isDraggingPreset) return;
|
||||
editPresetFromTab(presetId, tabId, preset);
|
||||
editPresetFromTab(presetId, zoneId, preset);
|
||||
});
|
||||
|
||||
actions.appendChild(editBtn);
|
||||
@@ -1864,7 +1864,7 @@ const createPresetButton = (presetId, preset, tabId, isSelected = false) => {
|
||||
return row;
|
||||
};
|
||||
|
||||
const editPresetFromTab = async (presetId, tabId, existingPreset) => {
|
||||
const editPresetFromTab = async (presetId, zoneId, existingPreset) => {
|
||||
try {
|
||||
let preset = existingPreset;
|
||||
if (!preset) {
|
||||
@@ -1880,7 +1880,7 @@ const editPresetFromTab = async (presetId, tabId, existingPreset) => {
|
||||
|
||||
// Dispatch a custom event to trigger the edit in the DOMContentLoaded scope
|
||||
const editEvent = new CustomEvent('editPreset', {
|
||||
detail: { presetId, preset, tabId }
|
||||
detail: { presetId, preset, zoneId }
|
||||
});
|
||||
document.dispatchEvent(editEvent);
|
||||
} catch (error) {
|
||||
@@ -1889,36 +1889,36 @@ const editPresetFromTab = async (presetId, tabId, existingPreset) => {
|
||||
}
|
||||
};
|
||||
|
||||
// Remove a preset from a specific tab (does not delete the preset itself)
|
||||
// Expected call style: removePresetFromTab(tabId, presetId)
|
||||
const removePresetFromTab = async (tabId, presetId) => {
|
||||
if (!tabId) {
|
||||
// Try to get tab ID from the left-panel
|
||||
const leftPanel = document.querySelector('.presets-section[data-tab-id]');
|
||||
tabId = leftPanel ? leftPanel.dataset.tabId : null;
|
||||
// Remove a preset from a specific zone (does not delete the preset itself)
|
||||
// Expected call style: removePresetFromTab(zoneId, presetId)
|
||||
const removePresetFromTab = async (zoneId, presetId) => {
|
||||
if (!zoneId) {
|
||||
// Try to get zone ID from the left-panel
|
||||
const leftPanel = document.querySelector('.presets-section[data-zone-id]');
|
||||
zoneId = leftPanel ? leftPanel.dataset.zoneId : null;
|
||||
|
||||
if (!tabId) {
|
||||
if (!zoneId) {
|
||||
// Fallback: try to get from URL
|
||||
const pathParts = window.location.pathname.split('/');
|
||||
const tabIndex = pathParts.indexOf('tabs');
|
||||
const tabIndex = pathParts.indexOf('zones');
|
||||
if (tabIndex !== -1 && tabIndex + 1 < pathParts.length) {
|
||||
tabId = pathParts[tabIndex + 1];
|
||||
zoneId = pathParts[tabIndex + 1];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!tabId) {
|
||||
alert('Could not determine current tab.');
|
||||
if (!zoneId) {
|
||||
alert('Could not determine current zone.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Get current tab data
|
||||
const tabResponse = await fetch(`/tabs/${tabId}`, {
|
||||
// Get current zone data
|
||||
const tabResponse = await fetch(`/zones/${zoneId}`, {
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
if (!tabResponse.ok) {
|
||||
throw new Error('Failed to load tab');
|
||||
throw new Error('Failed to load zone');
|
||||
}
|
||||
const tabData = await tabResponse.json();
|
||||
|
||||
@@ -1937,7 +1937,7 @@ const removePresetFromTab = async (tabId, presetId) => {
|
||||
const beforeLen = flat.length;
|
||||
flat = flat.filter(id => String(id) !== String(presetId));
|
||||
if (flat.length === beforeLen) {
|
||||
alert('Preset is not in this tab.');
|
||||
alert('Preset is not in this zone.');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1945,19 +1945,19 @@ const removePresetFromTab = async (tabId, presetId) => {
|
||||
tabData.presets = newGrid;
|
||||
tabData.presets_flat = flat;
|
||||
|
||||
const updateResponse = await fetch(`/tabs/${tabId}`, {
|
||||
const updateResponse = await fetch(`/zones/${zoneId}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(tabData),
|
||||
});
|
||||
if (!updateResponse.ok) {
|
||||
throw new Error('Failed to update tab presets');
|
||||
throw new Error('Failed to update zone presets');
|
||||
}
|
||||
|
||||
await renderTabPresets(tabId);
|
||||
await renderTabPresets(zoneId);
|
||||
} catch (error) {
|
||||
console.error('Failed to remove preset from tab:', error);
|
||||
alert('Failed to remove preset from tab.');
|
||||
console.error('Failed to remove preset from zone:', error);
|
||||
alert('Failed to remove preset from zone.');
|
||||
}
|
||||
};
|
||||
try {
|
||||
@@ -1966,13 +1966,13 @@ try {
|
||||
|
||||
// Listen for HTMX swaps to render presets
|
||||
document.body.addEventListener('htmx:afterSwap', (event) => {
|
||||
if (event.target && event.target.id === 'tab-content') {
|
||||
// Get tab ID from the left-panel
|
||||
const leftPanel = document.querySelector('.presets-section[data-tab-id]');
|
||||
if (event.target && event.target.id === 'zone-content') {
|
||||
// Get zone ID from the left-panel
|
||||
const leftPanel = document.querySelector('.presets-section[data-zone-id]');
|
||||
if (leftPanel) {
|
||||
const tabId = leftPanel.dataset.tabId;
|
||||
if (tabId) {
|
||||
renderTabPresets(tabId);
|
||||
const zoneId = leftPanel.dataset.zoneId;
|
||||
if (zoneId) {
|
||||
renderTabPresets(zoneId);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1993,9 +1993,9 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
}
|
||||
const mainMenu = document.getElementById('main-menu-dropdown');
|
||||
if (mainMenu) mainMenu.classList.remove('open');
|
||||
const leftPanel = document.querySelector('.presets-section[data-tab-id]');
|
||||
const leftPanel = document.querySelector('.presets-section[data-zone-id]');
|
||||
if (leftPanel) {
|
||||
renderTabPresets(leftPanel.dataset.tabId);
|
||||
renderTabPresets(leftPanel.dataset.zoneId);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -35,8 +35,8 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
};
|
||||
|
||||
const refreshTabsForActiveProfile = async () => {
|
||||
// Clear stale current tab so tab controller falls back to first tab of applied profile.
|
||||
document.cookie = "current_tab=; path=/; max-age=0";
|
||||
// Clear stale current zone so zone controller falls back to first zone of applied profile.
|
||||
document.cookie = "current_zone=; path=/; max-age=0";
|
||||
|
||||
if (window.tabsManager && typeof window.tabsManager.loadTabs === "function") {
|
||||
await window.tabsManager.loadTabs();
|
||||
@@ -231,7 +231,7 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
name,
|
||||
seed_dj_tab: !!(newProfileSeedDjInput && newProfileSeedDjInput.checked),
|
||||
seed_dj_zone: !!(newProfileSeedDjInput && newProfileSeedDjInput.checked),
|
||||
}),
|
||||
});
|
||||
if (!response.ok) {
|
||||
|
||||
@@ -203,7 +203,7 @@ body.preset-ui-run .edit-mode-only {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.tabs-container {
|
||||
.zones-container {
|
||||
background-color: transparent;
|
||||
padding: 0.5rem 0;
|
||||
flex: 1;
|
||||
@@ -213,7 +213,7 @@ body.preset-ui-run .edit-mode-only {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.tabs-list {
|
||||
.zones-list {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
overflow-x: auto;
|
||||
@@ -222,7 +222,7 @@ body.preset-ui-run .edit-mode-only {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.tab-button {
|
||||
.zone-button {
|
||||
padding: 0.5rem 1rem;
|
||||
background-color: #3a3a3a;
|
||||
color: white;
|
||||
@@ -234,16 +234,16 @@ body.preset-ui-run .edit-mode-only {
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.tab-button:hover {
|
||||
.zone-button:hover {
|
||||
background-color: #4a4a4a;
|
||||
}
|
||||
|
||||
.tab-button.active {
|
||||
.zone-button.active {
|
||||
background-color: #6a5acd;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.tab-content {
|
||||
.zone-content {
|
||||
flex: 1;
|
||||
display: block;
|
||||
overflow-y: auto;
|
||||
@@ -255,7 +255,7 @@ body.preset-ui-run .edit-mode-only {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.tab-brightness-group {
|
||||
.zone-brightness-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
@@ -263,7 +263,7 @@ body.preset-ui-run .edit-mode-only {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.tab-brightness-group label {
|
||||
.zone-brightness-group label {
|
||||
white-space: nowrap;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
@@ -509,8 +509,8 @@ body.preset-ui-run .edit-mode-only {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* Tab preset selecting area: 3 columns, vertical scroll only */
|
||||
#presets-list-tab {
|
||||
/* Zone preset selecting area: 3 columns, vertical scroll only */
|
||||
#presets-list-zone {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
@@ -750,8 +750,8 @@ body.preset-ui-run .edit-mode-only {
|
||||
background-color: #5a4f9f;
|
||||
}
|
||||
|
||||
/* Preset select buttons inside the tab grid */
|
||||
#presets-list-tab .pattern-button {
|
||||
/* Preset select buttons inside the zone grid */
|
||||
#presets-list-zone .pattern-button {
|
||||
display: flex;
|
||||
}
|
||||
.pattern-button .pattern-button-label {
|
||||
@@ -966,12 +966,12 @@ body.preset-ui-run .edit-mode-only {
|
||||
padding: 0.4rem 0.7rem;
|
||||
}
|
||||
|
||||
.tabs-container {
|
||||
.zones-container {
|
||||
padding: 0.5rem 0;
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.tab-content {
|
||||
.zone-content {
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
@@ -1064,24 +1064,24 @@ body.preset-ui-run .edit-mode-only {
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.tab-modal-create-row {
|
||||
.zone-modal-create-row {
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.tab-modal-create-row input[type="text"] {
|
||||
.zone-modal-create-row input[type="text"] {
|
||||
flex: 1;
|
||||
min-width: 8rem;
|
||||
}
|
||||
|
||||
.tab-devices-label {
|
||||
.zone-devices-label {
|
||||
display: block;
|
||||
margin-top: 0.75rem;
|
||||
margin-bottom: 0.35rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.tab-devices-editor {
|
||||
.zone-devices-editor {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
@@ -1090,12 +1090,12 @@ body.preset-ui-run .edit-mode-only {
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.tab-device-row-label {
|
||||
.zone-device-row-label {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.tab-device-add-select {
|
||||
.zone-device-add-select {
|
||||
flex: 1;
|
||||
min-width: 10rem;
|
||||
padding: 0.5rem;
|
||||
@@ -1105,19 +1105,19 @@ body.preset-ui-run .edit-mode-only {
|
||||
color: white;
|
||||
}
|
||||
|
||||
.tab-devices-add {
|
||||
.zone-devices-add {
|
||||
margin-top: 0;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.tab-presets-section-label {
|
||||
.zone-presets-section-label {
|
||||
display: block;
|
||||
margin-top: 1rem;
|
||||
margin-bottom: 0.35rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.edit-tab-presets-scroll {
|
||||
.edit-zone-presets-scroll {
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
margin-bottom: 1rem;
|
||||
@@ -1195,7 +1195,7 @@ body.preset-ui-run .edit-mode-only {
|
||||
}
|
||||
/* Presets list: 3 columns and vertical scroll (defined above); mobile same */
|
||||
@media (max-width: 800px) {
|
||||
#presets-list-tab {
|
||||
#presets-list-zone {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
@@ -1234,8 +1234,8 @@ body.preset-ui-run .edit-mode-only {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
/* Tab content placeholder (no tab selected) */
|
||||
.tab-content-placeholder {
|
||||
/* Zone content placeholder (no zone selected) */
|
||||
.zone-content-placeholder {
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
color: #aaa;
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
/* General tab styles */
|
||||
/* General zone styles */
|
||||
.tabs {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.tab {
|
||||
.zone {
|
||||
padding: 10px 20px;
|
||||
margin: 0 10px;
|
||||
cursor: pointer;
|
||||
@@ -15,23 +15,23 @@
|
||||
transition: background-color 0.3s ease;
|
||||
}
|
||||
|
||||
.tab:hover {
|
||||
.zone:hover {
|
||||
background-color: #ddd;
|
||||
}
|
||||
|
||||
.tab.active {
|
||||
.zone.active {
|
||||
background-color: #ccc;
|
||||
}
|
||||
|
||||
.tab-content {
|
||||
.zone-content {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.tab-pane {
|
||||
.zone-pane {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.tab-pane.active {
|
||||
.zone-pane.active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
@@ -1,24 +1,24 @@
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
let selectedIndex = null;
|
||||
|
||||
const getTab = async (tabId) => {
|
||||
const response = await fetch(`/tabs/${tabId}`, {
|
||||
const getTab = async (zoneId) => {
|
||||
const response = await fetch(`/zones/${zoneId}`, {
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error('No tab found');
|
||||
throw new Error('No zone found');
|
||||
}
|
||||
return response.json();
|
||||
};
|
||||
|
||||
const saveTabColors = async (tabId, colors) => {
|
||||
const response = await fetch(`/tabs/${tabId}`, {
|
||||
const saveTabColors = async (zoneId, colors) => {
|
||||
const response = await fetch(`/zones/${zoneId}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ colors }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to save tab colors');
|
||||
throw new Error('Failed to save zone colors');
|
||||
}
|
||||
return response.json();
|
||||
};
|
||||
@@ -101,23 +101,23 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
|
||||
const initTabPalette = async () => {
|
||||
const paletteContainer = document.getElementById('color-palette');
|
||||
const addButton = document.getElementById('tab-color-add-btn');
|
||||
const addFromPaletteButton = document.getElementById('tab-color-add-from-palette-btn');
|
||||
const colorInput = document.getElementById('tab-color-input');
|
||||
const addButton = document.getElementById('zone-color-add-btn');
|
||||
const addFromPaletteButton = document.getElementById('zone-color-add-from-palette-btn');
|
||||
const colorInput = document.getElementById('zone-color-input');
|
||||
|
||||
if (!paletteContainer || !addButton || !colorInput) {
|
||||
return;
|
||||
}
|
||||
|
||||
const tabId = paletteContainer.dataset.tabId;
|
||||
if (!tabId) {
|
||||
const zoneId = paletteContainer.dataset.zoneId;
|
||||
if (!zoneId) {
|
||||
renderPalette(paletteContainer, []);
|
||||
return;
|
||||
}
|
||||
|
||||
let tabData;
|
||||
try {
|
||||
tabData = await getTab(tabId);
|
||||
tabData = await getTab(zoneId);
|
||||
} catch (error) {
|
||||
renderPalette(paletteContainer, []);
|
||||
return;
|
||||
@@ -134,7 +134,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
}
|
||||
try {
|
||||
const updated = colors.filter((_, i) => i !== index);
|
||||
const saved = await saveTabColors(tabId, updated);
|
||||
const saved = await saveTabColors(zoneId, updated);
|
||||
colors = saved.colors || updated;
|
||||
selectedIndex = null;
|
||||
renderPalette(paletteContainer, colors, onColorChange, onRemoveColor, onReorder);
|
||||
@@ -152,7 +152,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
const updated = [...colors];
|
||||
const [moved] = updated.splice(fromIndex, 1);
|
||||
updated.splice(toIndex, 0, moved);
|
||||
const saved = await saveTabColors(tabId, updated);
|
||||
const saved = await saveTabColors(zoneId, updated);
|
||||
colors = saved.colors || updated;
|
||||
selectedIndex = toIndex;
|
||||
renderPalette(paletteContainer, colors, onColorChange, onRemoveColor, onReorder);
|
||||
@@ -169,7 +169,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
try {
|
||||
const updated = [...colors];
|
||||
updated[index] = newColor;
|
||||
const saved = await saveTabColors(tabId, updated);
|
||||
const saved = await saveTabColors(zoneId, updated);
|
||||
colors = saved.colors || updated;
|
||||
selectedIndex = index;
|
||||
renderPalette(paletteContainer, colors, onColorChange, onRemoveColor, onReorder);
|
||||
@@ -192,7 +192,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
}
|
||||
try {
|
||||
const updated = [...colors, newColor];
|
||||
const saved = await saveTabColors(tabId, updated);
|
||||
const saved = await saveTabColors(zoneId, updated);
|
||||
colors = saved.colors || updated;
|
||||
selectedIndex = colors.length - 1;
|
||||
renderPalette(paletteContainer, colors, onColorChange, onRemoveColor, onReorder);
|
||||
@@ -229,7 +229,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
try {
|
||||
if (!colors.includes(picked)) {
|
||||
const updated = [...colors, picked];
|
||||
const saved = await saveTabColors(tabId, updated);
|
||||
const saved = await saveTabColors(zoneId, updated);
|
||||
colors = saved.colors || updated;
|
||||
selectedIndex = colors.indexOf(picked);
|
||||
renderPalette(paletteContainer, colors, onColorChange, onRemoveColor, onReorder);
|
||||
@@ -252,7 +252,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
};
|
||||
|
||||
document.body.addEventListener('htmx:afterSwap', (event) => {
|
||||
if (event.target && event.target.id === 'tab-content') {
|
||||
if (event.target && event.target.id === 'zone-content') {
|
||||
selectedIndex = null;
|
||||
initTabPalette();
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3,21 +3,21 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>LED Controller - Tab Mode</title>
|
||||
<title>LED Controller - Zone Mode</title>
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="app-container">
|
||||
<header>
|
||||
<div class="tabs-container">
|
||||
<div id="tabs-list">
|
||||
Loading tabs...
|
||||
<div class="zones-container">
|
||||
<div id="zones-list">
|
||||
Loading zones...
|
||||
</div>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<button class="btn btn-secondary" id="profiles-btn">Profiles</button>
|
||||
<button class="btn btn-secondary edit-mode-only" id="devices-btn">Devices</button>
|
||||
<button class="btn btn-secondary edit-mode-only" id="tabs-btn">Tabs</button>
|
||||
<button class="btn btn-secondary edit-mode-only" id="zones-btn">Zones</button>
|
||||
<button class="btn btn-secondary edit-mode-only" id="presets-btn">Presets</button>
|
||||
<button class="btn btn-secondary edit-mode-only" id="patterns-btn">Patterns</button>
|
||||
<button class="btn btn-secondary edit-mode-only" id="color-palette-btn">Colour Palette</button>
|
||||
@@ -31,7 +31,7 @@
|
||||
<button type="button" class="ui-mode-toggle" id="ui-mode-toggle-mobile" aria-pressed="false">Edit mode</button>
|
||||
<button type="button" data-target="profiles-btn">Profiles</button>
|
||||
<button type="button" class="edit-mode-only" data-target="devices-btn">Devices</button>
|
||||
<button type="button" class="edit-mode-only" data-target="tabs-btn">Tabs</button>
|
||||
<button type="button" class="edit-mode-only" data-target="zones-btn">Tabs</button>
|
||||
<button type="button" class="edit-mode-only" data-target="presets-btn">Presets</button>
|
||||
<button type="button" class="edit-mode-only" data-target="patterns-btn">Patterns</button>
|
||||
<button type="button" class="edit-mode-only" data-target="color-palette-btn">Colour Palette</button>
|
||||
@@ -42,47 +42,47 @@
|
||||
</header>
|
||||
|
||||
<div class="main-content">
|
||||
<div id="tab-content" class="tab-content">
|
||||
<div class="tab-content-placeholder">
|
||||
Select a tab to get started
|
||||
<div id="zone-content" class="zone-content">
|
||||
<div class="zone-content-placeholder">
|
||||
Select a zone to get started
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tabs Modal -->
|
||||
<div id="tabs-modal" class="modal">
|
||||
<div id="zones-modal" class="modal">
|
||||
<div class="modal-content">
|
||||
<h2>Tabs</h2>
|
||||
<div class="profiles-actions tab-modal-create-row">
|
||||
<input type="text" id="new-tab-name" placeholder="Tab name">
|
||||
<button class="btn btn-primary" id="create-tab-btn">Create</button>
|
||||
<div class="profiles-actions zone-modal-create-row">
|
||||
<input type="text" id="new-zone-name" placeholder="Zone name">
|
||||
<button class="btn btn-primary" id="create-zone-btn">Create</button>
|
||||
</div>
|
||||
<div id="tabs-list-modal" class="profiles-list"></div>
|
||||
<div id="zones-list-modal" class="profiles-list"></div>
|
||||
<div class="modal-actions">
|
||||
<button class="btn btn-secondary" id="tabs-close-btn">Close</button>
|
||||
<button class="btn btn-secondary" id="zones-close-btn">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Edit Tab Modal -->
|
||||
<div id="edit-tab-modal" class="modal">
|
||||
<!-- Edit Zone Modal -->
|
||||
<div id="edit-zone-modal" class="modal">
|
||||
<div class="modal-content">
|
||||
<h2>Edit Tab</h2>
|
||||
<form id="edit-tab-form">
|
||||
<input type="hidden" id="edit-tab-id">
|
||||
<h2>Edit Zone</h2>
|
||||
<form id="edit-zone-form">
|
||||
<input type="hidden" id="edit-zone-id">
|
||||
<div class="modal-actions" style="margin-bottom: 1rem;">
|
||||
<button type="submit" class="btn btn-primary">Save</button>
|
||||
<button type="button" class="btn btn-secondary" onclick="document.getElementById('edit-tab-modal').classList.remove('active')">Close</button>
|
||||
<button type="button" class="btn btn-secondary" onclick="document.getElementById('edit-zone-modal').classList.remove('active')">Close</button>
|
||||
</div>
|
||||
<label>Tab Name:</label>
|
||||
<input type="text" id="edit-tab-name" placeholder="Enter tab name" required>
|
||||
<label class="tab-devices-label">Devices in this tab</label>
|
||||
<div id="edit-tab-devices-editor" class="tab-devices-editor"></div>
|
||||
<label class="tab-presets-section-label">Presets on this tab</label>
|
||||
<div id="edit-tab-presets-current" class="profiles-list edit-tab-presets-scroll"></div>
|
||||
<label class="tab-presets-section-label">Add presets to this tab</label>
|
||||
<div id="edit-tab-presets-list" class="profiles-list edit-tab-presets-scroll"></div>
|
||||
<label>Zone Name:</label>
|
||||
<input type="text" id="edit-zone-name" placeholder="Enter zone name" required>
|
||||
<label class="zone-devices-label">Devices in this zone</label>
|
||||
<div id="edit-zone-devices-editor" class="zone-devices-editor"></div>
|
||||
<label class="zone-presets-section-label">Presets on this zone</label>
|
||||
<div id="edit-zone-presets-current" class="profiles-list edit-zone-presets-scroll"></div>
|
||||
<label class="zone-presets-section-label">Add presets to this zone</label>
|
||||
<div id="edit-zone-presets-list" class="profiles-list edit-zone-presets-scroll"></div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
@@ -98,7 +98,7 @@
|
||||
<div class="form-group" style="margin-top: 0.5rem; margin-bottom: 0;">
|
||||
<label style="display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0;">
|
||||
<input type="checkbox" id="new-profile-seed-dj">
|
||||
DJ tab
|
||||
DJ zone
|
||||
</label>
|
||||
</div>
|
||||
<div id="profiles-list" class="profiles-list"></div>
|
||||
@@ -229,7 +229,7 @@
|
||||
<div class="modal-actions preset-editor-modal-actions">
|
||||
<button class="btn btn-secondary" id="preset-send-btn">Try</button>
|
||||
<button class="btn btn-secondary" id="preset-default-btn">Default</button>
|
||||
<button type="button" class="btn btn-danger" id="preset-remove-from-tab-btn" hidden>Remove from tab</button>
|
||||
<button type="button" class="btn btn-danger" id="preset-remove-from-zone-btn" hidden>Remove from zone</button>
|
||||
<button class="btn btn-primary" id="preset-save-btn">Save & Send</button>
|
||||
<button class="btn btn-secondary" id="preset-editor-close-btn">Close</button>
|
||||
</div>
|
||||
@@ -270,11 +270,11 @@
|
||||
|
||||
<h3>Run mode</h3>
|
||||
<ul>
|
||||
<li><strong>Select tab</strong>: left-click a tab button in the top bar.</li>
|
||||
<li><strong>Select preset</strong>: left-click a preset tile to send a <code>select</code> message to all devices in the tab.</li>
|
||||
<li><strong>Select zone</strong>: left-click a zone button in the top bar.</li>
|
||||
<li><strong>Select preset</strong>: left-click a preset tile to send a <code>select</code> message to all devices in the zone.</li>
|
||||
<li><strong>Profiles</strong>: open <strong>Profiles</strong> to apply a profile. Profile editing actions are hidden in Run mode.</li>
|
||||
<li><strong>Devices</strong>: open <strong>Devices</strong> to see drivers (Wi-Fi clients appear when they connect); edit or remove rows as needed.</li>
|
||||
<li><strong>Send all presets</strong>: this action is available in <strong>Edit mode</strong> and pushes every preset used in the current tab to all tab devices.</li>
|
||||
<li><strong>Send all presets</strong>: this action is available in <strong>Edit mode</strong> and pushes every preset used in the current zone to all zone devices.</li>
|
||||
<li><strong>Switch modes</strong>: use the mode button in the menu. The button label shows the mode you will switch to.</li>
|
||||
</ul>
|
||||
|
||||
@@ -283,8 +283,8 @@
|
||||
<li><strong>Tabs</strong>: create, edit, and manage tabs and device assignments.</li>
|
||||
<li><strong>Presets</strong>: create/manage reusable presets and edit preset details.</li>
|
||||
<li><strong>Preset tiles</strong>: each tile shows <strong>Edit</strong> and <strong>Remove</strong> controls in Edit mode.</li>
|
||||
<li><strong>Reorder presets</strong>: drag and drop preset tiles to save tab order.</li>
|
||||
<li><strong>Profiles</strong>: create/clone/delete profiles. New profiles get a populated <strong>default</strong> tab and can optionally seed a <strong>DJ tab</strong>.</li>
|
||||
<li><strong>Reorder presets</strong>: drag and drop preset tiles to save zone order.</li>
|
||||
<li><strong>Profiles</strong>: create/clone/delete profiles. New profiles get a populated <strong>default</strong> zone and can optionally seed a <strong>DJ zone</strong>.</li>
|
||||
<li><strong>Devices</strong>: view, edit, or remove registry entries (tabs use <strong>names</strong>; each row is keyed by <strong>MAC</strong>).</li>
|
||||
<li><strong>Colour Palette</strong>: build profile colours and use <strong>From Palette</strong> in preset editor to add linked colours (badge <strong>P</strong>) that update when palette colours change.</li>
|
||||
</ul>
|
||||
@@ -364,11 +364,11 @@
|
||||
</div>
|
||||
|
||||
<!-- Styles moved to /static/style.css -->
|
||||
<script src="/static/tabs.js"></script>
|
||||
<script src="/static/zones.js"></script>
|
||||
<script src="/static/help.js"></script>
|
||||
<script src="/static/color_palette.js"></script>
|
||||
<script src="/static/profiles.js"></script>
|
||||
<script src="/static/tab_palette.js"></script>
|
||||
<script src="/static/zone_palette.js"></script>
|
||||
<script src="/static/patterns.js"></script>
|
||||
<script src="/static/presets.js"></script>
|
||||
<script src="/static/devices.js"></script>
|
||||
|
||||
@@ -10,7 +10,7 @@ from test_preset import test_preset
|
||||
from test_profile import test_profile
|
||||
from test_group import test_group
|
||||
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_device import test_device
|
||||
|
||||
@@ -26,7 +26,7 @@ def run_all_tests():
|
||||
("Profile", test_profile),
|
||||
("Group", test_group),
|
||||
("Sequence", test_sequence),
|
||||
("Tab", test_tab),
|
||||
("Zone", test_zone),
|
||||
("Palette", test_palette),
|
||||
("Device", test_device),
|
||||
]
|
||||
|
||||
@@ -36,7 +36,7 @@ def test_device():
|
||||
|
||||
mac = "aabbccddeeff"
|
||||
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}")
|
||||
assert device_id == mac
|
||||
assert device_id in devices
|
||||
@@ -51,7 +51,7 @@ def test_device():
|
||||
assert device["transport"] == "espnow"
|
||||
assert device["address"] == mac
|
||||
assert device["default_pattern"] == "on"
|
||||
assert device["tabs"] == ["1", "2"]
|
||||
assert device["zones"] == ["1", "2"]
|
||||
|
||||
print("\nTesting read by colon MAC")
|
||||
assert devices.read("aa:bb:cc:dd:ee:ff")["id"] == mac
|
||||
@@ -65,14 +65,14 @@ def test_device():
|
||||
update_data = {
|
||||
"name": "Updated Device",
|
||||
"default_pattern": "rainbow",
|
||||
"tabs": ["1", "2", "3"],
|
||||
"zones": ["1", "2", "3"],
|
||||
}
|
||||
result = devices.update(device_id, update_data)
|
||||
assert result is True
|
||||
updated = devices.read(device_id)
|
||||
assert updated["name"] == "Updated Device"
|
||||
assert updated["default_pattern"] == "rainbow"
|
||||
assert len(updated["tabs"]) == 3
|
||||
assert len(updated["zones"]) == 3
|
||||
|
||||
print("\nTesting list devices")
|
||||
device_list = devices.list()
|
||||
|
||||
@@ -3,7 +3,7 @@ import os
|
||||
|
||||
def test_profile():
|
||||
"""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)
|
||||
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}")
|
||||
assert profile is not None
|
||||
assert profile["name"] == "test_profile"
|
||||
assert "tabs" in profile
|
||||
assert "zones" in profile
|
||||
assert "palette_id" in profile
|
||||
assert "type" in profile
|
||||
|
||||
print("\nTesting update profile")
|
||||
update_data = {
|
||||
"name": "updated_profile",
|
||||
"tabs": ["tab1"],
|
||||
"zones": ["tab1"],
|
||||
}
|
||||
result = profiles.update(profile_id, update_data)
|
||||
assert result is True
|
||||
updated = profiles.read(profile_id)
|
||||
assert updated["name"] == "updated_profile"
|
||||
assert "tab1" in updated["tabs"]
|
||||
assert "tab1" in updated["zones"]
|
||||
|
||||
print("\nTesting list profiles")
|
||||
profile_list = profiles.list()
|
||||
|
||||
@@ -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
57
tests/models/test_zone.py
Normal 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()
|
||||
@@ -162,13 +162,13 @@ class BrowserTest:
|
||||
print(f" ⚠ Failed to cleanup preset {preset_id}: {e}")
|
||||
|
||||
# Delete created tabs by ID
|
||||
for tab_id in self.created_tabs:
|
||||
for zone_id in self.created_tabs:
|
||||
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:
|
||||
print(f" ✓ Cleaned up tab: {tab_id}")
|
||||
print(f" ✓ Cleaned up zone: {zone_id}")
|
||||
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
|
||||
for profile_id in self.created_profiles:
|
||||
@@ -180,20 +180,20 @@ class BrowserTest:
|
||||
print(f" ⚠ Failed to cleanup profile {profile_id}: {e}")
|
||||
|
||||
# Also try to cleanup by name pattern (in case IDs weren't tracked)
|
||||
test_names = ['Browser Test Tab', 'Browser Test Profile', 'Browser Test Preset',
|
||||
'Preset 1', 'Preset 2', 'Preset 3', 'Edited Browser Tab']
|
||||
test_names = ['Browser Test Zone', 'Browser Test Profile', 'Browser Test Preset',
|
||||
'Preset 1', 'Preset 2', 'Preset 3', 'Edited Browser Zone']
|
||||
|
||||
# Cleanup tabs by name
|
||||
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:
|
||||
tabs_data = tabs_response.json()
|
||||
tabs = tabs_data.get('tabs', {})
|
||||
for tab_id, tab_data in tabs.items():
|
||||
tabs = tabs_data.get('zones', {})
|
||||
for zone_id, tab_data in zones.items():
|
||||
if isinstance(tab_data, dict) and tab_data.get('name') in test_names:
|
||||
try:
|
||||
session.delete(f"{self.base_url}/tabs/{tab_id}")
|
||||
print(f" ✓ Cleaned up tab by name: {tab_data.get('name')}")
|
||||
session.delete(f"{self.base_url}/zones/{zone_id}")
|
||||
print(f" ✓ Cleaned up zone by name: {tab_data.get('name')}")
|
||||
except:
|
||||
pass
|
||||
except:
|
||||
@@ -330,11 +330,11 @@ def test_tabs_ui(browser: BrowserTest) -> bool:
|
||||
|
||||
# Test 2: Open tabs modal
|
||||
total += 1
|
||||
if browser.click_element(By.ID, 'tabs-btn'):
|
||||
if browser.click_element(By.ID, 'zones-btn'):
|
||||
print("✓ Clicked Tabs button")
|
||||
# Wait for modal to appear
|
||||
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'):
|
||||
print("✓ Tabs modal opened")
|
||||
passed += 1
|
||||
@@ -343,58 +343,58 @@ def test_tabs_ui(browser: BrowserTest) -> bool:
|
||||
else:
|
||||
print("✗ Failed to click Tabs button")
|
||||
|
||||
# Test 3: Create a tab via UI
|
||||
# Test 3: Create a zone via UI
|
||||
total += 1
|
||||
try:
|
||||
# Fill in tab name
|
||||
if browser.fill_input(By.ID, 'new-tab-name', 'Browser Test Tab'):
|
||||
print(" ✓ Filled tab name")
|
||||
# Fill in zone name
|
||||
if browser.fill_input(By.ID, 'new-zone-name', 'Browser Test Zone'):
|
||||
print(" ✓ Filled zone name")
|
||||
# Devices default from registry or placeholder name "1"
|
||||
# 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")
|
||||
time.sleep(1) # Wait for creation
|
||||
# Check if tab appears in list and extract ID
|
||||
tabs_list = browser.wait_for_element(By.ID, 'tabs-list-modal')
|
||||
# Check if zone appears in list and extract ID
|
||||
tabs_list = browser.wait_for_element(By.ID, 'zones-list-modal')
|
||||
if tabs_list:
|
||||
list_text = tabs_list.text
|
||||
if 'Browser Test Tab' in list_text:
|
||||
print("✓ Created tab via UI")
|
||||
# Try to extract tab ID from the list (look for data-tab-id attribute)
|
||||
if 'Browser Test Zone' in list_text:
|
||||
print("✓ Created zone via UI")
|
||||
# Try to extract zone ID from the list (look for data-zone-id attribute)
|
||||
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:
|
||||
if 'Browser Test Tab' in row.text:
|
||||
tab_id = row.get_attribute('data-tab-id')
|
||||
if tab_id:
|
||||
browser.created_tabs.append(tab_id)
|
||||
if 'Browser Test Zone' in row.text:
|
||||
zone_id = row.get_attribute('data-zone-id')
|
||||
if zone_id:
|
||||
browser.created_tabs.append(zone_id)
|
||||
break
|
||||
except:
|
||||
pass # If we can't extract ID, cleanup will try by name
|
||||
passed += 1
|
||||
else:
|
||||
print("✗ Tab not found in list after creation")
|
||||
print("✗ Zone not found in list after creation")
|
||||
else:
|
||||
print("✗ Tabs list not found")
|
||||
else:
|
||||
print("✗ Failed to click create button")
|
||||
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
|
||||
try:
|
||||
# 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)
|
||||
browser.click_element(By.ID, 'tabs-btn')
|
||||
browser.click_element(By.ID, 'zones-btn')
|
||||
time.sleep(0.5)
|
||||
|
||||
# Right-click the row corresponding to 'Browser Test Tab'
|
||||
# Right-click the row corresponding to 'Browser Test Zone'
|
||||
try:
|
||||
tab_row = browser.driver.find_element(
|
||||
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:
|
||||
tab_row = None
|
||||
@@ -405,14 +405,14 @@ def test_tabs_ui(browser: BrowserTest) -> bool:
|
||||
time.sleep(0.5)
|
||||
|
||||
# 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:
|
||||
print("✓ Edit modal opened via right-click")
|
||||
# Fill in new name
|
||||
if browser.fill_input(By.ID, 'edit-tab-name', 'Edited Browser Tab'):
|
||||
print(" ✓ Filled new tab name")
|
||||
if browser.fill_input(By.ID, 'edit-zone-name', 'Edited Browser Zone'):
|
||||
print(" ✓ Filled new zone name")
|
||||
# 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:
|
||||
browser.driver.execute_script("arguments[0].submit();", edit_form)
|
||||
time.sleep(1) # Wait for update
|
||||
@@ -423,24 +423,24 @@ def test_tabs_ui(browser: BrowserTest) -> bool:
|
||||
else:
|
||||
print("✗ Edit modal didn't open after right-click")
|
||||
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:
|
||||
print(f"✗ Failed to edit tab via UI: {e}")
|
||||
print(f"✗ Failed to edit zone via UI: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
|
||||
# Test 5: Check current tab cookie
|
||||
# Test 5: Check current zone cookie
|
||||
total += 1
|
||||
cookie = browser.get_cookie('current_tab')
|
||||
cookie = browser.get_cookie('current_zone')
|
||||
if cookie:
|
||||
print(f"✓ Found current_tab cookie: {cookie.get('value')}")
|
||||
print(f"✓ Found current_zone cookie: {cookie.get('value')}")
|
||||
passed += 1
|
||||
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
|
||||
|
||||
# Close modal
|
||||
browser.click_element(By.ID, 'tabs-close-btn')
|
||||
browser.click_element(By.ID, 'zones-close-btn')
|
||||
|
||||
except Exception as 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():
|
||||
"""
|
||||
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.
|
||||
"""
|
||||
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)
|
||||
assert bt.navigate('/'), "Failed to load main page"
|
||||
|
||||
# Click the first tab button to load presets for that tab
|
||||
first_tab = bt.wait_for_element(By.CSS_SELECTOR, '.tab-button', timeout=10)
|
||||
assert first_tab is not None, "No tab buttons found"
|
||||
# Click the first zone button to load presets for that zone
|
||||
first_tab = bt.wait_for_element(By.CSS_SELECTOR, '.zone-button', timeout=10)
|
||||
assert first_tab is not None, "No zone buttons found"
|
||||
first_tab.click()
|
||||
time.sleep(1)
|
||||
|
||||
container = bt.wait_for_element(By.ID, 'presets-list-tab', timeout=10)
|
||||
assert container is not None, "presets-list-tab not found"
|
||||
container = bt.wait_for_element(By.ID, 'presets-list-zone', timeout=10)
|
||||
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
|
||||
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']
|
||||
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)
|
||||
|
||||
def test_preset_drag_and_drop(browser: BrowserTest) -> bool:
|
||||
"""Test dragging presets around in a tab."""
|
||||
print("\n=== Testing Preset Drag and Drop in Tab ===")
|
||||
"""Test dragging presets around in a zone."""
|
||||
print("\n=== Testing Preset Drag and Drop in Zone ===")
|
||||
passed = 0
|
||||
total = 0
|
||||
|
||||
@@ -769,7 +769,7 @@ def test_preset_drag_and_drop(browser: BrowserTest) -> bool:
|
||||
return False
|
||||
|
||||
try:
|
||||
# Test 1: Load page and ensure we have a tab
|
||||
# Test 1: Load page and ensure we have a zone
|
||||
total += 1
|
||||
if browser.navigate('/'):
|
||||
print("✓ Loaded main page")
|
||||
@@ -778,33 +778,33 @@ def test_preset_drag_and_drop(browser: BrowserTest) -> bool:
|
||||
browser.teardown()
|
||||
return False
|
||||
|
||||
# Test 2: Open tabs modal and create/select a tab
|
||||
# Test 2: Open tabs modal and create/select a zone
|
||||
total += 1
|
||||
browser.click_element(By.ID, 'tabs-btn')
|
||||
browser.click_element(By.ID, 'zones-btn')
|
||||
time.sleep(0.5)
|
||||
|
||||
# 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:
|
||||
# Create a tab
|
||||
browser.fill_input(By.ID, 'new-tab-name', 'Drag Test Tab')
|
||||
browser.click_element(By.ID, 'create-tab-btn')
|
||||
# Create a zone
|
||||
browser.fill_input(By.ID, 'new-zone-name', 'Drag Test Zone')
|
||||
browser.click_element(By.ID, 'create-zone-btn')
|
||||
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')]")
|
||||
if select_buttons:
|
||||
select_buttons[0].click()
|
||||
time.sleep(1)
|
||||
print("✓ Selected a tab")
|
||||
print("✓ Selected a zone")
|
||||
passed += 1
|
||||
else:
|
||||
print("✗ No tabs available to select")
|
||||
browser.click_element(By.ID, 'tabs-close-btn')
|
||||
browser.click_element(By.ID, 'zones-close-btn')
|
||||
browser.teardown()
|
||||
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)
|
||||
|
||||
# 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")
|
||||
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
|
||||
try:
|
||||
tab_id = browser.driver.execute_script(
|
||||
zone_id = browser.driver.execute_script(
|
||||
"return (window.tabsManager && window.tabsManager.getCurrentTabId && window.tabsManager.getCurrentTabId()) || null;"
|
||||
)
|
||||
if not tab_id:
|
||||
print("✗ Could not get current tab id")
|
||||
if not zone_id:
|
||||
print("✗ Could not get current zone id")
|
||||
else:
|
||||
browser.driver.execute_script(
|
||||
"if (window.tabsManager && window.tabsManager.openEditTabModal) { window.tabsManager.openEditTabModal(arguments[0], null); }",
|
||||
tab_id
|
||||
zone_id
|
||||
)
|
||||
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:
|
||||
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:
|
||||
browser.driver.execute_script("arguments[0].click();", select_buttons[0])
|
||||
time.sleep(1.5)
|
||||
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:
|
||||
browser.driver.execute_script("arguments[0].click();", select_buttons[0])
|
||||
time.sleep(1.5)
|
||||
browser.handle_alert(accept=True, timeout=1)
|
||||
print(" ✓ Added 2 presets to tab")
|
||||
print(" ✓ Added 2 presets to zone")
|
||||
passed += 1
|
||||
elif len(select_buttons) == 1:
|
||||
browser.driver.execute_script("arguments[0].click();", select_buttons[0])
|
||||
time.sleep(1.5)
|
||||
browser.handle_alert(accept=True, timeout=1)
|
||||
print(" ✓ Added 1 preset to tab")
|
||||
print(" ✓ Added 1 preset to zone")
|
||||
passed += 1
|
||||
else:
|
||||
print(" ⚠ No presets available to add (all already in tab)")
|
||||
print(" ⚠ No presets available to add (all already in zone)")
|
||||
else:
|
||||
print("✗ Edit tab presets list not found")
|
||||
print("✗ Edit zone presets list not found")
|
||||
except Exception as e:
|
||||
print(f"✗ Failed to add presets to tab: {e}")
|
||||
print(f"✗ Failed to add presets to zone: {e}")
|
||||
import traceback
|
||||
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
|
||||
try:
|
||||
# Wait for presets to load in the tab
|
||||
presets_list_tab = browser.wait_for_element(By.ID, 'presets-list-tab', timeout=5)
|
||||
# Wait for presets to load in the zone
|
||||
presets_list_tab = browser.wait_for_element(By.ID, 'presets-list-zone', timeout=5)
|
||||
if presets_list_tab:
|
||||
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
|
||||
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:
|
||||
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
|
||||
|
||||
# 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:
|
||||
new_order = [p.text for p in draggable_presets_after]
|
||||
print(f" New order: {new_order[:3]}")
|
||||
@@ -936,28 +936,28 @@ def test_preset_drag_and_drop(browser: BrowserTest) -> bool:
|
||||
else:
|
||||
print("✗ Presets disappeared after drag")
|
||||
elif len(draggable_presets) == 1:
|
||||
print(f"⚠ Only 1 preset found in tab (need 2 for drag test). Preset: {draggable_presets[0].text}")
|
||||
tab_id = browser.driver.execute_script(
|
||||
print(f"⚠ Only 1 preset found in zone (need 2 for drag test). Preset: {draggable_presets[0].text}")
|
||||
zone_id = browser.driver.execute_script(
|
||||
"return (window.tabsManager && window.tabsManager.getCurrentTabId && window.tabsManager.getCurrentTabId()) || null;"
|
||||
)
|
||||
if tab_id:
|
||||
if zone_id:
|
||||
browser.driver.execute_script(
|
||||
"if (window.tabsManager && window.tabsManager.openEditTabModal) { window.tabsManager.openEditTabModal(arguments[0], null); }",
|
||||
tab_id
|
||||
zone_id
|
||||
)
|
||||
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:
|
||||
print(" Attempting to add another preset...")
|
||||
browser.driver.execute_script("arguments[0].click();", select_buttons[0])
|
||||
time.sleep(1.5)
|
||||
browser.handle_alert(accept=True, timeout=1)
|
||||
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:
|
||||
pass
|
||||
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:
|
||||
print(" ✓ Added another preset, now testing drag...")
|
||||
source = draggable_presets[0]
|
||||
@@ -970,11 +970,11 @@ def test_preset_drag_and_drop(browser: BrowserTest) -> bool:
|
||||
else:
|
||||
print(f" ✗ Still only {len(draggable_presets)} preset(s) after adding")
|
||||
else:
|
||||
print(" ✗ No Add buttons found in Edit Tab modal")
|
||||
print(" ✗ No Add buttons found in Edit Zone modal")
|
||||
else:
|
||||
print(f"✗ No presets found in tab (found {len(draggable_presets)})")
|
||||
print(f"✗ No presets found in zone (found {len(draggable_presets)})")
|
||||
else:
|
||||
print("✗ Presets list in tab not found")
|
||||
print("✗ Presets list in zone not found")
|
||||
except Exception as e:
|
||||
print(f"✗ Drag and drop test error: {e}")
|
||||
import traceback
|
||||
|
||||
@@ -91,115 +91,115 @@ def test_tabs(client: TestClient) -> bool:
|
||||
# Test 1: List tabs
|
||||
total += 1
|
||||
try:
|
||||
response = client.get('/tabs')
|
||||
response = client.get('/zones')
|
||||
if response.status_code == 200:
|
||||
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
|
||||
else:
|
||||
print(f"✗ GET /tabs - Status: {response.status_code}")
|
||||
print(f"✗ GET /zones - Status: {response.status_code}")
|
||||
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
|
||||
try:
|
||||
tab_data = {
|
||||
"name": "Test Tab",
|
||||
"name": "Test Zone",
|
||||
"names": ["1", "2"]
|
||||
}
|
||||
response = client.post('/tabs', json_data=tab_data)
|
||||
response = client.post('/zones', json_data=tab_data)
|
||||
if response.status_code == 201:
|
||||
created_tab = response.json()
|
||||
# Response format: {tab_id: {tab_data}}
|
||||
# Response format: {zone_id: {tab_data}}
|
||||
if isinstance(created_tab, dict):
|
||||
# Get the first key which should be the tab ID
|
||||
tab_id = next(iter(created_tab.keys())) if created_tab else None
|
||||
# Get the first key which should be the zone ID
|
||||
zone_id = next(iter(created_tab.keys())) if created_tab else None
|
||||
else:
|
||||
tab_id = None
|
||||
print(f"✓ POST /tabs - Created tab: {tab_id}")
|
||||
zone_id = None
|
||||
print(f"✓ POST /zones - Created zone: {zone_id}")
|
||||
passed += 1
|
||||
|
||||
# Test 3: Get specific tab
|
||||
if tab_id:
|
||||
# Test 3: Get specific zone
|
||||
if zone_id:
|
||||
total += 1
|
||||
response = client.get(f'/tabs/{tab_id}')
|
||||
response = client.get(f'/zones/{zone_id}')
|
||||
if response.status_code == 200:
|
||||
print(f"✓ GET /tabs/{tab_id} - Retrieved tab")
|
||||
print(f"✓ GET /zones/{zone_id} - Retrieved zone")
|
||||
passed += 1
|
||||
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
|
||||
response = client.post(f'/tabs/{tab_id}/set-current')
|
||||
response = client.post(f'/zones/{zone_id}/set-current')
|
||||
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
|
||||
cookie = client.get_cookie('current_tab')
|
||||
if cookie == tab_id:
|
||||
print(f" ✓ Cookie 'current_tab' set to {tab_id}")
|
||||
cookie = client.get_cookie('current_zone')
|
||||
if cookie == zone_id:
|
||||
print(f" ✓ Cookie 'current_zone' set to {zone_id}")
|
||||
passed += 1
|
||||
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
|
||||
response = client.get('/tabs/current')
|
||||
response = client.get('/zones/current')
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
if data.get('tab_id') == tab_id:
|
||||
print(f"✓ GET /tabs/current - Current tab is {tab_id}")
|
||||
if data.get('zone_id') == zone_id:
|
||||
print(f"✓ GET /zones/current - Current zone is {zone_id}")
|
||||
passed += 1
|
||||
else:
|
||||
print(f"✗ GET /tabs/current - Wrong tab ID")
|
||||
print(f"✗ GET /zones/current - Wrong zone ID")
|
||||
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
|
||||
update_data = {
|
||||
"name": "Updated Test Tab",
|
||||
"name": "Updated Test Zone",
|
||||
"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:
|
||||
updated = response.json()
|
||||
if updated.get('name') == "Updated Test Tab" and updated.get('names') == ["1", "2", "3"]:
|
||||
print(f"✓ PUT /tabs/{tab_id} - Updated tab (name and device IDs)")
|
||||
if updated.get('name') == "Updated Test Zone" and updated.get('names') == ["1", "2", "3"]:
|
||||
print(f"✓ PUT /zones/{zone_id} - Updated zone (name and device IDs)")
|
||||
passed += 1
|
||||
else:
|
||||
print(f"✗ PUT /tabs/{tab_id} - Update didn't work correctly")
|
||||
print(f" Expected name='Updated Test Tab', got '{updated.get('name')}'")
|
||||
print(f"✗ PUT /zones/{zone_id} - Update didn't work correctly")
|
||||
print(f" Expected name='Updated Test Zone', got '{updated.get('name')}'")
|
||||
print(f" Expected names=['1','2','3'], got {updated.get('names')}")
|
||||
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
|
||||
total += 1
|
||||
response = client.get(f'/tabs/{tab_id}')
|
||||
response = client.get(f'/zones/{zone_id}')
|
||||
if response.status_code == 200:
|
||||
verified = response.json()
|
||||
if verified.get('name') == "Updated Test Tab":
|
||||
print(f"✓ GET /tabs/{tab_id} - Verified update persisted")
|
||||
if verified.get('name') == "Updated Test Zone":
|
||||
print(f"✓ GET /zones/{zone_id} - Verified update persisted")
|
||||
passed += 1
|
||||
else:
|
||||
print(f"✗ GET /tabs/{tab_id} - Update didn't persist")
|
||||
print(f"✗ GET /zones/{zone_id} - Update didn't persist")
|
||||
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
|
||||
response = client.delete(f'/tabs/{tab_id}')
|
||||
response = client.delete(f'/zones/{zone_id}')
|
||||
if response.status_code == 200:
|
||||
print(f"✓ DELETE /tabs/{tab_id} - Deleted tab")
|
||||
print(f"✓ DELETE /zones/{zone_id} - Deleted zone")
|
||||
passed += 1
|
||||
else:
|
||||
print(f"✗ DELETE /tabs/{tab_id} - Status: {response.status_code}")
|
||||
print(f"✗ DELETE /zones/{zone_id} - Status: {response.status_code}")
|
||||
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:
|
||||
print(f"✗ POST /tabs - Error: {e}")
|
||||
print(f"✗ POST /zones - Error: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
|
||||
@@ -409,87 +409,87 @@ def test_patterns(client: TestClient) -> bool:
|
||||
return passed == total
|
||||
|
||||
def test_tab_edit_workflow(client: TestClient) -> bool:
|
||||
"""Test complete tab edit workflow like a browser would."""
|
||||
print("\n=== Testing Tab Edit Workflow ===")
|
||||
"""Test complete zone edit workflow like a browser would."""
|
||||
print("\n=== Testing Zone Edit Workflow ===")
|
||||
passed = 0
|
||||
total = 0
|
||||
|
||||
# Step 1: Create a tab to edit
|
||||
# Step 1: Create a zone to edit
|
||||
total += 1
|
||||
try:
|
||||
tab_data = {
|
||||
"name": "Tab to Edit",
|
||||
"name": "Zone to Edit",
|
||||
"names": ["1"]
|
||||
}
|
||||
response = client.post('/tabs', json_data=tab_data)
|
||||
response = client.post('/zones', json_data=tab_data)
|
||||
if response.status_code == 201:
|
||||
created = response.json()
|
||||
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:
|
||||
tab_id = None
|
||||
zone_id = None
|
||||
|
||||
if tab_id:
|
||||
print(f"✓ Created tab {tab_id} for editing")
|
||||
if zone_id:
|
||||
print(f"✓ Created zone {zone_id} for editing")
|
||||
passed += 1
|
||||
|
||||
# Step 2: Get the tab to verify initial state
|
||||
# Step 2: Get the zone to verify initial state
|
||||
total += 1
|
||||
response = client.get(f'/tabs/{tab_id}')
|
||||
response = client.get(f'/zones/{zone_id}')
|
||||
if response.status_code == 200:
|
||||
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
|
||||
|
||||
# Step 3: Edit the tab (simulate browser edit form submission)
|
||||
# Step 3: Edit the zone (simulate browser edit form submission)
|
||||
total += 1
|
||||
edit_data = {
|
||||
"name": "Edited Tab Name",
|
||||
"name": "Edited Zone Name",
|
||||
"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:
|
||||
edited = response.json()
|
||||
if edited.get('name') == "Edited Tab Name" and edited.get('names') == ["2", "3", "4"]:
|
||||
print(f"✓ PUT /tabs/{tab_id} - Successfully edited tab")
|
||||
if edited.get('name') == "Edited Zone Name" and edited.get('names') == ["2", "3", "4"]:
|
||||
print(f"✓ PUT /zones/{zone_id} - Successfully edited zone")
|
||||
print(f" New name: '{edited.get('name')}'")
|
||||
print(f" New device IDs: {edited.get('names')}")
|
||||
passed += 1
|
||||
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}")
|
||||
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
|
||||
response = client.get(f'/tabs/{tab_id}')
|
||||
response = client.get(f'/zones/{zone_id}')
|
||||
if response.status_code == 200:
|
||||
verified = response.json()
|
||||
if verified.get('name') == "Edited Tab Name" and verified.get('names') == ["2", "3", "4"]:
|
||||
print(f"✓ GET /tabs/{tab_id} - Verified edit persisted")
|
||||
if verified.get('name') == "Edited Zone Name" and verified.get('names') == ["2", "3", "4"]:
|
||||
print(f"✓ GET /zones/{zone_id} - Verified edit persisted")
|
||||
passed += 1
|
||||
else:
|
||||
print(f"✗ GET /tabs/{tab_id} - Edit didn't persist")
|
||||
print(f" Expected name='Edited Tab Name', got '{verified.get('name')}'")
|
||||
print(f"✗ GET /zones/{zone_id} - Edit didn't persist")
|
||||
print(f" Expected name='Edited Zone Name', got '{verified.get('name')}'")
|
||||
print(f" Expected names=['2','3','4'], got {verified.get('names')}")
|
||||
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
|
||||
response = client.delete(f'/tabs/{tab_id}')
|
||||
response = client.delete(f'/zones/{zone_id}')
|
||||
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
|
||||
else:
|
||||
print(f"✗ DELETE /tabs/{tab_id} - Status: {response.status_code}")
|
||||
print(f"✗ DELETE /zones/{zone_id} - Status: {response.status_code}")
|
||||
else:
|
||||
print(f"✗ Failed to extract tab ID from create response")
|
||||
print(f"✗ Failed to extract zone ID from create response")
|
||||
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:
|
||||
print(f"✗ Tab edit workflow - Error: {e}")
|
||||
print(f"✗ Zone edit workflow - Error: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
|
||||
@@ -505,7 +505,7 @@ def test_static_files(client: TestClient) -> bool:
|
||||
static_files = [
|
||||
'/static/style.css',
|
||||
'/static/app.js',
|
||||
'/static/tabs.js',
|
||||
'/static/zones.js',
|
||||
'/static/presets.js',
|
||||
'/static/profiles.js',
|
||||
'/static/devices.js',
|
||||
@@ -544,7 +544,7 @@ def main():
|
||||
|
||||
# Run all tests
|
||||
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(("Presets", test_presets(client)))
|
||||
results.append(("Patterns", test_patterns(client)))
|
||||
|
||||
@@ -119,7 +119,7 @@ def server(monkeypatch, tmp_path_factory):
|
||||
import models.preset as models_preset # noqa: E402
|
||||
import models.profile as models_profile # 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.scene as models_scene # noqa: E402
|
||||
import models.pattern as models_pattern # noqa: E402
|
||||
@@ -130,7 +130,7 @@ def server(monkeypatch, tmp_path_factory):
|
||||
models_preset.Preset,
|
||||
models_profile.Profile,
|
||||
models_group.Group,
|
||||
models_tab.Tab,
|
||||
models_tab.Zone,
|
||||
models_pallet.Palette,
|
||||
models_scene.Scene,
|
||||
models_pattern.Pattern,
|
||||
@@ -164,7 +164,7 @@ def server(monkeypatch, tmp_path_factory):
|
||||
"controllers.profile",
|
||||
"controllers.group",
|
||||
"controllers.sequence",
|
||||
"controllers.tab",
|
||||
"controllers.zone",
|
||||
"controllers.palette",
|
||||
"controllers.scene",
|
||||
"controllers.pattern",
|
||||
@@ -178,7 +178,7 @@ def server(monkeypatch, tmp_path_factory):
|
||||
import controllers.profile as profile_ctl # noqa: E402
|
||||
import controllers.group as group_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.scene as scene_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(group_ctl.controller, "/groups")
|
||||
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(scene_ctl.controller, "/scenes")
|
||||
app.mount(pattern_ctl.controller, "/patterns")
|
||||
@@ -424,45 +424,45 @@ def test_profiles_presets_tabs_endpoints(server, monkeypatch):
|
||||
assert resp.status_code == 404
|
||||
|
||||
# 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(
|
||||
f"{base_url}/tabs",
|
||||
f"{base_url}/zones",
|
||||
json={"name": unique_tab_name, "names": ["1", "2"]},
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
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.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
|
||||
|
||||
resp = c.get(f"{base_url}/tabs/current")
|
||||
resp = c.get(f"{base_url}/zones/current")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["tab_id"] == str(tab_id)
|
||||
assert resp.json()["zone_id"] == str(zone_id)
|
||||
|
||||
resp = c.put(
|
||||
f"{base_url}/tabs/{tab_id}",
|
||||
f"{base_url}/zones/{zone_id}",
|
||||
json={"name": f"{unique_tab_name}-updated", "names": ["3"]},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
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
|
||||
clone_payload = resp.json()
|
||||
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
|
||||
|
||||
resp = c.delete(f"{base_url}/tabs/{clone_id}")
|
||||
resp = c.delete(f"{base_url}/zones/{clone_id}")
|
||||
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
|
||||
|
||||
# Profile clone + update endpoints.
|
||||
|
||||
Reference in New Issue
Block a user