Compare commits
5 Commits
v0.1.0-alp
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| f376622482 | |||
| 52c8614a3c | |||
| 5c814a64a7 | |||
| 0716d602e2 | |||
| 42bc05c968 |
@@ -25,13 +25,51 @@ jobs:
|
|||||||
IS_PRE="true"
|
IS_PRE="true"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Build registry path for Docker instructions
|
||||||
|
SERVER_HOST=$(echo "${{ gitea.server_url }}" | sed -E 's|https?://||')
|
||||||
|
REPO=$(echo "${{ gitea.repository }}" | tr '[:upper:]' '[:lower:]')
|
||||||
|
DOCKER_IMAGE="${SERVER_HOST}/${REPO}"
|
||||||
|
|
||||||
|
# Build release body via Python to avoid YAML escaping issues
|
||||||
|
BODY_JSON=$(python3 -c "
|
||||||
|
import json, sys
|
||||||
|
tag = '$TAG'
|
||||||
|
image = '$DOCKER_IMAGE'
|
||||||
|
body = f'''## Downloads
|
||||||
|
|
||||||
|
| Platform | File | Description |
|
||||||
|
|----------|------|-------------|
|
||||||
|
| Windows (installer) | \`LedGrab-{tag}-setup.exe\` | Install with Start Menu shortcut, optional autostart, uninstaller |
|
||||||
|
| Windows (portable) | \`LedGrab-{tag}-win-x64.zip\` | Unzip anywhere, run LedGrab.bat |
|
||||||
|
| Linux | \`LedGrab-{tag}-linux-x64.tar.gz\` | Extract, run ./run.sh |
|
||||||
|
| Docker | See below | docker pull + docker run |
|
||||||
|
|
||||||
|
After starting, open **http://localhost:8080** in your browser.
|
||||||
|
|
||||||
|
### Docker
|
||||||
|
|
||||||
|
\`\`\`bash
|
||||||
|
docker pull {image}:{tag}
|
||||||
|
docker run -d --name ledgrab -p 8080:8080 -v ledgrab-data:/app/data {image}:{tag}
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
### First-time setup
|
||||||
|
|
||||||
|
1. Change the default API key in config/default_config.yaml
|
||||||
|
2. Open http://localhost:8080 and discover your WLED devices
|
||||||
|
3. See INSTALLATION.md for detailed configuration
|
||||||
|
'''
|
||||||
|
import textwrap
|
||||||
|
print(json.dumps(textwrap.dedent(body).strip()))
|
||||||
|
")
|
||||||
|
|
||||||
RELEASE=$(curl -s -X POST "$BASE_URL/releases" \
|
RELEASE=$(curl -s -X POST "$BASE_URL/releases" \
|
||||||
-H "Authorization: token $GITEA_TOKEN" \
|
-H "Authorization: token $GITEA_TOKEN" \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d "{
|
-d "{
|
||||||
\"tag_name\": \"$TAG\",
|
\"tag_name\": \"$TAG\",
|
||||||
\"name\": \"LedGrab $TAG\",
|
\"name\": \"LedGrab $TAG\",
|
||||||
\"body\": \"## Downloads\\n\\n| Platform | File | How to run |\\n|----------|------|------------|\\n| Windows | \`LedGrab-${TAG}-win-x64.zip\` | Unzip → run \`LedGrab.bat\` → open http://localhost:8080 |\\n| Linux | \`LedGrab-${TAG}-linux-x64.tar.gz\` | Extract → run \`./run.sh\` → open http://localhost:8080 |\\n| Docker | See below | \`docker pull\` → \`docker run\` |\\n\\n### Docker\\n\\n\`\`\`bash\\ndocker pull ${{ gitea.server_url }}/${{ gitea.repository }}:${TAG}\\ndocker run -d -p 8080:8080 ${{ gitea.server_url }}/${{ gitea.repository }}:${TAG}\\n\`\`\`\",
|
\"body\": $BODY_JSON,
|
||||||
\"draft\": false,
|
\"draft\": false,
|
||||||
\"prerelease\": $IS_PRE
|
\"prerelease\": $IS_PRE
|
||||||
}")
|
}")
|
||||||
|
|||||||
12
CLAUDE.md
12
CLAUDE.md
@@ -38,6 +38,7 @@ ast-index changed --base master # Show symbols changed in current bran
|
|||||||
| [contexts/graph-editor.md](contexts/graph-editor.md) | Visual graph editor changes |
|
| [contexts/graph-editor.md](contexts/graph-editor.md) | Visual graph editor changes |
|
||||||
| [contexts/server-operations.md](contexts/server-operations.md) | Server restart, startup modes, demo mode |
|
| [contexts/server-operations.md](contexts/server-operations.md) | Server restart, startup modes, demo mode |
|
||||||
| [contexts/chrome-tools.md](contexts/chrome-tools.md) | Chrome MCP tool usage for testing |
|
| [contexts/chrome-tools.md](contexts/chrome-tools.md) | Chrome MCP tool usage for testing |
|
||||||
|
| [contexts/ci-cd.md](contexts/ci-cd.md) | CI/CD pipelines, release workflow, build scripts |
|
||||||
| [server/CLAUDE.md](server/CLAUDE.md) | Backend architecture, API patterns, common tasks |
|
| [server/CLAUDE.md](server/CLAUDE.md) | Backend architecture, API patterns, common tasks |
|
||||||
|
|
||||||
## Task Tracking via TODO.md
|
## Task Tracking via TODO.md
|
||||||
@@ -53,6 +54,7 @@ Use `TODO.md` in the project root as the primary task tracker. **Do NOT use the
|
|||||||
**NEVER rename a storage file path, store key, entity ID prefix, or JSON field name without writing a migration.** User data lives in JSON files under `data/`. If the code starts reading from a new filename while the old file still has user data, THAT DATA IS SILENTLY LOST.
|
**NEVER rename a storage file path, store key, entity ID prefix, or JSON field name without writing a migration.** User data lives in JSON files under `data/`. If the code starts reading from a new filename while the old file still has user data, THAT DATA IS SILENTLY LOST.
|
||||||
|
|
||||||
When renaming any storage-related identifier:
|
When renaming any storage-related identifier:
|
||||||
|
|
||||||
1. **Add migration logic in `BaseJsonStore.__init__`** (or the specific store) that detects the old file/key and migrates data to the new name automatically on startup
|
1. **Add migration logic in `BaseJsonStore.__init__`** (or the specific store) that detects the old file/key and migrates data to the new name automatically on startup
|
||||||
2. **Log a clear warning** when migration happens so the user knows
|
2. **Log a clear warning** when migration happens so the user knows
|
||||||
3. **Keep the old file as a backup** after migration (rename to `.migrated` or similar)
|
3. **Keep the old file as a backup** after migration (rename to `.migrated` or similar)
|
||||||
@@ -63,6 +65,16 @@ This applies to: file paths in `StorageConfig`, JSON root keys (e.g. `picture_ta
|
|||||||
|
|
||||||
**Incident context:** A past rename of `picture_targets.json` → `output_targets.json` was done without migration. The app created a new empty `output_targets.json` while the user's 7 targets sat unread in the old file. Data was silently lost.
|
**Incident context:** A past rename of `picture_targets.json` → `output_targets.json` was done without migration. The app created a new empty `output_targets.json` while the user's 7 targets sat unread in the old file. Data was silently lost.
|
||||||
|
|
||||||
|
## Pre-Commit Checks (MANDATORY)
|
||||||
|
|
||||||
|
Before every commit, run the relevant linters and fix any issues:
|
||||||
|
|
||||||
|
- **Python changes**: `cd server && ruff check src/ tests/ --fix`
|
||||||
|
- **TypeScript changes**: `cd server && npx tsc --noEmit && npm run build`
|
||||||
|
- **Both**: Run both checks
|
||||||
|
|
||||||
|
Do NOT commit code that fails linting. Fix the issues first.
|
||||||
|
|
||||||
## General Guidelines
|
## General Guidelines
|
||||||
|
|
||||||
- Always test changes before marking as complete
|
- Always test changes before marking as complete
|
||||||
|
|||||||
80
contexts/ci-cd.md
Normal file
80
contexts/ci-cd.md
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
# CI/CD & Release Workflow
|
||||||
|
|
||||||
|
## Workflows
|
||||||
|
|
||||||
|
| File | Trigger | Purpose |
|
||||||
|
|------|---------|---------|
|
||||||
|
| `.gitea/workflows/test.yml` | Push/PR to master | Lint (ruff) + pytest |
|
||||||
|
| `.gitea/workflows/release.yml` | Tag `v*` | Build artifacts + create Gitea release |
|
||||||
|
|
||||||
|
## Release Pipeline (`release.yml`)
|
||||||
|
|
||||||
|
Four parallel jobs triggered by pushing a `v*` tag:
|
||||||
|
|
||||||
|
### 1. `create-release`
|
||||||
|
Creates the Gitea release with a description table listing all artifacts. **The description must stay in sync with actual build outputs** — if you add/remove/rename an artifact, update the body template here.
|
||||||
|
|
||||||
|
### 2. `build-windows` (cross-built from Linux)
|
||||||
|
- Runs `build-dist-windows.sh` on Ubuntu with NSIS + msitools
|
||||||
|
- Downloads Windows embedded Python 3.11 + pip wheels cross-platform
|
||||||
|
- Bundles tkinter from Python MSI via msiextract
|
||||||
|
- Builds frontend (`npm run build`)
|
||||||
|
- Pre-compiles Python bytecode (`compileall`)
|
||||||
|
- Produces: **`LedGrab-{tag}-win-x64.zip`** (portable) and **`LedGrab-{tag}-setup.exe`** (NSIS installer)
|
||||||
|
|
||||||
|
### 3. `build-linux`
|
||||||
|
- Runs `build-dist.sh` on Ubuntu
|
||||||
|
- Creates a venv, installs deps, builds frontend
|
||||||
|
- Produces: **`LedGrab-{tag}-linux-x64.tar.gz`**
|
||||||
|
|
||||||
|
### 4. `build-docker`
|
||||||
|
- Plain `docker build` + `docker push` (no Buildx — TrueNAS runners lack nested networking)
|
||||||
|
- Registry: `{gitea_host}/{repo}:{tag}`
|
||||||
|
- Tags: `v0.x.x`, `0.x.x`, and `latest` (stable only, not alpha/beta/rc)
|
||||||
|
|
||||||
|
## Build Scripts
|
||||||
|
|
||||||
|
| Script | Platform | Output |
|
||||||
|
|--------|----------|--------|
|
||||||
|
| `build-dist-windows.sh` | Linux → Windows cross-build | ZIP + NSIS installer |
|
||||||
|
| `build-dist.sh` | Linux native | tarball |
|
||||||
|
| `server/Dockerfile` | Docker | Container image |
|
||||||
|
|
||||||
|
## Release Versioning
|
||||||
|
|
||||||
|
- Tags: `v{major}.{minor}.{patch}` for stable, `v{major}.{minor}.{patch}-alpha.{n}` for pre-release
|
||||||
|
- Pre-release tags set `prerelease: true` on the Gitea release
|
||||||
|
- Docker `latest` tag only applied to stable releases
|
||||||
|
- Version in `server/pyproject.toml` should match the tag (without `v` prefix)
|
||||||
|
|
||||||
|
## CI Runners
|
||||||
|
|
||||||
|
- Two TrueNAS Gitea runners with `ubuntu` tags
|
||||||
|
- No Windows runner available — Windows builds are cross-compiled from Linux
|
||||||
|
- Docker Buildx not available (networking limitations) — use plain `docker build`
|
||||||
|
|
||||||
|
## Test Pipeline (`test.yml`)
|
||||||
|
|
||||||
|
- Installs `opencv-python-headless` and `libportaudio2` for CI compatibility
|
||||||
|
- Display-dependent tests are skipped via `@requires_display` marker
|
||||||
|
- Uses `python` not `python3` (Git Bash on Windows resolves `python3` to MS Store stub)
|
||||||
|
|
||||||
|
## Common Tasks
|
||||||
|
|
||||||
|
### Creating a release
|
||||||
|
```bash
|
||||||
|
git tag v0.2.0
|
||||||
|
git push origin v0.2.0
|
||||||
|
```
|
||||||
|
|
||||||
|
### Creating a pre-release
|
||||||
|
```bash
|
||||||
|
git tag v0.2.0-alpha.1
|
||||||
|
git push origin v0.2.0-alpha.1
|
||||||
|
```
|
||||||
|
|
||||||
|
### Adding a new build artifact
|
||||||
|
1. Update the build script to produce the new file
|
||||||
|
2. Add upload step in the relevant `build-*` job
|
||||||
|
3. **Update the release description** in `create-release` job body template
|
||||||
|
4. Test with a pre-release tag first
|
||||||
@@ -829,6 +829,15 @@ async def test_color_strip_ws(
|
|||||||
if hasattr(stream, "configure"):
|
if hasattr(stream, "configure"):
|
||||||
stream.configure(max(1, led_count))
|
stream.configure(max(1, led_count))
|
||||||
|
|
||||||
|
# Reject picture sources with 0 calibration LEDs (no edges configured)
|
||||||
|
if stream.led_count <= 0:
|
||||||
|
csm.release(source_id, consumer_id)
|
||||||
|
await websocket.close(
|
||||||
|
code=4005,
|
||||||
|
reason="No LEDs configured. Open Calibration and set LED counts for each edge.",
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
# Clamp FPS to sane range
|
# Clamp FPS to sane range
|
||||||
fps = max(1, min(60, fps))
|
fps = max(1, min(60, fps))
|
||||||
_frame_interval = 1.0 / fps
|
_frame_interval = 1.0 / fps
|
||||||
|
|||||||
@@ -668,14 +668,20 @@ def create_pixel_mapper(
|
|||||||
return PixelMapper(calibration, interpolation_mode)
|
return PixelMapper(calibration, interpolation_mode)
|
||||||
|
|
||||||
|
|
||||||
def create_default_calibration(led_count: int) -> CalibrationConfig:
|
def create_default_calibration(
|
||||||
|
led_count: int,
|
||||||
|
aspect_width: int = 16,
|
||||||
|
aspect_height: int = 9,
|
||||||
|
) -> CalibrationConfig:
|
||||||
"""Create a default calibration for a rectangular screen.
|
"""Create a default calibration for a rectangular screen.
|
||||||
|
|
||||||
Assumes LEDs are evenly distributed around the screen edges in clockwise order
|
Distributes LEDs proportionally to the screen aspect ratio so that
|
||||||
starting from bottom-left.
|
horizontal and vertical edges have equal LED density.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
led_count: Total number of LEDs
|
led_count: Total number of LEDs
|
||||||
|
aspect_width: Screen width component of the aspect ratio (default 16)
|
||||||
|
aspect_height: Screen height component of the aspect ratio (default 9)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Default calibration configuration
|
Default calibration configuration
|
||||||
@@ -683,15 +689,48 @@ def create_default_calibration(led_count: int) -> CalibrationConfig:
|
|||||||
if led_count < 4:
|
if led_count < 4:
|
||||||
raise ValueError("Need at least 4 LEDs for default calibration")
|
raise ValueError("Need at least 4 LEDs for default calibration")
|
||||||
|
|
||||||
# Distribute LEDs evenly across 4 edges
|
# Distribute LEDs proportionally to aspect ratio (same density per edge)
|
||||||
leds_per_edge = led_count // 4
|
perimeter = 2 * (aspect_width + aspect_height)
|
||||||
remainder = led_count % 4
|
h_frac = aspect_width / perimeter # fraction for each horizontal edge
|
||||||
|
v_frac = aspect_height / perimeter # fraction for each vertical edge
|
||||||
|
|
||||||
# Distribute remainder to longer edges (bottom and top)
|
# Float counts, then round so total == led_count
|
||||||
bottom_count = leds_per_edge + (1 if remainder > 0 else 0)
|
raw_h = led_count * h_frac
|
||||||
right_count = leds_per_edge
|
raw_v = led_count * v_frac
|
||||||
top_count = leds_per_edge + (1 if remainder > 1 else 0)
|
bottom_count = round(raw_h)
|
||||||
left_count = leds_per_edge + (1 if remainder > 2 else 0)
|
top_count = round(raw_h)
|
||||||
|
right_count = round(raw_v)
|
||||||
|
left_count = round(raw_v)
|
||||||
|
|
||||||
|
# Fix rounding error
|
||||||
|
diff = led_count - (bottom_count + top_count + right_count + left_count)
|
||||||
|
# Distribute remainder to horizontal edges first (longer edges)
|
||||||
|
if diff > 0:
|
||||||
|
bottom_count += 1
|
||||||
|
diff -= 1
|
||||||
|
if diff > 0:
|
||||||
|
top_count += 1
|
||||||
|
diff -= 1
|
||||||
|
if diff > 0:
|
||||||
|
right_count += 1
|
||||||
|
diff -= 1
|
||||||
|
if diff > 0:
|
||||||
|
left_count += 1
|
||||||
|
diff -= 1
|
||||||
|
# If we over-counted, remove from shorter edges first
|
||||||
|
if diff < 0:
|
||||||
|
left_count += diff # diff is negative
|
||||||
|
diff = 0
|
||||||
|
if left_count < 0:
|
||||||
|
diff = left_count
|
||||||
|
left_count = 0
|
||||||
|
right_count += diff
|
||||||
|
|
||||||
|
# Ensure each edge has at least 1 LED
|
||||||
|
bottom_count = max(1, bottom_count)
|
||||||
|
top_count = max(1, top_count)
|
||||||
|
right_count = max(1, right_count)
|
||||||
|
left_count = max(1, left_count)
|
||||||
|
|
||||||
config = CalibrationConfig(
|
config = CalibrationConfig(
|
||||||
layout="clockwise",
|
layout="clockwise",
|
||||||
@@ -703,7 +742,8 @@ def create_default_calibration(led_count: int) -> CalibrationConfig:
|
|||||||
)
|
)
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Created default calibration for {led_count} LEDs: "
|
f"Created default calibration for {led_count} LEDs "
|
||||||
|
f"(aspect {aspect_width}:{aspect_height}): "
|
||||||
f"bottom={bottom_count}, right={right_count}, "
|
f"bottom={bottom_count}, right={right_count}, "
|
||||||
f"top={top_count}, left={left_count}"
|
f"top={top_count}, left={left_count}"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -98,10 +98,10 @@ async def lifespan(app: FastAPI):
|
|||||||
logger.info(f"Starting LED Grab v{__version__}")
|
logger.info(f"Starting LED Grab v{__version__}")
|
||||||
logger.info(f"Python version: {sys.version}")
|
logger.info(f"Python version: {sys.version}")
|
||||||
logger.info(f"Server listening on {config.server.host}:{config.server.port}")
|
logger.info(f"Server listening on {config.server.host}:{config.server.port}")
|
||||||
print(f"\n =============================================")
|
print("\n =============================================")
|
||||||
print(f" LED Grab v{__version__}")
|
print(f" LED Grab v{__version__}")
|
||||||
print(f" Open http://localhost:{config.server.port} in your browser")
|
print(f" Open http://localhost:{config.server.port} in your browser")
|
||||||
print(f" =============================================\n")
|
print(" =============================================\n")
|
||||||
|
|
||||||
# Validate authentication configuration
|
# Validate authentication configuration
|
||||||
if not config.auth.api_keys:
|
if not config.auth.api_keys:
|
||||||
|
|||||||
@@ -1024,3 +1024,68 @@ textarea:focus-visible {
|
|||||||
width: 18px;
|
width: 18px;
|
||||||
height: 18px;
|
height: 18px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Schedule time picker (value sources) ── */
|
||||||
|
.schedule-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
.schedule-time-wrap {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
background: var(--bg-color);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
padding: 2px 6px;
|
||||||
|
transition: border-color var(--duration-fast) ease;
|
||||||
|
}
|
||||||
|
.schedule-time-wrap:focus-within {
|
||||||
|
border-color: var(--primary-color);
|
||||||
|
box-shadow: 0 0 0 2px rgba(76, 175, 80, 0.15);
|
||||||
|
}
|
||||||
|
.schedule-time-wrap input[type="number"] {
|
||||||
|
width: 2.4ch;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
font-family: inherit;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
color: var(--text-color);
|
||||||
|
padding: 4px 2px;
|
||||||
|
-moz-appearance: textfield;
|
||||||
|
transition: border-color var(--duration-fast) ease,
|
||||||
|
background var(--duration-fast) ease;
|
||||||
|
}
|
||||||
|
.schedule-time-wrap input[type="number"]::-webkit-inner-spin-button,
|
||||||
|
.schedule-time-wrap input[type="number"]::-webkit-outer-spin-button {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.schedule-time-wrap input[type="number"]:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--primary-color);
|
||||||
|
background: color-mix(in srgb, var(--primary-color) 8%, var(--bg-secondary));
|
||||||
|
}
|
||||||
|
.schedule-time-colon {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-muted);
|
||||||
|
line-height: 1;
|
||||||
|
padding: 0 1px;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
.schedule-value {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 60px;
|
||||||
|
}
|
||||||
|
.schedule-value-display {
|
||||||
|
min-width: 2.5ch;
|
||||||
|
text-align: right;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|||||||
@@ -146,9 +146,7 @@ export class CardSection {
|
|||||||
? `<div class="template-card add-template-card cs-add-card" data-cs-add="${this.sectionKey}" onclick="${this.addCardOnclick}"><div class="add-template-icon">+</div></div>`
|
? `<div class="template-card add-template-card cs-add-card" data-cs-add="${this.sectionKey}" onclick="${this.addCardOnclick}"><div class="add-template-icon">+</div></div>`
|
||||||
: '';
|
: '';
|
||||||
|
|
||||||
const emptyState = (count === 0 && this.emptyKey)
|
const emptyState = '';
|
||||||
? `<div class="cs-empty-state" data-cs-empty="${this.sectionKey}"><span class="cs-empty-text text-muted">${t(this.emptyKey)}</span></div>`
|
|
||||||
: '';
|
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<div class="subtab-section${collapsedClass}" data-card-section="${this.sectionKey}">
|
<div class="subtab-section${collapsedClass}" data-card-section="${this.sectionKey}">
|
||||||
@@ -314,24 +312,9 @@ export class CardSection {
|
|||||||
const countEl = document.querySelector(`[data-cs-toggle="${this.sectionKey}"] .cs-count`);
|
const countEl = document.querySelector(`[data-cs-toggle="${this.sectionKey}"] .cs-count`);
|
||||||
if (countEl && !this._filterValue) countEl.textContent = String(items.length);
|
if (countEl && !this._filterValue) countEl.textContent = String(items.length);
|
||||||
|
|
||||||
// Show/hide empty state
|
// Remove any stale empty-state element from DOM
|
||||||
if (this.emptyKey) {
|
const staleEmpty = content.querySelector(`[data-cs-empty="${this.sectionKey}"]`);
|
||||||
let emptyEl = content.querySelector(`[data-cs-empty="${this.sectionKey}"]`) as HTMLElement | null;
|
if (staleEmpty) staleEmpty.remove();
|
||||||
if (items.length === 0) {
|
|
||||||
if (!emptyEl) {
|
|
||||||
emptyEl = document.createElement('div');
|
|
||||||
emptyEl.className = 'cs-empty-state';
|
|
||||||
emptyEl.setAttribute('data-cs-empty', this.sectionKey);
|
|
||||||
emptyEl.innerHTML = `<span class="cs-empty-text text-muted">${t(this.emptyKey)}</span>`;
|
|
||||||
const addCard = content.querySelector('.cs-add-card');
|
|
||||||
if (addCard) content.insertBefore(emptyEl, addCard);
|
|
||||||
else content.appendChild(emptyEl);
|
|
||||||
}
|
|
||||||
emptyEl.style.display = '';
|
|
||||||
} else if (emptyEl) {
|
|
||||||
emptyEl.style.display = 'none';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const newMap = new Map(items.map(i => [i.key, i.html]));
|
const newMap = new Map(items.map(i => [i.key, i.html]));
|
||||||
const addCard = content.querySelector('.cs-add-card');
|
const addCard = content.querySelector('.cs-add-card');
|
||||||
|
|||||||
@@ -383,10 +383,18 @@ function _cssTestConnect(sourceId: string, ledCount: number, fps?: number) {
|
|||||||
_cssTestWs.onerror = () => {
|
_cssTestWs.onerror = () => {
|
||||||
if (gen !== _cssTestGeneration) return;
|
if (gen !== _cssTestGeneration) return;
|
||||||
(document.getElementById('css-test-status') as HTMLElement).textContent = t('color_strip.test.error');
|
(document.getElementById('css-test-status') as HTMLElement).textContent = t('color_strip.test.error');
|
||||||
|
(document.getElementById('css-test-status') as HTMLElement).style.display = '';
|
||||||
};
|
};
|
||||||
|
|
||||||
_cssTestWs.onclose = () => {
|
_cssTestWs.onclose = (ev) => {
|
||||||
if (gen === _cssTestGeneration) _cssTestWs = null;
|
if (gen !== _cssTestGeneration) return;
|
||||||
|
_cssTestWs = null;
|
||||||
|
// Show server-provided close reason (e.g. "No LEDs configured")
|
||||||
|
if (ev.reason) {
|
||||||
|
const statusEl = document.getElementById('css-test-status') as HTMLElement;
|
||||||
|
statusEl.textContent = ev.reason;
|
||||||
|
statusEl.style.display = '';
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Start render loop (only once)
|
// Start render loop (only once)
|
||||||
|
|||||||
@@ -874,16 +874,73 @@ function _populatePictureSourceDropdown(selectedId: any) {
|
|||||||
export function addSchedulePoint(time: string = '', value: number = 1.0) {
|
export function addSchedulePoint(time: string = '', value: number = 1.0) {
|
||||||
const list = document.getElementById('value-source-schedule-list');
|
const list = document.getElementById('value-source-schedule-list');
|
||||||
if (!list) return;
|
if (!list) return;
|
||||||
|
const timeStr = time || '12:00';
|
||||||
|
const [h, m] = timeStr.split(':').map(Number);
|
||||||
|
const pad = (n: number) => String(n).padStart(2, '0');
|
||||||
|
|
||||||
const row = document.createElement('div');
|
const row = document.createElement('div');
|
||||||
row.className = 'schedule-row';
|
row.className = 'schedule-row';
|
||||||
row.innerHTML = `
|
row.innerHTML = `
|
||||||
<input type="time" class="schedule-time" value="${time || '12:00'}">
|
<div class="schedule-time-wrap">
|
||||||
|
<input type="number" class="schedule-h" min="0" max="23" value="${pad(h)}" data-role="hour">
|
||||||
|
<span class="schedule-time-colon">:</span>
|
||||||
|
<input type="number" class="schedule-m" min="0" max="59" value="${pad(m)}" data-role="minute">
|
||||||
|
</div>
|
||||||
|
<input type="hidden" class="schedule-time" value="${timeStr}">
|
||||||
<input type="range" class="schedule-value" min="0" max="1" step="0.01" value="${value}"
|
<input type="range" class="schedule-value" min="0" max="1" step="0.01" value="${value}"
|
||||||
oninput="this.nextElementSibling.textContent = this.value">
|
oninput="this.nextElementSibling.textContent = this.value">
|
||||||
<span class="schedule-value-display">${value}</span>
|
<span class="schedule-value-display">${value}</span>
|
||||||
<button type="button" class="btn btn-icon btn-danger btn-sm" onclick="this.parentElement.remove()">✕</button>
|
<button type="button" class="btn btn-icon btn-danger btn-sm" onclick="this.parentElement.remove()">✕</button>
|
||||||
`;
|
`;
|
||||||
list.appendChild(row);
|
list.appendChild(row);
|
||||||
|
_wireScheduleTimePicker(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
function _wireScheduleTimePicker(row: HTMLElement) {
|
||||||
|
const hInput = row.querySelector('.schedule-h') as HTMLInputElement;
|
||||||
|
const mInput = row.querySelector('.schedule-m') as HTMLInputElement;
|
||||||
|
const hidden = row.querySelector('.schedule-time') as HTMLInputElement;
|
||||||
|
if (!hInput || !mInput || !hidden) return;
|
||||||
|
|
||||||
|
const pad = (n: number) => String(n).padStart(2, '0');
|
||||||
|
|
||||||
|
function clamp(input: HTMLInputElement, min: number, max: number) {
|
||||||
|
let v = parseInt(input.value, 10);
|
||||||
|
if (isNaN(v)) v = min;
|
||||||
|
if (v < min) v = min;
|
||||||
|
if (v > max) v = max;
|
||||||
|
input.value = pad(v);
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sync() {
|
||||||
|
const hv = clamp(hInput, 0, 23);
|
||||||
|
const mv = clamp(mInput, 0, 59);
|
||||||
|
hidden.value = `${pad(hv)}:${pad(mv)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
[hInput, mInput].forEach(inp => {
|
||||||
|
inp.addEventListener('focus', () => inp.select());
|
||||||
|
inp.addEventListener('input', sync);
|
||||||
|
inp.addEventListener('blur', sync);
|
||||||
|
inp.addEventListener('keydown', (e) => {
|
||||||
|
const isHour = inp.dataset.role === 'hour';
|
||||||
|
const max = isHour ? 23 : 59;
|
||||||
|
if (e.key === 'ArrowUp') {
|
||||||
|
e.preventDefault();
|
||||||
|
let v = parseInt(inp.value, 10) || 0;
|
||||||
|
inp.value = pad(v >= max ? 0 : v + 1);
|
||||||
|
sync();
|
||||||
|
} else if (e.key === 'ArrowDown') {
|
||||||
|
e.preventDefault();
|
||||||
|
let v = parseInt(inp.value, 10) || 0;
|
||||||
|
inp.value = pad(v <= 0 ? max : v - 1);
|
||||||
|
sync();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
sync();
|
||||||
}
|
}
|
||||||
|
|
||||||
function _getScheduleFromUI() {
|
function _getScheduleFromUI() {
|
||||||
|
|||||||
@@ -10,9 +10,9 @@
|
|||||||
<div id="notification-history-status" style="display:none;color:var(--text-muted);font-size:0.85rem;margin-bottom:0.5rem"></div>
|
<div id="notification-history-status" style="display:none;color:var(--text-muted);font-size:0.85rem;margin-bottom:0.5rem"></div>
|
||||||
<div id="notification-history-list" style="max-height:340px;overflow-y:auto;border:1px solid var(--border-color);border-radius:4px;padding:0.25rem 0"></div>
|
<div id="notification-history-list" style="max-height:340px;overflow-y:auto;border:1px solid var(--border-color);border-radius:4px;padding:0.25rem 0"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-actions">
|
<div class="modal-footer">
|
||||||
<button class="btn btn-secondary" onclick="refreshNotificationHistory()" data-i18n="color_strip.notification.history.refresh">Refresh</button>
|
<button class="btn btn-icon btn-secondary" onclick="refreshNotificationHistory()" data-i18n-title="color_strip.notification.history.refresh" title="Refresh"><svg class="icon" viewBox="0 0 24 24"><path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/><path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"/><path d="M8 16H3v5"/></svg></button>
|
||||||
<button class="btn btn-secondary" onclick="closeNotificationHistory()" data-i18n="settings.button.cancel">Close</button>
|
<button class="btn btn-icon btn-secondary" onclick="closeNotificationHistory()" data-i18n-title="settings.button.cancel" title="Close"><svg class="icon" viewBox="0 0 24 24"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user