chore: sync CI/CD with upstream guide and update context docs
Some checks failed
Lint & Test / test (push) Failing after 30s
Some checks failed
Lint & Test / test (push) Failing after 30s
release.yml: add fallback for existing releases on tag re-push. installer.nsi: add .onInit file lock check, use LaunchApp function instead of RUN_PARAMETERS to fix NSIS quoting bug. build-dist.ps1: copy start-hidden.vbs to dist scripts/. start-hidden.vbs: embedded Python fallback for installed/dev envs. Update ci-cd.md with version detection, NSIS best practices, local build testing, Gitea vs GitHub differences, troubleshooting. Update frontend.md with full entity type checklist and common pitfalls.
This commit is contained in:
@@ -74,9 +74,16 @@ jobs:
|
|||||||
\"prerelease\": $IS_PRE
|
\"prerelease\": $IS_PRE
|
||||||
}")
|
}")
|
||||||
|
|
||||||
|
# Fallback: if release already exists for this tag, fetch it instead
|
||||||
|
RELEASE_ID=$(echo "$RELEASE" | python3 -c "import sys,json; print(json.load(sys.stdin)['id'])" 2>/dev/null)
|
||||||
|
if [ -z "$RELEASE_ID" ]; then
|
||||||
|
echo "::warning::Release already exists for tag $TAG — reusing existing release"
|
||||||
|
RELEASE=$(curl -s "$BASE_URL/releases/tags/$TAG" \
|
||||||
|
-H "Authorization: token $GITEA_TOKEN")
|
||||||
RELEASE_ID=$(echo "$RELEASE" | python3 -c "import sys,json; print(json.load(sys.stdin)['id'])")
|
RELEASE_ID=$(echo "$RELEASE" | python3 -c "import sys,json; print(json.load(sys.stdin)['id'])")
|
||||||
|
fi
|
||||||
echo "release_id=$RELEASE_ID" >> "$GITHUB_OUTPUT"
|
echo "release_id=$RELEASE_ID" >> "$GITHUB_OUTPUT"
|
||||||
echo "Created release ID: $RELEASE_ID"
|
echo "Release ID: $RELEASE_ID"
|
||||||
|
|
||||||
# ── Windows portable ZIP (cross-built from Linux) ─────────
|
# ── Windows portable ZIP (cross-built from Linux) ─────────
|
||||||
build-windows:
|
build-windows:
|
||||||
|
|||||||
@@ -228,6 +228,8 @@ WIN_DEPS=(
|
|||||||
"winrt-Windows.Foundation>=3.0.0"
|
"winrt-Windows.Foundation>=3.0.0"
|
||||||
"winrt-Windows.Foundation.Collections>=3.0.0"
|
"winrt-Windows.Foundation.Collections>=3.0.0"
|
||||||
"winrt-Windows.ApplicationModel>=3.0.0"
|
"winrt-Windows.ApplicationModel>=3.0.0"
|
||||||
|
# System tray
|
||||||
|
"pystray>=0.19.0"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Download cross-platform deps (prefer binary, allow source for pure Python)
|
# Download cross-platform deps (prefer binary, allow source for pure Python)
|
||||||
@@ -359,7 +361,6 @@ echo "[8b/9] Creating launcher and packaging..."
|
|||||||
|
|
||||||
cat > "$DIST_DIR/LedGrab.bat" << LAUNCHER
|
cat > "$DIST_DIR/LedGrab.bat" << LAUNCHER
|
||||||
@echo off
|
@echo off
|
||||||
title LedGrab v${VERSION_CLEAN}
|
|
||||||
cd /d "%~dp0"
|
cd /d "%~dp0"
|
||||||
|
|
||||||
:: Set paths
|
:: Set paths
|
||||||
@@ -370,15 +371,17 @@ set WLED_CONFIG_PATH=%~dp0app\config\default_config.yaml
|
|||||||
if not exist "%~dp0data" mkdir "%~dp0data"
|
if not exist "%~dp0data" mkdir "%~dp0data"
|
||||||
if not exist "%~dp0logs" mkdir "%~dp0logs"
|
if not exist "%~dp0logs" mkdir "%~dp0logs"
|
||||||
|
|
||||||
:: Start the server — reads port from config, prints its own banner
|
:: Start the server (tray icon handles UI and exit)
|
||||||
"%~dp0python\python.exe" -m wled_controller.main
|
"%~dp0python\pythonw.exe" -m wled_controller
|
||||||
|
|
||||||
pause
|
|
||||||
LAUNCHER
|
LAUNCHER
|
||||||
|
|
||||||
# Convert launcher to Windows line endings
|
# Convert launcher to Windows line endings
|
||||||
sed -i 's/$/\r/' "$DIST_DIR/LedGrab.bat"
|
sed -i 's/$/\r/' "$DIST_DIR/LedGrab.bat"
|
||||||
|
|
||||||
|
# Copy hidden launcher VBS
|
||||||
|
mkdir -p "$DIST_DIR/scripts"
|
||||||
|
cp server/scripts/start-hidden.vbs "$DIST_DIR/scripts/"
|
||||||
|
|
||||||
# ── Create autostart scripts ─────────────────────────────────
|
# ── Create autostart scripts ─────────────────────────────────
|
||||||
|
|
||||||
cat > "$DIST_DIR/install-autostart.bat" << 'AUTOSTART'
|
cat > "$DIST_DIR/install-autostart.bat" << 'AUTOSTART'
|
||||||
|
|||||||
@@ -130,7 +130,7 @@ if ($LASTEXITCODE -ne 0) { throw "Failed to install pip" }
|
|||||||
# ── Install dependencies ──────────────────────────────────────
|
# ── Install dependencies ──────────────────────────────────────
|
||||||
|
|
||||||
Write-Host "[5/8] Installing dependencies..."
|
Write-Host "[5/8] Installing dependencies..."
|
||||||
$extras = "camera,notifications"
|
$extras = "camera,notifications,tray"
|
||||||
if (-not $SkipPerf) { $extras += ",perf" }
|
if (-not $SkipPerf) { $extras += ",perf" }
|
||||||
|
|
||||||
# Install the project (pulls all deps via pyproject.toml), then remove
|
# Install the project (pulls all deps via pyproject.toml), then remove
|
||||||
@@ -202,7 +202,6 @@ Write-Host "[8/8] Creating launcher..."
|
|||||||
|
|
||||||
$launcherContent = @'
|
$launcherContent = @'
|
||||||
@echo off
|
@echo off
|
||||||
title LedGrab v%VERSION%
|
|
||||||
cd /d "%~dp0"
|
cd /d "%~dp0"
|
||||||
|
|
||||||
:: Set paths
|
:: Set paths
|
||||||
@@ -213,24 +212,19 @@ set WLED_CONFIG_PATH=%~dp0app\config\default_config.yaml
|
|||||||
if not exist "%~dp0data" mkdir "%~dp0data"
|
if not exist "%~dp0data" mkdir "%~dp0data"
|
||||||
if not exist "%~dp0logs" mkdir "%~dp0logs"
|
if not exist "%~dp0logs" mkdir "%~dp0logs"
|
||||||
|
|
||||||
echo.
|
:: Start the server (tray icon handles UI and exit)
|
||||||
echo =============================================
|
"%~dp0python\pythonw.exe" -m wled_controller
|
||||||
echo LedGrab v%VERSION%
|
|
||||||
echo Open http://localhost:8080 in your browser
|
|
||||||
echo =============================================
|
|
||||||
echo.
|
|
||||||
|
|
||||||
:: Start the server (open browser after short delay)
|
|
||||||
start "" /b cmd /c "timeout /t 2 /nobreak >nul && start http://localhost:8080"
|
|
||||||
"%~dp0python\python.exe" -m uvicorn wled_controller.main:app --host 0.0.0.0 --port 8080
|
|
||||||
|
|
||||||
pause
|
|
||||||
'@
|
'@
|
||||||
|
|
||||||
$launcherContent = $launcherContent -replace '%VERSION%', $VersionClean
|
$launcherContent = $launcherContent -replace '%VERSION%', $VersionClean
|
||||||
$launcherPath = Join-Path $DistDir "LedGrab.bat"
|
$launcherPath = Join-Path $DistDir "LedGrab.bat"
|
||||||
Set-Content -Path $launcherPath -Value $launcherContent -Encoding ASCII
|
Set-Content -Path $launcherPath -Value $launcherContent -Encoding ASCII
|
||||||
|
|
||||||
|
# Copy hidden launcher VBS
|
||||||
|
$scriptsDir = Join-Path $DistDir "scripts"
|
||||||
|
New-Item -ItemType Directory -Path $scriptsDir -Force | Out-Null
|
||||||
|
Copy-Item -Path (Join-Path $ServerDir "scripts\start-hidden.vbs") -Destination $scriptsDir
|
||||||
|
|
||||||
# ── Create ZIP ─────────────────────────────────────────────────
|
# ── Create ZIP ─────────────────────────────────────────────────
|
||||||
|
|
||||||
$ZipPath = Join-Path $BuildDir $ZipName
|
$ZipPath = Join-Path $BuildDir $ZipName
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
# CI/CD & Release Workflow
|
# CI/CD & Release Workflow
|
||||||
|
|
||||||
|
> **Reference guide:** [Gitea Python CI/CD Guide](https://git.dolgolyov-family.by/alexei.dolgolyov/claude-code-facts/src/branch/main/gitea-python-ci-cd.md) — reusable patterns for Gitea Actions, cross-build, NSIS, Docker. When modifying workflows or build scripts, consult this guide to stay in sync with established patterns.
|
||||||
|
|
||||||
## Workflows
|
## Workflows
|
||||||
|
|
||||||
| File | Trigger | Purpose |
|
| File | Trigger | Purpose |
|
||||||
@@ -59,6 +61,33 @@ Creates the Gitea release with a description table listing all artifacts. **The
|
|||||||
- Display-dependent tests are skipped via `@requires_display` marker
|
- Display-dependent tests are skipped via `@requires_display` marker
|
||||||
- Uses `python` not `python3` (Git Bash on Windows resolves `python3` to MS Store stub)
|
- Uses `python` not `python3` (Git Bash on Windows resolves `python3` to MS Store stub)
|
||||||
|
|
||||||
|
## Version Detection Pattern
|
||||||
|
|
||||||
|
Build scripts use a fallback chain: CLI argument → exact git tag → CI env var (`GITEA_REF_NAME` / `GITHUB_REF_NAME`) → hardcoded in source. Always strip leading `v` for clean version strings.
|
||||||
|
|
||||||
|
## NSIS Installer Best Practices
|
||||||
|
|
||||||
|
- **User-scoped install** (`$LOCALAPPDATA`, `RequestExecutionLevel user`) — no admin required
|
||||||
|
- **Launch after install**: Use `MUI_FINISHPAGE_RUN_FUNCTION` (not `MUI_FINISHPAGE_RUN_PARAMETERS` — NSIS `Exec` chokes on quoting). Still requires `MUI_FINISHPAGE_RUN ""` defined for checkbox visibility
|
||||||
|
- **Detect running instance**: `.onInit` checks file lock on `python.exe`, offers to kill process before install
|
||||||
|
- **Uninstall preserves user data**: Remove `python/`, `app/`, `logs/` but NOT `data/`
|
||||||
|
- **CI build**: `sudo apt-get install -y nsis msitools zip` then `makensis -DVERSION="${VERSION}" installer.nsi`
|
||||||
|
|
||||||
|
## Hidden Launcher (VBS)
|
||||||
|
|
||||||
|
All shortcuts and the installer finish page use `scripts/start-hidden.vbs` instead of `.bat` to avoid console window flash. The VBS launcher must include an embedded Python fallback — installed distributions don't have Python on PATH, dev environment uses system Python.
|
||||||
|
|
||||||
|
## Gitea vs GitHub Actions Differences
|
||||||
|
|
||||||
|
| Feature | GitHub Actions | Gitea Actions |
|
||||||
|
| ------- | -------------- | ------------- |
|
||||||
|
| Context prefix | `github.*` | `gitea.*` |
|
||||||
|
| Ref name | `${{ github.ref_name }}` | `${{ gitea.ref_name }}` |
|
||||||
|
| Server URL | `${{ github.server_url }}` | `${{ gitea.server_url }}` |
|
||||||
|
| Output vars | `$GITHUB_OUTPUT` | `$GITHUB_OUTPUT` (same) |
|
||||||
|
| Secrets | `${{ secrets.NAME }}` | `${{ secrets.NAME }}` (same) |
|
||||||
|
| Docker Buildx | Available | May not work (runner networking) |
|
||||||
|
|
||||||
## Common Tasks
|
## Common Tasks
|
||||||
|
|
||||||
### Creating a release
|
### Creating a release
|
||||||
@@ -78,3 +107,48 @@ git push origin v0.2.0-alpha.1
|
|||||||
2. Add upload step in the relevant `build-*` job
|
2. Add upload step in the relevant `build-*` job
|
||||||
3. **Update the release description** in `create-release` job body template
|
3. **Update the release description** in `create-release` job body template
|
||||||
4. Test with a pre-release tag first
|
4. Test with a pre-release tag first
|
||||||
|
|
||||||
|
### Re-triggering a failed release workflow
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Option A: Delete and re-push the same tag
|
||||||
|
git push origin :refs/tags/v0.1.0-alpha.2
|
||||||
|
# Delete the release in Gitea UI or via API
|
||||||
|
git tag -f v0.1.0-alpha.2
|
||||||
|
git push origin v0.1.0-alpha.2
|
||||||
|
|
||||||
|
# Option B: Just bump the version (simpler)
|
||||||
|
git tag v0.1.0-alpha.3
|
||||||
|
git push origin v0.1.0-alpha.3
|
||||||
|
```
|
||||||
|
|
||||||
|
The `create-release` job has fallback logic — if the release already exists for a tag, it fetches and reuses the existing release ID.
|
||||||
|
|
||||||
|
## Local Build Testing (Windows)
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
- NSIS: `& "$env:LOCALAPPDATA\Microsoft\WindowsApps\winget.exe" install NSIS.NSIS`
|
||||||
|
- Installs to `C:\Program Files (x86)\NSIS\makensis.exe`
|
||||||
|
|
||||||
|
### Build steps
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm ci && npm run build # frontend
|
||||||
|
bash build-dist-windows.sh v1.0.0 # Windows dist
|
||||||
|
"/c/Program Files (x86)/NSIS/makensis.exe" -DVERSION="1.0.0" installer.nsi # installer
|
||||||
|
```
|
||||||
|
|
||||||
|
### Iterating on installer only
|
||||||
|
If only `installer.nsi` changed (not app code), skip the full rebuild — just re-run `makensis`. If app code changed, re-run `build-dist-windows.sh` first since `dist/` is a snapshot.
|
||||||
|
|
||||||
|
### Common issues
|
||||||
|
|
||||||
|
| Issue | Fix |
|
||||||
|
| ----- | --- |
|
||||||
|
| `zip: command not found` | Git Bash doesn't include `zip` — harmless for installer builds |
|
||||||
|
| `Exec expects 1 parameters, got 2` | Use `MUI_FINISHPAGE_RUN_FUNCTION` instead of `MUI_FINISHPAGE_RUN_PARAMETERS` |
|
||||||
|
| `Error opening file for writing: python\_asyncio.pyd` | Server is running — stop it before installing |
|
||||||
|
| App doesn't start after install | VBS must use embedded Python fallback, not bare `python` |
|
||||||
|
| `winget` not recognized | Use full path: `$env:LOCALAPPDATA\Microsoft\WindowsApps\winget.exe` |
|
||||||
|
| `dist/` has stale files | Re-run full build script — `dist/` doesn't auto-update |
|
||||||
|
|||||||
@@ -76,7 +76,9 @@ Plain `<select>` dropdowns should be enhanced with visual selectors depending on
|
|||||||
|
|
||||||
- **Entity references** (picture sources, audio sources, devices, templates, clocks) → use `EntitySelect` from `js/core/entity-palette.ts`. This replaces the `<select>` with a searchable command-palette-style picker. See `_cssPictureSourceEntitySelect` in `color-strips.ts` or `_lineSourceEntitySelect` in `advanced-calibration.ts` for examples.
|
- **Entity references** (picture sources, audio sources, devices, templates, clocks) → use `EntitySelect` from `js/core/entity-palette.ts`. This replaces the `<select>` with a searchable command-palette-style picker. See `_cssPictureSourceEntitySelect` in `color-strips.ts` or `_lineSourceEntitySelect` in `advanced-calibration.ts` for examples.
|
||||||
|
|
||||||
Both widgets hide the native `<select>` but keep it in the DOM with its value in sync. After programmatically changing the `<select>` value, call `.refresh()` (EntitySelect) or `.setValue(val)` (IconSelect) to update the trigger display. Call `.destroy()` when the modal closes.
|
Both widgets hide the native `<select>` but keep it in the DOM with its value in sync. **The `<select>` and the visual widget are two separate things — changing one does NOT automatically update the other.** After programmatically changing the `<select>` value, call `.refresh()` (EntitySelect) or `.setValue(val)` (IconSelect) to update the trigger display. Call `.destroy()` when the modal closes.
|
||||||
|
|
||||||
|
**Common pitfall:** Using a preset/palette selector (e.g. gradient preset dropdown or effect type picker) that changes the underlying `<select>` value but forgets to call `.setValue()` on the IconSelect — the visual grid still shows the old selection.
|
||||||
|
|
||||||
**IMPORTANT:** For `IconSelect` item icons, use SVG icons from `js/core/icon-paths.ts` (via `_icon(P.iconName)`) or styled `<span>` elements (e.g., `<span style="font-weight:bold">A</span>`). **Never use emoji** — they render inconsistently across platforms and themes.
|
**IMPORTANT:** For `IconSelect` item icons, use SVG icons from `js/core/icon-paths.ts` (via `_icon(P.iconName)`) or styled `<span>` elements (e.g., `<span style="font-weight:bold">A</span>`). **Never use emoji** — they render inconsistently across platforms and themes.
|
||||||
|
|
||||||
@@ -190,6 +192,17 @@ document.addEventListener('languageChanged', () => {
|
|||||||
|
|
||||||
Static HTML using `data-i18n` attributes is handled automatically by the i18n system. Only dynamically generated HTML needs this pattern.
|
Static HTML using `data-i18n` attributes is handled automatically by the i18n system. Only dynamically generated HTML needs this pattern.
|
||||||
|
|
||||||
|
## API Calls (CRITICAL)
|
||||||
|
|
||||||
|
**ALWAYS use `fetchWithAuth()` from `core/api.ts` for authenticated API requests.** It auto-prepends `API_BASE` (`/api/v1`) and attaches the auth token.
|
||||||
|
|
||||||
|
- **Paths are relative to `/api/v1`** — pass `/gradients`, NOT `/api/v1/gradients`
|
||||||
|
- `fetchWithAuth('/gradients')` → `GET /api/v1/gradients` with auth header
|
||||||
|
- `fetchWithAuth('/devices/dev_123', { method: 'DELETE' })` → `DELETE /api/v1/devices/dev_123`
|
||||||
|
- Passing `/api/v1/gradients` results in **double prefix**: `/api/v1/api/v1/gradients` (404)
|
||||||
|
|
||||||
|
For raw `fetch()` without auth (rare), use the full path manually.
|
||||||
|
|
||||||
## Bundling & Development Workflow
|
## Bundling & Development Workflow
|
||||||
|
|
||||||
The frontend uses **esbuild** to bundle all JS modules and CSS files into single files for production.
|
The frontend uses **esbuild** to bundle all JS modules and CSS files into single files for production.
|
||||||
@@ -262,6 +275,315 @@ Reference: `.dashboard-metric-value` in `dashboard.css` uses `font-family: var(-
|
|||||||
|
|
||||||
Use `createFpsSparkline(canvasId, actualHistory, currentHistory, fpsTarget)` from `core/chart-utils.ts`. Wrap the canvas in `.target-fps-sparkline` (36px height, `position: relative`, `overflow: hidden`). Show the value in `.target-fps-label` with `.metric-value` and `.target-fps-avg`.
|
Use `createFpsSparkline(canvasId, actualHistory, currentHistory, fpsTarget)` from `core/chart-utils.ts`. Wrap the canvas in `.target-fps-sparkline` (36px height, `position: relative`, `overflow: hidden`). Show the value in `.target-fps-label` with `.metric-value` and `.target-fps-avg`.
|
||||||
|
|
||||||
|
## Adding a New Entity Type (Full Checklist)
|
||||||
|
|
||||||
|
This section documents the complete pattern for adding a new entity type to the frontend, covering tabs, cards, modals, CRUD operations, and all required wiring.
|
||||||
|
|
||||||
|
### 1. DataCache (state.ts)
|
||||||
|
|
||||||
|
Register a cache for the new entity's API endpoint in `static/js/core/state.ts`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export const myEntitiesCache = new DataCache<MyEntity[]>({
|
||||||
|
endpoint: '/my-entities',
|
||||||
|
extractData: json => json.entities || [],
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- `endpoint` is relative to `/api/v1` (fetchWithAuth prepends it)
|
||||||
|
- `extractData` unwraps the response envelope
|
||||||
|
- Subscribe to sync into a legacy variable if needed: `myEntitiesCache.subscribe(v => { _cachedMyEntities = v; });`
|
||||||
|
|
||||||
|
### 2. CardSection instance
|
||||||
|
|
||||||
|
Create a global CardSection in the feature module (e.g. `features/my-entities.ts`):
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const csMyEntities = new CardSection('my-entities', {
|
||||||
|
titleKey: 'my_entity.section_title', // i18n key for section header
|
||||||
|
gridClass: 'templates-grid', // CSS grid class
|
||||||
|
addCardOnclick: "showMyEntityEditor()", // onclick for the "+" card
|
||||||
|
keyAttr: 'data-my-id', // attribute used to match cards during reconcile
|
||||||
|
emptyKey: 'section.empty.my_entities', // i18n key shown when no cards
|
||||||
|
bulkActions: _myEntityBulkActions, // optional bulk action definitions
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Tab registration (streams.ts)
|
||||||
|
|
||||||
|
Add the tab entry in the `tabs` array inside `loadSourcesTab()`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
{ key: 'my_entities', icon: ICON_MY_ENTITY, titleKey: 'streams.group.my_entities', count: myEntities.length },
|
||||||
|
```
|
||||||
|
|
||||||
|
Then add the rendering block for the tab content:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// First load: full render
|
||||||
|
if (!csMyEntities.isMounted()) {
|
||||||
|
const items = myEntities.map(e => ({ key: e.id, html: createMyEntityCard(e) }));
|
||||||
|
html += csMyEntities.render(csMyEntities.applySortOrder(items));
|
||||||
|
} else {
|
||||||
|
// Incremental update
|
||||||
|
csMyEntities.reconcile(myEntities.map(e => ({ key: e.id, html: createMyEntityCard(e) })));
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
After `innerHTML` assignment, call `csMyEntities.bind()` for first mount.
|
||||||
|
|
||||||
|
### 4. Card builder function
|
||||||
|
|
||||||
|
Build cards using `wrapCard()` from `core/card-colors.ts`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
function createMyEntityCard(entity: MyEntity): string {
|
||||||
|
return wrapCard({
|
||||||
|
dataAttr: 'data-my-id',
|
||||||
|
id: entity.id,
|
||||||
|
removeOnclick: `deleteMyEntity('${entity.id}')`,
|
||||||
|
removeTitle: t('common.delete'),
|
||||||
|
content: `
|
||||||
|
<div class="card-header">
|
||||||
|
<div class="card-title" title="${escapeHtml(entity.name)}">
|
||||||
|
${ICON_MY_ENTITY} <span class="card-title-text">${escapeHtml(entity.name)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="stream-card-props">
|
||||||
|
<span class="stream-card-prop">🏷️ ${escapeHtml(entity.description || '')}</span>
|
||||||
|
</div>
|
||||||
|
${renderTagChips(entity.tags)}`,
|
||||||
|
actions: `
|
||||||
|
<button class="btn btn-icon btn-secondary" onclick="cloneMyEntity('${entity.id}')" title="${t('common.clone')}">${ICON_CLONE}</button>
|
||||||
|
<button class="btn btn-icon btn-secondary" onclick="showMyEntityEditor('${entity.id}')" title="${t('common.edit')}">${ICON_EDIT}</button>`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Required HTML classes:**
|
||||||
|
- `.template-card` — root (auto-added by wrapCard)
|
||||||
|
- `.card-header` > `.card-title` > `.card-title-text` — title with icon
|
||||||
|
- `.stream-card-props` > `.stream-card-prop` — property badges
|
||||||
|
- `.template-card-actions` — button row (auto-added by wrapCard)
|
||||||
|
- `.card-remove-btn` — delete X button (auto-added by wrapCard)
|
||||||
|
|
||||||
|
### 5. Modal HTML template
|
||||||
|
|
||||||
|
Create `templates/modals/my-entity-editor.html`:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<div id="my-entity-modal" class="modal" role="dialog" aria-modal="true" aria-labelledby="my-entity-title">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h2 id="my-entity-title" data-i18n="my_entity.add">Add Entity</h2>
|
||||||
|
<button class="modal-close-btn" onclick="closeMyEntityModal()" data-i18n-aria-label="aria.close">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<input type="hidden" id="my-entity-id">
|
||||||
|
<div id="my-entity-error" class="modal-error" style="display:none"></div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<div class="label-row">
|
||||||
|
<label for="my-entity-name" data-i18n="my_entity.name">Name:</label>
|
||||||
|
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
|
||||||
|
</div>
|
||||||
|
<small class="input-hint" style="display:none" data-i18n="my_entity.name.hint">...</small>
|
||||||
|
<input type="text" id="my-entity-name" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Type-specific fields here -->
|
||||||
|
|
||||||
|
<div id="my-entity-tags-container"></div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button class="btn btn-icon btn-secondary" onclick="closeMyEntityModal()" title="Cancel" data-i18n-title="settings.button.cancel">×</button>
|
||||||
|
<button class="btn btn-icon btn-primary" onclick="saveMyEntity()" title="Save" data-i18n-title="settings.button.save">✓</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
Include it in `templates/index.html`: `{% include 'modals/my-entity-editor.html' %}`
|
||||||
|
|
||||||
|
### 6. Modal class (dirty checking)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
class MyEntityModal extends Modal {
|
||||||
|
constructor() { super('my-entity-modal'); }
|
||||||
|
|
||||||
|
snapshotValues() {
|
||||||
|
return {
|
||||||
|
name: (document.getElementById('my-entity-name') as HTMLInputElement).value,
|
||||||
|
// ... all tracked fields, serialize complex state as JSON strings
|
||||||
|
tags: JSON.stringify(_tagsInput ? _tagsInput.getValue() : []),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
onForceClose() {
|
||||||
|
// Cleanup: destroy tag inputs, entity selects, etc.
|
||||||
|
if (_tagsInput) { _tagsInput.destroy(); _tagsInput = null; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const myEntityModal = new MyEntityModal();
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7. CRUD functions
|
||||||
|
|
||||||
|
**Create / Edit (unified):**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export async function showMyEntityEditor(editId: string | null = null) {
|
||||||
|
const titleEl = document.getElementById('my-entity-title')!;
|
||||||
|
const idInput = document.getElementById('my-entity-id') as HTMLInputElement;
|
||||||
|
const nameInput = document.getElementById('my-entity-name') as HTMLInputElement;
|
||||||
|
|
||||||
|
idInput.value = '';
|
||||||
|
nameInput.value = '';
|
||||||
|
|
||||||
|
if (editId) {
|
||||||
|
// Edit mode: populate from cache
|
||||||
|
const entities = await myEntitiesCache.fetch();
|
||||||
|
const entity = entities.find(e => e.id === editId);
|
||||||
|
if (!entity) return;
|
||||||
|
idInput.value = entity.id;
|
||||||
|
nameInput.value = entity.name;
|
||||||
|
titleEl.innerHTML = `${ICON_MY_ENTITY} ${t('my_entity.edit')}`;
|
||||||
|
} else {
|
||||||
|
titleEl.innerHTML = `${ICON_MY_ENTITY} ${t('my_entity.add')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
myEntityModal.open();
|
||||||
|
myEntityModal.snapshot();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Clone:** Fetch existing entity, open editor with its data but no ID (creates new):
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export async function cloneMyEntity(entityId: string) {
|
||||||
|
const entities = await myEntitiesCache.fetch();
|
||||||
|
const source = entities.find(e => e.id === entityId);
|
||||||
|
if (!source) return;
|
||||||
|
|
||||||
|
// Open editor as "create" with pre-filled data
|
||||||
|
await showMyEntityEditor(null);
|
||||||
|
(document.getElementById('my-entity-name') as HTMLInputElement).value = source.name + ' (Copy)';
|
||||||
|
// ... populate other fields from source
|
||||||
|
myEntityModal.snapshot(); // Re-snapshot after populating clone data
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Save:** POST (new) or PUT (edit) based on hidden ID field:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export async function saveMyEntity() {
|
||||||
|
const id = (document.getElementById('my-entity-id') as HTMLInputElement).value;
|
||||||
|
const name = (document.getElementById('my-entity-name') as HTMLInputElement).value.trim();
|
||||||
|
if (!name) { myEntityModal.showError(t('my_entity.error.name_required')); return; }
|
||||||
|
|
||||||
|
const payload = { name, /* ... other fields */ };
|
||||||
|
|
||||||
|
try {
|
||||||
|
const url = id ? `/my-entities/${id}` : '/my-entities';
|
||||||
|
const method = id ? 'PUT' : 'POST';
|
||||||
|
const res = await fetchWithAuth(url, { method, body: JSON.stringify(payload) });
|
||||||
|
if (!res!.ok) { const err = await res!.json(); throw new Error(err.detail); }
|
||||||
|
|
||||||
|
showToast(id ? t('my_entity.updated') : t('my_entity.created'), 'success');
|
||||||
|
myEntitiesCache.invalidate();
|
||||||
|
myEntityModal.forceClose();
|
||||||
|
if (window.loadSourcesTab) window.loadSourcesTab();
|
||||||
|
} catch (e: any) {
|
||||||
|
if (e.isAuth) return;
|
||||||
|
myEntityModal.showError(e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Delete:** Confirm, API call, invalidate cache, reload:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export async function deleteMyEntity(entityId: string) {
|
||||||
|
const ok = await showConfirm(t('my_entity.confirm_delete'));
|
||||||
|
if (!ok) return;
|
||||||
|
try {
|
||||||
|
await fetchWithAuth(`/my-entities/${entityId}`, { method: 'DELETE' });
|
||||||
|
showToast(t('my_entity.deleted'), 'success');
|
||||||
|
myEntitiesCache.invalidate();
|
||||||
|
if (window.loadSourcesTab) window.loadSourcesTab();
|
||||||
|
} catch (e: any) {
|
||||||
|
if (e.isAuth) return;
|
||||||
|
showToast(e.message || t('my_entity.error.delete_failed'), 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8. Window exports (app.ts)
|
||||||
|
|
||||||
|
Import and expose all onclick handlers:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { showMyEntityEditor, saveMyEntity, closeMyEntityModal, cloneMyEntity, deleteMyEntity } from './features/my-entities.ts';
|
||||||
|
|
||||||
|
Object.assign(window, {
|
||||||
|
showMyEntityEditor, saveMyEntity, closeMyEntityModal, cloneMyEntity, deleteMyEntity,
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**Critical:** Functions used in `onclick="..."` HTML attributes MUST appear in `Object.assign(window, ...)` or they will be undefined at runtime.
|
||||||
|
|
||||||
|
### 9. global.d.ts
|
||||||
|
|
||||||
|
Add the window function declarations so TypeScript doesn't complain:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
showMyEntityEditor?: (id?: string | null) => void;
|
||||||
|
cloneMyEntity?: (id: string) => void;
|
||||||
|
deleteMyEntity?: (id: string) => void;
|
||||||
|
```
|
||||||
|
|
||||||
|
### 10. i18n keys
|
||||||
|
|
||||||
|
Add keys to all three locale files (`en.json`, `ru.json`, `zh.json`):
|
||||||
|
|
||||||
|
```json
|
||||||
|
"my_entity.section_title": "My Entities",
|
||||||
|
"my_entity.add": "Add Entity",
|
||||||
|
"my_entity.edit": "Edit Entity",
|
||||||
|
"my_entity.created": "Entity created",
|
||||||
|
"my_entity.updated": "Entity updated",
|
||||||
|
"my_entity.deleted": "Entity deleted",
|
||||||
|
"my_entity.confirm_delete": "Delete this entity?",
|
||||||
|
"my_entity.error.name_required": "Name is required",
|
||||||
|
"my_entity.name": "Name:",
|
||||||
|
"my_entity.name.hint": "A descriptive name for this entity",
|
||||||
|
"section.empty.my_entities": "No entities yet. Click + to create one."
|
||||||
|
```
|
||||||
|
|
||||||
|
### 11. Cross-references
|
||||||
|
|
||||||
|
After adding the entity:
|
||||||
|
|
||||||
|
- **Backup/restore:** Add to `STORE_MAP` in `api/routes/system.py`
|
||||||
|
- **Graph editor:** Update entity maps in graph editor files (see graph-editor.md)
|
||||||
|
- **Tutorials:** Update tutorial steps if adding a new tab
|
||||||
|
|
||||||
|
### CRITICAL: Common Pitfalls (MUST READ)
|
||||||
|
|
||||||
|
These mistakes have been made repeatedly. **Check every one before considering the entity complete:**
|
||||||
|
|
||||||
|
1. **DOM ID conflicts:** If your modal reuses a shared component (e.g. gradient stop editor, color picker) that uses hardcoded `document.getElementById()` calls, **both modals exist in the DOM simultaneously**. The shared component will render into whichever element it finds first (the wrong one). Fix: add an ID prefix mechanism to the shared component, set it before init, reset it on modal close.
|
||||||
|
|
||||||
|
2. **Tags go under the name input:** The tags container `<div>` goes **inside the same `form-group` as the name `<input>`**, directly after it — NOT in a separate section or at the bottom of the modal. Look at any existing modal (css-editor.html, audio-source-editor.html, device-settings.html) for the pattern.
|
||||||
|
|
||||||
|
3. **Cache reload after save/delete/clone:** Use `cache.invalidate()` then `await loadPictureSources()` (imported directly from streams.ts). Do NOT use `window.loadSourcesTab()` without `await` — it reads the stale cache before the invalidation takes effect, so the new entity won't appear until page reload.
|
||||||
|
|
||||||
|
4. **IconSelect / EntitySelect sync:** When programmatically changing a `<select>` value (e.g. loading a preset, populating for edit), you MUST also call `.setValue(val)` (IconSelect) or `.refresh()` (EntitySelect). The native `<select>` and the visual widget are **separate** — changing one does NOT update the other.
|
||||||
|
|
||||||
|
5. **Never use `window.prompt()`:** Always use a proper Modal subclass with `snapshotValues()` for dirty checking. Prompts break the UX and have no validation, no hint text, no i18n.
|
||||||
|
|
||||||
|
6. **Never do API-only clone:** Clone should open the editor modal pre-filled with the source entity's data (name + " (Copy)"), with an empty ID field so saving creates a new entity. Do NOT call a `/clone` endpoint and refresh — the user must be able to edit before saving.
|
||||||
|
|
||||||
## Visual Graph Editor
|
## Visual Graph Editor
|
||||||
|
|
||||||
See [`contexts/graph-editor.md`](graph-editor.md) for full graph editor architecture and conventions.
|
See [`contexts/graph-editor.md`](graph-editor.md) for full graph editor architecture and conventions.
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ Two independent server modes with separate configs, ports, and data directories:
|
|||||||
|
|
||||||
| Mode | Command | Config | Port | API Key | Data |
|
| Mode | Command | Config | Port | API Key | Data |
|
||||||
| ---- | ------- | ------ | ---- | ------- | ---- |
|
| ---- | ------- | ------ | ---- | ------- | ---- |
|
||||||
| **Real** | `python -m wled_controller.main` | `config/default_config.yaml` | 8080 | `development-key-change-in-production` | `data/` |
|
| **Real** | `python -m wled_controller` | `config/default_config.yaml` | 8080 | `development-key-change-in-production` | `data/` |
|
||||||
| **Demo** | `python -m wled_controller.demo` | `config/demo_config.yaml` | 8081 | `demo` | `data/demo/` |
|
| **Demo** | `python -m wled_controller.demo` | `config/demo_config.yaml` | 8081 | `demo` | `data/demo/` |
|
||||||
|
|
||||||
Both can run simultaneously on different ports.
|
Both can run simultaneously on different ports.
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
; ── Metadata ────────────────────────────────────────────────
|
; ── Metadata ────────────────────────────────────────────────
|
||||||
|
|
||||||
!define APPNAME "LedGrab"
|
!define APPNAME "LedGrab"
|
||||||
|
!define VBSNAME "start-hidden.vbs"
|
||||||
!define DESCRIPTION "Ambient lighting system — captures screen content and drives LED strips in real time"
|
!define DESCRIPTION "Ambient lighting system — captures screen content and drives LED strips in real time"
|
||||||
!define VERSIONMAJOR 0
|
!define VERSIONMAJOR 0
|
||||||
!define VERSIONMINOR 1
|
!define VERSIONMINOR 1
|
||||||
@@ -33,6 +34,12 @@ SetCompressor /SOLID lzma
|
|||||||
|
|
||||||
; ── Pages ───────────────────────────────────────────────────
|
; ── Pages ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
; Use MUI_FINISHPAGE_RUN_FUNCTION instead of MUI_FINISHPAGE_RUN_PARAMETERS —
|
||||||
|
; NSIS Exec command chokes on the quoting with RUN_PARAMETERS.
|
||||||
|
!define MUI_FINISHPAGE_RUN ""
|
||||||
|
!define MUI_FINISHPAGE_RUN_TEXT "Launch ${APPNAME}"
|
||||||
|
!define MUI_FINISHPAGE_RUN_FUNCTION LaunchApp
|
||||||
|
|
||||||
!insertmacro MUI_PAGE_WELCOME
|
!insertmacro MUI_PAGE_WELCOME
|
||||||
!insertmacro MUI_PAGE_DIRECTORY
|
!insertmacro MUI_PAGE_DIRECTORY
|
||||||
!insertmacro MUI_PAGE_COMPONENTS
|
!insertmacro MUI_PAGE_COMPONENTS
|
||||||
@@ -44,6 +51,33 @@ SetCompressor /SOLID lzma
|
|||||||
|
|
||||||
!insertmacro MUI_LANGUAGE "English"
|
!insertmacro MUI_LANGUAGE "English"
|
||||||
|
|
||||||
|
; ── Functions ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
Function LaunchApp
|
||||||
|
ExecShell "open" "wscript.exe" '"$INSTDIR\scripts\${VBSNAME}"'
|
||||||
|
Sleep 2000
|
||||||
|
ExecShell "open" "http://localhost:8080/"
|
||||||
|
FunctionEnd
|
||||||
|
|
||||||
|
; Detect running instance before install (file lock check on python.exe)
|
||||||
|
Function .onInit
|
||||||
|
IfFileExists "$INSTDIR\python\python.exe" 0 done
|
||||||
|
ClearErrors
|
||||||
|
FileOpen $0 "$INSTDIR\python\python.exe" a
|
||||||
|
IfErrors locked
|
||||||
|
FileClose $0
|
||||||
|
Goto done
|
||||||
|
locked:
|
||||||
|
MessageBox MB_YESNOCANCEL|MB_ICONEXCLAMATION \
|
||||||
|
"${APPNAME} is currently running.$\n$\nYes = Stop and continue$\nNo = Continue anyway (may cause errors)$\nCancel = Abort" \
|
||||||
|
IDYES kill IDNO done
|
||||||
|
Abort
|
||||||
|
kill:
|
||||||
|
nsExec::ExecToLog 'wmic process where "ExecutablePath like $\'%${APPNAME}%python%$\'" call terminate'
|
||||||
|
Sleep 2000
|
||||||
|
done:
|
||||||
|
FunctionEnd
|
||||||
|
|
||||||
; ── Installer Sections ──────────────────────────────────────
|
; ── Installer Sections ──────────────────────────────────────
|
||||||
|
|
||||||
Section "!${APPNAME} (required)" SecCore
|
Section "!${APPNAME} (required)" SecCore
|
||||||
@@ -54,6 +88,7 @@ Section "!${APPNAME} (required)" SecCore
|
|||||||
; Copy the entire portable build
|
; Copy the entire portable build
|
||||||
File /r "build\LedGrab\python"
|
File /r "build\LedGrab\python"
|
||||||
File /r "build\LedGrab\app"
|
File /r "build\LedGrab\app"
|
||||||
|
File /r "build\LedGrab\scripts"
|
||||||
File "build\LedGrab\LedGrab.bat"
|
File "build\LedGrab\LedGrab.bat"
|
||||||
|
|
||||||
; Create data and logs directories
|
; Create data and logs directories
|
||||||
@@ -65,8 +100,9 @@ Section "!${APPNAME} (required)" SecCore
|
|||||||
|
|
||||||
; Start Menu shortcuts
|
; Start Menu shortcuts
|
||||||
CreateDirectory "$SMPROGRAMS\${APPNAME}"
|
CreateDirectory "$SMPROGRAMS\${APPNAME}"
|
||||||
CreateShortcut "$SMPROGRAMS\${APPNAME}\${APPNAME}.lnk" "$INSTDIR\LedGrab.bat" \
|
CreateShortcut "$SMPROGRAMS\${APPNAME}\${APPNAME}.lnk" \
|
||||||
"" "" "" SW_SHOWMINIMIZED "" "${DESCRIPTION}"
|
"wscript.exe" '"$INSTDIR\scripts\${VBSNAME}"' \
|
||||||
|
"$INSTDIR\python\pythonw.exe" 0
|
||||||
CreateShortcut "$SMPROGRAMS\${APPNAME}\Uninstall.lnk" "$INSTDIR\uninstall.exe"
|
CreateShortcut "$SMPROGRAMS\${APPNAME}\Uninstall.lnk" "$INSTDIR\uninstall.exe"
|
||||||
|
|
||||||
; Registry: install location + Add/Remove Programs entry
|
; Registry: install location + Add/Remove Programs entry
|
||||||
@@ -98,13 +134,15 @@ Section "!${APPNAME} (required)" SecCore
|
|||||||
SectionEnd
|
SectionEnd
|
||||||
|
|
||||||
Section "Desktop shortcut" SecDesktop
|
Section "Desktop shortcut" SecDesktop
|
||||||
CreateShortcut "$DESKTOP\${APPNAME}.lnk" "$INSTDIR\LedGrab.bat" \
|
CreateShortcut "$DESKTOP\${APPNAME}.lnk" \
|
||||||
"" "" "" SW_SHOWMINIMIZED "" "${DESCRIPTION}"
|
"wscript.exe" '"$INSTDIR\scripts\${VBSNAME}"' \
|
||||||
|
"$INSTDIR\python\pythonw.exe" 0
|
||||||
SectionEnd
|
SectionEnd
|
||||||
|
|
||||||
Section "Start with Windows" SecAutostart
|
Section "Start with Windows" SecAutostart
|
||||||
CreateShortcut "$SMSTARTUP\${APPNAME}.lnk" "$INSTDIR\LedGrab.bat" \
|
CreateShortcut "$SMSTARTUP\${APPNAME}.lnk" \
|
||||||
"" "" "" SW_SHOWMINIMIZED "" "${DESCRIPTION}"
|
"wscript.exe" '"$INSTDIR\scripts\${VBSNAME}"' \
|
||||||
|
"$INSTDIR\python\pythonw.exe" 0
|
||||||
SectionEnd
|
SectionEnd
|
||||||
|
|
||||||
; ── Section Descriptions ────────────────────────────────────
|
; ── Section Descriptions ────────────────────────────────────
|
||||||
@@ -131,6 +169,7 @@ Section "Uninstall"
|
|||||||
; Remove application files (but NOT data/ — preserve user config)
|
; Remove application files (but NOT data/ — preserve user config)
|
||||||
RMDir /r "$INSTDIR\python"
|
RMDir /r "$INSTDIR\python"
|
||||||
RMDir /r "$INSTDIR\app"
|
RMDir /r "$INSTDIR\app"
|
||||||
|
RMDir /r "$INSTDIR\scripts"
|
||||||
Delete "$INSTDIR\LedGrab.bat"
|
Delete "$INSTDIR\LedGrab.bat"
|
||||||
Delete "$INSTDIR\uninstall.exe"
|
Delete "$INSTDIR\uninstall.exe"
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user