Complete experimental UX and safe branch deployments
This commit is contained in:
parent
a95e0789cc
commit
0b3c8adbc2
2
.gitignore
vendored
2
.gitignore
vendored
@ -1,4 +1,6 @@
|
||||
node_modules/
|
||||
test-results/
|
||||
playwright-report/
|
||||
/data/
|
||||
/updates/
|
||||
plugins/*/data/**
|
||||
|
||||
@ -1,5 +1,12 @@
|
||||
# Lumi changelog
|
||||
|
||||
## 0.2.25
|
||||
|
||||
- Added verified, snapshot-backed production deployment switching between `main` and exact `experimental-*` branches, including bundled plugin code, preserved plugin data, automatic failed-apply restoration, and graceful wrapper restarts.
|
||||
- Added a guarded one-time bootstrap that completes bundled-plugin synchronization after an older stable updater installs this experimental core; metadata checks alone cannot trigger deployment.
|
||||
- Completed the experimental DesignMotionHQ UX foundation and representative settings, navigation, theme, command, updates, resources, and OBS overlay workflows with a five-viewport browser/accessibility matrix.
|
||||
- Fixed overlay editor scrolling and viewport containment, interactive pop-out preview editing, and persistent resource storage-limit saves with immediate page reconciliation.
|
||||
|
||||
## 0.2.24
|
||||
|
||||
- Fixed resource locking and deletion by sending the shared timed-confirmation token directly with AJAX actions, and made deletion reliable on Windows and network-backed storage.
|
||||
|
||||
@ -77,7 +77,11 @@ so dependency repair does not rely on spawning a command script directly.
|
||||
Recovery mode can be started with `LUMI_SAFE_MODE=1 npm run run`,
|
||||
`node run.js --safe-mode`, or `data/recovery/safe-mode.flag`. See
|
||||
[`docs/updates.md`](docs/updates.md) and
|
||||
[`docs/recovery-mode.md`](docs/recovery-mode.md).
|
||||
[`docs/recovery-mode.md`](docs/recovery-mode.md). Production can also be moved
|
||||
between `main` and an exact `experimental-*` branch from **Admin > Updates**
|
||||
with a verified pre-deployment snapshot, graceful restart, one-click return to
|
||||
`main`, and emergency restore. See
|
||||
[`docs/branch-deployments.md`](docs/branch-deployments.md).
|
||||
|
||||
## Twitch bot
|
||||
|
||||
|
||||
28
TODO.md
28
TODO.md
@ -2,6 +2,34 @@
|
||||
|
||||
This file tracks larger Lumi work that cannot safely be completed in one pass. Keep pending work under the relevant category and move completed items to the Done section with a short note.
|
||||
|
||||
## Remaining DesignMotionHQ UX work — experimental-ux checkpoint (2026-07-22)
|
||||
|
||||
The shared UX foundation and representative settings, navigation, theme, command,
|
||||
updates, resources, and overlay workflows are implemented. Continue applying the
|
||||
same hierarchy, progressive disclosure, motion, state feedback, accessibility,
|
||||
and context-preservation principles globally in this order:
|
||||
|
||||
- Migrate and browser-accept the remaining core route groups: Feedback;
|
||||
Logs/Diagnostics; Users/Privileges/Pages/Plugins; and their empty, loading,
|
||||
partial, error, offline, permission-limited, disabled, and read-only states.
|
||||
- Migrate remaining plugin-owned routes to the shared request, lifecycle, dialog,
|
||||
field-state, responsive-list, and no-forced-reload contracts.
|
||||
- Add a plugin-enabled authenticated browser fixture so Lumi AI polling,
|
||||
Improvement Center live updates, and plugin settings are exercised in the
|
||||
Playwright viewport matrix rather than only source/focused verification.
|
||||
- Browser-test the complete built-in/custom-theme contrast matrix, including
|
||||
default badges, disabled controls, preview surfaces, alerts, and plugin pages.
|
||||
- Complete manual keyboard and screen-reader acceptance for every destructive
|
||||
and entity-edit dialog, including focus restoration and timed confirmation.
|
||||
- Continue replacing broad legacy selectors and route-specific controls with
|
||||
explicit shared components; finish wording, density, helper-text, and advanced
|
||||
disclosure cleanup on lower-priority plugin and diagnostics surfaces.
|
||||
- Finish homepage hero/embed usability: non-technical configuration copy,
|
||||
unavailable/fallback explanations, Twitch parent-domain guidance, YouTube live
|
||||
failure states, and external iframe refusal handling.
|
||||
- Re-run the checkpoint review after each route group and remove completed items
|
||||
from `docs/lumi-ux-checkpoint-review.md` and the route manifest P1 list.
|
||||
|
||||
## Command policy and stream controls
|
||||
|
||||
Implemented on 2026-07-19: centralized command groups and per-command overrides
|
||||
|
||||
82
docs/branch-deployments.md
Normal file
82
docs/branch-deployments.md
Normal file
@ -0,0 +1,82 @@
|
||||
# Safe production branch deployments
|
||||
|
||||
Lumi can deploy the stable `main` branch or an exact published
|
||||
`experimental-*` branch from **Admin > Updates > Running code branch**. This is
|
||||
a managed deployment; Lumi does not change the live installation's Git
|
||||
worktree. It fetches the selected branch into `data/update-cache/repo`, verifies
|
||||
the core and bundled-plugin entry files, snapshots the running core and plugin
|
||||
code plus the database, replaces managed application files, records the deployed
|
||||
branch and commit, and then restarts.
|
||||
|
||||
Local data remains in place during a normal branch switch, including settings,
|
||||
databases, plugin data, uploads, logs, secrets, AI models/runtimes, community
|
||||
knowledge, corrections, and `node_modules`. Bundled plugin code follows the
|
||||
selected branch; local-only plugins absent from that branch stay installed. The
|
||||
update-aware wrapper repairs changed runtime dependencies before it starts the
|
||||
new branch.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
1. Start production through `npm start`, `npm run run`, or a service that runs
|
||||
`node run.js`. The wrapper recognizes Lumi's restart exit code and starts the
|
||||
deployed code after dependency verification.
|
||||
2. Ensure the configured repository credential can fetch branches without an
|
||||
interactive password prompt.
|
||||
3. Push the experimental branch before opening the deployment control. Only
|
||||
`main` and remote branches named `experimental-*` are accepted.
|
||||
4. Keep snapshot retention high enough to retain at least the latest core
|
||||
backup. The default keeps five per target for 30 days.
|
||||
|
||||
## Test an experimental branch
|
||||
|
||||
To enter from stable `0.2.24`, open **Admin > Settings**, select
|
||||
**Experimental** as the update branch, save, and use **Update from repository**.
|
||||
The older updater installs experimental core `0.2.25`; on its first startup the
|
||||
new guarded bootstrap confirms that an actual core update completed, snapshots
|
||||
the installation, synchronizes bundled plugin code, and restarts a second time.
|
||||
Merely checking metadata cannot trigger this bootstrap. Stable `0.2.24` predates
|
||||
the exact-branch picker, so it selects the newest published `experimental-*`
|
||||
branch. The same guarded entry path is available again after a return to stable.
|
||||
While experimental code is running, use the exact branch controls below to move
|
||||
between `main` and any published experimental branch.
|
||||
|
||||
1. Open **Admin > Updates** and find **Running code branch**.
|
||||
2. Confirm the page reports the expected running branch and commit.
|
||||
3. Choose the exact experimental branch and select **Deploy selected branch**.
|
||||
4. Review the confirmation. Keep the page open while Lumi creates the backup,
|
||||
deploys, restarts gracefully, and reconnects.
|
||||
5. Verify the branch and commit after reconnection, then test the affected
|
||||
production workflows.
|
||||
|
||||
The same branch can be selected again for a verified repair deployment.
|
||||
|
||||
## Return to stable main
|
||||
|
||||
Use **Return safely to main**. Lumi first snapshots the running experimental
|
||||
code, then deploys the current remote `main` while preserving live data. This is
|
||||
the preferred normal return path because it does not intentionally rewind the
|
||||
database or discard data written during the experiment.
|
||||
|
||||
Experimental database migrations must remain backward-compatible with `main`
|
||||
when this path is expected to work. Mark experimental update metadata with
|
||||
`rollback_safe: false` and document the migration when a change cannot safely be
|
||||
read by the stable code.
|
||||
|
||||
## Emergency restore
|
||||
|
||||
Use **Emergency restore previous backup** only when the deployed branch cannot
|
||||
run correctly enough to return through the normal branch control. It restores
|
||||
both the previous core code and the database captured before deployment, so
|
||||
database changes written after that snapshot may be lost.
|
||||
|
||||
If the normal WebUI is unavailable, start recovery mode with
|
||||
`LUMI_SAFE_MODE=1 npm run run`, `node run.js --safe-mode`, or the
|
||||
`data/recovery/safe-mode.flag` marker. Recovery markers and deployment state are
|
||||
also visible through the production diagnostics `update_state` check.
|
||||
|
||||
## Failure behavior
|
||||
|
||||
Fetching and target verification happen before live files are touched. Once
|
||||
replacement starts, failures trigger an automatic restore from the new
|
||||
snapshot. If automatic restore also fails, Lumi retains the recovery marker and
|
||||
snapshot for safe mode instead of silently continuing with a partial install.
|
||||
110
docs/lumi-ui.md
110
docs/lumi-ui.md
@ -19,10 +19,24 @@ from visual tokens and reusable components.
|
||||
- `src/web/public/lumi-interactions.js`: progressive interaction layer for
|
||||
server-sent events, no-auto-refresh notices, dirty settings save bars,
|
||||
expandable settings containers, refresh prompts, and soft navigation.
|
||||
- `src/web/public/lumi-request.js`: shared enhanced request parser and the
|
||||
`{ ok, message, fieldErrors, data, patch, redirect, reloadRequired }`
|
||||
response contract.
|
||||
- `src/web/public/lumi-page.js`: opt-in page mount/unmount lifecycle. It aborts
|
||||
page-owned listeners and requests before running the registered teardown.
|
||||
- `src/web/public/lumi-dialog.js`: shared focus trap, background isolation,
|
||||
Escape policy, scroll lock, and opener restoration for modal surfaces.
|
||||
- `src/web/public/command-management.js` and `command-policies.js`: lifecycle-owned
|
||||
custom-command editing, targeted collection refresh, policy search, inherited
|
||||
summaries, and scoped save reconciliation.
|
||||
- `src/web/public/update-management.js`: lifecycle-owned repository checks,
|
||||
progress subscriptions, recovery actions, snapshot-policy validation, and
|
||||
restart reconnection without document reloads.
|
||||
- `src/services/web-events.js`: small role-aware Server-Sent Events bus exposed
|
||||
at `GET /api/events` for authenticated users.
|
||||
- `src/web/public/styles.css`: legacy and feature-specific styles that still use
|
||||
the shared tokens. New general-purpose styling belongs in the Lumi UI files.
|
||||
- `src/web/public/styles.css`: layered legacy/base styles kept during migration.
|
||||
It must not become a second component library. New general-purpose styling
|
||||
belongs in the Lumi UI files.
|
||||
- `src/web/views/partials/page-header.ejs`: standard page title and description.
|
||||
- `src/web/views/partials/theme-vars.ejs`: safe active-theme variables for shell
|
||||
and standalone pages.
|
||||
@ -34,6 +48,32 @@ Use `lumi-stack`, `lumi-cluster`, `lumi-split`, `lumi-grid`, `page-header`,
|
||||
and `status-indicator` before adding one-off layout rules. Preserve existing IDs,
|
||||
field names, data attributes, and JavaScript hooks when restyling a page.
|
||||
|
||||
## Product-wide UX principles
|
||||
|
||||
Lumi follows the interaction principles catalogued by DesignMotionHQ across the
|
||||
entire product, not only on individual redesigns. Treat these as review criteria
|
||||
for core pages, plugins, overlays, dialogs, and future features:
|
||||
|
||||
- Use proximity and whitespace to make related controls read as a group before
|
||||
adding borders or extra containers. Keep within-group spacing tighter than the
|
||||
space between groups.
|
||||
- Match surface hierarchy to the task. Routine, non-blocking work belongs in the
|
||||
page, an anchored popover, or an edge drawer that preserves context. Reserve a
|
||||
modal for a decision or task that genuinely blocks the rest of the interface.
|
||||
- Keep disclosure predictable. Use native buttons and details/summary controls,
|
||||
expose their state to assistive technology, and avoid layout jumps that move
|
||||
the control a person just used.
|
||||
- Give every interactive element a visible focus state. DOM order must match the
|
||||
visual reading order, and closing a temporary surface returns focus to its
|
||||
opener.
|
||||
- Motion explains spatial or state changes and never delays routine work. Honor
|
||||
reduced-motion preferences and avoid decorative animation that competes with
|
||||
status, content, or controls.
|
||||
|
||||
New reusable behavior belongs in the shared Lumi UI layer. A feature stylesheet
|
||||
may compose those patterns for its own layout, but should not create a competing
|
||||
interaction model.
|
||||
|
||||
## Interaction Rules
|
||||
|
||||
Pages should not self-refresh for state or progress changes. Core connection
|
||||
@ -41,6 +81,10 @@ recovery now displays a notice instead of calling `window.location.reload()`.
|
||||
Server-originated events use `GET /api/events` with explicit event names such as
|
||||
`server:status`, `server:warning`, `ai:model_status`, and
|
||||
`data:new_available`. Admin-only events must be published with `{ role: "admin" }`.
|
||||
Browser consumers subscribe through `window.LumiEvents.subscribe(...)` so a page
|
||||
uses one shared event connection. The connection is closed before full navigation
|
||||
and on `pagehide`, preventing long-lived streams from starving the next document
|
||||
request. Public pages do not connect to the authenticated event endpoint.
|
||||
|
||||
List/data updates should announce that new data exists and show a refresh prompt.
|
||||
The shared refresh prompt uses a 3-second cooldown before another refresh can be
|
||||
@ -52,6 +96,18 @@ tracks original values, marks changed fields with theme-aware unsaved styling,
|
||||
shows a top Save changes bar, warns before accidental navigation, and clears
|
||||
markers only after successful saves.
|
||||
|
||||
Settings saves use `LumiRequest`, update each successful form's canonical
|
||||
snapshot, keep failed forms dirty, map returned field errors to their controls,
|
||||
and return focus to the last edited field. The save bar stays stable while
|
||||
scrolling and provides both Save and Revert. A successful ordinary save never
|
||||
reloads the page. Handlers may set `reloadRequired: true` only when a runtime
|
||||
restart or equivalent context replacement genuinely requires it.
|
||||
|
||||
Entity forms that can safely patch their current region use
|
||||
`data-lumi-enhanced-form`. They retain an ordinary method/action fallback,
|
||||
receive local pending and result text, preserve input after failure, and emit
|
||||
`lumi:form-saved` with the normalized response for feature-owned reconciliation.
|
||||
|
||||
Buttons should use `partials/state-button.ejs` for submit, loading, success, or
|
||||
error states. Single-state and multi-state buttons share the same Lumi button
|
||||
tokens. Hidden states stay measurable with `data-state-hidden`, so the button
|
||||
@ -71,14 +127,38 @@ known placeholders such as `{gifter_username}`, `{item_name}`,
|
||||
`{creator_username}`, and `{amount_display}` render with plausible sample values
|
||||
without changing the saved template.
|
||||
|
||||
Soft navigation progressively enhances same-origin links by replacing
|
||||
`main.content`, updating history, and fading content in place. If a fetch fails,
|
||||
JavaScript is unavailable, or unsaved settings are present, navigation falls back
|
||||
to normal browser behavior.
|
||||
Soft navigation is deliberately opt-in with `data-lumi-soft-nav`. A route may
|
||||
opt in only after its page code uses `LumiPage.register(...)` and its feature
|
||||
assets are already available. The lifecycle unmounts the previous page before
|
||||
replacing `main.content`, then updates title, history, active navigation, scroll,
|
||||
and heading focus. Arbitrary inline scripts are never re-executed. All other
|
||||
links retain normal browser navigation.
|
||||
|
||||
Sidebar navigation sections behave as an accordion. Opening one `.nav-section`
|
||||
closes the other expanded sections while preserving the active page highlight
|
||||
and `aria-expanded` state.
|
||||
and `aria-expanded` state. On mobile the sidebar is a modal drawer: its closed
|
||||
content is inert and hidden from the accessibility tree, focus is contained
|
||||
while open, Escape and the scrim close it, and focus returns to the opener.
|
||||
|
||||
## Cascade, widths, and state surfaces
|
||||
|
||||
The stylesheet contract is declared as `@layer reset, tokens, base, layout,
|
||||
components, utilities, features, overrides`. Tokens, layout, and shared
|
||||
components own their respective generic selectors; plugin and editor CSS belongs
|
||||
to `features`. Temporary specificity exceptions belong to `overrides` and need
|
||||
a removal note.
|
||||
|
||||
Rendered pages default to `standard` width. Pass `pageWidth` as `prose`, `form`,
|
||||
`standard`, `wide`, or `workspace` to the shared layout. Workspace width is for
|
||||
editors such as overlays, not ordinary settings pages. The document remains the
|
||||
primary vertical scroll container; bounded logs, tables, dialogs, canvas
|
||||
surfaces, and explicitly labelled workspace editor panes are the exceptions.
|
||||
|
||||
Use `empty-state`, `loading-state`, `partial-state`, `error-state`,
|
||||
`offline-state`, and `permission-state` with specific recovery copy. Status is
|
||||
never color-only. Persistent service degradation uses a banner, field/component
|
||||
errors stay inline, low-severity completion may use a dismissible notice, and a
|
||||
dialog is reserved for a genuine decision blocker.
|
||||
|
||||
## Themes
|
||||
|
||||
@ -120,6 +200,11 @@ Run `npm run verify:webui` to compile every EJS view and exercise built-in theme
|
||||
validation plus custom duplicate, apply, edit validation, typography validation,
|
||||
stateful theme actions, localhost login rendering, rename, and delete.
|
||||
|
||||
Run `npm run verify:ux` for the request/lifecycle/dialog/drawer/cascade and
|
||||
overlay regression contracts. Run `npm run test:ui` for the temporary-database
|
||||
Playwright matrix at 320, 390, 768, 1280, and 1920 px with axe, keyboard drawer,
|
||||
no-reload settings, and overlay scroll/save checks.
|
||||
|
||||
## Localhost Login
|
||||
|
||||
Development builds opened from `localhost`, `127.0.0.1`, or `::1` show a
|
||||
@ -198,11 +283,12 @@ with collapsed summaries, warning/danger badges, safe target versions, changelog
|
||||
ranges, migration notes, recovery state, snapshot/revert availability, and live
|
||||
progress from `GET /admin/updates/events`.
|
||||
|
||||
Core update success shows a five-second in-page notice before refresh/restart.
|
||||
Plugin update success logs progress for the affected plugin row without forcing
|
||||
a whole-page refresh. ZIP update controls remain available but are hidden under
|
||||
advanced/manual reveal sections because ZIPs may bypass repository metadata and
|
||||
compatibility checks.
|
||||
Core and plugin update success keeps the current document available while Lumi
|
||||
restarts, reports reconnect attempts, and refreshes the update workspace in
|
||||
place after the service is reachable. Repository checks and snapshot cleanup
|
||||
settings reconcile locally, with field-linked validation errors. ZIP update
|
||||
controls remain available but are hidden under advanced/manual reveal sections
|
||||
because ZIPs may bypass repository metadata and compatibility checks.
|
||||
|
||||
See [Update system](updates.md) and [Recovery mode](recovery-mode.md) for the
|
||||
semver policy, compatibility bridge behavior, snapshots, revert limits, recovery
|
||||
|
||||
50
docs/lumi-ux-checkpoint-review.md
Normal file
50
docs/lumi-ux-checkpoint-review.md
Normal file
@ -0,0 +1,50 @@
|
||||
# Lumi UX overhaul checkpoint review
|
||||
|
||||
This review records the current staged implementation checkpoint on `experimental-ux`. It covers the shared foundations and the highest-risk representative flows; it is not the final product-wide acceptance review.
|
||||
|
||||
## Implemented surfaces
|
||||
|
||||
- Shared tokens, cascade layers, content widths, field states, request envelopes, page lifecycle, dialogs, save/revert behavior, and explicit soft navigation.
|
||||
- Responsive shell navigation, including focus containment, inert background content, Escape/scrim dismissal, and opener focus restoration.
|
||||
- Admin settings targeted saves with inline and partial-error handling.
|
||||
- Overlay management document scrolling, lifecycle cleanup, targeted saves, and OBS import reconciliation without a document reload.
|
||||
- OKF draft reset and edit flows without a forced reload.
|
||||
- Navigation builder keyboard movement, scoped save/reset responses, explicit labels, no-JavaScript JSON fallback, and lifecycle-owned behavior.
|
||||
- Theme Studio targeted custom-theme saves, canonical preview reconciliation, lifecycle cleanup, and contrast corrections for disabled and preview states.
|
||||
- Lumi AI settings lifecycle ownership for polling/timers and structured enhanced responses while retaining normal POST fallbacks.
|
||||
- One shared authenticated live-event connection across core Updates and the Lumi AI Improvement Center, with explicit release before full navigation and repeated-navigation browser coverage.
|
||||
- Custom commands and command policies with lifecycle-owned editors, structured validation, targeted saves/list refresh, keyboard-sortable discovery, and labelled mobile command cards.
|
||||
- Updates and recovery with lifecycle-owned progress, shared event subscriptions, timed recovery/restore confirmations, targeted repository checks and cleanup-policy saves, and restart reconnection without a document reload.
|
||||
- Plugin authoring guidance and a route/state manifest.
|
||||
- Playwright coverage at 320, 390, 768, 1280, and 1920 pixels, including serious/critical axe checks on the public shell and representative authenticated pages.
|
||||
|
||||
## Severity review
|
||||
|
||||
### P0
|
||||
|
||||
No known P0 issue remains in the changed representative flows.
|
||||
|
||||
### P1
|
||||
|
||||
- Feedback, logs/diagnostics, users/privileges/pages/plugins, and remaining plugin routes still need page-by-page migration and acceptance against the route manifest.
|
||||
- The plugin-enabled browser fixture is not yet available, so Lumi AI lifecycle behavior is source/regression verified but not included in the authenticated Playwright route matrix.
|
||||
- The complete built-in/custom-theme contrast matrix has not yet been browser-tested.
|
||||
- All destructive and entity-edit dialogs have not yet received manual keyboard and screen-reader validation.
|
||||
|
||||
### P2
|
||||
|
||||
- Several legacy views still rely on broad base selectors. Cascade layers now constrain their precedence, but the markup should continue moving to explicit shared components.
|
||||
- Representative empty, loading, error, partial-success, offline, permission-limited, disabled, and read-only states exist in the design contract; not every route renders every applicable state yet.
|
||||
|
||||
### P3
|
||||
|
||||
- Visual polish and density tuning remain for lower-priority plugin and diagnostics surfaces after their component migration.
|
||||
|
||||
## Exit evidence for this checkpoint
|
||||
|
||||
- `npm run verify:ux`
|
||||
- `npm run test:ui`
|
||||
- `node scripts/verify-all.js`
|
||||
- `npm audit`
|
||||
|
||||
The next checkpoint should migrate the remaining route groups in the order recorded in `docs/lumi-ux-route-manifest.md`, then repeat this review with changed routes removed from the remaining P1 list.
|
||||
165
docs/lumi-ux-route-manifest.md
Normal file
165
docs/lumi-ux-route-manifest.md
Normal file
@ -0,0 +1,165 @@
|
||||
# Lumi UX route and state manifest
|
||||
|
||||
This manifest is the implementation contract for the DesignMotionHQ UX overhaul. It is maintained alongside `docs/lumi-ui.md` and must be updated whenever a rendered route, role boundary, asynchronous state, or page-owned script changes.
|
||||
|
||||
## Shared state contract
|
||||
|
||||
Every data-dependent surface distinguishes these states instead of collapsing them into a blank page or generic error:
|
||||
|
||||
| State | Required presentation | Recovery or next action |
|
||||
|---|---|---|
|
||||
| Loading | Preserve existing content where possible; otherwise use a delayed local spinner or content-shaped skeleton. | Cancel when supported; do not flash a loader for work completing in under 300 ms. |
|
||||
| Empty | Explain why the collection is empty. | Offer the role-appropriate create, connect, configure, or learn-more action. |
|
||||
| Partial | Keep successful content visible and name the unavailable provider, row, or widget. | Retry the failed region or review its settings. |
|
||||
| Error | Anchor a specific message to the failed form/component and state what remains safe. | Retry, correct linked fields, copy diagnostics, or return to a safe view. |
|
||||
| Success | Patch the smallest affected region and announce completion without moving focus. | Continue in place or follow an explicit next-step link. |
|
||||
| Offline/degraded | Distinguish local Lumi availability from Twitch, Discord, YouTube, OBS, AI runtime, socket, and other provider state. | Reconnect, configure, retry, or keep using unaffected local features. |
|
||||
|
||||
Dirty, saving, queued, disabled, read-only, permission-denied, rate-limited, stale, reconnecting, conflict, and destructive-pending states extend this table where relevant.
|
||||
|
||||
## Public and member surfaces
|
||||
|
||||
Routes: `/`, `/commands`, `/leaderboards`, `/stats`, `/stats/:username`, `/profile`, `/feedback`, `/pages/:slug`, `/mods`, `/moderator`, `/auth/*`, `/setup`, and restricted/error/missing views.
|
||||
|
||||
Surface: Public discovery and member account tasks
|
||||
Users/roles: anonymous visitors, members, moderators, administrators
|
||||
Primary job: discover community capabilities or complete the one account/community task named by the route
|
||||
Entry context: sidebar, shared link, authentication return, or a community call to action
|
||||
Desired outcome: useful information or a completed account action without admin density
|
||||
Primary action: search/discover, sign in/link, submit feedback, or save the current profile task
|
||||
Secondary actions: filters, comparison, history, related community destinations
|
||||
Tertiary/destructive actions: unlink account, revoke or delete through separated contextual confirmation
|
||||
Information needed immediately: current identity/status, the main content, eligibility and prerequisites
|
||||
Information deferred: raw IDs, diagnostics, uncommon account details
|
||||
Worst-case path: authentication/provider failure or destructive account action; preserve input and provide a safe return
|
||||
Six-state behavior: shared contract; empty and filtered-empty copy are distinct
|
||||
Feedback/loading strategy: local pending control, inline result, lightweight skeleton for collections
|
||||
Mobile/tablet/desktop strategy: single-column reading flow on phone; labelled list rows replace small action tables; standard/prose width
|
||||
Keyboard/focus strategy: heading focus after navigation, visible search, semantic lists/tables, labelled account actions
|
||||
Success exit/next step: remain in context with updated status, or return to the intended pre-auth route
|
||||
|
||||
## Core administration surfaces
|
||||
|
||||
Routes: `/admin`, `/admin/settings`, `/admin/navigation`, `/admin/theming`, `/admin/diagnostics`, `/admin/logs`, `/admin/feedback`, `/admin/commands`, `/admin/command-policies`, `/admin/privileges`, `/admin/users`, `/admin/pages`, `/admin/plugins`, `/admin/updates`, `/admin/resources`, and platform setup wizards.
|
||||
|
||||
Surface: Core administration workspaces
|
||||
Users/roles: administrators; moderators on explicitly delegated command/user routes
|
||||
Primary job: assess health, configure one domain, or manage one collection safely
|
||||
Entry context: persistent administration navigation or an actionable dashboard warning
|
||||
Desired outcome: a confirmed targeted update with place, filters, and draft context preserved
|
||||
Primary action: domain-specific save/create/review/update action
|
||||
Secondary actions: visible search, filters, preview, test, duplicate, export
|
||||
Tertiary/destructive actions: advanced diagnostics, reset, uninstall, delete, rollback and manual update paths
|
||||
Information needed immediately: current state, consequences, readiness, main decision fields, validation
|
||||
Information deferred: raw metadata, rare provider controls, verbose diagnostics and recovery tooling
|
||||
Worst-case path: partial save, invalid configuration, provider loss, destructive mutation, update/restart interruption
|
||||
Six-state behavior: shared contract plus scoped dirty/saving/conflict/restart-required states
|
||||
Feedback/loading strategy: enhanced response envelope, field-linked errors, local progress, persistent degraded banner
|
||||
Mobile/tablet/desktop strategy: form/standard widths for settings; wide list-detail layouts; workspace widths only for editors
|
||||
Keyboard/focus strategy: persistent labels, section links, table row names, non-drag reorder controls, dialog focus restoration
|
||||
Success exit/next step: patch the affected section and keep focus; restart/reconnect only when explicitly required
|
||||
|
||||
### Core route matrix
|
||||
|
||||
| Surface | Representative states | Width | Form taxonomy | Primary regression risk |
|
||||
|---|---|---|---|---|
|
||||
| Dashboard | healthy, partial provider failure, stale, loading | standard | immediate action | duplicated configuration and non-actionable status |
|
||||
| Settings/homepage | clean, dirty, invalid, partial save, restart-required | form/wide | settings | reload, lost preview, unrelated batched forms |
|
||||
| Navigation builder | empty, selected, dirty, invalid upload | workspace | settings/upload | lost selection and drag-only ordering |
|
||||
| Theme Studio | built-in read-only, custom draft, invalid contrast, pop-out | wide | settings/entity | draft leaking globally or preview reload |
|
||||
| Commands/policies | empty, filtered-empty, selected, invalid, permission-limited | wide | entity/filter/destructive | dense schema presentation and lost filters |
|
||||
| Feedback | empty queue, filtered, selected, export pending, merged | wide | entity/immediate/destructive | losing current item after mutation |
|
||||
| Diagnostics/logs | idle, running, partial, degraded, paused/following | wide | immediate/settings | whole-page scrolling for bounded log data |
|
||||
| Users/privileges/pages/plugins | empty, filtered, row pending, denied | wide | entity/filter/destructive/upload | unnamed row actions and forced reload |
|
||||
| Updates/recovery | current, available, applying, reconnecting, failed, recovery | form/wide | immediate/upload/destructive | hiding recommended path or implicit reload |
|
||||
| Resources | empty library, queue pending, partial upload, quota full, delivery error | workspace | upload/entity/filter | failed file erasing successful queue items |
|
||||
| Setup wizards | prerequisites, verifying, provider error, complete | form | entity/immediate | accidental submit and lost prior steps |
|
||||
|
||||
## Overlay management workspace
|
||||
|
||||
Routes: `/admin/overlays`, `/admin/overlays/:id`, `/api/admin/overlays/*`, and privileged OBS/preview endpoints.
|
||||
|
||||
Surface: Overlay list, scene/source editor, events, OBS integration and diagnostics
|
||||
Users/roles: administrators and users granted explicit overlay capabilities
|
||||
Primary job: compose and verify what OBS renders without losing editor context
|
||||
Entry context: overlay list or an OBS/integration warning
|
||||
Desired outcome: a saved canonical overlay matching the live draft preview
|
||||
Primary action: edit the selected scene/source and save its scoped changes
|
||||
Secondary actions: switch scene, test, align, reorder, preview, copy protected URL
|
||||
Tertiary/destructive actions: advanced diagnostics, token replacement, reset and delete
|
||||
Information needed immediately: selected object, preview, dirty/save state, validation and OBS connection state
|
||||
Information deferred: event bindings, delivery credentials, raw diagnostics and destructive operations
|
||||
Worst-case path: invalid source, blocked URL, partial render, disconnected OBS/socket, revoked link, destructive deletion
|
||||
Six-state behavior: loading/empty-scene/partial-source/error/success/offline plus dirty and reconnecting
|
||||
Feedback/loading strategy: draft-first rendering, targeted requests, optimistic transform with rollback, live status text
|
||||
Mobile/tablet/desktop strategy: document is the only main vertical scroller; preview before inspector on narrow layouts; sticky preview only when it fits
|
||||
Keyboard/focus strategy: named stage, arrow movement, labelled numeric fields and ordering buttons; no forced viewport movement
|
||||
Success exit/next step: selection, open inspector, focus, viewport, canvas zoom and preview remain unchanged
|
||||
|
||||
## Plugin surfaces
|
||||
|
||||
Routes: `/plugins/auto-vc`, `/plugins/birthday`, `/plugins/economy-framework`, `/plugins/economy-games`, `/plugins/expression-interaction`, `/plugins/lumi_ai`, `/plugins/lumi_ai/improvement_center`, `/plugins/moderation`, `/plugins/okf`, `/plugins/okf/admin`, `/plugins/quotes`, `/plugins/throne_wishlist`, and `/plugins/welcome_messages` plus their detail/action routes.
|
||||
|
||||
Surface: Plugin-owned member and administration workflows
|
||||
Users/roles: members, moderators, administrators, and plugin-specific grantees
|
||||
Primary job: use or configure the capability named by the plugin
|
||||
Entry context: role-filtered plugin navigation, profile integration, or linked improvement task
|
||||
Desired outcome: complete the plugin task with the same Lumi interaction contract as core
|
||||
Primary action: plugin-specific save/create/review action
|
||||
Secondary actions: search, filter, preview, test, duplicate, archive
|
||||
Tertiary/destructive actions: raw diagnostics, maintenance, reset, revoke and delete
|
||||
Information needed immediately: capability availability, current configuration/status, dependencies and main task
|
||||
Information deferred: advanced provider/model fields, history, raw logs and maintenance
|
||||
Worst-case path: missing dependency, runtime/provider offline, permission denial, partial save or destructive maintenance
|
||||
Six-state behavior: shared contract with explicit unconfigured and permission states
|
||||
Feedback/loading strategy: shared request/form/dialog/status services; long AI work uses progress or meaningful stages
|
||||
Mobile/tablet/desktop strategy: declared form/standard/wide width; responsive list/table strategy; section navigation for dense plugins
|
||||
Keyboard/focus strategy: labelled controls, contextual row actions, preserved master-detail selection and dialog return focus
|
||||
Success exit/next step: patch the current domain/row and preserve filter, entry and disclosure state
|
||||
|
||||
### Plugin migration order and intent
|
||||
|
||||
| Plugin group | Primary workflow | Information architecture | Representative degraded state |
|
||||
|---|---|---|---|
|
||||
| Lumi AI | configure runtime/models and review improvement work | runtime, budgets, routing, context, tools, safety, improvement, diagnostics | runtime/model/search dependency unavailable |
|
||||
| OKF | search knowledge or review/edit an entry | visible search plus master-detail review | entry permission changed or correction preview failed |
|
||||
| Economy + games | configure currency flows and manage balances/events | currency, earning, spending, banking, limits, maintenance | platform earning source unavailable |
|
||||
| Moderation | review status/actions/evidence/notes | list-detail with separated evidence and action | platform enforcement partially failed |
|
||||
| Remaining plugins | configure one bounded capability | summary, main settings, entities, advanced/maintenance | integration unconfigured or provider offline |
|
||||
|
||||
## Standalone browser surfaces
|
||||
|
||||
Routes: `/overlay/:token`, fixed-scene overlay routes, `/overlay-chat/:ticket`, `/overlay-web/:ticket`, and standalone moderation status.
|
||||
|
||||
Surface: OBS/browser-source rendering and standalone restriction status
|
||||
Users/roles: viewers of a protected browser source, OBS runtime, or sanctioned user
|
||||
Primary job: render reliably without the application shell, or understand a restriction and next step
|
||||
Entry context: OBS Browser Source URL or moderation redirect
|
||||
Desired outcome: stable transparent media rendering or a clear restriction explanation
|
||||
Primary action: normally none for OBS; follow the stated recovery/appeal action for restriction state
|
||||
Secondary actions: reconnect/status only when normal browser interaction exists
|
||||
Tertiary/destructive actions: none
|
||||
Information needed immediately: render/status content and concise recovery information
|
||||
Information deferred: technical diagnostics and protected credentials
|
||||
Worst-case path: expired ticket, blocked media, CSP failure, socket loss, unavailable provider
|
||||
Six-state behavior: loading, empty scene, partial module, render error, connected success, reconnecting/offline
|
||||
Feedback/loading strategy: preserve last safe frame while reconnecting; never require focus or user gesture assumptions unique to tabs
|
||||
Mobile/tablet/desktop strategy: transparent canvas geometry is independent of shell breakpoints; status pages use prose width
|
||||
Keyboard/focus strategy: decorative render tree is hidden where appropriate; status page remains normally navigable
|
||||
Success exit/next step: continue rendering; no automatic document navigation
|
||||
|
||||
## Baseline selectors and contracts to preserve
|
||||
|
||||
- Main replacement root: `main.content`; page scripts must register with `LumiPage` before opting into soft navigation.
|
||||
- Settings enhancement: `form[data-lumi-settings-form]`; ordinary POST remains the no-JavaScript fallback.
|
||||
- State actions: `[data-lumi-state-button]` and existing route-specific action attributes.
|
||||
- Overlay editor: `[data-overlay-editor-state]`, `[data-overlay-editor]`, `[data-editor-scene]`, `[data-module-editor]`, and protected overlay capability checks.
|
||||
- Dialog backdrops: `.modal-backdrop[aria-hidden]`; opening code must use `LumiDialog` or emit compatible state changes.
|
||||
- Navigation: `[data-sidebar-toggle]`, `#lumi-sidebar`, `[data-sidebar-dismiss]`, `.nav-link`, and `.nav-section`.
|
||||
- Enhanced response: `{ ok, message, fieldErrors, data, patch, redirect, reloadRequired }`; reload is permitted only when `reloadRequired === true`.
|
||||
|
||||
## Phase review record
|
||||
|
||||
Known baseline P0/P1 findings are the reload-based save flow, viewport-coupled overlay settings height, forced overlay scrolling, page scripts without teardown, incomplete drawer focus handling, fragmented dialogs, inconsistent labels, and duplicated generic CSS ownership. A phase cannot be closed while a changed surface retains a known P0/P1 finding.
|
||||
|
||||
Current migrated core surfaces include settings, overlay management, navigation builder, custom-theme editing, custom commands, command policies, and updates/recovery. Navigation changes now support pointer and labelled keyboard movement without a document reload; custom-theme saves preserve the draft preview and return canonical values. Command mutations return field-linked errors and refresh only the managed collection, while policy batches remain in place after save. Update checks, recovery actions, and cleanup settings now preserve the document, destructive recovery actions attach timed confirmation tokens, and restart flows reconnect before refreshing the workspace in place. Lumi AI settings polling is lifecycle-owned, but plugin-enabled browser-fixture coverage remains a required later gate.
|
||||
104
docs/plugin-ui-guide.md
Normal file
104
docs/plugin-ui-guide.md
Normal file
@ -0,0 +1,104 @@
|
||||
# Lumi plugin UI guide
|
||||
|
||||
Plugin pages are Lumi product surfaces. They use the shared EJS layout, tokens,
|
||||
components, request contract, dialog behavior, accessibility rules, and browser
|
||||
verification; plugins must not ship a parallel generic component library.
|
||||
|
||||
## Page composition
|
||||
|
||||
Render through the shared layout and pass a deliberate `pageWidth`: `form` for
|
||||
bounded configuration, `standard` for ordinary member/admin pages, `wide` for
|
||||
list-detail or data-heavy pages, and `workspace` only for a real editor. Use the
|
||||
shared page/section headers, stacks, clusters, fields, buttons, notices, badges,
|
||||
table wrappers, disclosures, and state surfaces.
|
||||
|
||||
Organize fields around the user's decision rather than storage order. Keep one
|
||||
primary action per region, quieter secondary actions nearby, tertiary work in a
|
||||
named disclosure/menu, and destructive actions spatially separated with the
|
||||
existing contextual safeguard.
|
||||
|
||||
## Forms and enhanced responses
|
||||
|
||||
Keep a functional method/action fallback on every form. Classify it as settings,
|
||||
entity, immediate action, destructive action, filter/search, or upload/import.
|
||||
|
||||
- Add `data-lumi-settings-form` only to genuinely related persistent settings.
|
||||
- Add `data-lumi-enhanced-form` to an entity/settings form when its handler can
|
||||
return a targeted result without reconstructing the route.
|
||||
- Do not put action, destructive, filter, or unrelated forms into the shared
|
||||
settings save bar.
|
||||
|
||||
Enhanced handlers return:
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"message": "Saved.",
|
||||
"fieldErrors": {},
|
||||
"data": {},
|
||||
"patch": {},
|
||||
"redirect": null,
|
||||
"reloadRequired": false
|
||||
}
|
||||
```
|
||||
|
||||
Return HTTP 400/422 with `ok: false` and field-name messages for validation.
|
||||
The browser keeps failed input and links errors through `aria-describedby`.
|
||||
Use `reloadRequired` only for an explicit restart/runtime replacement, never to
|
||||
communicate routine success.
|
||||
|
||||
## Lifecycle and dialogs
|
||||
|
||||
Any page opting into soft navigation registers a lifecycle:
|
||||
|
||||
```js
|
||||
window.LumiPage.register("plugin-example", {
|
||||
mount(root, { signal }) {
|
||||
window.addEventListener("resize", updatePreview, { signal });
|
||||
const timer = window.setInterval(refreshStatus, 5000);
|
||||
return () => window.clearInterval(timer);
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
Use the supplied abort signal for global listeners and fetches; dispose timers,
|
||||
observers, sockets, and inserted UI in the returned teardown. Do not re-execute
|
||||
inline IIFEs after content replacement.
|
||||
|
||||
Open modal surfaces through `LumiDialog` or a compatible
|
||||
`.modal-backdrop[aria-hidden]` state. Give the dialog a programmatic title and
|
||||
optional description. Do not implement a separate focus trap, background
|
||||
isolation, scroll lock, or opener restoration.
|
||||
|
||||
## Accessibility and responsive behavior
|
||||
|
||||
Every control has a stable ID and associated label. Link helper/error text with
|
||||
`aria-describedby`; use `aria-invalid` only while invalid. Icon actions include
|
||||
the object in their accessible name. Tables have semantic headings/captions and
|
||||
use the shared wrapper; choose horizontal scrolling for dense comparison data
|
||||
or labelled list rows for small action tables.
|
||||
|
||||
The complete workflow must reflow at 320 px and remain usable at 200% zoom,
|
||||
keyboard-only, touch/coarse pointer, reduced motion, High Contrast, and custom
|
||||
theme values. Dragging and hover are optional accelerators, never the only path.
|
||||
|
||||
Implement loading, empty, partial, error, success, and offline/degraded states
|
||||
where data or providers are involved. Empty and no-results are different.
|
||||
Errors name what failed, what is safe, and a real recovery action.
|
||||
|
||||
## Styling rules
|
||||
|
||||
Plugin CSS belongs in the `features` cascade layer and composes semantic tokens.
|
||||
Do not redefine `.button`, `.card`, `.field`, `.table`, `.modal`, or other shared
|
||||
primitives. Hard-coded colors are reserved for meaningful external brand/media
|
||||
values and still need contrast. Do not use remote fonts, shrink operational text
|
||||
below `0.875rem`, calculate the main layout height from the viewport in
|
||||
JavaScript, or create a nested main vertical scroller.
|
||||
|
||||
## Verification
|
||||
|
||||
Add EJS compile coverage and a representative Playwright/axe route state.
|
||||
Exercise empty, populated, error/unconfigured, permission, and degraded states;
|
||||
test keyboard focus, narrow reflow, theme values, no-reload mutation, and repeat
|
||||
mount/unmount behavior. Run `npm run verify:ux`, the plugin's focused verifier,
|
||||
and `npm run test:ui` before handoff.
|
||||
@ -52,6 +52,10 @@ Admins can:
|
||||
- clear a stale marker after verifying startup,
|
||||
- retry normal startup.
|
||||
|
||||
The normal Updates page submits these actions through the shared timed
|
||||
confirmation and request contracts. When an action restarts Lumi, the current
|
||||
document remains open and reconnects before refreshing recovery status in place.
|
||||
|
||||
Manual major-version rollback remains blocked unless the snapshot is explicitly
|
||||
marked rollback safe. The automatic rollback attempted for the same failed
|
||||
update is allowed because it restores the immediately preceding state.
|
||||
|
||||
@ -62,6 +62,22 @@ and optional core/plugin boundaries.
|
||||
Stable checks read `main` by default. The newest `experimental-*` branch is used
|
||||
only when explicitly selected on the updates page.
|
||||
|
||||
## Production Branch Switching
|
||||
|
||||
**Admin > Updates > Running code branch** lists the exact remote `main` and
|
||||
`experimental-*` branches with their commits. Deploying one uses the same
|
||||
managed repository, snapshot, protected-data, verification, recovery-marker,
|
||||
and restart path as core updates even when the branch has the same semantic
|
||||
version as the running code. Bundled plugin code is switched with core code;
|
||||
plugin data and local-only plugins remain preserved.
|
||||
|
||||
Returning to `main` through this control preserves the live database and is the
|
||||
normal rollback path for an experiment. Emergency snapshot restore remains
|
||||
available, but it restores the database captured before deployment and can
|
||||
therefore discard later database writes. See
|
||||
[Safe production branch deployments](branch-deployments.md) for the operating
|
||||
procedure and migration-safety requirements.
|
||||
|
||||
## Snapshots And Revert
|
||||
|
||||
Before a core, plugin, repo, or ZIP update, Lumi creates a snapshot under
|
||||
@ -162,9 +178,11 @@ Admin update actions publish Server-Sent Events through
|
||||
`update:download`, `update:apply`, `update:verify`, `update:complete`,
|
||||
`update:failed`, and `update:revert`.
|
||||
|
||||
Core update success returns a five-second in-page notice before refresh/restart.
|
||||
Plugin update success updates progress for the affected plugin action without a
|
||||
whole-page refresh, then restarts Lumi so the selected plugin code is loaded.
|
||||
Core and plugin update success returns a five-second in-page notice before the
|
||||
service restarts. The current document remains available, reports reconnect
|
||||
attempts, and refreshes the Updates workspace in place once Lumi responds again.
|
||||
Repository checks and cleanup-policy saves also reconcile without document
|
||||
navigation.
|
||||
Submit buttons inherit the endpoint and method from their parent form unless
|
||||
they explicitly declare `formaction` or `formmethod`; this prevents browsers'
|
||||
current-page fallback values from redirecting an update away from its apply
|
||||
|
||||
@ -14,7 +14,7 @@ editable: false
|
||||
Lumi is the core web UI and bot runtime.
|
||||
## Runtime
|
||||
Package: lumi-bot
|
||||
Version: 0.2.24
|
||||
Version: 0.2.25
|
||||
## Routes
|
||||
- POST /api/diagnostics/v1/run
|
||||
- GET /api/events
|
||||
@ -183,6 +183,7 @@ Version: 0.2.24
|
||||
- POST /admin/overlays
|
||||
- POST /admin/overlays/reorder
|
||||
- GET /admin/overlays/:id
|
||||
- GET /admin/overlays/:id/preview-window
|
||||
- POST /admin/overlays/:id
|
||||
- POST /admin/overlays/:id/duplicate
|
||||
- POST /admin/overlays/:id/delete
|
||||
@ -740,7 +741,7 @@ Version: 0.2.24
|
||||
- Purpose: Processes the admin navigation reset action and stores or applies submitted form data.
|
||||
- Inputs: No request parameters detected by static analysis.
|
||||
- Response format: HTTP redirect after handling the request
|
||||
- Access: admin access expected
|
||||
- Access: admin access expected; logged-in session required or used
|
||||
- Side effects: writes or mutates server-side state
|
||||
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. Most non-API POST routes are browser form submissions and usually redirect after completion.
|
||||
|
||||
@ -1135,16 +1136,16 @@ Version: 0.2.24
|
||||
|
||||
- Purpose: Creates, updates, previews, toggles, or deletes custom commands.
|
||||
- Inputs: body: `code`, `conditional_fuzzy`, `description`, `language`, `mode`, `response`, `trigger`
|
||||
- Response format: HTTP redirect after handling the request
|
||||
- Response format: Form/action response; exact format was not detected statically.
|
||||
- Access: admin access expected; logged-in session required or used
|
||||
- Side effects: writes or mutates server-side state; writes database state when the called service mutates data
|
||||
- Side effects: writes database state when the called service mutates data
|
||||
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. Input length or numeric bounds are enforced by helper functions in the handler. Errors are caught and usually returned as a flash message, JSON error, or error page. Most non-API POST routes are browser form submissions and usually redirect after completion.
|
||||
|
||||
### POST /admin/commands/:id/toggle
|
||||
|
||||
- Purpose: Creates, updates, previews, toggles, or deletes custom commands.
|
||||
- Inputs: path params: `id`
|
||||
- Response format: HTTP redirect after handling the request
|
||||
- Response format: Form/action response; exact format was not detected statically.
|
||||
- Access: admin access expected
|
||||
- Side effects: writes database state when the called service mutates data
|
||||
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. Most non-API POST routes are browser form submissions and usually redirect after completion.
|
||||
@ -1153,27 +1154,27 @@ Version: 0.2.24
|
||||
|
||||
- Purpose: Creates, updates, previews, toggles, or deletes custom commands.
|
||||
- Inputs: path params: `id`
|
||||
- Response format: HTTP redirect after handling the request
|
||||
- Response format: Form/action response; exact format was not detected statically.
|
||||
- Access: admin access expected
|
||||
- Side effects: writes or mutates server-side state; writes database state when the called service mutates data
|
||||
- Side effects: writes database state when the called service mutates data
|
||||
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. Most non-API POST routes are browser form submissions and usually redirect after completion.
|
||||
|
||||
### POST /admin/commands/:id/update
|
||||
|
||||
- Purpose: Creates, updates, previews, toggles, or deletes custom commands.
|
||||
- Inputs: path params: `id`; body: `code`, `conditional_fuzzy`, `description`, `language`, `mode`, `response`, `trigger`
|
||||
- Response format: HTTP redirect after handling the request
|
||||
- Response format: Form/action response; exact format was not detected statically.
|
||||
- Access: admin access expected; logged-in session required or used
|
||||
- Side effects: writes or mutates server-side state; writes database state when the called service mutates data
|
||||
- Side effects: writes database state when the called service mutates data
|
||||
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. Input length or numeric bounds are enforced by helper functions in the handler. Errors are caught and usually returned as a flash message, JSON error, or error page. Most non-API POST routes are browser form submissions and usually redirect after completion.
|
||||
|
||||
### POST /admin/commands/:id/preview
|
||||
|
||||
- Purpose: Creates, updates, previews, toggles, or deletes custom commands.
|
||||
- Inputs: path params: `id`
|
||||
- Response format: HTTP redirect after handling the request
|
||||
- Response format: Form/action response; exact format was not detected statically.
|
||||
- Access: admin access expected
|
||||
- Side effects: writes or mutates server-side state; writes database state when the called service mutates data
|
||||
- Side effects: writes database state when the called service mutates data
|
||||
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. Most non-API POST routes are browser form submissions and usually redirect after completion.
|
||||
|
||||
### GET /admin/pages
|
||||
@ -1414,9 +1415,9 @@ Version: 0.2.24
|
||||
|
||||
- Purpose: Checks, applies, reverts, or reports update state for core or plugin updates.
|
||||
- Inputs: file upload: multipart form file data
|
||||
- Response format: HTTP redirect after handling the request
|
||||
- Response format: Form/action response; exact format was not detected statically.
|
||||
- Access: admin access expected
|
||||
- Side effects: writes or mutates server-side state; writes files; may restart or stop runtime processes
|
||||
- Side effects: writes or mutates server-side state; writes files
|
||||
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. Errors are caught and usually returned as a flash message, JSON error, or error page. Most non-API POST routes are browser form submissions and usually redirect after completion.
|
||||
|
||||
### POST /admin/updates/plugins/:id/check
|
||||
@ -1716,6 +1717,15 @@ Version: 0.2.24
|
||||
- Side effects: Usually read-only.
|
||||
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations.
|
||||
|
||||
### GET /admin/overlays/:id/preview-window
|
||||
|
||||
- Purpose: Renders the admin overlays id preview window WebUI page.
|
||||
- Inputs: path params: `id`
|
||||
- Response format: HTML page rendered from an EJS view
|
||||
- Access: admin access expected
|
||||
- Side effects: Usually read-only.
|
||||
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations.
|
||||
|
||||
### POST /admin/overlays/:id
|
||||
|
||||
- Purpose: Processes the admin overlays id action and stores or applies submitted form data.
|
||||
@ -1934,9 +1944,9 @@ Version: 0.2.24
|
||||
|
||||
### POST /admin/overlays/:id/obs
|
||||
|
||||
- Purpose: Processes the admin overlays id obs action and stores or applies submitted form data.
|
||||
- Purpose: Provides admin overlays id obs data as JSON.
|
||||
- Inputs: path params: `id`; body: `endpoint`, `password`, `provider`, `sync_direction`
|
||||
- Response format: HTTP redirect after handling the request
|
||||
- Response format: JSON response; HTTP redirect after handling the request
|
||||
- Access: admin access expected
|
||||
- Side effects: writes or mutates server-side state
|
||||
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. Errors are caught and usually returned as a flash message, JSON error, or error page. Most non-API POST routes are browser form submissions and usually redirect after completion.
|
||||
|
||||
@ -99,7 +99,7 @@ Default state: enabled
|
||||
|
||||
- Purpose: Processes the okf plugin administration action for entries slug.
|
||||
- Inputs: path params: `slug`; body: full submitted body is passed to a helper; exact fields are defined by the matching form/service
|
||||
- Response format: HTTP redirect after handling the request
|
||||
- Response format: JSON response; HTTP redirect after handling the request
|
||||
- Access: OKF editor or manager permission required; admin access expected; logged-in session required or used
|
||||
- Side effects: writes or mutates server-side state
|
||||
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. Errors are caught and usually returned as a flash message, JSON error, or error page. Most non-API POST routes are browser form submissions and usually redirect after completion.
|
||||
|
||||
105
package-lock.json
generated
105
package-lock.json
generated
@ -1,14 +1,14 @@
|
||||
{
|
||||
"name": "lumi-bot",
|
||||
"version": "0.2.24",
|
||||
"version": "0.2.25",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "lumi-bot",
|
||||
"version": "0.2.24",
|
||||
"version": "0.2.25",
|
||||
"dependencies": {
|
||||
"adm-zip": "^0.5.12",
|
||||
"adm-zip": "^0.6.0",
|
||||
"better-sqlite3": "^11.5.0",
|
||||
"better-sqlite3-session-store": "^0.1.0",
|
||||
"discord.js": "^13.17.1",
|
||||
@ -19,6 +19,10 @@
|
||||
"tmi.js": "^1.8.5",
|
||||
"ws": "^8.21.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@axe-core/playwright": "^4.12.1",
|
||||
"@playwright/test": "^1.61.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
@ -26,6 +30,19 @@
|
||||
"obs-websocket-js": "^5.0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/@axe-core/playwright": {
|
||||
"version": "4.12.1",
|
||||
"resolved": "https://registry.npmjs.org/@axe-core/playwright/-/playwright-4.12.1.tgz",
|
||||
"integrity": "sha512-rMd7xriptqKpP+w5265i4Hdkv2X5kbu6uiBi/B2I7uf3hieRBM3qDCfaKPtxfiYb2mKXfF+yLODJwIx+Jv1GDw==",
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"dependencies": {
|
||||
"axe-core": "~4.12.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"playwright-core": ">= 1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@discordjs/builders": {
|
||||
"version": "0.16.0",
|
||||
"resolved": "https://registry.npmjs.org/@discordjs/builders/-/builders-0.16.0.tgz",
|
||||
@ -69,6 +86,22 @@
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@playwright/test": {
|
||||
"version": "1.61.1",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.1.tgz",
|
||||
"integrity": "sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright": "1.61.1"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@sapphire/async-queue": {
|
||||
"version": "1.5.5",
|
||||
"resolved": "https://registry.npmjs.org/@sapphire/async-queue/-/async-queue-1.5.5.tgz",
|
||||
@ -134,12 +167,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/adm-zip": {
|
||||
"version": "0.5.16",
|
||||
"resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.5.16.tgz",
|
||||
"integrity": "sha512-TGw5yVi4saajsSEgz25grObGHEUaDrniwvA2qwSC060KfqGPdglhvPMA2lPIoxs3PQIItj2iag35fONcQqgUaQ==",
|
||||
"version": "0.6.0",
|
||||
"resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.6.0.tgz",
|
||||
"integrity": "sha512-XleryMhbuksdKtofnWZ9Sk+4CUTbms4Mb/EU32SZwToAyZ5RgVos/ki8n+yr0LWHOGKuakbXTuuYNHLQjhddgg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12.0"
|
||||
"node": ">=14.0"
|
||||
}
|
||||
},
|
||||
"node_modules/append-field": {
|
||||
@ -166,6 +199,16 @@
|
||||
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/axe-core": {
|
||||
"version": "4.12.1",
|
||||
"resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.12.1.tgz",
|
||||
"integrity": "sha512-s7iGf5GaVMxEG0ENN9x+xTr7GFZCb1ZP/1uATUpCEK2X78nDB3RwbtFCo9pGAf9ru+VwoQ464DkaLEeRM08wJA==",
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/balanced-match": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
||||
@ -804,6 +847,21 @@
|
||||
"integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/function-bind": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
||||
@ -1300,6 +1358,39 @@
|
||||
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.61.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz",
|
||||
"integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.61.1"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.61.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz",
|
||||
"integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/prebuild-install": {
|
||||
"version": "7.1.3",
|
||||
"resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
|
||||
|
||||
11
package.json
11
package.json
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "lumi-bot",
|
||||
"version": "0.2.24",
|
||||
"version": "0.2.25",
|
||||
"private": true,
|
||||
"type": "commonjs",
|
||||
"scripts": {
|
||||
@ -16,6 +16,9 @@
|
||||
"verify:web-auth": "node scripts/verify-web-auth.js",
|
||||
"verify:destructive-actions": "node scripts/verify-destructive-actions.js",
|
||||
"verify:overlays": "node scripts/verify-overlays.js",
|
||||
"verify:ux": "node scripts/verify-ux-foundation.js",
|
||||
"test:ui": "playwright test",
|
||||
"test:ui:update": "playwright test --update-snapshots",
|
||||
"verify:webui": "node scripts/verify-webui.js && node scripts/verify-destructive-actions.js",
|
||||
"benchmark:okf": "node scripts/benchmark-okf-search.js",
|
||||
"verify:content": "node scripts/verify-content-library.js"
|
||||
@ -24,7 +27,7 @@
|
||||
"node": ">=18"
|
||||
},
|
||||
"dependencies": {
|
||||
"adm-zip": "^0.5.12",
|
||||
"adm-zip": "^0.6.0",
|
||||
"better-sqlite3": "^11.5.0",
|
||||
"better-sqlite3-session-store": "^0.1.0",
|
||||
"discord.js": "^13.17.1",
|
||||
@ -37,5 +40,9 @@
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"obs-websocket-js": "^5.0.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@axe-core/playwright": "^4.12.1",
|
||||
"@playwright/test": "^1.61.1"
|
||||
}
|
||||
}
|
||||
|
||||
40
playwright.config.js
Normal file
40
playwright.config.js
Normal file
@ -0,0 +1,40 @@
|
||||
const { defineConfig } = require("@playwright/test");
|
||||
|
||||
const port = Number(process.env.LUMI_UI_TEST_PORT || 3317);
|
||||
|
||||
module.exports = defineConfig({
|
||||
testDir: "./tests/ui",
|
||||
outputDir: "./test-results/ui",
|
||||
timeout: 45_000,
|
||||
expect: { timeout: 8_000 },
|
||||
fullyParallel: false,
|
||||
workers: 1,
|
||||
retries: process.env.CI ? 1 : 0,
|
||||
reporter: [["list"], ["html", { outputFolder: "test-results/ui-report", open: "never" }]],
|
||||
use: {
|
||||
baseURL: `http://127.0.0.1:${port}`,
|
||||
trace: "retain-on-failure",
|
||||
screenshot: "only-on-failure",
|
||||
video: "retain-on-failure",
|
||||
reducedMotion: "reduce"
|
||||
},
|
||||
webServer: {
|
||||
command: "node tests/ui/fixture-server.js",
|
||||
url: `http://127.0.0.1:${port}/health`,
|
||||
timeout: 60_000,
|
||||
reuseExistingServer: false,
|
||||
env: {
|
||||
...process.env,
|
||||
PORT: String(port),
|
||||
LUMI_UI_TEST_PORT: String(port),
|
||||
NODE_ENV: "test"
|
||||
}
|
||||
},
|
||||
projects: [
|
||||
{ name: "phone-320", use: { browserName: "chromium", viewport: { width: 320, height: 720 }, isMobile: true, hasTouch: true } },
|
||||
{ name: "phone-390", use: { browserName: "chromium", viewport: { width: 390, height: 844 }, isMobile: true, hasTouch: true } },
|
||||
{ name: "tablet-768", use: { browserName: "chromium", viewport: { width: 768, height: 1024 }, hasTouch: true } },
|
||||
{ name: "desktop-1280", use: { browserName: "chromium", viewport: { width: 1280, height: 900 } } },
|
||||
{ name: "desktop-1920", use: { browserName: "chromium", viewport: { width: 1920, height: 1080 } } }
|
||||
]
|
||||
});
|
||||
@ -1760,6 +1760,18 @@ function saveAiFeedbackOkfCorrection(review, correction, values, actor) {
|
||||
}, actor);
|
||||
}
|
||||
function flash(req, res, type, message) {
|
||||
if (req.get("X-Lumi-Enhanced") === "1" || req.get("X-Requested-With") === "XMLHttpRequest") {
|
||||
const ok = type !== "error";
|
||||
return res.status(ok ? 200 : 422).json({
|
||||
ok,
|
||||
message,
|
||||
fieldErrors: {},
|
||||
data: {},
|
||||
patch: {},
|
||||
redirect: null,
|
||||
reloadRequired: false
|
||||
});
|
||||
}
|
||||
req.session.flash = { type, message };
|
||||
return res.redirect(`/plugins/${PLUGIN_ID}`);
|
||||
}
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
@layer features {
|
||||
.lumi-ai-shell { width: 100%; min-width: 0; }
|
||||
.lumi-ai-overlay-root { position: fixed; inset: 0; z-index: 60; pointer-events: none; isolation: isolate; }
|
||||
.lumi-ai-pill { width: 100%; min-height: 42px; display: flex; align-items: center; gap: 9px; padding: 8px 10px; border: 1px solid var(--border); border-radius: 7px; background: var(--surface-2); color: var(--ink); cursor: pointer; }
|
||||
@ -76,3 +77,14 @@
|
||||
body.sidebar-collapsed .lumi-ai-pill { justify-content: center; padding: 8px; }
|
||||
body.sidebar-collapsed .lumi-ai-pill-label,
|
||||
body.sidebar-collapsed .lumi-ai-state { display: none; }
|
||||
|
||||
.lumi-ai-header span,
|
||||
.lumi-ai-clear,
|
||||
.lumi-ai-code-header,
|
||||
.lumi-ai-code-copy,
|
||||
.lumi-ai-timeout-details,
|
||||
.lumi-ai-feedback,
|
||||
.lumi-ai-cooldown,
|
||||
.lumi-ai-disclaimer,
|
||||
.lumi-ai-disclaimer-detail { font-size: var(--lumi-font-size-sm); }
|
||||
}
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
@layer features {
|
||||
.improvement-titlebar { align-items: center; }
|
||||
.improvement-live-status { position: sticky; top: 10px; z-index: 15; margin: 0 0 12px; padding: 10px 13px; border: 1px solid var(--border); border-radius: 8px; background: var(--card); box-shadow: 0 8px 24px rgba(0,0,0,.18); }
|
||||
.improvement-live-status[hidden] { display: none; }
|
||||
@ -64,3 +65,7 @@
|
||||
.improvement-dialog { width: calc(100vw - 16px); max-height: calc(100vh - 16px); padding: 14px; }
|
||||
.improvement-decision-row > *, .improvement-decision-row form, .improvement-decision-row .button { width: 100%; }
|
||||
}
|
||||
|
||||
.improvement-card > header,
|
||||
.improvement-pair span { font-size: var(--lumi-font-size-sm); }
|
||||
}
|
||||
|
||||
@ -168,18 +168,36 @@
|
||||
|
||||
function connectLiveUpdates() {
|
||||
if (!window.EventSource || stream) return;
|
||||
stream = new EventSource("/api/events");
|
||||
stream.addEventListener("ai:improvement_changed", (event) => {
|
||||
const handleChange = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data || "{}");
|
||||
scheduleRefresh(data.message || "The feedback queue was updated by another reviewer.");
|
||||
} catch {
|
||||
scheduleRefresh("The feedback queue was updated by another reviewer.");
|
||||
}
|
||||
});
|
||||
};
|
||||
if (window.LumiEvents?.subscribe) {
|
||||
const unsubscribe = window.LumiEvents.subscribe("ai:improvement_changed", handleChange);
|
||||
const handleStatus = (event) => {
|
||||
if (event.detail?.status === "disconnected") {
|
||||
showStatus("Live updates are reconnecting. Your saved actions are still safe.", "warning");
|
||||
}
|
||||
};
|
||||
window.addEventListener("lumi:event-status", handleStatus);
|
||||
stream = {
|
||||
close() {
|
||||
unsubscribe();
|
||||
window.removeEventListener("lumi:event-status", handleStatus);
|
||||
}
|
||||
};
|
||||
return;
|
||||
}
|
||||
stream = new EventSource("/api/events");
|
||||
stream.addEventListener("ai:improvement_changed", handleChange);
|
||||
stream.onerror = () => {
|
||||
showStatus("Live updates are reconnecting. Your saved actions are still safe.", "warning");
|
||||
};
|
||||
window.addEventListener("pagehide", () => stream?.close(), { once: true });
|
||||
}
|
||||
|
||||
connectLiveUpdates();
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
@layer features {
|
||||
.ai-titlebar, .ai-section-heading { display: flex; align-items: center; justify-content: space-between; gap: 20px; }
|
||||
.ai-titlebar { justify-content: flex-start; flex-wrap: wrap; margin-bottom: 0; padding: clamp(16px, 2vw, 24px); border: 1px solid var(--border); border-radius: var(--lumi-radius-md); background: var(--card); box-shadow: var(--lumi-shadow-sm); }
|
||||
.ai-titlebar > div:first-child { min-width: min(100%, 280px); margin-right: auto; }
|
||||
@ -166,3 +167,15 @@
|
||||
.ai-inline-form { flex-wrap: wrap; }
|
||||
.ai-diagnostic { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
.ai-stat-grid span,
|
||||
.ai-model-main span,
|
||||
.ai-tag,
|
||||
.ai-gpu-scale,
|
||||
.ai-tools-source,
|
||||
.ai-tool-diagnostic-row,
|
||||
.ai-tool-version,
|
||||
.ai-tool-scope,
|
||||
.ai-tool-details strong,
|
||||
.ai-tool-details span { font-size: var(--lumi-font-size-sm); }
|
||||
}
|
||||
|
||||
@ -1,13 +1,32 @@
|
||||
(() => {
|
||||
const actions = document.querySelector("[data-ai-runtime-actions]");
|
||||
const state = document.querySelector("[data-runtime-state]");
|
||||
const downloadStatus = document.querySelector("[data-download-status]");
|
||||
const testForm = document.querySelector("[data-ai-test-form]");
|
||||
const testOutput = document.querySelector("[data-ai-test-output]");
|
||||
const testToolsNotice = document.querySelector("[data-ai-test-tools-notice]");
|
||||
const gpuControl = document.querySelector("[data-gpu-control]");
|
||||
const accessForm = document.querySelector("[data-ai-access-form]");
|
||||
const runtimePrimary = document.querySelector("[data-runtime-primary]");
|
||||
const mount = (root, { signal } = {}) => {
|
||||
const listenerOptions = signal ? { signal } : undefined;
|
||||
const timeouts = new Set();
|
||||
const intervals = new Set();
|
||||
const scheduleTimeout = (callback, delay) => {
|
||||
const timer = window.setTimeout(() => {
|
||||
timeouts.delete(timer);
|
||||
if (!signal?.aborted) callback();
|
||||
}, delay);
|
||||
timeouts.add(timer);
|
||||
return timer;
|
||||
};
|
||||
const scheduleInterval = (callback, delay) => {
|
||||
const timer = window.setInterval(() => {
|
||||
if (!signal?.aborted) callback();
|
||||
}, delay);
|
||||
intervals.add(timer);
|
||||
return timer;
|
||||
};
|
||||
const actions = root.querySelector("[data-ai-runtime-actions]");
|
||||
const state = root.querySelector("[data-runtime-state]");
|
||||
const downloadStatus = root.querySelector("[data-download-status]");
|
||||
const testForm = root.querySelector("[data-ai-test-form]");
|
||||
const testOutput = root.querySelector("[data-ai-test-output]");
|
||||
const testToolsNotice = root.querySelector("[data-ai-test-tools-notice]");
|
||||
const gpuControl = root.querySelector("[data-gpu-control]");
|
||||
const accessForm = root.querySelector("[data-ai-access-form]");
|
||||
const runtimePrimary = root.querySelector("[data-runtime-primary]");
|
||||
if (actions) {
|
||||
const syncPrimary = (nextState) => {
|
||||
if (!runtimePrimary || !window.LumiStateButton) return;
|
||||
@ -42,9 +61,9 @@
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
}
|
||||
});
|
||||
}, listenerOptions);
|
||||
}
|
||||
document.querySelectorAll("[data-ai-download-form]").forEach((form) => {
|
||||
root.querySelectorAll("[data-ai-download-form]").forEach((form) => {
|
||||
form.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
const button = form.querySelector("[data-ai-download-button]");
|
||||
@ -65,7 +84,7 @@
|
||||
downloadStatus.textContent = error.message;
|
||||
}
|
||||
}
|
||||
});
|
||||
}, listenerOptions);
|
||||
});
|
||||
const pollDownloads = async () => {
|
||||
if (!downloadStatus) return;
|
||||
@ -97,7 +116,7 @@
|
||||
downloadStatus.hidden = false;
|
||||
downloadStatus.textContent = jobs.map(downloadStateLabel).join(" | ");
|
||||
jobs.forEach((job) => {
|
||||
const button = document.querySelector(`[data-ai-download-button][data-download-id="${CSS.escape(job.id)}"]`);
|
||||
const button = root.querySelector(`[data-ai-download-button][data-download-id="${CSS.escape(job.id)}"]`);
|
||||
if (!button || !window.LumiStateButton) return;
|
||||
if (job.state === "complete") window.LumiStateButton.setState(button, "success");
|
||||
else if (job.state === "error") window.LumiStateButton.setState(button, "error");
|
||||
@ -110,10 +129,9 @@
|
||||
: (job.state === "preparing_install" ? "Stopping runtimes" : job.state === "installing" ? "Installing" : "Working");
|
||||
}
|
||||
});
|
||||
if (active.length) window.setTimeout(pollDownloads, 1000);
|
||||
if (active.length) scheduleTimeout(pollDownloads, 1000);
|
||||
} catch {}
|
||||
};
|
||||
pollDownloads();
|
||||
if (testForm && testOutput) {
|
||||
const updateTestToolsNotice = () => {
|
||||
if (!testToolsNotice) return;
|
||||
@ -122,7 +140,7 @@
|
||||
? "Tools are enabled. This test uses normal discovery, prompt exposure, permission checks, and execution."
|
||||
: "Tools are disabled for this test; this result will not exercise tool discovery or execution.";
|
||||
};
|
||||
testForm.elements.allow_tools?.addEventListener("change", updateTestToolsNotice);
|
||||
testForm.elements.allow_tools?.addEventListener("change", updateTestToolsNotice, listenerOptions);
|
||||
updateTestToolsNotice();
|
||||
testForm.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
@ -149,11 +167,11 @@
|
||||
} catch (error) {
|
||||
testOutput.textContent = error.message;
|
||||
}
|
||||
});
|
||||
}, listenerOptions);
|
||||
}
|
||||
if (gpuControl) {
|
||||
const model = document.querySelector("[data-gpu-model]");
|
||||
const context = document.querySelector("[data-gpu-context]");
|
||||
const model = root.querySelector("[data-gpu-model]");
|
||||
const context = root.querySelector("[data-gpu-context]");
|
||||
const workload = gpuControl.querySelector("[data-gpu-workload]");
|
||||
const slider = gpuControl.querySelector("[data-gpu-slider]");
|
||||
const value = gpuControl.querySelector("[data-gpu-value]");
|
||||
@ -218,7 +236,7 @@
|
||||
};
|
||||
const scheduleCapacity = () => {
|
||||
window.clearTimeout(capacityTimer);
|
||||
capacityTimer = window.setTimeout(refreshCapacity, 250);
|
||||
capacityTimer = scheduleTimeout(refreshCapacity, 250);
|
||||
};
|
||||
workload.addEventListener("input", () => {
|
||||
const selected = clampNewIntent();
|
||||
@ -228,11 +246,11 @@
|
||||
memory.textContent = formatBytes(
|
||||
(Number(memory.dataset.fullOffloadMb) || 0) * actual / 100
|
||||
);
|
||||
});
|
||||
workload.addEventListener("change", refreshCapacity);
|
||||
model.addEventListener("change", refreshCapacity);
|
||||
context.addEventListener("input", scheduleCapacity);
|
||||
context.addEventListener("change", refreshCapacity);
|
||||
}, listenerOptions);
|
||||
workload.addEventListener("change", refreshCapacity, listenerOptions);
|
||||
model.addEventListener("change", refreshCapacity, listenerOptions);
|
||||
context.addEventListener("input", scheduleCapacity, listenerOptions);
|
||||
context.addEventListener("change", refreshCapacity, listenerOptions);
|
||||
refreshCapacity();
|
||||
}
|
||||
if (accessForm) {
|
||||
@ -246,10 +264,10 @@
|
||||
input.required = visible;
|
||||
if (!visible) input.value = "";
|
||||
};
|
||||
action.addEventListener("change", updateTimeoutVisibility);
|
||||
action.addEventListener("change", updateTimeoutVisibility, listenerOptions);
|
||||
updateTimeoutVisibility();
|
||||
}
|
||||
document.querySelectorAll("[data-work-retention]").forEach((section) => {
|
||||
root.querySelectorAll("[data-work-retention]").forEach((section) => {
|
||||
const mode = section.querySelector("[data-work-retention-mode]");
|
||||
const count = section.querySelector("[data-work-retention-count]");
|
||||
const age = section.querySelector("[data-work-retention-age]");
|
||||
@ -258,12 +276,13 @@
|
||||
if (count) count.hidden = ageMode;
|
||||
if (age) age.hidden = !ageMode;
|
||||
};
|
||||
mode?.addEventListener("change", sync);
|
||||
mode?.addEventListener("change", sync, listenerOptions);
|
||||
sync();
|
||||
});
|
||||
document.querySelectorAll("[data-ai-work-row]").forEach((row) => {
|
||||
root.querySelectorAll("[data-ai-work-row]").forEach((row) => {
|
||||
const button = row.querySelector(".ai-work-expand-button");
|
||||
const detail = document.getElementById(row.dataset.aiWorkDetail || "");
|
||||
const detailId = row.dataset.aiWorkDetail || "";
|
||||
const detail = detailId ? root.querySelector(`#${CSS.escape(detailId)}`) : null;
|
||||
if (!button || !detail) return;
|
||||
const toggle = () => {
|
||||
const open = detail.hidden;
|
||||
@ -274,9 +293,9 @@
|
||||
row.addEventListener("click", (event) => {
|
||||
if (event.target.closest("a, input, select, textarea") && !event.target.closest(".ai-work-expand-button")) return;
|
||||
toggle();
|
||||
});
|
||||
}, listenerOptions);
|
||||
});
|
||||
const assistantDiagnostics = document.querySelector("[data-assistant-diagnostics]");
|
||||
const assistantDiagnostics = root.querySelector("[data-assistant-diagnostics]");
|
||||
if (assistantDiagnostics) {
|
||||
const status = assistantDiagnostics.querySelector("[data-assistant-status]");
|
||||
const reason = assistantDiagnostics.querySelector("[data-assistant-reason]");
|
||||
@ -307,11 +326,11 @@
|
||||
reason.textContent = error.message;
|
||||
});
|
||||
refreshDiagnostics();
|
||||
window.setInterval(refreshDiagnostics, 5000);
|
||||
scheduleInterval(refreshDiagnostics, 5000);
|
||||
}
|
||||
const logContent = document.querySelector("[data-log-content]");
|
||||
const logFilter = document.querySelector("[data-log-filter]");
|
||||
const logCopy = document.querySelector("[data-log-copy]");
|
||||
const logContent = root.querySelector("[data-log-content]");
|
||||
const logFilter = root.querySelector("[data-log-filter]");
|
||||
const logCopy = root.querySelector("[data-log-copy]");
|
||||
if (logContent && logFilter) {
|
||||
const originalLines = logContent.textContent.split(/\r?\n/);
|
||||
logFilter.addEventListener("input", () => {
|
||||
@ -319,13 +338,27 @@
|
||||
logContent.textContent = term
|
||||
? originalLines.filter((line) => line.toLowerCase().includes(term)).join("\n")
|
||||
: originalLines.join("\n");
|
||||
});
|
||||
}, listenerOptions);
|
||||
logCopy?.addEventListener("click", async () => {
|
||||
await navigator.clipboard.writeText(logContent.textContent);
|
||||
const original = logCopy.textContent;
|
||||
logCopy.textContent = "Copied";
|
||||
window.setTimeout(() => { logCopy.textContent = original; }, 1200);
|
||||
});
|
||||
scheduleTimeout(() => { logCopy.textContent = original; }, 1200);
|
||||
}, listenerOptions);
|
||||
}
|
||||
pollDownloads();
|
||||
return () => {
|
||||
timeouts.forEach((timer) => window.clearTimeout(timer));
|
||||
intervals.forEach((timer) => window.clearInterval(timer));
|
||||
};
|
||||
};
|
||||
|
||||
if (window.LumiPage) {
|
||||
window.LumiPage.register("lumi-ai-settings", {
|
||||
selector: '[data-lumi-page~="lumi-ai-settings"]',
|
||||
mount
|
||||
});
|
||||
} else {
|
||||
mount(document);
|
||||
}
|
||||
})();
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
<%- include("../../../src/web/views/partials/layout-top", { title }) %>
|
||||
<%- include("../../../src/web/views/partials/layout-top", { title, pageWidth: "wide", pageId: "lumi-ai-settings" }) %>
|
||||
<link rel="stylesheet" href="/plugins/lumi_ai/assets/settings.css?v=<%= assetVersion %>" />
|
||||
<%
|
||||
const renderPresetOptions = (options, current) => {
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
@layer features {
|
||||
.lumi-web-search-status { display: grid; gap: 12px; margin-bottom: 16px; }
|
||||
.lumi-web-search-status-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 8px; }
|
||||
.lumi-web-search-status-grid > div { display: grid; gap: 3px; padding: 9px; border: 1px solid var(--border); border-radius: 7px; background: var(--surface-2); }
|
||||
@ -9,3 +10,7 @@
|
||||
.lumi-web-search-test label { display: grid; gap: 4px; font-weight: 800; }
|
||||
.lumi-web-search-test-output { max-height: 280px; overflow: auto; white-space: pre-wrap; }
|
||||
@media (max-width: 760px) { .lumi-web-search-test { grid-template-columns: 1fr; } }
|
||||
|
||||
.lumi-web-search-status-grid span,
|
||||
.lumi-web-search-recent { font-size: var(--lumi-font-size-sm); }
|
||||
}
|
||||
|
||||
@ -201,9 +201,31 @@ module.exports = {
|
||||
try {
|
||||
validateOkfPlaceholderFields(req.body, req.session.user, placeholders);
|
||||
const entry = updateEntry(db, req.params.slug, req.body, req.session.user);
|
||||
if (isEnhancedRequest(req)) {
|
||||
return res.json({
|
||||
ok: true,
|
||||
message: "OKF entry updated.",
|
||||
fieldErrors: {},
|
||||
data: { entry },
|
||||
patch: {},
|
||||
redirect: null,
|
||||
reloadRequired: false
|
||||
});
|
||||
}
|
||||
req.session.flash = { type: "success", message: "OKF entry updated." };
|
||||
res.redirect(`/plugins/${PLUGIN_ID}/admin?tab=general&edit=${encodeURIComponent(entry.slug)}`);
|
||||
} catch (error) {
|
||||
if (isEnhancedRequest(req)) {
|
||||
return res.status(422).json({
|
||||
ok: false,
|
||||
message: error.message,
|
||||
fieldErrors: {},
|
||||
data: {},
|
||||
patch: {},
|
||||
redirect: null,
|
||||
reloadRequired: false
|
||||
});
|
||||
}
|
||||
req.session.flash = { type: "error", message: error.message };
|
||||
res.redirect(`/plugins/${PLUGIN_ID}/admin?tab=general&edit=${encodeURIComponent(req.params.slug)}`);
|
||||
}
|
||||
@ -329,6 +351,10 @@ module.exports = {
|
||||
}
|
||||
};
|
||||
|
||||
function isEnhancedRequest(req) {
|
||||
return req.get("X-Lumi-Enhanced") === "1" || req.get("X-Requested-With") === "XMLHttpRequest";
|
||||
}
|
||||
|
||||
function formatAiContext(entries) {
|
||||
if (!entries.length) return [];
|
||||
const prepared = preferSpecificKnowledgeChunks(entries).slice(0, 3);
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
<%- include("../../../src/web/views/partials/layout-top", { title }) %>
|
||||
<%- include("../../../src/web/views/partials/layout-top", { title, pageWidth: "wide", pageId: "okf-admin" }) %>
|
||||
<section class="card">
|
||||
<%- include("../../../src/web/views/partials/page-header", {
|
||||
eyebrow: okfAccess.canManagePermissions ? "Administration" : "Community contribution",
|
||||
@ -331,7 +331,7 @@
|
||||
<% } %>
|
||||
|
||||
<% if (selected) { %>
|
||||
<div class="modal-backdrop okf-edit-modal is-open" id="okf-editor" data-okf-edit-modal aria-hidden="false">
|
||||
<div class="modal-backdrop okf-edit-modal is-open" id="okf-editor" data-okf-edit-modal data-dialog-static="true" aria-hidden="false">
|
||||
<div class="modal okf-entry-modal-dialog okf-entry-fullscreen-modal" role="dialog" aria-modal="true" aria-labelledby="okf-edit-title">
|
||||
<div class="modal-header">
|
||||
<div>
|
||||
@ -343,10 +343,10 @@
|
||||
<button class="button subtle" type="button" data-okf-edit-close>Close</button>
|
||||
</div>
|
||||
</div>
|
||||
<form method="post" action="/plugins/okf/admin/entries/<%= selected.slug %>" class="form-grid" data-okf-edit-form data-okf-draft-key="lumi.okf.edit.<%= selected.slug %>">
|
||||
<form method="post" action="/plugins/okf/admin/entries/<%= selected.slug %>" class="form-grid" data-okf-edit-form data-okf-draft-key="lumi.okf.edit.<%= selected.slug %>" data-lumi-enhanced-form>
|
||||
<div class="field">
|
||||
<label>Title</label>
|
||||
<input name="title" required value="<%= selected ? selected.title : '' %>" />
|
||||
<label for="okf-edit-entry-title">Title</label>
|
||||
<input id="okf-edit-entry-title" data-dialog-initial-focus name="title" required value="<%= selected ? selected.title : '' %>" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Slug</label>
|
||||
@ -531,8 +531,8 @@
|
||||
</div>
|
||||
<form method="post" action="/plugins/okf/admin/entries" class="form-grid">
|
||||
<div class="field">
|
||||
<label>Title</label>
|
||||
<input name="title" required />
|
||||
<label for="okf-create-entry-title">Title</label>
|
||||
<input id="okf-create-entry-title" data-dialog-initial-focus name="title" required />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Slug</label>
|
||||
@ -838,12 +838,28 @@
|
||||
form.addEventListener("input", saveDraft);
|
||||
form.addEventListener("change", saveDraft);
|
||||
form.addEventListener("submit", clearDraft);
|
||||
form.addEventListener("lumi:form-saved", (event) => {
|
||||
const entry = event.detail?.data?.entry;
|
||||
if (!entry?.slug) return;
|
||||
clearDraft();
|
||||
form.action = `/plugins/okf/admin/entries/${encodeURIComponent(entry.slug)}`;
|
||||
form.dataset.okfDraftKey = `lumi.okf.edit.${entry.slug}`;
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set("tab", "general");
|
||||
url.searchParams.set("edit", entry.slug);
|
||||
window.history.replaceState(window.history.state, "", url);
|
||||
});
|
||||
modal.querySelectorAll("[data-okf-edit-close]").forEach((button) => {
|
||||
button.addEventListener("click", closeModal);
|
||||
});
|
||||
modal.querySelector("[data-okf-edit-reset]")?.addEventListener("click", () => {
|
||||
clearDraft();
|
||||
window.location.reload();
|
||||
form.reset();
|
||||
form.querySelectorAll("input, textarea, select").forEach((field) => {
|
||||
field.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
field.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
});
|
||||
firstInput?.focus({ preventScroll: true });
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
@layer features {
|
||||
.throne-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@ -109,11 +110,6 @@
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.button.danger {
|
||||
background: var(--rose);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.debug-modal {
|
||||
width: min(900px, calc(100vw - 32px));
|
||||
}
|
||||
@ -151,3 +147,4 @@
|
||||
min-width: 900px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -6,6 +6,7 @@ const root = path.join(__dirname, "..");
|
||||
const checks = [
|
||||
"scripts/verify-preflight.js",
|
||||
"scripts/verify-webui.js",
|
||||
"scripts/verify-ux-foundation.js",
|
||||
"scripts/verify-web-auth.js",
|
||||
"scripts/verify-feedback-system.js",
|
||||
"scripts/verify-logging.js",
|
||||
|
||||
@ -103,6 +103,20 @@ let database = null;
|
||||
assert.strictEqual(configuredStorage.max_file_bytes, 32 * 1024 ** 2);
|
||||
assert.strictEqual(configuredStorage.max_files, 7);
|
||||
assert.deepStrictEqual(library.uploadLimits(), { max_file_bytes: 32 * 1024 ** 2, max_files: 7 });
|
||||
assert.throws(
|
||||
() => library.updateStorageSettings({}),
|
||||
/Complete every storage-limit field before saving/
|
||||
);
|
||||
assert.throws(
|
||||
() => library.updateStorageSettings({
|
||||
limit_gib: "2.5",
|
||||
reserve_gib: "-1",
|
||||
max_file_mib: "32",
|
||||
max_files: "7"
|
||||
}),
|
||||
/Storage values must be positive numbers/
|
||||
);
|
||||
assert.deepStrictEqual(library.uploadLimits(), { max_file_bytes: 32 * 1024 ** 2, max_files: 7 });
|
||||
|
||||
const lockedAgain = library.setResourceAccess(created.id, "locked");
|
||||
assert.strictEqual(library.getResourceByExposedToken(publicToken), null);
|
||||
@ -130,6 +144,10 @@ let database = null;
|
||||
assert(viewSource.includes("data-resource-rename-submit") && viewSource.includes("resource-more-actions"));
|
||||
assert(viewSource.includes("Available to Lumi"));
|
||||
assert(viewSource.includes("data-resource-access-form") && viewSource.includes("data-confirm-mode=\"modal\"") && viewSource.includes("data-no-destructive-confirm"));
|
||||
assert(viewSource.includes("data-resource-settings-summary"));
|
||||
for (const field of ["limit_gib", "reserve_gib", "max_file_mib", "max_files"]) {
|
||||
assert(new RegExp(`name="${field}"[^\\n]*required`).test(viewSource));
|
||||
}
|
||||
const contentRouteSource = fs.readFileSync(path.join(root, "src", "services", "content-routes.js"), "utf8");
|
||||
assert(contentRouteSource.includes('/admin/resources/:id/revoke'));
|
||||
assert(contentRouteSource.includes('/admin/resources/:id/download/:filename'));
|
||||
@ -138,6 +156,8 @@ let database = null;
|
||||
assert(clientSource.includes("window.LumiConfirm?.destructiveToken"), "Resource actions must use the shared timed confirmation API");
|
||||
assert(clientSource.includes('headers["X-Confirmation-Token"] = token'), "Resource actions must send the issued confirmation token directly");
|
||||
assert(clientSource.includes("data-resource-upload-limit-copy") && clientSource.includes("selectedFilesError"), "Saved upload limits must be reflected and enforced in the UI");
|
||||
assert(clientSource.includes('"Content-Type": "application/x-www-form-urlencoded;charset=UTF-8"'));
|
||||
assert(clientSource.includes("const body = new URLSearchParams()"), "Resource action forms must use an encoding Express parses without upload middleware");
|
||||
|
||||
const cssSource = fs.readFileSync(path.join(root, "src", "web", "public", "content-library.css"), "utf8");
|
||||
assert(cssSource.includes(".resource-empty-state[hidden]") && cssSource.includes("display: none !important"));
|
||||
|
||||
@ -75,12 +75,14 @@ function verifySharedInfrastructure() {
|
||||
assert.equal(consumeConfirmation(req, "/admin/updates/core/revert", issued.token).valid, true);
|
||||
|
||||
const appScript = fs.readFileSync(path.join(root, "src", "web", "public", "app.js"), "utf8");
|
||||
const updateScript = fs.readFileSync(path.join(root, "src", "web", "public", "update-management.js"), "utf8");
|
||||
assert(appScript.includes("submitter?.dataset?.confirmMode"), "Submit-button confirmation metadata is ignored");
|
||||
assert(appScript.includes("submitter?.dataset?.confirmLabel || form.dataset.confirmLabel"), "The clicked submit button cannot override generic confirmation copy");
|
||||
assert(appScript.includes('form?.getAttribute?.("action")'), "Shared form actions do not resist controls named action shadowing the endpoint");
|
||||
assert(appScript.includes("window.LumiForms = Object.freeze"), "Plugins cannot reuse the shared clobber-safe form helper");
|
||||
assert(appScript.includes('form.querySelector(\'input[name="confirmation_token"]\')'), "Timed form confirmation tokens are not attached to submitted forms");
|
||||
assert(appScript.includes("const response = await fetch(action, requestOptions)"), "Async update requests do not submit through the shared confirmed form payload");
|
||||
assert(updateScript.includes("window.LumiRequest.form(form, { submitter })"), "Async update requests do not use the shared request payload");
|
||||
assert(updateScript.includes('field.name = "confirmation_token"'), "Timed update confirmation tokens are not attached to the enhanced request");
|
||||
assert(!appScript.includes("window.LumiConfirm.destructiveFetch(form.action"), "Timed update forms request a second confirmation instead of reusing the form token");
|
||||
assert(appScript.includes('state.confirmButton.removeEventListener("click", state.onConfirm)'), "Cancelled or expired confirmations leave stale modal submissions behind");
|
||||
assert(appScript.includes("state.onConfirm = onConfirm"), "Timed form confirmations do not retain their listener for cleanup");
|
||||
|
||||
@ -346,6 +346,7 @@ try {
|
||||
const editorView = fs.readFileSync(path.join(root, "src", "web", "views", "admin-overlay-detail.ejs"), "utf8");
|
||||
const editorScript = fs.readFileSync(path.join(root, "src", "web", "public", "overlay-management.js"), "utf8");
|
||||
const runtimeScript = fs.readFileSync(path.join(root, "src", "web", "public", "overlay-runtime.js"), "utf8");
|
||||
const runtimeStyles = fs.readFileSync(path.join(root, "src", "web", "public", "overlay-runtime.css"), "utf8");
|
||||
const rendererScript = fs.readFileSync(path.join(root, "src", "web", "public", "overlay-renderer.js"), "utf8");
|
||||
const chatDockScript = fs.readFileSync(path.join(root, "src", "web", "public", "overlay-chat-dock.js"), "utf8");
|
||||
const chatDockView = fs.readFileSync(path.join(root, "src", "web", "views", "overlay-chat-dock.ejs"), "utf8");
|
||||
@ -358,7 +359,11 @@ try {
|
||||
const discordServiceScript = fs.readFileSync(path.join(root, "src", "services", "discord.js"), "utf8");
|
||||
const serverScript = fs.readFileSync(path.join(root, "src", "web", "server.js"), "utf8");
|
||||
const routeScript = fs.readFileSync(path.join(root, "src", "services", "overlay-routes.js"), "utf8");
|
||||
const previewWindowView = fs.readFileSync(path.join(root, "src", "web", "views", "admin-overlay-preview.ejs"), "utf8");
|
||||
const previewWindowScript = fs.readFileSync(path.join(root, "src", "web", "public", "overlay-preview-window.js"), "utf8");
|
||||
assert(editorView.includes("data-overlay-canvas"), "management page must include a visual canvas");
|
||||
assert(runtimeStyles.includes("html.lumi-overlay-runtime-page") && runtimeStyles.includes("body.lumi-overlay-runtime-page"), "OBS viewport locking must not affect the admin editor");
|
||||
assert(renderView.includes('class="lumi-overlay-runtime-page"'), "OBS render documents must opt into viewport locking");
|
||||
assert(editorView.includes('data-module-fields="text"') && editorView.includes('data-module-fields="web"'));
|
||||
assert(editorView.includes('data-module-fields="video"') && editorView.includes('data-module-fields="audio"'));
|
||||
assert(editorView.includes('data-module-fields="chat"') && editorView.includes('name="chat_custom_css"'));
|
||||
@ -368,13 +373,18 @@ try {
|
||||
assert(editorView.includes('name="inject_page_css"') && editorView.includes("Apply CSS inside the website"));
|
||||
assert(editorView.includes('name="force_transparent_background"') && editorView.includes("Keep the website background transparent"));
|
||||
assert(editorView.includes("data-preview-background") && editorView.includes("data-source-revert"));
|
||||
assert(editorView.includes("data-preview-drawer-toggle") && editorView.includes("data-preview-popout"));
|
||||
assert(editorView.includes("obsBrowserDefaultCss") && editorView.includes("OBS Browser Source setup"));
|
||||
assert(editorView.includes("data-color-picker") && editorView.includes("overlay-transform-disclosure"));
|
||||
assert(editorView.includes('data-confirm-action="/admin/overlays/<%= overlay.id %>/modules/<%= module.id %>/delete"'), "source deletion must use the shared timed-confirmation action flow");
|
||||
assert(editorScript.includes("data-resize-handle") && editorScript.includes("snapTargets"));
|
||||
assert(editorScript.includes("previewForm(form)") && editorScript.includes("Live preview · not saved"));
|
||||
assert(editorScript.includes("focusCanvas = true") && editorScript.includes("focusCanvas: false"), "draft rerenders must preserve form focus");
|
||||
assert(editorScript.includes("--overlay-settings-max-height"), "settings height must follow its viewport position");
|
||||
assert(!editorScript.includes("--overlay-settings-max-height"), "settings height must not follow viewport geometry");
|
||||
assert(!editorScript.includes("scrollIntoView"), "scene and source selection must not force viewport movement");
|
||||
assert(!editorScript.includes("window.location.reload"), "normal overlay actions must patch in place");
|
||||
assert(editorScript.includes("window.clearInterval(obsStatusTimer)"), "OBS polling must stop when the overlay page unmounts");
|
||||
assert(editorScript.includes("editorRoot.inert = !open") && editorScript.includes("BroadcastChannel"));
|
||||
assert(runtimeScript.includes("overlay:module-refresh") && runtimeScript.includes("checkWebsiteSource"));
|
||||
assert(runtimeScript.includes("overlay:action") && runtimeScript.includes("playMediaOnce") && runtimeScript.includes("showSourceTemporarily"));
|
||||
assert(runtimeScript.includes("overlay:chat-message") && runtimeScript.includes("handleChatMessage"));
|
||||
@ -429,6 +439,11 @@ try {
|
||||
assert(routeScript.includes("/obs-bridge") && routeScript.includes("/obs/import"));
|
||||
assert(routeScript.includes('/overlay-web/:ticket') && routeScript.includes("loadInjectedOverlayDocument"));
|
||||
assert(routeScript.includes("/preview") && routeScript.includes("web-preview/:ticket"));
|
||||
assert(routeScript.includes('/admin/overlays/:id/preview-window') && routeScript.includes("includeSecrets: false"));
|
||||
assert(previewWindowView.includes("data-preview-window-frame") && previewWindowView.includes("overlay-preview-window.js"));
|
||||
assert(previewWindowView.includes("data-popout-source") && previewWindowView.includes("data-popout-snap"));
|
||||
assert(previewWindowScript.includes("Math.min(previewViewport.clientWidth / width, previewViewport.clientHeight / height)"));
|
||||
assert(previewWindowScript.includes("editor: true") && previewWindowScript.includes("saveTransform(module)"));
|
||||
assert(routeScript.includes('/overlay-chat/:ticket') && routeScript.includes("resolveChatDockTicket"));
|
||||
assert(routeScript.includes("/event-hooks/:hookId/test") && routeScript.includes("testOverlayEventHook"));
|
||||
assert(editorView.includes("Event alerts") && editorView.includes("data-event-hook-form"));
|
||||
|
||||
@ -4,8 +4,9 @@ const path = require("path");
|
||||
const { findSafeTarget } = require("../src/services/versioning");
|
||||
|
||||
const root = path.join(__dirname, "..");
|
||||
const releaseVersion = "0.2.24";
|
||||
const previousCoreVersion = "0.2.23";
|
||||
const releaseVersion = "0.2.25";
|
||||
const stableReleaseVersion = "0.2.24";
|
||||
const previousStableVersion = "0.2.23";
|
||||
const earliestCompatibleCoreVersion = "0.1.9";
|
||||
const changedPlugins = {
|
||||
"auto-vc": { from: "0.1.5", to: "0.1.6", knowledge: "auto-vc" },
|
||||
@ -44,19 +45,19 @@ assert.equal(packageJson.version, releaseVersion);
|
||||
assert.equal(packageLock.version, releaseVersion);
|
||||
assert.equal(packageLock.packages?.[""]?.version, releaseVersion);
|
||||
assert.equal(coreManifest.version, releaseVersion);
|
||||
assert.equal(coreManifest.channel, "stable");
|
||||
assert.equal(coreManifest.channel, "experimental");
|
||||
assert.equal(coreManifest.compatible_from, earliestCompatibleCoreVersion);
|
||||
assert.equal(coreManifest.rollback_safe, true);
|
||||
assert.deepEqual(coreManifest.replaces_versions, ["1.2.0"]);
|
||||
assert.equal(releaseIndex.releases[0].version, releaseVersion);
|
||||
assert.equal(releaseIndex.releases[0].ref, `refs/tags/v${releaseVersion}`);
|
||||
assert.equal(releaseIndex.releases[1].version, previousCoreVersion);
|
||||
assert.equal(releaseIndex.releases[1].ref, `refs/tags/v${previousCoreVersion}`);
|
||||
assert.equal(releaseIndex.releases[0].version, stableReleaseVersion);
|
||||
assert.equal(releaseIndex.releases[0].ref, `refs/tags/v${stableReleaseVersion}`);
|
||||
assert.equal(releaseIndex.releases[1].version, previousStableVersion);
|
||||
assert.equal(releaseIndex.releases[1].ref, `refs/tags/v${previousStableVersion}`);
|
||||
assert.equal(releaseIndex.releases.at(-1).version, earliestCompatibleCoreVersion);
|
||||
assert.equal(hasVersionHeading(readText("CHANGELOG.md"), releaseVersion), true);
|
||||
assert.match(readText("knowledge/core/lumi-core.md"), new RegExp(`^Version: ${escapeRegex(releaseVersion)}$`, "m"));
|
||||
|
||||
const safeCoreTarget = findSafeTarget(previousCoreVersion, [
|
||||
const safeCoreTarget = findSafeTarget(stableReleaseVersion, [
|
||||
...(Array.isArray(coreManifest.versions) ? coreManifest.versions : []),
|
||||
coreManifest
|
||||
]);
|
||||
@ -87,4 +88,4 @@ assert.equal(webSearch.minimum_lumi_version, "0.2.0");
|
||||
assert.equal(webSearch.minimum_lumi_ai_version, "0.8.2");
|
||||
assert.equal(hasVersionHeading(readText("plugins/lumi_ai_web_search/CHANGELOG.md"), webSearch.version), true);
|
||||
|
||||
console.log("Release metadata verification passed: core 0.2.24, Lumi AI 0.8.5, and synchronized package metadata.");
|
||||
console.log("Release metadata verification passed: experimental core 0.2.25 over stable 0.2.24, Lumi AI 0.8.5, and synchronized package metadata.");
|
||||
|
||||
@ -7,8 +7,13 @@ const root = path.join(__dirname, "..");
|
||||
const { dependencyIssues, ensureRuntimeDependencies, npmInvocation } = require("../src/services/dependency-manager");
|
||||
const { targetForRequestedVersion } = require("../src/services/repo-update");
|
||||
const { buildStatus } = require("../src/services/update-index");
|
||||
const { normalizeRepositoryRef } = require("../src/services/update-repository");
|
||||
const {
|
||||
normalizeDeploymentBranch,
|
||||
normalizeRepositoryRef,
|
||||
parseDeploymentBranches
|
||||
} = require("../src/services/update-repository");
|
||||
const { buildCorePreservePaths } = require("../src/services/update-manager");
|
||||
const { pendingDeploymentBranch } = require("../src/services/updater");
|
||||
|
||||
function readJson(relativePath) {
|
||||
return JSON.parse(fs.readFileSync(path.join(root, relativePath), "utf8"));
|
||||
@ -25,7 +30,12 @@ for (const release of releaseIndex.releases) {
|
||||
}
|
||||
|
||||
const currentRelease = releaseIndex.releases[0];
|
||||
assert.equal(currentRelease.version, readJson("package.json").version);
|
||||
const packageVersion = readJson("package.json").version;
|
||||
const coreManifest = readJson("update-manifest.json");
|
||||
assert.equal(packageVersion, coreManifest.version);
|
||||
assert.equal(coreManifest.channel, "experimental");
|
||||
assert.equal(packageVersion, "0.2.25");
|
||||
assert.equal(currentRelease.version, "0.2.24");
|
||||
assert.deepEqual(currentRelease.replaces_versions, ["1.2.0"]);
|
||||
for (const [pluginId, version] of Object.entries(currentRelease.plugins)) {
|
||||
assert.equal(readJson(`plugins/${pluginId}/plugin.json`).version, version, `${pluginId} release catalog version`);
|
||||
@ -74,6 +84,29 @@ assert.equal(selected.requires_manual_confirmation, true);
|
||||
assert.throws(() => targetForRequestedVersion(baseTarget, "9.9.9", "Core version"), /not listed/);
|
||||
assert.throws(() => normalizeRepositoryRef("--upload-pack=bad"), /Invalid/);
|
||||
assert.throws(() => normalizeRepositoryRef("refs/tags/../main"), /Invalid/);
|
||||
assert.equal(normalizeDeploymentBranch("main"), "main");
|
||||
assert.equal(normalizeDeploymentBranch("refs/heads/experimental-ux"), "experimental-ux");
|
||||
assert.throws(() => normalizeDeploymentBranch("feature/unsafe"), /main or an experimental-\*/);
|
||||
assert.deepEqual(parseDeploymentBranches([
|
||||
"1111111111111111111111111111111111111111\trefs/heads/main",
|
||||
"2222222222222222222222222222222222222222\trefs/heads/experimental-ux",
|
||||
"3333333333333333333333333333333333333333\trefs/heads/feature/not-deployable"
|
||||
].join("\n")), [
|
||||
{ name: "main", commit: "1111111111111111111111111111111111111111", channel: "stable" },
|
||||
{ name: "experimental-ux", commit: "2222222222222222222222222222222222222222", channel: "experimental" }
|
||||
]);
|
||||
const completedExperimentalUpdate = {
|
||||
branch: "experimental-ux",
|
||||
last_update_status: "complete",
|
||||
last_target_kind: "core",
|
||||
last_target_version: "0.2.25",
|
||||
last_snapshot_id: "snapshot-1"
|
||||
};
|
||||
assert.equal(pendingDeploymentBranch(completedExperimentalUpdate, "experimental", "0.2.25"), "experimental-ux");
|
||||
assert.equal(pendingDeploymentBranch({ branch: "experimental-ux" }, "experimental", "0.2.25"), null, "metadata checks must not trigger deployment");
|
||||
assert.equal(pendingDeploymentBranch({ ...completedExperimentalUpdate, deployed_branch: "experimental-ux" }, "experimental", "0.2.25"), null, "completed deployments must not loop");
|
||||
assert.equal(pendingDeploymentBranch({ ...completedExperimentalUpdate, deployed_branch: "main" }, "experimental", "0.2.25"), "experimental-ux", "stable re-entry must complete the experimental deployment");
|
||||
assert.equal(pendingDeploymentBranch(completedExperimentalUpdate, "main", "0.2.25"), null, "stable configuration must not bootstrap experimental code");
|
||||
|
||||
const corrected = buildStatus({
|
||||
kind: "core",
|
||||
@ -84,7 +117,7 @@ const corrected = buildStatus({
|
||||
channel: "stable"
|
||||
});
|
||||
assert.equal(corrected.version_correction, true);
|
||||
assert.equal(corrected.safe_target_version, "0.2.24");
|
||||
assert.equal(corrected.safe_target_version, "0.2.25");
|
||||
assert.equal(corrected.update_available, true);
|
||||
assert.equal(corrected.blocked, false);
|
||||
|
||||
@ -102,6 +135,30 @@ assert.match(repoUpdateSource, /last_update_stage: stage/);
|
||||
assert.match(repoUpdateSource, /stage = "preparing the selected repository version"[\s\S]*verifyPluginFiles\(pluginId, managed\.path, target\.safe_target_version\)[\s\S]*stage = "creating the rollback snapshot"/);
|
||||
assert.match(repoUpdateSource, /stage = "replacing plugin files"[\s\S]*applyPluginFiles\(path\.join\(managed\.path, "plugins", pluginId\)/);
|
||||
assert.match(repoUpdateSource, /last_update_stage: "complete",\s*last_error: null/);
|
||||
assert.match(repoUpdateSource, /async function deployCoreBranch/);
|
||||
assert.match(repoUpdateSource, /update_method: "branch_deploy"/);
|
||||
assert.match(repoUpdateSource, /setSetting\("git_branch", managed\.branch\)/);
|
||||
assert.match(repoUpdateSource, /applyCoreFilesFromDirectory\(managed\.path\)[\s\S]*verifyCoreFiles\(repoRoot, target\.version\)/);
|
||||
const updateManagerSource = fs.readFileSync(path.join(root, "src", "services", "update-manager.js"), "utf8");
|
||||
assert.match(updateManagerSource, /from_source_branch: snapshot\.metadata\?\.from_source_branch/);
|
||||
assert.match(updateManagerSource, /target_commit: snapshot\.metadata\?\.target_commit/);
|
||||
assert.match(updateManagerSource, /includes_plugin_code: Boolean\(snapshot\.bundledPluginsZip\)/);
|
||||
assert.match(updateManagerSource, /plugins\.zip/);
|
||||
assert.match(repoUpdateSource, /target_plugin_ids: targetPluginIds/);
|
||||
assert.match(repoUpdateSource, /applyPluginFiles\(path\.join\(managed\.path, "plugins", pluginId\), pluginId, \{ preserveData: true \}\)/);
|
||||
const updaterSource = fs.readFileSync(path.join(root, "src", "services", "updater.js"), "utf8");
|
||||
const mainSource = fs.readFileSync(path.join(root, "src", "main.js"), "utf8");
|
||||
assert.match(updaterSource, /function registerRestartHandler/);
|
||||
assert.match(updaterSource, /async function completePendingBranchDeployment/);
|
||||
assert.match(updaterSource, /state\.last_update_status === "complete"[\s\S]*state\.last_target_kind === "core"[\s\S]*state\.last_target_version === localVersion/);
|
||||
assert.match(mainSource, /registerRestartHandler\(\(\) => shutdown\(10\)\)/);
|
||||
assert.match(mainSource, /await completePendingBranchDeployment\(configuredRemote, configuredBranch\)/);
|
||||
assert.match(mainSource, /overlayConnectorManager\.stop\(\)[\s\S]*stopPlugins\(\)[\s\S]*process\.exit\(Number\(exitCode\) === 10 \? 10 : 0\)/);
|
||||
const updatesView = fs.readFileSync(path.join(root, "src", "web", "views", "admin-updates.ejs"), "utf8");
|
||||
assert.match(serverSource, /app\.post\("\/admin\/updates\/core\/deploy-branch"/);
|
||||
assert.match(updatesView, /Running code branch/);
|
||||
assert.match(updatesView, /Return safely to main/);
|
||||
assert.match(updatesView, /Emergency restore previous backup/);
|
||||
const connectorSource = fs.readFileSync(path.join(root, "src", "services", "overlay-connectors.js"), "utf8");
|
||||
assert.match(connectorSource, /try\s*{[\s\S]*require\("obs-websocket-js"\)/);
|
||||
|
||||
|
||||
89
scripts/verify-ux-foundation.js
Normal file
89
scripts/verify-ux-foundation.js
Normal file
@ -0,0 +1,89 @@
|
||||
const assert = require("assert");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
const root = path.join(__dirname, "..");
|
||||
const read = (file) => fs.readFileSync(path.join(root, file), "utf8");
|
||||
|
||||
const interactions = read("src/web/public/lumi-interactions.js");
|
||||
const request = read("src/web/public/lumi-request.js");
|
||||
const pages = read("src/web/public/lumi-page.js");
|
||||
const dialogs = read("src/web/public/lumi-dialog.js");
|
||||
const app = read("src/web/public/app.js");
|
||||
const overlay = read("src/web/public/overlay-management.js");
|
||||
const overlayCss = read("src/web/public/overlay-management.css");
|
||||
const overlayPreviewWindow = read("src/web/public/overlay-preview-window.js");
|
||||
const overlayRoute = read("src/services/overlay-routes.js");
|
||||
const layout = read("src/web/views/partials/layout-top.ejs");
|
||||
const tokens = read("src/web/public/lumi-tokens.css");
|
||||
const legacy = read("src/web/public/styles.css");
|
||||
const navigationTemplate = read("src/web/views/admin-navigation.ejs");
|
||||
const navigationClient = read("src/web/public/navigation-builder.js");
|
||||
const server = read("src/web/server.js");
|
||||
const themeTemplate = read("src/web/views/admin-theme.ejs");
|
||||
const themeClient = read("src/web/public/theme-editor.js");
|
||||
const aiTemplate = read("plugins/lumi_ai/views/settings.ejs");
|
||||
const aiClient = read("plugins/lumi_ai/public/settings.js");
|
||||
const aiServer = read("plugins/lumi_ai/index.js");
|
||||
const commandTemplate = read("src/web/views/admin-commands.ejs");
|
||||
const commandClient = read("src/web/public/command-management.js");
|
||||
const policyTemplate = read("src/web/views/admin-command-policies.ejs");
|
||||
const policyClient = read("src/web/public/command-policies.js");
|
||||
const updateTemplate = read("src/web/views/admin-updates.ejs");
|
||||
const updateClient = read("src/web/public/update-management.js");
|
||||
|
||||
assert(interactions.includes("window.LumiRequest.form(form)"), "settings saves must use the shared request contract");
|
||||
assert(!interactions.includes("Saved. Reloading"), "ordinary settings saves must not announce an implicit reload");
|
||||
assert(!interactions.includes("executePageScripts"), "soft navigation must not re-execute arbitrary page scripts");
|
||||
assert(interactions.includes('a[data-lumi-soft-nav][href]'), "soft navigation must be explicit while routes migrate");
|
||||
assert(interactions.includes("window.LumiEvents") && interactions.includes('window.addEventListener("beforeunload", closeEvents)'), "shared live events must release their connection before full navigation");
|
||||
assert(!app.includes('new EventSource("/admin/updates/events")') || app.includes("window.LumiEvents?.subscribe"), "updates must prefer the shared event stream");
|
||||
assert(request.includes('"X-Lumi-Enhanced": "1"') && request.includes("reloadRequired"));
|
||||
assert(request.includes('getAttribute?.("formaction")') && !request.includes("submitter?.formAction"), "enhanced requests must use authored form actions");
|
||||
assert(pages.includes("controller.abort()") && pages.includes("teardown"));
|
||||
assert(dialogs.includes("restoreFocus") && dialogs.includes('event.key !== "Tab"') && dialogs.includes("inert"));
|
||||
assert(app.includes("page.inert = true") && app.includes("closeSidebar") && app.includes('event.key === "Escape"'));
|
||||
assert(layout.includes('id="lumi-sidebar"') && layout.includes('aria-controls="lumi-sidebar"'));
|
||||
assert(!overlay.includes("--overlay-settings-max-height"), "overlay height must not be calculated from viewport geometry");
|
||||
assert(!overlay.includes("scrollIntoView"), "scene/source selection must not force viewport movement");
|
||||
assert(!overlay.includes("window.location.reload"), "normal overlay actions must not reload the document");
|
||||
assert(overlay.includes("window.clearInterval(obsStatusTimer)"), "overlay polling must be lifecycle-owned");
|
||||
assert(!overlayCss.includes("--overlay-settings-max-height"));
|
||||
assert(/@media \(min-width: 1180px\)[\s\S]*\.overlay-settings-column\s*\{[^}]*overflow-y:\s*auto/s.test(overlayCss), "desktop overlay settings need a bounded workspace scroller");
|
||||
assert(!/\.overlay-preview-panel\s*\{[^}]*overflow:\s*auto/s.test(overlayCss), "the sticky preview cannot become a nested main scroller");
|
||||
assert(overlayCss.includes(".overlay-preview-panel.is-open") && overlayCss.includes("position: fixed"), "thin overlay layouts need a viewport-contained edge drawer");
|
||||
assert(overlay.includes("editorRoot.inert = !open") && overlay.includes("restoreFocus"), "the preview drawer must hide closed controls and restore focus");
|
||||
assert(overlay.includes("BroadcastChannel") && overlay.includes("dataset.previewPopoutUrl"), "the editor must synchronize its desktop pop-out preview");
|
||||
assert(overlayPreviewWindow.includes("Math.min(previewViewport.clientWidth / width, previewViewport.clientHeight / height)"), "the pop-out must preserve the canvas aspect ratio");
|
||||
assert(overlayPreviewWindow.includes("editor: true") && overlayPreviewWindow.includes("/transform") && overlayPreviewWindow.includes('type: "transform"'), "the pop-out must support synchronized transform editing");
|
||||
assert(overlayRoute.includes('app.get("/admin/overlays/:id/preview-window", requireOverlayAccess(OVERLAY_CAPABILITIES.MANAGE)'), "the pop-out must remain behind overlay management access");
|
||||
assert(tokens.includes("@layer reset, tokens, base, layout, components, utilities, features, overrides"));
|
||||
assert(!legacy.includes("fonts.googleapis.com"), "core CSS cannot depend on remote Google Fonts");
|
||||
assert(!navigationTemplate.includes("<style>"), "the navigation builder must use the feature stylesheet instead of page-local CSS");
|
||||
assert(!navigationTemplate.includes("<script>"), "the navigation builder must use lifecycle-owned client behavior");
|
||||
assert(navigationTemplate.includes("data-lumi-enhanced-form") && navigationTemplate.includes('pageId: "navigation-builder"'));
|
||||
assert(navigationClient.includes('LumiPage.register("navigation-builder"'));
|
||||
assert(navigationClient.includes("data-item-previous-group") && navigationClient.includes("data-item-next-group"), "navigation items need non-drag movement controls");
|
||||
assert(server.includes('message: "Navigation saved without leaving the editor."'));
|
||||
assert(themeTemplate.includes("data-lumi-enhanced-form") && themeTemplate.includes('pageId: "theme-studio"'));
|
||||
assert(themeClient.includes('LumiPage.register("theme-studio"') && themeClient.includes("applyCanonicalTheme"));
|
||||
assert(server.includes("data: { theme, applied }"), "theme saves must return canonical values to the editor");
|
||||
assert(aiTemplate.includes('pageId: "lumi-ai-settings"'));
|
||||
assert(aiClient.includes('LumiPage.register("lumi-ai-settings"') && aiClient.includes("intervals.forEach"));
|
||||
assert(aiClient.includes(" pollDownloads();\n return () =>"), "Lumi AI download polling must start once per mount");
|
||||
assert(aiServer.includes('req.get("X-Lumi-Enhanced")') && aiServer.includes("reloadRequired: false"));
|
||||
assert(!commandTemplate.includes("<script>"), "custom command behavior must not remain inline");
|
||||
assert(commandTemplate.includes('pageId: "command-management"') && commandTemplate.includes("data-command-list-section"));
|
||||
assert(commandClient.includes('LumiPage.register("command-management"') && commandClient.includes("refreshCommandList"));
|
||||
assert(!policyTemplate.includes("<script>"), "command policy behavior must not remain inline");
|
||||
assert(policyTemplate.includes('pageId: "command-policies"') && policyTemplate.includes("data-lumi-enhanced-form"));
|
||||
assert(policyClient.includes('LumiPage.register("command-policies"') && policyClient.includes("refreshInheritedSummaries"));
|
||||
assert(server.includes("sendCommandMutationSuccess") && server.includes('patch: { refresh: "command-list" }'));
|
||||
assert(!updateTemplate.includes("<script>"), "update behavior must not remain inline");
|
||||
assert(updateTemplate.includes('pageId: "update-management"') && updateTemplate.includes("data-update-recovery-region"));
|
||||
assert(updateClient.includes('LumiPage.register("update-management"') && updateClient.includes("subscriptions.splice(0)"));
|
||||
assert(updateClient.includes("LumiConfirm?.destructiveToken") && updateClient.includes("confirmation_token"));
|
||||
assert(!updateClient.includes("window.location.reload"), "restart recovery must reconnect without a document reload");
|
||||
assert(server.includes("Correct the snapshot cleanup limits and try again."), "snapshot limits need field-linked validation");
|
||||
|
||||
console.log("UX foundation verification passed: request, lifecycle, dialogs, drawer, cascade, navigation, commands, updates, and overlay regressions.");
|
||||
@ -190,17 +190,17 @@ function verifyUpdateCachePreserved() {
|
||||
|
||||
function verifySharedUpdateActions() {
|
||||
const appSource = fs.readFileSync(path.join(root, "src", "web", "public", "app.js"), "utf8");
|
||||
const updateSource = fs.readFileSync(path.join(root, "src", "web", "public", "update-management.js"), "utf8");
|
||||
const serverSource = fs.readFileSync(path.join(root, "src", "web", "server.js"), "utf8");
|
||||
const updates = fs.readFileSync(path.join(root, "src", "web", "views", "admin-updates.ejs"), "utf8");
|
||||
const dashboard = fs.readFileSync(path.join(root, "src", "web", "views", "admin-dashboard.ejs"), "utf8");
|
||||
const settings = fs.readFileSync(path.join(root, "src", "web", "views", "admin-settings.ejs"), "utf8");
|
||||
assert(appSource.includes('button[data-update-action]'));
|
||||
assert(updateSource.includes('submitter?.matches?.("[data-update-action]")'));
|
||||
assert(appSource.includes('form?.getAttribute?.("action")'));
|
||||
assert(appSource.includes("window.LumiForms = Object.freeze"));
|
||||
assert(appSource.includes('form?.getAttribute?.("method")'));
|
||||
assert(!appSource.includes("submitter?.formAction || form.action"));
|
||||
assert(appSource.includes('actionPath.endsWith("/check")'));
|
||||
assert(appSource.includes('form.matches("[data-update-check-form]")'));
|
||||
assert(updateSource.includes('form.matches("[data-update-check-form]")'));
|
||||
assert(dashboard.includes('action="/admin/check-update" class="inline-form" data-update-action'));
|
||||
assert(settings.includes('formaction=\\"/admin/check-update\\" formmethod=\\"post\\" data-update-action'));
|
||||
assert(serverSource.includes('sendUpdateResult(req, res, {\n status,'));
|
||||
@ -210,7 +210,11 @@ function verifySharedUpdateActions() {
|
||||
assert(updates.includes("data-update-check-form"));
|
||||
assert(updates.includes("data-update-inline-result"));
|
||||
assert(updates.includes('class="lumi-expandable-settings update-version-picker"'));
|
||||
assert(appSource.includes('submitter.textContent = "Update failed"'));
|
||||
assert(updateSource.includes("The update action failed. Your current page is unchanged."));
|
||||
assert(!updates.includes("<script>"), "Updates must use lifecycle-owned client behavior instead of an inline script");
|
||||
assert(updateSource.includes('LumiPage.register("update-management"'));
|
||||
assert(updateSource.includes("LumiConfirm?.destructiveToken"), "Destructive update actions must attach a timed confirmation token");
|
||||
assert(!updateSource.includes("window.location.reload"), "Update recovery must reconnect without reloading the document");
|
||||
assert(!updates.includes("reinstall current"));
|
||||
assert(!updates.includes(" · core <%= release.core_version %>"));
|
||||
}
|
||||
|
||||
49
src/main.js
49
src/main.js
@ -10,7 +10,7 @@ const stopPlugins =
|
||||
typeof pluginService.stopPlugins === "function"
|
||||
? pluginService.stopPlugins
|
||||
: async () => {};
|
||||
const { checkForUpdates, pullUpdates, requestRestart } = require("./services/updater");
|
||||
const { checkForUpdates, completePendingBranchDeployment, pullUpdates, registerRestartHandler, requestRestart } = require("./services/updater");
|
||||
const { createCommandRouter } = require("./services/command-router");
|
||||
const { registerTopCommand } = require("./services/top");
|
||||
const logger = require("./services/logger");
|
||||
@ -27,6 +27,14 @@ const {
|
||||
async function main() {
|
||||
migrate();
|
||||
ensureDefaults();
|
||||
const configuredRemote = getSetting("git_remote", "origin");
|
||||
const configuredBranch = getSetting("git_branch", "main");
|
||||
const completedDeployment = await completePendingBranchDeployment(configuredRemote, configuredBranch);
|
||||
if (completedDeployment) {
|
||||
console.log(`Completed deployment of ${completedDeployment.deployment.branch}; restarting with synchronized core and plugin code.`);
|
||||
requestRestart({ delayMs: 0 });
|
||||
return;
|
||||
}
|
||||
registerCorePlaceholders();
|
||||
logger.hookConsole();
|
||||
const runtimeLog = logger.createLogger("core:runtime", { category: "lifecycle" });
|
||||
@ -112,7 +120,7 @@ async function main() {
|
||||
}
|
||||
|
||||
const port = Number(process.env.PORT || 3000);
|
||||
app.listen(port, () => {
|
||||
const webServer = app.listen(port, () => {
|
||||
runtimeLog.run({ event: "web_ready" }, () => {
|
||||
console.log(`WebUI listening on http://localhost:${port}`, { port });
|
||||
});
|
||||
@ -142,23 +150,38 @@ async function main() {
|
||||
}
|
||||
|
||||
let shuttingDown = false;
|
||||
const shutdown = async () => {
|
||||
const shutdown = async (exitCode = 0) => {
|
||||
if (shuttingDown) {
|
||||
return;
|
||||
}
|
||||
shuttingDown = true;
|
||||
runtimeLog.info("Lumi shutdown started", null, { event: "shutdown" });
|
||||
await overlayConnectorManager.stop();
|
||||
await twitchEventSubManager.stop();
|
||||
await stopPlugins();
|
||||
await stopBot();
|
||||
await stopTwitchBot();
|
||||
await stopYouTubeBot();
|
||||
process.exit(0);
|
||||
runtimeLog.info("Lumi shutdown started", { exit_code: exitCode }, { event: exitCode === 10 ? "restart" : "shutdown" });
|
||||
const closeWebServer = new Promise((resolve) => webServer.close(resolve));
|
||||
for (const stop of [
|
||||
() => overlayConnectorManager.stop(),
|
||||
() => twitchEventSubManager.stop(),
|
||||
() => stopPlugins(),
|
||||
() => stopBot(),
|
||||
() => stopTwitchBot(),
|
||||
() => stopYouTubeBot()
|
||||
]) {
|
||||
try {
|
||||
await stop();
|
||||
} catch (error) {
|
||||
runtimeLog.warn("A service did not stop cleanly", { error: error.message }, { event: "shutdown_service_error" });
|
||||
}
|
||||
}
|
||||
await Promise.race([
|
||||
closeWebServer,
|
||||
new Promise((resolve) => setTimeout(resolve, 2500))
|
||||
]);
|
||||
webServer.closeAllConnections?.();
|
||||
process.exit(Number(exitCode) === 10 ? 10 : 0);
|
||||
};
|
||||
|
||||
process.on("SIGINT", shutdown);
|
||||
process.on("SIGTERM", shutdown);
|
||||
registerRestartHandler(() => shutdown(10));
|
||||
process.on("SIGINT", () => shutdown(0));
|
||||
process.on("SIGTERM", () => shutdown(0));
|
||||
}
|
||||
|
||||
main();
|
||||
|
||||
@ -430,6 +430,11 @@ function deleteResource(id) {
|
||||
}
|
||||
|
||||
function updateStorageSettings(values = {}) {
|
||||
for (const field of ["limit_gib", "reserve_gib", "max_file_mib", "max_files"]) {
|
||||
if (values[field] === undefined || values[field] === null || String(values[field]).trim() === "") {
|
||||
throw new Error("Complete every storage-limit field before saving.");
|
||||
}
|
||||
}
|
||||
const limitBytes = gibToBytes(values.limit_gib, { allowZero: true, max: 1024 * 1024 });
|
||||
const reserveBytes = gibToBytes(values.reserve_gib, { allowZero: true, max: 1024 });
|
||||
const maxFileBytes = mibToBytes(values.max_file_mib, {
|
||||
@ -442,10 +447,12 @@ function updateStorageSettings(values = {}) {
|
||||
if (limitBytes > 0 && limitBytes < usedBytes) {
|
||||
throw new Error(`The storage limit cannot be lower than the ${formatBytes(usedBytes)} already stored.`);
|
||||
}
|
||||
setSetting("content_storage_limit_bytes", limitBytes);
|
||||
setSetting("content_storage_reserve_bytes", reserveBytes);
|
||||
setSetting("content_max_file_bytes", maxFileBytes);
|
||||
setSetting("content_upload_max_files", maxFiles);
|
||||
db.transaction(() => {
|
||||
setSetting("content_storage_limit_bytes", limitBytes);
|
||||
setSetting("content_storage_reserve_bytes", reserveBytes);
|
||||
setSetting("content_max_file_bytes", maxFileBytes);
|
||||
setSetting("content_upload_max_files", maxFiles);
|
||||
})();
|
||||
return getStorageStats();
|
||||
}
|
||||
|
||||
@ -835,7 +842,7 @@ function clampInteger(value, min, max, fallback) {
|
||||
|
||||
function gibToBytes(value, options = {}) {
|
||||
const number = Number(value);
|
||||
if (options.allowZero && (!Number.isFinite(number) || number <= 0)) return 0;
|
||||
if (options.allowZero && number === 0) return 0;
|
||||
if (!Number.isFinite(number) || number < 0) throw new Error("Storage values must be positive numbers.");
|
||||
const bounded = Math.min(options.max || number, number);
|
||||
return Math.floor(bounded * 1024 * 1024 * 1024);
|
||||
|
||||
@ -2,7 +2,9 @@ const path = require("path");
|
||||
const fs = require("fs");
|
||||
const Database = require("better-sqlite3");
|
||||
|
||||
const dataDir = path.join(__dirname, "..", "..", "data");
|
||||
const dataDir = process.env.LUMI_DATA_DIR
|
||||
? path.resolve(process.env.LUMI_DATA_DIR)
|
||||
: path.join(__dirname, "..", "..", "data");
|
||||
const dbPath = path.join(dataDir, "app.db");
|
||||
|
||||
fs.mkdirSync(dataDir, { recursive: true });
|
||||
|
||||
@ -351,9 +351,15 @@ function flash(req, type, message) {
|
||||
function redirectWithResult(req, res, pathValue, work, success) {
|
||||
try {
|
||||
const value = work();
|
||||
if (req.get("X-Lumi-Enhanced") === "1") {
|
||||
return res.json({ ok: true, message: success, fieldErrors: {}, data: { result: value }, patch: {}, redirect: null, reloadRequired: false });
|
||||
}
|
||||
flash(req, "success", success);
|
||||
return res.redirect(pathValue);
|
||||
} catch (error) {
|
||||
if (req.get("X-Lumi-Enhanced") === "1") {
|
||||
return res.status(422).json({ ok: false, message: error.message, fieldErrors: {}, data: {}, patch: {}, redirect: null, reloadRequired: false });
|
||||
}
|
||||
flash(req, "error", error.message);
|
||||
return res.redirect(pathValue);
|
||||
}
|
||||
@ -441,6 +447,32 @@ function moduleInput(body) {
|
||||
};
|
||||
}
|
||||
|
||||
function overlayEditorState(overlay) {
|
||||
return {
|
||||
overlayId: overlay.id,
|
||||
canvas: { width: overlay.canvas_width || 1920, height: overlay.canvas_height || 1080 },
|
||||
activeSceneId: overlay.active_scene_id,
|
||||
scenes: overlay.scenes.map((scene) => ({
|
||||
id: scene.id,
|
||||
name: scene.name,
|
||||
enabled: scene.enabled,
|
||||
modules: scene.modules.map((module) => ({
|
||||
id: module.id,
|
||||
name: module.name,
|
||||
type: module.type,
|
||||
renderType: getOverlayModuleType(module.type)?.renderType || module.type,
|
||||
enabled: module.enabled,
|
||||
config: {
|
||||
...module.config,
|
||||
...((getOverlayModuleType(module.type)?.renderType || module.type) === "web" && module.config?.injectPageCss !== false
|
||||
? { renderUrl: webSourceRenderUrl(module.id) }
|
||||
: {})
|
||||
}
|
||||
}))
|
||||
}))
|
||||
};
|
||||
}
|
||||
|
||||
function baseUrl(req) {
|
||||
return `${req.protocol}://${req.get("host")}`;
|
||||
}
|
||||
@ -510,29 +542,7 @@ function registerOverlayAdminRoutes(app) {
|
||||
eventActions: OVERLAY_EVENT_ACTIONS,
|
||||
eventHooks: listOverlayEventHooks(overlay.id),
|
||||
twitchEventStatus: twitchEventSubManager.getStatus(),
|
||||
editorStateJson: JSON.stringify({
|
||||
overlayId: overlay.id,
|
||||
canvas: { width: overlay.canvas_width || 1920, height: overlay.canvas_height || 1080 },
|
||||
activeSceneId: overlay.active_scene_id,
|
||||
scenes: overlay.scenes.map((scene) => ({
|
||||
id: scene.id,
|
||||
name: scene.name,
|
||||
enabled: scene.enabled,
|
||||
modules: scene.modules.map((module) => ({
|
||||
id: module.id,
|
||||
name: module.name,
|
||||
type: module.type,
|
||||
renderType: getOverlayModuleType(module.type)?.renderType || module.type,
|
||||
enabled: module.enabled,
|
||||
config: {
|
||||
...module.config,
|
||||
...((getOverlayModuleType(module.type)?.renderType || module.type) === "web" && module.config?.injectPageCss !== false
|
||||
? { renderUrl: webSourceRenderUrl(module.id) }
|
||||
: {})
|
||||
}
|
||||
}))
|
||||
}))
|
||||
}).replace(/</g, "\\u003c"),
|
||||
editorStateJson: JSON.stringify(overlayEditorState(overlay)).replace(/</g, "\\u003c"),
|
||||
connectorProviders: canManageObs ? listOverlayConnectorProviders() : [],
|
||||
obsStatus: canManageObs
|
||||
? await overlayConnectorManager.status(overlay.id, { includeScenes: true })
|
||||
@ -540,6 +550,17 @@ function registerOverlayAdminRoutes(app) {
|
||||
});
|
||||
});
|
||||
|
||||
app.get("/admin/overlays/:id/preview-window", requireOverlayAccess(OVERLAY_CAPABILITIES.MANAGE), (req, res) => {
|
||||
noStore(res);
|
||||
const overlay = getOverlay(req.params.id, { includeSecrets: false });
|
||||
if (!overlay) return res.status(404).render("error", { title: "Overlay not found", message: "That overlay does not exist." });
|
||||
res.render("admin-overlay-preview", {
|
||||
title: `${overlay.name} preview`,
|
||||
overlay,
|
||||
editorStateJson: JSON.stringify(overlayEditorState(overlay)).replace(/</g, "\\u003c")
|
||||
});
|
||||
});
|
||||
|
||||
app.post("/admin/overlays/:id", requireOverlayAccess(), (req, res) => redirectWithResult(req, res, `/admin/overlays/${req.params.id}`, () => updateOverlay(req.params.id, {
|
||||
name: req.body.name,
|
||||
description: req.body.description,
|
||||
@ -678,8 +699,22 @@ function registerOverlayAdminRoutes(app) {
|
||||
reconnect: booleanField(req.body, "reconnect")
|
||||
});
|
||||
await overlayConnectorManager.restart(req.params.id);
|
||||
if (req.get("X-Lumi-Enhanced") === "1") {
|
||||
return res.json({
|
||||
ok: true,
|
||||
message: "OBS connector settings saved.",
|
||||
fieldErrors: {},
|
||||
data: { status: await overlayConnectorManager.status(req.params.id, { includeScenes: true }) },
|
||||
patch: {},
|
||||
redirect: null,
|
||||
reloadRequired: false
|
||||
});
|
||||
}
|
||||
flash(req, "success", "OBS connector settings saved.");
|
||||
} catch (error) {
|
||||
if (req.get("X-Lumi-Enhanced") === "1") {
|
||||
return res.status(422).json({ ok: false, message: error.message, fieldErrors: {}, data: {}, patch: {}, redirect: null, reloadRequired: false });
|
||||
}
|
||||
flash(req, "error", error.message);
|
||||
}
|
||||
res.redirect(`/admin/overlays/${req.params.id}`);
|
||||
@ -757,6 +792,8 @@ function registerOverlayAdminRoutes(app) {
|
||||
created,
|
||||
mapped,
|
||||
canvas_updated: Boolean(status.canvas_width && status.canvas_height),
|
||||
editor: overlayEditorState(overlay),
|
||||
status: await overlayConnectorManager.status(req.params.id, { includeScenes: true }),
|
||||
message: `OBS setup applied: ${created} scene${created === 1 ? "" : "s"} added, ${mapped} existing scene${mapped === 1 ? "" : "s"} matched.`
|
||||
});
|
||||
} catch (error) {
|
||||
|
||||
@ -12,8 +12,11 @@ const {
|
||||
const { getUpdateStatus } = require("./update-index");
|
||||
const {
|
||||
ensureManagedRepo,
|
||||
normalizeDeploymentBranch,
|
||||
readUpdateState,
|
||||
writeUpdateState
|
||||
} = require("./update-repository");
|
||||
const { getSetting, setSetting } = require("./settings");
|
||||
const { compareSemver, parseSemver } = require("./versioning");
|
||||
const {
|
||||
createRecoveryMarker,
|
||||
@ -51,6 +54,38 @@ function verifyCoreFiles(rootPath = repoRoot, expectedVersion = null) {
|
||||
if (expectedVersion && actualVersion !== expectedVersion) {
|
||||
throw new Error(`Core verification failed: requested ${expectedVersion}, but repository ref contains ${actualVersion || "no version"}.`);
|
||||
}
|
||||
return actualVersion;
|
||||
}
|
||||
|
||||
function coreBranchMetadata(rootPath) {
|
||||
const version = verifyCoreFiles(rootPath);
|
||||
let manifest = {};
|
||||
for (const name of ["update-manifest.json", "lumi.manifest.json"]) {
|
||||
try {
|
||||
manifest = JSON.parse(fs.readFileSync(path.join(rootPath, name), "utf8"));
|
||||
break;
|
||||
} catch {
|
||||
// Branch deployments can proceed without optional release metadata.
|
||||
}
|
||||
}
|
||||
return {
|
||||
version,
|
||||
rollback_safe: manifest.rollback_safe !== false,
|
||||
migration_notes: String(manifest.migration_notes || ""),
|
||||
danger_notes: Array.isArray(manifest.dangers) ? manifest.dangers : []
|
||||
};
|
||||
}
|
||||
|
||||
function bundledPluginIds(rootPath) {
|
||||
const pluginsPath = path.join(rootPath, "plugins");
|
||||
try {
|
||||
return fs.readdirSync(pluginsPath, { withFileTypes: true })
|
||||
.filter((entry) => entry.isDirectory() && fs.existsSync(path.join(pluginsPath, entry.name, "plugin.json")))
|
||||
.map((entry) => entry.name)
|
||||
.sort();
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function verifyPluginFiles(pluginId, rootPath = repoRoot, expectedVersion = null) {
|
||||
@ -195,6 +230,148 @@ async function applyCoreUpdate({ source = "stable", remote = null, version = nul
|
||||
});
|
||||
}
|
||||
|
||||
async function deployCoreBranch({ branch, remote = null, publish } = {}) {
|
||||
return withOperation("core", async () => {
|
||||
const targetBranch = normalizeDeploymentBranch(branch);
|
||||
const configuredRemote = remote || getSetting("git_remote", "origin");
|
||||
const priorState = readUpdateState();
|
||||
const fromBranch = String(priorState.deployed_branch || getSetting("git_branch", "main") || "main");
|
||||
const fromCommit = priorState.deployed_commit || null;
|
||||
const currentVersion = verifyCoreFiles(repoRoot);
|
||||
let stage = "fetching and verifying the selected branch";
|
||||
let managed = null;
|
||||
let target = null;
|
||||
let marker = null;
|
||||
let snapshot = null;
|
||||
let snapshotRecord = null;
|
||||
try {
|
||||
emitProgress(publish, "update:queued", { target: "core", deployment_branch: targetBranch });
|
||||
emitProgress(publish, "update:checking", { target: "core", deployment_branch: targetBranch });
|
||||
managed = ensureManagedRepo(configuredRemote, targetBranch);
|
||||
target = coreBranchMetadata(managed.path);
|
||||
const targetPluginIds = bundledPluginIds(managed.path);
|
||||
for (const pluginId of targetPluginIds) verifyPluginFiles(pluginId, managed.path);
|
||||
const majorCrossing = parseSemver(currentVersion)?.major !== parseSemver(target.version)?.major;
|
||||
const deployment = {
|
||||
from_branch: fromBranch,
|
||||
from_commit: fromCommit,
|
||||
from_version: currentVersion,
|
||||
branch: managed.branch,
|
||||
commit: managed.commit,
|
||||
version: target.version,
|
||||
rollback_safe: target.rollback_safe,
|
||||
major_crossing: majorCrossing
|
||||
};
|
||||
emitProgress(publish, "update:metadata", { target: "core", deployment });
|
||||
|
||||
stage = "preparing recovery";
|
||||
marker = createRecoveryMarker({
|
||||
target_kind: "core",
|
||||
target_id: "core",
|
||||
from_version: currentVersion,
|
||||
to_version: target.version,
|
||||
source_branch: managed.branch,
|
||||
update_method: "branch_deploy",
|
||||
rollback_safe: target.rollback_safe,
|
||||
major_crossing: majorCrossing
|
||||
});
|
||||
updateRecoveryMarker({ status: "applying" });
|
||||
|
||||
stage = "creating the rollback snapshot";
|
||||
snapshot = await createSnapshot({
|
||||
type: "bot",
|
||||
metadata: {
|
||||
target_kind: "core",
|
||||
target_id: "core",
|
||||
from_version: currentVersion,
|
||||
to_version: target.version,
|
||||
from_source_branch: fromBranch,
|
||||
from_commit: fromCommit,
|
||||
source_branch: managed.branch,
|
||||
target_commit: managed.commit,
|
||||
update_method: "branch_deploy",
|
||||
rollback_safe: target.rollback_safe,
|
||||
recovery_marker_id: marker.id,
|
||||
major_crossing: majorCrossing,
|
||||
migration_notes: target.migration_notes,
|
||||
danger_notes: target.danger_notes,
|
||||
include_plugin_code: true,
|
||||
target_plugin_ids: targetPluginIds
|
||||
}
|
||||
});
|
||||
snapshotRecord = finalizeSnapshot(snapshot);
|
||||
emitProgress(publish, "update:snapshot", { target: "core", snapshot_id: snapshotRecord.id });
|
||||
emitProgress(publish, "update:recovery_marker", { target: "core", marker_id: marker.id });
|
||||
emitProgress(publish, "update:download", { target: "core", branch: managed.branch, commit: managed.commit });
|
||||
|
||||
stage = "replacing core files";
|
||||
emitProgress(publish, "update:apply", { target: "core", branch: managed.branch });
|
||||
applyCoreFilesFromDirectory(managed.path);
|
||||
for (const pluginId of targetPluginIds) {
|
||||
applyPluginFiles(path.join(managed.path, "plugins", pluginId), pluginId, { preserveData: true });
|
||||
}
|
||||
updateRecoveryMarker({ status: "verifying" });
|
||||
|
||||
stage = "verifying the deployed branch";
|
||||
emitProgress(publish, "update:verify", { target: "core", branch: managed.branch });
|
||||
verifyCoreFiles(repoRoot, target.version);
|
||||
for (const pluginId of targetPluginIds) verifyPluginFiles(pluginId, repoRoot);
|
||||
syncPluginRegistry();
|
||||
setSetting("git_branch", managed.branch);
|
||||
markRecoveryMarkerComplete({ snapshot_id: snapshotRecord.id });
|
||||
writeUpdateState({
|
||||
remote: managed.repository,
|
||||
branch: managed.branch,
|
||||
commit: managed.commit,
|
||||
deployed_branch: managed.branch,
|
||||
deployed_commit: managed.commit,
|
||||
deployed_version: target.version,
|
||||
previous_deployed_branch: fromBranch,
|
||||
previous_deployed_commit: fromCommit,
|
||||
last_update_at: new Date().toISOString(),
|
||||
last_update_status: "complete",
|
||||
last_update_stage: "complete",
|
||||
last_error: null,
|
||||
last_snapshot_id: snapshotRecord.id,
|
||||
last_target_kind: "core",
|
||||
last_target_version: target.version,
|
||||
last_deployment_method: "branch_deploy"
|
||||
});
|
||||
emitProgress(publish, "update:restart_required", { target: "core", deployment });
|
||||
emitProgress(publish, "update:complete", { target: "core", snapshot_id: snapshotRecord.id, deployment });
|
||||
return { status: "complete", restart_required: true, snapshot: snapshotRecord, deployment };
|
||||
} catch (error) {
|
||||
const originalMessage = error?.message || String(error || "Unknown deployment error.");
|
||||
error.message = `Branch deployment failed while ${stage}: ${originalMessage}`;
|
||||
if (snapshotRecord) {
|
||||
try {
|
||||
restoreSnapshot(snapshotRecord.id, {
|
||||
expectedType: "bot",
|
||||
currentVersion: target?.version,
|
||||
allowUnsafeMajorRollback: true
|
||||
});
|
||||
setSetting("git_branch", fromBranch);
|
||||
} catch (restoreError) {
|
||||
error.message += ` Automatic restore also failed: ${restoreError.message}`;
|
||||
}
|
||||
} else if (snapshot) {
|
||||
try { discardSnapshot(snapshot); } catch {}
|
||||
}
|
||||
if (marker) markRecoveryMarkerFailed(error);
|
||||
writeUpdateState({
|
||||
last_update_at: new Date().toISOString(),
|
||||
last_update_status: "failed",
|
||||
last_update_stage: stage,
|
||||
last_target_kind: "core",
|
||||
last_target_version: target?.version || null,
|
||||
last_error: error.message
|
||||
});
|
||||
emitProgress(publish, "update:failed", { target: "core", deployment_branch: targetBranch, error: error.message });
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function applyPluginUpdateFromRepo(pluginId, { source = "stable", remote = null, version = null, publish } = {}) {
|
||||
return withOperation(`plugin:${pluginId}`, async () => {
|
||||
let status = null;
|
||||
@ -327,12 +504,30 @@ async function applyPluginUpdateFromRepo(pluginId, { source = "stable", remote =
|
||||
}
|
||||
|
||||
async function revertCoreSnapshot(snapshotId, { currentVersion, publish } = {}) {
|
||||
return withOperation("core:revert", async () => {
|
||||
return withOperation("core", async () => {
|
||||
emitProgress(publish, "update:revert", { target: "core", snapshot_id: snapshotId });
|
||||
const entry = restoreSnapshot(snapshotId, {
|
||||
expectedType: "bot",
|
||||
currentVersion
|
||||
});
|
||||
if (entry.update_method === "branch_deploy" && entry.from_source_branch) {
|
||||
setSetting("git_branch", entry.from_source_branch);
|
||||
writeUpdateState({
|
||||
deployed_branch: entry.from_source_branch,
|
||||
deployed_commit: entry.from_commit || null,
|
||||
deployed_version: entry.from_version || null,
|
||||
previous_deployed_branch: entry.source_branch || null,
|
||||
previous_deployed_commit: entry.target_commit || null,
|
||||
last_update_at: new Date().toISOString(),
|
||||
last_update_status: "complete",
|
||||
last_update_stage: "reverted",
|
||||
last_error: null,
|
||||
last_snapshot_id: entry.id,
|
||||
last_target_kind: "core",
|
||||
last_target_version: entry.from_version || null,
|
||||
last_deployment_method: "snapshot_revert"
|
||||
});
|
||||
}
|
||||
return { status: "reverted", restart_required: true, snapshot: entry };
|
||||
});
|
||||
}
|
||||
@ -358,6 +553,7 @@ function disablePluginForRecovery(pluginId, publish) {
|
||||
|
||||
module.exports = {
|
||||
applyCoreUpdate,
|
||||
deployCoreBranch,
|
||||
applyPluginUpdateFromRepo,
|
||||
targetForRequestedVersion,
|
||||
revertCoreSnapshot,
|
||||
|
||||
@ -128,9 +128,27 @@ async function createSnapshot({ type, pluginId, metadata = {} }) {
|
||||
|
||||
let pluginExisted = false;
|
||||
let pluginZip = null;
|
||||
let bundledPluginsZip = null;
|
||||
let bundledPluginIds = [];
|
||||
if (type === "bot") {
|
||||
const coreZip = path.join(snapshotPath, "core.zip");
|
||||
zipCore(coreZip);
|
||||
if (metadata.include_plugin_code) {
|
||||
const pluginsDir = path.join(repoRoot, "plugins");
|
||||
if (fs.existsSync(pluginsDir)) {
|
||||
bundledPluginIds = fs.readdirSync(pluginsDir, { withFileTypes: true })
|
||||
.filter((entry) => entry.isDirectory())
|
||||
.map((entry) => entry.name)
|
||||
.sort();
|
||||
const ignore = new Set(["node_modules"]);
|
||||
for (const id of bundledPluginIds) {
|
||||
ignore.add(`${id}/data`);
|
||||
ignore.add(`${id}/node_modules`);
|
||||
}
|
||||
bundledPluginsZip = path.join(snapshotPath, "plugins.zip");
|
||||
zipFolder(pluginsDir, bundledPluginsZip, { base: pluginsDir, ignore });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (type === "plugin" && pluginId) {
|
||||
@ -145,7 +163,7 @@ async function createSnapshot({ type, pluginId, metadata = {} }) {
|
||||
}
|
||||
}
|
||||
|
||||
return { id, type, pluginId, pluginExisted, pluginZip, databaseZip, snapshotPath, metadata };
|
||||
return { id, type, pluginId, pluginExisted, pluginZip, bundledPluginsZip, bundledPluginIds, databaseZip, snapshotPath, metadata };
|
||||
}
|
||||
|
||||
function finalizeSnapshot(snapshot) {
|
||||
@ -162,7 +180,13 @@ function finalizeSnapshot(snapshot) {
|
||||
target_id: snapshot.metadata?.target_id || snapshot.pluginId || null,
|
||||
from_version: snapshot.metadata?.from_version || null,
|
||||
to_version: snapshot.metadata?.to_version || null,
|
||||
from_source_branch: snapshot.metadata?.from_source_branch || null,
|
||||
from_commit: snapshot.metadata?.from_commit || null,
|
||||
source_branch: snapshot.metadata?.source_branch || null,
|
||||
target_commit: snapshot.metadata?.target_commit || null,
|
||||
includes_plugin_code: Boolean(snapshot.bundledPluginsZip),
|
||||
plugin_ids: snapshot.bundledPluginIds || [],
|
||||
target_plugin_ids: Array.isArray(snapshot.metadata?.target_plugin_ids) ? snapshot.metadata.target_plugin_ids : [],
|
||||
update_method: snapshot.metadata?.update_method || null,
|
||||
rollback_safe: snapshot.metadata?.rollback_safe !== false,
|
||||
recovery_marker_id: snapshot.metadata?.recovery_marker_id || null,
|
||||
@ -996,6 +1020,25 @@ function restoreSnapshot(id, options = {}) {
|
||||
} finally {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
if (entry.includes_plugin_code) {
|
||||
const pluginsZip = path.join(entry.path, "plugins.zip");
|
||||
if (!fs.existsSync(pluginsZip)) throw new Error("Snapshot bundled-plugin archive missing.");
|
||||
const pluginsTempDir = fs.mkdtempSync(path.join(os.tmpdir(), "lumi-plugins-restore-"));
|
||||
try {
|
||||
extractZip(pluginsZip, pluginsTempDir);
|
||||
for (const pluginId of entry.plugin_ids || []) {
|
||||
const pluginRoot = path.join(pluginsTempDir, pluginId);
|
||||
if (fs.existsSync(pluginRoot)) applyPluginFiles(pluginRoot, pluginId, { preserveData: true });
|
||||
}
|
||||
for (const pluginId of entry.target_plugin_ids || []) {
|
||||
if (!(entry.plugin_ids || []).includes(pluginId)) {
|
||||
resetPluginCode(path.join(repoRoot, "plugins", pluginId));
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
fs.rmSync(pluginsTempDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (entry.type === "plugin") {
|
||||
|
||||
@ -170,6 +170,52 @@ function normalizeRepositoryRef(value) {
|
||||
return reference.replace(/^origin\//, "");
|
||||
}
|
||||
|
||||
function normalizeDeploymentBranch(value) {
|
||||
const reference = normalizeRepositoryRef(value).replace(/^refs\/heads\//, "");
|
||||
if (reference === "main") return reference;
|
||||
if (/^experimental-[A-Za-z0-9][A-Za-z0-9._\/-]*$/.test(reference)) return reference;
|
||||
throw new Error("Choose main or an experimental-* branch published by the configured repository.");
|
||||
}
|
||||
|
||||
function parseDeploymentBranches(output) {
|
||||
const branches = new Map();
|
||||
for (const line of String(output || "").split(/\r?\n/)) {
|
||||
const [commit = "", ref = ""] = line.trim().split(/\s+/);
|
||||
if (!/^[a-f0-9]{40,64}$/i.test(commit) || !ref.startsWith("refs/heads/")) continue;
|
||||
const name = ref.slice("refs/heads/".length);
|
||||
try {
|
||||
const normalized = normalizeDeploymentBranch(name);
|
||||
branches.set(normalized, {
|
||||
name: normalized,
|
||||
commit,
|
||||
channel: normalized === "main" ? "stable" : "experimental"
|
||||
});
|
||||
} catch {
|
||||
// Only stable main and explicitly named experimental branches are deployable.
|
||||
}
|
||||
}
|
||||
return Array.from(branches.values()).sort((left, right) => {
|
||||
if (left.name === "main") return -1;
|
||||
if (right.name === "main") return 1;
|
||||
return right.name.localeCompare(left.name);
|
||||
});
|
||||
}
|
||||
|
||||
function listDeploymentBranches(remote) {
|
||||
const repository = resolveRepositoryRemote(remote);
|
||||
const output = runGit([
|
||||
"ls-remote",
|
||||
"--heads",
|
||||
repository,
|
||||
"refs/heads/main",
|
||||
"refs/heads/experimental-*"
|
||||
], { timeout: 60000 });
|
||||
return {
|
||||
repository,
|
||||
branches: parseDeploymentBranches(output)
|
||||
};
|
||||
}
|
||||
|
||||
function managedTargetForRef(reference) {
|
||||
if (reference.startsWith("refs/tags/")) return reference;
|
||||
if (reference.startsWith("refs/heads/")) return `refs/remotes/origin/${reference.slice("refs/heads/".length)}`;
|
||||
@ -275,6 +321,9 @@ module.exports = {
|
||||
isGitRepository,
|
||||
normalizeRepositoryTarget,
|
||||
normalizeRepositoryRef,
|
||||
normalizeDeploymentBranch,
|
||||
parseDeploymentBranches,
|
||||
listDeploymentBranches,
|
||||
resolveRepositoryRemote,
|
||||
resolveSourceBranch,
|
||||
migrateZipEraLayout,
|
||||
|
||||
@ -1,10 +1,13 @@
|
||||
const { getUpdateStatus } = require("./update-index");
|
||||
const { applyCoreUpdate } = require("./repo-update");
|
||||
const { applyCoreUpdate, deployCoreBranch } = require("./repo-update");
|
||||
const {
|
||||
normalizeRepositoryTarget,
|
||||
readUpdateState,
|
||||
resolveSourceBranch
|
||||
} = require("./update-repository");
|
||||
|
||||
let restartHandler = null;
|
||||
|
||||
function normalizeUpdateSource(branch) {
|
||||
const requested = String(branch || "main").trim();
|
||||
if (requested === "main" || requested === "stable") return "stable";
|
||||
@ -30,14 +33,58 @@ function resolveUpdateBranch(remote, branch) {
|
||||
return resolveSourceBranch(normalizeRepositoryTarget(remote), normalizeUpdateSource(branch));
|
||||
}
|
||||
|
||||
function requestRestart() {
|
||||
setTimeout(() => process.exit(10), 500);
|
||||
function pendingDeploymentBranch(state, configuredBranch, localVersion) {
|
||||
if (String(configuredBranch || "") !== "experimental") return null;
|
||||
const targetBranch = String(state.branch || "");
|
||||
const confirmedCoreUpdate =
|
||||
state.last_update_status === "complete" &&
|
||||
state.last_target_kind === "core" &&
|
||||
state.last_target_version === localVersion &&
|
||||
Boolean(state.last_snapshot_id);
|
||||
if (
|
||||
state.deployed_branch === targetBranch ||
|
||||
!confirmedCoreUpdate ||
|
||||
!targetBranch.startsWith("experimental-")
|
||||
) return null;
|
||||
return targetBranch;
|
||||
}
|
||||
|
||||
async function completePendingBranchDeployment(remote, configuredBranch) {
|
||||
const state = readUpdateState();
|
||||
const localVersion = require("../../package.json").version;
|
||||
const targetBranch = pendingDeploymentBranch(state, configuredBranch, localVersion);
|
||||
if (!targetBranch) return null;
|
||||
return deployCoreBranch({ branch: targetBranch, remote });
|
||||
}
|
||||
|
||||
function registerRestartHandler(handler) {
|
||||
restartHandler = typeof handler === "function" ? handler : null;
|
||||
return () => {
|
||||
if (restartHandler === handler) restartHandler = null;
|
||||
};
|
||||
}
|
||||
|
||||
function requestRestart(options = {}) {
|
||||
const delayMs = Math.max(0, Number(options.delayMs ?? 500));
|
||||
setTimeout(() => {
|
||||
if (!restartHandler) {
|
||||
process.exit(10);
|
||||
return;
|
||||
}
|
||||
Promise.resolve(restartHandler(10)).catch((error) => {
|
||||
console.error("Graceful restart failed; forcing wrapper restart.", error);
|
||||
process.exit(10);
|
||||
});
|
||||
}, delayMs);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
checkForUpdates,
|
||||
completePendingBranchDeployment,
|
||||
pullUpdates,
|
||||
normalizeRepositoryTarget,
|
||||
pendingDeploymentBranch,
|
||||
resolveUpdateBranch,
|
||||
registerRestartHandler,
|
||||
requestRestart
|
||||
};
|
||||
|
||||
@ -2,41 +2,111 @@
|
||||
const body = document.body;
|
||||
const media = window.matchMedia("(max-width: 900px)");
|
||||
const sidebarPreferenceKey = "lumi-sidebar-collapsed";
|
||||
const sidebar = document.getElementById("lumi-sidebar");
|
||||
const page = document.querySelector(".page");
|
||||
const sidebarToggles = Array.from(document.querySelectorAll("[data-sidebar-toggle]"));
|
||||
let sidebarOpener = null;
|
||||
|
||||
const sidebarFocusable = () => Array.from(sidebar?.querySelectorAll("a[href], button:not([disabled]), summary, [tabindex]:not([tabindex='-1'])") || [])
|
||||
.filter((item) => !item.hidden && item.offsetParent !== null);
|
||||
|
||||
const updateSidebarState = () => {
|
||||
const open = body.classList.contains("sidebar-open");
|
||||
const collapsed = body.classList.contains("sidebar-collapsed");
|
||||
sidebarToggles.forEach((button) => {
|
||||
const expanded = media.matches ? open : !collapsed;
|
||||
button.setAttribute("aria-expanded", expanded ? "true" : "false");
|
||||
button.setAttribute("aria-label", media.matches
|
||||
? `${open ? "Close" : "Open"} navigation`
|
||||
: `${collapsed ? "Expand" : "Collapse"} navigation`);
|
||||
});
|
||||
};
|
||||
|
||||
const closeSidebar = ({ restoreFocus = true } = {}) => {
|
||||
if (!body.classList.contains("sidebar-open")) return;
|
||||
body.classList.remove("sidebar-open");
|
||||
if (page) page.inert = false;
|
||||
if (sidebar && media.matches) {
|
||||
sidebar.inert = true;
|
||||
sidebar.setAttribute("aria-hidden", "true");
|
||||
}
|
||||
sidebar?.removeAttribute("role");
|
||||
sidebar?.removeAttribute("aria-modal");
|
||||
updateSidebarState();
|
||||
if (restoreFocus && sidebarOpener?.isConnected) sidebarOpener.focus({ preventScroll: true });
|
||||
sidebarOpener = null;
|
||||
};
|
||||
|
||||
const openSidebar = (opener) => {
|
||||
if (!sidebar) return;
|
||||
sidebarOpener = opener;
|
||||
sidebar.inert = false;
|
||||
sidebar.removeAttribute("aria-hidden");
|
||||
body.classList.add("sidebar-open");
|
||||
if (page) page.inert = true;
|
||||
sidebar.setAttribute("role", "dialog");
|
||||
sidebar.setAttribute("aria-modal", "true");
|
||||
updateSidebarState();
|
||||
window.setTimeout(() => (sidebarFocusable()[0] || sidebar).focus({ preventScroll: true }), 0);
|
||||
};
|
||||
|
||||
if (!media.matches && window.localStorage.getItem(sidebarPreferenceKey) === "true") {
|
||||
body.classList.add("sidebar-collapsed");
|
||||
}
|
||||
|
||||
document.querySelectorAll("[data-sidebar-toggle]").forEach((button) => {
|
||||
sidebarToggles.forEach((button) => {
|
||||
button.addEventListener("click", () => {
|
||||
if (media.matches) {
|
||||
body.classList.toggle("sidebar-open");
|
||||
if (body.classList.contains("sidebar-open")) closeSidebar();
|
||||
else openSidebar(button);
|
||||
} else {
|
||||
body.classList.toggle("sidebar-collapsed");
|
||||
window.localStorage.setItem(
|
||||
sidebarPreferenceKey,
|
||||
body.classList.contains("sidebar-collapsed") ? "true" : "false"
|
||||
);
|
||||
updateSidebarState();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
document.querySelector("[data-sidebar-dismiss]")?.addEventListener("click", () => {
|
||||
body.classList.remove("sidebar-open");
|
||||
closeSidebar();
|
||||
});
|
||||
|
||||
document.querySelectorAll("[data-expand-text]").forEach((button) => {
|
||||
document.addEventListener("keydown", (event) => {
|
||||
if (!media.matches || !body.classList.contains("sidebar-open")) return;
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
closeSidebar();
|
||||
return;
|
||||
}
|
||||
if (event.key !== "Tab") return;
|
||||
const items = sidebarFocusable();
|
||||
if (!items.length) return;
|
||||
const first = items[0];
|
||||
const last = items[items.length - 1];
|
||||
if (event.shiftKey && document.activeElement === first) {
|
||||
event.preventDefault();
|
||||
last.focus();
|
||||
} else if (!event.shiftKey && document.activeElement === last) {
|
||||
event.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener("click", (event) => {
|
||||
const button = event.target.closest("[data-expand-text]");
|
||||
if (!button) return;
|
||||
const target = document.getElementById(button.dataset.expandText || "");
|
||||
if (!target) return;
|
||||
button.addEventListener("click", () => {
|
||||
const expanded = button.getAttribute("aria-expanded") !== "true";
|
||||
target.classList.toggle("is-collapsed", !expanded);
|
||||
target.classList.toggle("is-expanded", expanded);
|
||||
button.setAttribute("aria-expanded", expanded ? "true" : "false");
|
||||
button.textContent = expanded
|
||||
? button.dataset.expandedLabel || "Read less"
|
||||
: button.dataset.collapsedLabel || "Read more";
|
||||
});
|
||||
const expanded = button.getAttribute("aria-expanded") !== "true";
|
||||
target.classList.toggle("is-collapsed", !expanded);
|
||||
target.classList.toggle("is-expanded", expanded);
|
||||
button.setAttribute("aria-expanded", expanded ? "true" : "false");
|
||||
button.textContent = expanded
|
||||
? button.dataset.expandedLabel || "Read less"
|
||||
: button.dataset.collapsedLabel || "Read more";
|
||||
});
|
||||
|
||||
document.querySelectorAll("[data-command-content-toggle]").forEach((controls) => {
|
||||
@ -64,7 +134,7 @@
|
||||
document.querySelectorAll(".nav-link").forEach((link) => {
|
||||
link.addEventListener("click", () => {
|
||||
if (body.classList.contains("sidebar-open")) {
|
||||
body.classList.remove("sidebar-open");
|
||||
closeSidebar({ restoreFocus: false });
|
||||
}
|
||||
});
|
||||
});
|
||||
@ -177,13 +247,31 @@
|
||||
});
|
||||
|
||||
media.addEventListener?.("change", () => {
|
||||
body.classList.remove("sidebar-open");
|
||||
closeSidebar({ restoreFocus: false });
|
||||
if (media.matches) {
|
||||
body.classList.remove("sidebar-collapsed");
|
||||
if (sidebar) {
|
||||
sidebar.inert = true;
|
||||
sidebar.setAttribute("aria-hidden", "true");
|
||||
}
|
||||
} else if (window.localStorage.getItem(sidebarPreferenceKey) === "true") {
|
||||
if (sidebar) {
|
||||
sidebar.inert = false;
|
||||
sidebar.removeAttribute("aria-hidden");
|
||||
}
|
||||
body.classList.add("sidebar-collapsed");
|
||||
} else if (sidebar) {
|
||||
sidebar.inert = false;
|
||||
sidebar.removeAttribute("aria-hidden");
|
||||
}
|
||||
updateSidebarState();
|
||||
});
|
||||
if (sidebar) {
|
||||
sidebar.inert = media.matches;
|
||||
if (media.matches) sidebar.setAttribute("aria-hidden", "true");
|
||||
else sidebar.removeAttribute("aria-hidden");
|
||||
}
|
||||
updateSidebarState();
|
||||
|
||||
const placeholderOptionCache = new Map();
|
||||
const placeholderFetchPromises = new Map();
|
||||
@ -923,13 +1011,25 @@
|
||||
|
||||
const headers = table.querySelectorAll("th[data-sort]");
|
||||
headers.forEach((header) => {
|
||||
header.addEventListener("click", () => {
|
||||
header.setAttribute("aria-sort", "none");
|
||||
const sortControl = header.querySelector("[data-table-sort-control]") || header;
|
||||
if (sortControl === header) {
|
||||
if (!header.hasAttribute("tabindex")) header.tabIndex = 0;
|
||||
if (!header.hasAttribute("role")) header.setAttribute("role", "button");
|
||||
header.addEventListener("keydown", (event) => {
|
||||
if (event.key !== "Enter" && event.key !== " ") return;
|
||||
event.preventDefault();
|
||||
header.click();
|
||||
});
|
||||
}
|
||||
sortControl.addEventListener("click", () => {
|
||||
const key = header.dataset.sort;
|
||||
const currentKey = table.dataset.sortKey;
|
||||
const currentDir = table.dataset.sortDir || "asc";
|
||||
const nextDir = currentKey === key && currentDir === "asc" ? "desc" : "asc";
|
||||
table.dataset.sortKey = key;
|
||||
table.dataset.sortDir = nextDir;
|
||||
headers.forEach((item) => item.setAttribute("aria-sort", item === header ? (nextDir === "asc" ? "ascending" : "descending") : "none"));
|
||||
|
||||
const compare = (a, b) => {
|
||||
const aValue = (a.dataset[key] || "").toString();
|
||||
@ -1556,122 +1656,6 @@
|
||||
}
|
||||
});
|
||||
|
||||
const updateLog = document.querySelector("[data-update-progress-log]");
|
||||
const appendUpdateLog = (message, level = "info", form = null) => {
|
||||
if (updateLog) {
|
||||
const row = document.createElement("div");
|
||||
row.className = `update-progress-entry ${level}`;
|
||||
row.textContent = message;
|
||||
updateLog.prepend(row);
|
||||
}
|
||||
const resultScope = form?.closest(".plugin-update-row, [data-update-target='core'], .card");
|
||||
const inlineResult = resultScope?.querySelector("[data-update-inline-result]");
|
||||
if (inlineResult) {
|
||||
inlineResult.className = `hint ${level === "danger" ? "status-danger" : level === "success" ? "status-success" : ""}`.trim();
|
||||
inlineResult.textContent = message;
|
||||
}
|
||||
};
|
||||
if (updateLog) {
|
||||
try {
|
||||
const source = new EventSource("/admin/updates/events");
|
||||
[
|
||||
"update:queued",
|
||||
"update:checking",
|
||||
"update:metadata",
|
||||
"update:snapshot",
|
||||
"update:recovery_marker",
|
||||
"update:download",
|
||||
"update:apply",
|
||||
"update:verify",
|
||||
"update:restart_required",
|
||||
"update:complete",
|
||||
"update:failed",
|
||||
"update:revert",
|
||||
"recovery:plugin_disabled",
|
||||
"recovery:retry_startup"
|
||||
].forEach((eventName) => {
|
||||
source.addEventListener(eventName, (event) => {
|
||||
const payload = JSON.parse(event.data || "{}");
|
||||
const target = payload.plugin_id ? `plugin ${payload.plugin_id}` : payload.target || "recovery";
|
||||
appendUpdateLog(`${eventName.replace("update:", "").replace("recovery:", "recovery ")}: ${target}`, eventName.includes("failed") ? "danger" : "info");
|
||||
});
|
||||
});
|
||||
} catch {
|
||||
appendUpdateLog("Live update stream is unavailable.", "danger");
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
const updateForms = new Set([
|
||||
...document.querySelectorAll("form[data-update-action]"),
|
||||
...[...document.querySelectorAll("button[data-update-action]")].map((button) => button.form).filter(Boolean)
|
||||
]);
|
||||
updateForms.forEach((form) => {
|
||||
form.addEventListener("submit", async (event) => {
|
||||
const submitter = event.submitter || form.querySelector("button[type='submit']");
|
||||
if (!form.matches("[data-update-action]") && !submitter?.matches?.("[data-update-action]")) return;
|
||||
if (form.matches("[data-update-check-form]")) return;
|
||||
const action = effectiveFormAction(form, submitter);
|
||||
const actionPath = normalizeDestructiveAction(action);
|
||||
if (updateLog && actionPath.startsWith("/admin/updates/") && actionPath.endsWith("/check")) return;
|
||||
event.preventDefault();
|
||||
const requiresTimedConfirmation = destructivePattern.test(actionPath);
|
||||
const confirmMode = submitter?.dataset.confirmMode || form.dataset.confirmMode;
|
||||
const confirmTitle = submitter?.dataset.confirmTitle || form.dataset.confirmTitle;
|
||||
const confirmText = submitter?.dataset.confirmText || form.dataset.confirmText;
|
||||
const confirmLabel = submitter?.dataset.confirmLabel || form.dataset.confirmLabel;
|
||||
const confirmed = confirmMode === "modal" && !requiresTimedConfirmation
|
||||
? await window.LumiConfirm?.destructive?.({
|
||||
title: confirmTitle || "Confirm update action",
|
||||
text: confirmText || "This update action will change local files.",
|
||||
label: confirmLabel || submitter?.textContent || "Confirm"
|
||||
})
|
||||
: true;
|
||||
if (!confirmed) {
|
||||
window.LumiStateButton?.reset?.(submitter);
|
||||
return;
|
||||
}
|
||||
const originalText = submitter?.textContent;
|
||||
const isStateButton = submitter?.matches?.("[data-lumi-state-button]");
|
||||
if (isStateButton) {
|
||||
window.LumiStateButton?.setState?.(submitter, "loading", { busy: true });
|
||||
} else if (submitter) {
|
||||
submitter.disabled = true;
|
||||
submitter.textContent = "Working...";
|
||||
}
|
||||
appendUpdateLog(`Started ${submitter?.textContent?.trim() || "update action"}.`, "info", form);
|
||||
try {
|
||||
const requestOptions = {
|
||||
method: effectiveFormMethod(form, submitter) || "POST",
|
||||
body: new FormData(form),
|
||||
headers: { Accept: "application/json" },
|
||||
credentials: "same-origin"
|
||||
};
|
||||
const response = await fetch(action, requestOptions);
|
||||
const result = await response.json();
|
||||
if (!response.ok || result.ok === false) throw new Error(result.error || "Update action failed.");
|
||||
if (isStateButton) window.LumiStateButton?.success?.(submitter);
|
||||
else if (submitter) submitter.textContent = "Done";
|
||||
appendUpdateLog(result.message || "Update action completed.", "success", form);
|
||||
if (result.refresh_after_ms) {
|
||||
appendUpdateLog(`Lumi will refresh in ${Math.round(result.refresh_after_ms / 1000)} seconds.`, "success", form);
|
||||
window.setTimeout(() => window.location.reload(), Number(result.refresh_after_ms));
|
||||
}
|
||||
} catch (error) {
|
||||
if (isStateButton) window.LumiStateButton?.error?.(submitter);
|
||||
else if (submitter) submitter.textContent = "Update failed";
|
||||
appendUpdateLog(error.message, "danger", form);
|
||||
} finally {
|
||||
if (!isStateButton && submitter) {
|
||||
window.setTimeout(() => {
|
||||
submitter.disabled = false;
|
||||
submitter.textContent = originalText;
|
||||
}, 2500);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const copyText = async (text) => {
|
||||
if (navigator.clipboard?.writeText && window.isSecureContext) {
|
||||
await navigator.clipboard.writeText(text);
|
||||
|
||||
161
src/web/public/command-management.js
Normal file
161
src/web/public/command-management.js
Normal file
@ -0,0 +1,161 @@
|
||||
(() => {
|
||||
if (!window.LumiPage) return;
|
||||
|
||||
window.LumiPage.register("command-management", {
|
||||
selector: "[data-command-management]",
|
||||
mount(root, { signal }) {
|
||||
const forms = () => Array.from(root.querySelectorAll(".command-form"));
|
||||
let refreshController = null;
|
||||
|
||||
const toggleFields = (form) => {
|
||||
const mode = form.querySelector(".js-command-mode")?.value || "plain";
|
||||
const response = form.querySelector(".js-field-response")?.closest(".field");
|
||||
const code = form.querySelector(".js-field-code");
|
||||
const language = form.querySelector(".js-field-language");
|
||||
const random = form.querySelector(".js-field-random");
|
||||
const conditional = form.querySelector(".js-field-conditional");
|
||||
if (response) response.hidden = !["plain", "conditional"].includes(mode);
|
||||
if (code) code.hidden = mode !== "advanced";
|
||||
if (language) language.hidden = mode !== "advanced";
|
||||
if (random) random.hidden = mode !== "random";
|
||||
if (conditional) conditional.hidden = mode !== "conditional";
|
||||
};
|
||||
|
||||
const toggleRngFields = (form) => {
|
||||
const range = form.querySelector("[data-rng-range]");
|
||||
if (range) range.hidden = form.querySelector(".js-rng-enabled")?.checked !== true;
|
||||
};
|
||||
|
||||
const removeButton = (kind) => {
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.className = "button subtle";
|
||||
button.dataset[kind] = "";
|
||||
button.textContent = "Remove";
|
||||
return button;
|
||||
};
|
||||
|
||||
const addConditionalReply = (form) => {
|
||||
const list = form.querySelector("[data-conditional-replies]");
|
||||
if (!list) return;
|
||||
const row = document.createElement("div");
|
||||
row.className = "command-random-row command-conditional-row";
|
||||
row.dataset.conditionalReplyRow = "";
|
||||
const keyword = document.createElement("input");
|
||||
keyword.name = "conditional_keyword";
|
||||
keyword.placeholder = "keyword, alias";
|
||||
keyword.setAttribute("aria-label", "Keywords, separated with commas");
|
||||
const response = document.createElement("input");
|
||||
response.name = "conditional_response";
|
||||
response.placeholder = "The reply for this keyword";
|
||||
response.setAttribute("aria-label", "Reply");
|
||||
response.dataset.placeholderField = "core.custom_commands.static_response";
|
||||
response.dataset.placeholderOutputAudience = "user";
|
||||
row.append(keyword, response, removeButton("conditionalRemove"));
|
||||
list.append(row);
|
||||
keyword.focus();
|
||||
};
|
||||
|
||||
const addRandomReply = (form) => {
|
||||
const list = form.querySelector("[data-random-replies]");
|
||||
if (!list) return;
|
||||
const row = document.createElement("div");
|
||||
row.className = "command-random-row";
|
||||
row.dataset.randomReplyRow = "";
|
||||
const text = document.createElement("input");
|
||||
text.name = "random_text";
|
||||
text.placeholder = "Message to send";
|
||||
text.dataset.placeholderField = "core.custom_commands.static_response";
|
||||
text.dataset.placeholderOutputAudience = "user";
|
||||
const weight = document.createElement("input");
|
||||
weight.name = "random_weight";
|
||||
weight.type = "number";
|
||||
weight.min = "1";
|
||||
weight.max = "999";
|
||||
weight.step = "1";
|
||||
weight.value = "1";
|
||||
weight.setAttribute("aria-label", "Selection weight");
|
||||
const condition = document.createElement("input");
|
||||
condition.name = "random_condition";
|
||||
condition.placeholder = "Optional: <20";
|
||||
condition.setAttribute("aria-label", "RNG condition");
|
||||
row.append(text, weight, condition, removeButton("randomRemove"));
|
||||
list.append(row);
|
||||
text.focus();
|
||||
};
|
||||
|
||||
const initializeForms = (scope = root) => {
|
||||
scope.querySelectorAll(".command-form").forEach((form) => {
|
||||
toggleFields(form);
|
||||
toggleRngFields(form);
|
||||
});
|
||||
};
|
||||
initializeForms();
|
||||
|
||||
const refreshCommandList = async (message) => {
|
||||
const current = root.querySelector("[data-command-list-section]");
|
||||
if (!current) return;
|
||||
refreshController?.abort();
|
||||
refreshController = new AbortController();
|
||||
current.setAttribute("aria-busy", "true");
|
||||
try {
|
||||
const response = await fetch("/admin/commands", {
|
||||
cache: "no-store",
|
||||
headers: { Accept: "text/html", "X-Lumi-Partial": "command-list" },
|
||||
signal: refreshController.signal
|
||||
});
|
||||
if (!response.ok || response.redirected) throw new Error("The command list could not be refreshed.");
|
||||
const html = await response.text();
|
||||
const nextDocument = new DOMParser().parseFromString(html, "text/html");
|
||||
const next = nextDocument.querySelector("[data-command-list-section]");
|
||||
if (!next) throw new Error("The refreshed command list was incomplete.");
|
||||
current.replaceWith(next);
|
||||
const status = next.querySelector("[data-command-management-status]");
|
||||
if (status) status.textContent = message || "Command list updated.";
|
||||
initializeForms(next);
|
||||
window.LumiInteractions?.init?.(next);
|
||||
} catch (error) {
|
||||
if (error.name === "AbortError") return;
|
||||
current.removeAttribute("aria-busy");
|
||||
const status = current.querySelector("[data-command-management-status]");
|
||||
if (status) {
|
||||
status.setAttribute("role", "alert");
|
||||
status.textContent = `${message || "The command was saved"} The list could not refresh; reload this page to see the latest state.`;
|
||||
}
|
||||
}
|
||||
};
|
||||
signal.addEventListener("abort", () => refreshController?.abort(), { once: true });
|
||||
|
||||
root.addEventListener("change", (event) => {
|
||||
const form = event.target.closest(".command-form");
|
||||
if (!form) return;
|
||||
if (event.target.matches(".js-command-mode")) toggleFields(form);
|
||||
if (event.target.matches(".js-rng-enabled")) toggleRngFields(form);
|
||||
}, { signal });
|
||||
|
||||
root.addEventListener("click", (event) => {
|
||||
const form = event.target.closest(".command-form");
|
||||
if (!form) return;
|
||||
if (event.target.closest("[data-random-add]")) addRandomReply(form);
|
||||
else if (event.target.closest("[data-conditional-add]")) addConditionalReply(form);
|
||||
else event.target.closest("[data-random-remove]")?.closest("[data-random-reply-row]")?.remove();
|
||||
if (event.target.closest("[data-conditional-remove]")) {
|
||||
event.target.closest("[data-conditional-remove]").closest("[data-conditional-reply-row]")?.remove();
|
||||
}
|
||||
}, { signal });
|
||||
|
||||
root.addEventListener("lumi:form-saved", (event) => {
|
||||
const form = event.target;
|
||||
if (!(form instanceof HTMLFormElement)) return;
|
||||
if (form.matches("[data-command-create-form]")) {
|
||||
form.reset();
|
||||
toggleFields(form);
|
||||
toggleRngFields(form);
|
||||
}
|
||||
if (form.matches("[data-command-create-form], [data-command-list-action]")) {
|
||||
refreshCommandList(event.detail?.message);
|
||||
}
|
||||
}, { signal });
|
||||
}
|
||||
});
|
||||
})();
|
||||
189
src/web/public/command-policies.js
Normal file
189
src/web/public/command-policies.js
Normal file
@ -0,0 +1,189 @@
|
||||
(() => {
|
||||
if (!window.LumiPage) return;
|
||||
|
||||
window.LumiPage.register("command-policies", {
|
||||
selector: "[data-command-policies]",
|
||||
mount(root, { signal }) {
|
||||
const normalizeSearch = (value) => String(value || "").toLowerCase().replace(/[^a-z0-9]+/g, " ").trim();
|
||||
const editDistance = (left, right) => {
|
||||
const row = Array.from({ length: right.length + 1 }, (_, index) => index);
|
||||
for (let i = 1; i <= left.length; i += 1) {
|
||||
let diagonal = row[0];
|
||||
row[0] = i;
|
||||
for (let j = 1; j <= right.length; j += 1) {
|
||||
const above = row[j];
|
||||
row[j] = Math.min(row[j] + 1, row[j - 1] + 1, diagonal + (left[i - 1] === right[j - 1] ? 0 : 1));
|
||||
diagonal = above;
|
||||
}
|
||||
}
|
||||
return row[right.length];
|
||||
};
|
||||
const searchScore = (query, text) => {
|
||||
const desired = normalizeSearch(query);
|
||||
const candidate = normalizeSearch(text);
|
||||
if (!desired || candidate.includes(desired)) return 1;
|
||||
const words = candidate.split(" ").filter(Boolean);
|
||||
if (words.some((word) => word.startsWith(desired))) return 0.92;
|
||||
return words.reduce((best, word) => Math.max(best, 1 - editDistance(desired, word) / Math.max(desired.length, word.length, 1)), 0);
|
||||
};
|
||||
const refreshSearchScope = (scope) => {
|
||||
const query = scope.querySelector("[data-policy-search]")?.value || "";
|
||||
const setting = scope.querySelector("[data-policy-setting-filter]")?.value || "";
|
||||
const origin = scope.querySelector("[data-policy-origin-filter]")?.value || "";
|
||||
const items = Array.from(scope.querySelectorAll("[data-policy-item]"));
|
||||
const ranked = [];
|
||||
let shown = 0;
|
||||
items.forEach((item) => {
|
||||
const score = searchScore(query, item.dataset.search || "");
|
||||
const threshold = normalizeSearch(query).length <= 3 ? 0.82 : 0.62;
|
||||
const matchesSetting = !setting || (item.dataset.settings || "").split(" ").includes(setting);
|
||||
const matchesOrigin = !origin || item.dataset.origin === origin;
|
||||
item.hidden = !((!normalizeSearch(query) || score >= threshold) && matchesSetting && matchesOrigin);
|
||||
if (!item.hidden) shown += 1;
|
||||
if (matchesSetting && matchesOrigin && score >= threshold) ranked.push({ item, score });
|
||||
});
|
||||
const count = scope.querySelector("[data-policy-result-count]");
|
||||
if (count) count.textContent = `Showing ${shown} of ${items.length}`;
|
||||
const noResults = scope.querySelector("[data-policy-no-results]");
|
||||
if (noResults) noResults.hidden = shown !== 0;
|
||||
const suggestions = scope.querySelector("[data-policy-suggestions]");
|
||||
if (!suggestions) return;
|
||||
suggestions.replaceChildren();
|
||||
if (normalizeSearch(query)) {
|
||||
ranked.sort((left, right) => right.score - left.score).slice(0, 5).forEach(({ item }) => {
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.textContent = item.dataset.searchName || "Match";
|
||||
button.dataset.policySuggestion = item.dataset.searchName || "";
|
||||
suggestions.append(button);
|
||||
});
|
||||
}
|
||||
suggestions.hidden = !suggestions.children.length;
|
||||
};
|
||||
|
||||
const parsePolicyData = (value) => { try { return JSON.parse(value || "{}"); } catch { return {}; } };
|
||||
const hasPolicyCategory = (policy, name) => Object.prototype.hasOwnProperty.call(policy || {}, name);
|
||||
const roleLabel = (value = {}) => value.role === "subscriber"
|
||||
? (Number(value.subscriberTier) > 1 ? `Tier ${value.subscriberTier}+ subscribers` : "Subscribers / members")
|
||||
: ({ follower: "Followers", vip: "VIPs", mod: "Moderators", editor: "Editors", streamer: "Streamer only" })[value.role] || "Everyone";
|
||||
const ruleText = (name, value) => {
|
||||
if (name === "window") return `${value.uses} use${Number(value.uses) === 1 ? "" : "s"} per ${value.seconds} seconds, ${value.scope === "global" ? "shared by everyone" : "per user"}`;
|
||||
if (name === "perStream") return `${value.uses} use${Number(value.uses) === 1 ? "" : "s"} per stream, ${value.scope === "global" ? "shared by everyone" : "per user"}`;
|
||||
if (name === "requirement") return roleLabel(value);
|
||||
if (name === "cost") return `${value.amount} currency per successful run.`;
|
||||
return "";
|
||||
};
|
||||
const emptyRuleText = (name) => ({ window: "No time-window limit.", perStream: "No per-stream limit.", requirement: "Everyone may use it.", cost: "Free to use." })[name];
|
||||
const refreshInheritedSummaries = (editor) => {
|
||||
const builtIn = parsePolicyData(editor.dataset.builtInPolicy);
|
||||
const globalDefault = parsePolicyData(editor.dataset.defaultGroupPolicy);
|
||||
const selected = Array.from(editor.querySelectorAll("input[data-group-policy]:checked")).map((input) => ({
|
||||
name: input.dataset.groupName || "Group",
|
||||
policy: parsePolicyData(input.dataset.groupPolicy)
|
||||
}));
|
||||
for (const name of ["window", "perStream", "requirement", "cost"]) {
|
||||
const summary = editor.querySelector(`[data-policy-inherited="${name}"] strong`);
|
||||
if (!summary) continue;
|
||||
const positive = selected.filter((item) => hasPolicyCategory(item.policy, name) && item.policy[name] && item.policy[name] !== false)
|
||||
.map((item) => ({ source: item.name, value: item.policy[name] }));
|
||||
const defaultHas = hasPolicyCategory(globalDefault, name);
|
||||
if (defaultHas && globalDefault[name] && globalDefault[name] !== false) positive.push({ source: "Default group", value: globalDefault[name] });
|
||||
if (name === "cost" && positive.length) {
|
||||
const highest = positive.reduce((best, item) => Number(item.value.amount) > Number(best.value.amount) ? item : best);
|
||||
summary.textContent = `${highest.source}: ${ruleText(name, highest.value)}`;
|
||||
} else if (positive.length) summary.textContent = positive.map((item) => `${item.source}: ${ruleText(name, item.value)}`).join(" · ");
|
||||
else if (defaultHas && globalDefault[name] === false) summary.textContent = emptyRuleText(name);
|
||||
else if (builtIn[name] && builtIn[name] !== false) summary.textContent = `Built-in: ${ruleText(name, builtIn[name])}`;
|
||||
else summary.textContent = emptyRuleText(name);
|
||||
}
|
||||
};
|
||||
const updatePolicyCategory = (category) => {
|
||||
const mode = category.querySelector("[data-policy-mode]")?.value || "off";
|
||||
const values = category.querySelector("[data-policy-values]");
|
||||
const inherited = category.querySelector("[data-policy-inherited]");
|
||||
if (values) values.hidden = mode !== "custom";
|
||||
if (inherited) inherited.hidden = mode !== "inherit";
|
||||
const tier = category.querySelector("[data-subscriber-tier]");
|
||||
if (tier) tier.hidden = mode !== "custom" || category.querySelector("[data-requirement-role]")?.value !== "subscriber";
|
||||
};
|
||||
|
||||
root.querySelectorAll("[data-policy-search-scope]").forEach(refreshSearchScope);
|
||||
root.querySelectorAll("[data-command-policy-editor]").forEach(refreshInheritedSummaries);
|
||||
root.querySelectorAll("[data-policy-category]").forEach(updatePolicyCategory);
|
||||
|
||||
root.addEventListener("input", (event) => {
|
||||
const scope = event.target.closest("[data-policy-search-scope]");
|
||||
if (event.target.matches("[data-policy-search]")) refreshSearchScope(scope);
|
||||
const batch = event.target.closest("[data-command-policy-batch]");
|
||||
const state = batch?.querySelector("[data-command-save-state]");
|
||||
if (state) state.textContent = "Unsaved command changes.";
|
||||
}, { signal });
|
||||
root.addEventListener("change", (event) => {
|
||||
const scope = event.target.closest("[data-policy-search-scope]");
|
||||
if (scope && event.target.matches("[data-policy-setting-filter], [data-policy-origin-filter]")) refreshSearchScope(scope);
|
||||
const category = event.target.closest("[data-policy-category]");
|
||||
if (category && event.target.matches("[data-policy-mode], [data-requirement-role]")) updatePolicyCategory(category);
|
||||
const editor = event.target.closest("[data-command-policy-editor]");
|
||||
if (editor && event.target.matches("input[data-group-policy]")) refreshInheritedSummaries(editor);
|
||||
const batch = event.target.closest("[data-command-policy-batch]");
|
||||
const state = batch?.querySelector("[data-command-save-state]");
|
||||
if (state) state.textContent = "Unsaved command changes.";
|
||||
}, { signal });
|
||||
root.addEventListener("keydown", (event) => {
|
||||
if (event.key === "Escape" && event.target.matches("[data-policy-search]")) {
|
||||
const suggestions = event.target.closest("[data-policy-search-scope]")?.querySelector("[data-policy-suggestions]");
|
||||
if (suggestions) suggestions.hidden = true;
|
||||
}
|
||||
}, { signal });
|
||||
root.addEventListener("click", (event) => {
|
||||
const suggestion = event.target.closest("[data-policy-suggestion]");
|
||||
if (!suggestion) return;
|
||||
const scope = suggestion.closest("[data-policy-search-scope]");
|
||||
const input = scope?.querySelector("[data-policy-search]");
|
||||
if (!input) return;
|
||||
input.value = suggestion.dataset.policySuggestion;
|
||||
suggestion.parentElement.hidden = true;
|
||||
refreshSearchScope(scope);
|
||||
const item = Array.from(scope.querySelectorAll("[data-policy-item]")).find((candidate) => candidate.dataset.searchName === input.value);
|
||||
if (item) {
|
||||
item.open = true;
|
||||
item.scrollIntoView({ block: "nearest", behavior: "smooth" });
|
||||
}
|
||||
}, { signal });
|
||||
root.addEventListener("lumi:form-saved", (event) => {
|
||||
if (event.target.matches("[data-command-policy-batch]")) {
|
||||
const state = event.target.querySelector("[data-command-save-state]");
|
||||
if (state) state.textContent = event.detail?.message || "All displayed values are saved.";
|
||||
}
|
||||
if (event.target.matches("[data-command-group-form]") && event.detail?.data?.group) {
|
||||
const group = event.detail.data.group;
|
||||
const details = event.target.closest("[data-policy-item]");
|
||||
const title = details?.querySelector(":scope > summary strong");
|
||||
const description = details?.querySelector(":scope > summary .hint");
|
||||
if (title) title.textContent = group.name;
|
||||
if (description) description.textContent = group.description || "Reusable command rules";
|
||||
if (details) {
|
||||
details.dataset.searchName = group.name;
|
||||
details.dataset.search = `${group.name} ${group.description || ""}`.toLowerCase();
|
||||
details.dataset.settings = Object.keys(group.policy || {}).map((name) => ({ window: "rate", perStream: "stream", requirement: "access", cost: "cost" })[name]).filter(Boolean).join(" ");
|
||||
}
|
||||
if (group.is_default) {
|
||||
root.querySelectorAll("[data-command-policy-editor]").forEach((editor) => {
|
||||
editor.dataset.defaultGroupPolicy = JSON.stringify(group.policy || {});
|
||||
refreshInheritedSummaries(editor);
|
||||
});
|
||||
} else {
|
||||
root.querySelectorAll("input[data-group-policy]").forEach((input) => {
|
||||
if (input.value !== group.id) return;
|
||||
input.dataset.groupName = group.name;
|
||||
input.dataset.groupPolicy = JSON.stringify(group.policy || {});
|
||||
refreshInheritedSummaries(input.closest("[data-command-policy-editor]"));
|
||||
});
|
||||
}
|
||||
const scope = details?.closest("[data-policy-search-scope]");
|
||||
if (scope) refreshSearchScope(scope);
|
||||
}
|
||||
}, { signal });
|
||||
}
|
||||
});
|
||||
})();
|
||||
@ -1,3 +1,4 @@
|
||||
@layer features {
|
||||
.visually-hidden {
|
||||
position: absolute !important;
|
||||
width: 1px !important;
|
||||
@ -234,6 +235,12 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.resource-settings-panel > summary > span {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: var(--lumi-space-1);
|
||||
}
|
||||
|
||||
.resource-settings-panel[open] > summary {
|
||||
margin-bottom: var(--lumi-space-4);
|
||||
}
|
||||
@ -815,3 +822,4 @@
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -189,7 +189,15 @@
|
||||
const submitter = event.submitter;
|
||||
const button = submitter || form.querySelector("button[type='submit'], input[type='submit']");
|
||||
const action = effectiveAction(form, submitter);
|
||||
const headers = { Accept: "application/json", "X-Requested-With": "XMLHttpRequest" };
|
||||
const headers = {
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/x-www-form-urlencoded;charset=UTF-8",
|
||||
"X-Requested-With": "XMLHttpRequest"
|
||||
};
|
||||
const body = new URLSearchParams();
|
||||
for (const [name, value] of new FormData(form).entries()) {
|
||||
if (typeof value === "string") body.append(name, value);
|
||||
}
|
||||
let releasedCard = null;
|
||||
if (isTimedResourceAction(action)) {
|
||||
const token = await requestResourceConfirmation(form, action);
|
||||
@ -204,7 +212,7 @@
|
||||
try {
|
||||
const response = await fetch(action, {
|
||||
method: effectiveMethod(form, submitter),
|
||||
body: new FormData(form),
|
||||
body,
|
||||
headers,
|
||||
credentials: "same-origin"
|
||||
});
|
||||
@ -794,6 +802,7 @@
|
||||
const field = form.elements.namedItem(name);
|
||||
if (field) field.value = value;
|
||||
}
|
||||
setText(library, "[data-resource-settings-summary]", `${settings.limit_gib > 0 ? `${settings.limit_gib} GiB library limit` : "Unlimited library"} · ${settings.max_file_mib} MiB per file · ${settings.max_files} files per upload`);
|
||||
}
|
||||
|
||||
function setText(root, selector, value) {
|
||||
|
||||
@ -1,11 +1,12 @@
|
||||
@layer components {
|
||||
::selection {
|
||||
color: var(--lumi-button-text);
|
||||
background: var(--lumi-primary);
|
||||
}
|
||||
|
||||
:focus-visible {
|
||||
outline: 3px solid color-mix(in srgb, var(--lumi-focus) 78%, transparent);
|
||||
outline-offset: 3px;
|
||||
outline: var(--lumi-focus-size) solid color-mix(in srgb, var(--lumi-focus) 78%, transparent);
|
||||
outline-offset: var(--lumi-focus-offset);
|
||||
}
|
||||
|
||||
h1,
|
||||
@ -359,7 +360,10 @@ input[type="submit"].button {
|
||||
.button.disabled,
|
||||
button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.55;
|
||||
border-color: var(--lumi-border);
|
||||
background: var(--lumi-field-disabled-bg);
|
||||
color: var(--lumi-text-muted);
|
||||
opacity: 1;
|
||||
box-shadow: none;
|
||||
transform: none;
|
||||
}
|
||||
@ -437,6 +441,24 @@ button:disabled {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.table-sort-button {
|
||||
appearance: none;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
font-weight: inherit;
|
||||
text-align: start;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.table-sort-button:focus-visible {
|
||||
border-radius: var(--lumi-radius-sm);
|
||||
outline: 2px solid var(--lumi-focus);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
|
||||
.form-grid .field.button-group,
|
||||
.field.button-group {
|
||||
flex-direction: row;
|
||||
@ -681,17 +703,50 @@ textarea {
|
||||
input:hover,
|
||||
select:hover,
|
||||
textarea:hover {
|
||||
border-color: color-mix(in srgb, var(--lumi-primary) 35%, var(--lumi-input-border));
|
||||
border-color: var(--lumi-field-hover-border);
|
||||
}
|
||||
|
||||
input:focus,
|
||||
select:focus,
|
||||
textarea:focus {
|
||||
border-color: var(--lumi-focus);
|
||||
border-color: var(--lumi-field-focus-border);
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--lumi-focus) 22%, transparent);
|
||||
}
|
||||
|
||||
input[aria-invalid="true"],
|
||||
select[aria-invalid="true"],
|
||||
textarea[aria-invalid="true"] {
|
||||
border-color: var(--lumi-field-invalid-border);
|
||||
background: var(--lumi-field-invalid-bg);
|
||||
}
|
||||
|
||||
input:disabled,
|
||||
select:disabled,
|
||||
textarea:disabled {
|
||||
background: var(--lumi-field-disabled-bg);
|
||||
}
|
||||
|
||||
input:read-only,
|
||||
textarea:read-only {
|
||||
background: var(--lumi-field-readonly-bg);
|
||||
}
|
||||
|
||||
.field-error {
|
||||
display: block;
|
||||
color: var(--lumi-danger);
|
||||
font-size: var(--lumi-font-size-sm);
|
||||
font-weight: var(--lumi-weight-bold);
|
||||
}
|
||||
|
||||
.field-error::before {
|
||||
content: "! ";
|
||||
}
|
||||
|
||||
.lumi-form-error-summary {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
input[type="checkbox"],
|
||||
input[type="radio"] {
|
||||
width: 1.1rem;
|
||||
@ -739,7 +794,7 @@ input[type="color"] {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
background: var(--lumi-surface-subtle);
|
||||
background: var(--lumi-table-header-bg);
|
||||
color: var(--lumi-text-muted);
|
||||
font: 700 0.75rem/1.3 var(--lumi-font-display);
|
||||
letter-spacing: 0.06em;
|
||||
@ -751,9 +806,11 @@ input[type="color"] {
|
||||
}
|
||||
|
||||
.table tbody tr:hover {
|
||||
background: color-mix(in srgb, var(--lumi-primary) 5%, var(--lumi-surface));
|
||||
background: var(--lumi-table-row-hover);
|
||||
}
|
||||
|
||||
.table tbody tr[aria-selected="true"] { background: var(--lumi-table-row-selected); }
|
||||
|
||||
.table tbody tr:last-child td {
|
||||
border-bottom: 0;
|
||||
}
|
||||
@ -777,6 +834,13 @@ input[type="color"] {
|
||||
border-radius: var(--lumi-radius-pill);
|
||||
}
|
||||
|
||||
:where(.badge:not(.discord, .both, .twitch, .kick, .youtube, .success, .warning, .danger, .info, .muted)) {
|
||||
padding-inline: var(--lumi-space-2);
|
||||
border: 1px solid var(--lumi-border);
|
||||
background: var(--lumi-surface-subtle);
|
||||
color: var(--lumi-text-muted);
|
||||
}
|
||||
|
||||
.status-indicator::before {
|
||||
content: "";
|
||||
width: 0.5rem;
|
||||
@ -791,28 +855,28 @@ input[type="color"] {
|
||||
}
|
||||
|
||||
.badge.success {
|
||||
color: var(--lumi-success);
|
||||
color: var(--lumi-text);
|
||||
background: color-mix(in srgb, var(--lumi-success) 12%, var(--lumi-surface));
|
||||
border: 1px solid color-mix(in srgb, var(--lumi-success) 35%, var(--lumi-border));
|
||||
padding: 0 var(--lumi-space-2);
|
||||
}
|
||||
|
||||
.badge.warning {
|
||||
color: var(--lumi-warning);
|
||||
color: var(--lumi-text);
|
||||
background: color-mix(in srgb, var(--lumi-warning) 12%, var(--lumi-surface));
|
||||
border: 1px solid color-mix(in srgb, var(--lumi-warning) 35%, var(--lumi-border));
|
||||
padding: 0 var(--lumi-space-2);
|
||||
}
|
||||
|
||||
.badge.danger {
|
||||
color: var(--lumi-danger);
|
||||
color: var(--lumi-text);
|
||||
background: color-mix(in srgb, var(--lumi-danger) 12%, var(--lumi-surface));
|
||||
border: 1px solid color-mix(in srgb, var(--lumi-danger) 35%, var(--lumi-border));
|
||||
padding: 0 var(--lumi-space-2);
|
||||
}
|
||||
|
||||
.badge.info {
|
||||
color: var(--lumi-info);
|
||||
color: var(--lumi-text);
|
||||
background: color-mix(in srgb, var(--lumi-info) 12%, var(--lumi-surface));
|
||||
border: 1px solid color-mix(in srgb, var(--lumi-info) 35%, var(--lumi-border));
|
||||
padding: 0 var(--lumi-space-2);
|
||||
@ -885,7 +949,7 @@ input[type="color"] {
|
||||
position: fixed;
|
||||
top: var(--lumi-space-3);
|
||||
left: 50%;
|
||||
z-index: 80;
|
||||
z-index: var(--lumi-z-savebar);
|
||||
width: min(42rem, calc(100vw - 2rem));
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@ -901,25 +965,28 @@ input[type="color"] {
|
||||
transition: opacity var(--lumi-transition), transform var(--lumi-transition);
|
||||
}
|
||||
|
||||
.lumi-savebar[hidden] {
|
||||
display: none;
|
||||
.lumi-savebar-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--lumi-space-2);
|
||||
}
|
||||
|
||||
.lumi-savebar.is-hidden-by-scroll {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transform: translate(-50%, -110%);
|
||||
.lumi-savebar[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.lumi-savebar.has-error {
|
||||
border-color: var(--lumi-danger);
|
||||
}
|
||||
|
||||
.lumi-savebar.has-success { border-color: var(--lumi-success-border); }
|
||||
|
||||
.lumi-event-notices {
|
||||
position: fixed;
|
||||
right: var(--lumi-space-4);
|
||||
bottom: var(--lumi-space-4);
|
||||
z-index: 90;
|
||||
z-index: var(--lumi-z-notice);
|
||||
display: grid;
|
||||
gap: var(--lumi-space-2);
|
||||
width: min(24rem, calc(100vw - 2rem));
|
||||
@ -1619,7 +1686,8 @@ input[type="color"] {
|
||||
|
||||
.modal-backdrop {
|
||||
padding: var(--lumi-space-4);
|
||||
background: rgba(5, 10, 12, 0.62);
|
||||
z-index: var(--lumi-z-dialog);
|
||||
background: var(--lumi-dialog-scrim);
|
||||
backdrop-filter: blur(5px);
|
||||
}
|
||||
|
||||
@ -1860,7 +1928,10 @@ details > summary:focus-visible {
|
||||
|
||||
.empty-state,
|
||||
.loading-state,
|
||||
.error-state {
|
||||
.error-state,
|
||||
.partial-state,
|
||||
.offline-state,
|
||||
.permission-state {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
min-height: 10rem;
|
||||
@ -1872,6 +1943,11 @@ details > summary:focus-visible {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.error-state { border-color: var(--lumi-danger-border); background: var(--lumi-danger-bg); }
|
||||
.partial-state { border-color: var(--lumi-warning-border); background: var(--lumi-warning-bg); }
|
||||
.offline-state { border-color: var(--lumi-info-border); background: var(--lumi-info-bg); }
|
||||
.permission-state { border-style: solid; }
|
||||
|
||||
.loading-state::before {
|
||||
content: "";
|
||||
width: 1.6rem;
|
||||
@ -1927,7 +2003,114 @@ details > summary:focus-visible {
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.command-policy-grid,
|
||||
.command-policy-toolbar,
|
||||
.command-policy-command-toolbar {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.command-policy-savebar {
|
||||
position: static;
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.command-policy-savebar .button {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
[data-update-snapshot-table],
|
||||
[data-update-snapshot-table] tbody {
|
||||
display: block;
|
||||
}
|
||||
|
||||
[data-update-snapshot-table] {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
[data-update-snapshot-table] thead {
|
||||
display: none;
|
||||
}
|
||||
|
||||
[data-update-snapshot-table] tbody > tr {
|
||||
display: block;
|
||||
margin-block: var(--lumi-space-3);
|
||||
padding: var(--lumi-space-3);
|
||||
border: 1px solid var(--lumi-border);
|
||||
border-radius: var(--lumi-radius-md);
|
||||
background: var(--lumi-surface);
|
||||
}
|
||||
|
||||
[data-update-snapshot-table] tbody > tr > td {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(5rem, 0.35fr) minmax(0, 1fr);
|
||||
gap: var(--lumi-space-3);
|
||||
min-width: 0;
|
||||
padding: var(--lumi-space-2) 0;
|
||||
border: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
[data-update-snapshot-table] tbody > tr > td::before {
|
||||
content: attr(data-label);
|
||||
color: var(--lumi-text-muted);
|
||||
font-size: var(--lumi-font-size-sm);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
[data-command-list-section] .table-wrap {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
[data-command-list-section] table,
|
||||
[data-command-list-section] tbody {
|
||||
display: block;
|
||||
}
|
||||
|
||||
[data-command-list-section] table {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
[data-command-list-section] thead {
|
||||
display: none;
|
||||
}
|
||||
|
||||
[data-command-list-section] tbody > tr:not(.edit-row) {
|
||||
display: block;
|
||||
margin-block: var(--lumi-space-3);
|
||||
padding: var(--lumi-space-3);
|
||||
border: 1px solid var(--lumi-border);
|
||||
border-radius: var(--lumi-radius-md);
|
||||
background: var(--lumi-surface);
|
||||
}
|
||||
|
||||
[data-command-list-section] tbody > tr:not(.edit-row) > td {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(5rem, 0.35fr) minmax(0, 1fr);
|
||||
gap: var(--lumi-space-3);
|
||||
min-width: 0;
|
||||
padding: var(--lumi-space-2) 0;
|
||||
border: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
[data-command-list-section] tbody > tr:not(.edit-row) > td::before {
|
||||
content: attr(data-label);
|
||||
color: var(--lumi-text-muted);
|
||||
font-size: var(--lumi-font-size-sm);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
[data-command-list-section] .table td .command-actions,
|
||||
[data-command-list-section] .table td .table-actions {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: calc(clamp(1.45rem, 8vw, 2rem) * var(--lumi-heading-scale));
|
||||
}
|
||||
@ -2002,24 +2185,11 @@ details > summary:focus-visible {
|
||||
}
|
||||
|
||||
.command-random-row,
|
||||
.command-policy-grid,
|
||||
.command-policy-toolbar,
|
||||
.command-policy-command-toolbar,
|
||||
.command-rng-range,
|
||||
.custom-placeholder-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.command-policy-savebar {
|
||||
position: static;
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.command-policy-savebar .button {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.homepage-hero-media {
|
||||
min-height: 12rem;
|
||||
}
|
||||
@ -2077,3 +2247,18 @@ details > summary:focus-visible {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
.visually-hidden {
|
||||
position: absolute !important;
|
||||
width: 1px !important;
|
||||
height: 1px !important;
|
||||
padding: 0 !important;
|
||||
margin: -1px !important;
|
||||
overflow: hidden !important;
|
||||
clip: rect(0 0 0 0) !important;
|
||||
white-space: nowrap !important;
|
||||
border: 0 !important;
|
||||
}
|
||||
}
|
||||
|
||||
118
src/web/public/lumi-dialog.js
Normal file
118
src/web/public/lumi-dialog.js
Normal file
@ -0,0 +1,118 @@
|
||||
(() => {
|
||||
const focusableSelector = [
|
||||
"a[href]", "button:not([disabled])", "input:not([disabled]):not([type='hidden'])",
|
||||
"select:not([disabled])", "textarea:not([disabled])", "[tabindex]:not([tabindex='-1'])"
|
||||
].join(",");
|
||||
const records = new WeakMap();
|
||||
let active = null;
|
||||
|
||||
function focusables(dialog) {
|
||||
return Array.from(dialog.querySelectorAll(focusableSelector)).filter((item) => !item.hidden && item.getAttribute("aria-hidden") !== "true");
|
||||
}
|
||||
|
||||
function isolate(backdrop) {
|
||||
const changed = [];
|
||||
let current = backdrop;
|
||||
while (current?.parentElement && current.parentElement !== document.documentElement) {
|
||||
for (const sibling of current.parentElement.children) {
|
||||
if (sibling === current || sibling.tagName === "SCRIPT") continue;
|
||||
changed.push({ node: sibling, inert: sibling.inert, ariaHidden: sibling.getAttribute("aria-hidden") });
|
||||
sibling.inert = true;
|
||||
}
|
||||
current = current.parentElement;
|
||||
if (current === document.body) break;
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
|
||||
function restoreIsolation(changed) {
|
||||
for (const item of changed || []) {
|
||||
item.node.inert = item.inert;
|
||||
if (item.ariaHidden === null) item.node.removeAttribute("aria-hidden");
|
||||
else item.node.setAttribute("aria-hidden", item.ariaHidden);
|
||||
}
|
||||
}
|
||||
|
||||
function open(backdrop, options = {}) {
|
||||
if (!backdrop || records.has(backdrop)) return;
|
||||
if (active && active !== backdrop) close(active, { restoreFocus: false });
|
||||
const dialog = backdrop.matches("[role='dialog'], dialog") ? backdrop : backdrop.querySelector("[role='dialog'], dialog, .modal");
|
||||
if (!dialog) return;
|
||||
const opener = options.opener || document.activeElement;
|
||||
const record = {
|
||||
dialog,
|
||||
opener: opener instanceof HTMLElement ? opener : null,
|
||||
isolated: isolate(backdrop),
|
||||
previousOverflow: document.documentElement.style.overflow
|
||||
};
|
||||
records.set(backdrop, record);
|
||||
active = backdrop;
|
||||
backdrop.setAttribute("aria-hidden", "false");
|
||||
document.documentElement.classList.add("lumi-dialog-open");
|
||||
document.documentElement.style.overflow = "hidden";
|
||||
const initial = options.initialFocus || dialog.querySelector("[autofocus], [data-dialog-initial-focus]") || focusables(dialog)[0] || dialog;
|
||||
if (!dialog.hasAttribute("tabindex") && initial === dialog) dialog.setAttribute("tabindex", "-1");
|
||||
window.setTimeout(() => initial.focus?.({ preventScroll: true }), 0);
|
||||
}
|
||||
|
||||
function close(backdrop, options = {}) {
|
||||
const record = records.get(backdrop);
|
||||
if (!record) return;
|
||||
records.delete(backdrop);
|
||||
if (active === backdrop) active = null;
|
||||
restoreIsolation(record.isolated);
|
||||
document.documentElement.classList.remove("lumi-dialog-open");
|
||||
document.documentElement.style.overflow = record.previousOverflow;
|
||||
backdrop.setAttribute("aria-hidden", "true");
|
||||
if (options.restoreFocus !== false && record.opener?.isConnected) {
|
||||
window.setTimeout(() => record.opener.focus({ preventScroll: true }), 0);
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeydown(event) {
|
||||
if (!active) return;
|
||||
const record = records.get(active);
|
||||
if (!record) return;
|
||||
if (event.key === "Escape" && active.dataset.dialogStatic !== "true") {
|
||||
event.preventDefault();
|
||||
active.dispatchEvent(new CustomEvent("lumi:dialog-cancel", { bubbles: true }));
|
||||
close(active);
|
||||
active.classList.remove("is-open");
|
||||
return;
|
||||
}
|
||||
if (event.key !== "Tab") return;
|
||||
const items = focusables(record.dialog);
|
||||
if (!items.length) {
|
||||
event.preventDefault();
|
||||
record.dialog.focus();
|
||||
return;
|
||||
}
|
||||
const first = items[0];
|
||||
const last = items[items.length - 1];
|
||||
if (event.shiftKey && document.activeElement === first) {
|
||||
event.preventDefault();
|
||||
last.focus();
|
||||
} else if (!event.shiftKey && document.activeElement === last) {
|
||||
event.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
}
|
||||
|
||||
function observe(root = document) {
|
||||
root.querySelectorAll?.(".modal-backdrop[aria-hidden='false']").forEach((item) => open(item));
|
||||
const observer = new MutationObserver((mutations) => {
|
||||
for (const mutation of mutations) {
|
||||
const item = mutation.target;
|
||||
if (!(item instanceof HTMLElement) || !item.matches(".modal-backdrop")) continue;
|
||||
if (item.getAttribute("aria-hidden") === "false") open(item);
|
||||
else close(item);
|
||||
}
|
||||
});
|
||||
observer.observe(root === document ? document.body : root, { subtree: true, attributes: true, attributeFilter: ["aria-hidden"] });
|
||||
return () => observer.disconnect();
|
||||
}
|
||||
|
||||
document.addEventListener("keydown", handleKeydown);
|
||||
document.addEventListener("DOMContentLoaded", () => observe(document), { once: true });
|
||||
window.LumiDialog = Object.freeze({ open, close, observe });
|
||||
})();
|
||||
@ -1,17 +1,50 @@
|
||||
(() => {
|
||||
const initializedForms = new WeakSet();
|
||||
const initializedEnhancedForms = new WeakSet();
|
||||
const initializedExpandables = new WeakSet();
|
||||
const REFRESH_COOLDOWN_MS = 3000;
|
||||
let saveBar = null;
|
||||
let stream = null;
|
||||
let lastScrollY = window.scrollY;
|
||||
let dirtyNavigationBound = false;
|
||||
let navigationController = null;
|
||||
const eventSubscriptions = new Map();
|
||||
|
||||
const init = (root = document) => {
|
||||
initFormSemantics(root);
|
||||
initSettingsDirty(root);
|
||||
initEnhancedForms(root);
|
||||
initExpandables(root);
|
||||
initSoftNavigation(root);
|
||||
};
|
||||
|
||||
function semanticId(value) {
|
||||
return String(value || "field").toLowerCase().replace(/[^a-z0-9_-]+/g, "-").replace(/^-+|-+$/g, "") || "field";
|
||||
}
|
||||
|
||||
function initFormSemantics(root) {
|
||||
root.querySelectorAll?.("form").forEach((form, formIndex) => {
|
||||
const formKey = semanticId(form.dataset.lumiFormId || form.getAttribute("action") || `form-${formIndex + 1}`);
|
||||
form.querySelectorAll("input:not([type='hidden']), select, textarea").forEach((control, controlIndex) => {
|
||||
const wrapped = control.closest("label");
|
||||
const field = control.closest(".field, fieldset");
|
||||
const directLabel = field && !wrapped
|
||||
? Array.from(field.children).find((item) => item.tagName === "LABEL" && !item.contains(control))
|
||||
: null;
|
||||
if (!control.id && (wrapped || directLabel)) {
|
||||
control.id = `lumi-${formKey}-${semanticId(control.name || control.type)}-${controlIndex + 1}`;
|
||||
}
|
||||
if (directLabel && !directLabel.htmlFor) directLabel.htmlFor = control.id;
|
||||
const help = field?.querySelector(":scope > .hint, :scope > small.hint");
|
||||
if (help && control.id) {
|
||||
if (!help.id) help.id = `${control.id}-help`;
|
||||
const ids = new Set(String(control.getAttribute("aria-describedby") || "").split(/\s+/).filter(Boolean));
|
||||
ids.add(help.id);
|
||||
control.setAttribute("aria-describedby", Array.from(ids).join(" "));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const ensureSaveBar = () => {
|
||||
if (saveBar) return saveBar;
|
||||
saveBar = document.createElement("div");
|
||||
@ -20,13 +53,16 @@
|
||||
saveBar.innerHTML = `
|
||||
<div>
|
||||
<strong data-savebar-count>Unsaved changes</strong>
|
||||
<span class="hint" data-savebar-status>Review and save changed settings on this page.</span>
|
||||
<span class="hint" data-savebar-status role="status" aria-live="polite">Review and save changed settings on this page.</span>
|
||||
</div>
|
||||
<div class="lumi-savebar-actions">
|
||||
<button type="button" class="button subtle" data-savebar-discard>Revert changes</button>
|
||||
<button type="button" class="button" data-savebar-submit>Save changes</button>
|
||||
</div>
|
||||
<button type="button" class="button" data-savebar-submit>Save changes</button>
|
||||
`;
|
||||
document.body.append(saveBar);
|
||||
saveBar.querySelector("[data-savebar-submit]").addEventListener("click", saveDirtyForms);
|
||||
window.addEventListener("scroll", updateSaveBarScroll, { passive: true });
|
||||
saveBar.querySelector("[data-savebar-discard]").addEventListener("click", discardDirtyForms);
|
||||
return saveBar;
|
||||
};
|
||||
|
||||
@ -82,7 +118,6 @@
|
||||
bar.querySelector("[data-savebar-count]").textContent =
|
||||
dirtyCount === 1 ? "1 unsaved setting" : `${dirtyCount} unsaved settings`;
|
||||
if (dirtyCount === 0) bar.querySelector("[data-savebar-status]").textContent = "Saved.";
|
||||
updateSaveBarScroll();
|
||||
};
|
||||
|
||||
function initSettingsDirty(root) {
|
||||
@ -90,8 +125,14 @@
|
||||
if (initializedForms.has(form)) return;
|
||||
initializedForms.add(form);
|
||||
form._lumiSnapshot = snapshotForm(form);
|
||||
form.addEventListener("input", updateDirtyState);
|
||||
form.addEventListener("change", updateDirtyState);
|
||||
form.addEventListener("input", (event) => {
|
||||
form._lumiLastEditedField = event.target;
|
||||
updateDirtyState();
|
||||
});
|
||||
form.addEventListener("change", (event) => {
|
||||
form._lumiLastEditedField = event.target;
|
||||
updateDirtyState();
|
||||
});
|
||||
form.addEventListener("submit", () => {
|
||||
form._lumiSnapshot = snapshotForm(form);
|
||||
window.setTimeout(updateDirtyState, 0);
|
||||
@ -100,47 +141,199 @@
|
||||
if (document.querySelector("form[data-lumi-settings-form]")) {
|
||||
ensureSaveBar();
|
||||
updateDirtyState();
|
||||
window.addEventListener("beforeunload", warnDirtyNavigation);
|
||||
if (!dirtyNavigationBound) {
|
||||
window.addEventListener("beforeunload", warnDirtyNavigation);
|
||||
dirtyNavigationBound = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function ensureFormStatus(form) {
|
||||
let status = form.querySelector("[data-lumi-form-status]");
|
||||
if (status) return status;
|
||||
status = document.createElement("p");
|
||||
status.className = "hint lumi-form-status";
|
||||
status.dataset.lumiFormStatus = "";
|
||||
status.setAttribute("role", "status");
|
||||
status.setAttribute("aria-live", "polite");
|
||||
const actions = form.querySelector(".form-actions, .modal-actions");
|
||||
(actions || form).append(status);
|
||||
return status;
|
||||
}
|
||||
|
||||
function initEnhancedForms(root) {
|
||||
root.querySelectorAll?.("form[data-lumi-enhanced-form]").forEach((form) => {
|
||||
if (initializedEnhancedForms.has(form)) return;
|
||||
initializedEnhancedForms.add(form);
|
||||
form.addEventListener("submit", async (event) => {
|
||||
const submitter = event.submitter;
|
||||
if (submitter?.matches("[data-no-enhance]") || !window.LumiRequest) return;
|
||||
if (!form.checkValidity()) {
|
||||
event.preventDefault();
|
||||
form.reportValidity();
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
clearFieldErrors(form);
|
||||
const status = ensureFormStatus(form);
|
||||
const label = submitter?.textContent || "Save";
|
||||
const isStateButton = submitter?.matches?.("[data-lumi-state-button]");
|
||||
if (isStateButton) {
|
||||
window.LumiStateButton?.setState?.(submitter, submitter.dataset.loadingState || "loading", { busy: true });
|
||||
} else if (submitter) {
|
||||
submitter.disabled = true;
|
||||
submitter.setAttribute("aria-busy", "true");
|
||||
submitter.textContent = submitter.dataset.pendingLabel || "Saving…";
|
||||
}
|
||||
form.setAttribute("aria-busy", "true");
|
||||
status.setAttribute("role", "status");
|
||||
status.textContent = "Saving…";
|
||||
try {
|
||||
const result = await window.LumiRequest.form(form, { submitter });
|
||||
form._lumiSnapshot = snapshotForm(form);
|
||||
form.classList.remove("has-unsaved-settings");
|
||||
status.textContent = result.message || "Saved.";
|
||||
form.dispatchEvent(new CustomEvent("lumi:form-saved", { bubbles: true, detail: result }));
|
||||
if (isStateButton) window.LumiStateButton?.success?.(submitter);
|
||||
if (result.reloadRequired) window.location.reload();
|
||||
else if (result.redirect && form.dataset.lumiFollowRedirect === "true") window.location.assign(result.redirect);
|
||||
} catch (error) {
|
||||
status.setAttribute("role", "alert");
|
||||
status.textContent = error.message || "The change could not be saved. Your input is still here.";
|
||||
applyFieldErrors(form, error.fieldErrors)?.focus();
|
||||
form.dispatchEvent(new CustomEvent("lumi:form-error", { bubbles: true, detail: { error } }));
|
||||
if (isStateButton) window.LumiStateButton?.error?.(submitter);
|
||||
} finally {
|
||||
form.removeAttribute("aria-busy");
|
||||
if (submitter && !isStateButton) {
|
||||
submitter.disabled = false;
|
||||
submitter.removeAttribute("aria-busy");
|
||||
submitter.textContent = label;
|
||||
}
|
||||
if (document.querySelector("form[data-lumi-settings-form]")) updateDirtyState();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function clearFieldErrors(form) {
|
||||
form.querySelectorAll("[data-lumi-field-error]").forEach((item) => item.remove());
|
||||
form.querySelectorAll("[aria-invalid='true']").forEach((field) => {
|
||||
field.removeAttribute("aria-invalid");
|
||||
const describedBy = String(field.getAttribute("aria-describedby") || "")
|
||||
.split(/\s+/).filter((id) => id && !id.startsWith("lumi-error-"));
|
||||
if (describedBy.length) field.setAttribute("aria-describedby", describedBy.join(" "));
|
||||
else field.removeAttribute("aria-describedby");
|
||||
});
|
||||
form.querySelector("[data-lumi-form-error]")?.remove();
|
||||
}
|
||||
|
||||
function applyFieldErrors(form, fieldErrors = {}) {
|
||||
clearFieldErrors(form);
|
||||
const entries = Object.entries(fieldErrors || {}).filter(([, message]) => message);
|
||||
if (!entries.length) return null;
|
||||
const summary = document.createElement("div");
|
||||
summary.className = "notice danger lumi-form-error-summary";
|
||||
summary.dataset.lumiFormError = "";
|
||||
summary.setAttribute("role", "alert");
|
||||
summary.textContent = entries.length === 1 ? "Correct the highlighted field and try again." : `Correct ${entries.length} highlighted fields and try again.`;
|
||||
form.prepend(summary);
|
||||
let first = null;
|
||||
entries.forEach(([name, message], index) => {
|
||||
const field = form.elements.namedItem(name);
|
||||
const control = field instanceof RadioNodeList ? field[0] : field;
|
||||
if (!(control instanceof HTMLElement)) return;
|
||||
first ||= control;
|
||||
const id = `lumi-error-${Date.now()}-${index}`;
|
||||
const error = document.createElement("span");
|
||||
error.id = id;
|
||||
error.className = "field-error";
|
||||
error.dataset.lumiFieldError = "";
|
||||
error.textContent = String(message);
|
||||
control.setAttribute("aria-invalid", "true");
|
||||
control.setAttribute("aria-describedby", `${control.getAttribute("aria-describedby") || ""} ${id}`.trim());
|
||||
(control.closest(".field, fieldset") || control.parentElement)?.append(error);
|
||||
});
|
||||
return first;
|
||||
}
|
||||
|
||||
async function saveDirtyForms() {
|
||||
const bar = ensureSaveBar();
|
||||
const button = bar.querySelector("[data-savebar-submit]");
|
||||
const discard = bar.querySelector("[data-savebar-discard]");
|
||||
const status = bar.querySelector("[data-savebar-status]");
|
||||
const forms = Array.from(document.querySelectorAll("form[data-lumi-settings-form].has-unsaved-settings"));
|
||||
if (!forms.length) return;
|
||||
const focusReturn = [...forms].reverse().map((form) => form._lumiLastEditedField).find((field) => field?.isConnected);
|
||||
button.disabled = true;
|
||||
status.textContent = "Saving...";
|
||||
try {
|
||||
for (const form of forms) {
|
||||
const action = window.LumiForms?.action(form) || form.getAttribute("action") || window.location.href;
|
||||
const body = new URLSearchParams();
|
||||
for (const [key, value] of new FormData(form).entries()) {
|
||||
if (typeof value === "string") body.append(key, value);
|
||||
}
|
||||
const response = await fetch(action, {
|
||||
method: window.LumiForms?.method(form) || form.getAttribute("method") || "POST",
|
||||
body,
|
||||
headers: {
|
||||
Accept: "text/html,application/json",
|
||||
"Content-Type": "application/x-www-form-urlencoded;charset=UTF-8"
|
||||
},
|
||||
redirect: "follow"
|
||||
});
|
||||
if (!response.ok) throw new Error(`Save failed for ${action || "settings form"}.`);
|
||||
discard.disabled = true;
|
||||
button.setAttribute("aria-busy", "true");
|
||||
status.textContent = forms.length === 1 ? "Saving 1 section…" : `Saving ${forms.length} sections…`;
|
||||
bar.classList.remove("has-error", "has-success");
|
||||
let saved = 0;
|
||||
const failures = [];
|
||||
let firstInvalid = null;
|
||||
for (const form of forms) {
|
||||
clearFieldErrors(form);
|
||||
form.setAttribute("aria-busy", "true");
|
||||
try {
|
||||
const result = await window.LumiRequest.form(form);
|
||||
form._lumiSnapshot = snapshotForm(form);
|
||||
form.classList.remove("has-unsaved-settings");
|
||||
saved += 1;
|
||||
if (result.reloadRequired) {
|
||||
status.textContent = result.message || "Saved. Lumi needs to reload this page to finish applying the change.";
|
||||
window.removeEventListener("beforeunload", warnDirtyNavigation);
|
||||
window.location.reload();
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
failures.push({ form, error });
|
||||
firstInvalid ||= applyFieldErrors(form, error.fieldErrors);
|
||||
} finally {
|
||||
form.removeAttribute("aria-busy");
|
||||
}
|
||||
status.textContent = "Saved. Reloading...";
|
||||
window.removeEventListener("beforeunload", warnDirtyNavigation);
|
||||
window.setTimeout(() => window.location.reload(), 75);
|
||||
} catch (error) {
|
||||
status.textContent = error.message || "Save failed.";
|
||||
bar.classList.add("has-error");
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
window.setTimeout(() => bar.classList.remove("has-error"), 2500);
|
||||
}
|
||||
if (failures.length) {
|
||||
const failed = failures.length;
|
||||
status.textContent = saved
|
||||
? `${saved} section${saved === 1 ? "" : "s"} saved. ${failed} failed and remains unsaved: ${failures[0].error.message}`
|
||||
: `Nothing was saved. ${failures[0].error.message}`;
|
||||
bar.classList.add("has-error");
|
||||
status.setAttribute("role", "alert");
|
||||
firstInvalid?.focus({ preventScroll: false });
|
||||
} else {
|
||||
const successMessage = forms.length === 1 ? "Section saved." : `${saved} sections saved.`;
|
||||
status.textContent = successMessage;
|
||||
status.setAttribute("role", "status");
|
||||
bar.classList.add("has-success");
|
||||
showEventNotice({ message: successMessage }, "success");
|
||||
}
|
||||
updateDirtyState();
|
||||
if (!failures.length && focusReturn) focusReturn.focus({ preventScroll: true });
|
||||
button.disabled = false;
|
||||
discard.disabled = false;
|
||||
button.removeAttribute("aria-busy");
|
||||
}
|
||||
|
||||
function discardDirtyForms() {
|
||||
const forms = Array.from(document.querySelectorAll("form[data-lumi-settings-form].has-unsaved-settings"));
|
||||
for (const form of forms) {
|
||||
const snapshot = form._lumiSnapshot;
|
||||
if (!snapshot) continue;
|
||||
for (const field of formFields(form)) {
|
||||
const original = snapshot.get(field.name) || "";
|
||||
if (field.type === "checkbox") field.checked = original === "on";
|
||||
else if (field.type === "radio") field.checked = field.value === original;
|
||||
else field.value = original;
|
||||
field.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
field.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
}
|
||||
clearFieldErrors(form);
|
||||
}
|
||||
const bar = ensureSaveBar();
|
||||
bar.querySelector("[data-savebar-status]").textContent = forms.length === 1 ? "Changes reverted." : `${forms.length} sections reverted.`;
|
||||
updateDirtyState();
|
||||
}
|
||||
|
||||
function warnDirtyNavigation(event) {
|
||||
@ -149,14 +342,6 @@
|
||||
event.returnValue = "";
|
||||
}
|
||||
|
||||
function updateSaveBarScroll() {
|
||||
if (!saveBar || saveBar.hidden) return;
|
||||
const canScroll = document.documentElement.scrollHeight > window.innerHeight + 12;
|
||||
const next = window.scrollY;
|
||||
saveBar.classList.toggle("is-hidden-by-scroll", canScroll && next > lastScrollY + 4);
|
||||
lastScrollY = next;
|
||||
}
|
||||
|
||||
function initExpandables(root) {
|
||||
root.querySelectorAll?.("[data-lumi-expandable-settings]").forEach((item) => {
|
||||
if (initializedExpandables.has(item)) return;
|
||||
@ -183,7 +368,7 @@
|
||||
}
|
||||
|
||||
function connectEvents() {
|
||||
if (!window.EventSource || stream) return;
|
||||
if (!window.EventSource || stream || document.body?.dataset.authenticated !== "true") return;
|
||||
stream = new EventSource("/api/events");
|
||||
stream.addEventListener("server:warning", (event) => showEventNotice(readEvent(event), "warning"));
|
||||
stream.addEventListener("server:status", (event) => {
|
||||
@ -202,6 +387,31 @@
|
||||
document.body.dataset.eventStream = "disconnected";
|
||||
window.dispatchEvent(new CustomEvent("lumi:event-status", { detail: { status: "disconnected" } }));
|
||||
};
|
||||
for (const [eventName, listeners] of eventSubscriptions) {
|
||||
for (const listener of listeners) stream.addEventListener(eventName, listener);
|
||||
}
|
||||
}
|
||||
|
||||
function subscribeEvent(eventName, listener) {
|
||||
if (!eventName || typeof listener !== "function") return () => {};
|
||||
let listeners = eventSubscriptions.get(eventName);
|
||||
if (!listeners) {
|
||||
listeners = new Set();
|
||||
eventSubscriptions.set(eventName, listeners);
|
||||
}
|
||||
listeners.add(listener);
|
||||
stream?.addEventListener(eventName, listener);
|
||||
connectEvents();
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
stream?.removeEventListener(eventName, listener);
|
||||
if (!listeners.size) eventSubscriptions.delete(eventName);
|
||||
};
|
||||
}
|
||||
|
||||
function closeEvents() {
|
||||
stream?.close();
|
||||
stream = null;
|
||||
}
|
||||
|
||||
function readEvent(event) {
|
||||
@ -249,7 +459,7 @@
|
||||
}
|
||||
|
||||
function initSoftNavigation(root) {
|
||||
root.querySelectorAll?.("a[href]").forEach((link) => {
|
||||
root.querySelectorAll?.("a[data-lumi-soft-nav][href]").forEach((link) => {
|
||||
if (link.dataset.softNavBound || link.target || link.hasAttribute("download")) return;
|
||||
const url = new URL(link.href, window.location.href);
|
||||
if (url.origin !== window.location.origin || url.pathname.startsWith("/auth/")) return;
|
||||
@ -267,24 +477,30 @@
|
||||
async function softNavigate(url, push = true) {
|
||||
const main = document.querySelector("main.content");
|
||||
if (!main) return window.location.assign(url);
|
||||
navigationController?.abort();
|
||||
navigationController = new AbortController();
|
||||
main.classList.add("is-soft-loading");
|
||||
try {
|
||||
const response = await fetch(url, { headers: { "X-Lumi-Soft-Navigation": "1" } });
|
||||
const response = await fetch(url, { headers: { "X-Lumi-Soft-Navigation": "1" }, signal: navigationController.signal });
|
||||
if (!response.ok) throw new Error("Navigation failed.");
|
||||
const html = await response.text();
|
||||
const doc = new DOMParser().parseFromString(html, "text/html");
|
||||
const nextMain = doc.querySelector("main.content");
|
||||
if (!nextMain) throw new Error("Navigation target did not contain page content.");
|
||||
window.LumiPage?.unmount();
|
||||
document.title = doc.title || document.title;
|
||||
main.replaceChildren(...Array.from(nextMain.childNodes));
|
||||
main.classList.remove("is-soft-loading");
|
||||
main.classList.add("is-soft-loaded");
|
||||
window.setTimeout(() => main.classList.remove("is-soft-loaded"), 180);
|
||||
updateActiveNavigation(new URL(url, window.location.href).pathname);
|
||||
if (push) history.pushState({}, "", url);
|
||||
if (push) history.pushState({ lumiSoft: true }, "", url);
|
||||
init(main);
|
||||
executePageScripts(main);
|
||||
window.LumiPage?.mount(main);
|
||||
window.scrollTo({ top: 0, behavior: "auto" });
|
||||
const focusTarget = main.querySelector("h1") || main;
|
||||
if (!focusTarget.hasAttribute("tabindex")) focusTarget.setAttribute("tabindex", "-1");
|
||||
focusTarget.focus({ preventScroll: true });
|
||||
} catch {
|
||||
window.location.assign(url);
|
||||
}
|
||||
@ -297,17 +513,15 @@
|
||||
});
|
||||
}
|
||||
|
||||
function executePageScripts(root) {
|
||||
root.querySelectorAll("script").forEach((script) => {
|
||||
const next = document.createElement("script");
|
||||
for (const attr of script.attributes) next.setAttribute(attr.name, attr.value);
|
||||
next.textContent = script.textContent;
|
||||
script.replaceWith(next);
|
||||
});
|
||||
}
|
||||
|
||||
window.addEventListener("popstate", () => softNavigate(window.location.href, false));
|
||||
window.addEventListener("popstate", (event) => {
|
||||
if (event.state?.lumiSoft) softNavigate(window.location.href, false);
|
||||
});
|
||||
window.LumiEvents = Object.freeze({ subscribe: subscribeEvent, connect: connectEvents, close: closeEvents });
|
||||
window.LumiInteractions = { init, connectEvents, showEventNotice, showRefreshPrompt };
|
||||
window.addEventListener("beforeunload", closeEvents);
|
||||
window.addEventListener("pagehide", closeEvents);
|
||||
window.addEventListener("pageshow", connectEvents);
|
||||
window.addEventListener("focus", connectEvents);
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
init(document);
|
||||
connectEvents();
|
||||
|
||||
@ -1,7 +1,9 @@
|
||||
@layer layout {
|
||||
html {
|
||||
min-width: 320px;
|
||||
background: var(--bg-2);
|
||||
scroll-behavior: smooth;
|
||||
scroll-behavior: auto;
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
|
||||
body {
|
||||
@ -12,22 +14,22 @@ body {
|
||||
radial-gradient(circle at 8% 0%, var(--bg-1) 0, transparent 34rem),
|
||||
radial-gradient(circle at 100% 100%, var(--bg-3) 0, transparent 38rem),
|
||||
var(--bg-2);
|
||||
background-attachment: fixed;
|
||||
background-attachment: scroll;
|
||||
}
|
||||
|
||||
.app-shell {
|
||||
grid-template-columns: 17rem minmax(0, 1fr);
|
||||
grid-template-columns: var(--lumi-sidebar-expanded) minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
width: 17rem;
|
||||
width: var(--lumi-sidebar-expanded);
|
||||
gap: var(--lumi-space-4);
|
||||
padding: var(--lumi-space-4);
|
||||
background: color-mix(in srgb, var(--lumi-surface) 94%, transparent);
|
||||
border-color: var(--lumi-border);
|
||||
box-shadow: var(--lumi-shadow-sm);
|
||||
backdrop-filter: blur(18px);
|
||||
z-index: 30;
|
||||
z-index: var(--lumi-z-navigation);
|
||||
}
|
||||
|
||||
.sidebar-brand {
|
||||
@ -118,6 +120,17 @@ body {
|
||||
gap: var(--lumi-space-5);
|
||||
}
|
||||
|
||||
.content.content-prose,
|
||||
.content[data-content-width="prose"] { max-width: var(--lumi-content-prose); }
|
||||
.content.content-form,
|
||||
.content[data-content-width="form"] { max-width: var(--lumi-content-form); }
|
||||
.content.content-standard,
|
||||
.content[data-content-width="standard"] { max-width: var(--lumi-content-standard); }
|
||||
.content.content-wide,
|
||||
.content[data-content-width="wide"] { max-width: var(--lumi-content-wide); }
|
||||
.content.content-workspace,
|
||||
.content[data-content-width="workspace"] { max-width: var(--lumi-content-workspace); }
|
||||
|
||||
.content > * {
|
||||
min-width: 0;
|
||||
}
|
||||
@ -183,7 +196,7 @@ body {
|
||||
display: none;
|
||||
background: rgba(5, 10, 12, 0.52);
|
||||
backdrop-filter: blur(2px);
|
||||
z-index: 25;
|
||||
z-index: calc(var(--lumi-z-navigation) - 1);
|
||||
}
|
||||
|
||||
.standalone-page {
|
||||
@ -203,11 +216,11 @@ body {
|
||||
|
||||
@media (min-width: 901px) {
|
||||
body.sidebar-collapsed .app-shell {
|
||||
grid-template-columns: 5.5rem minmax(0, 1fr);
|
||||
grid-template-columns: var(--lumi-sidebar-collapsed) minmax(0, 1fr);
|
||||
}
|
||||
|
||||
body.sidebar-collapsed .sidebar {
|
||||
width: 5.5rem;
|
||||
width: var(--lumi-sidebar-collapsed);
|
||||
}
|
||||
|
||||
body.sidebar-collapsed .sidebar-brand {
|
||||
@ -269,7 +282,7 @@ body {
|
||||
}
|
||||
|
||||
.mobile-topbar {
|
||||
min-height: 4rem;
|
||||
min-height: var(--lumi-mobile-header-height);
|
||||
padding: var(--lumi-space-3) var(--lumi-space-4);
|
||||
background: color-mix(in srgb, var(--lumi-surface) 92%, transparent);
|
||||
backdrop-filter: blur(18px);
|
||||
@ -281,10 +294,6 @@ body {
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
body {
|
||||
font-size: calc(var(--lumi-font-size-base) * 0.94);
|
||||
}
|
||||
|
||||
.mobile-topbar {
|
||||
min-height: 3.5rem;
|
||||
padding: var(--lumi-space-2) var(--lumi-space-3);
|
||||
@ -325,3 +334,4 @@ body {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
34
src/web/public/lumi-page.js
Normal file
34
src/web/public/lumi-page.js
Normal file
@ -0,0 +1,34 @@
|
||||
(() => {
|
||||
const registrations = new Map();
|
||||
let mounted = [];
|
||||
|
||||
function register(name, definition) {
|
||||
if (!name || !definition) throw new TypeError("LumiPage.register requires a name and mount definition.");
|
||||
registrations.set(name, typeof definition === "function" ? { mount: definition } : definition);
|
||||
}
|
||||
|
||||
function unmount() {
|
||||
for (const item of mounted.splice(0).reverse()) {
|
||||
try { item.controller.abort(); } catch {}
|
||||
try { item.teardown?.(); } catch (error) { console.error(`Lumi page teardown failed for ${item.name}.`, error); }
|
||||
}
|
||||
window.dispatchEvent(new CustomEvent("lumi:page-unmounted"));
|
||||
}
|
||||
|
||||
function mount(root = document) {
|
||||
unmount();
|
||||
for (const [name, definition] of registrations) {
|
||||
const selector = definition.selector || `[data-lumi-page~="${CSS.escape(name)}"]`;
|
||||
const target = root.matches?.(selector) ? root : root.querySelector?.(selector);
|
||||
if (!target || typeof definition.mount !== "function") continue;
|
||||
const controller = new AbortController();
|
||||
const context = { root: target, signal: controller.signal, pageRoot: root };
|
||||
const result = definition.mount(target, context);
|
||||
mounted.push({ name, controller, teardown: typeof result === "function" ? result : result?.destroy?.bind(result) });
|
||||
}
|
||||
window.dispatchEvent(new CustomEvent("lumi:page-mounted", { detail: { root } }));
|
||||
}
|
||||
|
||||
window.LumiPage = Object.freeze({ register, mount, unmount });
|
||||
document.addEventListener("DOMContentLoaded", () => mount(document), { once: true });
|
||||
})();
|
||||
112
src/web/public/lumi-request.js
Normal file
112
src/web/public/lumi-request.js
Normal file
@ -0,0 +1,112 @@
|
||||
(() => {
|
||||
class LumiRequestError extends Error {
|
||||
constructor(message, details = {}) {
|
||||
super(message || "The request could not be completed.");
|
||||
this.name = "LumiRequestError";
|
||||
this.kind = details.kind || "server";
|
||||
this.status = details.status || 0;
|
||||
this.fieldErrors = details.fieldErrors || {};
|
||||
this.data = details.data || {};
|
||||
}
|
||||
}
|
||||
|
||||
const enhancedHeaders = {
|
||||
Accept: "application/json, text/html;q=0.8",
|
||||
"X-Lumi-Enhanced": "1",
|
||||
"X-Requested-With": "XMLHttpRequest"
|
||||
};
|
||||
|
||||
function normalize(payload, response) {
|
||||
if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
|
||||
return {
|
||||
ok: response.ok,
|
||||
message: response.ok ? "Saved." : "The request could not be completed.",
|
||||
fieldErrors: {},
|
||||
data: {},
|
||||
patch: {},
|
||||
redirect: null,
|
||||
reloadRequired: false
|
||||
};
|
||||
}
|
||||
const explicitOk = payload.ok ?? payload.success;
|
||||
return {
|
||||
...payload,
|
||||
ok: explicitOk === undefined ? response.ok : Boolean(explicitOk),
|
||||
message: payload.message || payload.error || (response.ok ? "Saved." : "The request could not be completed."),
|
||||
fieldErrors: payload.fieldErrors && typeof payload.fieldErrors === "object" ? payload.fieldErrors : {},
|
||||
data: payload.data && typeof payload.data === "object" ? payload.data : {},
|
||||
patch: payload.patch && typeof payload.patch === "object" ? payload.patch : {},
|
||||
redirect: payload.redirect || null,
|
||||
reloadRequired: payload.reloadRequired === true
|
||||
};
|
||||
}
|
||||
|
||||
async function parseResponse(response) {
|
||||
const contentType = String(response.headers.get("content-type") || "").toLowerCase();
|
||||
if (contentType.includes("application/json")) {
|
||||
return normalize(await response.json().catch(() => null), response);
|
||||
}
|
||||
const html = await response.text().catch(() => "");
|
||||
if (response.ok) return normalize(null, response);
|
||||
const parsed = new DOMParser().parseFromString(html, "text/html");
|
||||
const message = parsed.querySelector("[role='alert'], .flash.error, .error-message, main h1 + p")?.textContent?.trim();
|
||||
return normalize({ ok: false, message: message || `The server returned ${response.status}.` }, response);
|
||||
}
|
||||
|
||||
async function request(url, options = {}) {
|
||||
const headers = new Headers(options.headers || {});
|
||||
Object.entries(enhancedHeaders).forEach(([name, value]) => {
|
||||
if (!headers.has(name)) headers.set(name, value);
|
||||
});
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
credentials: "same-origin",
|
||||
redirect: "follow",
|
||||
...options,
|
||||
headers
|
||||
});
|
||||
const result = await parseResponse(response);
|
||||
if (!response.ok || !result.ok) {
|
||||
throw new LumiRequestError(result.message, {
|
||||
kind: response.status === 401 || response.status === 403 ? "permission" : response.status === 422 || response.status === 400 ? "validation" : "server",
|
||||
status: response.status,
|
||||
fieldErrors: result.fieldErrors,
|
||||
data: result.data
|
||||
});
|
||||
}
|
||||
return result;
|
||||
} catch (error) {
|
||||
if (error instanceof LumiRequestError || error?.name === "AbortError") throw error;
|
||||
throw new LumiRequestError("Lumi could not be reached. Your changes are still in this page; check the connection and try again.", {
|
||||
kind: "network"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function formRequest(form, options = {}) {
|
||||
const submitter = options.submitter || null;
|
||||
const method = String(submitter?.getAttribute?.("formmethod") || form.getAttribute("method") || "POST").toUpperCase();
|
||||
const url = submitter?.getAttribute?.("formaction") || form.getAttribute("action") || window.location.href;
|
||||
const formData = submitter ? new FormData(form, submitter) : new FormData(form);
|
||||
const hasFile = Array.from(formData.values()).some((value) => value instanceof File && value.size > 0);
|
||||
let body = formData;
|
||||
const headers = new Headers(options.headers || {});
|
||||
if (!hasFile && method !== "GET") {
|
||||
body = new URLSearchParams();
|
||||
for (const [name, value] of formData.entries()) {
|
||||
if (typeof value === "string") body.append(name, value);
|
||||
}
|
||||
headers.set("Content-Type", "application/x-www-form-urlencoded;charset=UTF-8");
|
||||
}
|
||||
if (method === "GET") {
|
||||
const next = new URL(url, window.location.href);
|
||||
for (const [name, value] of formData.entries()) {
|
||||
if (typeof value === "string") next.searchParams.append(name, value);
|
||||
}
|
||||
return request(next.href, { ...options, method, body: undefined, headers });
|
||||
}
|
||||
return request(url, { ...options, method, body, headers });
|
||||
}
|
||||
|
||||
window.LumiRequest = Object.freeze({ request, form: formRequest, Error: LumiRequestError });
|
||||
})();
|
||||
@ -5,7 +5,11 @@
|
||||
|
||||
const setState = (button, state, options = {}) => {
|
||||
if (!button) return;
|
||||
const nextState = state || button.dataset.defaultState || "idle";
|
||||
const requestedState = state || button.dataset.defaultState || "idle";
|
||||
const availableStates = new Set(getViews(button).map((view) => view.dataset.stateView));
|
||||
const nextState = availableStates.has(requestedState)
|
||||
? requestedState
|
||||
: button.dataset.defaultState || "idle";
|
||||
const busyState = button.dataset.loadingState || "loading";
|
||||
const isBusy = nextState === busyState || options.busy === true;
|
||||
|
||||
|
||||
@ -1,3 +1,6 @@
|
||||
@layer reset, tokens, base, layout, components, utilities, features, overrides;
|
||||
|
||||
@layer tokens {
|
||||
:root {
|
||||
color-scheme: light dark;
|
||||
--lumi-font-body: "Source Sans 3", Inter, ui-sans-serif, system-ui, -apple-system,
|
||||
@ -23,6 +26,16 @@
|
||||
--lumi-border: var(--border, #d8e0e3);
|
||||
--lumi-border-strong: color-mix(in srgb, var(--lumi-border) 72%, var(--lumi-text));
|
||||
--lumi-font-size-sm: 0.875rem;
|
||||
--lumi-font-size-xs: 0.8125rem;
|
||||
--lumi-font-size-md: 1rem;
|
||||
--lumi-font-size-lg: 1.125rem;
|
||||
--lumi-line-height-tight: 1.2;
|
||||
--lumi-line-height-body: 1.55;
|
||||
--lumi-line-height-relaxed: 1.7;
|
||||
--lumi-weight-regular: 400;
|
||||
--lumi-weight-medium: 600;
|
||||
--lumi-weight-bold: 700;
|
||||
--lumi-weight-heavy: 800;
|
||||
--lumi-input-bg: var(--lumi-surface);
|
||||
--lumi-input-border: var(--lumi-border);
|
||||
--lumi-input-text: var(--lumi-text);
|
||||
@ -34,6 +47,14 @@
|
||||
--lumi-color-unsaved-border: color-mix(in srgb, var(--lumi-warning) 45%, var(--lumi-border));
|
||||
--lumi-color-unsaved-text: var(--lumi-text);
|
||||
--lumi-color-unsaved-ring: color-mix(in srgb, var(--lumi-warning) 26%, transparent);
|
||||
--lumi-field-hover-border: color-mix(in srgb, var(--lumi-primary) 35%, var(--lumi-input-border));
|
||||
--lumi-field-focus-border: var(--lumi-focus);
|
||||
--lumi-field-invalid-bg: color-mix(in srgb, var(--lumi-danger) 8%, var(--lumi-input-bg));
|
||||
--lumi-field-invalid-border: color-mix(in srgb, var(--lumi-danger) 72%, var(--lumi-border));
|
||||
--lumi-field-warning-bg: color-mix(in srgb, var(--lumi-warning) 8%, var(--lumi-input-bg));
|
||||
--lumi-field-warning-border: color-mix(in srgb, var(--lumi-warning) 64%, var(--lumi-border));
|
||||
--lumi-field-disabled-bg: color-mix(in srgb, var(--lumi-surface-subtle) 88%, var(--lumi-border));
|
||||
--lumi-field-readonly-bg: var(--lumi-surface-subtle);
|
||||
--lumi-savebar-bg: color-mix(in srgb, var(--lumi-surface) 94%, transparent);
|
||||
--lumi-savebar-border: var(--lumi-color-unsaved-border);
|
||||
--lumi-savebar-shadow: var(--lumi-shadow-md);
|
||||
@ -46,6 +67,12 @@
|
||||
--lumi-space-5: calc(1.5rem * var(--lumi-space-scale));
|
||||
--lumi-space-6: calc(2rem * var(--lumi-space-scale));
|
||||
--lumi-space-7: calc(3rem * var(--lumi-space-scale));
|
||||
--lumi-section-gap: var(--lumi-space-6);
|
||||
--lumi-group-gap: var(--lumi-space-3);
|
||||
|
||||
--lumi-density-compact: 0.82;
|
||||
--lumi-density-default: 1;
|
||||
--lumi-density-comfortable: 1.16;
|
||||
|
||||
--lumi-radius-sm: calc(var(--lumi-radius, 14px) * 0.58);
|
||||
--lumi-radius-md: var(--lumi-radius, 14px);
|
||||
@ -54,9 +81,46 @@
|
||||
--lumi-shadow-sm: 0 1px 2px rgba(11, 20, 24, calc(var(--lumi-shadow-strength, 0.14) * 0.7));
|
||||
--lumi-shadow-md: 0 12px 34px rgba(11, 20, 24, var(--lumi-shadow-strength, 0.14));
|
||||
--lumi-shadow-lg: 0 22px 60px rgba(11, 20, 24, calc(var(--lumi-shadow-strength, 0.14) * 1.15));
|
||||
--lumi-transition: 150ms ease;
|
||||
--lumi-motion-fast: 100ms;
|
||||
--lumi-motion-default: 160ms;
|
||||
--lumi-motion-slow: 260ms;
|
||||
--lumi-ease-standard: cubic-bezier(0.2, 0, 0, 1);
|
||||
--lumi-ease-emphasized: cubic-bezier(0.2, 0, 0, 1.2);
|
||||
--lumi-transition: var(--lumi-motion-default) var(--lumi-ease-standard);
|
||||
--lumi-control-height-compact: calc(2.5rem * var(--lumi-control-scale));
|
||||
--lumi-control-height: calc(2.75rem * var(--lumi-control-scale));
|
||||
--lumi-content-max: 1600px;
|
||||
--lumi-touch-target: 2.75rem;
|
||||
--lumi-content-prose: 46rem;
|
||||
--lumi-content-form: 64rem;
|
||||
--lumi-content-standard: 82rem;
|
||||
--lumi-content-wide: 100rem;
|
||||
--lumi-content-workspace: 120rem;
|
||||
--lumi-content-max: var(--lumi-content-wide);
|
||||
--lumi-sidebar-expanded: 17rem;
|
||||
--lumi-sidebar-collapsed: 5.5rem;
|
||||
--lumi-mobile-header-height: 4rem;
|
||||
--lumi-z-navigation: 30;
|
||||
--lumi-z-sticky: 40;
|
||||
--lumi-z-popover: 70;
|
||||
--lumi-z-savebar: 80;
|
||||
--lumi-z-notice: 90;
|
||||
--lumi-z-dialog: 200;
|
||||
--lumi-focus-size: 3px;
|
||||
--lumi-focus-offset: 3px;
|
||||
--lumi-dialog-scrim: rgba(5, 10, 12, 0.62);
|
||||
--lumi-code-bg: color-mix(in srgb, var(--lumi-text) 94%, var(--lumi-surface));
|
||||
--lumi-code-text: color-mix(in srgb, var(--lumi-surface) 92%, white);
|
||||
--lumi-table-header-bg: var(--lumi-surface-subtle);
|
||||
--lumi-table-row-hover: color-mix(in srgb, var(--lumi-primary) 5%, var(--lumi-surface));
|
||||
--lumi-table-row-selected: color-mix(in srgb, var(--lumi-primary) 12%, var(--lumi-surface));
|
||||
--lumi-success-bg: color-mix(in srgb, var(--lumi-success) 10%, var(--lumi-surface));
|
||||
--lumi-success-border: color-mix(in srgb, var(--lumi-success) 42%, var(--lumi-border));
|
||||
--lumi-warning-bg: color-mix(in srgb, var(--lumi-warning) 10%, var(--lumi-surface));
|
||||
--lumi-warning-border: color-mix(in srgb, var(--lumi-warning) 45%, var(--lumi-border));
|
||||
--lumi-danger-bg: color-mix(in srgb, var(--lumi-danger) 10%, var(--lumi-surface));
|
||||
--lumi-danger-border: color-mix(in srgb, var(--lumi-danger) 45%, var(--lumi-border));
|
||||
--lumi-info-bg: color-mix(in srgb, var(--lumi-info) 9%, var(--lumi-surface));
|
||||
--lumi-info-border: color-mix(in srgb, var(--lumi-info) 42%, var(--lumi-border));
|
||||
|
||||
/* Compatibility aliases for existing core and plugin styles. */
|
||||
--text: var(--lumi-text);
|
||||
@ -70,6 +134,7 @@
|
||||
--panel: var(--lumi-surface);
|
||||
--panel-2: var(--lumi-surface-subtle);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
|
||||
264
src/web/public/navigation-builder.css
Normal file
264
src/web/public/navigation-builder.css
Normal file
@ -0,0 +1,264 @@
|
||||
@layer features {
|
||||
.nav-builder {
|
||||
display: grid;
|
||||
gap: var(--lumi-section-gap);
|
||||
}
|
||||
|
||||
.nav-builder-page-header {
|
||||
align-items: flex-start;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.nav-builder-settings {
|
||||
padding: var(--lumi-space-4);
|
||||
border: 1px solid var(--lumi-border);
|
||||
border-radius: var(--lumi-radius-md);
|
||||
background: var(--lumi-surface-subtle);
|
||||
}
|
||||
|
||||
.nav-builder-settings > legend {
|
||||
padding-inline: var(--lumi-space-2);
|
||||
font: var(--lumi-weight-bold) var(--lumi-font-size-lg)/var(--lumi-line-height-tight) var(--lumi-font-display);
|
||||
}
|
||||
|
||||
.nav-builder-setting-grid,
|
||||
.nav-builder-unassigned-settings {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: var(--lumi-space-4);
|
||||
}
|
||||
|
||||
.nav-builder-unassigned-settings {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
margin-top: var(--lumi-space-4);
|
||||
}
|
||||
|
||||
.nav-builder-setting-grid .switch {
|
||||
align-items: center;
|
||||
min-height: var(--lumi-touch-target);
|
||||
padding: var(--lumi-space-3);
|
||||
border: 1px solid var(--lumi-border);
|
||||
border-radius: var(--lumi-radius-md);
|
||||
background: var(--lumi-surface);
|
||||
}
|
||||
|
||||
.nav-builder-setting-grid .switch > span:last-child {
|
||||
display: grid;
|
||||
gap: var(--lumi-space-1);
|
||||
}
|
||||
|
||||
.nav-builder-workspace {
|
||||
display: grid;
|
||||
gap: var(--lumi-space-4);
|
||||
}
|
||||
|
||||
.nav-toolbar,
|
||||
.nav-item-actions,
|
||||
.nav-section-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--lumi-space-2);
|
||||
}
|
||||
|
||||
.nav-builder-body {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 2fr) minmax(18rem, 1fr);
|
||||
gap: var(--lumi-space-5);
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.nav-builder-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: var(--lumi-space-3);
|
||||
margin-bottom: var(--lumi-space-3);
|
||||
}
|
||||
|
||||
.nav-builder-header h2,
|
||||
.nav-builder-header p {
|
||||
margin-block: 0;
|
||||
}
|
||||
|
||||
.nav-section-list {
|
||||
display: grid;
|
||||
gap: var(--lumi-space-4);
|
||||
}
|
||||
|
||||
.nav-section-card,
|
||||
.nav-pool-card,
|
||||
.nav-advanced {
|
||||
padding: var(--lumi-space-4);
|
||||
border: 1px solid var(--lumi-border);
|
||||
border-radius: var(--lumi-radius-md);
|
||||
background: var(--lumi-surface-subtle);
|
||||
}
|
||||
|
||||
.nav-section-card[data-drag-state="over"],
|
||||
.nav-dd-list.drag-over {
|
||||
border-color: var(--lumi-primary);
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--lumi-primary) 20%, transparent);
|
||||
}
|
||||
|
||||
.nav-section-header {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(10rem, 1.2fr) minmax(8rem, 1fr) minmax(8rem, 1fr) auto;
|
||||
gap: var(--lumi-space-3);
|
||||
align-items: end;
|
||||
margin-bottom: var(--lumi-space-3);
|
||||
}
|
||||
|
||||
.nav-section-header .field {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.nav-section-actions {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.nav-section-actions .icon-button,
|
||||
.nav-item-actions .icon-button {
|
||||
width: var(--lumi-touch-target);
|
||||
min-width: var(--lumi-touch-target);
|
||||
min-height: var(--lumi-touch-target);
|
||||
}
|
||||
|
||||
.nav-dd-list {
|
||||
display: grid;
|
||||
gap: var(--lumi-space-2);
|
||||
min-height: 4rem;
|
||||
padding: var(--lumi-space-2);
|
||||
border: 1px dashed var(--lumi-border-strong);
|
||||
border-radius: var(--lumi-radius-md);
|
||||
background: var(--lumi-surface-raised);
|
||||
}
|
||||
|
||||
.nav-dd-list:empty::before {
|
||||
content: "No items in this group.";
|
||||
align-self: center;
|
||||
padding: var(--lumi-space-3);
|
||||
color: var(--lumi-text-muted);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.nav-dd-item {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
gap: var(--lumi-space-3);
|
||||
align-items: center;
|
||||
padding: var(--lumi-space-2) var(--lumi-space-3);
|
||||
border: 1px solid var(--lumi-border);
|
||||
border-radius: var(--lumi-radius-md);
|
||||
background: var(--lumi-surface);
|
||||
box-shadow: var(--lumi-shadow-sm);
|
||||
}
|
||||
|
||||
.nav-dd-item[draggable="true"] {
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.nav-dd-item.dragging {
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.nav-dd-handle {
|
||||
color: var(--lumi-text-muted);
|
||||
font-family: var(--lumi-font-mono);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.nav-dd-meta {
|
||||
display: grid;
|
||||
gap: var(--lumi-space-1);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.nav-dd-meta span {
|
||||
overflow: hidden;
|
||||
color: var(--lumi-text-muted);
|
||||
font-size: var(--lumi-font-size-sm);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.nav-item-actions {
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
.nav-advanced {
|
||||
display: grid;
|
||||
gap: var(--lumi-space-4);
|
||||
}
|
||||
|
||||
.nav-advanced[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.nav-actions {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.nav-actions .inline-form {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.nav-builder-status {
|
||||
min-height: 1.5rem;
|
||||
}
|
||||
|
||||
.nav-builder[data-dirty="true"] .nav-builder-status {
|
||||
color: var(--lumi-warning);
|
||||
font-weight: var(--lumi-weight-medium);
|
||||
}
|
||||
|
||||
@media (min-width: 901px) and (min-height: 720px) {
|
||||
.nav-pool-card {
|
||||
position: sticky;
|
||||
top: var(--lumi-space-4);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.nav-section-header {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.nav-section-actions {
|
||||
grid-column: 1 / -1;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.nav-builder-body,
|
||||
.nav-builder-setting-grid,
|
||||
.nav-builder-unassigned-settings {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.nav-pool-card {
|
||||
order: -1;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.nav-section-header,
|
||||
.nav-dd-item {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.nav-item-actions,
|
||||
.nav-section-actions {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.nav-dd-handle {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.nav-dd-meta span {
|
||||
white-space: normal;
|
||||
}
|
||||
}
|
||||
}
|
||||
379
src/web/public/navigation-builder.js
Normal file
379
src/web/public/navigation-builder.js
Normal file
@ -0,0 +1,379 @@
|
||||
(() => {
|
||||
if (!window.LumiPage) return;
|
||||
|
||||
window.LumiPage.register("navigation-builder", {
|
||||
selector: "[data-navigation-builder]",
|
||||
mount(root, { signal }) {
|
||||
const form = root.querySelector("#nav-form");
|
||||
const sectionList = root.querySelector("[data-section-list]");
|
||||
const unassignedList = root.querySelector("[data-unassigned-list]");
|
||||
const jsonField = root.querySelector("#nav-sections-json");
|
||||
const advancedToggle = root.querySelector("[data-advanced-toggle]");
|
||||
const advancedPanel = root.querySelector("[data-advanced-panel]");
|
||||
const status = root.querySelector("[data-nav-builder-status]");
|
||||
const dirtyBadge = root.querySelector("[data-nav-dirty-badge]");
|
||||
const saveButton = root.querySelector("button[form='nav-form']");
|
||||
if (!form || !sectionList || !unassignedList || !jsonField) return;
|
||||
|
||||
const readJson = (id, fallback = []) => {
|
||||
try {
|
||||
return JSON.parse(document.getElementById(id)?.textContent || JSON.stringify(fallback));
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
};
|
||||
|
||||
const navItems = readJson("nav-items-data");
|
||||
const initialSections = readJson("nav-sections-data");
|
||||
const navIcons = readJson("nav-icons-data");
|
||||
const itemMap = new Map(navItems.map((item) => [item.navId, item]));
|
||||
let sectionSequence = 0;
|
||||
let canonicalSnapshot = "";
|
||||
let jsonPending = false;
|
||||
advancedPanel.hidden = true;
|
||||
|
||||
const escapeHtml = (value) => String(value || "").replace(/[&<>"']/g, (char) => ({
|
||||
"&": "&",
|
||||
"<": "<",
|
||||
">": ">",
|
||||
'"': """,
|
||||
"'": "'"
|
||||
}[char]));
|
||||
|
||||
const groupLists = () => [
|
||||
...sectionList.querySelectorAll("[data-section-items]"),
|
||||
unassignedList
|
||||
];
|
||||
|
||||
const groupLabel = (list) => list.dataset.groupLabel || list.closest("[data-nav-section-card]")?.querySelector(".nav-section-label")?.value || "section";
|
||||
|
||||
function buildItem(navId) {
|
||||
const data = itemMap.get(navId) || { navId, label: navId, path: navId };
|
||||
const label = String(data.label || navId);
|
||||
const item = document.createElement("article");
|
||||
item.className = "nav-dd-item";
|
||||
item.draggable = true;
|
||||
item.dataset.navId = navId;
|
||||
item.setAttribute("aria-label", `${label} navigation item`);
|
||||
item.innerHTML = `
|
||||
<span class="nav-dd-handle" aria-hidden="true">⋮⋮</span>
|
||||
<div class="nav-dd-meta">
|
||||
<strong>${escapeHtml(label)}</strong>
|
||||
<span>${escapeHtml(data.path || navId)}</span>
|
||||
</div>
|
||||
<div class="nav-item-actions" aria-label="Move ${escapeHtml(label)}">
|
||||
<button type="button" class="icon-button" data-item-up aria-label="Move ${escapeHtml(label)} up" title="Move up">↑</button>
|
||||
<button type="button" class="icon-button" data-item-down aria-label="Move ${escapeHtml(label)} down" title="Move down">↓</button>
|
||||
<button type="button" class="icon-button" data-item-previous-group aria-label="Move ${escapeHtml(label)} to the previous group" title="Move to previous group">←</button>
|
||||
<button type="button" class="icon-button" data-item-next-group aria-label="Move ${escapeHtml(label)} to the next group" title="Move to next group">→</button>
|
||||
</div>
|
||||
`;
|
||||
return item;
|
||||
}
|
||||
|
||||
function buildSection(section) {
|
||||
sectionSequence += 1;
|
||||
const fieldKey = `nav-section-${sectionSequence}`;
|
||||
const card = document.createElement("section");
|
||||
card.className = "nav-section-card";
|
||||
card.dataset.navSectionCard = "";
|
||||
card.innerHTML = `
|
||||
<div class="nav-section-header">
|
||||
<div class="field">
|
||||
<label for="${fieldKey}-label">Section label</label>
|
||||
<input id="${fieldKey}-label" type="text" class="nav-section-label" value="${escapeHtml(section.label || "")}" required>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="${fieldKey}-id">Section ID</label>
|
||||
<input id="${fieldKey}-id" type="text" class="nav-section-id" value="${escapeHtml(section.id || "")}" autocapitalize="none" spellcheck="false" required>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="${fieldKey}-icon">Section icon</label>
|
||||
<select id="${fieldKey}-icon" class="nav-section-icon"></select>
|
||||
</div>
|
||||
<div class="nav-section-actions" aria-label="Reorder or remove section">
|
||||
<button type="button" class="icon-button" data-section-up aria-label="Move section up" title="Move section up">↑</button>
|
||||
<button type="button" class="icon-button" data-section-down aria-label="Move section down" title="Move section down">↓</button>
|
||||
<button type="button" class="button subtle" data-section-remove>Remove</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="nav-dd-list" data-section-items data-group-label="${escapeHtml(section.label || section.id || "Section")}"></div>
|
||||
`;
|
||||
const select = card.querySelector(".nav-section-icon");
|
||||
const empty = document.createElement("option");
|
||||
empty.value = "";
|
||||
empty.textContent = "No icon";
|
||||
select.append(empty);
|
||||
navIcons.forEach((icon) => {
|
||||
const option = document.createElement("option");
|
||||
option.value = icon;
|
||||
option.textContent = icon;
|
||||
option.selected = icon === String(section.icon || "").toLowerCase();
|
||||
select.append(option);
|
||||
});
|
||||
const list = card.querySelector("[data-section-items]");
|
||||
(section.items || []).forEach((navId) => {
|
||||
if (itemMap.has(navId)) list.append(buildItem(navId));
|
||||
});
|
||||
return card;
|
||||
}
|
||||
|
||||
function collectSections() {
|
||||
return Array.from(sectionList.querySelectorAll("[data-nav-section-card]")).map((card, index) => {
|
||||
const label = card.querySelector(".nav-section-label").value.trim();
|
||||
const typedId = card.querySelector(".nav-section-id").value.trim();
|
||||
const id = typedId || label || `section-${index + 1}`;
|
||||
return {
|
||||
id,
|
||||
label: label || id,
|
||||
icon: card.querySelector(".nav-section-icon").value.trim(),
|
||||
items: Array.from(card.querySelectorAll(".nav-dd-item")).map((item) => item.dataset.navId)
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function syncJson() {
|
||||
jsonPending = false;
|
||||
jsonField.dataset.pending = "false";
|
||||
jsonField.value = JSON.stringify(collectSections(), null, 2);
|
||||
if (saveButton) saveButton.disabled = false;
|
||||
}
|
||||
|
||||
function snapshot() {
|
||||
const data = new FormData(form);
|
||||
return JSON.stringify(Array.from(data.entries()).map(([name, value]) => [name, String(value)]));
|
||||
}
|
||||
|
||||
function updateDirtyState(message = "Navigation has unsaved changes.") {
|
||||
const dirty = snapshot() !== canonicalSnapshot;
|
||||
root.dataset.dirty = String(dirty);
|
||||
dirtyBadge.hidden = !dirty;
|
||||
if (dirty && status && !jsonPending) status.textContent = message;
|
||||
if (!dirty && status) status.textContent = "All changes are saved.";
|
||||
}
|
||||
|
||||
function rebuildFromSections(sections, { focusFirst = false } = {}) {
|
||||
sectionList.replaceChildren();
|
||||
unassignedList.replaceChildren();
|
||||
const assigned = new Set();
|
||||
(sections || []).forEach((section) => {
|
||||
sectionList.append(buildSection(section));
|
||||
(section.items || []).forEach((navId) => assigned.add(navId));
|
||||
});
|
||||
navItems.forEach((item) => {
|
||||
if (!assigned.has(item.navId)) unassignedList.append(buildItem(item.navId));
|
||||
});
|
||||
syncJson();
|
||||
if (focusFirst) sectionList.querySelector(".nav-section-label")?.focus();
|
||||
}
|
||||
|
||||
function applyStructureSettings(structure = {}) {
|
||||
const enabled = form.elements.namedItem("nav_enabled");
|
||||
const includeUnassigned = form.elements.namedItem("nav_include_unassigned");
|
||||
if (enabled) enabled.checked = Boolean(structure.enabled);
|
||||
if (includeUnassigned) includeUnassigned.checked = structure.includeUnassigned !== false;
|
||||
form.elements.namedItem("nav_unassigned_label").value = structure.unassignedLabel || "Other";
|
||||
form.elements.namedItem("nav_unassigned_id").value = structure.unassignedId || "other";
|
||||
form.elements.namedItem("nav_unassigned_icon").value = structure.unassignedIcon || "";
|
||||
root.querySelectorAll("[data-switch-state]").forEach((state) => {
|
||||
const input = state.closest("label")?.querySelector("input[type='checkbox']");
|
||||
state.textContent = input?.checked ? "Enabled" : "Disabled";
|
||||
});
|
||||
}
|
||||
|
||||
function announce(message, role = "status") {
|
||||
if (!status) return;
|
||||
status.setAttribute("role", role);
|
||||
status.textContent = message;
|
||||
}
|
||||
|
||||
function moveItem(item, direction) {
|
||||
if (!item) return;
|
||||
const sibling = direction < 0 ? item.previousElementSibling : item.nextElementSibling;
|
||||
if (!sibling) return;
|
||||
if (direction < 0) item.parentElement.insertBefore(item, sibling);
|
||||
else item.parentElement.insertBefore(sibling, item);
|
||||
syncJson();
|
||||
updateDirtyState(`${item.querySelector("strong")?.textContent || "Item"} reordered.`);
|
||||
}
|
||||
|
||||
function moveItemGroup(item, direction) {
|
||||
if (!item) return;
|
||||
const lists = groupLists();
|
||||
const current = lists.indexOf(item.parentElement);
|
||||
const target = lists[current + direction];
|
||||
if (!target) return;
|
||||
target.append(item);
|
||||
syncJson();
|
||||
updateDirtyState(`${item.querySelector("strong")?.textContent || "Item"} moved to ${groupLabel(target)}.`);
|
||||
}
|
||||
|
||||
function moveSection(card, direction) {
|
||||
if (!card) return;
|
||||
const sibling = direction < 0 ? card.previousElementSibling : card.nextElementSibling;
|
||||
if (!sibling) return;
|
||||
if (direction < 0) sectionList.insertBefore(card, sibling);
|
||||
else sectionList.insertBefore(sibling, card);
|
||||
syncJson();
|
||||
updateDirtyState("Section order changed.");
|
||||
}
|
||||
|
||||
root.addEventListener("click", (event) => {
|
||||
const button = event.target.closest("button");
|
||||
if (!button || !root.contains(button)) return;
|
||||
const item = button.closest(".nav-dd-item");
|
||||
const card = button.closest("[data-nav-section-card]");
|
||||
if (button.matches("[data-add-section]")) {
|
||||
const existingIds = new Set(Array.from(sectionList.querySelectorAll(".nav-section-id"), (input) => input.value.trim().toLowerCase()));
|
||||
let index = sectionList.children.length + 1;
|
||||
while (existingIds.has(`section-${index}`)) index += 1;
|
||||
const next = buildSection({ id: `section-${index}`, label: `Section ${index}`, icon: "", items: [] });
|
||||
sectionList.append(next);
|
||||
syncJson();
|
||||
updateDirtyState("Section added.");
|
||||
next.querySelector(".nav-section-label")?.focus();
|
||||
} else if (button.matches("[data-advanced-toggle]")) {
|
||||
const expanded = button.getAttribute("aria-expanded") === "true";
|
||||
button.setAttribute("aria-expanded", String(!expanded));
|
||||
advancedPanel.hidden = expanded;
|
||||
if (!expanded) jsonField.focus();
|
||||
} else if (button.matches("[data-apply-json]")) {
|
||||
try {
|
||||
const parsed = JSON.parse(jsonField.value || "[]");
|
||||
if (!Array.isArray(parsed)) throw new TypeError("JSON must be an array of sections.");
|
||||
rebuildFromSections(parsed, { focusFirst: true });
|
||||
updateDirtyState("Advanced JSON applied to the visual builder.");
|
||||
announce("Advanced JSON applied. Review the visual builder, then save navigation.");
|
||||
} catch (error) {
|
||||
jsonPending = true;
|
||||
jsonField.dataset.pending = "true";
|
||||
jsonField.setAttribute("aria-invalid", "true");
|
||||
announce(error.message || "JSON is invalid.", "alert");
|
||||
jsonField.focus();
|
||||
}
|
||||
} else if (button.matches("[data-section-remove]")) {
|
||||
card.querySelectorAll(".nav-dd-item").forEach((entry) => unassignedList.append(entry));
|
||||
card.remove();
|
||||
syncJson();
|
||||
updateDirtyState("Section removed. Its items are now unassigned.");
|
||||
root.querySelector("[data-add-section]")?.focus();
|
||||
} else if (button.matches("[data-section-up]")) moveSection(card, -1);
|
||||
else if (button.matches("[data-section-down]")) moveSection(card, 1);
|
||||
else if (button.matches("[data-item-up]")) moveItem(item, -1);
|
||||
else if (button.matches("[data-item-down]")) moveItem(item, 1);
|
||||
else if (button.matches("[data-item-previous-group]")) moveItemGroup(item, -1);
|
||||
else if (button.matches("[data-item-next-group]")) moveItemGroup(item, 1);
|
||||
}, { signal });
|
||||
|
||||
root.addEventListener("input", (event) => {
|
||||
if (event.target === jsonField) {
|
||||
jsonPending = true;
|
||||
jsonField.dataset.pending = "true";
|
||||
jsonField.removeAttribute("aria-invalid");
|
||||
if (saveButton) saveButton.disabled = true;
|
||||
announce("Apply the advanced JSON to the visual builder before saving.");
|
||||
root.dataset.dirty = "true";
|
||||
dirtyBadge.hidden = false;
|
||||
return;
|
||||
}
|
||||
const card = event.target.closest("[data-nav-section-card]");
|
||||
if (card && event.target.matches(".nav-section-label")) {
|
||||
card.querySelector("[data-section-items]").dataset.groupLabel = event.target.value.trim() || "Section";
|
||||
}
|
||||
root.querySelectorAll("[data-switch-state]").forEach((state) => {
|
||||
const input = state.closest("label")?.querySelector("input[type='checkbox']");
|
||||
state.textContent = input?.checked ? "Enabled" : "Disabled";
|
||||
});
|
||||
syncJson();
|
||||
updateDirtyState();
|
||||
}, { signal });
|
||||
|
||||
root.addEventListener("change", (event) => {
|
||||
if (event.target === jsonField) return;
|
||||
syncJson();
|
||||
updateDirtyState();
|
||||
}, { signal });
|
||||
|
||||
root.addEventListener("dragstart", (event) => {
|
||||
const item = event.target.closest(".nav-dd-item");
|
||||
if (!item) return;
|
||||
item.classList.add("dragging");
|
||||
event.dataTransfer.effectAllowed = "move";
|
||||
event.dataTransfer.setData("text/plain", item.dataset.navId || "");
|
||||
}, { signal });
|
||||
|
||||
root.addEventListener("dragend", (event) => {
|
||||
const item = event.target.closest(".nav-dd-item");
|
||||
item?.classList.remove("dragging");
|
||||
root.querySelectorAll(".drag-over").forEach((entry) => entry.classList.remove("drag-over"));
|
||||
syncJson();
|
||||
updateDirtyState("Navigation item moved.");
|
||||
}, { signal });
|
||||
|
||||
root.addEventListener("dragover", (event) => {
|
||||
const list = event.target.closest(".nav-dd-list");
|
||||
const dragging = root.querySelector(".nav-dd-item.dragging");
|
||||
if (!list || !dragging) return;
|
||||
event.preventDefault();
|
||||
list.classList.add("drag-over");
|
||||
const siblings = Array.from(list.querySelectorAll(".nav-dd-item:not(.dragging)"));
|
||||
const after = siblings.reduce((closest, child) => {
|
||||
const box = child.getBoundingClientRect();
|
||||
const offset = event.clientY - box.top - box.height / 2;
|
||||
return offset < 0 && offset > closest.offset ? { offset, element: child } : closest;
|
||||
}, { offset: Number.NEGATIVE_INFINITY, element: null }).element;
|
||||
if (after) list.insertBefore(dragging, after);
|
||||
else list.append(dragging);
|
||||
}, { signal });
|
||||
|
||||
root.addEventListener("dragleave", (event) => {
|
||||
const list = event.target.closest(".nav-dd-list");
|
||||
if (list && !list.contains(event.relatedTarget)) list.classList.remove("drag-over");
|
||||
}, { signal });
|
||||
|
||||
root.addEventListener("drop", (event) => {
|
||||
const list = event.target.closest(".nav-dd-list");
|
||||
if (!list) return;
|
||||
event.preventDefault();
|
||||
list.classList.remove("drag-over");
|
||||
syncJson();
|
||||
updateDirtyState(`Item moved to ${groupLabel(list)}.`);
|
||||
}, { signal });
|
||||
|
||||
form.addEventListener("submit", () => {
|
||||
if (!jsonPending) syncJson();
|
||||
}, { signal });
|
||||
|
||||
root.addEventListener("lumi:form-saved", (event) => {
|
||||
const action = event.target?.getAttribute?.("action") || "";
|
||||
if (action.endsWith("/reset")) {
|
||||
const structure = event.detail?.data?.navStructure;
|
||||
if (structure) {
|
||||
applyStructureSettings(structure);
|
||||
rebuildFromSections(structure.sections || []);
|
||||
}
|
||||
canonicalSnapshot = snapshot();
|
||||
updateDirtyState();
|
||||
announce(event.detail?.message || "Navigation reset to Lumi defaults.");
|
||||
root.querySelector("[data-add-section]")?.focus({ preventScroll: true });
|
||||
return;
|
||||
}
|
||||
if (event.target === form) {
|
||||
canonicalSnapshot = snapshot();
|
||||
updateDirtyState();
|
||||
announce(event.detail?.message || "Navigation saved.");
|
||||
}
|
||||
}, { signal });
|
||||
|
||||
window.addEventListener("beforeunload", (event) => {
|
||||
if (root.dataset.dirty !== "true") return;
|
||||
event.preventDefault();
|
||||
event.returnValue = "";
|
||||
}, { signal });
|
||||
|
||||
rebuildFromSections(initialSections);
|
||||
canonicalSnapshot = snapshot();
|
||||
updateDirtyState();
|
||||
}
|
||||
});
|
||||
})();
|
||||
@ -1,3 +1,4 @@
|
||||
@layer features {
|
||||
:root,
|
||||
html,
|
||||
body {
|
||||
@ -28,3 +29,4 @@ body {
|
||||
.lumi-overlay-chat-dock {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
@layer features {
|
||||
.overlay-url-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
@ -413,11 +414,37 @@
|
||||
.overlay-preview-panel {
|
||||
min-width: 0;
|
||||
align-self: start;
|
||||
display: grid;
|
||||
grid-template-rows: repeat(7, auto);
|
||||
gap: var(--lumi-space-2);
|
||||
overflow: clip;
|
||||
}
|
||||
|
||||
.overlay-preview-launcher,
|
||||
.overlay-preview-close {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.overlay-preview-header {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.overlay-preview-header-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: var(--lumi-space-2);
|
||||
}
|
||||
|
||||
.overlay-preview-popout {
|
||||
min-height: 2rem;
|
||||
padding-block: 0.3rem;
|
||||
}
|
||||
|
||||
.overlay-editor-toolbar {
|
||||
justify-content: space-between;
|
||||
margin-bottom: var(--lumi-space-3);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.overlay-editor-toolbar label:first-child {
|
||||
@ -435,7 +462,7 @@
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--lumi-space-2);
|
||||
margin-bottom: var(--lumi-space-2);
|
||||
margin: 0;
|
||||
color: var(--lumi-text-muted);
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
@ -453,6 +480,8 @@
|
||||
|
||||
.overlay-canvas-frame {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
aspect-ratio: var(--overlay-aspect, 16 / 9);
|
||||
overflow: hidden;
|
||||
border: 1px solid color-mix(in srgb, var(--lumi-primary) 45%, var(--lumi-border));
|
||||
@ -468,6 +497,14 @@
|
||||
box-shadow: inset 0 0 0 1px rgba(255,255,255,0.04);
|
||||
}
|
||||
|
||||
.overlay-preview-stage {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.overlay-canvas-frame.preview-bg-dark {
|
||||
background: #070b12;
|
||||
}
|
||||
@ -516,6 +553,10 @@
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.overlay-canvas:focus-visible {
|
||||
box-shadow: inset 0 0 0 3px var(--lumi-focus, var(--lumi-primary));
|
||||
}
|
||||
|
||||
.overlay-canvas-layer {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
@ -592,7 +633,7 @@
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: var(--lumi-space-2);
|
||||
padding: var(--lumi-space-2) 0;
|
||||
padding: 0;
|
||||
color: var(--lumi-text-muted);
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
@ -601,7 +642,7 @@
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--lumi-space-1);
|
||||
margin-top: var(--lumi-space-3);
|
||||
margin-top: var(--lumi-space-2);
|
||||
}
|
||||
|
||||
.overlay-canvas-source-list .is-selected {
|
||||
@ -609,6 +650,28 @@
|
||||
background: color-mix(in srgb, var(--lumi-primary) 16%, var(--lumi-surface));
|
||||
}
|
||||
|
||||
.overlay-preview-details {
|
||||
min-height: 0;
|
||||
border-top: 1px solid var(--lumi-border);
|
||||
}
|
||||
|
||||
.overlay-preview-details > summary {
|
||||
padding-block: var(--lumi-space-2);
|
||||
cursor: pointer;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.overlay-preview-details-body {
|
||||
max-height: min(9rem, 22dvh);
|
||||
padding-right: var(--lumi-space-1);
|
||||
overflow: auto;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
.overlay-preview-details-body > .hint {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@media (min-width: 1180px) {
|
||||
.overlay-workspace {
|
||||
grid-template-columns: minmax(0, 1.15fr) minmax(25rem, 0.85fr);
|
||||
@ -616,21 +679,75 @@
|
||||
}
|
||||
.overlay-settings-column {
|
||||
min-height: 0;
|
||||
max-height: var(--overlay-settings-max-height, calc(100dvh - 2 * var(--lumi-space-4)));
|
||||
max-height: var(--overlay-workspace-height, calc(100dvh - (2 * var(--lumi-space-4))));
|
||||
overflow-x: clip;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
overscroll-behavior-y: contain;
|
||||
overscroll-behavior: contain;
|
||||
scrollbar-gutter: stable;
|
||||
padding: 0 var(--lumi-space-2) max(var(--lumi-space-7), env(safe-area-inset-bottom)) 0;
|
||||
}
|
||||
.overlay-preview-panel {
|
||||
position: sticky;
|
||||
top: var(--lumi-space-4);
|
||||
top: calc(var(--lumi-space-4) + env(safe-area-inset-top));
|
||||
height: auto;
|
||||
max-height: var(--overlay-workspace-height, calc(100dvh - (2 * var(--lumi-space-4))));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1179px) {
|
||||
.overlay-preview-panel { grid-row: 1; }
|
||||
.overlay-workspace {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.overlay-preview-panel {
|
||||
position: fixed;
|
||||
z-index: 1200;
|
||||
inset: max(var(--lumi-space-2), env(safe-area-inset-top)) max(var(--lumi-space-2), env(safe-area-inset-right)) auto auto;
|
||||
width: min(46rem, calc(100vw - (2 * var(--lumi-space-2))));
|
||||
max-width: calc(100vw - (2 * var(--lumi-space-2)));
|
||||
height: auto;
|
||||
max-height: calc(100dvh - (2 * var(--lumi-space-2)));
|
||||
overflow-x: clip;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior-y: auto;
|
||||
scrollbar-gutter: stable;
|
||||
visibility: hidden;
|
||||
opacity: 0;
|
||||
transform: translateX(calc(100% + var(--lumi-space-4)));
|
||||
transition: transform 180ms ease, opacity 140ms ease, visibility 0s linear 180ms;
|
||||
box-shadow: var(--lumi-shadow-lg);
|
||||
}
|
||||
|
||||
.overlay-preview-panel.is-open {
|
||||
visibility: visible;
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
transition-delay: 0s;
|
||||
}
|
||||
|
||||
.overlay-preview-launcher {
|
||||
position: fixed;
|
||||
z-index: 1190;
|
||||
top: calc(100dvh - max(var(--lumi-space-3), env(safe-area-inset-bottom)) - var(--lumi-control-height));
|
||||
right: max(var(--lumi-space-3), env(safe-area-inset-right));
|
||||
bottom: auto;
|
||||
display: inline-flex;
|
||||
box-shadow: var(--lumi-shadow-lg);
|
||||
}
|
||||
|
||||
.overlay-settings-column :where(summary, button, input, select, textarea) {
|
||||
scroll-margin-block: calc(var(--lumi-control-height) + (2 * var(--lumi-space-4)));
|
||||
}
|
||||
|
||||
.overlay-preview-close {
|
||||
display: inline-grid;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 700px), (pointer: coarse) {
|
||||
.overlay-preview-popout {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
@ -644,3 +761,25 @@
|
||||
.overlay-transform-fields,
|
||||
.overlay-conditional-fields { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
@media (max-height: 620px) {
|
||||
.overlay-preview-panel {
|
||||
grid-template-rows: repeat(7, auto);
|
||||
gap: var(--lumi-space-1);
|
||||
}
|
||||
|
||||
.overlay-preview-stage {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.overlay-preview-details-body {
|
||||
max-height: 5rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.overlay-preview-panel {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,4 +1,8 @@
|
||||
(() => {
|
||||
function mountOverlayManagement(root, { signal } = {}) {
|
||||
const lifecycleSignal = signal || new AbortController().signal;
|
||||
let obsStatusTimer = null;
|
||||
let editorCleanup = () => {};
|
||||
async function jsonRequest(url, options = {}) {
|
||||
const response = await fetch(url, {
|
||||
credentials: "same-origin",
|
||||
@ -38,33 +42,12 @@
|
||||
document.querySelectorAll(openSelector).forEach((button) => button.addEventListener("click", open));
|
||||
modal.querySelectorAll(closeSelector).forEach((button) => button.addEventListener("click", close));
|
||||
modal.addEventListener("click", (event) => { if (event.target === modal) close(); });
|
||||
document.addEventListener("keydown", (event) => { if (event.key === "Escape") close(); });
|
||||
document.addEventListener("keydown", (event) => { if (event.key === "Escape") close(); }, { signal: lifecycleSignal });
|
||||
}
|
||||
|
||||
wireModal("[data-overlay-create-modal]", "[data-overlay-create-open]", "[data-overlay-create-close]");
|
||||
wireModal("[data-scene-create-modal]", "[data-scene-create-open]", "[data-scene-create-close]");
|
||||
|
||||
const settingsColumn = document.querySelector(".overlay-settings-column");
|
||||
if (settingsColumn) {
|
||||
let layoutFrame = null;
|
||||
const syncSettingsHeight = () => {
|
||||
layoutFrame = null;
|
||||
if (!window.matchMedia("(min-width: 1180px)").matches) {
|
||||
settingsColumn.style.removeProperty("--overlay-settings-max-height");
|
||||
return;
|
||||
}
|
||||
const gap = 24;
|
||||
const top = Math.max(gap, settingsColumn.getBoundingClientRect().top);
|
||||
settingsColumn.style.setProperty("--overlay-settings-max-height", `${Math.max(240, Math.floor(window.innerHeight - top - gap))}px`);
|
||||
};
|
||||
const scheduleSettingsHeight = () => {
|
||||
if (layoutFrame === null) layoutFrame = window.requestAnimationFrame(syncSettingsHeight);
|
||||
};
|
||||
window.addEventListener("resize", scheduleSettingsHeight);
|
||||
window.addEventListener("scroll", scheduleSettingsHeight, { passive: true });
|
||||
syncSettingsHeight();
|
||||
}
|
||||
|
||||
const supportedHexColor = /^#(?:[0-9a-f]{3}|[0-9a-f]{4}|[0-9a-f]{6}|[0-9a-f]{8})$/i;
|
||||
const pickerValue = (value) => {
|
||||
if (!supportedHexColor.test(value)) return null;
|
||||
@ -149,6 +132,13 @@
|
||||
const sourceList = editorRoot.querySelector("[data-canvas-source-list]");
|
||||
const guideX = editorRoot.querySelector("[data-guide-x]");
|
||||
const guideY = editorRoot.querySelector("[data-guide-y]");
|
||||
const workspace = editorRoot.closest(".overlay-workspace");
|
||||
const previewStage = editorRoot.querySelector("[data-preview-stage]");
|
||||
const previewFrame = editorRoot.querySelector("[data-preview-frame]");
|
||||
const drawerToggle = document.querySelector("[data-preview-drawer-toggle]");
|
||||
const drawerClose = editorRoot.querySelector("[data-preview-drawer-close]");
|
||||
const popoutButton = editorRoot.querySelector("[data-preview-popout]");
|
||||
const widePreview = window.matchMedia("(min-width: 1180px)");
|
||||
const anchorFactors = {
|
||||
"top-left": [0, 0], "top-center": [0.5, 0], "top-right": [1, 0],
|
||||
"center-left": [0, 0.5], center: [0.5, 0.5], "center-right": [1, 0.5],
|
||||
@ -158,12 +148,141 @@
|
||||
let drag = null;
|
||||
let elements = new Map();
|
||||
const previewRequests = new Map();
|
||||
let previewChannel = null;
|
||||
|
||||
const currentScene = () => state.scenes.find((scene) => scene.id === sceneSelect.value) || state.scenes[0];
|
||||
const selectedModule = () => currentScene()?.modules.find((module) => module.id === selectedId) || null;
|
||||
const round = (number) => Math.round(number * 100) / 100;
|
||||
const clamp = (number, min, max) => Math.min(max, Math.max(min, number));
|
||||
|
||||
function previewState() {
|
||||
return {
|
||||
overlayId: state.overlayId,
|
||||
canvas: state.canvas,
|
||||
activeSceneId: state.activeSceneId,
|
||||
previewSceneId: sceneSelect.value,
|
||||
scenes: state.scenes
|
||||
};
|
||||
}
|
||||
|
||||
function broadcastPreview() {
|
||||
previewChannel?.postMessage({ type: "state", state: previewState() });
|
||||
}
|
||||
|
||||
function sizeWorkspace() {
|
||||
if (!widePreview.matches) {
|
||||
workspace?.style.removeProperty("--overlay-workspace-height");
|
||||
return;
|
||||
}
|
||||
const styles = getComputedStyle(editorRoot);
|
||||
const stickyTop = Number.parseFloat(styles.top) || 0;
|
||||
const currentTop = Math.max(stickyTop, workspace?.getBoundingClientRect().top || editorRoot.getBoundingClientRect().top);
|
||||
const bottomSpace = Math.max(8, stickyTop);
|
||||
workspace?.style.setProperty("--overlay-workspace-height", `${Math.max(180, Math.floor(window.innerHeight - currentTop - bottomSpace))}px`);
|
||||
}
|
||||
|
||||
function fitPreview() {
|
||||
if (!previewStage || !previewFrame) return;
|
||||
sizeWorkspace();
|
||||
const sourceWidth = Math.max(1, Number(state.canvas?.width) || 1920);
|
||||
const sourceHeight = Math.max(1, Number(state.canvas?.height) || 1080);
|
||||
const availableWidth = Math.max(1, previewStage.clientWidth);
|
||||
const panelMaxHeight = Number.parseFloat(getComputedStyle(editorRoot).maxHeight) || window.innerHeight;
|
||||
const nonCanvasHeight = Math.max(0, editorRoot.scrollHeight - previewStage.offsetHeight);
|
||||
const availableHeight = Math.max(72, panelMaxHeight - nonCanvasHeight);
|
||||
const scale = Math.min(availableWidth / sourceWidth, availableHeight / sourceHeight);
|
||||
const frameWidth = Math.max(1, sourceWidth * scale);
|
||||
const frameHeight = Math.max(1, sourceHeight * scale);
|
||||
previewFrame.style.width = `${frameWidth}px`;
|
||||
previewFrame.style.height = `${frameHeight}px`;
|
||||
previewStage.style.height = `${frameHeight}px`;
|
||||
previewFrame.style.setProperty("--overlay-aspect", `${sourceWidth} / ${sourceHeight}`);
|
||||
}
|
||||
|
||||
function scaleRenderedCanvas() {
|
||||
fitPreview();
|
||||
const stageElement = layer.querySelector(":scope > .lumi-overlay-stage");
|
||||
if (!stageElement) return;
|
||||
const layout = renderer.stageLayout(layer, state.canvas);
|
||||
stageElement.style.width = `${layout.width}px`;
|
||||
stageElement.style.height = `${layout.height}px`;
|
||||
stageElement.style.transform = `scale(${layout.scaleX}, ${layout.scaleY})`;
|
||||
const editorScale = Math.max(0.01, Math.min(layout.scaleX, layout.scaleY));
|
||||
const handleSize = window.matchMedia("(max-width: 700px)").matches ? 14 : 10;
|
||||
stageElement.style.setProperty("--lumi-editor-handle-size", `${handleSize / editorScale}px`);
|
||||
stageElement.style.setProperty("--lumi-editor-outline-size", `${2 / editorScale}px`);
|
||||
stageElement.style.setProperty("--lumi-editor-label-font-size", `${11.2 / editorScale}px`);
|
||||
stageElement.style.setProperty("--lumi-editor-label-max-width", `${192 / editorScale}px`);
|
||||
stageElement.style.setProperty("--lumi-editor-label-padding-y", `${3.2 / editorScale}px`);
|
||||
stageElement.style.setProperty("--lumi-editor-label-padding-x", `${6.4 / editorScale}px`);
|
||||
stageElement.style.setProperty("--lumi-editor-label-radius", `${4 / editorScale}px`);
|
||||
}
|
||||
|
||||
function setDrawerOpen(open, { restoreFocus = false } = {}) {
|
||||
if (widePreview.matches) {
|
||||
editorRoot.classList.remove("is-open");
|
||||
editorRoot.inert = false;
|
||||
editorRoot.removeAttribute("aria-hidden");
|
||||
drawerToggle?.setAttribute("aria-expanded", "true");
|
||||
return;
|
||||
}
|
||||
editorRoot.classList.toggle("is-open", open);
|
||||
editorRoot.inert = !open;
|
||||
editorRoot.setAttribute("aria-hidden", open ? "false" : "true");
|
||||
drawerToggle?.setAttribute("aria-expanded", open ? "true" : "false");
|
||||
if (open) requestAnimationFrame(() => {
|
||||
renderCanvas();
|
||||
sceneSelect.focus({ preventScroll: true });
|
||||
});
|
||||
if (!open && restoreFocus) drawerToggle?.focus({ preventScroll: true });
|
||||
}
|
||||
|
||||
drawerToggle?.addEventListener("click", () => setDrawerOpen(!editorRoot.classList.contains("is-open")), { signal: lifecycleSignal });
|
||||
drawerClose?.addEventListener("click", () => setDrawerOpen(false, { restoreFocus: true }), { signal: lifecycleSignal });
|
||||
document.addEventListener("keydown", (event) => {
|
||||
if (event.key === "Escape" && !widePreview.matches && editorRoot.classList.contains("is-open")) setDrawerOpen(false, { restoreFocus: true });
|
||||
}, { signal: lifecycleSignal });
|
||||
widePreview.addEventListener("change", () => {
|
||||
setDrawerOpen(false);
|
||||
requestAnimationFrame(renderCanvas);
|
||||
}, { signal: lifecycleSignal });
|
||||
setDrawerOpen(false);
|
||||
|
||||
if ("BroadcastChannel" in window) {
|
||||
previewChannel = new BroadcastChannel(`lumi-overlay-preview:${state.overlayId}`);
|
||||
previewChannel.addEventListener("message", (event) => {
|
||||
if (event.data?.type === "request-state") {
|
||||
broadcastPreview();
|
||||
return;
|
||||
}
|
||||
if (event.data?.type !== "transform" || !event.data.moduleId || !event.data.config) return;
|
||||
const module = moduleForId(event.data.moduleId);
|
||||
if (!module) return;
|
||||
if (event.data.sceneId && state.scenes.some((scene) => scene.id === event.data.sceneId)) sceneSelect.value = event.data.sceneId;
|
||||
Object.assign(module.config, event.data.config);
|
||||
const saved = savedModules.get(module.id);
|
||||
if (saved) Object.assign(saved.config, event.data.config);
|
||||
selectedId = module.id;
|
||||
syncForm(module);
|
||||
renderCanvas();
|
||||
saveState.textContent = "Saved from pop-out";
|
||||
setTimeout(() => { if (saveState.textContent === "Saved from pop-out") saveState.textContent = "Ready"; }, 1600);
|
||||
});
|
||||
}
|
||||
|
||||
popoutButton?.addEventListener("click", () => {
|
||||
const url = editorRoot.dataset.previewPopoutUrl;
|
||||
if (!url) return;
|
||||
const popup = window.open(url, `lumi-overlay-preview-${state.overlayId}`, "popup=yes,width=960,height=600,resizable=yes,scrollbars=no");
|
||||
if (!popup) {
|
||||
const original = popoutButton.textContent;
|
||||
popoutButton.textContent = "Allow pop-ups to preview";
|
||||
setTimeout(() => { popoutButton.textContent = original; }, 2400);
|
||||
return;
|
||||
}
|
||||
setTimeout(broadcastPreview, 250);
|
||||
}, { signal: lifecycleSignal });
|
||||
|
||||
function edges(config) {
|
||||
const [ax, ay] = anchorFactors[config.anchor] || anchorFactors["top-left"];
|
||||
const width = Number(config.width || 0);
|
||||
@ -257,6 +376,17 @@
|
||||
setPreviewStatus(form, "Saving…");
|
||||
saveState.textContent = "Saving…";
|
||||
});
|
||||
form.addEventListener("lumi:form-saved", (event) => {
|
||||
const canonical = event.detail?.data?.result;
|
||||
const module = moduleForId(form.dataset.moduleId);
|
||||
if (!canonical || !module) return;
|
||||
Object.assign(module, canonical);
|
||||
savedModules.set(module.id, JSON.parse(JSON.stringify(module)));
|
||||
selectedId = module.id;
|
||||
renderCanvas();
|
||||
setPreviewStatus(form, "Saved");
|
||||
saveState.textContent = "Saved";
|
||||
});
|
||||
form.querySelector("[data-source-revert]")?.addEventListener("click", () => {
|
||||
const saved = savedModules.get(form.dataset.moduleId);
|
||||
const module = moduleForId(form.dataset.moduleId);
|
||||
@ -287,6 +417,7 @@
|
||||
|
||||
function renderCanvas() {
|
||||
const scene = currentScene();
|
||||
fitPreview();
|
||||
elements = renderer.render(layer, { enabled: true, canvas: state.canvas, scene }, { editor: true, canvas: state.canvas });
|
||||
sourceList.replaceChildren();
|
||||
for (const module of scene?.modules || []) {
|
||||
@ -302,6 +433,7 @@
|
||||
if (!elements.has(selectedId)) selectedId = elements.keys().next().value || null;
|
||||
if (selectedId) selectSource(selectedId, { focusCanvas: false });
|
||||
else updateReadout();
|
||||
broadcastPreview();
|
||||
}
|
||||
|
||||
function showGuide(axis, value) {
|
||||
@ -423,13 +555,13 @@
|
||||
handle: event.target.closest("[data-resize-handle]")?.dataset.resizeHandle || null
|
||||
};
|
||||
});
|
||||
window.addEventListener("pointermove", applyDrag);
|
||||
window.addEventListener("pointermove", applyDrag, { signal: lifecycleSignal });
|
||||
window.addEventListener("pointerup", () => {
|
||||
if (!drag) return;
|
||||
drag = null;
|
||||
clearGuides();
|
||||
saveTransform(selectedModule());
|
||||
});
|
||||
}, { signal: lifecycleSignal });
|
||||
|
||||
canvas.addEventListener("keydown", (event) => {
|
||||
if (!selectedModule() || !["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown"].includes(event.key)) return;
|
||||
@ -460,7 +592,6 @@
|
||||
editorRoot.querySelector("[data-editor-center]")?.addEventListener("click", () => transformSelected({ x: 50, y: 50, anchor: "center" }));
|
||||
editorRoot.querySelector("[data-editor-fit]")?.addEventListener("click", () => transformSelected({ x: 0, y: 0, width: 100, height: 100, anchor: "top-left" }));
|
||||
editorRoot.querySelector("[data-editor-reset]")?.addEventListener("click", () => transformSelected({ x: 0, y: 0, width: 50, height: 25, anchor: "top-left" }));
|
||||
const previewFrame = editorRoot.querySelector("[data-preview-frame]");
|
||||
editorRoot.querySelectorAll("[data-preview-background]").forEach((button) => button.addEventListener("click", () => {
|
||||
const background = button.dataset.previewBackground;
|
||||
previewFrame?.classList.toggle("preview-bg-dark", background === "dark");
|
||||
@ -474,23 +605,48 @@
|
||||
});
|
||||
snapToggle.addEventListener("change", () => { snapHint.textContent = snapToggle.checked ? "off" : "on"; });
|
||||
sceneSelect.addEventListener("change", () => { selectedId = null; renderCanvas(); });
|
||||
window.addEventListener("resize", () => renderCanvas());
|
||||
let previewLayoutFrame = null;
|
||||
const schedulePreviewLayout = () => {
|
||||
if (previewLayoutFrame) return;
|
||||
previewLayoutFrame = requestAnimationFrame(() => {
|
||||
previewLayoutFrame = null;
|
||||
scaleRenderedCanvas();
|
||||
});
|
||||
};
|
||||
window.addEventListener("resize", schedulePreviewLayout, { signal: lifecycleSignal });
|
||||
window.addEventListener("scroll", schedulePreviewLayout, { signal: lifecycleSignal, passive: true });
|
||||
editorRoot.querySelector(".overlay-preview-details")?.addEventListener("toggle", schedulePreviewLayout, { signal: lifecycleSignal });
|
||||
|
||||
document.querySelectorAll("[data-edit-scene]").forEach((button) => button.addEventListener("click", () => {
|
||||
sceneSelect.value = button.dataset.editScene;
|
||||
selectedId = null;
|
||||
renderCanvas();
|
||||
editorRoot.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
}));
|
||||
document.querySelectorAll("[data-select-source]").forEach((button) => button.addEventListener("click", () => {
|
||||
const scene = state.scenes.find((item) => item.modules.some((module) => module.id === button.dataset.selectSource));
|
||||
if (scene) sceneSelect.value = scene.id;
|
||||
selectedId = button.dataset.selectSource;
|
||||
renderCanvas();
|
||||
editorRoot.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
}));
|
||||
|
||||
editorApi = {
|
||||
reconcile(nextState) {
|
||||
if (!nextState?.scenes) return;
|
||||
const selectedScene = sceneSelect.value;
|
||||
state.canvas = nextState.canvas || state.canvas;
|
||||
state.activeSceneId = nextState.activeSceneId || state.activeSceneId;
|
||||
state.scenes = nextState.scenes;
|
||||
sceneSelect.replaceChildren(...state.scenes.map((scene) => {
|
||||
const option = document.createElement("option");
|
||||
option.value = scene.id;
|
||||
option.textContent = scene.name;
|
||||
return option;
|
||||
}));
|
||||
sceneSelect.value = state.scenes.some((scene) => scene.id === selectedScene) ? selectedScene : state.activeSceneId || state.scenes[0]?.id || "";
|
||||
const frame = editorRoot.querySelector("[data-preview-frame]");
|
||||
if (frame && state.canvas?.width && state.canvas?.height) frame.style.setProperty("--overlay-aspect", `${state.canvas.width} / ${state.canvas.height}`);
|
||||
renderCanvas();
|
||||
},
|
||||
reload(moduleId) {
|
||||
const frame = renderer.findSourceContent(layer, moduleId, ".lumi-overlay-frame");
|
||||
if (frame) {
|
||||
@ -502,6 +658,10 @@
|
||||
renderer.restartMediaElement(renderer.findSourceContent(layer, moduleId, "video, audio"));
|
||||
}
|
||||
};
|
||||
editorCleanup = () => {
|
||||
if (previewLayoutFrame) cancelAnimationFrame(previewLayoutFrame);
|
||||
previewChannel?.close();
|
||||
};
|
||||
renderCanvas();
|
||||
}
|
||||
|
||||
@ -571,7 +731,7 @@
|
||||
});
|
||||
|
||||
const connector = document.querySelector("[data-obs-connector]");
|
||||
if (!connector) return;
|
||||
if (!connector) return editorCleanup;
|
||||
const overlayId = connector.dataset.overlayId;
|
||||
const settingsForm = connector.querySelector("[data-obs-settings-form]");
|
||||
const sceneForm = connector.querySelector("[data-obs-scene-form]");
|
||||
@ -633,7 +793,10 @@
|
||||
catch (requestError) { renderStatus({ state: "error", error: requestError.message }); }
|
||||
};
|
||||
connector.querySelector("[data-obs-refresh]")?.addEventListener("click", refreshObsStatus);
|
||||
window.setInterval(refreshObsStatus, 5000);
|
||||
settingsForm?.addEventListener("lumi:form-saved", (event) => {
|
||||
if (event.detail?.data?.status) renderStatus(event.detail.data.status);
|
||||
});
|
||||
obsStatusTimer = window.setInterval(refreshObsStatus, 5000);
|
||||
connector.querySelector("[data-obs-test]")?.addEventListener("click", async () => {
|
||||
const data = new FormData(settingsForm);
|
||||
renderStatus({ state: "testing", error: null });
|
||||
@ -647,8 +810,10 @@
|
||||
if (importResult) importResult.textContent = "Applying the detected OBS setup…";
|
||||
try {
|
||||
const result = await jsonRequest(`/api/admin/overlays/${overlayId}/obs/import`, { method: "POST", body: "{}" });
|
||||
if (importResult) importResult.textContent = `${result.message}${result.canvas_updated ? " Canvas size updated." : ""} Reloading…`;
|
||||
window.setTimeout(() => window.location.reload(), 700);
|
||||
editorApi?.reconcile(result.editor);
|
||||
renderStatus(result.status || await jsonRequest(`/api/admin/overlays/${overlayId}/obs/status`));
|
||||
if (importResult) importResult.textContent = `${result.message}${result.canvas_updated ? " Canvas size and preview updated." : " Preview updated."}`;
|
||||
button.disabled = false;
|
||||
} catch (requestError) {
|
||||
if (importResult) importResult.textContent = requestError.message;
|
||||
button.disabled = false;
|
||||
@ -661,4 +826,15 @@
|
||||
try { renderStatus(await jsonRequest(`/api/admin/overlays/${overlayId}/obs/scene`, { method: "POST", body: JSON.stringify({ scene_name: sceneName }) })); }
|
||||
catch (requestError) { renderStatus({ state: "error", error: requestError.message }); }
|
||||
});
|
||||
return () => {
|
||||
if (obsStatusTimer) window.clearInterval(obsStatusTimer);
|
||||
editorCleanup();
|
||||
};
|
||||
}
|
||||
|
||||
if (window.LumiPage) {
|
||||
window.LumiPage.register("overlay-management", { mount: mountOverlayManagement });
|
||||
} else {
|
||||
document.addEventListener("DOMContentLoaded", () => mountOverlayManagement(document), { once: true });
|
||||
}
|
||||
})();
|
||||
|
||||
220
src/web/public/overlay-preview-window.css
Normal file
220
src/web/public/overlay-preview-window.css
Normal file
@ -0,0 +1,220 @@
|
||||
@layer features {
|
||||
html,
|
||||
body {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
background: #07090d !important;
|
||||
}
|
||||
|
||||
.overlay-preview-window-stage {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: grid;
|
||||
grid-template-rows: auto minmax(0, 1fr);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.overlay-preview-window-viewport {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.overlay-preview-window-frame {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
aspect-ratio: var(--overlay-aspect, 16 / 9);
|
||||
overflow: hidden;
|
||||
background:
|
||||
linear-gradient(45deg, #252a2f 25%, transparent 25%),
|
||||
linear-gradient(-45deg, #252a2f 25%, transparent 25%),
|
||||
linear-gradient(45deg, transparent 75%, #252a2f 75%),
|
||||
linear-gradient(-45deg, transparent 75%, #252a2f 75%),
|
||||
#171b1f;
|
||||
background-position: 0 0, 0 12px, 12px -12px, -12px 0;
|
||||
background-size: 24px 24px;
|
||||
}
|
||||
|
||||
.overlay-preview-window-canvas {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
overflow: hidden;
|
||||
outline: none;
|
||||
touch-action: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.overlay-preview-window-canvas:focus-visible {
|
||||
box-shadow: inset 0 0 0 3px #38bdf8;
|
||||
}
|
||||
|
||||
.overlay-preview-window-layer {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
.overlay-preview-window-toolbar {
|
||||
position: relative;
|
||||
z-index: 4000;
|
||||
width: min(42rem, calc(100% - 1.2rem));
|
||||
display: grid;
|
||||
gap: 0.45rem;
|
||||
padding: 0.65rem;
|
||||
border: 1px solid rgba(255, 255, 255, 0.24);
|
||||
border-radius: 0.75rem;
|
||||
background: rgba(7, 9, 13, 0.86);
|
||||
color: #f8fafc;
|
||||
box-shadow: 0 1rem 2.5rem rgba(0, 0, 0, 0.35);
|
||||
font: 600 0.78rem/1.25 system-ui, sans-serif;
|
||||
backdrop-filter: blur(12px);
|
||||
margin: max(0.6rem, env(safe-area-inset-top)) max(0.6rem, env(safe-area-inset-right)) 0 max(0.6rem, env(safe-area-inset-left));
|
||||
}
|
||||
|
||||
.overlay-preview-window-fields,
|
||||
.overlay-preview-window-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: end;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.overlay-preview-window-fields label {
|
||||
display: grid;
|
||||
gap: 0.2rem;
|
||||
}
|
||||
|
||||
.overlay-preview-window-fields label:first-child,
|
||||
.overlay-preview-window-fields label:nth-child(2) {
|
||||
flex: 1 1 10rem;
|
||||
}
|
||||
|
||||
.overlay-preview-window-fields select,
|
||||
.overlay-preview-window-actions button {
|
||||
min-height: 2rem;
|
||||
border: 1px solid rgba(255, 255, 255, 0.28);
|
||||
border-radius: 0.45rem;
|
||||
background: #151a22;
|
||||
color: #f8fafc;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.overlay-preview-window-fields select {
|
||||
width: 100%;
|
||||
padding: 0.25rem 0.45rem;
|
||||
}
|
||||
|
||||
.overlay-preview-window-actions button {
|
||||
padding: 0.3rem 0.65rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.overlay-preview-window-fields select:focus-visible,
|
||||
.overlay-preview-window-actions button:focus-visible {
|
||||
outline: 3px solid #38bdf8;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.overlay-preview-window-snap {
|
||||
min-height: 2rem;
|
||||
display: flex !important;
|
||||
grid-auto-flow: column;
|
||||
align-items: center;
|
||||
gap: 0.35rem !important;
|
||||
}
|
||||
|
||||
.overlay-preview-window-readout,
|
||||
.overlay-preview-window-status {
|
||||
margin: 0;
|
||||
color: rgba(255, 255, 255, 0.76);
|
||||
}
|
||||
|
||||
.overlay-preview-window-canvas .lumi-overlay-module {
|
||||
pointer-events: auto;
|
||||
cursor: move;
|
||||
}
|
||||
|
||||
.overlay-preview-window-canvas .lumi-overlay-module.is-selected {
|
||||
z-index: 2000 !important;
|
||||
overflow: visible;
|
||||
outline: var(--lumi-editor-outline-size, 2px) solid #ef4444;
|
||||
outline-offset: -1px;
|
||||
}
|
||||
|
||||
.overlay-canvas-source-label {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
bottom: 100%;
|
||||
display: none;
|
||||
max-width: var(--lumi-editor-label-max-width, 12rem);
|
||||
padding: var(--lumi-editor-label-padding-y, 0.2rem) var(--lumi-editor-label-padding-x, 0.4rem);
|
||||
overflow: hidden;
|
||||
border-radius: var(--lumi-editor-label-radius, 4px) var(--lumi-editor-label-radius, 4px) 0 0;
|
||||
background: #ef4444;
|
||||
color: #fff;
|
||||
font: 700 var(--lumi-editor-label-font-size, 0.7rem)/1.2 system-ui, sans-serif;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.is-selected > .overlay-canvas-source-label,
|
||||
.is-selected > .overlay-resize-handle {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.overlay-resize-handle {
|
||||
position: absolute;
|
||||
z-index: 5;
|
||||
width: var(--lumi-editor-handle-size, 10px);
|
||||
height: var(--lumi-editor-handle-size, 10px);
|
||||
display: none;
|
||||
padding: 0;
|
||||
border: 1px solid #fff;
|
||||
border-radius: 1px;
|
||||
background: #ef4444;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
|
||||
.handle-nw { left: 0; top: 0; cursor: nwse-resize; }
|
||||
.handle-n { left: 50%; top: 0; cursor: ns-resize; }
|
||||
.handle-ne { left: 100%; top: 0; cursor: nesw-resize; }
|
||||
.handle-e { left: 100%; top: 50%; cursor: ew-resize; }
|
||||
.handle-se { left: 100%; top: 100%; cursor: nwse-resize; }
|
||||
.handle-s { left: 50%; top: 100%; cursor: ns-resize; }
|
||||
.handle-sw { left: 0; top: 100%; cursor: nesw-resize; }
|
||||
.handle-w { left: 0; top: 50%; cursor: ew-resize; }
|
||||
|
||||
.overlay-preview-window-guide {
|
||||
position: absolute;
|
||||
z-index: 3000;
|
||||
display: none;
|
||||
pointer-events: none;
|
||||
background: #38bdf8;
|
||||
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.overlay-preview-window-guide.is-visible { display: block; }
|
||||
.overlay-preview-window-guide.guide-x { top: 0; bottom: 0; width: 1px; }
|
||||
.overlay-preview-window-guide.guide-y { left: 0; right: 0; height: 1px; }
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.overlay-preview-window-toolbar {
|
||||
width: calc(100% - 1.2rem);
|
||||
}
|
||||
|
||||
.overlay-preview-window-fields label:first-child,
|
||||
.overlay-preview-window-fields label:nth-child(2) {
|
||||
flex-basis: calc(50% - 0.5rem);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, *::before, *::after {
|
||||
scroll-behavior: auto !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
281
src/web/public/overlay-preview-window.js
Normal file
281
src/web/public/overlay-preview-window.js
Normal file
@ -0,0 +1,281 @@
|
||||
(() => {
|
||||
const stage = document.querySelector("[data-overlay-preview-window]");
|
||||
const previewViewport = document.querySelector("[data-preview-window-viewport]");
|
||||
const frame = document.querySelector("[data-preview-window-frame]");
|
||||
const canvas = document.querySelector("[data-preview-window-canvas]");
|
||||
const layer = document.querySelector("[data-preview-window-layer]");
|
||||
const status = document.querySelector("[data-preview-window-status]");
|
||||
const readout = document.querySelector("[data-popout-readout]");
|
||||
const sceneSelect = document.querySelector("[data-popout-scene]");
|
||||
const sourceSelect = document.querySelector("[data-popout-source]");
|
||||
const snapToggle = document.querySelector("[data-popout-snap]");
|
||||
const guideX = document.querySelector("[data-popout-guide-x]");
|
||||
const guideY = document.querySelector("[data-popout-guide-y]");
|
||||
const stateData = document.querySelector("[data-overlay-preview-state]");
|
||||
const renderer = window.LumiOverlayRenderer;
|
||||
if (!stage || !previewViewport || !frame || !canvas || !layer || !stateData || !renderer) return;
|
||||
|
||||
const anchorFactors = {
|
||||
"top-left": [0, 0], "top-center": [0.5, 0], "top-right": [1, 0],
|
||||
"center-left": [0, 0.5], center: [0.5, 0.5], "center-right": [1, 0.5],
|
||||
"bottom-left": [0, 1], "bottom-center": [0.5, 1], "bottom-right": [1, 1]
|
||||
};
|
||||
let editorState = JSON.parse(stateData.textContent || "{}");
|
||||
let selectedId = null;
|
||||
let elements = new Map();
|
||||
let drag = null;
|
||||
let saveTimer = null;
|
||||
const channelName = `lumi-overlay-preview:${editorState.overlayId}`;
|
||||
let channel = null;
|
||||
|
||||
const clamp = (number, min, max) => Math.min(max, Math.max(min, number));
|
||||
const round = (number) => Math.round(number * 100) / 100;
|
||||
const selectedScene = () => editorState.scenes?.find((scene) => scene.id === sceneSelect.value)
|
||||
|| editorState.scenes?.find((scene) => scene.id === editorState.previewSceneId)
|
||||
|| editorState.scenes?.find((scene) => scene.id === editorState.activeSceneId)
|
||||
|| editorState.scenes?.[0]
|
||||
|| null;
|
||||
const selectedModule = () => selectedScene()?.modules.find((module) => module.id === selectedId) || null;
|
||||
|
||||
function edges(config) {
|
||||
const [ax, ay] = anchorFactors[config.anchor] || anchorFactors["top-left"];
|
||||
const width = Number(config.width || 0);
|
||||
const height = Number(config.height || 0);
|
||||
const left = Number(config.x || 0) - ax * width;
|
||||
const top = Number(config.y || 0) - ay * height;
|
||||
return { left, top, right: left + width, bottom: top + height };
|
||||
}
|
||||
|
||||
function configFromEdges(box, anchor) {
|
||||
const [ax, ay] = anchorFactors[anchor] || anchorFactors["top-left"];
|
||||
const width = clamp(box.right - box.left, 0.5, 100);
|
||||
const height = clamp(box.bottom - box.top, 0.5, 100);
|
||||
return {
|
||||
x: clamp(box.left + ax * width, 0, 100),
|
||||
y: clamp(box.top + ay * height, 0, 100),
|
||||
width,
|
||||
height
|
||||
};
|
||||
}
|
||||
|
||||
function fitFrame() {
|
||||
const width = Math.max(1, Number(editorState.canvas?.width) || 1920);
|
||||
const height = Math.max(1, Number(editorState.canvas?.height) || 1080);
|
||||
const scale = Math.min(previewViewport.clientWidth / width, previewViewport.clientHeight / height);
|
||||
frame.style.width = `${Math.max(1, width * scale)}px`;
|
||||
frame.style.height = `${Math.max(1, height * scale)}px`;
|
||||
frame.style.setProperty("--overlay-aspect", `${width} / ${height}`);
|
||||
}
|
||||
|
||||
function updateReadout() {
|
||||
const module = selectedModule();
|
||||
if (!module) {
|
||||
readout.textContent = "No source selected";
|
||||
return;
|
||||
}
|
||||
readout.textContent = `${module.name} · X ${round(module.config.x)}% · Y ${round(module.config.y)}% · ${round(module.config.width)} × ${round(module.config.height)}%`;
|
||||
}
|
||||
|
||||
function selectSource(moduleId, { focus = true } = {}) {
|
||||
selectedId = moduleId;
|
||||
layer.querySelectorAll(".lumi-overlay-module").forEach((element) => element.classList.toggle("is-selected", element.dataset.moduleId === moduleId));
|
||||
sourceSelect.value = moduleId || "";
|
||||
updateReadout();
|
||||
if (focus) canvas.focus({ preventScroll: true });
|
||||
}
|
||||
|
||||
function syncControls() {
|
||||
const sceneId = selectedScene()?.id || "";
|
||||
sceneSelect.replaceChildren(...(editorState.scenes || []).map((scene) => new Option(scene.name, scene.id)));
|
||||
sceneSelect.value = sceneId;
|
||||
const scene = selectedScene();
|
||||
sourceSelect.replaceChildren(new Option("Choose a source", ""), ...(scene?.modules || []).filter((module) => module.enabled).map((module) => new Option(module.name, module.id)));
|
||||
sourceSelect.disabled = !(scene?.modules || []).some((module) => module.enabled);
|
||||
}
|
||||
|
||||
function render() {
|
||||
fitFrame();
|
||||
syncControls();
|
||||
const scene = selectedScene();
|
||||
elements = renderer.render(layer, { enabled: true, canvas: editorState.canvas, scene }, { editor: true, canvas: editorState.canvas });
|
||||
if (!elements.has(selectedId)) selectedId = elements.keys().next().value || null;
|
||||
selectSource(selectedId, { focus: false });
|
||||
if (!/^(Saving|Saved)/.test(status.textContent)) status.textContent = `${editorState.canvas?.width || 1920} × ${editorState.canvas?.height || 1080} · drag, resize, or use arrow keys`;
|
||||
}
|
||||
|
||||
function resize() {
|
||||
fitFrame();
|
||||
const renderedStage = layer.querySelector(":scope > .lumi-overlay-stage");
|
||||
if (!renderedStage) return;
|
||||
const layout = renderer.stageLayout(layer, editorState.canvas);
|
||||
renderedStage.style.width = `${layout.width}px`;
|
||||
renderedStage.style.height = `${layout.height}px`;
|
||||
renderedStage.style.transform = `scale(${layout.scaleX}, ${layout.scaleY})`;
|
||||
}
|
||||
|
||||
function showGuide(axis, value) {
|
||||
const guide = axis === "x" ? guideX : guideY;
|
||||
guide.style[axis === "x" ? "left" : "top"] = `${value}%`;
|
||||
guide.classList.add("is-visible");
|
||||
}
|
||||
|
||||
function clearGuides() {
|
||||
guideX.classList.remove("is-visible");
|
||||
guideY.classList.remove("is-visible");
|
||||
}
|
||||
|
||||
function snapAdjustment(points, threshold) {
|
||||
let best = null;
|
||||
for (const point of points) for (const target of [0, 50, 100]) {
|
||||
const difference = target - point;
|
||||
if (Math.abs(difference) <= threshold && (!best || Math.abs(difference) < Math.abs(best.difference))) best = { difference, target };
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
function snappingActive(event) {
|
||||
return Boolean(snapToggle.checked) !== Boolean(event.ctrlKey || event.metaKey);
|
||||
}
|
||||
|
||||
function applyDrag(event) {
|
||||
if (!drag) return;
|
||||
const module = selectedModule();
|
||||
if (!module) return;
|
||||
const dx = ((event.clientX - drag.startX) / drag.rect.width) * 100;
|
||||
const dy = ((event.clientY - drag.startY) / drag.rect.height) * 100;
|
||||
const start = edges(drag.config);
|
||||
const box = { ...start };
|
||||
if (drag.handle) {
|
||||
if (drag.handle.includes("w")) box.left += dx;
|
||||
if (drag.handle.includes("e")) box.right += dx;
|
||||
if (drag.handle.includes("n")) box.top += dy;
|
||||
if (drag.handle.includes("s")) box.bottom += dy;
|
||||
if (box.right - box.left < 0.5) box.right = box.left + 0.5;
|
||||
if (box.bottom - box.top < 0.5) box.bottom = box.top + 0.5;
|
||||
} else {
|
||||
box.left += dx; box.right += dx; box.top += dy; box.bottom += dy;
|
||||
}
|
||||
clearGuides();
|
||||
if (snappingActive(event)) {
|
||||
const xPoints = drag.handle ? [drag.handle.includes("w") ? box.left : drag.handle.includes("e") ? box.right : (box.left + box.right) / 2] : [box.left, (box.left + box.right) / 2, box.right];
|
||||
const yPoints = drag.handle ? [drag.handle.includes("n") ? box.top : drag.handle.includes("s") ? box.bottom : (box.top + box.bottom) / 2] : [box.top, (box.top + box.bottom) / 2, box.bottom];
|
||||
const snapX = snapAdjustment(xPoints, (7 / drag.rect.width) * 100);
|
||||
const snapY = snapAdjustment(yPoints, (7 / drag.rect.height) * 100);
|
||||
if (snapX) {
|
||||
if (drag.handle?.includes("w")) box.left += snapX.difference;
|
||||
else if (drag.handle?.includes("e")) box.right += snapX.difference;
|
||||
else { box.left += snapX.difference; box.right += snapX.difference; }
|
||||
showGuide("x", snapX.target);
|
||||
}
|
||||
if (snapY) {
|
||||
if (drag.handle?.includes("n")) box.top += snapY.difference;
|
||||
else if (drag.handle?.includes("s")) box.bottom += snapY.difference;
|
||||
else { box.top += snapY.difference; box.bottom += snapY.difference; }
|
||||
showGuide("y", snapY.target);
|
||||
}
|
||||
}
|
||||
Object.assign(module.config, configFromEdges(box, module.config.anchor || "top-left"));
|
||||
renderer.applyBox(elements.get(module.id), module.config);
|
||||
updateReadout();
|
||||
}
|
||||
|
||||
async function saveTransform(module) {
|
||||
if (!module) return;
|
||||
status.textContent = "Saving adjustment…";
|
||||
try {
|
||||
const response = await fetch(`/api/admin/overlays/${editorState.overlayId}/modules/${module.id}/transform`, {
|
||||
method: "POST",
|
||||
credentials: "same-origin",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
x: module.config.x,
|
||||
y: module.config.y,
|
||||
width: module.config.width,
|
||||
height: module.config.height,
|
||||
anchor: module.config.anchor || "top-left"
|
||||
})
|
||||
});
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
if (!response.ok) throw new Error(payload.error || "The adjustment could not be saved.");
|
||||
Object.assign(module.config, payload.module.config);
|
||||
renderer.applyBox(elements.get(module.id), module.config);
|
||||
updateReadout();
|
||||
channel?.postMessage({ type: "transform", sceneId: selectedScene()?.id, moduleId: module.id, config: module.config });
|
||||
status.textContent = "Saved · editor synchronized";
|
||||
} catch (error) {
|
||||
status.textContent = `Save failed · ${error.message}`;
|
||||
}
|
||||
}
|
||||
|
||||
function transformSelected(changes) {
|
||||
const module = selectedModule();
|
||||
if (!module) return;
|
||||
Object.assign(module.config, changes);
|
||||
renderer.applyBox(elements.get(module.id), module.config);
|
||||
updateReadout();
|
||||
saveTransform(module);
|
||||
}
|
||||
|
||||
layer.addEventListener("pointerdown", (event) => {
|
||||
const element = event.target.closest(".lumi-overlay-module");
|
||||
if (!element) return;
|
||||
event.preventDefault();
|
||||
selectSource(element.dataset.moduleId);
|
||||
drag = {
|
||||
startX: event.clientX,
|
||||
startY: event.clientY,
|
||||
rect: canvas.getBoundingClientRect(),
|
||||
config: { ...selectedModule().config },
|
||||
handle: event.target.closest("[data-resize-handle]")?.dataset.resizeHandle || null
|
||||
};
|
||||
});
|
||||
window.addEventListener("pointermove", applyDrag);
|
||||
window.addEventListener("pointerup", () => {
|
||||
if (!drag) return;
|
||||
drag = null;
|
||||
clearGuides();
|
||||
saveTransform(selectedModule());
|
||||
});
|
||||
|
||||
canvas.addEventListener("keydown", (event) => {
|
||||
if (!selectedModule() || !["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown"].includes(event.key)) return;
|
||||
event.preventDefault();
|
||||
const amount = event.shiftKey ? 1 : 0.1;
|
||||
const module = selectedModule();
|
||||
if (event.key === "ArrowLeft") module.config.x = clamp(Number(module.config.x) - amount, 0, 100);
|
||||
if (event.key === "ArrowRight") module.config.x = clamp(Number(module.config.x) + amount, 0, 100);
|
||||
if (event.key === "ArrowUp") module.config.y = clamp(Number(module.config.y) - amount, 0, 100);
|
||||
if (event.key === "ArrowDown") module.config.y = clamp(Number(module.config.y) + amount, 0, 100);
|
||||
renderer.applyBox(elements.get(module.id), module.config);
|
||||
updateReadout();
|
||||
clearTimeout(saveTimer);
|
||||
saveTimer = setTimeout(() => saveTransform(module), 280);
|
||||
});
|
||||
|
||||
sceneSelect.addEventListener("change", () => { selectedId = null; editorState.previewSceneId = sceneSelect.value; render(); });
|
||||
sourceSelect.addEventListener("change", () => selectSource(sourceSelect.value));
|
||||
document.querySelector("[data-popout-center]")?.addEventListener("click", () => transformSelected({ x: 50, y: 50, anchor: "center" }));
|
||||
document.querySelector("[data-popout-fit]")?.addEventListener("click", () => transformSelected({ x: 0, y: 0, width: 100, height: 100, anchor: "top-left" }));
|
||||
document.querySelector("[data-popout-reset]")?.addEventListener("click", () => transformSelected({ x: 0, y: 0, width: 50, height: 25, anchor: "top-left" }));
|
||||
|
||||
if ("BroadcastChannel" in window) {
|
||||
channel = new BroadcastChannel(channelName);
|
||||
channel.addEventListener("message", (event) => {
|
||||
if (event.data?.type !== "state" || !event.data.state) return;
|
||||
const currentSceneId = sceneSelect.value;
|
||||
editorState = event.data.state;
|
||||
editorState.previewSceneId = editorState.scenes?.some((scene) => scene.id === currentSceneId) ? currentSceneId : editorState.previewSceneId;
|
||||
render();
|
||||
});
|
||||
channel.postMessage({ type: "request-state" });
|
||||
} else {
|
||||
status.textContent = "Interactive preview opened · editor synchronization is unavailable in this browser";
|
||||
}
|
||||
|
||||
window.addEventListener("resize", resize);
|
||||
window.addEventListener("beforeunload", () => {
|
||||
clearTimeout(saveTimer);
|
||||
channel?.close();
|
||||
}, { once: true });
|
||||
render();
|
||||
})();
|
||||
@ -493,6 +493,7 @@
|
||||
for (const direction of ["nw", "n", "ne", "e", "se", "s", "sw", "w"]) {
|
||||
const handle = document.createElement("button");
|
||||
handle.type = "button";
|
||||
handle.tabIndex = -1;
|
||||
handle.className = `overlay-resize-handle handle-${direction}`;
|
||||
handle.dataset.resizeHandle = direction;
|
||||
handle.setAttribute("aria-label", `Resize ${module.name || "source"} from ${direction}`);
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
:root,
|
||||
html,
|
||||
body {
|
||||
@layer features {
|
||||
html.lumi-overlay-runtime-page,
|
||||
body.lumi-overlay-runtime-page {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
@ -8,7 +8,7 @@ body {
|
||||
background-color: rgba(0, 0, 0, 0) !important;
|
||||
}
|
||||
|
||||
body {
|
||||
body.lumi-overlay-runtime-page {
|
||||
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
}
|
||||
|
||||
@ -98,3 +98,4 @@ body {
|
||||
font: 700 0.9rem/1.2 system-ui, sans-serif;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,4 +1,7 @@
|
||||
@import url("https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;600;700&family=Source+Sans+3:wght@400;600&display=swap");
|
||||
@layer base {
|
||||
[hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
:root {
|
||||
--ink: #121518;
|
||||
@ -1366,7 +1369,7 @@ body {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 999px;
|
||||
background: var(--sea);
|
||||
color: var(--ink-inverse, #081114);
|
||||
color: var(--lumi-button-text, #ffffff);
|
||||
font-weight: 800;
|
||||
box-shadow: 0 12px 28px rgba(0, 0, 0, 0.24);
|
||||
cursor: pointer;
|
||||
@ -2281,6 +2284,7 @@ body.sidebar-collapsed .nav-item-icon img {
|
||||
margin-bottom: 10px;
|
||||
font-family: "Space Grotesk", sans-serif;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
@layer features {
|
||||
.theme-library {
|
||||
display: grid;
|
||||
gap: var(--lumi-space-4);
|
||||
@ -414,6 +415,18 @@
|
||||
padding: 0.25rem 0.55rem;
|
||||
border: 1px solid var(--lumi-border);
|
||||
background: var(--lumi-surface-subtle);
|
||||
color: var(--lumi-text);
|
||||
}
|
||||
|
||||
.theme-role-swatch {
|
||||
display: inline-block;
|
||||
width: 0.75rem;
|
||||
height: 0.75rem;
|
||||
margin-right: var(--lumi-space-1);
|
||||
border: 1px solid var(--lumi-border-strong);
|
||||
border-radius: 50%;
|
||||
background: var(--role-admin);
|
||||
vertical-align: -0.08em;
|
||||
}
|
||||
|
||||
.theme-preview-dirty {
|
||||
@ -537,3 +550,4 @@
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,7 +1,8 @@
|
||||
(() => {
|
||||
const editor = document.querySelector("[data-theme-editor]");
|
||||
const mount = (editor, { signal } = {}) => {
|
||||
const form = editor?.querySelector("[data-theme-form]");
|
||||
if (!editor || !form) return;
|
||||
const listenerOptions = signal ? { signal } : undefined;
|
||||
|
||||
const previewRoots = () => Array.from(editor.querySelectorAll("[data-theme-preview-root]"));
|
||||
const tokenVariables = {
|
||||
@ -154,6 +155,34 @@
|
||||
});
|
||||
};
|
||||
|
||||
const applyCanonicalTheme = (theme) => {
|
||||
if (!theme) return;
|
||||
const root = document.documentElement;
|
||||
const explicitScheme = root.dataset.colorScheme;
|
||||
const mode = explicitScheme === "dark" || (!explicitScheme && window.matchMedia("(prefers-color-scheme: dark)").matches)
|
||||
? "dark"
|
||||
: "light";
|
||||
const values = theme[mode] || theme.light;
|
||||
Object.entries(tokenVariables).forEach(([field, variable]) => {
|
||||
if (values?.[field]) root.style.setProperty(variable, values[field]);
|
||||
});
|
||||
Object.entries(metricVariables).forEach(([field, [variable, unit]]) => {
|
||||
if (theme.metrics?.[field] !== undefined) root.style.setProperty(variable, `${theme.metrics[field]}${unit}`);
|
||||
});
|
||||
if (theme.typography) {
|
||||
root.style.setProperty("--lumi-font-body", theme.typography.bodyFontStack);
|
||||
root.style.setProperty("--lumi-font-display", theme.typography.displayFontStack);
|
||||
root.style.setProperty("--lumi-font-mono", theme.typography.monoFontStack);
|
||||
root.style.setProperty("--lumi-font-size-base", `${theme.typography.baseSize}px`);
|
||||
root.style.setProperty("--lumi-heading-scale", theme.typography.headingScale);
|
||||
root.style.setProperty("--lumi-control-scale", theme.typography.controlScale);
|
||||
}
|
||||
Object.entries(theme.role || {}).forEach(([role, value]) => root.style.setProperty(`--role-${role}`, value));
|
||||
document.body.dataset.themeId = theme.id || "";
|
||||
const activeStatus = document.querySelector("[data-active-theme-status]");
|
||||
if (activeStatus) activeStatus.textContent = `Active: ${theme.name}`;
|
||||
};
|
||||
|
||||
const previewShell = () => `<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
@ -227,8 +256,13 @@
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
form.addEventListener("input", applyPreview);
|
||||
form.addEventListener("change", applyPreview);
|
||||
form.addEventListener("input", applyPreview, listenerOptions);
|
||||
form.addEventListener("change", applyPreview, listenerOptions);
|
||||
form.addEventListener("lumi:form-saved", (event) => {
|
||||
const { theme, applied } = event.detail?.data || {};
|
||||
if (applied) applyCanonicalTheme(theme);
|
||||
applyPreview();
|
||||
}, listenerOptions);
|
||||
editor.querySelectorAll("[data-theme-preview-mode]").forEach((button) => {
|
||||
button.addEventListener("click", () => {
|
||||
previewMode = button.dataset.themePreviewMode;
|
||||
@ -236,21 +270,21 @@
|
||||
item.classList.toggle("is-selected", item === button);
|
||||
});
|
||||
applyPreview();
|
||||
});
|
||||
}, listenerOptions);
|
||||
});
|
||||
|
||||
editor.querySelector("[data-theme-reset]")?.addEventListener("click", () => {
|
||||
form.reset();
|
||||
applyPreview();
|
||||
window.LumiStateButton?.success(editor.querySelector("[data-theme-reset]"));
|
||||
});
|
||||
}, listenerOptions);
|
||||
|
||||
editor.querySelector("[data-theme-reset-to-base]")?.addEventListener("click", () => {
|
||||
form.querySelectorAll("[data-base-value]").forEach((input) => {
|
||||
input.value = input.dataset.baseValue;
|
||||
});
|
||||
applyPreview();
|
||||
});
|
||||
}, listenerOptions);
|
||||
|
||||
editor.querySelectorAll("[data-theme-popout]").forEach((button) => {
|
||||
button.addEventListener("click", () => {
|
||||
@ -266,8 +300,21 @@
|
||||
popout.focus?.();
|
||||
if (status) status.textContent = "Pop-out preview is open and updates with this editor.";
|
||||
syncPopout();
|
||||
});
|
||||
}, listenerOptions);
|
||||
});
|
||||
|
||||
applyPreview();
|
||||
return () => {
|
||||
if (popout && !popout.closed) popout.close();
|
||||
};
|
||||
};
|
||||
|
||||
if (window.LumiPage) {
|
||||
window.LumiPage.register("theme-studio", {
|
||||
selector: "[data-theme-editor]",
|
||||
mount
|
||||
});
|
||||
} else {
|
||||
mount(document.querySelector("[data-theme-editor]"));
|
||||
}
|
||||
})();
|
||||
|
||||
469
src/web/public/update-management.js
Normal file
469
src/web/public/update-management.js
Normal file
@ -0,0 +1,469 @@
|
||||
(() => {
|
||||
if (!window.LumiPage) return;
|
||||
|
||||
const eventNames = [
|
||||
"update:queued",
|
||||
"update:checking",
|
||||
"update:metadata",
|
||||
"update:snapshot",
|
||||
"update:recovery_marker",
|
||||
"update:download",
|
||||
"update:apply",
|
||||
"update:verify",
|
||||
"update:restart_required",
|
||||
"update:complete",
|
||||
"update:failed",
|
||||
"update:revert",
|
||||
"recovery:marker_detected",
|
||||
"recovery:plugin_disabled",
|
||||
"recovery:retry_startup"
|
||||
];
|
||||
const destructivePattern = /(?:^|\/)(?:delete|remove|clear|reset|renew|uninstall|cleanup|archive|revoke|unlink|unset|revert)(?:\/|$)/i;
|
||||
|
||||
window.LumiPage.register("update-actions", {
|
||||
selector: "main.content",
|
||||
mount(pageRoot, { signal }) {
|
||||
const timers = new Set();
|
||||
const schedule = (callback, delay) => {
|
||||
const timer = window.setTimeout(() => {
|
||||
timers.delete(timer);
|
||||
callback();
|
||||
}, delay);
|
||||
timers.add(timer);
|
||||
};
|
||||
const statusFor = (form) => {
|
||||
let status = form.querySelector("[data-update-inline-result]") || form.closest(".field, .card")?.querySelector("[data-update-inline-result]");
|
||||
if (!status) {
|
||||
status = document.createElement("p");
|
||||
status.className = "hint";
|
||||
status.dataset.updateInlineResult = "";
|
||||
status.setAttribute("aria-live", "polite");
|
||||
form.insertAdjacentElement("afterend", status);
|
||||
}
|
||||
return status;
|
||||
};
|
||||
const monitorRestart = (status, delay = 1000) => {
|
||||
let attempts = 0;
|
||||
const poll = async () => {
|
||||
if (signal.aborted) return;
|
||||
attempts += 1;
|
||||
status.textContent = `Reconnecting to Lumi… attempt ${attempts} of 45.`;
|
||||
try {
|
||||
const response = await fetch("/admin/updates/status", { cache: "no-store", headers: { Accept: "application/json" }, signal });
|
||||
if (!response.ok) throw new Error("Not ready");
|
||||
status.className = "hint status-success";
|
||||
status.textContent = "Lumi is available again. You can continue on this page.";
|
||||
return;
|
||||
} catch (error) {
|
||||
if (error.name === "AbortError") return;
|
||||
}
|
||||
if (attempts >= 45) {
|
||||
status.className = "hint status-danger";
|
||||
status.setAttribute("role", "alert");
|
||||
status.textContent = "Lumi has not reconnected yet. Open Updates or Diagnostics in a new tab when the service is available.";
|
||||
return;
|
||||
}
|
||||
schedule(poll, 1000);
|
||||
};
|
||||
schedule(poll, Math.max(0, Number(delay) || 0));
|
||||
};
|
||||
|
||||
pageRoot.addEventListener("submit", async (event) => {
|
||||
const form = event.target;
|
||||
const submitter = event.submitter || form?.querySelector?.("button[type='submit']");
|
||||
if (!(form instanceof HTMLFormElement) || form.closest("[data-update-management]")) return;
|
||||
if (!form.matches("[data-update-action]") && !submitter?.matches?.("[data-update-action]")) return;
|
||||
if (!window.LumiRequest) return;
|
||||
event.preventDefault();
|
||||
event.stopImmediatePropagation();
|
||||
const status = statusFor(form);
|
||||
const confirmMode = submitter?.dataset.confirmMode || form.dataset.confirmMode;
|
||||
if (confirmMode === "modal") {
|
||||
const confirmed = await window.LumiConfirm?.destructive?.({
|
||||
title: submitter?.dataset.confirmTitle || form.dataset.confirmTitle || "Confirm update",
|
||||
text: submitter?.dataset.confirmText || form.dataset.confirmText || "Apply the recommended update and restart Lumi?",
|
||||
label: submitter?.dataset.confirmLabel || form.dataset.confirmLabel || "Update Lumi",
|
||||
danger: true
|
||||
});
|
||||
if (!confirmed) {
|
||||
window.LumiStateButton?.reset?.(submitter);
|
||||
return;
|
||||
}
|
||||
}
|
||||
window.LumiStateButton?.setState?.(submitter, "loading", { busy: true });
|
||||
status.className = "hint";
|
||||
status.setAttribute("role", "status");
|
||||
status.textContent = form.getAttribute("action")?.includes("check") || submitter?.getAttribute("formaction")?.includes("check")
|
||||
? "Checking for updates…"
|
||||
: "Applying update…";
|
||||
try {
|
||||
const result = await window.LumiRequest.form(form, { submitter });
|
||||
status.className = "hint status-success";
|
||||
status.textContent = result.message || "Update action completed.";
|
||||
window.LumiStateButton?.success?.(submitter);
|
||||
if (result.restart_required || result.reloadRequired || result.refresh_after_ms) monitorRestart(status, result.refresh_after_ms || 1000);
|
||||
} catch (error) {
|
||||
status.className = "hint status-danger";
|
||||
status.setAttribute("role", "alert");
|
||||
status.textContent = error.message || "The update action failed. Your current page is unchanged.";
|
||||
window.LumiStateButton?.error?.(submitter);
|
||||
}
|
||||
}, { signal });
|
||||
|
||||
signal.addEventListener("abort", () => {
|
||||
timers.forEach((timer) => window.clearTimeout(timer));
|
||||
timers.clear();
|
||||
}, { once: true });
|
||||
}
|
||||
});
|
||||
|
||||
window.LumiPage.register("update-management", {
|
||||
selector: "[data-update-management]",
|
||||
mount(root, { signal }) {
|
||||
const timers = new Set();
|
||||
const subscriptions = [];
|
||||
let refreshController = null;
|
||||
|
||||
const schedule = (callback, delay) => {
|
||||
const timer = window.setTimeout(() => {
|
||||
timers.delete(timer);
|
||||
callback();
|
||||
}, delay);
|
||||
timers.add(timer);
|
||||
return timer;
|
||||
};
|
||||
|
||||
const updateLog = () => root.querySelector("[data-update-progress-log]");
|
||||
const noticeRoot = () => root.querySelector("[data-update-notices]");
|
||||
|
||||
const showNotice = (message, tone = "info", { persistent = false } = {}) => {
|
||||
const notices = noticeRoot();
|
||||
if (!notices) return null;
|
||||
const item = document.createElement("div");
|
||||
item.className = `update-notice ${tone}`;
|
||||
item.setAttribute("role", tone === "danger" ? "alert" : "status");
|
||||
const text = document.createElement("span");
|
||||
text.textContent = message || "Update status changed.";
|
||||
const close = document.createElement("button");
|
||||
close.type = "button";
|
||||
close.className = "icon-button";
|
||||
close.setAttribute("aria-label", "Dismiss notification");
|
||||
close.textContent = "×";
|
||||
close.addEventListener("click", () => item.remove(), { signal });
|
||||
item.append(text, close);
|
||||
notices.append(item);
|
||||
if (!persistent && !["danger", "warning"].includes(tone)) schedule(() => item.remove(), 6500);
|
||||
return { item, text };
|
||||
};
|
||||
|
||||
const inlineResult = (form) => {
|
||||
const scope = form?.closest(".plugin-update-row, [data-update-target='core'], .card");
|
||||
let status = scope?.querySelector("[data-update-inline-result]");
|
||||
if (!status && form) {
|
||||
status = form.querySelector("[data-update-inline-result]");
|
||||
if (!status) {
|
||||
status = document.createElement("p");
|
||||
status.className = "hint";
|
||||
status.dataset.updateInlineResult = "";
|
||||
status.setAttribute("aria-live", "polite");
|
||||
form.append(status);
|
||||
}
|
||||
}
|
||||
return status;
|
||||
};
|
||||
|
||||
const appendLog = (message, level = "info", form = null) => {
|
||||
const log = updateLog();
|
||||
if (log) {
|
||||
log.querySelector(":scope > .hint")?.remove();
|
||||
const row = document.createElement("div");
|
||||
row.className = `update-progress-entry ${level}`;
|
||||
row.textContent = message;
|
||||
log.prepend(row);
|
||||
}
|
||||
const status = inlineResult(form);
|
||||
if (status) {
|
||||
status.className = `hint ${level === "danger" ? "status-danger" : level === "success" ? "status-success" : ""}`.trim();
|
||||
status.setAttribute("role", level === "danger" ? "alert" : "status");
|
||||
status.textContent = message;
|
||||
}
|
||||
};
|
||||
|
||||
const readEvent = (event) => {
|
||||
try { return JSON.parse(event.data || "{}"); } catch { return {}; }
|
||||
};
|
||||
|
||||
const eventLabel = (eventName, payload) => {
|
||||
const stage = eventName.replace("update:", "").replace("recovery:", "recovery ").replaceAll("_", " ");
|
||||
const target = payload.plugin_id ? `plugin ${payload.plugin_id}` : payload.target || "recovery";
|
||||
return `${stage}: ${target}`;
|
||||
};
|
||||
|
||||
if (window.LumiEvents?.subscribe) {
|
||||
eventNames.forEach((eventName) => {
|
||||
subscriptions.push(window.LumiEvents.subscribe(eventName, (event) => {
|
||||
const payload = readEvent(event);
|
||||
appendLog(payload.message || eventLabel(eventName, payload), eventName.includes("failed") ? "danger" : eventName.includes("complete") ? "success" : "info");
|
||||
}));
|
||||
});
|
||||
} else {
|
||||
appendLog("Live update progress is unavailable. Actions still report their final result.", "danger");
|
||||
}
|
||||
|
||||
const fieldError = (form, name, message, index) => {
|
||||
const collection = form.elements.namedItem(name);
|
||||
const control = collection instanceof RadioNodeList ? collection[0] : collection;
|
||||
if (!(control instanceof HTMLElement)) return null;
|
||||
const id = `update-error-${Date.now()}-${index}`;
|
||||
const error = document.createElement("span");
|
||||
error.id = id;
|
||||
error.className = "field-error";
|
||||
error.dataset.updateFieldError = "";
|
||||
error.textContent = String(message);
|
||||
control.setAttribute("aria-invalid", "true");
|
||||
control.setAttribute("aria-describedby", `${control.getAttribute("aria-describedby") || ""} ${id}`.trim());
|
||||
(control.closest("label, .field, fieldset") || control.parentElement)?.append(error);
|
||||
return control;
|
||||
};
|
||||
|
||||
const clearFieldErrors = (form) => {
|
||||
form.querySelectorAll("[data-update-field-error]").forEach((item) => item.remove());
|
||||
form.querySelectorAll("[aria-invalid='true']").forEach((control) => {
|
||||
control.removeAttribute("aria-invalid");
|
||||
const ids = String(control.getAttribute("aria-describedby") || "").split(/\s+/).filter((id) => id && !id.startsWith("update-error-"));
|
||||
if (ids.length) control.setAttribute("aria-describedby", ids.join(" "));
|
||||
else control.removeAttribute("aria-describedby");
|
||||
});
|
||||
};
|
||||
|
||||
const applyFieldErrors = (form, errors = {}) => {
|
||||
let first = null;
|
||||
Object.entries(errors).forEach(([name, message], index) => {
|
||||
first ||= fieldError(form, name, message, index);
|
||||
});
|
||||
first?.focus({ preventScroll: false });
|
||||
};
|
||||
|
||||
const buttonState = (button, busy, pendingLabel = "Working…") => {
|
||||
if (!button) return;
|
||||
if (busy) {
|
||||
button.dataset.updateOriginalLabel = button.textContent;
|
||||
button.disabled = true;
|
||||
button.setAttribute("aria-busy", "true");
|
||||
button.textContent = pendingLabel;
|
||||
} else {
|
||||
button.disabled = false;
|
||||
button.removeAttribute("aria-busy");
|
||||
button.textContent = button.dataset.updateOriginalLabel || button.textContent;
|
||||
delete button.dataset.updateOriginalLabel;
|
||||
}
|
||||
};
|
||||
|
||||
const setText = (row, selector, value) => {
|
||||
const target = row?.querySelector(selector);
|
||||
if (target) target.textContent = value || "None";
|
||||
};
|
||||
|
||||
const updateRow = (row, item) => {
|
||||
if (!row || !item) return;
|
||||
const summary = row.querySelector("[data-update-summary]");
|
||||
if (summary) {
|
||||
const current = item.installed === false ? "Not installed" : item.current_version || "unknown";
|
||||
summary.textContent = row.matches("[data-update-target='core']")
|
||||
? `Current ${current} · Recommended ${item.safe_target_version || "none"} · ${item.source_branch || "main"}`
|
||||
: `${current} -> ${item.safe_target_version || "none"} · ${item.source_branch || "main"}`;
|
||||
}
|
||||
const badge = row.querySelector("[data-update-badge]");
|
||||
if (badge) {
|
||||
const tone = item.blocked ? "danger" : item.update_available ? "warning" : "success";
|
||||
badge.className = `badge ${tone}`;
|
||||
badge.textContent = item.blocked ? "Blocked" : item.update_available ? "Update available" : "Current";
|
||||
}
|
||||
setText(row, "[data-update-field='current_version']", item.current_version);
|
||||
setText(row, "[data-update-field='safe_target_version']", item.safe_target_version);
|
||||
setText(row, "[data-update-field='latest_available_version']", item.latest_available_version);
|
||||
setText(row, "[data-update-field='source_branch']", item.source_branch);
|
||||
setText(row, "[data-update-description]", item.version_description);
|
||||
const apply = row.querySelector("[data-update-apply-button]");
|
||||
if (apply) apply.disabled = Boolean(item.blocked || !item.update_available);
|
||||
};
|
||||
|
||||
const sourceUrl = () => `/admin/updates?source=${encodeURIComponent(root.dataset.updateSource || "stable")}`;
|
||||
|
||||
const replaceWholePage = async (url = sourceUrl(), focusSelector = "[data-update-source-form] select") => {
|
||||
refreshController?.abort();
|
||||
refreshController = new AbortController();
|
||||
const response = await fetch(url, {
|
||||
cache: "no-store",
|
||||
headers: { Accept: "text/html", "X-Lumi-Partial": "update-management" },
|
||||
signal: refreshController.signal
|
||||
});
|
||||
if (!response.ok || response.redirected) throw new Error("The update workspace could not be refreshed.");
|
||||
const html = await response.text();
|
||||
const nextDocument = new DOMParser().parseFromString(html, "text/html");
|
||||
const next = nextDocument.querySelector("[data-update-management]");
|
||||
if (!next) throw new Error("The refreshed update workspace was incomplete.");
|
||||
const nextUrl = new URL(url, window.location.href);
|
||||
window.LumiPage?.unmount?.();
|
||||
root.replaceWith(next);
|
||||
history.replaceState(history.state, "", nextUrl.pathname + nextUrl.search);
|
||||
window.LumiInteractions?.init?.(next);
|
||||
window.LumiPage?.mount?.(document.querySelector("main.content") || document);
|
||||
next.querySelector(focusSelector)?.focus({ preventScroll: true });
|
||||
};
|
||||
|
||||
const refreshRecovery = async () => {
|
||||
const current = root.querySelector("[data-update-recovery-region]");
|
||||
if (!current) return;
|
||||
const response = await fetch(sourceUrl(), { cache: "no-store", headers: { Accept: "text/html", "X-Lumi-Partial": "update-recovery" }, signal });
|
||||
if (!response.ok || response.redirected) throw new Error("Recovery status could not be refreshed.");
|
||||
const html = await response.text();
|
||||
const next = new DOMParser().parseFromString(html, "text/html").querySelector("[data-update-recovery-region]");
|
||||
if (!next) throw new Error("The refreshed recovery status was incomplete.");
|
||||
current.replaceWith(next);
|
||||
window.LumiInteractions?.init?.(next);
|
||||
};
|
||||
|
||||
const reconnect = (delay, message) => {
|
||||
const notice = showNotice(message || "Lumi is restarting. This page will reconnect automatically.", "warning", { persistent: true });
|
||||
appendLog("Waiting for Lumi to restart; the current page remains available.", "info");
|
||||
let attempts = 0;
|
||||
const poll = async () => {
|
||||
if (signal.aborted) return;
|
||||
attempts += 1;
|
||||
if (notice?.text) notice.text.textContent = `Reconnecting to Lumi… attempt ${attempts} of 45.`;
|
||||
try {
|
||||
const response = await fetch(`/admin/updates/status?source=${encodeURIComponent(root.dataset.updateSource || "stable")}`, {
|
||||
cache: "no-store",
|
||||
headers: { Accept: "application/json" },
|
||||
signal
|
||||
});
|
||||
if (!response.ok) throw new Error("Not ready");
|
||||
appendLog("Lumi is available again. Refreshing update status in place.", "success");
|
||||
notice?.item.remove();
|
||||
await replaceWholePage(sourceUrl(), "[data-update-progress-log]");
|
||||
return;
|
||||
} catch (error) {
|
||||
if (error.name === "AbortError") return;
|
||||
}
|
||||
if (attempts >= 45) {
|
||||
if (notice?.text) notice.text.textContent = "Lumi has not reconnected yet. Your current page is preserved; retry the connection or open Diagnostics in a new tab.";
|
||||
notice?.item.classList.remove("warning");
|
||||
notice?.item.classList.add("danger");
|
||||
notice?.item.setAttribute("role", "alert");
|
||||
appendLog("Lumi did not reconnect within 45 seconds.", "danger");
|
||||
return;
|
||||
}
|
||||
schedule(poll, 1000);
|
||||
};
|
||||
schedule(poll, Math.max(0, Number(delay) || 0));
|
||||
};
|
||||
|
||||
const confirmAction = async (form, submitter, action) => {
|
||||
if (form.dataset.confirmMode !== "modal") return true;
|
||||
const options = {
|
||||
title: form.dataset.confirmTitle || "Confirm update action",
|
||||
text: form.dataset.confirmText || "This update action will change local files.",
|
||||
label: form.dataset.confirmLabel || submitter?.textContent || "Confirm",
|
||||
danger: true
|
||||
};
|
||||
const pathname = new URL(action, window.location.href).pathname;
|
||||
if (destructivePattern.test(pathname)) {
|
||||
const token = await window.LumiConfirm?.destructiveToken?.({ ...options, action: pathname });
|
||||
if (!token) return false;
|
||||
let field = form.querySelector('input[name="confirmation_token"]');
|
||||
if (!field) {
|
||||
field = document.createElement("input");
|
||||
field.type = "hidden";
|
||||
field.name = "confirmation_token";
|
||||
form.append(field);
|
||||
}
|
||||
field.value = token;
|
||||
return true;
|
||||
}
|
||||
return Boolean(await window.LumiConfirm?.destructive?.(options));
|
||||
};
|
||||
|
||||
root.addEventListener("submit", async (event) => {
|
||||
const form = event.target;
|
||||
if (!(form instanceof HTMLFormElement)) return;
|
||||
if (form.matches("[data-update-source-form]")) {
|
||||
event.preventDefault();
|
||||
const submitter = event.submitter || form.querySelector("button[type='submit']");
|
||||
buttonState(submitter, true, "Loading…");
|
||||
const url = new URL(form.getAttribute("action") || "/admin/updates", window.location.href);
|
||||
new FormData(form).forEach((value, name) => {
|
||||
if (typeof value === "string") url.searchParams.set(name, value);
|
||||
});
|
||||
try {
|
||||
await replaceWholePage(url.href);
|
||||
} catch (error) {
|
||||
if (error.name !== "AbortError") showNotice(error.message, "danger", { persistent: true });
|
||||
buttonState(submitter, false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!form.matches("[data-update-action]") || !window.LumiRequest) return;
|
||||
event.preventDefault();
|
||||
event.stopImmediatePropagation();
|
||||
if (!form.checkValidity()) {
|
||||
form.reportValidity();
|
||||
return;
|
||||
}
|
||||
clearFieldErrors(form);
|
||||
const submitter = event.submitter || form.querySelector("button[type='submit']");
|
||||
const action = window.LumiForms?.action?.(form, submitter) || form.getAttribute("action") || window.location.href;
|
||||
let confirmed = false;
|
||||
try {
|
||||
confirmed = await confirmAction(form, submitter, action);
|
||||
} catch (error) {
|
||||
showNotice(error.message || "Confirmation could not be prepared.", "danger", { persistent: true });
|
||||
return;
|
||||
}
|
||||
if (!confirmed) return;
|
||||
const label = submitter?.textContent?.trim() || "Update action";
|
||||
buttonState(submitter, true, form.matches("[data-update-check-form]") ? "Checking…" : "Working…");
|
||||
form.setAttribute("aria-busy", "true");
|
||||
appendLog(`Started ${label}.`, "info", form);
|
||||
try {
|
||||
const result = await window.LumiRequest.form(form, { submitter });
|
||||
const data = result.data || {};
|
||||
const item = data.plugin || result.plugin || data.status?.core || result.status?.core;
|
||||
if (item) updateRow(form.closest(".plugin-update-row, [data-update-target='core']"), item);
|
||||
if (data.retention) {
|
||||
const age = form.elements.namedItem("max_age_days");
|
||||
const count = form.elements.namedItem("max_per_target");
|
||||
if (age) age.value = data.retention.max_age_days;
|
||||
if (count) count.value = data.retention.max_per_target;
|
||||
}
|
||||
appendLog(result.message || "Update action completed.", "success", form);
|
||||
const tone = item?.blocked ? "danger" : item?.update_available ? "warning" : "success";
|
||||
showNotice(result.message || "Update action completed.", tone, { persistent: tone !== "success" });
|
||||
if (action.includes("/recovery/clear-marker")) await refreshRecovery();
|
||||
if (result.restart_required || result.reloadRequired || result.refresh_after_ms) {
|
||||
reconnect(result.refresh_after_ms || 1000, result.message);
|
||||
}
|
||||
} catch (error) {
|
||||
applyFieldErrors(form, error.fieldErrors);
|
||||
appendLog(error.message || "Update action failed. Your current page and input are unchanged.", "danger", form);
|
||||
showNotice(error.message || "Update action failed. Your current page and input are unchanged.", "danger", { persistent: true });
|
||||
} finally {
|
||||
form.removeAttribute("aria-busy");
|
||||
form.querySelector('input[name="confirmation_token"]')?.remove();
|
||||
buttonState(submitter, false);
|
||||
}
|
||||
}, { signal });
|
||||
|
||||
window.addEventListener("lumi:event-status", (event) => {
|
||||
if (event.detail?.status === "disconnected") appendLog("Live progress disconnected. The page will keep trying through the shared connection.", "danger");
|
||||
}, { signal });
|
||||
|
||||
signal.addEventListener("abort", () => {
|
||||
refreshController?.abort();
|
||||
subscriptions.splice(0).forEach((unsubscribe) => unsubscribe());
|
||||
timers.forEach((timer) => window.clearTimeout(timer));
|
||||
timers.clear();
|
||||
}, { once: true });
|
||||
}
|
||||
});
|
||||
})();
|
||||
@ -127,11 +127,17 @@ const {
|
||||
const { getUpdateStatus } = require("../services/update-index");
|
||||
const {
|
||||
applyCoreUpdate,
|
||||
deployCoreBranch,
|
||||
applyPluginUpdateFromRepo,
|
||||
revertCoreSnapshot,
|
||||
revertPluginSnapshot,
|
||||
disablePluginForRecovery
|
||||
} = require("../services/repo-update");
|
||||
const {
|
||||
listDeploymentBranches,
|
||||
normalizeDeploymentBranch,
|
||||
readUpdateState
|
||||
} = require("../services/update-repository");
|
||||
const {
|
||||
safeModeStatus,
|
||||
clearRecoveryMarker,
|
||||
@ -1911,6 +1917,60 @@ function setFlash(req, type, message) {
|
||||
req.session.flash = { type, message };
|
||||
}
|
||||
|
||||
function uiContentWidth(requestPath = "/") {
|
||||
const route = String(requestPath || "/");
|
||||
if (/^\/admin\/(?:overlays\/[^/]+|navigation|resources)/.test(route)) return "workspace";
|
||||
if (/^\/admin\/(?:feedback|commands|command-policies|diagnostics|logs|plugins|privileges|theming|updates|users)/.test(route)) return "wide";
|
||||
if (/^\/plugins\/(?:lumi_ai|okf|economy-framework|economy-games|moderation)/.test(route)) return "wide";
|
||||
if (/^\/(?:auth\/localhost|setup|admin\/(?:settings|discord-wizard|twitch-wizard|youtube-wizard))/.test(route)) return "form";
|
||||
if (/^\/(?:pages\/|error|missing|plugins\/moderation\/status)/.test(route)) return "prose";
|
||||
return "standard";
|
||||
}
|
||||
|
||||
function wantsEnhancedResponse(req) {
|
||||
return req.get("X-Lumi-Enhanced") === "1" || req.get("X-Requested-With") === "XMLHttpRequest";
|
||||
}
|
||||
|
||||
function sendEnhancedResult(res, { message = "Saved.", fieldErrors = {}, data = {}, patch = {}, redirect = null, reloadRequired = false } = {}) {
|
||||
return res.json({ ok: true, message, fieldErrors, data, patch, redirect, reloadRequired });
|
||||
}
|
||||
|
||||
function sendEnhancedError(req, res, { message, fieldErrors = {}, redirect } = {}) {
|
||||
if (wantsEnhancedResponse(req)) {
|
||||
return res.status(422).json({
|
||||
ok: false,
|
||||
message: message || "Correct the highlighted fields and try again.",
|
||||
fieldErrors,
|
||||
data: {},
|
||||
patch: {},
|
||||
redirect: null,
|
||||
reloadRequired: false
|
||||
});
|
||||
}
|
||||
setFlash(req, "error", message);
|
||||
return res.redirect(redirect);
|
||||
}
|
||||
|
||||
function sendCommandMutationError(req, res, message, fieldErrors = {}) {
|
||||
return sendEnhancedError(req, res, {
|
||||
message,
|
||||
fieldErrors,
|
||||
redirect: "/admin/commands"
|
||||
});
|
||||
}
|
||||
|
||||
function sendCommandMutationSuccess(req, res, { message, data = {} }) {
|
||||
if (wantsEnhancedResponse(req)) {
|
||||
return sendEnhancedResult(res, {
|
||||
message,
|
||||
data,
|
||||
patch: { refresh: "command-list" }
|
||||
});
|
||||
}
|
||||
setFlash(req, "success", message);
|
||||
return res.redirect("/admin/commands");
|
||||
}
|
||||
|
||||
function screenshotExtension(mime) {
|
||||
if (mime === "image/jpeg") return ".jpg";
|
||||
if (mime === "image/webp") return ".webp";
|
||||
@ -3586,6 +3646,7 @@ function createWebServer({ loadPlugins, discordClient, commandRouter }) {
|
||||
const twitchPlatform = platformStatus.find((platform) => platform.id === "twitch");
|
||||
res.locals.twitchConfigured = Boolean(twitchPlatform?.configured);
|
||||
res.locals.currentPath = req.path;
|
||||
res.locals.pageWidth = uiContentWidth(req.path);
|
||||
res.locals.userAvatar = req.session.user
|
||||
? getPreferredAvatar(req.session.user.id)
|
||||
: null;
|
||||
@ -5374,8 +5435,23 @@ function createWebServer({ loadPlugins, discordClient, commandRouter }) {
|
||||
app.post("/admin/settings", requireRole("admin"), (req, res) => {
|
||||
const customPlaceholderValidation = placeholders.validateCustomPlaceholders(customPlaceholdersFromBody(req.body));
|
||||
if (!customPlaceholderValidation.ok) {
|
||||
setFlash(req, "error", customPlaceholderValidation.errors[0]);
|
||||
return res.redirect("/admin/settings");
|
||||
return sendEnhancedError(req, res, {
|
||||
message: customPlaceholderValidation.errors[0],
|
||||
fieldErrors: { custom_placeholders: customPlaceholderValidation.errors[0] },
|
||||
redirect: "/admin/settings"
|
||||
});
|
||||
}
|
||||
if (req.body.git_branch !== undefined) {
|
||||
const branch = String(req.body.git_branch || "").trim();
|
||||
try {
|
||||
if (branch !== "experimental") normalizeDeploymentBranch(branch);
|
||||
} catch (error) {
|
||||
return sendEnhancedError(req, res, {
|
||||
message: error.message,
|
||||
fieldErrors: { git_branch: error.message },
|
||||
redirect: "/admin/settings"
|
||||
});
|
||||
}
|
||||
}
|
||||
const fields = [
|
||||
"site_title",
|
||||
@ -5420,14 +5496,18 @@ function createWebServer({ loadPlugins, discordClient, commandRouter }) {
|
||||
if (field === "homepage_hero_entries") {
|
||||
const heroErrors = validateHomepageHeroEntries(parsed);
|
||||
if (heroErrors.length) {
|
||||
setFlash(req, "error", `Homepage hero cannot be saved yet. ${heroErrors.slice(0, 3).join(" ")}`);
|
||||
return res.redirect("/admin/settings");
|
||||
const message = `Homepage hero cannot be saved yet. ${heroErrors.slice(0, 3).join(" ")}`;
|
||||
return sendEnhancedError(req, res, {
|
||||
message,
|
||||
fieldErrors: { homepage_hero_entries: message },
|
||||
redirect: "/admin/settings"
|
||||
});
|
||||
}
|
||||
}
|
||||
setSetting(field, parsed);
|
||||
} catch (error) {
|
||||
setFlash(req, "error", `${field.replaceAll("_", " ")} JSON is invalid: ${error.message}`);
|
||||
return res.redirect("/admin/settings");
|
||||
const message = `${field.replaceAll("_", " ")} JSON is invalid: ${error.message}`;
|
||||
return sendEnhancedError(req, res, { message, fieldErrors: { [field]: message }, redirect: "/admin/settings" });
|
||||
}
|
||||
}
|
||||
const platformStatus = getPlatformStatus();
|
||||
@ -5442,12 +5522,11 @@ function createWebServer({ loadPlugins, discordClient, commandRouter }) {
|
||||
nextPlatformValues.get(platform.id)
|
||||
);
|
||||
if (!hasLoginPlatform && !isLocalhostLoginAvailable(req)) {
|
||||
setFlash(
|
||||
req,
|
||||
"error",
|
||||
"At least one login platform must remain enabled."
|
||||
);
|
||||
return res.redirect("/admin/settings");
|
||||
return sendEnhancedError(req, res, {
|
||||
message: "At least one login platform must remain enabled.",
|
||||
fieldErrors: Object.fromEntries(platformStatus.filter((platform) => platform.supportsLogin).map((platform) => [platform.enabledKey, "Keep at least one login platform enabled."])),
|
||||
redirect: "/admin/settings"
|
||||
});
|
||||
}
|
||||
|
||||
setSetting("custom_placeholders", customPlaceholderValidation.definitions);
|
||||
@ -5462,12 +5541,13 @@ function createWebServer({ loadPlugins, discordClient, commandRouter }) {
|
||||
}
|
||||
setSetting(platform.enabledKey, nextValue);
|
||||
}
|
||||
setFlash(
|
||||
req,
|
||||
"success",
|
||||
restartNeeded ? "Settings saved. Restarting..." : "Settings saved."
|
||||
);
|
||||
res.redirect("/admin/settings");
|
||||
const message = restartNeeded ? "Settings saved. Lumi is restarting to apply platform changes." : "Settings saved.";
|
||||
if (wantsEnhancedResponse(req)) {
|
||||
sendEnhancedResult(res, { message, reloadRequired: restartNeeded });
|
||||
} else {
|
||||
setFlash(req, "success", restartNeeded ? "Settings saved. Restarting..." : "Settings saved.");
|
||||
res.redirect("/admin/settings");
|
||||
}
|
||||
if (restartNeeded) {
|
||||
requestRestart();
|
||||
}
|
||||
@ -5573,13 +5653,19 @@ function createWebServer({ loadPlugins, discordClient, commandRouter }) {
|
||||
try {
|
||||
sections = JSON.parse(rawSections);
|
||||
} catch {
|
||||
setFlash(req, "error", "Navigation JSON is invalid.");
|
||||
return res.redirect("/admin/navigation");
|
||||
return sendEnhancedError(req, res, {
|
||||
message: "Navigation JSON is invalid.",
|
||||
fieldErrors: { nav_sections: "Enter a valid JSON array of navigation sections." },
|
||||
redirect: "/admin/navigation"
|
||||
});
|
||||
}
|
||||
}
|
||||
if (!Array.isArray(sections)) {
|
||||
setFlash(req, "error", "Navigation JSON must be an array of sections.");
|
||||
return res.redirect("/admin/navigation");
|
||||
return sendEnhancedError(req, res, {
|
||||
message: "Navigation JSON must be an array of sections.",
|
||||
fieldErrors: { nav_sections: "Use an array, for example: []." },
|
||||
redirect: "/admin/navigation"
|
||||
});
|
||||
}
|
||||
const navStructure = normalizeNavStructure({
|
||||
enabled,
|
||||
@ -5590,16 +5676,41 @@ function createWebServer({ loadPlugins, discordClient, commandRouter }) {
|
||||
sections
|
||||
});
|
||||
if (!navStructure) {
|
||||
setFlash(req, "error", "Navigation settings could not be saved.");
|
||||
return res.redirect("/admin/navigation");
|
||||
return sendEnhancedError(req, res, {
|
||||
message: "Navigation settings could not be saved.",
|
||||
fieldErrors: { nav_sections: "Check that every section has a unique ID, label, and items array." },
|
||||
redirect: "/admin/navigation"
|
||||
});
|
||||
}
|
||||
setSetting("nav_structure", navStructure);
|
||||
if (wantsEnhancedResponse(req)) {
|
||||
return sendEnhancedResult(res, {
|
||||
message: "Navigation saved without leaving the editor.",
|
||||
data: { navStructure }
|
||||
});
|
||||
}
|
||||
setFlash(req, "success", "Navigation updated.");
|
||||
res.redirect("/admin/navigation");
|
||||
});
|
||||
|
||||
app.post("/admin/navigation/reset", requireRole("admin"), (req, res) => {
|
||||
setSetting("nav_structure", null);
|
||||
if (wantsEnhancedResponse(req)) {
|
||||
const availableItems = collectNavItems(req.session.user, navItems, req.path)
|
||||
.slice()
|
||||
.sort((a, b) => a.section === b.section ? a.label.localeCompare(b.label) : a.section.localeCompare(b.section));
|
||||
const defaultStructure = buildDefaultNavStructure(availableItems);
|
||||
return sendEnhancedResult(res, {
|
||||
message: "Navigation reset to Lumi defaults without reloading the page.",
|
||||
data: {
|
||||
navStructure: {
|
||||
...defaultStructure,
|
||||
enabled: false,
|
||||
includeUnassigned: true
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
setFlash(req, "success", "Navigation reset to default.");
|
||||
res.redirect("/admin/navigation");
|
||||
});
|
||||
@ -5933,16 +6044,28 @@ function createWebServer({ loadPlugins, discordClient, commandRouter }) {
|
||||
const current = getThemeById(themeId);
|
||||
if (!current) throw new Error("Theme not found.");
|
||||
const theme = saveCustomTheme(themeId, valuesFromRequest(req.body, current));
|
||||
if (req.body.apply === "on") setActiveTheme(theme.id);
|
||||
const applied = req.body.apply === "on";
|
||||
if (applied) setActiveTheme(theme.id);
|
||||
const message = applied ? `${theme.name} saved and applied.` : `${theme.name} saved.`;
|
||||
if (wantsEnhancedResponse(req)) {
|
||||
return sendEnhancedResult(res, {
|
||||
message,
|
||||
data: { theme, applied }
|
||||
});
|
||||
}
|
||||
setFlash(
|
||||
req,
|
||||
"success",
|
||||
req.body.apply === "on"
|
||||
? `${theme.name} saved and applied.`
|
||||
: `${theme.name} saved.`
|
||||
message
|
||||
);
|
||||
} catch (error) {
|
||||
const detail = error.validationErrors?.join(" ") || error.message;
|
||||
if (wantsEnhancedResponse(req)) {
|
||||
return sendEnhancedError(req, res, {
|
||||
message: detail,
|
||||
redirect: `/admin/theming?edit=${encodeURIComponent(themeId)}#theme-editor`
|
||||
});
|
||||
}
|
||||
setFlash(req, "error", detail);
|
||||
}
|
||||
res.redirect(`/admin/theming?edit=${encodeURIComponent(themeId)}#theme-editor`);
|
||||
@ -6483,8 +6606,22 @@ function createWebServer({ loadPlugins, discordClient, commandRouter }) {
|
||||
description: req.body.description,
|
||||
policy: commandPolicyFromBody(req.body, { allowInherit: Boolean(existing.is_default) })
|
||||
});
|
||||
const group = listCommandGroups().find((item) => item.id === req.params.id);
|
||||
if (wantsEnhancedResponse(req)) {
|
||||
return sendEnhancedResult(res, {
|
||||
message: "Command group updated.",
|
||||
data: { group }
|
||||
});
|
||||
}
|
||||
setFlash(req, "success", "Command group updated.");
|
||||
} catch (error) {
|
||||
if (wantsEnhancedResponse(req)) {
|
||||
return sendEnhancedError(req, res, {
|
||||
message: error?.message || "The command group could not be updated.",
|
||||
fieldErrors: !String(req.body.name || "").trim() ? { name: "Enter a group name." } : {},
|
||||
redirect: "/admin/command-policies"
|
||||
});
|
||||
}
|
||||
setFlash(req, "error", error?.message || "The command group could not be updated.");
|
||||
}
|
||||
res.redirect("/admin/command-policies");
|
||||
@ -6519,13 +6656,32 @@ function createWebServer({ loadPlugins, discordClient, commandRouter }) {
|
||||
});
|
||||
}
|
||||
if (!updates.length) {
|
||||
if (wantsEnhancedResponse(req)) {
|
||||
return sendEnhancedError(req, res, {
|
||||
message: "No available command settings were submitted.",
|
||||
redirect: "/admin/command-policies"
|
||||
});
|
||||
}
|
||||
setFlash(req, "error", "No available command settings were submitted.");
|
||||
return res.redirect("/admin/command-policies");
|
||||
}
|
||||
try {
|
||||
saveCommandPolicies(updates);
|
||||
setFlash(req, "success", `${updates.length} command${updates.length === 1 ? "" : "s"} saved.`);
|
||||
const message = `${updates.length} command${updates.length === 1 ? "" : "s"} saved.`;
|
||||
if (wantsEnhancedResponse(req)) {
|
||||
return sendEnhancedResult(res, {
|
||||
message,
|
||||
data: { savedCount: updates.length }
|
||||
});
|
||||
}
|
||||
setFlash(req, "success", message);
|
||||
} catch (error) {
|
||||
if (wantsEnhancedResponse(req)) {
|
||||
return sendEnhancedError(req, res, {
|
||||
message: error?.message || "Command settings could not be saved.",
|
||||
redirect: "/admin/command-policies"
|
||||
});
|
||||
}
|
||||
setFlash(req, "error", error?.message || "Command settings could not be saved.");
|
||||
}
|
||||
return res.redirect("/admin/command-policies");
|
||||
@ -6567,31 +6723,24 @@ function createWebServer({ loadPlugins, discordClient, commandRouter }) {
|
||||
availablePlatforms
|
||||
);
|
||||
if (!trigger) {
|
||||
setFlash(req, "error", "Trigger is required.");
|
||||
return res.redirect("/admin/commands");
|
||||
return sendCommandMutationError(req, res, "Trigger is required.", { trigger: "Enter a command trigger." });
|
||||
}
|
||||
if (!selectedPlatforms.length) {
|
||||
setFlash(req, "error", "Select at least one platform.");
|
||||
return res.redirect("/admin/commands");
|
||||
return sendCommandMutationError(req, res, "Select at least one platform.", { platforms: "Select at least one platform." });
|
||||
}
|
||||
if (!isAdmin && mode === "advanced") {
|
||||
setFlash(req, "error", "Dynamic commands are restricted to admins.");
|
||||
return res.redirect("/admin/commands");
|
||||
return sendCommandMutationError(req, res, "Dynamic commands are restricted to admins.", { mode: "Choose Static, Random Reply, or Conditional Reply." });
|
||||
}
|
||||
if (mode === "advanced") {
|
||||
if (!code) {
|
||||
setFlash(req, "error", "Dynamic commands require code.");
|
||||
return res.redirect("/admin/commands");
|
||||
return sendCommandMutationError(req, res, "Dynamic commands require code.", { code: "Enter the command code." });
|
||||
}
|
||||
} else if (mode === "plain" && !response) {
|
||||
setFlash(req, "error", "Static commands require a response.");
|
||||
return res.redirect("/admin/commands");
|
||||
return sendCommandMutationError(req, res, "Static commands require a response.", { response: "Enter the command response." });
|
||||
} else if (mode === "random" && !randomValidation.ok) {
|
||||
setFlash(req, "error", randomValidation.errors[0]);
|
||||
return res.redirect("/admin/commands");
|
||||
return sendCommandMutationError(req, res, randomValidation.errors[0], { random_text: randomValidation.errors[0] });
|
||||
} else if (mode === "conditional" && !conditionalValidation.ok) {
|
||||
setFlash(req, "error", conditionalValidation.errors[0]);
|
||||
return res.redirect("/admin/commands");
|
||||
return sendCommandMutationError(req, res, conditionalValidation.errors[0], { conditional_keyword: conditionalValidation.errors[0] });
|
||||
}
|
||||
if (mode === "plain") {
|
||||
const placeholderValidation = placeholders.validateTemplate({
|
||||
@ -6601,20 +6750,18 @@ function createWebServer({ loadPlugins, discordClient, commandRouter }) {
|
||||
user: req.session.user
|
||||
});
|
||||
if (!placeholderValidation.ok) {
|
||||
setFlash(req, "error", formatPlaceholderErrors(placeholderValidation.errors));
|
||||
return res.redirect("/admin/commands");
|
||||
const message = formatPlaceholderErrors(placeholderValidation.errors);
|
||||
return sendCommandMutationError(req, res, message, { response: message });
|
||||
}
|
||||
} else if (mode === "random") {
|
||||
const templateError = validateRandomReplyTemplates(randomValidation.config, req.session.user);
|
||||
if (templateError) {
|
||||
setFlash(req, "error", templateError);
|
||||
return res.redirect("/admin/commands");
|
||||
return sendCommandMutationError(req, res, templateError, { random_text: templateError });
|
||||
}
|
||||
} else if (mode === "conditional") {
|
||||
const templateError = validateConditionalReplyTemplates(conditionalValidation, req.session.user);
|
||||
if (templateError) {
|
||||
setFlash(req, "error", templateError);
|
||||
return res.redirect("/admin/commands");
|
||||
return sendCommandMutationError(req, res, templateError, { conditional_response: templateError });
|
||||
}
|
||||
}
|
||||
const now = Date.now();
|
||||
@ -6622,7 +6769,7 @@ function createWebServer({ loadPlugins, discordClient, commandRouter }) {
|
||||
? await generateCommandPreview({ code, language })
|
||||
: emptyCommandPreview();
|
||||
try {
|
||||
db.prepare(
|
||||
const result = db.prepare(
|
||||
"INSERT INTO custom_commands (trigger, description, response, mode, language, code, platform, preview_text, preview_status, preview_error, preview_generated_at, preview_dynamic_segments, random_replies_json, rng_enabled, rng_min, rng_max, conditional_replies_json, conditional_fuzzy, enabled, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?)"
|
||||
).run(
|
||||
trigger,
|
||||
@ -6646,11 +6793,12 @@ function createWebServer({ loadPlugins, discordClient, commandRouter }) {
|
||||
now,
|
||||
now
|
||||
);
|
||||
setFlash(req, "success", "Command created.");
|
||||
res.redirect("/admin/commands");
|
||||
return sendCommandMutationSuccess(req, res, {
|
||||
message: "Command created.",
|
||||
data: { commandId: String(result.lastInsertRowid), trigger }
|
||||
});
|
||||
} catch (error) {
|
||||
setFlash(req, "error", "That command already exists.");
|
||||
res.redirect("/admin/commands");
|
||||
return sendCommandMutationError(req, res, "That command already exists.", { trigger: "Choose a unique trigger." });
|
||||
}
|
||||
});
|
||||
|
||||
@ -6663,7 +6811,11 @@ function createWebServer({ loadPlugins, discordClient, commandRouter }) {
|
||||
"UPDATE custom_commands SET enabled = ?, updated_at = ? WHERE id = ?"
|
||||
).run(row.enabled ? 0 : 1, Date.now(), req.params.id);
|
||||
}
|
||||
res.redirect("/admin/commands");
|
||||
if (!row) return sendCommandMutationError(req, res, "That command no longer exists.");
|
||||
return sendCommandMutationSuccess(req, res, {
|
||||
message: row.enabled ? "Command disabled." : "Command enabled.",
|
||||
data: { commandId: req.params.id, enabled: !Boolean(row.enabled) }
|
||||
});
|
||||
});
|
||||
|
||||
app.post("/admin/commands/:id/delete", requireRole("mod"), (req, res) => {
|
||||
@ -6672,8 +6824,10 @@ function createWebServer({ loadPlugins, discordClient, commandRouter }) {
|
||||
db.prepare("DELETE FROM command_group_members WHERE command_key = ?").run(customCommandKey(req.params.id));
|
||||
db.prepare("DELETE FROM custom_commands WHERE id = ?").run(req.params.id);
|
||||
})();
|
||||
setFlash(req, "success", "Command deleted.");
|
||||
res.redirect("/admin/commands");
|
||||
return sendCommandMutationSuccess(req, res, {
|
||||
message: "Command deleted.",
|
||||
data: { commandId: req.params.id }
|
||||
});
|
||||
});
|
||||
|
||||
app.post("/admin/commands/:id/update", requireRole("mod"), async (req, res) => {
|
||||
@ -6681,9 +6835,11 @@ function createWebServer({ loadPlugins, discordClient, commandRouter }) {
|
||||
const existing = db
|
||||
.prepare("SELECT mode FROM custom_commands WHERE id = ?")
|
||||
.get(req.params.id);
|
||||
if (!existing) {
|
||||
return sendCommandMutationError(req, res, "That command no longer exists.");
|
||||
}
|
||||
if (existing?.mode === "advanced" && !isAdmin) {
|
||||
setFlash(req, "error", "Dynamic commands can only be edited by admins.");
|
||||
return res.redirect("/admin/commands");
|
||||
return sendCommandMutationError(req, res, "Dynamic commands can only be edited by admins.", { mode: "Ask an administrator to edit this command." });
|
||||
}
|
||||
const availablePlatforms = getPlatformStatus()
|
||||
.filter((platform) => platform.supported)
|
||||
@ -6702,31 +6858,24 @@ function createWebServer({ loadPlugins, discordClient, commandRouter }) {
|
||||
availablePlatforms
|
||||
);
|
||||
if (!trigger) {
|
||||
setFlash(req, "error", "Trigger is required.");
|
||||
return res.redirect("/admin/commands");
|
||||
return sendCommandMutationError(req, res, "Trigger is required.", { trigger: "Enter a command trigger." });
|
||||
}
|
||||
if (!selectedPlatforms.length) {
|
||||
setFlash(req, "error", "Select at least one platform.");
|
||||
return res.redirect("/admin/commands");
|
||||
return sendCommandMutationError(req, res, "Select at least one platform.", { platforms: "Select at least one platform." });
|
||||
}
|
||||
if (!isAdmin && mode === "advanced") {
|
||||
setFlash(req, "error", "Dynamic commands are restricted to admins.");
|
||||
return res.redirect("/admin/commands");
|
||||
return sendCommandMutationError(req, res, "Dynamic commands are restricted to admins.", { mode: "Choose Static, Random Reply, or Conditional Reply." });
|
||||
}
|
||||
if (mode === "advanced") {
|
||||
if (!code) {
|
||||
setFlash(req, "error", "Dynamic commands require code.");
|
||||
return res.redirect("/admin/commands");
|
||||
return sendCommandMutationError(req, res, "Dynamic commands require code.", { code: "Enter the command code." });
|
||||
}
|
||||
} else if (mode === "plain" && !response) {
|
||||
setFlash(req, "error", "Static commands require a response.");
|
||||
return res.redirect("/admin/commands");
|
||||
return sendCommandMutationError(req, res, "Static commands require a response.", { response: "Enter the command response." });
|
||||
} else if (mode === "random" && !randomValidation.ok) {
|
||||
setFlash(req, "error", randomValidation.errors[0]);
|
||||
return res.redirect("/admin/commands");
|
||||
return sendCommandMutationError(req, res, randomValidation.errors[0], { random_text: randomValidation.errors[0] });
|
||||
} else if (mode === "conditional" && !conditionalValidation.ok) {
|
||||
setFlash(req, "error", conditionalValidation.errors[0]);
|
||||
return res.redirect("/admin/commands");
|
||||
return sendCommandMutationError(req, res, conditionalValidation.errors[0], { conditional_keyword: conditionalValidation.errors[0] });
|
||||
}
|
||||
if (mode === "plain") {
|
||||
const placeholderValidation = placeholders.validateTemplate({
|
||||
@ -6736,20 +6885,18 @@ function createWebServer({ loadPlugins, discordClient, commandRouter }) {
|
||||
user: req.session.user
|
||||
});
|
||||
if (!placeholderValidation.ok) {
|
||||
setFlash(req, "error", formatPlaceholderErrors(placeholderValidation.errors));
|
||||
return res.redirect("/admin/commands");
|
||||
const message = formatPlaceholderErrors(placeholderValidation.errors);
|
||||
return sendCommandMutationError(req, res, message, { response: message });
|
||||
}
|
||||
} else if (mode === "random") {
|
||||
const templateError = validateRandomReplyTemplates(randomValidation.config, req.session.user);
|
||||
if (templateError) {
|
||||
setFlash(req, "error", templateError);
|
||||
return res.redirect("/admin/commands");
|
||||
return sendCommandMutationError(req, res, templateError, { random_text: templateError });
|
||||
}
|
||||
} else if (mode === "conditional") {
|
||||
const templateError = validateConditionalReplyTemplates(conditionalValidation, req.session.user);
|
||||
if (templateError) {
|
||||
setFlash(req, "error", templateError);
|
||||
return res.redirect("/admin/commands");
|
||||
return sendCommandMutationError(req, res, templateError, { conditional_response: templateError });
|
||||
}
|
||||
}
|
||||
const preview = isAdmin && mode === "advanced"
|
||||
@ -6780,11 +6927,12 @@ function createWebServer({ loadPlugins, discordClient, commandRouter }) {
|
||||
Date.now(),
|
||||
req.params.id
|
||||
);
|
||||
setFlash(req, "success", "Command updated.");
|
||||
res.redirect("/admin/commands");
|
||||
return sendCommandMutationSuccess(req, res, {
|
||||
message: "Command updated.",
|
||||
data: { commandId: req.params.id, trigger }
|
||||
});
|
||||
} catch (error) {
|
||||
setFlash(req, "error", "Unable to update command.");
|
||||
res.redirect("/admin/commands");
|
||||
return sendCommandMutationError(req, res, "Unable to update command.", { trigger: "Check that this trigger is unique." });
|
||||
}
|
||||
});
|
||||
|
||||
@ -6793,8 +6941,7 @@ function createWebServer({ loadPlugins, discordClient, commandRouter }) {
|
||||
.prepare("SELECT mode, language, code FROM custom_commands WHERE id = ?")
|
||||
.get(req.params.id);
|
||||
if (!command || command.mode !== "advanced" || !command.code) {
|
||||
setFlash(req, "error", "Dynamic command not found.");
|
||||
return res.redirect("/admin/commands");
|
||||
return sendCommandMutationError(req, res, "Dynamic command not found.");
|
||||
}
|
||||
const preview = await generateCommandPreview(command);
|
||||
db.prepare(
|
||||
@ -6807,9 +6954,13 @@ function createWebServer({ loadPlugins, discordClient, commandRouter }) {
|
||||
preview.preview_dynamic_segments,
|
||||
req.params.id
|
||||
);
|
||||
setFlash(req, preview.preview_status === "ready" ? "success" : "error",
|
||||
preview.preview_status === "ready" ? "Command preview refreshed." : "Preview unavailable.");
|
||||
return res.redirect("/admin/commands");
|
||||
if (preview.preview_status !== "ready") {
|
||||
return sendCommandMutationError(req, res, preview.preview_error || "Preview unavailable.");
|
||||
}
|
||||
return sendCommandMutationSuccess(req, res, {
|
||||
message: "Command preview refreshed.",
|
||||
data: { commandId: req.params.id, previewStatus: preview.preview_status }
|
||||
});
|
||||
});
|
||||
|
||||
app.get("/admin/pages", requireRole("admin"), (req, res) => {
|
||||
@ -7080,7 +7231,16 @@ function createWebServer({ loadPlugins, discordClient, commandRouter }) {
|
||||
|
||||
function sendUpdateResult(req, res, result, redirectPath = "/admin/updates") {
|
||||
if (wantsJson(req)) {
|
||||
return res.json({ ok: true, ...result });
|
||||
const payload = result && typeof result === "object" ? result : {};
|
||||
const { message = "Update action completed.", patch = {}, reloadRequired = false, ...data } = payload;
|
||||
return res.json({
|
||||
ok: true,
|
||||
message,
|
||||
...data,
|
||||
data,
|
||||
patch,
|
||||
reloadRequired
|
||||
});
|
||||
}
|
||||
setFlash(req, "success", result.message || "Update action completed.");
|
||||
return res.redirect(redirectPath);
|
||||
@ -7088,7 +7248,16 @@ function createWebServer({ loadPlugins, discordClient, commandRouter }) {
|
||||
|
||||
function sendUpdateError(req, res, error, redirectPath = "/admin/updates") {
|
||||
if (wantsJson(req)) {
|
||||
return res.status(400).json({ ok: false, error: error.message });
|
||||
const fieldErrors = error?.fieldErrors && typeof error.fieldErrors === "object" ? error.fieldErrors : {};
|
||||
return res.status(Object.keys(fieldErrors).length ? 422 : 400).json({
|
||||
ok: false,
|
||||
message: error.message,
|
||||
error: error.message,
|
||||
fieldErrors,
|
||||
data: {},
|
||||
patch: {},
|
||||
reloadRequired: false
|
||||
});
|
||||
}
|
||||
setFlash(req, "error", error.message);
|
||||
return res.redirect(redirectPath);
|
||||
@ -7121,7 +7290,11 @@ function createWebServer({ loadPlugins, discordClient, commandRouter }) {
|
||||
app.post("/admin/updates/recovery/retry-normal-startup", requireRole("admin"), (req, res) => {
|
||||
updateRecoveryMarker({ status: "retry_startup", retry_at: new Date().toISOString() });
|
||||
publishWebEvent("recovery:retry_startup", {}, { role: "admin" });
|
||||
sendUpdateResult(req, res, { message: "Retrying normal startup." });
|
||||
sendUpdateResult(req, res, {
|
||||
restart_required: true,
|
||||
refresh_after_ms: 5000,
|
||||
message: "Retrying normal startup. This page will reconnect when Lumi is available."
|
||||
});
|
||||
scheduleRestartAfterNotice();
|
||||
});
|
||||
|
||||
@ -7130,6 +7303,12 @@ function createWebServer({ loadPlugins, discordClient, commandRouter }) {
|
||||
let updateStatusError = null;
|
||||
let snapshots = [];
|
||||
let recoveryStatus = {};
|
||||
let deploymentBranches = [];
|
||||
let deploymentError = null;
|
||||
const configuredRemote = getSetting("git_remote", "origin");
|
||||
const configuredBranch = getSetting("git_branch", "main");
|
||||
const updateState = readUpdateState();
|
||||
const deployedBranch = String(updateState.deployed_branch || configuredBranch || "main");
|
||||
try {
|
||||
updateStatus = getUpdateStatus({ source: updateSourceFrom(req) });
|
||||
} catch (error) {
|
||||
@ -7149,21 +7328,57 @@ function createWebServer({ loadPlugins, discordClient, commandRouter }) {
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
}
|
||||
try {
|
||||
deploymentBranches = listDeploymentBranches(configuredRemote).branches;
|
||||
} catch (error) {
|
||||
deploymentError = error.message;
|
||||
}
|
||||
if (!deploymentBranches.some((item) => item.name === deployedBranch) && (deployedBranch === "main" || deployedBranch.startsWith("experimental-"))) {
|
||||
deploymentBranches.push({
|
||||
name: deployedBranch,
|
||||
commit: updateState.deployed_commit || "",
|
||||
channel: deployedBranch === "main" ? "stable" : "experimental",
|
||||
unavailable: true
|
||||
});
|
||||
}
|
||||
res.render("admin-updates", {
|
||||
title: "Updates",
|
||||
snapshots,
|
||||
snapshotRetention: getSnapshotRetention(),
|
||||
updateStatus,
|
||||
updateStatusError,
|
||||
recoveryStatus
|
||||
recoveryStatus,
|
||||
deploymentBranches,
|
||||
deploymentError,
|
||||
deploymentState: {
|
||||
branch: deployedBranch,
|
||||
commit: updateState.deployed_commit || null,
|
||||
version: updateState.deployed_version || require("../../package.json").version,
|
||||
previous_branch: updateState.previous_deployed_branch || null,
|
||||
remote: configuredRemote
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
app.post("/admin/updates/snapshots/settings", requireRole("admin"), (req, res) => {
|
||||
try {
|
||||
const fieldErrors = {};
|
||||
const age = Number(req.body.max_age_days);
|
||||
const count = Number(req.body.max_per_target);
|
||||
if (!Number.isInteger(age) || age < 1 || age > 3650) {
|
||||
fieldErrors.max_age_days = "Enter a whole number from 1 to 3650.";
|
||||
}
|
||||
if (!Number.isInteger(count) || count < 1 || count > 50) {
|
||||
fieldErrors.max_per_target = "Enter a whole number from 1 to 50.";
|
||||
}
|
||||
if (Object.keys(fieldErrors).length) {
|
||||
const error = new Error("Correct the snapshot cleanup limits and try again.");
|
||||
error.fieldErrors = fieldErrors;
|
||||
throw error;
|
||||
}
|
||||
const retention = setSnapshotRetention({
|
||||
max_age_days: req.body.max_age_days,
|
||||
max_per_target: req.body.max_per_target
|
||||
max_age_days: age,
|
||||
max_per_target: count
|
||||
});
|
||||
sendUpdateResult(req, res, {
|
||||
retention,
|
||||
@ -7199,6 +7414,7 @@ function createWebServer({ loadPlugins, discordClient, commandRouter }) {
|
||||
});
|
||||
sendUpdateResult(req, res, {
|
||||
...result,
|
||||
restart_required: true,
|
||||
refresh_after_ms: 5000,
|
||||
message: "Core update applied. Lumi will restart after the confirmation notice."
|
||||
});
|
||||
@ -7208,6 +7424,25 @@ function createWebServer({ loadPlugins, discordClient, commandRouter }) {
|
||||
}
|
||||
});
|
||||
|
||||
app.post("/admin/updates/core/deploy-branch", requireRole("admin"), async (req, res) => {
|
||||
try {
|
||||
const result = await deployCoreBranch({
|
||||
branch: req.body.branch,
|
||||
remote: getSetting("git_remote", "origin"),
|
||||
publish: publishWebEvent
|
||||
});
|
||||
sendUpdateResult(req, res, {
|
||||
...result,
|
||||
restart_required: true,
|
||||
refresh_after_ms: 5000,
|
||||
message: `${result.deployment.branch} was deployed and verified. Lumi will restart gracefully after this notice.`
|
||||
});
|
||||
scheduleRestartAfterNotice();
|
||||
} catch (error) {
|
||||
sendUpdateError(req, res, error);
|
||||
}
|
||||
});
|
||||
|
||||
app.post("/admin/updates/core/revert", requireRole("admin"), async (req, res) => {
|
||||
try {
|
||||
const status = getUpdateStatus({ source: updateSourceFrom(req) });
|
||||
@ -7219,6 +7454,7 @@ function createWebServer({ loadPlugins, discordClient, commandRouter }) {
|
||||
});
|
||||
sendUpdateResult(req, res, {
|
||||
...result,
|
||||
restart_required: true,
|
||||
refresh_after_ms: 5000,
|
||||
message: "Core snapshot reverted. Lumi will restart after the confirmation notice."
|
||||
});
|
||||
@ -7303,22 +7539,19 @@ function createWebServer({ loadPlugins, discordClient, commandRouter }) {
|
||||
requireRole("admin"),
|
||||
uploadSingle("plugin_zip"),
|
||||
async (req, res) => {
|
||||
if (req.uploadError) {
|
||||
setFlash(req, "error", req.uploadError);
|
||||
return res.redirect("/admin/updates");
|
||||
}
|
||||
if (!req.file) {
|
||||
setFlash(req, "error", "Upload a ZIP archive.");
|
||||
return res.redirect("/admin/updates");
|
||||
}
|
||||
if (req.uploadError) return sendUpdateError(req, res, new Error(req.uploadError));
|
||||
if (!req.file) return sendUpdateError(req, res, new Error("Upload a ZIP archive."));
|
||||
try {
|
||||
await applyPluginUpdate(req.file.path);
|
||||
setFlash(req, "success", "Plugin update applied. Restarting...");
|
||||
res.redirect("/admin/updates");
|
||||
requestRestart();
|
||||
const snapshot = await applyPluginUpdate(req.file.path);
|
||||
sendUpdateResult(req, res, {
|
||||
snapshot,
|
||||
restart_required: true,
|
||||
refresh_after_ms: 5000,
|
||||
message: "Plugin update applied. Lumi will restart after the confirmation notice."
|
||||
});
|
||||
scheduleRestartAfterNotice();
|
||||
} catch (error) {
|
||||
setFlash(req, "error", error.message);
|
||||
res.redirect("/admin/updates");
|
||||
sendUpdateError(req, res, error);
|
||||
} finally {
|
||||
try {
|
||||
fs.rmSync(req.file.path, { force: true });
|
||||
@ -7356,6 +7589,7 @@ function createWebServer({ loadPlugins, discordClient, commandRouter }) {
|
||||
});
|
||||
sendUpdateResult(req, res, {
|
||||
...result,
|
||||
restart_required: true,
|
||||
refresh_after_ms: 5000,
|
||||
message: "Plugin update applied. Lumi will restart after the confirmation notice."
|
||||
});
|
||||
@ -7378,6 +7612,8 @@ function createWebServer({ loadPlugins, discordClient, commandRouter }) {
|
||||
});
|
||||
sendUpdateResult(req, res, {
|
||||
...result,
|
||||
restart_required: true,
|
||||
refresh_after_ms: 5000,
|
||||
message: "Plugin snapshot reverted. Restarting..."
|
||||
});
|
||||
scheduleRestartAfterNotice();
|
||||
@ -7429,11 +7665,12 @@ function createWebServer({ loadPlugins, discordClient, commandRouter }) {
|
||||
const branch = getSetting("git_branch", "main");
|
||||
const result = await applyCoreUpdate({
|
||||
remote,
|
||||
source: branch === "experimental" ? "experimental" : "stable",
|
||||
source: branch === "main" ? "stable" : branch,
|
||||
publish: publishWebEvent
|
||||
});
|
||||
sendUpdateResult(req, res, {
|
||||
...result,
|
||||
restart_required: true,
|
||||
refresh_after_ms: 5000,
|
||||
message: "Core update applied. Lumi will restart in a few seconds."
|
||||
}, "/admin");
|
||||
@ -7449,7 +7686,7 @@ function createWebServer({ loadPlugins, discordClient, commandRouter }) {
|
||||
const branch = getSetting("git_branch", "main");
|
||||
const status = getUpdateStatus({
|
||||
remote,
|
||||
source: branch === "experimental" ? "experimental" : "stable"
|
||||
source: branch === "main" ? "stable" : branch
|
||||
});
|
||||
sendUpdateResult(req, res, {
|
||||
status,
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
<%- include("partials/layout-top", { title }) %>
|
||||
<%- include("partials/layout-top", { title, pageWidth: "wide", pageId: "command-policies" }) %>
|
||||
<div data-command-policies>
|
||||
<%
|
||||
const hasSetting = (policy, name) => Object.prototype.hasOwnProperty.call(policy || {}, name);
|
||||
const settingTokens = (policy, extra = []) => [
|
||||
@ -51,7 +52,7 @@
|
||||
<summary>
|
||||
<span class="command-policy-summary-copy"><span class="command-policy-title"><strong><%= defaultGroup.name %></strong><span class="badge">Protected default</span></span><span class="hint"><%= defaultGroup.description %></span></span>
|
||||
</summary>
|
||||
<form method="post" action="/admin/command-policies/groups/<%= defaultGroup.id %>" class="form-grid command-policy-form">
|
||||
<form method="post" action="/admin/command-policies/groups/<%= defaultGroup.id %>" class="form-grid command-policy-form" data-lumi-enhanced-form data-command-group-form data-command-group-id="<%= defaultGroup.id %>">
|
||||
<div class="field"><label>Name<input name="name" maxlength="100" required value="<%= defaultGroup.name %>" /></label></div>
|
||||
<div class="field"><label>Description<input name="description" maxlength="500" value="<%= defaultGroup.description %>" /></label></div>
|
||||
<div class="callout info field full"><strong>Inherited by every command</strong><p>“Keep command built-ins” leaves that category untouched. Choose an explicit value to replace built-in defaults globally.</p></div>
|
||||
@ -74,7 +75,7 @@
|
||||
<% groups.forEach((group) => { %>
|
||||
<details class="lumi-expandable-settings command-policy-item" data-policy-item data-search-name="<%= group.name %>" data-search="<%= `${group.name} ${group.description}`.toLowerCase() %>" data-settings="<%= settingTokens(group.policy) %>">
|
||||
<summary><span class="command-policy-summary-copy"><strong><%= group.name %></strong><span class="hint"><%= group.description || "Reusable command rules" %></span></span></summary>
|
||||
<form method="post" action="/admin/command-policies/groups/<%= group.id %>" class="form-grid command-policy-form">
|
||||
<form method="post" action="/admin/command-policies/groups/<%= group.id %>" class="form-grid command-policy-form" data-lumi-enhanced-form data-command-group-form data-command-group-id="<%= group.id %>">
|
||||
<div class="field"><label>Name<input name="name" maxlength="100" required value="<%= group.name %>" /></label></div>
|
||||
<div class="field"><label>Description<input name="description" maxlength="500" value="<%= group.description %>" /></label></div>
|
||||
<div class="field full"><%- include("partials/command-policy-fields", { policy: group.policy, allowInherit: false, namePrefix: "", effective: null }) %></div>
|
||||
@ -115,7 +116,7 @@
|
||||
|
||||
<% if (!commands.length) { %><p>No commands are currently registered.</p><% } %>
|
||||
<% if (commands.length) { %>
|
||||
<form method="post" action="/admin/command-policies/commands" class="command-policy-batch-form" data-command-policy-batch>
|
||||
<form method="post" action="/admin/command-policies/commands" class="command-policy-batch-form" data-command-policy-batch data-lumi-enhanced-form data-lumi-form-id="command-policies">
|
||||
<input type="hidden" name="command_count" value="<%= commands.length %>" />
|
||||
<div class="command-policy-savebar">
|
||||
<span><strong>Command settings</strong><span class="hint" data-command-save-state>All displayed values are saved.</span></span>
|
||||
@ -155,156 +156,5 @@
|
||||
</form>
|
||||
<% } %>
|
||||
</section>
|
||||
<script>
|
||||
const normalizeSearch = (value) => String(value || "").toLowerCase().replace(/[^a-z0-9]+/g, " ").trim();
|
||||
const editDistance = (left, right) => {
|
||||
const row = Array.from({ length: right.length + 1 }, (_, index) => index);
|
||||
for (let i = 1; i <= left.length; i += 1) {
|
||||
let diagonal = row[0]; row[0] = i;
|
||||
for (let j = 1; j <= right.length; j += 1) {
|
||||
const above = row[j];
|
||||
row[j] = Math.min(row[j] + 1, row[j - 1] + 1, diagonal + (left[i - 1] === right[j - 1] ? 0 : 1));
|
||||
diagonal = above;
|
||||
}
|
||||
}
|
||||
return row[right.length];
|
||||
};
|
||||
const searchScore = (query, text) => {
|
||||
const desired = normalizeSearch(query);
|
||||
const candidate = normalizeSearch(text);
|
||||
if (!desired) return 1;
|
||||
if (candidate.includes(desired)) return 1;
|
||||
const words = candidate.split(" ").filter(Boolean);
|
||||
if (words.some((word) => word.startsWith(desired))) return 0.92;
|
||||
return words.reduce((best, word) => Math.max(best, 1 - editDistance(desired, word) / Math.max(desired.length, word.length, 1)), 0);
|
||||
};
|
||||
const refreshSearchScope = (scope) => {
|
||||
const query = scope.querySelector("[data-policy-search]")?.value || "";
|
||||
const setting = scope.querySelector("[data-policy-setting-filter]")?.value || "";
|
||||
const origin = scope.querySelector("[data-policy-origin-filter]")?.value || "";
|
||||
const items = [...scope.querySelectorAll("[data-policy-item]")];
|
||||
let shown = 0;
|
||||
const ranked = [];
|
||||
items.forEach((item) => {
|
||||
const score = searchScore(query, item.dataset.search || "");
|
||||
const threshold = normalizeSearch(query).length <= 3 ? 0.82 : 0.62;
|
||||
const matchesSearch = !normalizeSearch(query) || score >= threshold;
|
||||
const matchesSetting = !setting || (item.dataset.settings || "").split(" ").includes(setting);
|
||||
const matchesOrigin = !origin || item.dataset.origin === origin;
|
||||
item.hidden = !(matchesSearch && matchesSetting && matchesOrigin);
|
||||
if (!item.hidden) shown += 1;
|
||||
if (matchesSetting && matchesOrigin && score >= threshold) ranked.push({ item, score });
|
||||
});
|
||||
const count = scope.querySelector("[data-policy-result-count]");
|
||||
if (count) count.textContent = `Showing ${shown} of ${items.length}`;
|
||||
const noResults = scope.querySelector("[data-policy-no-results]");
|
||||
if (noResults) noResults.hidden = shown !== 0;
|
||||
const suggestions = scope.querySelector("[data-policy-suggestions]");
|
||||
if (suggestions) {
|
||||
suggestions.replaceChildren();
|
||||
if (normalizeSearch(query)) {
|
||||
ranked.sort((left, right) => right.score - left.score).slice(0, 5).forEach(({ item }) => {
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.textContent = item.dataset.searchName || "Match";
|
||||
button.addEventListener("click", () => {
|
||||
const input = scope.querySelector("[data-policy-search]");
|
||||
input.value = item.dataset.searchName || "";
|
||||
suggestions.hidden = true;
|
||||
refreshSearchScope(scope);
|
||||
item.open = true;
|
||||
item.scrollIntoView({ block: "nearest", behavior: "smooth" });
|
||||
});
|
||||
suggestions.append(button);
|
||||
});
|
||||
}
|
||||
suggestions.hidden = !suggestions.children.length;
|
||||
}
|
||||
};
|
||||
document.querySelectorAll("[data-policy-search-scope]").forEach((scope) => {
|
||||
scope.querySelectorAll("[data-policy-search], [data-policy-setting-filter], [data-policy-origin-filter]").forEach((control) => {
|
||||
control.addEventListener(control.matches("input") ? "input" : "change", () => refreshSearchScope(scope));
|
||||
});
|
||||
scope.querySelector("[data-policy-search]")?.addEventListener("keydown", (event) => {
|
||||
if (event.key === "Escape") scope.querySelector("[data-policy-suggestions]").hidden = true;
|
||||
});
|
||||
refreshSearchScope(scope);
|
||||
});
|
||||
|
||||
const parsePolicyData = (value) => { try { return JSON.parse(value || "{}"); } catch { return {}; } };
|
||||
const hasPolicyCategory = (policy, name) => Object.prototype.hasOwnProperty.call(policy || {}, name);
|
||||
const roleLabel = (value = {}) => {
|
||||
if (value.role === "subscriber") return Number(value.subscriberTier) > 1 ? `Tier ${value.subscriberTier}+ subscribers` : "Subscribers / members";
|
||||
return ({ follower: "Followers", vip: "VIPs", mod: "Moderators", editor: "Editors", streamer: "Streamer only" })[value.role] || "Everyone";
|
||||
};
|
||||
const ruleText = (name, value) => {
|
||||
if (name === "window") return `${value.uses} use${Number(value.uses) === 1 ? "" : "s"} per ${value.seconds} seconds, ${value.scope === "global" ? "shared by everyone" : "per user"}`;
|
||||
if (name === "perStream") return `${value.uses} use${Number(value.uses) === 1 ? "" : "s"} per stream, ${value.scope === "global" ? "shared by everyone" : "per user"}`;
|
||||
if (name === "requirement") return roleLabel(value);
|
||||
if (name === "cost") return `${value.amount} currency per successful run.`;
|
||||
return "";
|
||||
};
|
||||
const emptyRuleText = (name) => ({
|
||||
window: "No time-window limit.",
|
||||
perStream: "No per-stream limit.",
|
||||
requirement: "Everyone may use it.",
|
||||
cost: "Free to use."
|
||||
})[name];
|
||||
const refreshInheritedSummaries = (editor) => {
|
||||
const builtIn = parsePolicyData(editor.dataset.builtInPolicy);
|
||||
const globalDefault = parsePolicyData(editor.dataset.defaultGroupPolicy);
|
||||
const selected = [...editor.querySelectorAll("input[data-group-policy]:checked")].map((input) => ({
|
||||
name: input.dataset.groupName || "Group",
|
||||
policy: parsePolicyData(input.dataset.groupPolicy)
|
||||
}));
|
||||
for (const name of ["window", "perStream", "requirement", "cost"]) {
|
||||
const summary = editor.querySelector(`[data-policy-inherited="${name}"] strong`);
|
||||
if (!summary) continue;
|
||||
const positive = selected
|
||||
.filter((item) => hasPolicyCategory(item.policy, name) && item.policy[name] && item.policy[name] !== false)
|
||||
.map((item) => ({ source: item.name, value: item.policy[name] }));
|
||||
const defaultHas = hasPolicyCategory(globalDefault, name);
|
||||
if (defaultHas && globalDefault[name] && globalDefault[name] !== false) positive.push({ source: "Default group", value: globalDefault[name] });
|
||||
if (name === "cost" && positive.length) {
|
||||
const highest = positive.reduce((best, item) => Number(item.value.amount) > Number(best.value.amount) ? item : best);
|
||||
summary.textContent = `${highest.source}: ${ruleText(name, highest.value)}`;
|
||||
} else if (positive.length) {
|
||||
summary.textContent = positive.map((item) => `${item.source}: ${ruleText(name, item.value)}`).join(" · ");
|
||||
} else if (defaultHas && globalDefault[name] === false) {
|
||||
summary.textContent = emptyRuleText(name);
|
||||
} else if (builtIn[name] && builtIn[name] !== false) {
|
||||
summary.textContent = `Built-in: ${ruleText(name, builtIn[name])}`;
|
||||
} else {
|
||||
summary.textContent = emptyRuleText(name);
|
||||
}
|
||||
}
|
||||
};
|
||||
document.querySelectorAll("[data-command-policy-editor]").forEach((editor) => {
|
||||
refreshInheritedSummaries(editor);
|
||||
editor.querySelectorAll("input[data-group-policy]").forEach((input) => input.addEventListener("change", () => refreshInheritedSummaries(editor)));
|
||||
});
|
||||
|
||||
const updatePolicyCategory = (category) => {
|
||||
const mode = category.querySelector("[data-policy-mode]")?.value || "off";
|
||||
const custom = mode === "custom";
|
||||
const inherited = mode === "inherit";
|
||||
const values = category.querySelector("[data-policy-values]");
|
||||
const inheritedSummary = category.querySelector("[data-policy-inherited]");
|
||||
if (values) values.hidden = !custom;
|
||||
if (inheritedSummary) inheritedSummary.hidden = !inherited;
|
||||
const role = category.querySelector("[data-requirement-role]");
|
||||
const tier = category.querySelector("[data-subscriber-tier]");
|
||||
if (tier) tier.hidden = !custom || role?.value !== "subscriber";
|
||||
};
|
||||
document.querySelectorAll("[data-policy-category]").forEach((category) => {
|
||||
updatePolicyCategory(category);
|
||||
category.querySelector("[data-policy-mode]")?.addEventListener("change", () => updatePolicyCategory(category));
|
||||
category.querySelector("[data-requirement-role]")?.addEventListener("change", () => updatePolicyCategory(category));
|
||||
});
|
||||
|
||||
const batch = document.querySelector("[data-command-policy-batch]");
|
||||
const saveState = document.querySelector("[data-command-save-state]");
|
||||
batch?.addEventListener("change", () => { if (saveState) saveState.textContent = "Unsaved command changes."; });
|
||||
batch?.addEventListener("input", () => { if (saveState) saveState.textContent = "Unsaved command changes."; });
|
||||
</script>
|
||||
</div>
|
||||
<%- include("partials/layout-bottom") %>
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
<%- include("partials/layout-top", { title }) %>
|
||||
<%- include("partials/layout-top", { title, pageWidth: "wide", pageId: "command-management" }) %>
|
||||
<div data-command-management>
|
||||
<section class="card">
|
||||
<%- include("partials/page-header", {
|
||||
eyebrow: "Chat tools",
|
||||
@ -7,7 +8,7 @@
|
||||
actions: isAdmin ? '<a class="button subtle" href="/admin/command-policies">Access, groups and limits</a>' : ''
|
||||
}) %>
|
||||
<% const platformLabelMap = new Map((platforms || []).map((item) => [item.id, item.label])); %>
|
||||
<form method="post" action="/admin/commands" class="form-grid command-form">
|
||||
<form method="post" action="/admin/commands" class="form-grid command-form" data-command-create-form data-lumi-enhanced-form data-lumi-form-id="create-command">
|
||||
<div class="field">
|
||||
<label>Trigger</label>
|
||||
<input name="trigger" placeholder="hello" />
|
||||
@ -104,8 +105,8 @@
|
||||
<p class="hint">Moderators can create Static, Random Reply, and Conditional Reply commands. Dynamic code remains admin-only.</p>
|
||||
<% } %>
|
||||
</section>
|
||||
<section class="card">
|
||||
<div class="section-header"><div><h2>Existing commands</h2><p class="hint">Review command replies and change their availability.</p></div></div>
|
||||
<section class="card" data-command-list-section>
|
||||
<div class="section-header"><div><h2>Existing commands</h2><p class="hint">Review command replies and change their availability.</p><p class="hint" data-command-management-status role="status" aria-live="polite"></p></div></div>
|
||||
<% if (!commands.length) { %>
|
||||
<p>No commands created yet.</p>
|
||||
<% } else { %>
|
||||
@ -121,8 +122,8 @@
|
||||
<tbody>
|
||||
<% commands.forEach((command) => { %>
|
||||
<tr>
|
||||
<td><%= command.trigger %></td>
|
||||
<td>
|
||||
<td data-label="Trigger"><%= command.trigger %></td>
|
||||
<td data-label="Response">
|
||||
<div class="command-meta">
|
||||
<span class="platform-pills">
|
||||
<% (command.platforms || []).forEach((platform) => { %>
|
||||
@ -157,16 +158,16 @@
|
||||
<% } %>
|
||||
</div>
|
||||
</td>
|
||||
<td><%= command.enabled ? "Enabled" : "Disabled" %></td>
|
||||
<td><div class="table-actions command-actions">
|
||||
<form method="post" action="/admin/commands/<%= command.id %>/toggle" class="inline-form">
|
||||
<td data-label="Status"><%= command.enabled ? "Enabled" : "Disabled" %></td>
|
||||
<td data-label="Actions"><div class="table-actions command-actions">
|
||||
<form method="post" action="/admin/commands/<%= command.id %>/toggle" class="inline-form" data-lumi-enhanced-form data-command-list-action>
|
||||
<button type="submit" class="button subtle"><%= command.enabled ? "Disable" : "Enable" %></button>
|
||||
</form>
|
||||
<form method="post" action="/admin/commands/<%= command.id %>/delete" class="inline-form" data-confirm-mode="modal" data-confirm-title="Delete command" data-confirm-text="Delete !<%= command.trigger %>?" data-confirm-label="Delete command">
|
||||
<form method="post" action="/admin/commands/<%= command.id %>/delete" class="inline-form" data-lumi-enhanced-form data-command-list-action data-confirm-mode="modal" data-confirm-title="Delete command" data-confirm-text="Delete !<%= command.trigger %>?" data-confirm-label="Delete command">
|
||||
<button type="submit" class="button danger">Delete</button>
|
||||
</form>
|
||||
<% if (isAdmin && command.mode === "advanced") { %>
|
||||
<form method="post" action="/admin/commands/<%= command.id %>/preview" class="inline-form">
|
||||
<form method="post" action="/admin/commands/<%= command.id %>/preview" class="inline-form" data-lumi-enhanced-form data-command-list-action>
|
||||
<button type="submit" class="button subtle">Refresh preview</button>
|
||||
</form>
|
||||
<% } %>
|
||||
@ -184,7 +185,7 @@
|
||||
</tr>
|
||||
<tr class="edit-row" data-edit-row="command-<%= command.id %>">
|
||||
<td colspan="4">
|
||||
<form method="post" action="/admin/commands/<%= command.id %>/update" class="form-grid command-form">
|
||||
<form method="post" action="/admin/commands/<%= command.id %>/update" class="form-grid command-form" data-lumi-enhanced-form data-command-list-action>
|
||||
<div class="field">
|
||||
<label>Trigger</label>
|
||||
<input name="trigger" value="<%= command.trigger %>" />
|
||||
@ -286,104 +287,5 @@
|
||||
</table></div>
|
||||
<% } %>
|
||||
</section>
|
||||
<script>
|
||||
const forms = document.querySelectorAll(".command-form");
|
||||
const toggleFields = (form) => {
|
||||
const mode = form.querySelector(".js-command-mode")?.value || "plain";
|
||||
const response = form.querySelector(".js-field-response")?.closest(".field");
|
||||
const code = form.querySelector(".js-field-code");
|
||||
const language = form.querySelector(".js-field-language");
|
||||
const random = form.querySelector(".js-field-random");
|
||||
const conditional = form.querySelector(".js-field-conditional");
|
||||
const isAdvanced = mode === "advanced";
|
||||
if (response) response.style.display = ["plain", "conditional"].includes(mode) ? "" : "none";
|
||||
if (code) code.style.display = isAdvanced ? "" : "none";
|
||||
if (language) language.style.display = isAdvanced ? "" : "none";
|
||||
if (random) random.style.display = mode === "random" ? "" : "none";
|
||||
if (conditional) conditional.style.display = mode === "conditional" ? "" : "none";
|
||||
};
|
||||
const addConditionalReply = (form) => {
|
||||
const list = form.querySelector("[data-conditional-replies]");
|
||||
if (!list) return;
|
||||
const row = document.createElement("div");
|
||||
row.className = "command-random-row command-conditional-row";
|
||||
row.dataset.conditionalReplyRow = "";
|
||||
const keyword = document.createElement("input");
|
||||
keyword.name = "conditional_keyword";
|
||||
keyword.placeholder = "keyword, alias";
|
||||
keyword.setAttribute("aria-label", "Keywords, separated with commas");
|
||||
const response = document.createElement("input");
|
||||
response.name = "conditional_response";
|
||||
response.placeholder = "The reply for this keyword";
|
||||
response.setAttribute("aria-label", "Reply");
|
||||
response.dataset.placeholderField = "core.custom_commands.static_response";
|
||||
response.dataset.placeholderOutputAudience = "user";
|
||||
const remove = document.createElement("button");
|
||||
remove.type = "button";
|
||||
remove.className = "button subtle";
|
||||
remove.dataset.conditionalRemove = "";
|
||||
remove.textContent = "Remove";
|
||||
row.append(keyword, response, remove);
|
||||
list.append(row);
|
||||
keyword.focus();
|
||||
};
|
||||
const toggleRngFields = (form) => {
|
||||
const enabled = form.querySelector(".js-rng-enabled")?.checked === true;
|
||||
const range = form.querySelector("[data-rng-range]");
|
||||
if (range) range.hidden = !enabled;
|
||||
};
|
||||
const addRandomReply = (form) => {
|
||||
const list = form.querySelector("[data-random-replies]");
|
||||
if (!list) return;
|
||||
const row = document.createElement("div");
|
||||
row.className = "command-random-row";
|
||||
row.dataset.randomReplyRow = "";
|
||||
const text = document.createElement("input");
|
||||
text.name = "random_text";
|
||||
text.placeholder = "Message to send";
|
||||
text.dataset.placeholderField = "core.custom_commands.static_response";
|
||||
text.dataset.placeholderOutputAudience = "user";
|
||||
const weight = document.createElement("input");
|
||||
weight.name = "random_weight";
|
||||
weight.type = "number";
|
||||
weight.min = "1";
|
||||
weight.max = "999";
|
||||
weight.step = "1";
|
||||
weight.value = "1";
|
||||
weight.setAttribute("aria-label", "Selection weight");
|
||||
const condition = document.createElement("input");
|
||||
condition.name = "random_condition";
|
||||
condition.placeholder = "Optional: <20";
|
||||
condition.setAttribute("aria-label", "RNG condition");
|
||||
const remove = document.createElement("button");
|
||||
remove.type = "button";
|
||||
remove.className = "button subtle";
|
||||
remove.dataset.randomRemove = "";
|
||||
remove.textContent = "Remove";
|
||||
row.append(text, weight, condition, remove);
|
||||
list.append(row);
|
||||
text.focus();
|
||||
};
|
||||
forms.forEach((form) => {
|
||||
toggleFields(form);
|
||||
toggleRngFields(form);
|
||||
form.querySelector(".js-command-mode")?.addEventListener("change", () => {
|
||||
toggleFields(form);
|
||||
});
|
||||
form.querySelector(".js-rng-enabled")?.addEventListener("change", () => toggleRngFields(form));
|
||||
form.querySelector("[data-random-add]")?.addEventListener("click", () => addRandomReply(form));
|
||||
form.querySelector("[data-conditional-add]")?.addEventListener("click", () => addConditionalReply(form));
|
||||
form.addEventListener("click", (event) => {
|
||||
const remove = event.target.closest("[data-random-remove]");
|
||||
if (!remove || !form.contains(remove)) return;
|
||||
remove.closest("[data-random-reply-row]")?.remove();
|
||||
return;
|
||||
});
|
||||
form.addEventListener("click", (event) => {
|
||||
const remove = event.target.closest("[data-conditional-remove]");
|
||||
if (!remove || !form.contains(remove)) return;
|
||||
remove.closest("[data-conditional-reply-row]")?.remove();
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</div>
|
||||
<%- include("partials/layout-bottom") %>
|
||||
|
||||
@ -1,214 +1,145 @@
|
||||
<%- include("partials/layout-top", { title }) %>
|
||||
<style>
|
||||
.nav-builder {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
.nav-builder-body {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 2fr) minmax(0, 1fr);
|
||||
gap: 1.5rem;
|
||||
}
|
||||
.nav-builder-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
.nav-section-card,
|
||||
.nav-pool-card {
|
||||
background: var(--surface-2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 16px;
|
||||
padding: 1rem;
|
||||
}
|
||||
.nav-actions {
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
.nav-actions .inline-form {
|
||||
margin: 0;
|
||||
}
|
||||
.nav-section-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
.nav-section-header {
|
||||
display: grid;
|
||||
grid-template-columns: 1.2fr 1fr 1fr auto;
|
||||
gap: 0.75rem;
|
||||
align-items: center;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
.nav-section-header input,
|
||||
.nav-section-header select {
|
||||
width: 100%;
|
||||
}
|
||||
.nav-dd-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
min-height: 2.5rem;
|
||||
padding: 0.5rem;
|
||||
border-radius: 12px;
|
||||
border: 1px dashed transparent;
|
||||
background: var(--surface-3);
|
||||
}
|
||||
.nav-dd-list.drag-over {
|
||||
border-color: var(--sea);
|
||||
background: rgba(79, 182, 194, 0.08);
|
||||
}
|
||||
.nav-dd-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.6rem 0.8rem;
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--card);
|
||||
cursor: grab;
|
||||
}
|
||||
.nav-dd-item.dragging {
|
||||
opacity: 0.55;
|
||||
}
|
||||
.nav-dd-handle {
|
||||
font-family: "Courier New", monospace;
|
||||
opacity: 0.65;
|
||||
}
|
||||
.nav-dd-meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.15rem;
|
||||
}
|
||||
.nav-dd-meta span {
|
||||
font-size: 0.85rem;
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
.nav-advanced {
|
||||
display: none;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
.nav-advanced.open {
|
||||
display: block;
|
||||
}
|
||||
.nav-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.nav-toolbar .button {
|
||||
white-space: nowrap;
|
||||
}
|
||||
@media (min-width: 901px) {
|
||||
.nav-pool-card {
|
||||
position: sticky;
|
||||
top: 1rem;
|
||||
align-self: start;
|
||||
max-height: calc(100vh - 2rem);
|
||||
overflow: auto;
|
||||
}
|
||||
}
|
||||
@media (max-width: 900px) {
|
||||
.nav-builder-body {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.nav-section-header {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<%- include("partials/layout-top", { title, pageWidth: "workspace", pageId: "navigation-builder" }) %>
|
||||
|
||||
<section class="card">
|
||||
<h1>Navigation</h1>
|
||||
<p class="hint">Drag items between sections to build the sidebar layout.</p>
|
||||
<form method="post" action="/admin/navigation" class="form-grid" id="nav-form">
|
||||
<div class="field">
|
||||
<label>Enable custom navigation</label>
|
||||
<label class="switch">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="switch-input"
|
||||
name="nav_enabled"
|
||||
<%= navStructure.enabled ? "checked" : "" %>
|
||||
/>
|
||||
<span class="switch-track" aria-hidden="true"></span>
|
||||
<span class="switch-text"><%= navStructure.enabled ? "Enabled" : "Disabled" %></span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Include unassigned items</label>
|
||||
<label class="switch">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="switch-input"
|
||||
name="nav_include_unassigned"
|
||||
<%= navStructure.includeUnassigned ? "checked" : "" %>
|
||||
/>
|
||||
<span class="switch-track" aria-hidden="true"></span>
|
||||
<span class="switch-text"><%= navStructure.includeUnassigned ? "Enabled" : "Disabled" %></span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Unassigned section label</label>
|
||||
<input name="nav_unassigned_label" value="<%= navStructure.unassignedLabel || 'Other' %>" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Unassigned section id</label>
|
||||
<input name="nav_unassigned_id" value="<%= navStructure.unassignedId || 'other' %>" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Unassigned section icon</label>
|
||||
<select name="nav_unassigned_icon">
|
||||
<option value="">Default (blocks)</option>
|
||||
<% (sectionIcons || []).forEach((icon) => { %>
|
||||
<option value="<%= icon %>" <%= navStructure.unassignedIcon === icon ? 'selected' : '' %>><%= icon %></option>
|
||||
<% }) %>
|
||||
</select>
|
||||
<section class="card nav-builder" data-navigation-builder>
|
||||
<header class="section-header nav-builder-page-header">
|
||||
<div>
|
||||
<p class="eyebrow">Application shell</p>
|
||||
<h1>Navigation</h1>
|
||||
<p class="hint">Organize sidebar destinations into clear sections. Drag with a pointer or use the labelled move controls.</p>
|
||||
</div>
|
||||
<span class="badge" data-nav-dirty-badge hidden>Unsaved changes</span>
|
||||
</header>
|
||||
|
||||
<div class="field full nav-builder">
|
||||
<div class="nav-toolbar">
|
||||
<button type="button" class="button subtle" data-add-section>Add section</button>
|
||||
<button type="button" class="button subtle" data-advanced-toggle>Advanced</button>
|
||||
<form
|
||||
method="post"
|
||||
action="/admin/navigation"
|
||||
class="form-grid"
|
||||
id="nav-form"
|
||||
data-lumi-enhanced-form
|
||||
data-lumi-form-id="navigation-builder"
|
||||
>
|
||||
<fieldset class="field full nav-builder-settings">
|
||||
<legend>Sidebar behavior</legend>
|
||||
<div class="nav-builder-setting-grid">
|
||||
<label class="switch" for="nav-enabled">
|
||||
<input
|
||||
id="nav-enabled"
|
||||
type="checkbox"
|
||||
class="switch-input"
|
||||
name="nav_enabled"
|
||||
<%= navStructure.enabled ? "checked" : "" %>
|
||||
/>
|
||||
<span class="switch-track" aria-hidden="true"></span>
|
||||
<span>
|
||||
<strong>Enable custom navigation</strong>
|
||||
<span class="hint switch-text" data-switch-state><%= navStructure.enabled ? "Enabled" : "Disabled" %></span>
|
||||
</span>
|
||||
</label>
|
||||
<label class="switch" for="nav-include-unassigned">
|
||||
<input
|
||||
id="nav-include-unassigned"
|
||||
type="checkbox"
|
||||
class="switch-input"
|
||||
name="nav_include_unassigned"
|
||||
<%= navStructure.includeUnassigned ? "checked" : "" %>
|
||||
/>
|
||||
<span class="switch-track" aria-hidden="true"></span>
|
||||
<span>
|
||||
<strong>Include unassigned items</strong>
|
||||
<span class="hint switch-text" data-switch-state><%= navStructure.includeUnassigned ? "Enabled" : "Disabled" %></span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-grid nav-builder-unassigned-settings">
|
||||
<div class="field">
|
||||
<label for="nav-unassigned-label">Unassigned section label</label>
|
||||
<input id="nav-unassigned-label" name="nav_unassigned_label" value="<%= navStructure.unassignedLabel || 'Other' %>" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="nav-unassigned-id">Unassigned section ID</label>
|
||||
<input id="nav-unassigned-id" name="nav_unassigned_id" value="<%= navStructure.unassignedId || 'other' %>" autocapitalize="none" spellcheck="false" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="nav-unassigned-icon">Unassigned section icon</label>
|
||||
<select id="nav-unassigned-icon" name="nav_unassigned_icon">
|
||||
<option value="">Default (blocks)</option>
|
||||
<% (sectionIcons || []).forEach((icon) => { %>
|
||||
<option value="<%= icon %>" <%= navStructure.unassignedIcon === icon ? 'selected' : '' %>><%= icon %></option>
|
||||
<% }) %>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<div class="field full nav-builder-workspace">
|
||||
<div class="nav-toolbar" aria-label="Navigation editor actions">
|
||||
<button type="button" class="button subtle" data-add-section>Add section</button>
|
||||
<button type="button" class="button subtle" data-advanced-toggle aria-expanded="false" aria-controls="nav-advanced-panel">Advanced JSON</button>
|
||||
</div>
|
||||
|
||||
<div class="nav-builder-body">
|
||||
<div>
|
||||
<section aria-labelledby="nav-sections-heading">
|
||||
<div class="nav-builder-header">
|
||||
<h2>Sections</h2>
|
||||
<div>
|
||||
<h2 id="nav-sections-heading">Sidebar sections</h2>
|
||||
<p class="hint">Items appear in this order in the application sidebar.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="nav-section-list" data-section-list></div>
|
||||
</div>
|
||||
<div class="nav-pool-card">
|
||||
</section>
|
||||
|
||||
<aside class="nav-pool-card" aria-labelledby="nav-unassigned-heading">
|
||||
<div class="nav-builder-header">
|
||||
<h2>Unassigned items</h2>
|
||||
<div>
|
||||
<h2 id="nav-unassigned-heading">Unassigned items</h2>
|
||||
<p class="hint">Move an item into a section to give it a deliberate location.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="nav-dd-list" data-unassigned-list></div>
|
||||
</div>
|
||||
<div class="nav-dd-list" data-unassigned-list data-group-label="Unassigned items"></div>
|
||||
</aside>
|
||||
</div>
|
||||
<div class="nav-advanced" data-advanced-panel>
|
||||
<div class="field full">
|
||||
<label>Sections JSON (advanced)</label>
|
||||
<textarea name="nav_sections" rows="14" id="nav-sections-json"><%- navSectionsJson %></textarea>
|
||||
<p class="hint">Each section needs <code>id</code>, <code>label</code>, <code>icon</code>, and <code>items</code>.</p>
|
||||
|
||||
<section class="nav-advanced" id="nav-advanced-panel" data-advanced-panel aria-labelledby="nav-advanced-heading">
|
||||
<div class="section-header">
|
||||
<div>
|
||||
<h2 id="nav-advanced-heading">Advanced structure</h2>
|
||||
<p class="hint">Use JSON only when bulk editing is faster than the visual builder.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field full">
|
||||
<button type="button" class="button subtle" data-apply-json>Apply JSON to builder</button>
|
||||
<div class="form-grid">
|
||||
<div class="field full">
|
||||
<label for="nav-sections-json">Sections JSON</label>
|
||||
<textarea name="nav_sections" rows="14" id="nav-sections-json" spellcheck="false" aria-describedby="nav-sections-json-help"><%- navSectionsJson %></textarea>
|
||||
<p class="hint" id="nav-sections-json-help">Each section needs <code>id</code>, <code>label</code>, <code>icon</code>, and an <code>items</code> array.</p>
|
||||
</div>
|
||||
<div class="field full">
|
||||
<button type="button" class="button subtle" data-apply-json>Apply JSON to visual builder</button>
|
||||
</div>
|
||||
<details class="field full">
|
||||
<summary>View Lumi's default structure</summary>
|
||||
<pre tabindex="0"><code><%- defaultSectionsJson %></code></pre>
|
||||
</details>
|
||||
</div>
|
||||
<div class="field full">
|
||||
<label>Default structure</label>
|
||||
<pre><code><%- defaultSectionsJson %></code></pre>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<p class="hint lumi-form-status nav-builder-status" data-lumi-form-status data-nav-builder-status role="status" aria-live="polite">All changes are saved.</p>
|
||||
</form>
|
||||
|
||||
<div class="button-group centered nav-actions">
|
||||
<button type="submit" form="nav-form" class="button">Save navigation</button>
|
||||
<form method="post" action="/admin/navigation/reset" class="inline-form" data-confirm-mode="modal" data-confirm-title="Reset navigation" data-confirm-text="Replace the current navigation layout with Lumi's default structure?" data-confirm-label="Reset navigation">
|
||||
<button type="submit" class="button subtle">Reset to default</button>
|
||||
<button type="submit" form="nav-form" class="button" data-pending-label="Saving navigation…">Save navigation</button>
|
||||
<form
|
||||
method="post"
|
||||
action="/admin/navigation/reset"
|
||||
class="inline-form"
|
||||
data-lumi-enhanced-form
|
||||
data-confirm-mode="modal"
|
||||
data-confirm-title="Reset navigation"
|
||||
data-confirm-text="Replace the current navigation layout with Lumi's default structure?"
|
||||
data-confirm-label="Reset navigation"
|
||||
>
|
||||
<button type="submit" class="button subtle" data-pending-label="Resetting…">Reset to default</button>
|
||||
<span class="visually-hidden" data-lumi-form-status role="status" aria-live="polite"></span>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
@ -216,229 +147,4 @@
|
||||
<script id="nav-items-data" type="application/json"><%- navItemsJson %></script>
|
||||
<script id="nav-sections-data" type="application/json"><%- navSectionsData %></script>
|
||||
<script id="nav-icons-data" type="application/json"><%- JSON.stringify(sectionIcons || []) %></script>
|
||||
<script>
|
||||
(() => {
|
||||
const navItems = JSON.parse(document.getElementById("nav-items-data").textContent || "[]");
|
||||
const navSections = JSON.parse(document.getElementById("nav-sections-data").textContent || "[]");
|
||||
const navIcons = JSON.parse(document.getElementById("nav-icons-data").textContent || "[]");
|
||||
const form = document.getElementById("nav-form");
|
||||
const sectionList = document.querySelector("[data-section-list]");
|
||||
const unassignedList = document.querySelector("[data-unassigned-list]");
|
||||
const jsonField = document.getElementById("nav-sections-json");
|
||||
const advancedToggle = document.querySelector("[data-advanced-toggle]");
|
||||
const advancedPanel = document.querySelector("[data-advanced-panel]");
|
||||
const addSectionButton = document.querySelector("[data-add-section]");
|
||||
const applyJsonButton = document.querySelector("[data-apply-json]");
|
||||
const itemMap = new Map(navItems.map((item) => [item.navId, item]));
|
||||
|
||||
function buildItem(navId) {
|
||||
const data = itemMap.get(navId) || { navId, label: navId, path: "", role: "public" };
|
||||
const item = document.createElement("div");
|
||||
item.className = "nav-dd-item";
|
||||
item.setAttribute("draggable", "true");
|
||||
item.dataset.navId = navId;
|
||||
item.innerHTML = `
|
||||
<span class="nav-dd-handle">||</span>
|
||||
<div class="nav-dd-meta">
|
||||
<strong>${escapeHtml(data.label || navId)}</strong>
|
||||
<span>${escapeHtml(data.path || navId)}</span>
|
||||
</div>
|
||||
`;
|
||||
item.addEventListener("dragstart", () => {
|
||||
item.classList.add("dragging");
|
||||
});
|
||||
item.addEventListener("dragend", () => {
|
||||
item.classList.remove("dragging");
|
||||
syncJson();
|
||||
});
|
||||
return item;
|
||||
}
|
||||
|
||||
function buildSection(section) {
|
||||
const card = document.createElement("div");
|
||||
card.className = "nav-section-card";
|
||||
card.innerHTML = `
|
||||
<div class="nav-section-header">
|
||||
<input type="text" class="nav-section-label" placeholder="Section label" value="${escapeHtml(section.label || "")}">
|
||||
<input type="text" class="nav-section-id" placeholder="section-id" value="${escapeHtml(section.id || "")}">
|
||||
<select class="nav-section-icon"></select>
|
||||
<button type="button" class="button subtle nav-section-remove">Remove</button>
|
||||
</div>
|
||||
<div class="nav-dd-list" data-section-items></div>
|
||||
`;
|
||||
const select = card.querySelector(".nav-section-icon");
|
||||
const selectedIcon = (section.icon || "").toString().toLowerCase();
|
||||
const options = [`<option value="">Icon</option>`].concat(
|
||||
navIcons.map((icon) => {
|
||||
const selected = icon === selectedIcon ? "selected" : "";
|
||||
return `<option value="${icon}" ${selected}>${icon}</option>`;
|
||||
})
|
||||
);
|
||||
select.innerHTML = options.join("");
|
||||
const list = card.querySelector("[data-section-items]");
|
||||
(section.items || []).forEach((navId) => {
|
||||
if (itemMap.has(navId)) {
|
||||
list.appendChild(buildItem(navId));
|
||||
}
|
||||
});
|
||||
attachList(list);
|
||||
card.querySelector(".nav-section-remove").addEventListener("click", () => {
|
||||
const items = Array.from(list.querySelectorAll(".nav-dd-item"));
|
||||
items.forEach((item) => unassignedList.appendChild(item));
|
||||
card.remove();
|
||||
syncJson();
|
||||
});
|
||||
card.querySelectorAll("input, select").forEach((input) => {
|
||||
input.addEventListener("change", syncJson);
|
||||
});
|
||||
return card;
|
||||
}
|
||||
|
||||
function attachList(list) {
|
||||
list.addEventListener("dragover", (event) => {
|
||||
event.preventDefault();
|
||||
list.classList.add("drag-over");
|
||||
const afterElement = getDragAfterElement(list, event.clientY);
|
||||
const dragging = document.querySelector(".nav-dd-item.dragging");
|
||||
if (!dragging) return;
|
||||
if (afterElement == null) {
|
||||
list.appendChild(dragging);
|
||||
} else {
|
||||
list.insertBefore(dragging, afterElement);
|
||||
}
|
||||
});
|
||||
list.addEventListener("dragleave", () => {
|
||||
list.classList.remove("drag-over");
|
||||
});
|
||||
list.addEventListener("drop", (event) => {
|
||||
event.preventDefault();
|
||||
list.classList.remove("drag-over");
|
||||
syncJson();
|
||||
});
|
||||
}
|
||||
|
||||
function getDragAfterElement(container, y) {
|
||||
const draggableElements = [
|
||||
...container.querySelectorAll(".nav-dd-item:not(.dragging)")
|
||||
];
|
||||
return draggableElements.reduce(
|
||||
(closest, child) => {
|
||||
const box = child.getBoundingClientRect();
|
||||
const offset = y - box.top - box.height / 2;
|
||||
if (offset < 0 && offset > closest.offset) {
|
||||
return { offset, element: child };
|
||||
}
|
||||
return closest;
|
||||
},
|
||||
{ offset: Number.NEGATIVE_INFINITY, element: null }
|
||||
).element;
|
||||
}
|
||||
|
||||
function collectSections() {
|
||||
const sections = [];
|
||||
sectionList.querySelectorAll(".nav-section-card").forEach((card, index) => {
|
||||
const label = card.querySelector(".nav-section-label").value.trim();
|
||||
const idInput = card.querySelector(".nav-section-id").value.trim();
|
||||
const id = idInput || label || `section-${index + 1}`;
|
||||
const icon = card.querySelector(".nav-section-icon").value.trim();
|
||||
const items = Array.from(card.querySelectorAll(".nav-dd-item")).map(
|
||||
(item) => item.dataset.navId
|
||||
);
|
||||
sections.push({
|
||||
id,
|
||||
label: label || id,
|
||||
icon,
|
||||
items
|
||||
});
|
||||
});
|
||||
return sections;
|
||||
}
|
||||
|
||||
function syncJson() {
|
||||
if (!jsonField) return;
|
||||
jsonField.value = JSON.stringify(collectSections(), null, 2);
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
return (value || "").replace(/[&<>"']/g, (char) => {
|
||||
switch (char) {
|
||||
case "&": return "&";
|
||||
case "<": return "<";
|
||||
case ">": return ">";
|
||||
case "\"": return """;
|
||||
case "'": return "'";
|
||||
default: return char;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function buildInitial() {
|
||||
rebuildFromSections(navSections);
|
||||
}
|
||||
|
||||
function rebuildFromSections(sections) {
|
||||
sectionList.innerHTML = "";
|
||||
unassignedList.innerHTML = "";
|
||||
const assigned = new Set();
|
||||
(sections || []).forEach((section) => {
|
||||
const card = buildSection(section);
|
||||
sectionList.appendChild(card);
|
||||
(section.items || []).forEach((navId) => assigned.add(navId));
|
||||
});
|
||||
navItems.forEach((item) => {
|
||||
if (!assigned.has(item.navId)) {
|
||||
unassignedList.appendChild(buildItem(item.navId));
|
||||
}
|
||||
});
|
||||
attachList(unassignedList);
|
||||
syncJson();
|
||||
}
|
||||
|
||||
addSectionButton.addEventListener("click", () => {
|
||||
const existingIds = new Set(
|
||||
Array.from(sectionList.querySelectorAll(".nav-section-id")).map((input) =>
|
||||
input.value.trim().toLowerCase()
|
||||
)
|
||||
);
|
||||
let index = sectionList.querySelectorAll(".nav-section-card").length + 1;
|
||||
let nextId = `section-${index}`;
|
||||
while (existingIds.has(nextId)) {
|
||||
index += 1;
|
||||
nextId = `section-${index}`;
|
||||
}
|
||||
const newSection = buildSection({
|
||||
id: nextId,
|
||||
label: `Section ${index}`,
|
||||
icon: "",
|
||||
items: []
|
||||
});
|
||||
sectionList.appendChild(newSection);
|
||||
syncJson();
|
||||
});
|
||||
|
||||
advancedToggle.addEventListener("click", () => {
|
||||
advancedPanel.classList.toggle("open");
|
||||
});
|
||||
|
||||
applyJsonButton.addEventListener("click", () => {
|
||||
if (!jsonField) return;
|
||||
try {
|
||||
const parsed = JSON.parse(jsonField.value || "[]");
|
||||
if (!Array.isArray(parsed)) {
|
||||
window.alert("JSON must be an array of sections.");
|
||||
return;
|
||||
}
|
||||
rebuildFromSections(parsed);
|
||||
} catch (error) {
|
||||
window.alert("JSON is invalid.");
|
||||
}
|
||||
});
|
||||
|
||||
form.addEventListener("submit", () => {
|
||||
syncJson();
|
||||
});
|
||||
|
||||
buildInitial();
|
||||
})();
|
||||
</script>
|
||||
<%- include("partials/layout-bottom") %>
|
||||
|
||||
@ -18,7 +18,7 @@
|
||||
renderType: moduleTypes.find((type) => type.id === module.type)?.renderType || module.type
|
||||
})));
|
||||
%>
|
||||
<%- include("partials/layout-top", { title }) %>
|
||||
<%- include("partials/layout-top", { title, pageWidth: "workspace", pageId: "overlay-management overlay-detail" }) %>
|
||||
<link rel="stylesheet" href="/overlay-runtime.css?v=<%= assetVersion %>" />
|
||||
<link rel="stylesheet" href="/overlay-management.css?v=<%= assetVersion %>" />
|
||||
|
||||
@ -31,7 +31,7 @@
|
||||
}) %>
|
||||
<details class="overlay-settings-disclosure">
|
||||
<summary>Overlay name and canvas size</summary>
|
||||
<form method="post" action="/admin/overlays/<%= overlay.id %>" class="form-grid">
|
||||
<form method="post" action="/admin/overlays/<%= overlay.id %>" class="form-grid" data-lumi-enhanced-form>
|
||||
<div class="field"><label>Name</label><input name="name" value="<%= overlay.name %>" required maxlength="120" /></div>
|
||||
<div class="field"><label>Shown in OBS</label><label class="switch"><input class="switch-input" type="checkbox" name="enabled" <%= overlay.enabled ? "checked" : "" %> /><span class="switch-track" aria-hidden="true"></span><span class="switch-text"><%= overlay.enabled ? "On" : "Off" %></span></label></div>
|
||||
<div class="field"><label>Canvas width <button type="button" class="overlay-help" data-tooltip="Use the same base width as OBS, usually 1920.">?</button></label><input type="number" min="240" max="7680" name="canvas_width" value="<%= overlay.canvas_width || 1920 %>" /></div>
|
||||
@ -46,8 +46,12 @@
|
||||
</details>
|
||||
</section>
|
||||
|
||||
<button type="button" class="button overlay-preview-launcher" data-preview-drawer-toggle aria-controls="overlay-preview-panel" aria-expanded="false">
|
||||
Open scene preview
|
||||
</button>
|
||||
|
||||
<div class="overlay-workspace">
|
||||
<div class="overlay-settings-column">
|
||||
<div class="overlay-settings-column" role="region" aria-label="Overlay settings" tabindex="0">
|
||||
<section class="card">
|
||||
<h2>Use this overlay in OBS</h2>
|
||||
<% if (!canViewSecrets) { %>
|
||||
@ -67,7 +71,7 @@
|
||||
<form method="post" action="/admin/overlays/<%= overlay.id %>/token/revoke" class="inline-form" data-confirm-mode="modal" data-confirm-title="Replace private links" data-confirm-text="Replace every current OBS link for this overlay? You will need to paste the new links into OBS." data-confirm-label="Replace links"><button class="button danger" type="submit">Replace private links</button></form>
|
||||
</div>
|
||||
<% } %>
|
||||
<form method="post" action="/admin/overlays/<%= overlay.id %>/active-scene" class="input-action-form overlay-live-scene-form">
|
||||
<form method="post" action="/admin/overlays/<%= overlay.id %>/active-scene" class="input-action-form overlay-live-scene-form" data-lumi-enhanced-form>
|
||||
<div class="field"><label>Live scene <button type="button" class="overlay-help" data-tooltip="The main OBS link shows whichever scene is live here.">?</button></label><select name="scene_id"><% overlay.scenes.forEach((scene) => { %><option value="<%= scene.id %>" <%= scene.id === overlay.active_scene_id ? "selected" : "" %> <%= !scene.enabled ? "disabled" : "" %>><%= scene.name %><%= scene.enabled ? "" : " (off)" %></option><% }) %></select></div>
|
||||
<button class="button" type="submit">Show this scene</button>
|
||||
</form>
|
||||
@ -80,7 +84,7 @@
|
||||
<details class="overlay-scene-card" data-scene-card="<%= scene.id %>" <%= scene.id === overlay.active_scene_id ? "open" : "" %>>
|
||||
<summary><%= scene.name %> · <%= scene.enabled ? "On" : "Off" %><%= scene.id === overlay.active_scene_id ? " · Live" : "" %></summary>
|
||||
<div class="overlay-scene-body">
|
||||
<form method="post" action="/admin/overlays/<%= overlay.id %>/scenes/<%= scene.id %>" class="form-grid">
|
||||
<form method="post" action="/admin/overlays/<%= overlay.id %>/scenes/<%= scene.id %>" class="form-grid" data-lumi-enhanced-form>
|
||||
<div class="field"><label>Scene name</label><input name="name" value="<%= scene.name %>" required /></div>
|
||||
<div class="field"><label>Matching OBS scene <button type="button" class="overlay-help" data-tooltip="Optional. Enter the exact OBS scene name if you want scene syncing.">?</button></label><input name="obs_scene_name" value="<%= scene.obs_scene_name || '' %>" placeholder="Starting Soon" /></div>
|
||||
<div class="field"><label>Available</label><label class="switch"><input class="switch-input" type="checkbox" name="enabled" <%= scene.enabled ? "checked" : "" %> /><span class="switch-track" aria-hidden="true"></span><span class="switch-text"><%= scene.enabled ? "On" : "Off" %></span></label></div>
|
||||
@ -100,7 +104,7 @@
|
||||
<% scene.modules.forEach((module) => { const config = module.config || {}; %>
|
||||
<details class="overlay-source-card" data-module-card="<%= module.id %>">
|
||||
<summary><span><strong><%= module.name %></strong><small><%= moduleTypes.find((item) => item.id === module.type)?.label || module.type %> · <%= module.enabled ? "Visible" : "Hidden" %></small></span></summary>
|
||||
<form method="post" action="/admin/overlays/<%= overlay.id %>/modules/<%= module.id %>" class="form-grid overlay-source-form" data-module-editor data-module-id="<%= module.id %>">
|
||||
<form method="post" action="/admin/overlays/<%= overlay.id %>/modules/<%= module.id %>" class="form-grid overlay-source-form" data-module-editor data-module-id="<%= module.id %>" data-lumi-enhanced-form>
|
||||
<div class="field"><label>Name</label><input name="name" value="<%= module.name %>" required /></div>
|
||||
<div class="field"><label>Source type</label><select name="type" data-module-type-select><% moduleTypes.forEach((type) => { %><option value="<%= type.id %>" data-render-type="<%= type.renderType || type.id %>" <%= type.id === module.type ? "selected" : "" %>><%= type.label %></option><% }) %></select></div>
|
||||
<div class="field"><label>Visible</label><label class="switch"><input class="switch-input" type="checkbox" name="enabled" <%= module.enabled ? "checked" : "" %> /><span class="switch-track" aria-hidden="true"></span><span class="switch-text"><%= module.enabled ? "Shown" : "Hidden" %></span></label></div>
|
||||
@ -245,7 +249,7 @@
|
||||
<% eventHooks.forEach((hook) => { %>
|
||||
<details class="overlay-event-hook-card">
|
||||
<summary><span><strong><%= hook.event?.label || hook.event_type %></strong><small><%= hook.action?.label || hook.action_type %> · <%= hook.module_name %> · <%= hook.enabled ? "On" : "Off" %></small></span></summary>
|
||||
<form method="post" action="/admin/overlays/<%= overlay.id %>/event-hooks/<%= hook.id %>" class="form-grid" data-event-hook-form>
|
||||
<form method="post" action="/admin/overlays/<%= overlay.id %>/event-hooks/<%= hook.id %>" class="form-grid" data-event-hook-form data-lumi-enhanced-form>
|
||||
<div class="field"><label>When this happens</label><select name="event_type" data-event-type><% eventTypes.forEach((eventType) => { %><option value="<%= eventType.id %>" data-supports-tier="<%= eventType.supportsTier ? 'true' : 'false' %>" <%= eventType.id === hook.event_type ? "selected" : "" %>><%= eventType.label %></option><% }) %></select></div>
|
||||
<div class="field"><label>Do this</label><select name="action_type" data-event-action><% eventActions.forEach((action) => { %><option value="<%= action.id %>" data-render-types="<%= action.renderTypes.join(',') %>" <%= action.id === hook.action_type ? "selected" : "" %>><%= action.label %></option><% }) %></select></div>
|
||||
<div class="field"><label>Source</label><select name="module_id" data-event-source><% overlayModules.forEach((module) => { %><option value="<%= module.id %>" data-render-type="<%= module.renderType %>" <%= module.id === hook.module_id ? "selected" : "" %>><%= module.sceneName %> — <%= module.name %></option><% }) %></select></div>
|
||||
@ -276,7 +280,7 @@
|
||||
<section class="card" data-obs-connector data-overlay-id="<%= overlay.id %>">
|
||||
<details><summary><strong>Connect Lumi to OBS</strong> <span class="hint">Optional</span></summary><p class="hint">This can switch scenes in OBS and keep OBS scenes matched with Lumi scenes. Most users can leave it off.</p>
|
||||
<div class="overlay-status-grid"><div><span>Connection</span><strong data-obs-state><%= obsStatus.state %></strong></div><div><span>Current OBS scene</span><strong data-obs-current><%= obsStatus.current_scene || "Unknown" %></strong></div><div><span>Page permission</span><strong data-obs-permission><%= obsStatus.control_level_label || "Not reported" %></strong></div><div><span>Current transition</span><strong data-obs-transition><%= obsStatus.current_transition || "Unknown" %></strong></div><div><span>OBS canvas</span><strong data-obs-canvas><%= obsStatus.canvas_width && obsStatus.canvas_height ? `${obsStatus.canvas_width} × ${obsStatus.canvas_height}` : "Unknown" %></strong></div><div><span>Outputs</span><strong data-obs-outputs><%= obsStatus.outputs?.streaming ? "Streaming" : obsStatus.outputs?.recording ? "Recording" : "Idle or unavailable" %></strong></div><div><span>Profile</span><strong data-obs-profile><%= obsStatus.profiles_supported === false ? "Not exposed by Browser Bridge" : (obsStatus.current_profile || "Unknown") %></strong></div><div><span>Scene collection</span><strong data-obs-collection><%= obsStatus.scene_collections_supported === false ? "Not exposed by Browser Bridge" : (obsStatus.current_scene_collection || "Unknown") %></strong></div><div><span>Browser Source</span><strong data-obs-source><%= obsStatus.source_active === true ? "Active" : obsStatus.source_visible === true ? "Visible" : "Unknown" %></strong></div><div><span>Problem</span><strong data-obs-error><%= obsStatus.error || "None" %></strong></div></div>
|
||||
<form method="post" action="/admin/overlays/<%= overlay.id %>/obs" class="form-grid" data-obs-settings-form>
|
||||
<form method="post" action="/admin/overlays/<%= overlay.id %>/obs" class="form-grid" data-obs-settings-form data-lumi-enhanced-form>
|
||||
<div class="field"><label>Connect to OBS</label><label class="switch"><input class="switch-input" type="checkbox" name="enabled" <%= overlay.obs.enabled ? "checked" : "" %> /><span class="switch-track"></span><span class="switch-text"><%= overlay.obs.enabled ? "On" : "Off" %></span></label></div>
|
||||
<div class="field"><label>Connection type</label><select name="provider" data-obs-provider><% connectorProviders.forEach((provider) => { %><option value="<%= provider.id %>" <%= overlay.obs.provider === provider.id ? "selected" : "" %> <%= provider.available === false ? "disabled" : "" %>><%= provider.label %><%= provider.available === false ? " — coming later" : "" %></option><% }) %></select></div>
|
||||
<div class="field" data-obs-websocket-field><label>OBS address <button type="button" class="overlay-help" data-tooltip="The normal OBS address is ws://127.0.0.1:4455 when Lumi runs on the same computer.">?</button></label><input name="endpoint" value="<%= overlay.obs.endpoint %>" /></div><div class="field" data-obs-websocket-field><label>OBS password</label><input name="password" type="password" autocomplete="new-password" placeholder="<%= overlay.obs.has_password ? 'Saved — leave blank to keep it' : 'Password from OBS' %>" /></div>
|
||||
@ -290,20 +294,32 @@
|
||||
<% } %>
|
||||
</div>
|
||||
|
||||
<aside class="overlay-preview-panel card" data-overlay-editor>
|
||||
<div class="section-header"><div><span class="eyebrow">Live editor</span><h2>Scene preview</h2></div><span class="badge" data-editor-save-state>Ready</span></div>
|
||||
<aside id="overlay-preview-panel" class="overlay-preview-panel card" data-overlay-editor data-preview-popout-url="/admin/overlays/<%= overlay.id %>/preview-window" aria-label="Scene preview">
|
||||
<div class="section-header overlay-preview-header">
|
||||
<div><span class="eyebrow">Live editor</span><h2>Scene preview</h2></div>
|
||||
<div class="overlay-preview-header-actions">
|
||||
<span class="badge" data-editor-save-state>Ready</span>
|
||||
<button type="button" class="button subtle overlay-preview-popout" data-preview-popout>Pop out</button>
|
||||
<button type="button" class="icon-button overlay-preview-close" data-preview-drawer-close aria-label="Close scene preview">×</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="overlay-editor-toolbar"><label>Editing <select data-editor-scene><% overlay.scenes.forEach((scene) => { %><option value="<%= scene.id %>" <%= scene.id === overlay.active_scene_id ? "selected" : "" %>><%= scene.name %></option><% }) %></select></label><label class="compact-field"><input type="checkbox" data-editor-snap checked /> Snap</label></div>
|
||||
<div class="overlay-preview-tools" aria-label="Preview background"><span>Check contrast</span><div class="button-group"><button type="button" class="button subtle" data-preview-background="transparent" aria-pressed="true">Transparency</button><button type="button" class="button subtle" data-preview-background="dark" aria-pressed="false">Dark</button><button type="button" class="button subtle" data-preview-background="light" aria-pressed="false">Light</button><button type="button" class="button subtle" data-preview-grid aria-pressed="false">Grid</button></div></div>
|
||||
<div class="overlay-canvas-frame" data-preview-frame style="--overlay-aspect: <%= overlay.canvas_width || 1920 %> / <%= overlay.canvas_height || 1080 %>"><div class="overlay-canvas" data-overlay-canvas tabindex="0" aria-label="Visual overlay editor"><div class="overlay-canvas-layer" data-overlay-canvas-layer></div><div class="overlay-snap-guide guide-x" data-guide-x></div><div class="overlay-snap-guide guide-y" data-guide-y></div></div></div>
|
||||
<div class="overlay-editor-readout"><span data-selected-name>No source selected</span><span data-selected-position></span></div>
|
||||
<div class="overlay-preview-stage" data-preview-stage><div class="overlay-canvas-frame" data-preview-frame style="--overlay-aspect: <%= overlay.canvas_width || 1920 %> / <%= overlay.canvas_height || 1080 %>"><div class="overlay-canvas" data-overlay-canvas tabindex="0" role="application" aria-label="Visual overlay stage" aria-describedby="overlay-stage-help overlay-stage-status"><div class="overlay-canvas-layer" data-overlay-canvas-layer aria-hidden="true"></div><div class="overlay-snap-guide guide-x" data-guide-x aria-hidden="true"></div><div class="overlay-snap-guide guide-y" data-guide-y aria-hidden="true"></div></div></div></div>
|
||||
<div class="overlay-editor-readout" id="overlay-stage-status" role="status" aria-live="polite"><span data-selected-name>No source selected</span><span data-selected-position></span></div>
|
||||
<div class="overlay-editor-actions"><button type="button" class="button subtle" data-editor-center>Center</button><button type="button" class="button subtle" data-editor-fit>Fit to canvas</button><button type="button" class="button subtle" data-editor-reset>Reset box</button></div>
|
||||
<p class="hint">This is the same <strong><%= overlay.canvas_width || 1920 %> × <%= overlay.canvas_height || 1080 %></strong> canvas OBS receives. Source settings preview here while you type; use <strong>Save source</strong> only when you want to keep them. Drag a source to move it, use the red handles to resize it, or use arrow keys for precise nudging. Hold Ctrl or Cmd to temporarily turn snapping <span data-snap-hint>off</span>.</p>
|
||||
<div class="overlay-canvas-source-list" data-canvas-source-list></div>
|
||||
<details class="overlay-preview-details">
|
||||
<summary>Sources and editing help</summary>
|
||||
<div class="overlay-preview-details-body">
|
||||
<p class="hint" id="overlay-stage-help">This is the same <strong><%= overlay.canvas_width || 1920 %> × <%= overlay.canvas_height || 1080 %></strong> canvas OBS receives. Source settings preview here while you type; use <strong>Save source</strong> only when you want to keep them. Drag a source to move it, use the labelled position and size fields, or use arrow keys for precise nudging. Hold Ctrl or Cmd to temporarily turn snapping <span data-snap-hint>off</span>.</p>
|
||||
<div class="overlay-canvas-source-list" data-canvas-source-list></div>
|
||||
</div>
|
||||
</details>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<script type="application/json" data-overlay-editor-state><%- editorStateJson %></script>
|
||||
<div class="modal-backdrop" data-scene-create-modal aria-hidden="true"><div class="modal" role="dialog" aria-modal="true" aria-labelledby="scene-create-title"><div class="modal-header"><h2 id="scene-create-title">Add scene</h2><button type="button" class="icon-button" data-scene-create-close aria-label="Close">×</button></div><form method="post" action="/admin/overlays/<%= overlay.id %>/scenes" class="form-grid"><div class="field"><label>Scene name</label><input name="name" required /></div><div class="field"><label>Matching OBS scene (optional)</label><input name="obs_scene_name" /></div><div class="modal-actions"><button type="button" class="button subtle" data-scene-create-close>Cancel</button><button type="submit" class="button">Add scene</button></div></form></div></div>
|
||||
<div class="modal-backdrop" data-scene-create-modal aria-hidden="true"><div class="modal" role="dialog" aria-modal="true" aria-labelledby="scene-create-title"><div class="modal-header"><h2 id="scene-create-title">Add scene</h2><button type="button" class="icon-button" data-scene-create-close aria-label="Close">×</button></div><form method="post" action="/admin/overlays/<%= overlay.id %>/scenes" class="form-grid"><div class="field"><label>Scene name</label><input data-dialog-initial-focus name="name" required /></div><div class="field"><label>Matching OBS scene (optional)</label><input name="obs_scene_name" /></div><div class="modal-actions"><button type="button" class="button subtle" data-scene-create-close>Cancel</button><button type="submit" class="button">Add scene</button></div></form></div></div>
|
||||
|
||||
<script src="/overlay-renderer.js?v=<%= assetVersion %>" defer></script>
|
||||
<script src="/overlay-management.js?v=<%= assetVersion %>" defer></script>
|
||||
|
||||
41
src/web/views/admin-overlay-preview.ejs
Normal file
41
src/web/views/admin-overlay-preview.ejs
Normal file
@ -0,0 +1,41 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<meta name="referrer" content="no-referrer" />
|
||||
<title><%= title %></title>
|
||||
<link rel="stylesheet" href="/overlay-runtime.css?v=<%= assetVersion %>" />
|
||||
<link rel="stylesheet" href="/overlay-preview-window.css?v=<%= assetVersion %>" />
|
||||
</head>
|
||||
<body>
|
||||
<main class="overlay-preview-window-stage" data-overlay-preview-window>
|
||||
<section class="overlay-preview-window-toolbar" aria-label="Preview editing controls">
|
||||
<div class="overlay-preview-window-fields">
|
||||
<label>Scene<select data-popout-scene></select></label>
|
||||
<label>Source<select data-popout-source></select></label>
|
||||
<label class="overlay-preview-window-snap"><input type="checkbox" data-popout-snap checked /> Snap</label>
|
||||
</div>
|
||||
<div class="overlay-preview-window-actions">
|
||||
<button type="button" data-popout-center>Center</button>
|
||||
<button type="button" data-popout-fit>Fit</button>
|
||||
<button type="button" data-popout-reset>Reset</button>
|
||||
</div>
|
||||
<p class="overlay-preview-window-readout" data-popout-readout>No source selected</p>
|
||||
<p class="overlay-preview-window-status" data-preview-window-status role="status" aria-live="polite">Waiting for editor</p>
|
||||
</section>
|
||||
<div class="overlay-preview-window-viewport" data-preview-window-viewport>
|
||||
<div class="overlay-preview-window-frame" data-preview-window-frame style="--overlay-aspect: <%= overlay.canvas_width || 1920 %> / <%= overlay.canvas_height || 1080 %>">
|
||||
<div class="overlay-preview-window-canvas" data-preview-window-canvas tabindex="0" role="application" aria-label="Interactive preview for <%= overlay.name %>">
|
||||
<div class="overlay-preview-window-layer" data-preview-window-layer></div>
|
||||
<div class="overlay-preview-window-guide guide-x" data-popout-guide-x aria-hidden="true"></div>
|
||||
<div class="overlay-preview-window-guide guide-y" data-popout-guide-y aria-hidden="true"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
<script type="application/json" data-overlay-preview-state><%- editorStateJson %></script>
|
||||
<script src="/overlay-renderer.js?v=<%= assetVersion %>" defer></script>
|
||||
<script src="/overlay-preview-window.js?v=<%= assetVersion %>" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
@ -1,4 +1,4 @@
|
||||
<%- include("partials/layout-top", { title }) %>
|
||||
<%- include("partials/layout-top", { title, pageWidth: "wide", pageId: "overlay-management" }) %>
|
||||
<link rel="stylesheet" href="/overlay-management.css?v=<%= assetVersion %>" />
|
||||
<section class="card">
|
||||
<%- include("partials/page-header", {
|
||||
@ -50,7 +50,7 @@
|
||||
<div class="modal" role="dialog" aria-modal="true" aria-labelledby="overlay-create-title">
|
||||
<div class="modal-header"><h2 id="overlay-create-title">Create overlay</h2><button type="button" class="icon-button" data-overlay-create-close aria-label="Close">×</button></div>
|
||||
<form method="post" action="/admin/overlays" class="form-grid">
|
||||
<div class="field"><label>Overlay name</label><input name="name" required maxlength="120" placeholder="Main stream" /></div>
|
||||
<div class="field"><label>Overlay name</label><input data-dialog-initial-focus name="name" required maxlength="120" placeholder="Main stream" /></div>
|
||||
<div class="field"><label>First scene name</label><input name="scene_name" required maxlength="120" value="Main" /></div>
|
||||
<div class="field full"><label>Description</label><textarea name="description" rows="3" maxlength="1000"></textarea></div>
|
||||
<div class="modal-actions"><button type="button" class="button subtle" data-overlay-create-close>Cancel</button><button type="submit" class="button">Create overlay</button></div>
|
||||
|
||||
@ -85,28 +85,28 @@
|
||||
</section>
|
||||
|
||||
<details class="resource-settings-panel">
|
||||
<summary>Storage limits and supported formats</summary>
|
||||
<summary><span><strong>Storage limits and supported formats</strong><span class="hint" data-resource-settings-summary><%= settings.limit_gib > 0 ? `${settings.limit_gib} GiB library limit` : "Unlimited library" %> · <%= settings.max_file_mib %> MiB per file · <%= settings.max_files %> files per upload</span></span></summary>
|
||||
<form method="post" action="/admin/resources/settings" class="form-grid" data-resource-action-form data-resource-settings-form>
|
||||
<div class="field">
|
||||
<label for="resource-limit-gib">Library limit (GiB)</label>
|
||||
<input id="resource-limit-gib" name="limit_gib" type="number" min="0" step="0.1" value="<%= settings.limit_gib %>" />
|
||||
<input id="resource-limit-gib" name="limit_gib" type="number" min="0" step="0.1" value="<%= settings.limit_gib %>" required />
|
||||
<span class="hint">Use 0 for no Lumi-specific quota. Physical disk capacity still applies.</span>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="resource-reserve-gib">Keep free on disk (GiB)</label>
|
||||
<input id="resource-reserve-gib" name="reserve_gib" type="number" min="0" step="0.1" value="<%= settings.reserve_gib %>" />
|
||||
<input id="resource-reserve-gib" name="reserve_gib" type="number" min="0" step="0.1" value="<%= settings.reserve_gib %>" required />
|
||||
<span class="hint">Lumi stops accepting uploads before the disk falls below this reserve.</span>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="resource-max-file-mib">Maximum file size (MiB)</label>
|
||||
<input id="resource-max-file-mib" name="max_file_mib" type="number" min="1" max="8192" step="1" value="<%= settings.max_file_mib %>" />
|
||||
<input id="resource-max-file-mib" name="max_file_mib" type="number" min="1" max="8192" step="1" value="<%= settings.max_file_mib %>" required />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="resource-max-files">Files per upload</label>
|
||||
<input id="resource-max-files" name="max_files" type="number" min="1" max="50" step="1" value="<%= settings.max_files %>" />
|
||||
<input id="resource-max-files" name="max_files" type="number" min="1" max="50" step="1" value="<%= settings.max_files %>" required />
|
||||
</div>
|
||||
<div class="field full">
|
||||
<button type="submit" class="button subtle">Save storage settings</button>
|
||||
<button type="submit" class="button">Save storage settings</button>
|
||||
<p class="resource-status" data-resource-status data-resource-settings-status aria-live="polite"></p>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@ -42,12 +42,15 @@
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Update branch</label>
|
||||
<% const selectedUpdateBranch = settings.git_branch === 'experimental' ? 'experimental' : 'main'; %>
|
||||
<% const selectedUpdateBranch = settings.git_branch || 'main'; %>
|
||||
<select name="git_branch">
|
||||
<option value="main" <%= selectedUpdateBranch === 'main' ? 'selected' : '' %>>Stable</option>
|
||||
<option value="experimental" <%= selectedUpdateBranch === 'experimental' ? 'selected' : '' %>>Experimental</option>
|
||||
<% if (selectedUpdateBranch !== 'main' && selectedUpdateBranch !== 'experimental') { %>
|
||||
<option value="<%= selectedUpdateBranch %>" selected>Experimental · <%= selectedUpdateBranch %></option>
|
||||
<% } %>
|
||||
</select>
|
||||
<p class="hint">Stable uses <code>main</code>. Experimental uses the newest <code>experimental-*</code> branch.</p>
|
||||
<p class="hint">Stable uses <code>main</code>. Experimental follows the newest <code>experimental-*</code> branch. Deploy and change an exact production branch from <a href="/admin/updates">Updates</a>.</p>
|
||||
</div>
|
||||
<div class="field full">
|
||||
<div class="inline-actions">
|
||||
@ -219,7 +222,7 @@
|
||||
<form method="post" action="/admin/settings/nav-icons" enctype="multipart/form-data" class="inline-form">
|
||||
<input type="hidden" name="item_id" value="<%= item.id %>" />
|
||||
<div class="input-action-row">
|
||||
<input type="file" name="icon_file" accept="image/svg+xml,image/png" />
|
||||
<input type="file" name="icon_file" accept="image/svg+xml,image/png" aria-label="Upload icon for <%= item.label %>" />
|
||||
<button type="submit" class="button subtle">Upload</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
<%- include("partials/layout-top", { title }) %>
|
||||
<%- include("partials/layout-top", { title, pageWidth: "wide", pageId: "theme-studio" }) %>
|
||||
<link rel="stylesheet" href="/theme-editor.css?v=<%= assetVersion %>" />
|
||||
<%
|
||||
const fieldLabels = {
|
||||
@ -46,7 +46,7 @@
|
||||
Select a protected Lumi preset or duplicate one into a custom theme.
|
||||
</p>
|
||||
</div>
|
||||
<span class="status-indicator status-success">
|
||||
<span class="status-indicator status-success" data-active-theme-status>
|
||||
Active: <strong><%= activeTheme.name %></strong>
|
||||
</span>
|
||||
</header>
|
||||
@ -166,6 +166,8 @@
|
||||
action="/admin/theming/custom/<%= editingTheme.id.replace('custom:', '') %>/save"
|
||||
class="theme-edit-form"
|
||||
data-theme-form
|
||||
data-lumi-enhanced-form
|
||||
data-lumi-form-id="theme-editor"
|
||||
>
|
||||
<% ["light", "dark"].forEach((mode) => { %>
|
||||
<fieldset class="theme-fieldset" data-theme-mode-fields="<%= mode %>">
|
||||
@ -279,6 +281,7 @@
|
||||
</fieldset>
|
||||
|
||||
<div class="theme-validation-panel" data-theme-warnings aria-live="polite"></div>
|
||||
<p class="hint lumi-form-status" data-lumi-form-status role="status" aria-live="polite">Draft changes stay in this preview until you save.</p>
|
||||
|
||||
<div class="theme-editor-actions">
|
||||
<label class="switch">
|
||||
@ -352,7 +355,7 @@
|
||||
</div>
|
||||
<div class="theme-preview-badges">
|
||||
<span class="badge">Badge</span>
|
||||
<span class="pill">Role <span style="color:var(--role-admin)">admin</span></span>
|
||||
<span class="pill">Role <span><span class="theme-role-swatch" aria-hidden="true"></span>admin</span></span>
|
||||
</div>
|
||||
<div class="is-unsaved theme-preview-dirty">Dirty state marker</div>
|
||||
<div class="table-wrap theme-preview-table">
|
||||
@ -381,5 +384,5 @@
|
||||
</section>
|
||||
<% } %>
|
||||
|
||||
<script src="/theme-editor.js?v=<%= assetVersion %>"></script>
|
||||
<script src="/theme-editor.js?v=<%= assetVersion %>" defer></script>
|
||||
<%- include("partials/layout-bottom") %>
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
<%- include("partials/layout-top", { title }) %>
|
||||
<%- include("partials/layout-top", { title, pageWidth: "wide", pageId: "update-management" }) %>
|
||||
<%
|
||||
const status = updateStatus || {};
|
||||
const core = status.core || null;
|
||||
@ -20,6 +20,13 @@
|
||||
return `${base} Warning: this update is to or from an unversioned install or update, so Lumi cannot fully verify version order, changelog range, or restore safety. ${warnings}`;
|
||||
};
|
||||
const retention = snapshotRetention || { max_age_days: 30, max_per_target: 5 };
|
||||
const deployment = typeof deploymentState !== "undefined" && deploymentState ? deploymentState : { branch: "main", commit: null, version: core?.current_version || "unknown", previous_branch: null };
|
||||
const deployableBranches = typeof deploymentBranches !== "undefined" && Array.isArray(deploymentBranches) ? deploymentBranches : [];
|
||||
const deploymentIssue = typeof deploymentError !== "undefined" ? deploymentError : null;
|
||||
const deploymentSnapshot = (snapshots || [])
|
||||
.filter((snapshot) => snapshot.type === "bot" && snapshot.status === "available")
|
||||
.sort((left, right) => Number(right.createdAt || 0) - Number(left.createdAt || 0))[0] || null;
|
||||
const shortCommit = (value) => value ? String(value).slice(0, 10) : "Not recorded";
|
||||
const formatBytes = (value) => {
|
||||
const bytes = Number(value) || 0;
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
@ -31,6 +38,8 @@
|
||||
};
|
||||
%>
|
||||
|
||||
<div data-update-management data-update-source="<%= selectedSource %>">
|
||||
|
||||
<section class="card">
|
||||
<%- include("partials/page-header", {
|
||||
eyebrow: "Maintenance",
|
||||
@ -40,7 +49,7 @@
|
||||
<% if (updateStatusError) { %>
|
||||
<div class="callout danger">Update metadata could not be loaded: <%= updateStatusError %></div>
|
||||
<% } %>
|
||||
<form method="get" action="/admin/updates" class="inline-actions">
|
||||
<form method="get" action="/admin/updates" class="inline-actions" data-update-source-form>
|
||||
<label class="field compact">
|
||||
<span>Update source</span>
|
||||
<select name="source">
|
||||
@ -57,8 +66,67 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="update-notice-stack" data-update-notices aria-live="polite"></div>
|
||||
<div class="update-notice-stack" data-update-notices aria-live="polite" aria-label="Update notifications"></div>
|
||||
|
||||
<section class="card" data-deployment-panel>
|
||||
<div class="section-header">
|
||||
<div>
|
||||
<span class="eyebrow">Production deployment</span>
|
||||
<h2>Running code branch</h2>
|
||||
<p class="hint">Safely test an experimental branch, return to stable <code>main</code>, or repair the currently selected branch, including bundled plugin code.</p>
|
||||
</div>
|
||||
<span class="badge <%= deployment.branch === "main" ? "success" : "warning" %>"><%= deployment.branch === "main" ? "Stable" : "Experimental" %></span>
|
||||
</div>
|
||||
|
||||
<div class="update-meta-grid">
|
||||
<div><span>Running branch</span><strong><%= deployment.branch %></strong></div>
|
||||
<div><span>Running commit</span><strong><%= shortCommit(deployment.commit) %></strong></div>
|
||||
<div><span>Core version</span><strong><%= deployment.version || core?.current_version || "unknown" %></strong></div>
|
||||
<div><span>Previous branch</span><strong><%= deployment.previous_branch || "Not recorded" %></strong></div>
|
||||
</div>
|
||||
|
||||
<div class="callout info">
|
||||
<strong>Safe switch sequence</strong>
|
||||
<p>Lumi fetches and verifies the exact branch, backs up core and bundled-plugin code plus the database, preserves every plugin data directory and local-only plugin, applies the branch, then gracefully restarts. If applying the branch fails, Lumi automatically restores the backup.</p>
|
||||
</div>
|
||||
|
||||
<% if (deploymentIssue) { %>
|
||||
<div class="callout warning"><strong>Branch list unavailable</strong><p><%= deploymentIssue %> Existing update and recovery controls remain available.</p></div>
|
||||
<% } %>
|
||||
|
||||
<% if (deployableBranches.length) { %>
|
||||
<form method="post" action="/admin/updates/core/deploy-branch" class="input-action-row" data-update-action data-confirm-mode="modal" data-confirm-title="Switch Lumi production branch" data-confirm-text="Verify and deploy core plus bundled-plugin code from the selected branch after creating a rollback snapshot, then gracefully restart Lumi. Local data, plugin data, and local-only plugins stay in place." data-confirm-label="Deploy branch">
|
||||
<label class="field">
|
||||
<span>Branch to run</span>
|
||||
<select name="branch" required>
|
||||
<% deployableBranches.forEach((branch) => { %>
|
||||
<option value="<%= branch.name %>" <%= branch.name === deployment.branch ? "selected" : "" %>><%= branch.name === "main" ? "Stable · main" : `Experimental · ${branch.name}` %><%= branch.commit ? ` · ${shortCommit(branch.commit)}` : "" %><%= branch.unavailable ? " · no longer listed remotely" : "" %></option>
|
||||
<% }) %>
|
||||
</select>
|
||||
</label>
|
||||
<button class="button" type="submit">Deploy selected branch</button>
|
||||
</form>
|
||||
<p class="hint">Selecting the running branch performs a verified repair deployment. Branches are limited to <code>main</code> and published <code>experimental-*</code> branches.</p>
|
||||
<% } %>
|
||||
|
||||
<div class="inline-actions">
|
||||
<% if (deployment.branch !== "main" && deployableBranches.some((branch) => branch.name === "main")) { %>
|
||||
<form method="post" action="/admin/updates/core/deploy-branch" data-update-action data-confirm-mode="modal" data-confirm-title="Return production to stable main" data-confirm-text="Back up the running experimental code, deploy the current main branch while preserving live data, verify it, and gracefully restart Lumi." data-confirm-label="Return to main">
|
||||
<input type="hidden" name="branch" value="main" />
|
||||
<button class="button" type="submit">Return safely to main</button>
|
||||
</form>
|
||||
<% } %>
|
||||
<% if (deploymentSnapshot) { %>
|
||||
<form method="post" action="/admin/updates/core/revert" data-update-action data-confirm-mode="modal" data-confirm-title="Restore emergency deployment backup" data-confirm-text="Restore the previous core code and its database backup, then restart Lumi. Changes written to the database after that backup may be lost; prefer Return safely to main when Lumi is still working." data-confirm-label="Restore backup">
|
||||
<input type="hidden" name="source" value="<%= selectedSource %>" />
|
||||
<input type="hidden" name="snapshot_id" value="<%= deploymentSnapshot.id %>" />
|
||||
<button class="button danger" type="submit">Emergency restore previous backup</button>
|
||||
</form>
|
||||
<% } %>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div data-update-recovery-region>
|
||||
<% if (recovery.has_incomplete_marker || marker) { %>
|
||||
<section class="card update-recovery-banner">
|
||||
<h2>Safe Mode / Recovery</h2>
|
||||
@ -81,17 +149,18 @@
|
||||
<% if (marker.error) { %><p class="hint status-danger"><%= marker.error %></p><% } %>
|
||||
<% } %>
|
||||
<div class="inline-actions">
|
||||
<form method="post" action="/admin/updates/recovery/clear-marker" data-confirm-mode="modal" data-confirm-title="Clear recovery marker" data-confirm-text="Clear the stale recovery marker? Do this only after you have verified Lumi starts normally." data-confirm-label="Clear marker">
|
||||
<form method="post" action="/admin/updates/recovery/clear-marker" data-update-action data-confirm-mode="modal" data-confirm-title="Clear recovery marker" data-confirm-text="Clear the stale recovery marker? Do this only after you have verified Lumi starts normally." data-confirm-label="Clear marker">
|
||||
<button class="button danger" type="submit">Clear stale marker</button>
|
||||
</form>
|
||||
<form method="post" action="/admin/updates/recovery/retry-normal-startup">
|
||||
<form method="post" action="/admin/updates/recovery/retry-normal-startup" data-update-action data-confirm-mode="modal" data-confirm-title="Retry normal startup" data-confirm-text="Restart Lumi and try a normal startup. Keep this page open while Lumi reconnects." data-confirm-label="Restart Lumi">
|
||||
<button class="button subtle" type="submit">Retry normal startup</button>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
<% } %>
|
||||
</div>
|
||||
|
||||
<section class="card" data-update-panel data-update-target="core">
|
||||
<section class="card" data-update-panel data-update-target="core" data-update-key="core">
|
||||
<details class="lumi-expandable-settings" <%= core?.blocked || core?.update_available || recovery.has_incomplete_marker ? "open" : "" %>>
|
||||
<summary>
|
||||
<span>
|
||||
@ -187,7 +256,7 @@
|
||||
<div class="empty-state">No installed plugins found.</div>
|
||||
<% } %>
|
||||
<% plugins.forEach((plugin) => { %>
|
||||
<details class="lumi-expandable-settings plugin-update-row" <%= plugin.blocked || plugin.update_available ? "open" : "" %>>
|
||||
<details class="lumi-expandable-settings plugin-update-row" data-update-key="plugin:<%= plugin.id %>" <%= plugin.blocked || plugin.update_available ? "open" : "" %>>
|
||||
<summary>
|
||||
<span>
|
||||
<strong><%= plugin.name %></strong>
|
||||
@ -280,7 +349,7 @@
|
||||
</form>
|
||||
<% } %>
|
||||
<% if (plugin.installed !== false) { %>
|
||||
<form method="post" action="/admin/updates/plugins/<%= plugin.id %>/disable" data-confirm-mode="modal" data-confirm-title="Disable plugin" data-confirm-text="Disable this plugin for recovery? Lumi may need a restart for already-loaded plugin code to unload." data-confirm-label="Disable plugin">
|
||||
<form method="post" action="/admin/updates/plugins/<%= plugin.id %>/disable" data-update-action data-confirm-mode="modal" data-confirm-title="Disable plugin" data-confirm-text="Disable this plugin for recovery? Lumi may need a restart for already-loaded plugin code to unload." data-confirm-label="Disable plugin">
|
||||
<button class="button danger" type="submit">Disable temporarily</button>
|
||||
</form>
|
||||
<% } %>
|
||||
@ -330,7 +399,7 @@
|
||||
<div class="card">
|
||||
<h3>Core ZIP</h3>
|
||||
<p class="hint">Use a full core ZIP or a patch ZIP containing files relative to the Lumi install root.</p>
|
||||
<form method="post" action="/admin/updates/core/zip" enctype="multipart/form-data" class="form-grid">
|
||||
<form method="post" action="/admin/updates/core/zip" enctype="multipart/form-data" class="form-grid" data-update-action data-confirm-mode="modal" data-confirm-title="Apply core ZIP update" data-confirm-text="Apply this manual core ZIP after creating a recovery snapshot? Keep this page open while Lumi restarts and reconnects." data-confirm-label="Apply core ZIP">
|
||||
<div class="field full input-action-row">
|
||||
<input type="file" name="update_zip" accept=".zip" required />
|
||||
<button type="submit" class="button">Upload core ZIP</button>
|
||||
@ -342,7 +411,7 @@
|
||||
<div class="card">
|
||||
<h3>Plugin ZIP</h3>
|
||||
<p class="hint">Use when plugin repository metadata is unavailable. Plugin ZIPs must include a valid <code>plugin.json</code>.</p>
|
||||
<form method="post" action="/admin/updates/plugin" enctype="multipart/form-data" class="form-grid">
|
||||
<form method="post" action="/admin/updates/plugin" enctype="multipart/form-data" class="form-grid" data-update-action data-confirm-mode="modal" data-confirm-title="Apply plugin ZIP update" data-confirm-text="Apply this manual plugin ZIP after creating a recovery snapshot? Keep this page open while Lumi restarts and reconnects." data-confirm-label="Apply plugin ZIP">
|
||||
<div class="field full input-action-row">
|
||||
<input type="file" name="plugin_zip" accept=".zip" required />
|
||||
<button type="submit" class="button">Upload plugin ZIP</button>
|
||||
@ -356,7 +425,7 @@
|
||||
|
||||
<section class="card">
|
||||
<h2>Live Progress</h2>
|
||||
<div class="update-progress-log" data-update-progress-log>
|
||||
<div class="update-progress-log" data-update-progress-log role="log" aria-label="Update and recovery progress" aria-live="polite" aria-relevant="additions" tabindex="0">
|
||||
<div class="hint">Update and recovery progress appears here while actions run.</div>
|
||||
</div>
|
||||
</section>
|
||||
@ -366,7 +435,7 @@
|
||||
<p class="hint">Snapshots contain compressed rollback code and a compressed database backup. Preserved local data—such as AI models, runtimes, uploads, logs, plugin data, and community knowledge—is kept in place during updates and is not copied into new snapshots.</p>
|
||||
<fieldset class="lumi-fieldset">
|
||||
<legend>Automatic snapshot cleanup</legend>
|
||||
<form method="post" action="/admin/updates/snapshots/settings" class="form-grid">
|
||||
<form method="post" action="/admin/updates/snapshots/settings" class="form-grid" data-update-action data-update-settings-form>
|
||||
<label>Maximum age in days
|
||||
<input type="number" name="max_age_days" min="1" max="3650" value="<%= retention.max_age_days %>" required />
|
||||
<span class="hint">Snapshots older than this are removed, even if the count limit has not been reached.</span>
|
||||
@ -384,7 +453,7 @@
|
||||
<div class="empty-state">No snapshots yet.</div>
|
||||
<% } else { %>
|
||||
<div class="table-wrap">
|
||||
<table class="table">
|
||||
<table class="table" data-update-snapshot-table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Target</th>
|
||||
@ -398,12 +467,12 @@
|
||||
<tbody>
|
||||
<% snapshots.forEach((snap) => { %>
|
||||
<tr>
|
||||
<td><%= snap.type === "plugin" ? `Plugin: ${snap.pluginId}` : "Core" %></td>
|
||||
<td><%= snap.from_version || "?" %> -> <%= snap.to_version || "?" %></td>
|
||||
<td><%= snap.update_method || "snapshot" %></td>
|
||||
<td><%= snap.major_crossing && snap.rollback_safe === false ? "Blocked after major migration" : "Previous-version restore allowed" %></td>
|
||||
<td><%= formatBytes(snap.storage_bytes) %><%= snap.compressed ? " · compressed" : "" %></td>
|
||||
<td><%= new Date(snap.createdAt).toLocaleString() %></td>
|
||||
<td data-label="Target"><%= snap.type === "plugin" ? `Plugin: ${snap.pluginId}` : "Core" %></td>
|
||||
<td data-label="Version"><%= snap.from_version || "?" %> -> <%= snap.to_version || "?" %></td>
|
||||
<td data-label="Method"><%= snap.update_method || "snapshot" %></td>
|
||||
<td data-label="Rollback"><%= snap.major_crossing && snap.rollback_safe === false ? "Blocked after major migration" : "Previous-version restore allowed" %></td>
|
||||
<td data-label="Stored size"><%= formatBytes(snap.storage_bytes) %><%= snap.compressed ? " · compressed" : "" %></td>
|
||||
<td data-label="Created"><%= new Date(snap.createdAt).toLocaleString() %></td>
|
||||
</tr>
|
||||
<% }) %>
|
||||
</tbody>
|
||||
@ -411,102 +480,5 @@
|
||||
</div>
|
||||
<% } %>
|
||||
</section>
|
||||
<script>
|
||||
(() => {
|
||||
const root = document.querySelector("[data-update-notices]");
|
||||
const badgeClass = (item) => item?.blocked ? "danger" : item?.update_available ? "warning" : "success";
|
||||
const badgeText = (item) => item?.blocked ? "Blocked" : item?.update_available ? "Update available" : "Current";
|
||||
|
||||
const showNotice = (message, tone = "info") => {
|
||||
if (!root) return;
|
||||
const item = document.createElement("div");
|
||||
item.className = `update-notice ${tone}`;
|
||||
item.setAttribute("role", tone === "danger" ? "alert" : "status");
|
||||
const text = document.createElement("span");
|
||||
text.textContent = message || "Update status changed.";
|
||||
const close = document.createElement("button");
|
||||
close.type = "button";
|
||||
close.className = "icon-button";
|
||||
close.setAttribute("aria-label", "Dismiss notification");
|
||||
close.textContent = "×";
|
||||
close.addEventListener("click", () => item.remove());
|
||||
item.append(text, close);
|
||||
root.append(item);
|
||||
if (!["danger", "warning"].includes(tone)) {
|
||||
window.setTimeout(() => item.remove(), 6500);
|
||||
}
|
||||
};
|
||||
|
||||
const setButtonLoading = (button, loading) => {
|
||||
if (!button) return;
|
||||
if (loading) {
|
||||
button.dataset.originalText = button.textContent;
|
||||
button.textContent = "Checking...";
|
||||
button.disabled = true;
|
||||
button.setAttribute("aria-busy", "true");
|
||||
} else {
|
||||
button.textContent = button.dataset.originalText || "Check for updates";
|
||||
button.disabled = false;
|
||||
button.setAttribute("aria-busy", "false");
|
||||
}
|
||||
};
|
||||
|
||||
const setText = (row, selector, value) => {
|
||||
const target = row.querySelector(selector);
|
||||
if (target) target.textContent = value || "None";
|
||||
};
|
||||
|
||||
const updateRow = (row, item) => {
|
||||
if (!row || !item) return;
|
||||
const summary = row.querySelector("[data-update-summary]");
|
||||
if (summary) {
|
||||
const current = item.installed === false ? "Not installed" : item.current_version;
|
||||
summary.textContent = `${current || "unknown"} -> ${item.safe_target_version || "none"} · ${item.source_branch || "main"}`;
|
||||
}
|
||||
const badge = row.querySelector("[data-update-badge]");
|
||||
if (badge) {
|
||||
badge.className = `badge ${badgeClass(item)}`;
|
||||
badge.textContent = badgeText(item);
|
||||
}
|
||||
setText(row, "[data-update-field='current_version']", item.current_version);
|
||||
setText(row, "[data-update-field='safe_target_version']", item.safe_target_version);
|
||||
setText(row, "[data-update-field='latest_available_version']", item.latest_available_version);
|
||||
setText(row, "[data-update-field='source_branch']", item.source_branch);
|
||||
setText(row, "[data-update-description]", item.version_description);
|
||||
const apply = row.querySelector("[data-update-apply-button]");
|
||||
if (apply) apply.disabled = Boolean(item.blocked || !item.update_available);
|
||||
};
|
||||
|
||||
document.querySelectorAll("form[data-update-action]").forEach((form) => {
|
||||
if (!form.action.endsWith("/check")) return;
|
||||
form.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
event.stopImmediatePropagation();
|
||||
const button = event.submitter || form.querySelector("[data-update-check-button]");
|
||||
const row = form.closest(".plugin-update-row, [data-update-target='core']");
|
||||
setButtonLoading(button, true);
|
||||
try {
|
||||
const response = await fetch(form.action, {
|
||||
method: "POST",
|
||||
body: new FormData(form),
|
||||
headers: { Accept: "application/json" },
|
||||
credentials: "same-origin"
|
||||
});
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
if (!response.ok || payload.ok === false) {
|
||||
throw new Error(payload.error || "The update check failed.");
|
||||
}
|
||||
const item = payload.plugin || payload.status?.core;
|
||||
updateRow(row, item);
|
||||
const tone = item?.blocked ? "danger" : item?.update_available ? "warning" : "success";
|
||||
showNotice(payload.message || (item?.update_available ? "Updates are available." : "No updates found."), tone);
|
||||
} catch (error) {
|
||||
showNotice(error.message || "The update check failed. Try again or use manual ZIP recovery if repository checks keep failing.", "danger");
|
||||
} finally {
|
||||
setButtonLoading(button, false);
|
||||
}
|
||||
});
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
</div>
|
||||
<%- include("partials/layout-bottom") %>
|
||||
|
||||
@ -42,13 +42,13 @@
|
||||
<table class="table commands-table" data-table="commands">
|
||||
<thead>
|
||||
<tr>
|
||||
<th data-sort="trigger">Trigger</th>
|
||||
<th data-sort="name">Name</th>
|
||||
<th data-sort="description">Preview / description</th>
|
||||
<th data-sort="level">Level</th>
|
||||
<th data-sort="platform">Platform</th>
|
||||
<th data-sort="origin">Origin</th>
|
||||
<th data-sort="count">Count</th>
|
||||
<th data-sort="trigger" aria-sort="none"><button type="button" class="table-sort-button" data-table-sort-control>Trigger</button></th>
|
||||
<th data-sort="name" aria-sort="none"><button type="button" class="table-sort-button" data-table-sort-control>Name</button></th>
|
||||
<th data-sort="description" aria-sort="none"><button type="button" class="table-sort-button" data-table-sort-control>Preview / description</button></th>
|
||||
<th data-sort="level" aria-sort="none"><button type="button" class="table-sort-button" data-table-sort-control>Level</button></th>
|
||||
<th data-sort="platform" aria-sort="none"><button type="button" class="table-sort-button" data-table-sort-control>Platform</button></th>
|
||||
<th data-sort="origin" aria-sort="none"><button type="button" class="table-sort-button" data-table-sort-control>Origin</button></th>
|
||||
<th data-sort="count" aria-sort="none"><button type="button" class="table-sort-button" data-table-sort-control>Count</button></th>
|
||||
<th>Link</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
@ -8,12 +8,12 @@
|
||||
</p>
|
||||
<form method="post" action="/auth/localhost" class="form-grid">
|
||||
<div class="field full">
|
||||
<label>Username</label>
|
||||
<input name="username" value="<%= username || 'admin' %>" autocomplete="username" required />
|
||||
<label for="localhost-username">Username</label>
|
||||
<input id="localhost-username" name="username" value="<%= username || 'admin' %>" autocomplete="username" required />
|
||||
</div>
|
||||
<div class="field full">
|
||||
<label>Password</label>
|
||||
<input name="password" type="password" autocomplete="current-password" required />
|
||||
<label for="localhost-password">Password</label>
|
||||
<input id="localhost-password" name="password" type="password" autocomplete="current-password" required />
|
||||
</div>
|
||||
<div class="field full">
|
||||
<button type="submit" class="button">Login locally</button>
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<html lang="en" class="lumi-overlay-runtime-page">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no" />
|
||||
@ -8,7 +8,7 @@
|
||||
<link rel="stylesheet" href="/overlay-runtime.css" />
|
||||
<style id="obs-browser-default-css"><%- obsBrowserDefaultCss %></style>
|
||||
</head>
|
||||
<body>
|
||||
<body class="lumi-overlay-runtime-page">
|
||||
<main id="lumi-overlay-root" aria-live="off"></main>
|
||||
<script id="lumi-overlay-state" type="application/json"><%- initialStateJson %></script>
|
||||
<div id="lumi-overlay-config" data-state-url="<%= statePath %>" data-events-url="<%= eventsPath %>" data-health-url="<%= healthPath %>" data-bridge-url="<%= bridgePath %>" hidden></div>
|
||||
|
||||
@ -10,7 +10,7 @@
|
||||
<% } %>
|
||||
</div>
|
||||
</div>
|
||||
<button class="sidebar-scrim" type="button" data-sidebar-dismiss aria-label="Close navigation"></button>
|
||||
<button class="sidebar-scrim" type="button" data-sidebar-dismiss aria-label="Close navigation" aria-controls="lumi-sidebar" tabindex="-1"></button>
|
||||
<% if (user) { %>
|
||||
<% const feedbackNotice = typeof feedbackNotifications !== "undefined" ? feedbackNotifications : { total: 0, solved: 0, needs_context: 0, not_worked: 0 }; %>
|
||||
<% const feedbackOptionData = typeof feedbackOptions !== "undefined" ? feedbackOptions : {}; %>
|
||||
@ -23,6 +23,7 @@
|
||||
<div
|
||||
class="modal-backdrop feedback-modal-backdrop"
|
||||
data-feedback-modal
|
||||
data-dialog-static="true"
|
||||
aria-hidden="true"
|
||||
data-feedback-options="<%= JSON.stringify(feedbackOptionData || {}) %>"
|
||||
>
|
||||
@ -154,6 +155,13 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script src="/lumi-request.js?v=<%= assetVersion %>"></script>
|
||||
<script src="/lumi-page.js?v=<%= assetVersion %>"></script>
|
||||
<script src="/navigation-builder.js?v=<%= assetVersion %>"></script>
|
||||
<script src="/command-management.js?v=<%= assetVersion %>"></script>
|
||||
<script src="/command-policies.js?v=<%= assetVersion %>"></script>
|
||||
<script src="/update-management.js?v=<%= assetVersion %>"></script>
|
||||
<script src="/lumi-dialog.js?v=<%= assetVersion %>"></script>
|
||||
<script src="/assistant-panels.js?v=<%= assetVersion %>"></script>
|
||||
<script src="/lumi-state-button.js?v=<%= assetVersion %>"></script>
|
||||
<script src="/lumi-interactions.js?v=<%= assetVersion %>"></script>
|
||||
|
||||
@ -8,9 +8,10 @@
|
||||
<link rel="stylesheet" href="/styles.css?v=<%= assetVersion %>" />
|
||||
<link rel="stylesheet" href="/lumi-layout.css?v=<%= assetVersion %>" />
|
||||
<link rel="stylesheet" href="/lumi-components.css?v=<%= assetVersion %>" />
|
||||
<link rel="stylesheet" href="/navigation-builder.css?v=<%= assetVersion %>" />
|
||||
<%- include("theme-vars", { theme }) %>
|
||||
</head>
|
||||
<body data-theme-id="<%= theme ? theme.id : '' %>">
|
||||
<body data-theme-id="<%= theme ? theme.id : '' %>" data-authenticated="<%= user ? 'true' : 'false' %>">
|
||||
<% const icons = {
|
||||
home: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 10.5L12 4l8 6.5V20a1 1 0 0 1-1 1h-5v-6H10v6H5a1 1 0 0 1-1-1z" fill="currentColor"/></svg>',
|
||||
spark: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 3l2.4 5.6L20 11l-5.6 2.4L12 19l-2.4-5.6L4 11l5.6-2.4z" fill="currentColor"/></svg>',
|
||||
@ -19,7 +20,7 @@
|
||||
blocks: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 4h7v7H4zM13 4h7v7h-7zM4 13h7v7H4zM13 13h7v7h-7z" fill="currentColor"/></svg>'
|
||||
}; %>
|
||||
<div class="app-shell">
|
||||
<aside class="sidebar">
|
||||
<aside class="sidebar" id="lumi-sidebar" aria-label="Primary navigation">
|
||||
<div class="sidebar-brand">
|
||||
<a href="/" class="brand-link">
|
||||
<span class="logo">
|
||||
@ -31,7 +32,7 @@
|
||||
</span>
|
||||
<span class="title"><%= siteTitle %></span>
|
||||
</a>
|
||||
<button class="icon-button sidebar-toggle" type="button" data-sidebar-toggle aria-label="Toggle sidebar">
|
||||
<button class="icon-button sidebar-toggle" type="button" data-sidebar-toggle aria-label="Collapse navigation" aria-controls="lumi-sidebar" aria-expanded="true">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M8 5v14M16 8l-4 4 4 4" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
@ -97,13 +98,13 @@
|
||||
</aside>
|
||||
<div class="page">
|
||||
<div class="mobile-topbar">
|
||||
<button class="icon-button" type="button" data-sidebar-toggle aria-label="Toggle menu">
|
||||
<button class="icon-button" type="button" data-sidebar-toggle aria-label="Open navigation" aria-controls="lumi-sidebar" aria-expanded="false">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 6h16M4 12h16M4 18h16" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>
|
||||
</button>
|
||||
<span class="mobile-title"><%= siteTitle %></span>
|
||||
</div>
|
||||
<main class="content">
|
||||
<main id="lumi-main" class="content content-<%= typeof pageWidth !== 'undefined' && pageWidth ? pageWidth : 'standard' %>" data-lumi-page="<%= typeof pageId !== 'undefined' && pageId ? pageId : '' %>">
|
||||
<% if (flash) { %>
|
||||
<div class="flash <%= flash.type %>"><%= flash.message %></div>
|
||||
<div class="flash <%= flash.type %>" role="<%= flash.type === 'error' ? 'alert' : 'status' %>"><%= flash.message %></div>
|
||||
<% } %>
|
||||
|
||||
|
||||
35
tests/ui/fixture-server.js
Normal file
35
tests/ui/fixture-server.js
Normal file
@ -0,0 +1,35 @@
|
||||
const fs = require("fs");
|
||||
const os = require("os");
|
||||
const path = require("path");
|
||||
|
||||
const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), "lumi-ui-test-"));
|
||||
process.env.LUMI_DATA_DIR = dataDir;
|
||||
process.env.NODE_ENV = "test";
|
||||
|
||||
const database = require("../../src/services/db");
|
||||
const { ensureDefaults } = require("../../src/services/settings");
|
||||
const { createWebServer } = require("../../src/web/server");
|
||||
|
||||
database.migrate();
|
||||
ensureDefaults();
|
||||
|
||||
const app = createWebServer({
|
||||
discordClient: null,
|
||||
commandRouter: null,
|
||||
loadPlugins() {}
|
||||
});
|
||||
const port = Number(process.env.LUMI_UI_TEST_PORT || process.env.PORT || 3317);
|
||||
const server = app.listen(port, "127.0.0.1", () => {
|
||||
console.log(`Lumi UI fixture listening on http://127.0.0.1:${port}`);
|
||||
});
|
||||
|
||||
function stop() {
|
||||
server.close(() => {
|
||||
try { database.db.close(); } catch {}
|
||||
fs.rmSync(dataDir, { recursive: true, force: true });
|
||||
process.exit(0);
|
||||
});
|
||||
}
|
||||
|
||||
process.on("SIGINT", stop);
|
||||
process.on("SIGTERM", stop);
|
||||
506
tests/ui/lumi-ux.spec.js
Normal file
506
tests/ui/lumi-ux.spec.js
Normal file
@ -0,0 +1,506 @@
|
||||
const { test, expect } = require("@playwright/test");
|
||||
const AxeBuilder = require("@axe-core/playwright").default;
|
||||
|
||||
async function loginAsAdmin(page) {
|
||||
await page.goto("/auth/localhost");
|
||||
await page.getByLabel(/username/i).fill("admin");
|
||||
await page.getByLabel(/password/i).fill("admin");
|
||||
await Promise.all([
|
||||
page.waitForURL(/\/$|\/admin/),
|
||||
page.getByRole("button", { name: /login locally/i }).click()
|
||||
]);
|
||||
}
|
||||
|
||||
async function expectNoSeriousAxeViolations(page) {
|
||||
const result = await new AxeBuilder({ page }).analyze();
|
||||
const blocking = result.violations.filter((item) => ["serious", "critical"].includes(item.impact));
|
||||
expect(blocking, blocking.map((item) => `${item.id}: ${item.help}`).join("\n")).toEqual([]);
|
||||
}
|
||||
|
||||
async function clickSidebarLink(page, href) {
|
||||
const link = page.locator(`.nav-link[href="${href}"]`);
|
||||
if (!(await link.isVisible())) {
|
||||
await link.locator("xpath=ancestor::details/summary").click();
|
||||
}
|
||||
await Promise.all([
|
||||
page.waitForNavigation({ waitUntil: "load" }),
|
||||
link.click()
|
||||
]);
|
||||
}
|
||||
|
||||
test("public shell renders without page errors or serious accessibility violations", async ({ page }, testInfo) => {
|
||||
const errors = [];
|
||||
page.on("pageerror", (error) => errors.push(error.message));
|
||||
await page.goto("/");
|
||||
await expect(page.locator("main.content")).toBeVisible();
|
||||
await expect(page.getByRole("heading", { level: 1 })).toBeVisible();
|
||||
await expectNoSeriousAxeViolations(page);
|
||||
await page.screenshot({ path: testInfo.outputPath("home-baseline.png"), fullPage: true });
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
|
||||
test("mobile navigation is a focus-contained drawer and restores its opener", async ({ page }) => {
|
||||
test.skip((page.viewportSize()?.width || 999) > 900, "Mobile drawer contract applies below 900px.");
|
||||
await page.goto("/");
|
||||
const opener = page.locator(".mobile-topbar [data-sidebar-toggle]");
|
||||
await opener.click();
|
||||
await expect(opener).toHaveAttribute("aria-expanded", "true");
|
||||
await expect(page.locator("#lumi-sidebar")).toHaveAttribute("aria-modal", "true");
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(opener).toHaveAttribute("aria-expanded", "false");
|
||||
await expect(opener).toBeFocused();
|
||||
});
|
||||
|
||||
test("live events use one connection and release it across repeated navigation", async ({ page }) => {
|
||||
test.skip((page.viewportSize()?.width || 0) <= 900, "Desktop navigation stress coverage exercises the same event lifecycle.");
|
||||
await page.addInitScript(() => {
|
||||
const NativeEventSource = window.EventSource;
|
||||
window.__lumiEventSourceUrls = [];
|
||||
window.EventSource = new Proxy(NativeEventSource, {
|
||||
construct(Target, args) {
|
||||
window.__lumiEventSourceUrls.push(new URL(args[0], window.location.href).pathname);
|
||||
return Reflect.construct(Target, args);
|
||||
}
|
||||
});
|
||||
});
|
||||
await loginAsAdmin(page);
|
||||
await clickSidebarLink(page, "/admin/updates");
|
||||
await expect.poll(() => page.evaluate(() => window.__lumiEventSourceUrls)).toEqual(["/api/events"]);
|
||||
|
||||
const routes = ["/commands", "/feedback", "/admin", "/admin/navigation", "/admin/settings", "/"];
|
||||
for (let cycle = 0; cycle < 3; cycle += 1) {
|
||||
for (const route of routes) {
|
||||
await clickSidebarLink(page, route);
|
||||
await expect.poll(() => page.evaluate(() => window.__lumiEventSourceUrls)).toEqual(["/api/events"]);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("updates reconcile checks and cleanup settings without document reloads", async ({ page }) => {
|
||||
await loginAsAdmin(page);
|
||||
let snapshotSaveAttempts = 0;
|
||||
await page.route("**/admin/updates/core/check", async (route) => {
|
||||
if (route.request().method() !== "POST") return route.continue();
|
||||
const core = {
|
||||
current_version: "0.2.24",
|
||||
safe_target_version: "0.2.25",
|
||||
latest_available_version: "0.2.25",
|
||||
source_branch: "main",
|
||||
update_available: true,
|
||||
blocked: false,
|
||||
version_description: "A targeted browser-test update is available."
|
||||
};
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
ok: true,
|
||||
message: "Core version 0.2.25 is available.",
|
||||
status: { core },
|
||||
data: { status: { core } },
|
||||
patch: {},
|
||||
reloadRequired: false
|
||||
})
|
||||
});
|
||||
});
|
||||
await page.route("**/admin/updates/snapshots/settings", async (route) => {
|
||||
if (route.request().method() !== "POST" || snapshotSaveAttempts++ > 0) return route.continue();
|
||||
await route.fulfill({
|
||||
status: 422,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
ok: false,
|
||||
message: "Correct the snapshot cleanup limits and try again.",
|
||||
fieldErrors: { max_age_days: "Enter a whole number from 1 to 3650." },
|
||||
data: {},
|
||||
patch: {},
|
||||
reloadRequired: false
|
||||
})
|
||||
});
|
||||
});
|
||||
await page.goto("/admin/updates");
|
||||
await expect(page.locator("[data-update-management]")).toBeVisible();
|
||||
const deployment = page.locator("[data-deployment-panel]");
|
||||
await expect(deployment.getByRole("heading", { name: "Running code branch" })).toBeVisible();
|
||||
await expect(deployment.locator("select[name='branch']")).toContainText("Stable · main");
|
||||
await expect(deployment.getByRole("button", { name: "Deploy selected branch" })).toBeVisible();
|
||||
const navigationCount = await page.evaluate(() => performance.getEntriesByType("navigation").length);
|
||||
const core = page.locator('[data-update-key="core"]');
|
||||
await core.locator("summary").first().click();
|
||||
const [checkResponse] = await Promise.all([
|
||||
page.waitForResponse((response) => new URL(response.url()).pathname === "/admin/updates/core/check"),
|
||||
core.getByRole("button", { name: "Check for updates" }).click()
|
||||
]);
|
||||
expect(checkResponse.headers()["content-type"]).toContain("application/json");
|
||||
await expect(core.locator("[data-update-summary]")).toContainText("0.2.25");
|
||||
await expect(core.locator("[data-update-badge]")).toHaveText("Update available");
|
||||
await expect(core.locator("[data-update-inline-result]")).toContainText("0.2.25");
|
||||
expect(await page.evaluate(() => performance.getEntriesByType("navigation").length)).toBe(navigationCount);
|
||||
|
||||
const settings = page.locator("[data-update-settings-form]");
|
||||
const age = settings.locator("input[name='max_age_days']");
|
||||
const count = settings.locator("input[name='max_per_target']");
|
||||
const originalAge = await age.inputValue();
|
||||
const originalCount = await count.inputValue();
|
||||
const [invalidResponse] = await Promise.all([
|
||||
page.waitForResponse((response) => new URL(response.url()).pathname === "/admin/updates/snapshots/settings"),
|
||||
settings.getByRole("button", { name: "Save cleanup policy" }).click()
|
||||
]);
|
||||
expect(invalidResponse.status()).toBe(422);
|
||||
await expect(age).toHaveAttribute("aria-invalid", "true");
|
||||
await expect(settings.locator("[data-update-inline-result]")).toContainText("Correct the snapshot cleanup limits");
|
||||
await age.fill(originalAge);
|
||||
await count.fill(originalCount);
|
||||
const [saveResponse] = await Promise.all([
|
||||
page.waitForResponse((response) => new URL(response.url()).pathname === "/admin/updates/snapshots/settings" && response.status() === 200),
|
||||
settings.getByRole("button", { name: "Save cleanup policy" }).click()
|
||||
]);
|
||||
expect(saveResponse.headers()["content-type"]).toContain("application/json");
|
||||
await expect(settings.locator("[data-update-inline-result]")).toContainText("Snapshot cleanup now keeps");
|
||||
expect(await page.evaluate(() => performance.getEntriesByType("navigation").length)).toBe(navigationCount);
|
||||
|
||||
const reflow = await page.evaluate(() => ({ viewport: document.documentElement.clientWidth, documentWidth: document.documentElement.scrollWidth }));
|
||||
expect(reflow.documentWidth).toBeLessThanOrEqual(reflow.viewport + 1);
|
||||
await expectNoSeriousAxeViolations(page);
|
||||
});
|
||||
|
||||
test("custom commands and policies save targeted state without document reloads", async ({ page }) => {
|
||||
await loginAsAdmin(page);
|
||||
await page.goto("/commands");
|
||||
const triggerHeader = page.locator("th[data-sort='trigger']");
|
||||
await triggerHeader.getByRole("button", { name: "Trigger" }).focus();
|
||||
await page.keyboard.press("Enter");
|
||||
await expect(triggerHeader).toHaveAttribute("aria-sort", "ascending");
|
||||
await expectNoSeriousAxeViolations(page);
|
||||
await page.goto("/admin/commands");
|
||||
const createForm = page.locator("[data-command-create-form]");
|
||||
const mode = createForm.locator("select[name='mode']");
|
||||
await mode.selectOption("random");
|
||||
await expect(createForm.locator(".js-field-random")).toBeVisible();
|
||||
await expect(createForm.locator(".js-field-response").locator("xpath=ancestor::div[contains(@class,'field')]")).toBeHidden();
|
||||
await mode.selectOption("plain");
|
||||
|
||||
const trigger = `uxcommand${Date.now()}`;
|
||||
await createForm.locator("input[name='trigger']").fill(trigger);
|
||||
await createForm.locator("input[name='response']").fill("A targeted command response");
|
||||
const commandNavigationCount = await page.evaluate(() => performance.getEntriesByType("navigation").length);
|
||||
const [createResponse] = await Promise.all([
|
||||
page.waitForResponse((response) => new URL(response.url()).pathname === "/admin/commands" && response.request().method() === "POST"),
|
||||
createForm.getByRole("button", { name: "Create command" }).click({ timeout: 5000 })
|
||||
]);
|
||||
expect(createResponse.headers()["content-type"]).toContain("application/json");
|
||||
await expect(page.locator("[data-command-list-section]")).toContainText(trigger);
|
||||
await expect(page.locator("[data-command-management-status]")).toContainText("Command created");
|
||||
expect(await page.evaluate(() => performance.getEntriesByType("navigation").length)).toBe(commandNavigationCount);
|
||||
await expect(createForm.locator("input[name='trigger']")).toHaveValue("");
|
||||
expect(await createForm.evaluate((form) => Array.from(form.elements)
|
||||
.filter((field) => typeof field.checkValidity === "function" && !field.checkValidity())
|
||||
.map((field) => ({ name: field.name, message: field.validationMessage })))).toEqual([]);
|
||||
const [invalidResponse] = await Promise.all([
|
||||
page.waitForResponse((response) => new URL(response.url()).pathname === "/admin/commands" && response.request().method() === "POST"),
|
||||
createForm.evaluate((form) => form.requestSubmit(form.querySelector("button[type='submit']")))
|
||||
]);
|
||||
expect(invalidResponse.status()).toBe(422);
|
||||
await expect(createForm.locator("input[name='trigger']")).toHaveAttribute("aria-invalid", "true");
|
||||
await expect(createForm.locator("[data-lumi-form-status]")).toContainText("Trigger is required");
|
||||
expect(await page.evaluate(() => performance.getEntriesByType("navigation").length)).toBe(commandNavigationCount);
|
||||
const commandReflow = await page.evaluate(() => ({ viewport: document.documentElement.clientWidth, documentWidth: document.documentElement.scrollWidth }));
|
||||
expect(commandReflow.documentWidth).toBeLessThanOrEqual(commandReflow.viewport + 1);
|
||||
|
||||
await page.goto("/admin/command-policies");
|
||||
const commandItem = page.locator('[data-policy-search-scope="commands"] [data-policy-item]', { hasText: `!${trigger}` });
|
||||
await expect(commandItem).toHaveCount(1);
|
||||
await commandItem.locator("summary").click();
|
||||
await commandItem.locator("select[name$='window_mode']").selectOption("custom");
|
||||
await commandItem.locator("input[name$='window_uses']").fill("2");
|
||||
const policyNavigationCount = await page.evaluate(() => performance.getEntriesByType("navigation").length);
|
||||
const [policyResponse] = await Promise.all([
|
||||
page.waitForResponse((response) => new URL(response.url()).pathname === "/admin/command-policies/commands"),
|
||||
page.locator(".command-policy-savebar").first().getByRole("button", { name: "Save all command settings" }).click()
|
||||
]);
|
||||
expect(policyResponse.headers()["content-type"]).toContain("application/json");
|
||||
await expect(page.locator("[data-command-save-state]")).toContainText("saved");
|
||||
expect(await page.evaluate(() => performance.getEntriesByType("navigation").length)).toBe(policyNavigationCount);
|
||||
|
||||
const reflow = await page.evaluate(() => ({ viewport: document.documentElement.clientWidth, documentWidth: document.documentElement.scrollWidth }));
|
||||
expect(reflow.documentWidth).toBeLessThanOrEqual(reflow.viewport + 1);
|
||||
await expectNoSeriousAxeViolations(page);
|
||||
});
|
||||
|
||||
test("enhanced settings save preserves the document, focus, and viewport", async ({ page }) => {
|
||||
await loginAsAdmin(page);
|
||||
await page.goto("/admin/settings");
|
||||
const title = page.locator("input[name='site_title']");
|
||||
await title.focus();
|
||||
const navigationCount = await page.evaluate(() => performance.getEntriesByType("navigation").length);
|
||||
const scrollBefore = await page.evaluate(() => window.scrollY);
|
||||
await title.fill(`Lumi UI Test ${Date.now()}`);
|
||||
await page.getByRole("button", { name: "Save changes" }).click();
|
||||
await expect(page.getByText(/Section saved|Settings saved/).first()).toBeVisible();
|
||||
expect(await page.evaluate(() => performance.getEntriesByType("navigation").length)).toBe(navigationCount);
|
||||
expect(Math.abs((await page.evaluate(() => window.scrollY)) - scrollBefore)).toBeLessThan(3);
|
||||
await expect(title).toBeFocused();
|
||||
await expectNoSeriousAxeViolations(page);
|
||||
});
|
||||
|
||||
test("overlay editor keeps settings scrollable and synchronizes interactive previews", async ({ page }) => {
|
||||
await loginAsAdmin(page);
|
||||
await page.goto("/admin/overlays");
|
||||
await page.getByRole("button", { name: "Create overlay" }).click();
|
||||
const createName = page.locator("[data-overlay-create-modal] input[name='name']");
|
||||
const overlayName = `UX overlay ${Date.now()}`;
|
||||
await expect(createName).toBeFocused();
|
||||
await createName.fill(overlayName);
|
||||
await Promise.all([
|
||||
page.waitForURL(/\/admin\/overlays/),
|
||||
page.locator("[data-overlay-create-modal]").getByRole("button", { name: /create|add/i }).click()
|
||||
]);
|
||||
await page.locator("tbody tr", { hasText: overlayName }).getByRole("link", { name: "Open editor" }).click();
|
||||
const viewportWidth = page.viewportSize()?.width || 0;
|
||||
const preview = page.locator("[data-overlay-editor]");
|
||||
const previewLauncher = page.locator("[data-preview-drawer-toggle]");
|
||||
if (viewportWidth >= 1180) {
|
||||
await expect(preview).toBeVisible();
|
||||
} else {
|
||||
await expect(preview).toBeHidden();
|
||||
await page.mouse.move((page.viewportSize()?.width || 320) / 2, Math.min(420, (page.viewportSize()?.height || 720) / 2));
|
||||
await page.mouse.wheel(0, 480);
|
||||
await expect.poll(() => page.evaluate(() => window.scrollY)).toBeGreaterThan(0);
|
||||
await page.evaluate(() => window.scrollTo(0, 0));
|
||||
const launcherGeometry = await previewLauncher.evaluate((button) => {
|
||||
const rect = button.getBoundingClientRect();
|
||||
return {
|
||||
top: rect.top,
|
||||
bottom: rect.bottom,
|
||||
viewportHeight: window.innerHeight
|
||||
};
|
||||
});
|
||||
expect(launcherGeometry.top).toBeGreaterThanOrEqual(0);
|
||||
expect(launcherGeometry.bottom).toBeLessThanOrEqual(launcherGeometry.viewportHeight);
|
||||
await previewLauncher.click();
|
||||
await expect(preview).toBeVisible();
|
||||
await expect(previewLauncher).toHaveAttribute("aria-expanded", "true");
|
||||
await expect(preview).toHaveCSS("overflow-y", "auto");
|
||||
}
|
||||
|
||||
const previewGeometry = await preview.evaluate((panel) => {
|
||||
const panelRect = panel.getBoundingClientRect();
|
||||
const frameRect = panel.querySelector("[data-preview-frame]").getBoundingClientRect();
|
||||
return {
|
||||
panelTop: panelRect.top,
|
||||
panelBottom: panelRect.bottom,
|
||||
viewportHeight: window.innerHeight,
|
||||
frameRatio: frameRect.width / frameRect.height,
|
||||
stageHeight: panel.querySelector("[data-preview-stage]").getBoundingClientRect().height,
|
||||
frameHeight: frameRect.height,
|
||||
panelHeight: panelRect.height
|
||||
};
|
||||
});
|
||||
expect(previewGeometry.panelTop).toBeGreaterThanOrEqual(-1);
|
||||
expect(previewGeometry.panelBottom).toBeLessThanOrEqual(previewGeometry.viewportHeight + 1);
|
||||
expect(previewGeometry.frameRatio).toBeCloseTo(16 / 9, 2);
|
||||
expect(Math.abs(previewGeometry.stageHeight - previewGeometry.frameHeight)).toBeLessThanOrEqual(2);
|
||||
if (viewportWidth >= 1180) expect(previewGeometry.panelHeight).toBeLessThan(previewGeometry.viewportHeight * 0.9);
|
||||
|
||||
if (viewportWidth < 1180) {
|
||||
await preview.getByRole("button", { name: "Close scene preview" }).click();
|
||||
await expect(preview).toBeHidden();
|
||||
await expect(previewLauncher).toBeFocused();
|
||||
}
|
||||
|
||||
const settingsColumn = page.locator(".overlay-settings-column");
|
||||
const finalSettingsCard = page.locator(".overlay-settings-column > .card").last();
|
||||
const finalDisclosure = finalSettingsCard.locator("details").first();
|
||||
if (viewportWidth < 1180) {
|
||||
await finalDisclosure.locator("summary").evaluate((item) => item.scrollIntoView({ block: "center", behavior: "instant" }));
|
||||
}
|
||||
await finalDisclosure.locator("summary").click();
|
||||
const scrollContract = await settingsColumn.evaluate((item) => ({
|
||||
overflowY: getComputedStyle(item).overflowY,
|
||||
maxHeight: getComputedStyle(item).maxHeight,
|
||||
legacyHeight: item.style.getPropertyValue("--overlay-settings-max-height"),
|
||||
clientHeight: item.clientHeight,
|
||||
scrollHeight: item.scrollHeight
|
||||
}));
|
||||
expect(scrollContract.legacyHeight).toBe("");
|
||||
if (viewportWidth >= 1180) {
|
||||
expect(scrollContract.overflowY).toBe("auto");
|
||||
expect(scrollContract.scrollHeight).toBeGreaterThan(scrollContract.clientHeight);
|
||||
await settingsColumn.evaluate((item) => { item.scrollTop = item.scrollHeight; });
|
||||
await expect.poll(() => settingsColumn.evaluate((item) => item.scrollTop)).toBeGreaterThan(0);
|
||||
} else {
|
||||
expect(["visible", "clip"]).toContain(scrollContract.overflowY);
|
||||
}
|
||||
|
||||
const finalSettingsAction = finalSettingsCard.locator("button").last();
|
||||
await finalSettingsAction.scrollIntoViewIfNeeded();
|
||||
await expect(finalSettingsAction).toBeVisible();
|
||||
const finalActionGeometry = await finalSettingsAction.evaluate((button) => {
|
||||
const rect = button.getBoundingClientRect();
|
||||
return { top: rect.top, bottom: rect.bottom, viewportHeight: window.innerHeight };
|
||||
});
|
||||
expect(finalActionGeometry.top).toBeGreaterThanOrEqual(-1);
|
||||
expect(finalActionGeometry.bottom).toBeLessThanOrEqual(finalActionGeometry.viewportHeight + 1);
|
||||
if (viewportWidth >= 1180) {
|
||||
const stickyGeometry = await preview.evaluate((panel) => {
|
||||
const rect = panel.getBoundingClientRect();
|
||||
return { top: rect.top, bottom: rect.bottom, viewportHeight: window.innerHeight };
|
||||
});
|
||||
expect(stickyGeometry.top).toBeGreaterThanOrEqual(-1);
|
||||
expect(stickyGeometry.bottom).toBeLessThanOrEqual(stickyGeometry.viewportHeight + 1);
|
||||
|
||||
const addSource = page.locator(".overlay-add-source").first();
|
||||
await addSource.scrollIntoViewIfNeeded();
|
||||
await addSource.locator("summary").click();
|
||||
await addSource.locator("input[name='name']").fill("Pop-out adjustment source");
|
||||
await Promise.all([
|
||||
page.waitForNavigation({ waitUntil: "load" }),
|
||||
addSource.getByRole("button", { name: "Add source" }).click()
|
||||
]);
|
||||
await expect(page.locator("[data-overlay-editor]")).toBeVisible();
|
||||
|
||||
const [popup] = await Promise.all([
|
||||
page.waitForEvent("popup"),
|
||||
preview.getByRole("button", { name: "Pop out" }).click()
|
||||
]);
|
||||
await popup.waitForLoadState("domcontentloaded");
|
||||
await popup.setViewportSize({ width: 900, height: 500 });
|
||||
const popoutFrame = popup.locator("[data-preview-window-frame]");
|
||||
await expect(popoutFrame).toBeVisible();
|
||||
await expect.poll(() => popoutFrame.evaluate((frame) => {
|
||||
const rect = frame.getBoundingClientRect();
|
||||
return rect.right <= window.innerWidth + 1 && rect.bottom <= window.innerHeight + 1;
|
||||
})).toBe(true);
|
||||
const popoutGeometry = await popoutFrame.evaluate((frame) => {
|
||||
const rect = frame.getBoundingClientRect();
|
||||
return {
|
||||
top: rect.top,
|
||||
right: rect.right,
|
||||
bottom: rect.bottom,
|
||||
left: rect.left,
|
||||
ratio: rect.width / rect.height,
|
||||
viewportWidth: window.innerWidth,
|
||||
viewportHeight: window.innerHeight
|
||||
};
|
||||
});
|
||||
expect(popoutGeometry.top).toBeGreaterThanOrEqual(-1);
|
||||
expect(popoutGeometry.left).toBeGreaterThanOrEqual(-1);
|
||||
expect(popoutGeometry.right).toBeLessThanOrEqual(popoutGeometry.viewportWidth + 1);
|
||||
expect(popoutGeometry.bottom).toBeLessThanOrEqual(popoutGeometry.viewportHeight + 1);
|
||||
expect(popoutGeometry.ratio).toBeCloseTo(16 / 9, 2);
|
||||
await expect(popup.locator("[data-popout-source]")).toHaveValue(/.+/);
|
||||
await expect(popup.locator(".lumi-overlay-module.is-selected")).toHaveCount(1);
|
||||
const mainX = page.locator("[data-module-editor][data-module-id] input[name='x']").first();
|
||||
await expect(mainX).toHaveValue("0");
|
||||
const transformResponse = popup.waitForResponse((response) => /\/api\/admin\/overlays\/[^/]+\/modules\/[^/]+\/transform$/.test(new URL(response.url()).pathname));
|
||||
await popup.locator("[data-preview-window-canvas]").focus();
|
||||
await popup.keyboard.press("ArrowRight");
|
||||
expect((await transformResponse).ok()).toBe(true);
|
||||
await expect(popup.locator("[data-preview-window-status]")).toContainText("Saved");
|
||||
await expect(mainX).toHaveValue("0.1");
|
||||
await popup.close();
|
||||
}
|
||||
|
||||
const navigationCount = await page.evaluate(() => performance.getEntriesByType("navigation").length);
|
||||
await page.getByText("Overlay name and canvas size", { exact: true }).click();
|
||||
const name = page.locator("form[data-lumi-enhanced-form] input[name='name']").first();
|
||||
await name.fill(`${await name.inputValue()} edited`);
|
||||
await name.locator("xpath=ancestor::form").getByRole("button", { name: /save overlay settings/i }).click();
|
||||
await expect(name.locator("xpath=ancestor::form").locator("[data-lumi-form-status]")).toContainText("Overlay saved");
|
||||
expect(await page.evaluate(() => performance.getEntriesByType("navigation").length)).toBe(navigationCount);
|
||||
await expectNoSeriousAxeViolations(page);
|
||||
});
|
||||
|
||||
test("resource storage limits save, reconcile immediately, and persist", async ({ page }) => {
|
||||
await loginAsAdmin(page);
|
||||
await page.goto("/admin/resources");
|
||||
const settings = page.locator(".resource-settings-panel");
|
||||
await settings.locator("summary").click();
|
||||
const form = settings.locator("[data-resource-settings-form]");
|
||||
await form.locator("[name='limit_gib']").fill("3.5");
|
||||
await form.locator("[name='reserve_gib']").fill("0.3");
|
||||
await form.locator("[name='max_file_mib']").fill("24");
|
||||
await form.locator("[name='max_files']").fill("6");
|
||||
const [response] = await Promise.all([
|
||||
page.waitForResponse((candidate) => new URL(candidate.url()).pathname === "/admin/resources/settings"),
|
||||
form.getByRole("button", { name: "Save storage settings" }).click()
|
||||
]);
|
||||
expect(response.ok()).toBe(true);
|
||||
const payload = await response.json();
|
||||
expect(payload.settings).toEqual({ limit_gib: 3.5, reserve_gib: 0.3, max_file_mib: 24, max_files: 6 });
|
||||
await expect(form.locator("[data-resource-settings-status]")).toContainText("saved");
|
||||
await expect(page.locator("[data-storage-limit]")).toContainText("3.50 GB");
|
||||
await expect(page.locator("[data-storage-max-file]")).toContainText("24.0 MB");
|
||||
const fileInput = page.locator("[data-resource-file-input]");
|
||||
await expect(fileInput).toHaveAttribute("data-max-files", "6");
|
||||
await expect(fileInput).toHaveAttribute("data-max-file-bytes", String(24 * 1024 * 1024));
|
||||
|
||||
await page.reload();
|
||||
await page.locator(".resource-settings-panel summary").click();
|
||||
await expect(page.locator("[data-resource-settings-form] [name='limit_gib']")).toHaveValue("3.5");
|
||||
await expect(page.locator("[data-resource-settings-form] [name='reserve_gib']")).toHaveValue("0.3");
|
||||
await expect(page.locator("[data-resource-settings-form] [name='max_file_mib']")).toHaveValue("24");
|
||||
await expect(page.locator("[data-resource-settings-form] [name='max_files']")).toHaveValue("6");
|
||||
await expectNoSeriousAxeViolations(page);
|
||||
});
|
||||
|
||||
test("navigation builder supports keyboard movement and saves without navigation", async ({ page }) => {
|
||||
await loginAsAdmin(page);
|
||||
await page.goto("/admin/navigation");
|
||||
await expect(page.locator("[data-navigation-builder]" )).toBeVisible();
|
||||
const navigationCount = await page.evaluate(() => performance.getEntriesByType("navigation").length);
|
||||
|
||||
const firstItem = page.locator("[data-nav-section-card] .nav-dd-item").first();
|
||||
const navId = await firstItem.getAttribute("data-nav-id");
|
||||
const movedItem = page.locator(`.nav-dd-item[data-nav-id="${navId}"]`);
|
||||
const originalGroup = await movedItem.locator("xpath=parent::*").getAttribute("data-group-label");
|
||||
await movedItem.getByRole("button", { name: /to the next group/i }).click();
|
||||
const movedGroup = await movedItem.locator("xpath=parent::*").getAttribute("data-group-label");
|
||||
expect(movedGroup).not.toBe(originalGroup);
|
||||
|
||||
await page.getByRole("button", { name: "Add section" }).click();
|
||||
const newLabel = page.locator("[data-nav-section-card] .nav-section-label").last();
|
||||
await expect(newLabel).toBeFocused();
|
||||
await newLabel.fill("Automation");
|
||||
await page.getByRole("button", { name: "Save navigation" }).click();
|
||||
await expect(page.locator("[data-nav-builder-status]")).toContainText("Navigation saved");
|
||||
expect(await page.evaluate(() => performance.getEntriesByType("navigation").length)).toBe(navigationCount);
|
||||
await expect(page.locator("[data-nav-dirty-badge]")).toBeHidden();
|
||||
|
||||
const reflow = await page.evaluate(() => ({
|
||||
viewport: document.documentElement.clientWidth,
|
||||
documentWidth: document.documentElement.scrollWidth
|
||||
}));
|
||||
expect(reflow.documentWidth).toBeLessThanOrEqual(reflow.viewport + 1);
|
||||
await expectNoSeriousAxeViolations(page);
|
||||
});
|
||||
|
||||
test("theme draft preview survives a targeted custom-theme save", async ({ page }) => {
|
||||
await loginAsAdmin(page);
|
||||
await page.goto("/admin/theming");
|
||||
const sourceTheme = page.locator(".theme-card").first();
|
||||
await sourceTheme.locator("summary", { hasText: "More actions" }).click();
|
||||
const copyName = `UX Theme ${Date.now()}`;
|
||||
await sourceTheme.locator("form[action='/admin/theming/duplicate'] input[name='name']").fill(copyName);
|
||||
await Promise.all([
|
||||
page.waitForURL(/\/admin\/theming\?edit=.*#theme-editor/),
|
||||
sourceTheme.getByRole("button", { name: "Duplicate" }).click()
|
||||
]);
|
||||
|
||||
const form = page.locator("[data-theme-form]");
|
||||
await expect(form).toBeVisible();
|
||||
const accent = form.locator("input[name='light_accent']");
|
||||
const nextAccent = (await accent.inputValue()).toLowerCase() === "#176b76" ? "#176b75" : "#176b76";
|
||||
await accent.fill(nextAccent);
|
||||
const navigationCount = await page.evaluate(() => performance.getEntriesByType("navigation").length);
|
||||
const [saveResponse] = await Promise.all([
|
||||
page.waitForResponse((response) => /\/admin\/theming\/custom\/[^/]+\/save$/.test(new URL(response.url()).pathname)),
|
||||
form.getByRole("button", { name: "Save theme" }).click()
|
||||
]);
|
||||
expect(saveResponse.headers()["content-type"]).toContain("application/json");
|
||||
expect((await saveResponse.json()).message).toContain(`${copyName} saved`);
|
||||
await expect(form.locator("[data-lumi-form-status]")).toContainText(`${copyName} saved`);
|
||||
expect(await page.evaluate(() => performance.getEntriesByType("navigation").length)).toBe(navigationCount);
|
||||
await expect(form.getByRole("button", { name: "Saved", exact: true })).toBeVisible();
|
||||
await expectNoSeriousAxeViolations(page);
|
||||
});
|
||||
@ -1,14 +1,14 @@
|
||||
{
|
||||
"name": "Lumi Core",
|
||||
"version": "0.2.24",
|
||||
"channel": "stable",
|
||||
"released_at": "2026-07-20",
|
||||
"version": "0.2.25",
|
||||
"channel": "experimental",
|
||||
"released_at": "2026-07-22",
|
||||
"compatible_from": "0.1.9",
|
||||
"migration_kind": "patch",
|
||||
"replaces_versions": [
|
||||
"1.2.0"
|
||||
],
|
||||
"migration_notes": "Repairs resource locking, deletion, downloads, responsive cards, embedded media previews, empty/filter states, and live storage-limit reflection. Existing content files, metadata, settings, tokens, overlays, plugins, databases, uploads, models, feedback, and secrets are preserved; no data migration is required.",
|
||||
"migration_notes": "Experimental DesignMotionHQ UX checkpoint plus safe production branch deployment controls. Existing content, settings, tokens, overlays, databases, uploads, models, feedback, secrets, plugin data, and local-only plugins are preserved. Bundled plugin code follows the selected branch.",
|
||||
"rollback_safe": true,
|
||||
"requirements": [
|
||||
"Node.js 18 or newer"
|
||||
|
||||
Loading…
Reference in New Issue
Block a user