merge codex update system work
This commit is contained in:
commit
32443335c3
3
.gitignore
vendored
3
.gitignore
vendored
@ -16,3 +16,6 @@ npm-debug.log
|
||||
security-audit-*.json
|
||||
security-audit-*.md
|
||||
taskfile.txt
|
||||
codex-guidelines
|
||||
Twitch.png
|
||||
twitch-credentials-lumi.png
|
||||
|
||||
22
README.md
22
README.md
@ -38,6 +38,19 @@ You can set these in `.env` or change role IDs in **Admin → Settings**.
|
||||
Use **Admin → Plugins** to install, enable, update, or uninstall plugins.
|
||||
You can also create a local plugin from the WebUI.
|
||||
|
||||
## Updates and recovery
|
||||
|
||||
Use **Admin → Updates** for version-aware core and plugin updates. Lumi reads
|
||||
repo metadata from `main` by default, can explicitly target the newest
|
||||
`experimental-*` branch, creates snapshots before updates, blocks unsafe major
|
||||
jumps without compatibility bridge metadata, and keeps advanced ZIP updates
|
||||
hidden behind manual reveal controls.
|
||||
|
||||
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).
|
||||
|
||||
## Twitch bot
|
||||
|
||||
Configure Twitch chat settings in **Admin → Settings**:
|
||||
@ -53,7 +66,14 @@ Users have an internal UUID and username. Link Twitch accounts in **Profile** an
|
||||
|
||||
## Theming
|
||||
|
||||
Use **Admin → Theming** to adjust light and dark mode colors. The UI uses your OS theme preference.
|
||||
Use **Admin → Theming** to select one of six read-only Lumi themes. Duplicate a
|
||||
built-in or custom theme to edit colors, surfaces, controls, status colors,
|
||||
focus states, radius, shadows, and spacing with a live light/dark preview.
|
||||
Custom themes can be applied, renamed, duplicated, and deleted. Invalid or
|
||||
incomplete values fall back safely to the selected built-in base theme.
|
||||
|
||||
Developer and modding conventions are documented in
|
||||
[`docs/lumi-ui.md`](docs/lumi-ui.md).
|
||||
|
||||
## Notes
|
||||
|
||||
|
||||
178
codex-guidelines
178
codex-guidelines
@ -1,178 +0,0 @@
|
||||
Project: Lumi Bot (Discord + Twitch + YouTube) — WebUI-first management
|
||||
|
||||
Purpose of this file
|
||||
- Single source of truth for cross‑conversation context, conventions, and packaging.
|
||||
- Update when project behavior changes (routes, APIs, packaging, DB schema).
|
||||
- Refer back here before making changes.
|
||||
|
||||
Repository layout
|
||||
- Core:
|
||||
- src/main.js (entry)
|
||||
- src/web/server.js (WebUI + routes + wizards)
|
||||
- src/services (auth, platforms, users, plugins, update-manager, etc.)
|
||||
- src/web/views (EJS pages, partials/layout)
|
||||
- src/web/public (styles.css, app.js)
|
||||
- Plugins: plugins/<plugin-id>/ (plugin.json + index.js + optional views/)
|
||||
- Data: data/app.db (SQLite), snapshots, uploads (should be excluded from updates)
|
||||
- Updates output: updates/ (all update zips go here)
|
||||
|
||||
Platform integration (current)
|
||||
- Discord, Twitch, YouTube supported; modular via src/services/platforms.js
|
||||
- Wizards in /setup/*:
|
||||
- /setup/discord, /setup/twitch, /setup/youtube (cancelable)
|
||||
- OAuth routes in /auth/*:
|
||||
- /auth/discord, /auth/twitch, /auth/youtube
|
||||
- Role mapping:
|
||||
- Discord roles from settings discord_admin_role_id / discord_mod_role_id (supports comma-separated)
|
||||
- Twitch: broadcaster/admin/mod via tags/badges
|
||||
- YouTube: chat owner/moderator flags
|
||||
|
||||
WebUI key routes (core)
|
||||
- / (home)
|
||||
- /commands, /leaderboards, /stats, /profile
|
||||
- /moderator (Mods List)
|
||||
- /admin
|
||||
- /admin/settings
|
||||
- /admin/navigation
|
||||
- /admin/theming
|
||||
- /admin/privileges
|
||||
- /admin/logs
|
||||
- /admin/updates
|
||||
- /admin/commands
|
||||
- /admin/pages
|
||||
- /admin/users
|
||||
- /admin/plugins
|
||||
|
||||
WebUI profile hook (core)
|
||||
- web.addProfileSection({ id, label, view?, content?, role?, order?, locals? })
|
||||
- view: EJS include path; content: raw HTML string
|
||||
- role defaults to "public" if omitted
|
||||
- In profile template, sections render under "Personalized"
|
||||
- Profile view receives: user, profile, accounts + section.locals
|
||||
|
||||
Update system (core)
|
||||
- applyBotUpdate(zipPath, { mode: "full"|"patch" })
|
||||
- Full update requires: package.json, safe-mode.js, src/main.js, src/web/server.js
|
||||
- Patch mode accepts any files and overlays them (no deletes)
|
||||
- applyPluginUpdate(zipPath) expects plugin.json in root + entry file
|
||||
- Snapshots: data/snapshots (keeps last 20 successful)
|
||||
- Safe Mode: safe-mode.js supports rollback
|
||||
|
||||
Update packaging standards
|
||||
- Always place update zips in updates/
|
||||
- Core full update (default):
|
||||
- Zip from repo root EXCLUDING: .git, node_modules, data, plugins, updates
|
||||
- Filename: updates/lumi-update-<topic>.zip
|
||||
- Core patch update:
|
||||
- Zip only changed files/folders
|
||||
- Use Patch Mode in UI
|
||||
- Filename: updates/lumi-update-<topic>-patch.zip (or similar)
|
||||
- Plugin update:
|
||||
- Zip contents of plugins/<plugin-id>/ (root = plugin folder)
|
||||
- Filename: updates/lumi-plugin-<plugin-id>-vX.Y.Z.zip
|
||||
- Preferred zip tool on Windows:
|
||||
- tar -a -c -f <zip> -C <folder> .
|
||||
|
||||
Command framework (core)
|
||||
- commandRouter.registerCommands(pluginId, [{ id, triggers, platforms, handler }])
|
||||
- Platforms enumerated by services/platforms.js
|
||||
- Plugins can expose cmds.json for admin command list ingestion
|
||||
- Core dynamic command: !top <category> (categories pulled from leaderboards/providers via src/services/top.js)
|
||||
|
||||
Database schema (core)
|
||||
- data/app.db (SQLite)
|
||||
- user_profiles table includes:
|
||||
- internal_username (unique, NOCASE)
|
||||
- username_updated_at (added for 90‑day cooldown)
|
||||
- user_identities: provider/user mapping
|
||||
- plugin_settings: plugin key/value store
|
||||
- plugins: plugin registry
|
||||
|
||||
Profile username cooldown (core)
|
||||
- User can update once every 90 days
|
||||
- Stored in user_profiles.username_updated_at
|
||||
- /profile/username checks cooldown server‑side
|
||||
- UI: modal with disabled button + hint when on cooldown
|
||||
|
||||
Sidebar UX standards (core)
|
||||
- User chip is clickable to /profile
|
||||
- Collapsed sidebar:
|
||||
- Icons centered, tooltips on items/sections
|
||||
- Subitem icons remain visible (default or admin-uploaded)
|
||||
- Consistent padding/width
|
||||
- Enable/disable inputs should use green/red switch toggles (not plain checkboxes)
|
||||
- /admin/navigation uses drag-and-drop layout with an Advanced JSON editor
|
||||
|
||||
Plugins (important)
|
||||
- Plugin system loads from plugins/ directory
|
||||
- Each plugin:
|
||||
- plugin.json with id, name, version, main (index.js)
|
||||
- index.js exports { id, init(...) }
|
||||
- web.mount("/plugins/<id>", router, navItem) to add nav entry
|
||||
- Plugins should avoid core edits unless explicitly requested
|
||||
- Plugins receive `webhooks` for raw-body inbound endpoint registration and
|
||||
outbound webhook sending. See `docs/webhooks.md`.
|
||||
- `web.addAssistantPanel({ id, view, stylesheet?, script?, role?, getAvailability?(user), version?, locals? })`
|
||||
registers a dynamically mounted sidebar pill/global panel above the user footer.
|
||||
`getAvailability` may be async and must return `{ available, status?, reason_code? }`.
|
||||
The core polls `/api/assistant-panels`, renders HTML only for available panels, and
|
||||
returns a cleanup function that plugins should call when unloading.
|
||||
|
||||
Current notable plugins
|
||||
- echonomy-framework:
|
||||
- Currency framework only (no items/betting)
|
||||
- Banking UI: /profile/banking (plugin)
|
||||
- Response templates with random/weighted replies
|
||||
- Community funds (renamable)
|
||||
- Commands under root (default "coins")
|
||||
- Activity rewards (discord/twitch messages, discord voice) are queued per user per hour and flushed as one hourly transaction note "Activity Reward" with metadata breakdown
|
||||
- echonomy-games:
|
||||
- Uses echonomy-framework currency
|
||||
- Hot Potato / Coinflip / Mystery Box
|
||||
- Per-platform enable + configurable triggers/aliases
|
||||
- Admin UI at /plugins/echonomy-games
|
||||
- Stats stored in echonomy_game_stats (plays, coins won/lost, last played)
|
||||
- moderation:
|
||||
- Global moderation actions, notes, sanctions
|
||||
- Ban/timeout UI at /plugins/moderation
|
||||
- TOs & Bans view at /plugins/moderation/tos-bans
|
||||
- Login gating shows moderation status screen
|
||||
- Evidence uploads stored in data/moderation/evidence (download via /plugins/moderation/evidence/:id)
|
||||
- quotes:
|
||||
- Quote storage/search with WebUI at /plugins/quotes
|
||||
- quotes table fields include quoter_user_id (internal user id) + game_name
|
||||
- Stats provider adds total quotes, top quoters, and top quoted games
|
||||
|
||||
Database schema (core)
|
||||
- mod_role_periods table tracks mod/admin time for Mods List
|
||||
- auto-vc:
|
||||
- Auto VC creation based on lobby channels
|
||||
- Game name detection uses Discord presence PLAYING/STREAMING/COMPETING only
|
||||
- expression-interaction:
|
||||
- Action commands with stats tracking
|
||||
|
||||
Important settings keys (core)
|
||||
- discord: discord_client_id, discord_client_secret, discord_bot_token, discord_guild_id, discord_admin_role_id, discord_mod_role_id, discord_redirect_uri
|
||||
- twitch: twitch_client_id, twitch_client_secret, twitch_bot_username, twitch_bot_oauth, twitch_channels, twitch_redirect_uri
|
||||
- youtube: youtube_client_id, youtube_client_secret, youtube_bot_refresh_token, youtube_bot_channel_id, youtube_redirect_uri
|
||||
- site: site_title, bot_avatar_url, command_prefix
|
||||
- nav: nav_item_icons (map of nav item id -> filename in data/nav-icons), nav_structure (custom sidebar layout)
|
||||
|
||||
Known file locations
|
||||
- Layout partials: src/web/views/partials/layout-top.ejs, layout-bottom.ejs
|
||||
- Global CSS: src/web/public/styles.css
|
||||
- Global JS: src/web/public/app.js
|
||||
- Asset versioning: res.locals.assetVersion (cache-bust for styles/app)
|
||||
- Nav icons: src/web/public/icons/nav (defaults), data/nav-icons (admin uploads)
|
||||
|
||||
Packaging sanity checks (before shipping)
|
||||
- Plugin zips contain plugin.json at root
|
||||
- Core zip contains package.json and src/main.js
|
||||
- No data/ or node_modules included
|
||||
- Update zip placed in updates/
|
||||
|
||||
TODOs / Open questions
|
||||
- Align plugin profile sections with new core hook (replace direct injection if used)
|
||||
- Standardize plugin response templates UI to avoid duplication
|
||||
- Consider centralized search API for profile user lookup
|
||||
- Add docs for web.addProfileSection usage (when stable)
|
||||
222
docs/lumi-ui.md
Normal file
222
docs/lumi-ui.md
Normal file
@ -0,0 +1,222 @@
|
||||
# Lumi UI and theme system
|
||||
|
||||
Lumi UI is the project-native design layer used by every page rendered through the
|
||||
shared EJS layout. It keeps route behavior and page-specific JavaScript separate
|
||||
from visual tokens and reusable components.
|
||||
|
||||
## Files
|
||||
|
||||
- `src/web/public/lumi-tokens.css`: semantic colors, spacing, radii, shadows,
|
||||
typography, compatibility aliases, and reduced-motion behavior.
|
||||
- `src/web/public/lumi-layout.css`: application shell, sidebar, content
|
||||
containers, responsive grids, stacks, clusters, and mobile navigation.
|
||||
- `src/web/public/lumi-components.css`: buttons, forms, cards, tables, lists,
|
||||
badges, alerts, tabs, modals, empty/loading/error states, stateful action
|
||||
buttons, and tooltips.
|
||||
- `src/web/public/lumi-state-button.js` and
|
||||
`src/web/views/partials/state-button.ejs`: reusable multi-state button
|
||||
behavior for submit/loading/success actions.
|
||||
- `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/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/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.
|
||||
- `src/services/themes.js`: built-in themes, custom theme CRUD, validation,
|
||||
migration, fallback handling, and active-theme selection.
|
||||
|
||||
Use `lumi-stack`, `lumi-cluster`, `lumi-split`, `lumi-grid`, `page-header`,
|
||||
`button-group`, `card`, `panel`, `table-wrap`, `empty-state`, `loading-state`,
|
||||
and `status-indicator` before adding one-off layout rules. Preserve existing IDs,
|
||||
field names, data attributes, and JavaScript hooks when restyling a page.
|
||||
|
||||
## Interaction Rules
|
||||
|
||||
Pages should not self-refresh for state or progress changes. Core connection
|
||||
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" }`.
|
||||
|
||||
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
|
||||
requested. It does not replace list contents automatically.
|
||||
|
||||
Forms that represent page settings should add `data-lumi-settings-form`.
|
||||
Action-only forms must not use that attribute. The shared dirty-state layer
|
||||
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.
|
||||
|
||||
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
|
||||
width is based on the widest state and state changes do not shift surrounding
|
||||
layout. Use `.input-action-row` for desktop file/input + action pairs such as
|
||||
ZIP updates and navigation icon uploads; the row stacks on mobile.
|
||||
|
||||
Destructive POST forms should provide context through `data-confirm-title`,
|
||||
`data-confirm-text`, and `data-confirm-label`. Dynamic JavaScript-only
|
||||
destructive actions can call `window.LumiConfirm.destructive({ title, text,
|
||||
label })` to reuse the same modal. The helper keeps vague default confirmation
|
||||
copy out of normal admin flows and returns focus after cancel/confirm.
|
||||
|
||||
Expandable settings rows use `data-lumi-expandable-settings` on a `<details>`
|
||||
container. Preview text can be wired with `data-placeholder-preview="#field-id"`;
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
## Themes
|
||||
|
||||
Lumi ships with six read-only themes: Lumi Default, Lumi Dark, Lumi Light, High
|
||||
Contrast, Midnight, and Soft Aurora. Admins select them from **Admin > Theming**.
|
||||
Built-in themes cannot be renamed, edited, or deleted.
|
||||
|
||||
Open a theme's **More actions** section and duplicate it to create an editable
|
||||
custom theme. Custom themes can be previewed in light and dark mode, saved,
|
||||
applied globally, renamed, duplicated, or deleted. Deleting the active custom
|
||||
theme falls back to Lumi Default.
|
||||
|
||||
The compact editor exposes common colors first. Advanced controls cover
|
||||
background glows, raised surfaces, links, buttons, inputs, focus rings, radius,
|
||||
shadow strength, and spacing scale. Typography controls use constrained font
|
||||
presets plus bounded base-size, heading-scale, and control-density ranges. The
|
||||
server accepts only six-digit hex colors, supported font presets, bounded metric
|
||||
values, and readable text/button/input contrast.
|
||||
|
||||
Draft values are isolated to preview roots. The compact preview and pop-out
|
||||
preview update colors, role colors, metrics, spacing, and typography before
|
||||
save, but the editor shell and live site keep the active saved theme until the
|
||||
admin saves/applies the custom theme. The compact preview is hidden on narrow
|
||||
phone layouts; use the Preview action to open the synchronized pop-out instead.
|
||||
|
||||
The compact preview includes representative headings, pills, cards, buttons,
|
||||
state buttons, inputs, toggles, alerts/statuses, badges, tables, modal samples,
|
||||
dirty-state markers, and spacing samples. The pop-out uses a faithful Lumi page
|
||||
shell with safe sample data so admins can test draft themes against dashboard,
|
||||
settings, logs, AI, and component-like layouts without exposing real admin data.
|
||||
|
||||
Missing or invalid stored values are replaced from the custom theme's built-in
|
||||
base. Existing installations with modified legacy `theme_light_*`,
|
||||
`theme_dark_*`, or `theme_role_*` settings are migrated once into a custom
|
||||
**Migrated Theme** and selected automatically. The legacy `/admin/theming` POST
|
||||
route remains supported and writes into an editable custom theme.
|
||||
|
||||
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.
|
||||
|
||||
## Localhost Login
|
||||
|
||||
Development builds opened from `localhost`, `127.0.0.1`, or `::1` show a
|
||||
**Localhost Login** option. It defaults to username `admin` and password `admin`
|
||||
unless those settings have already been changed. The option is not inserted into
|
||||
the login list, cannot be used, and does not satisfy setup requirements for
|
||||
non-localhost requests.
|
||||
|
||||
Admins can change the localhost username and password from **Admin > Settings**
|
||||
when the settings page itself is accessed through localhost. Leaving the
|
||||
password field blank keeps the existing password.
|
||||
|
||||
## Lumi AI Settings And Feedback
|
||||
|
||||
Lumi AI's main Selected model dropdown lists only installed/downloaded models.
|
||||
If the configured model is missing, the settings page shows a warning and saving
|
||||
requires selecting an installed model. Main context, gate context, and output
|
||||
token budgets use shared presets from Tiny (256) through Extra extended
|
||||
(32768). Unsupported freeform values are rejected server-side.
|
||||
|
||||
AI feedback supports `feedback_kind` values `strict_correction` and
|
||||
`instruction_based`; instruction-based feedback is the default because most
|
||||
reviews are guidance for future replies rather than exact replacement answers.
|
||||
Feedback tags include `wrong_tool_usage` for cases where the model called the
|
||||
wrong tool or failed to call an expected tool. Review, edit, and implementation
|
||||
views show both the kind and tag so admins can tell direct answer corrections
|
||||
from broader tool-calling or instruction guidance.
|
||||
|
||||
Model/runtime downloads and the combined Start/Restart runtime control use the
|
||||
Lumi state button behavior. Enhanced browsers start downloads and runtime
|
||||
actions through fetch, update button state/progress in place, and avoid hard
|
||||
page refreshes; the underlying POST routes remain available for non-JavaScript
|
||||
fallbacks.
|
||||
|
||||
## Homepage Content
|
||||
|
||||
Admins configure homepage external link buttons from Admin > Settings with the
|
||||
Homepage content builder. It writes the existing `homepage_link_buttons` JSON
|
||||
setting behind the scenes. Each entry may include `enabled`, `label`,
|
||||
`description`, `url`, `icon_mode`, `icon_url`, `fetched_favicon_url`,
|
||||
`permission` (`public`, `user`, `mod`, `admin`), and `sort_order`. Entries can
|
||||
be added, duplicated, moved up/down, removed with contextual confirmation,
|
||||
enabled/disabled, and previewed in place. Links open in a new tab with
|
||||
`rel="noopener noreferrer"` and are filtered server-side by permission.
|
||||
|
||||
Admins configure priority-based hero entries with the same builder; it writes
|
||||
the existing `homepage_hero_entries` JSON setting behind the scenes. The
|
||||
homepage renders the first enabled, available entry the current user can access.
|
||||
Hero entries support type, priority/order, permission, source/embed/image URLs,
|
||||
video IDs, availability mode, mutually exclusive autoplay modes, duration
|
||||
fields, fallback behavior, and a live card preview. The builder shows
|
||||
video-only controls only for stream/video types and image/embed/source fields
|
||||
only when they apply. Slow external availability checks are intentionally
|
||||
avoided; entries fail closed if required local configuration is missing.
|
||||
|
||||
## Admin Dashboard And Logs
|
||||
|
||||
The admin dashboard polls `GET /api/admin/dashboard-metrics` for process
|
||||
uptime, memory, plugin counts, content counts, and recent log severity totals.
|
||||
The dashboard renders lightweight SVG graphs using Lumi tokens and does not add
|
||||
a frontend framework dependency.
|
||||
|
||||
The logs page keeps server-side range/severity/limit filters and adds a labeled
|
||||
responsive filter bar with search, reset, refresh, and download actions. Search
|
||||
filters the loaded entries client-side; changing range, severity, or limit
|
||||
reloads the same `/admin/logs` route with query parameters.
|
||||
|
||||
## Updates And Local-Only Files
|
||||
|
||||
Admin update controls use the same progressive action patterns as other Lumi
|
||||
actions. `/admin/updates` is split into Core and Plugins expandable sections
|
||||
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.
|
||||
|
||||
See [Update system](updates.md) and [Recovery mode](recovery-mode.md) for the
|
||||
semver policy, compatibility bridge behavior, snapshots, revert limits, recovery
|
||||
markers, and manual safe-mode triggers.
|
||||
|
||||
The repository ignores local-only coordination and credential artifacts such as
|
||||
`codex-guidelines`, `Twitch.png`, and `twitch-credentials-lumi.png`. Plugin
|
||||
runtime data stays excluded from source control; runtime folders are recreated
|
||||
by the plugin data-directory initializer.
|
||||
|
||||
## Visual references
|
||||
|
||||
- [Home, desktop](screenshots/lumi-home-desktop.png)
|
||||
- [Home, 360px mobile](screenshots/lumi-home-mobile.png)
|
||||
- [Custom theme editor, desktop](screenshots/lumi-theme-editor-desktop.png)
|
||||
- [Custom theme editor, 360px mobile](screenshots/lumi-theme-editor-mobile.png)
|
||||
|
||||
The broad sidebar and content structure remains in place. Theme controls moved
|
||||
from one long raw color form into the Theme Studio library and grouped custom
|
||||
editor; no navigation destination or non-theme control was relocated.
|
||||
54
docs/recovery-mode.md
Normal file
54
docs/recovery-mode.md
Normal file
@ -0,0 +1,54 @@
|
||||
# Lumi Recovery Mode
|
||||
|
||||
Failed updates should leave an administrator with a recovery path. Lumi writes a
|
||||
recovery marker before update files are applied and keeps snapshots available for
|
||||
manual revert.
|
||||
|
||||
## Recovery Marker
|
||||
|
||||
The marker lives at `data/recovery/update-marker.json` and records:
|
||||
|
||||
- target kind and id
|
||||
- from/to versions
|
||||
- source branch
|
||||
- update method
|
||||
- rollback safety
|
||||
- snapshot id when available
|
||||
- status and timestamps
|
||||
|
||||
Statuses include `pending`, `applying`, `verifying`, `completed`, `failed`, and
|
||||
`stale`. A completed marker is cleared after a successful normal startup. If
|
||||
Lumi starts and finds an incomplete marker, it marks it stale so the admin UI and
|
||||
safe-mode UI can show the last attempted update.
|
||||
|
||||
## Manual Safe Mode
|
||||
|
||||
Safe mode can be started with any of these triggers:
|
||||
|
||||
```bash
|
||||
LUMI_SAFE_MODE=1 npm run run
|
||||
node run.js --safe-mode
|
||||
```
|
||||
|
||||
Creating `data/recovery/safe-mode.flag` also makes the wrapper start
|
||||
`safe-mode.js` instead of the full bot. Safe mode loads only the minimum services
|
||||
needed for recovery: config/database, auth/session, static recovery UI,
|
||||
snapshots/revert, plugin disable, and restart controls. Optional plugins,
|
||||
platform clients, AI runtime, scheduled jobs, and non-essential integrations are
|
||||
not loaded.
|
||||
|
||||
## Admin Recovery UI
|
||||
|
||||
The normal **Admin > Updates** page shows a recovery banner when a marker is
|
||||
present. The standalone safe-mode page shows the last attempted target, versions,
|
||||
method, source branch, snapshot id, timestamp, and error.
|
||||
|
||||
Admins can:
|
||||
|
||||
- revert a safe snapshot,
|
||||
- disable a problematic plugin,
|
||||
- clear a stale marker after verifying startup,
|
||||
- retry normal startup.
|
||||
|
||||
Rollback is never automatic. Major-version rollback remains blocked unless the
|
||||
snapshot is explicitly marked rollback safe.
|
||||
BIN
docs/screenshots/lumi-home-desktop.png
Normal file
BIN
docs/screenshots/lumi-home-desktop.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 338 KiB |
BIN
docs/screenshots/lumi-home-mobile.png
Normal file
BIN
docs/screenshots/lumi-home-mobile.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 158 KiB |
BIN
docs/screenshots/lumi-theme-editor-desktop.png
Normal file
BIN
docs/screenshots/lumi-theme-editor-desktop.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 361 KiB |
BIN
docs/screenshots/lumi-theme-editor-mobile.png
Normal file
BIN
docs/screenshots/lumi-theme-editor-mobile.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 372 KiB |
81
docs/updates.md
Normal file
81
docs/updates.md
Normal file
@ -0,0 +1,81 @@
|
||||
# Lumi Updates
|
||||
|
||||
Lumi updates are managed from **Admin > Updates**. The page separates Core and
|
||||
Plugins into expandable sections and uses repository metadata to choose a safe
|
||||
target before any files are changed.
|
||||
|
||||
## Version Policy
|
||||
|
||||
Core and plugin versions use `major.minor.patch`.
|
||||
|
||||
- Patch: fixes only.
|
||||
- Minor: non-breaking features or design changes.
|
||||
- Major: potentially incompatible changes.
|
||||
|
||||
The UI tracks three values separately: current version, latest available
|
||||
version, and safe target version. Same-major updates can target the latest
|
||||
version. Major crossings are blocked unless metadata exposes a compatibility
|
||||
bridge, usually `X.0.0`, or another version marked with
|
||||
`migration_kind: "compatibility_bridge"` and compatible with the current
|
||||
install.
|
||||
|
||||
If the latest version is unsafe, the UI shows the safe target as the actionable
|
||||
version and lists why the absolute latest is not selected.
|
||||
|
||||
Updates to or from an unversioned install or target remain available so an admin
|
||||
can recover directly from the repository. They are treated as manual repo
|
||||
updates: the action button stays enabled, but the UI shows warnings and the
|
||||
confirmation modal states that version ordering, changelog range, and rollback
|
||||
safety cannot be verified from metadata.
|
||||
|
||||
## Metadata
|
||||
|
||||
The updater looks for these files on the source branch:
|
||||
|
||||
- `update-manifest.json` or `lumi.manifest.json`
|
||||
- `CHANGELOG.md` or `changelog.json`
|
||||
- `plugins/<plugin_id>/plugin.json`
|
||||
- `plugins/<plugin_id>/CHANGELOG.md` or `changelog.json`
|
||||
|
||||
Useful metadata fields include `version`, `channel`, `compatible_from`,
|
||||
`migration_kind`, `migration_notes`, `rollback_safe`, `requirements`, `size`,
|
||||
and changelog entries. Missing changelog data is shown as a warning.
|
||||
|
||||
Stable checks read `main` by default. The newest `experimental-*` branch is used
|
||||
only when explicitly selected on the updates page.
|
||||
|
||||
## Snapshots And Revert
|
||||
|
||||
Before a core, plugin, repo, or ZIP update, Lumi creates a snapshot under
|
||||
`data/snapshots/` and records metadata in the snapshot index:
|
||||
|
||||
- target kind/id
|
||||
- from/to versions
|
||||
- source branch
|
||||
- update method
|
||||
- rollback safety
|
||||
- recovery marker id
|
||||
- major-crossing flag
|
||||
|
||||
Manual revert is available for core and individual plugins. Revert actions are
|
||||
limited to the previous-version snapshot for that target. Major-version rollback
|
||||
is blocked unless the snapshot or manifest explicitly marks rollback as safe.
|
||||
|
||||
## ZIP Fallback
|
||||
|
||||
Core and plugin ZIP updates remain available, but they are hidden under
|
||||
**Show advanced ZIP update options**. ZIP updates create snapshots and recovery
|
||||
markers. They may bypass repo metadata and compatibility checks unless the ZIP
|
||||
contains valid manifest data, so use them as a manual fallback.
|
||||
|
||||
## Progress Events
|
||||
|
||||
Admin update actions publish Server-Sent Events through
|
||||
`GET /admin/updates/events`. Event names include `update:checking`,
|
||||
`update:metadata`, `update:snapshot`, `update:recovery_marker`,
|
||||
`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.
|
||||
@ -5,7 +5,8 @@
|
||||
"type": "commonjs",
|
||||
"scripts": {
|
||||
"start": "node src/main.js",
|
||||
"run": "node run.js"
|
||||
"run": "node run.js",
|
||||
"verify:webui": "node scripts/verify-webui.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
|
||||
@ -36,6 +36,8 @@ class CorrectionStore {
|
||||
const entry = {
|
||||
id: crypto.randomUUID(),
|
||||
source_feedback_id: feedback.id,
|
||||
feedback_kind: feedback.feedback_kind || "strict_correction",
|
||||
feedback_tag: feedback.feedback_tag || "",
|
||||
prompt: feedback.user_message,
|
||||
corrected_answer: answer,
|
||||
rejected_answer: feedback.assistant_answer,
|
||||
@ -166,6 +168,9 @@ class CorrectionStore {
|
||||
.filter((entry) => ["correction", "route_alias", "predefined_answer"].includes(entry.target))
|
||||
.map((entry) => [
|
||||
`Reviewed correction for a similar request (minimum role: ${entry.min_role}):`,
|
||||
entry.feedback_kind === "instruction_based" || entry.feedback_tag === "wrong_tool_usage"
|
||||
? `Revision guidance: ${entry.feedback_tag === "wrong_tool_usage" ? "tool-calling behavior" : "instruction"}`
|
||||
: "",
|
||||
`Request: ${entry.prompt}`,
|
||||
`Approved answer: ${entry.corrected_answer}`,
|
||||
entry.expected_link ? `Verified link: ${entry.expected_link}` : ""
|
||||
|
||||
@ -12,9 +12,12 @@ const FEEDBACK_TAGS = Object.freeze([
|
||||
"unsafe",
|
||||
"should_clarify",
|
||||
"bad_code",
|
||||
"wrong_scope"
|
||||
"wrong_scope",
|
||||
"wrong_tool_usage"
|
||||
]);
|
||||
|
||||
const FEEDBACK_KINDS = Object.freeze(["instruction_based", "strict_correction"]);
|
||||
|
||||
class FeedbackStore {
|
||||
constructor(options = {}) {
|
||||
this.file = options.file || resolveData("feedback", "reviews.json");
|
||||
@ -34,6 +37,9 @@ class FeedbackStore {
|
||||
model: clean(input.model, 200),
|
||||
timestamp: validDate(input.timestamp) || new Date().toISOString(),
|
||||
feedback_tag: tag,
|
||||
feedback_kind: FEEDBACK_KINDS.includes(input.feedback_kind)
|
||||
? input.feedback_kind
|
||||
: "instruction_based",
|
||||
optional_correction: clean(input.optional_correction, 16000),
|
||||
status: "pending",
|
||||
submitted_by: String(actor?.id || "anonymous"),
|
||||
@ -70,6 +76,7 @@ class FeedbackStore {
|
||||
return this.mutate(id, (entry) => ({
|
||||
...entry,
|
||||
feedback_tag: FEEDBACK_TAGS.includes(values.feedback_tag) ? values.feedback_tag : entry.feedback_tag,
|
||||
feedback_kind: FEEDBACK_KINDS.includes(values.feedback_kind) ? values.feedback_kind : entry.feedback_kind || "instruction_based",
|
||||
optional_correction: clean(values.optional_correction, 16000),
|
||||
review_notes: clean(values.review_notes, 4000),
|
||||
reviewed_by: String(actor.id),
|
||||
@ -196,6 +203,7 @@ function validDate(value) {
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
FEEDBACK_KINDS,
|
||||
FEEDBACK_TAGS,
|
||||
FeedbackStore,
|
||||
improvementAccess,
|
||||
|
||||
@ -1 +0,0 @@
|
||||
|
||||
@ -25,7 +25,7 @@ const { AiRateLimiter, mergeLimits } = require("./backend/rate_limits");
|
||||
const { buildOriginContext, formatPlatformReply, formatPlatformReplyDetails } = require("./backend/commands");
|
||||
const { AssistantPanelDiagnostics } = require("./backend/assistant_panel_diagnostics");
|
||||
const { formatAssistantResponse } = require("./backend/response_formatter");
|
||||
const { FeedbackStore, FEEDBACK_TAGS, improvementAccess } = require("./backend/feedback");
|
||||
const { FeedbackStore, FEEDBACK_KINDS, FEEDBACK_TAGS, improvementAccess } = require("./backend/feedback");
|
||||
const { CorrectionStore, PROMOTION_TARGETS } = require("./backend/corrections");
|
||||
const { EvalStore } = require("./backend/evals");
|
||||
const { TrainingExporter } = require("./backend/training_export");
|
||||
@ -39,6 +39,18 @@ const storage = require("./backend/storage");
|
||||
const { formatBytes, bytesFromMb, sanityCheckSize } = require("./backend/size_utils");
|
||||
|
||||
const PLUGIN_ID = "lumi_ai";
|
||||
const TOKEN_PRESETS = Object.freeze([
|
||||
{ label: "Tiny (256)", value: 256, description: "Small helper replies and minimal context." },
|
||||
{ label: "Very small (512)", value: 512, description: "Short replies and low memory usage." },
|
||||
{ label: "Small (1024)", value: 1024, description: "Compact answers and lightweight request gates." },
|
||||
{ label: "Short (2048)", value: 2048, description: "Short conversations and normal commands." },
|
||||
{ label: "Medium (4096)", value: 4096, description: "Balanced default for normal assistant use." },
|
||||
{ label: "Large (8192)", value: 8192, description: "Longer conversations and documents." },
|
||||
{ label: "Extended (16384)", value: 16384, description: "Long context when the selected model supports it." },
|
||||
{ label: "Extra extended (32768)", value: 32768, description: "Highest supported local preset for large-context models." }
|
||||
]);
|
||||
const CONTEXT_OPTIONS = TOKEN_PRESETS;
|
||||
const GATE_CONTEXT_OPTIONS = TOKEN_PRESETS.filter((option) => option.value >= 512 && option.value <= 4096);
|
||||
const modelManifest = require("./models_manifest.json");
|
||||
const runtimeManifest = require("./runtime_manifest.json");
|
||||
|
||||
@ -155,6 +167,10 @@ module.exports = {
|
||||
if (config.enabled) {
|
||||
ensureGateRuntime().catch((error) => {
|
||||
metrics.record({ kind: "gate_runtime", status: "failed", reason_code: "gate_start_failed", message: error.message });
|
||||
web.emitEvent?.("ai:model_status", {
|
||||
status: "gate_start_failed",
|
||||
message: `Lumi AI gate runtime failed to start: ${error.message}`
|
||||
}, { role: "admin" });
|
||||
});
|
||||
}
|
||||
const main = await ensureMainRuntime(options);
|
||||
@ -171,6 +187,10 @@ module.exports = {
|
||||
try { await ensureGateRuntime(); }
|
||||
catch (error) {
|
||||
metrics.record({ kind: "gate_runtime", status: "failed", reason_code: "gate_restart_failed", message: error.message });
|
||||
web.emitEvent?.("ai:model_status", {
|
||||
status: "gate_restart_failed",
|
||||
message: `Lumi AI gate runtime restart failed: ${error.message}`
|
||||
}, { role: "admin" });
|
||||
}
|
||||
}
|
||||
return { ...main, gate: await gateRuntime.health() };
|
||||
@ -184,7 +204,12 @@ module.exports = {
|
||||
) return;
|
||||
gateRecoveryPending = true;
|
||||
try { await ensureGateRuntime(); }
|
||||
catch {}
|
||||
catch (error) {
|
||||
web.emitEvent?.("ai:model_status", {
|
||||
status: "gate_recovery_failed",
|
||||
message: `Lumi AI gate recovery failed: ${error.message}`
|
||||
}, { role: "admin" });
|
||||
}
|
||||
finally { gateRecoveryPending = false; }
|
||||
}, 30000);
|
||||
gateMonitor.unref?.();
|
||||
@ -287,17 +312,24 @@ module.exports = {
|
||||
sanityCheckSize("Estimated GPU memory", bytesFromMb(gpuAllocation.estimated_gpu_memory_mb), 100 * 1024 ** 3)
|
||||
].filter((check) => !check.valid);
|
||||
for (const diagnostic of sizeDiagnostics) console.warn(`Lumi AI size diagnostic: ${diagnostic.message}`);
|
||||
const models = modelManifest.models.map((model) => ({
|
||||
...model,
|
||||
downloaded: fs.existsSync(resolveData("models", model.filename)),
|
||||
installed_size: fs.existsSync(resolveData("models", model.filename))
|
||||
? fs.statSync(resolveData("models", model.filename)).size
|
||||
: 0,
|
||||
compatible: model.ram_gb * 1024 <= hardware.total_ram_mb && model.size / 1048576 <= hardware.free_disk_mb
|
||||
}));
|
||||
const installedModels = models.filter((model) => model.downloaded);
|
||||
res.render(path.join(__dirname, "views", "settings.ejs"), {
|
||||
title: "Lumi AI",
|
||||
config,
|
||||
models: modelManifest.models.map((model) => ({
|
||||
...model,
|
||||
downloaded: fs.existsSync(resolveData("models", model.filename)),
|
||||
installed_size: fs.existsSync(resolveData("models", model.filename))
|
||||
? fs.statSync(resolveData("models", model.filename)).size
|
||||
: 0,
|
||||
compatible: model.ram_gb * 1024 <= hardware.total_ram_mb && model.size / 1048576 <= hardware.free_disk_mb
|
||||
})),
|
||||
models,
|
||||
installedModels,
|
||||
selectedModelInstalled: installedModels.some((model) => model.id === config.selected_model_id),
|
||||
contextOptions: CONTEXT_OPTIONS,
|
||||
tokenPresets: TOKEN_PRESETS,
|
||||
gateContextOptions: GATE_CONTEXT_OPTIONS,
|
||||
runtimeTarget,
|
||||
runtimeManifest,
|
||||
runtimeStatus,
|
||||
@ -338,9 +370,32 @@ module.exports = {
|
||||
if (!req.session.user?.isAdmin) return denied(res);
|
||||
const model = getModel(req.body.selected_model_id);
|
||||
if (!model) return flash(req, res, "error", "Unknown model.");
|
||||
const contextSize = boundedInt(req.body.context_size, 512, 131072, 4096);
|
||||
if (!fs.existsSync(resolveData("models", model.filename))) {
|
||||
return flash(req, res, "error", "Selected model must be installed before it can be saved.");
|
||||
}
|
||||
const contextValues = CONTEXT_OPTIONS.map((option) => option.value);
|
||||
const requestedContext = Number(req.body.context_size);
|
||||
if (!contextValues.includes(requestedContext)) {
|
||||
return flash(req, res, "error", "Choose a supported AI context size.");
|
||||
}
|
||||
const contextSize = requestedContext;
|
||||
const tokenValues = TOKEN_PRESETS.map((option) => option.value);
|
||||
const gateContextValues = GATE_CONTEXT_OPTIONS.map((option) => option.value);
|
||||
const requestedGateContext = Number(req.body.gate_context_size);
|
||||
if (!gateContextValues.includes(requestedGateContext)) {
|
||||
return flash(req, res, "error", "Choose a supported gate context size.");
|
||||
}
|
||||
const presetToken = (field, fallback, label) => {
|
||||
const value = Number(req.body[field]);
|
||||
if (!tokenValues.includes(value)) {
|
||||
throw new Error(`Choose a supported preset for ${label}.`);
|
||||
}
|
||||
return value || fallback;
|
||||
};
|
||||
const previousConfig = config;
|
||||
config = saveConfig({
|
||||
let nextConfig;
|
||||
try {
|
||||
nextConfig = {
|
||||
...config,
|
||||
enabled: req.body.enabled === "on",
|
||||
selected_model_id: model.id,
|
||||
@ -353,13 +408,13 @@ module.exports = {
|
||||
request_timeout_ms: boundedInt(req.body.hard_generation_timeout_ms, 30000, 3600000, 600000),
|
||||
ui_soft_timeout_ms: boundedInt(req.body.ui_soft_timeout_ms, 5000, 300000, 45000),
|
||||
hard_generation_timeout_ms: boundedInt(req.body.hard_generation_timeout_ms, 30000, 3600000, 600000),
|
||||
max_output_tokens: boundedInt(req.body.max_output_tokens, 64, 32768, 2048),
|
||||
max_output_tokens: presetToken("max_output_tokens", 2048, "API/test output tokens"),
|
||||
output_budgets: {
|
||||
navigation_help: boundedInt(req.body.output_budget_navigation_help, 64, 32768, 256),
|
||||
simple_answer: boundedInt(req.body.output_budget_simple_answer, 64, 32768, 512),
|
||||
code_custom_command: boundedInt(req.body.output_budget_code_custom_command, 64, 32768, 896),
|
||||
admin_debug: boundedInt(req.body.output_budget_admin_debug, 64, 32768, 1280),
|
||||
explicit_long: boundedInt(req.body.output_budget_explicit_long, 64, 32768, 2048)
|
||||
navigation_help: presetToken("output_budget_navigation_help", 256, "navigation/help tokens"),
|
||||
simple_answer: presetToken("output_budget_simple_answer", 512, "simple answer tokens"),
|
||||
code_custom_command: presetToken("output_budget_code_custom_command", 1024, "code/custom command tokens"),
|
||||
admin_debug: presetToken("output_budget_admin_debug", 2048, "admin debug tokens"),
|
||||
explicit_long: presetToken("output_budget_explicit_long", 4096, "explicit long-answer tokens")
|
||||
},
|
||||
batch_size: boundedInt(req.body.batch_size, 32, 4096, 512),
|
||||
ubatch_size: boundedInt(req.body.ubatch_size, 16, 4096, 128),
|
||||
@ -411,7 +466,7 @@ module.exports = {
|
||||
gate: {
|
||||
...config.gate,
|
||||
model_id: getModel(req.body.gate_model_id)?.id || config.gate.model_id,
|
||||
context_size: boundedInt(req.body.gate_context_size, 512, 4096, 1024),
|
||||
context_size: requestedGateContext,
|
||||
threads: boundedInt(req.body.gate_threads, 1, 16, 2),
|
||||
timeout_ms: boundedInt(req.body.gate_timeout_ms, 1000, 5000, 3000),
|
||||
high_confidence_threshold: boundedNumber(req.body.gate_high_confidence_threshold, 0.5, 0.99, 0.88),
|
||||
@ -457,7 +512,11 @@ module.exports = {
|
||||
trusted_moderator_reviewers: parseIdList(req.body.trusted_moderator_reviewers),
|
||||
corrections_enabled: req.body.corrections_enabled === "on"
|
||||
}
|
||||
});
|
||||
};
|
||||
} catch (error) {
|
||||
return flash(req, res, "error", error.message);
|
||||
}
|
||||
config = saveConfig(nextConfig);
|
||||
registerAssistantCommands({
|
||||
commandRouter,
|
||||
provider,
|
||||
@ -493,11 +552,15 @@ module.exports = {
|
||||
|
||||
router.post("/download/runtime", (req, res) => {
|
||||
if (!req.session.user?.isAdmin) return denied(res);
|
||||
const wantsJson = req.accepts(["json", "html"]) === "json";
|
||||
const hardware = detectHardware(modelManifest.models, runtimeManifest);
|
||||
const target = getRuntimeTarget(hardware);
|
||||
if (!target) return flash(req, res, "error", "No managed llama.cpp runtime is available for this platform.");
|
||||
if (!target) {
|
||||
if (wantsJson) return res.status(400).json({ error: "No managed llama.cpp runtime is available for this platform." });
|
||||
return flash(req, res, "error", "No managed llama.cpp runtime is available for this platform.");
|
||||
}
|
||||
try {
|
||||
downloads.start({
|
||||
const job = downloads.start({
|
||||
id: "runtime",
|
||||
...target,
|
||||
kind: "runtime",
|
||||
@ -508,29 +571,37 @@ module.exports = {
|
||||
target: target.filename
|
||||
}
|
||||
});
|
||||
if (wantsJson) return res.json({ success: true, job });
|
||||
return flash(req, res, "success", `${String(target.backend || "CPU").toUpperCase()} runtime download started.`);
|
||||
} catch (error) {
|
||||
if (wantsJson) return res.status(400).json({ error: error.message });
|
||||
return flash(req, res, "error", error.message);
|
||||
}
|
||||
});
|
||||
|
||||
router.post("/download/model/:id", (req, res) => {
|
||||
if (!req.session.user?.isAdmin) return denied(res);
|
||||
const wantsJson = req.accepts(["json", "html"]) === "json";
|
||||
const model = getModel(req.params.id);
|
||||
if (!model) return flash(req, res, "error", "Unknown model.");
|
||||
if (!model) {
|
||||
if (wantsJson) return res.status(404).json({ error: "Unknown model." });
|
||||
return flash(req, res, "error", "Unknown model.");
|
||||
}
|
||||
if (
|
||||
(model.id === config.selected_model_id && runtime.status().state === "running") ||
|
||||
(model.id === config.gate.model_id && gateRuntime.status().state === "running")
|
||||
) {
|
||||
if (wantsJson) return res.status(400).json({ error: "Stop the AI runtimes before replacing an active model." });
|
||||
return flash(req, res, "error", "Stop the AI runtimes before replacing an active model.");
|
||||
}
|
||||
const hardware = detectHardware(modelManifest.models);
|
||||
const incompatible = model.ram_gb * 1024 > hardware.total_ram_mb || model.size / 1048576 > hardware.free_disk_mb;
|
||||
if (incompatible && req.body.override_compatibility !== "on") {
|
||||
if (wantsJson) return res.status(400).json({ error: "This model exceeds detected RAM or free disk. Check override to download anyway." });
|
||||
return flash(req, res, "error", "This model exceeds detected RAM or free disk. Check override to download anyway.");
|
||||
}
|
||||
try {
|
||||
downloads.start({
|
||||
const job = downloads.start({
|
||||
id: `model:${model.id}`,
|
||||
url: `https://huggingface.co/${model.repo}/resolve/${model.revision}/${model.filename}`,
|
||||
filename: model.filename,
|
||||
@ -538,8 +609,10 @@ module.exports = {
|
||||
size: model.size,
|
||||
kind: "model"
|
||||
});
|
||||
if (wantsJson) return res.json({ success: true, job });
|
||||
return flash(req, res, "success", `${model.label} download started.`);
|
||||
} catch (error) {
|
||||
if (wantsJson) return res.status(400).json({ error: error.message });
|
||||
return flash(req, res, "error", error.message);
|
||||
}
|
||||
});
|
||||
@ -891,6 +964,7 @@ module.exports = {
|
||||
model: req.body.model,
|
||||
timestamp: req.body.timestamp,
|
||||
feedback_tag: req.body.feedback_tag,
|
||||
feedback_kind: req.body.feedback_kind,
|
||||
optional_correction: req.body.optional_correction
|
||||
}, req.session.user);
|
||||
return res.status(201).json({ success: true, id: entry.id });
|
||||
@ -1148,6 +1222,7 @@ module.exports = {
|
||||
config,
|
||||
access,
|
||||
feedbackTags: FEEDBACK_TAGS,
|
||||
feedbackKinds: FEEDBACK_KINDS,
|
||||
promotionTargets: PROMOTION_TARGETS,
|
||||
reviews: feedbackStore.list({
|
||||
page: req.query.review_page,
|
||||
|
||||
@ -8,7 +8,7 @@
|
||||
.lumi-ai-state.ready { background: #2ea043; box-shadow: 0 0 0 3px color-mix(in srgb, #2ea043 18%, transparent); }
|
||||
.lumi-ai-state.warming { background: #d29922; box-shadow: 0 0 0 3px color-mix(in srgb, #d29922 18%, transparent); }
|
||||
.lumi-ai-state.error { background: #d73a49; box-shadow: 0 0 0 3px color-mix(in srgb, #d73a49 18%, transparent); }
|
||||
.lumi-ai-panel { position: fixed; z-index: 1; left: calc(var(--sidebar-width, 260px) + 14px); right: 14px; top: var(--lumi-ai-top, calc(100vh - 16.666vh - 14px)); height: max(180px, 16.666vh); min-height: 180px; max-height: calc(100vh - 16px); display: grid; grid-template-rows: 8px auto 1fr auto auto; overflow: hidden; border: 1px solid var(--border); border-radius: 8px; background: var(--card); box-shadow: 0 18px 55px rgba(0,0,0,.22); opacity: 0; transform: translateY(100%); pointer-events: none; transition: transform 0.25s ease-in-out, opacity 0.25s ease-in-out; }
|
||||
.lumi-ai-panel { position: fixed; z-index: 1; left: calc(var(--sidebar-width, 260px) + 14px); right: 14px; bottom: 14px; height: max(180px, 16.666vh); min-height: 180px; max-height: calc(100vh - 96px); display: grid; grid-template-rows: 8px auto 1fr auto auto; overflow: hidden; border: 1px solid var(--border); border-radius: 8px; background: var(--card); box-shadow: 0 18px 55px rgba(0,0,0,.22); opacity: 0; transform: translateY(100%); pointer-events: none; transition: transform 0.25s ease-in-out, opacity 0.25s ease-in-out; }
|
||||
.lumi-ai-panel.open { opacity: 1; transform: translateY(0); pointer-events: auto; }
|
||||
.lumi-ai-resize-handle { position: relative; cursor: ns-resize; background: var(--surface-2); touch-action: none; }
|
||||
.lumi-ai-resize-handle::after { content: ""; position: absolute; top: 3px; left: 50%; width: 42px; height: 2px; transform: translateX(-50%); border-radius: 2px; background: var(--border); }
|
||||
|
||||
@ -65,15 +65,8 @@
|
||||
};
|
||||
|
||||
const positionPanel = (height = panelHeight()) => {
|
||||
const viewportHeight = window.innerHeight;
|
||||
const footerRect = document.querySelector(".site-footer")?.getBoundingClientRect();
|
||||
const bottomLimit = footerRect && footerRect.top < viewportHeight && footerRect.bottom > 0
|
||||
? Math.max(MIN_HEIGHT + 8, footerRect.top - 8)
|
||||
: viewportHeight - 8;
|
||||
const maximum = Math.max(MIN_HEIGHT, bottomLimit - 8);
|
||||
const maximum = Math.max(MIN_HEIGHT, window.innerHeight - 96);
|
||||
const clampedHeight = Math.min(maximum, Math.max(MIN_HEIGHT, height));
|
||||
const top = Math.max(8, bottomLimit - clampedHeight);
|
||||
panel.style.setProperty("--lumi-ai-top", `${top}px`);
|
||||
panel.style.height = `${clampedHeight}px`;
|
||||
return clampedHeight;
|
||||
};
|
||||
@ -296,21 +289,33 @@
|
||||
"unsafe",
|
||||
"should_clarify",
|
||||
"bad_code",
|
||||
"wrong_scope"
|
||||
"wrong_scope",
|
||||
"wrong_tool_usage"
|
||||
]) {
|
||||
const option = document.createElement("option");
|
||||
option.value = tag;
|
||||
option.textContent = tag.replaceAll("_", " ");
|
||||
select.append(option);
|
||||
}
|
||||
const kind = document.createElement("select");
|
||||
kind.setAttribute("aria-label", "Feedback type");
|
||||
for (const [value, label] of [
|
||||
["instruction_based", "Instruction-based guidance"],
|
||||
["strict_correction", "Strict correction"]
|
||||
]) {
|
||||
const option = document.createElement("option");
|
||||
option.value = value;
|
||||
option.textContent = label;
|
||||
kind.append(option);
|
||||
}
|
||||
const correction = document.createElement("input");
|
||||
correction.maxLength = 16000;
|
||||
correction.placeholder = "Optional correction";
|
||||
correction.setAttribute("aria-label", "Optional correction");
|
||||
correction.placeholder = "Correction or instruction for future replies";
|
||||
correction.setAttribute("aria-label", "Correction or instruction");
|
||||
const submitFeedback = document.createElement("button");
|
||||
submitFeedback.type = "submit";
|
||||
submitFeedback.textContent = "Send feedback";
|
||||
controls.append(select, correction, submitFeedback);
|
||||
controls.append(select, kind, correction, submitFeedback);
|
||||
controls.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
submitFeedback.disabled = true;
|
||||
@ -321,6 +326,7 @@
|
||||
body: JSON.stringify({
|
||||
...context,
|
||||
feedback_tag: select.value,
|
||||
feedback_kind: kind.value,
|
||||
optional_correction: correction.value.trim()
|
||||
})
|
||||
});
|
||||
|
||||
@ -7,27 +7,66 @@
|
||||
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]");
|
||||
if (actions) {
|
||||
const syncPrimary = (nextState) => {
|
||||
if (!runtimePrimary || !window.LumiStateButton) return;
|
||||
const normalized = String(nextState || state?.textContent || "").toLowerCase();
|
||||
window.LumiStateButton.setState(runtimePrimary, normalized === "running" ? "running" : "idle");
|
||||
};
|
||||
actions.addEventListener("click", async (event) => {
|
||||
const button = event.target.closest("[data-runtime-action]");
|
||||
const primaryButton = event.target.closest("[data-runtime-primary]");
|
||||
const button = primaryButton || event.target.closest("[data-runtime-action]");
|
||||
if (!button) return;
|
||||
const action = primaryButton
|
||||
? (String(state?.textContent || "").trim().toLowerCase() === "running" ? "restart" : "start")
|
||||
: button.dataset.runtimeAction;
|
||||
if (!action) return;
|
||||
button.disabled = true;
|
||||
if (primaryButton && window.LumiStateButton) {
|
||||
window.LumiStateButton.setState(button, action === "restart" ? "restarting" : "starting", { busy: true });
|
||||
}
|
||||
try {
|
||||
const response = await fetch(`/plugins/lumi_ai/runtime/${button.dataset.runtimeAction}`, { method: "POST" });
|
||||
const response = await fetch(`/plugins/lumi_ai/runtime/${action}`, { method: "POST" });
|
||||
const data = await response.json();
|
||||
if (!response.ok) throw new Error(data.error || "Runtime action failed.");
|
||||
if (data.state) state.textContent = data.state;
|
||||
if (["self-test", "verify-runtime", "verify-model", "verify-gate-model"].includes(button.dataset.runtimeAction)) {
|
||||
syncPrimary(data.state);
|
||||
if (["self-test", "verify-runtime", "verify-model", "verify-gate-model"].includes(action)) {
|
||||
const labels = { "self-test": "Runtime self-test passed.", "verify-runtime": "Runtime installation verified.", "verify-model": "Model verification passed.", "verify-gate-model": "Gate model verification passed." };
|
||||
window.alert(labels[button.dataset.runtimeAction]);
|
||||
window.alert(labels[action]);
|
||||
}
|
||||
} catch (error) {
|
||||
if (primaryButton && window.LumiStateButton) window.LumiStateButton.error(button);
|
||||
window.alert(error.message);
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
document.querySelectorAll("[data-ai-download-form]").forEach((form) => {
|
||||
form.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
const button = form.querySelector("[data-ai-download-button]");
|
||||
window.LumiStateButton?.setState(button, "loading", { busy: true });
|
||||
try {
|
||||
const response = await fetch(form.action, {
|
||||
method: "POST",
|
||||
headers: { "Accept": "application/json" },
|
||||
body: new FormData(form)
|
||||
});
|
||||
const data = await response.json();
|
||||
if (!response.ok) throw new Error(data.error || "Download failed to start.");
|
||||
pollDownloads();
|
||||
} catch (error) {
|
||||
window.LumiStateButton?.error(button);
|
||||
if (downloadStatus) {
|
||||
downloadStatus.hidden = false;
|
||||
downloadStatus.textContent = error.message;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
const pollDownloads = async () => {
|
||||
if (!downloadStatus) return;
|
||||
try {
|
||||
@ -41,9 +80,19 @@
|
||||
const percent = job.total ? Math.floor(job.downloaded / job.total * 100) : 0;
|
||||
return `${job.id}: ${job.state}${job.total ? ` ${percent}%` : ""}${job.error ? ` - ${job.error}` : ""}`;
|
||||
}).join(" | ");
|
||||
jobs.forEach((job) => {
|
||||
const button = document.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");
|
||||
else window.LumiStateButton.setState(button, "loading", { busy: true });
|
||||
const label = button.querySelector('[data-state-view="loading"] span:last-child');
|
||||
if (label && job.total) label.textContent = `Downloading ${Math.floor(job.downloaded / job.total * 100)}%`;
|
||||
});
|
||||
if (active.length) window.setTimeout(pollDownloads, 1000);
|
||||
} catch {}
|
||||
};
|
||||
pollDownloads();
|
||||
if (testForm && testOutput) {
|
||||
const updateTestToolsNotice = () => {
|
||||
if (!testToolsNotice) return;
|
||||
@ -162,6 +211,7 @@
|
||||
workload.addEventListener("change", refreshCapacity);
|
||||
model.addEventListener("change", refreshCapacity);
|
||||
context.addEventListener("input", scheduleCapacity);
|
||||
context.addEventListener("change", refreshCapacity);
|
||||
refreshCapacity();
|
||||
}
|
||||
if (accessForm) {
|
||||
|
||||
@ -43,14 +43,14 @@
|
||||
<% reviews.entries.forEach((review) => { %>
|
||||
<article class="improvement-card">
|
||||
<header>
|
||||
<div><strong><%= review.feedback_tag %></strong> <span class="ai-tag"><%= review.status %></span></div>
|
||||
<div><strong><%= review.feedback_tag %></strong> <span class="ai-tag"><%= review.feedback_kind || "strict_correction" %></span> <span class="ai-tag"><%= review.status %></span></div>
|
||||
<span><%= formatDate(review.timestamp) %> · <%= review.role %> · <%= review.platform %> · <%= review.route_used || "unknown route" %></span>
|
||||
</header>
|
||||
<div class="improvement-pair">
|
||||
<div><span>User message</span><pre><%= review.user_message %></pre></div>
|
||||
<div><span>Assistant answer</span><pre><%= review.assistant_answer %></pre></div>
|
||||
</div>
|
||||
<% if (review.optional_correction) { %><div class="improvement-correction"><strong>Suggested correction</strong><pre><%= review.optional_correction %></pre></div><% } %>
|
||||
<% if (review.optional_correction) { %><div class="improvement-correction"><strong><%= review.feedback_kind === "instruction_based" ? "Instruction guidance" : "Suggested correction" %></strong><pre><%= review.optional_correction %></pre></div><% } %>
|
||||
<% if (review.review_notes) { %><p class="hint"><strong>Review notes:</strong> <%= review.review_notes %></p><% } %>
|
||||
<div class="improvement-actions">
|
||||
<% if (access.can_flag) { %>
|
||||
@ -93,7 +93,8 @@
|
||||
<form method="post" action="/plugins/lumi_ai/improvement_center/reviews/<%= review.id %>" class="form-grid ai-form">
|
||||
<input type="hidden" name="action" value="edit" />
|
||||
<div class="field"><label>Feedback tag</label><select name="feedback_tag"><% feedbackTags.forEach((tag) => { %><option value="<%= tag %>" <%= tag === review.feedback_tag ? "selected" : "" %>><%= tag %></option><% }) %></select></div>
|
||||
<div class="field full"><label>Optional correction</label><textarea name="optional_correction" rows="7"><%= review.optional_correction %></textarea></div>
|
||||
<div class="field"><label>Feedback type</label><select name="feedback_kind"><% feedbackKinds.forEach((kind) => { %><option value="<%= kind %>" <%= kind === (review.feedback_kind || "instruction_based") ? "selected" : "" %>><%= kind.replaceAll("_", " ") %></option><% }) %></select></div>
|
||||
<div class="field full"><label>Correction or instruction</label><textarea name="optional_correction" rows="7"><%= review.optional_correction %></textarea></div>
|
||||
<div class="field full"><label>Review notes</label><textarea name="review_notes" rows="3"><%= review.review_notes %></textarea></div>
|
||||
<div class="field full improvement-actions"><button class="button" type="submit">Save review</button><button class="button subtle" type="button" data-close-dialog>Cancel</button></div>
|
||||
</form>
|
||||
@ -105,7 +106,7 @@
|
||||
<form method="post" action="/plugins/lumi_ai/improvement_center/reviews/<%= review.id %>/implement" class="form-grid ai-form">
|
||||
<div class="field"><label>Promotion target</label><select name="target"><% promotionTargets.forEach((target) => { %><option value="<%= target %>"><%= target.replaceAll("_", " ") %></option><% }) %></select></div>
|
||||
<div class="field"><label>Minimum role</label><select name="min_role"><% ["user", "mod", "admin"].forEach((role) => { %><option value="<%= role %>" <%= role === review.role ? "selected" : "" %>><%= role %></option><% }) %></select></div>
|
||||
<div class="field full"><label>Corrected / expected answer</label><textarea name="corrected_answer" rows="7" required><%= review.optional_correction %></textarea></div>
|
||||
<div class="field full"><label><%= review.feedback_kind === "instruction_based" ? "Instruction to apply" : "Corrected / expected answer" %></label><textarea name="corrected_answer" rows="7" required><%= review.optional_correction %></textarea></div>
|
||||
<div class="field"><label>Origin scope</label><input name="permission_origin" value="<%= review.origin || "any" %>" /></div>
|
||||
<div class="field"><label>Platform scope</label><input name="permission_platform" value="<%= review.platform || "any" %>" /></div>
|
||||
<div class="field"><label>Route alias</label><input name="route_alias" /></div>
|
||||
|
||||
@ -1,5 +1,17 @@
|
||||
<%- include("../../../src/web/views/partials/layout-top", { title }) %>
|
||||
<link rel="stylesheet" href="/plugins/lumi_ai/assets/settings.css?v=<%= assetVersion %>" />
|
||||
<%
|
||||
const renderPresetOptions = (options, current) => {
|
||||
const value = Number(current);
|
||||
const hasValue = options.some((option) => option.value === value);
|
||||
let html = "";
|
||||
if (!hasValue && Number.isFinite(value)) {
|
||||
html += `<option value="${value}" selected disabled>Unsupported current value (${value})</option>`;
|
||||
}
|
||||
html += options.map((option) => `<option value="${option.value}" ${option.value === value ? "selected" : ""}>${option.label}</option>`).join("");
|
||||
return html;
|
||||
};
|
||||
%>
|
||||
|
||||
<section class="ai-titlebar">
|
||||
<div>
|
||||
@ -70,19 +82,39 @@
|
||||
<form method="post" action="/plugins/lumi_ai/models/<%= model.id %>/verify">
|
||||
<button class="button subtle" type="submit">Verify</button>
|
||||
</form>
|
||||
<form method="post" action="/plugins/lumi_ai/download/model/<%= model.id %>">
|
||||
<button class="button subtle" type="submit">Redownload</button>
|
||||
<form method="post" action="/plugins/lumi_ai/download/model/<%= model.id %>" data-ai-download-form data-download-id="model:<%= model.id %>">
|
||||
<%- include("../../../src/web/views/partials/state-button", {
|
||||
type: "submit",
|
||||
classes: "subtle",
|
||||
attrs: `data-ai-download-button data-download-id="model:${model.id}"`,
|
||||
states: [
|
||||
{ id: "idle", text: "Redownload" },
|
||||
{ id: "loading", text: "Downloading", spinner: true },
|
||||
{ id: "success", text: "Downloaded" },
|
||||
{ id: "error", text: "Retry" }
|
||||
]
|
||||
}) %>
|
||||
</form>
|
||||
<form method="post" action="/plugins/lumi_ai/models/<%= model.id %>/delete" data-confirm-form="Delete <%= model.label %> and recover <%= formatBytes(model.installed_size || model.size) %>?">
|
||||
<form method="post" action="/plugins/lumi_ai/models/<%= model.id %>/delete" data-confirm-title="Delete model" data-confirm-text="Delete <%= model.label %> and recover <%= formatBytes(model.installed_size || model.size) %>?" data-confirm-label="Delete model">
|
||||
<input type="hidden" name="confirm" value="yes" />
|
||||
<button class="button danger" type="submit" <%= (model.id === config.selected_model_id && runtimeStatus.state === "running") || (model.id === config.gate.model_id && gateStatus.state === "running") ? "disabled title='Stop the AI runtimes before deleting an active model'" : "" %>>Delete</button>
|
||||
</form>
|
||||
<% } else { %>
|
||||
<form method="post" action="/plugins/lumi_ai/download/model/<%= model.id %>">
|
||||
<form method="post" action="/plugins/lumi_ai/download/model/<%= model.id %>" data-ai-download-form data-download-id="model:<%= model.id %>">
|
||||
<% if (!model.compatible) { %>
|
||||
<label title="Allow download despite detected capacity"><input type="checkbox" name="override_compatibility" /> Override</label>
|
||||
<% } %>
|
||||
<button class="button subtle" type="submit">Download</button>
|
||||
<%- include("../../../src/web/views/partials/state-button", {
|
||||
type: "submit",
|
||||
classes: "subtle",
|
||||
attrs: `data-ai-download-button data-download-id="model:${model.id}"`,
|
||||
states: [
|
||||
{ id: "idle", text: "Download" },
|
||||
{ id: "loading", text: "Downloading", spinner: true },
|
||||
{ id: "success", text: "Downloaded" },
|
||||
{ id: "error", text: "Retry" }
|
||||
]
|
||||
}) %>
|
||||
</form>
|
||||
<% } %>
|
||||
</div>
|
||||
@ -95,12 +127,25 @@
|
||||
<div class="ai-section-heading">
|
||||
<div><h2>Runtime</h2><p>Official llama.cpp release, bound to localhost and stored inside this plugin.</p></div>
|
||||
<div class="ai-actions" data-ai-runtime-actions>
|
||||
<button class="button" type="button" data-runtime-action="start">Start</button>
|
||||
<%- include("../../../src/web/views/partials/state-button", {
|
||||
type: "button",
|
||||
attrs: "data-runtime-primary",
|
||||
loadingState: "starting",
|
||||
successState: "running",
|
||||
errorState: "error",
|
||||
defaultState: runtimeStatus.state === "running" ? "running" : "idle",
|
||||
states: [
|
||||
{ id: "idle", text: "Start" },
|
||||
{ id: "starting", text: "Starting", spinner: true },
|
||||
{ id: "running", text: "Restart" },
|
||||
{ id: "restarting", text: "Restarting", spinner: true },
|
||||
{ id: "error", text: "Retry" }
|
||||
]
|
||||
}) %>
|
||||
<button class="button subtle" type="button" data-runtime-action="self-test">Run self-test</button>
|
||||
<button class="button subtle" type="button" data-runtime-action="verify-runtime">Verify runtime</button>
|
||||
<button class="button subtle" type="button" data-runtime-action="verify-model">Verify model</button>
|
||||
<button class="button subtle" type="button" data-runtime-action="verify-gate-model">Verify gate model</button>
|
||||
<button class="button subtle" type="button" data-runtime-action="restart">Restart</button>
|
||||
<button class="button danger" type="button" data-runtime-action="stop">Stop</button>
|
||||
</div>
|
||||
</div>
|
||||
@ -138,8 +183,18 @@
|
||||
<% if (runtimeTarget) { %>
|
||||
<p><strong>Managed <%= String(runtimeTarget.backend || "cpu").toUpperCase() %> release <%= runtimeManifest?.version || "b9592" %></strong></p>
|
||||
<p class="hint"><%= runtimeTarget.filename %> · <%= formatBytes(runtimeTarget.size) %></p>
|
||||
<form method="post" action="/plugins/lumi_ai/download/runtime">
|
||||
<button class="button subtle" type="submit"><%= runtimeStatus.runtime_installed ? "Reinstall runtime" : "Download runtime" %></button>
|
||||
<form method="post" action="/plugins/lumi_ai/download/runtime" data-ai-download-form data-download-id="runtime">
|
||||
<%- include("../../../src/web/views/partials/state-button", {
|
||||
type: "submit",
|
||||
classes: "subtle",
|
||||
attrs: "data-ai-download-button data-download-id=\"runtime\"",
|
||||
states: [
|
||||
{ id: "idle", text: runtimeStatus.runtime_installed ? "Reinstall runtime" : "Download runtime" },
|
||||
{ id: "loading", text: "Downloading", spinner: true },
|
||||
{ id: "success", text: "Downloaded" },
|
||||
{ id: "error", text: "Retry" }
|
||||
]
|
||||
}) %>
|
||||
</form>
|
||||
<% } else { %>
|
||||
<div class="callout">No managed runtime build is available for this OS and architecture.</div>
|
||||
@ -189,7 +244,7 @@
|
||||
<div><span><%= category.replace("_", " ") %></span><strong><%= formatBytes(bytes) %></strong></div>
|
||||
<% }) %>
|
||||
</div>
|
||||
<form method="post" action="/plugins/lumi_ai/storage/cleanup" class="ai-cleanup-form" data-confirm-form="Delete the selected plugin-local storage categories?">
|
||||
<form method="post" action="/plugins/lumi_ai/storage/cleanup" class="ai-cleanup-form" data-confirm-title="Clean AI storage" data-confirm-text="Delete the selected plugin-local storage categories?" data-confirm-label="Clean selected">
|
||||
<label><input type="checkbox" name="categories" value="unused_models" /> Unused models</label>
|
||||
<label><input type="checkbox" name="categories" value="runtime_archives" /> Runtime archives</label>
|
||||
<label><input type="checkbox" name="categories" value="logs" /> Old logs</label>
|
||||
@ -202,7 +257,7 @@
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<form method="post" action="/plugins/lumi_ai/settings">
|
||||
<form method="post" action="/plugins/lumi_ai/settings" data-lumi-settings-form>
|
||||
<section class="ai-band" id="assistant">
|
||||
<div class="ai-section-heading">
|
||||
<div><h2>Assistant</h2><p>Configuration remains admin-only. Visibility controls only the sidebar assistant.</p></div>
|
||||
@ -222,9 +277,24 @@
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="selected-model">Selected model</label>
|
||||
<select id="selected-model" name="selected_model_id" data-gpu-model><% models.forEach((model) => { %><option value="<%= model.id %>" <%= model.id === config.selected_model_id ? "selected" : "" %>><%= model.label %></option><% }) %></select>
|
||||
<% if (!selectedModelInstalled) { %>
|
||||
<div class="callout danger">The currently selected model is not installed. Choose an installed model before saving.</div>
|
||||
<% } %>
|
||||
<select id="selected-model" name="selected_model_id" data-gpu-model required>
|
||||
<% if (!installedModels.length) { %>
|
||||
<option value="">No installed models available</option>
|
||||
<% } %>
|
||||
<% installedModels.forEach((model) => { %><option value="<%= model.id %>" <%= model.id === config.selected_model_id ? "selected" : "" %>><%= model.label %></option><% }) %>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Context size</label>
|
||||
<select name="context_size" data-gpu-context>
|
||||
<% contextOptions.forEach((option) => { %>
|
||||
<option value="<%= option.value %>" <%= Number(config.context_size) === option.value ? "selected" : "" %>><%= option.label %> - <%= option.description %></option>
|
||||
<% }) %>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field"><label>Context size</label><input type="number" name="context_size" min="512" max="131072" value="<%= config.context_size %>" data-gpu-context /></div>
|
||||
<div class="field"><label>CPU threads (0 = auto)</label><input type="number" name="threads" min="0" max="256" value="<%= config.threads %>" /></div>
|
||||
<div class="field full ai-gpu-control" data-gpu-control data-endpoint="/plugins/lumi_ai/api/gpu-capacity">
|
||||
<div class="ai-gpu-label">
|
||||
@ -250,12 +320,12 @@
|
||||
<div class="field"><label>Maximum queue</label><input type="number" name="max_queue_length" min="1" max="100" value="<%= config.max_queue_length %>" /></div>
|
||||
<div class="field"><label>UI soft timeout (ms)</label><input type="number" name="ui_soft_timeout_ms" min="5000" max="300000" value="<%= config.ui_soft_timeout_ms %>" /><span class="hint">Shows Continue waiting controls without stopping the job.</span></div>
|
||||
<div class="field"><label>Hard generation timeout (ms)</label><input type="number" name="hard_generation_timeout_ms" min="30000" max="3600000" value="<%= config.hard_generation_timeout_ms %>" /></div>
|
||||
<div class="field"><label>API/test output token fallback</label><input type="number" name="max_output_tokens" min="64" max="32768" value="<%= config.max_output_tokens %>" /><span class="hint">Normal assistant requests use the class budgets below.</span></div>
|
||||
<div class="field"><label>Navigation/help tokens</label><input type="number" name="output_budget_navigation_help" min="64" max="32768" value="<%= config.output_budgets.navigation_help %>" /></div>
|
||||
<div class="field"><label>Simple answer tokens</label><input type="number" name="output_budget_simple_answer" min="64" max="32768" value="<%= config.output_budgets.simple_answer %>" /></div>
|
||||
<div class="field"><label>Code/custom command tokens</label><input type="number" name="output_budget_code_custom_command" min="64" max="32768" value="<%= config.output_budgets.code_custom_command %>" /></div>
|
||||
<div class="field"><label>Admin debug tokens</label><input type="number" name="output_budget_admin_debug" min="64" max="32768" value="<%= config.output_budgets.admin_debug %>" /></div>
|
||||
<div class="field"><label>Explicit long-answer tokens</label><input type="number" name="output_budget_explicit_long" min="64" max="32768" value="<%= config.output_budgets.explicit_long %>" /></div>
|
||||
<div class="field"><label>API/test output token fallback</label><select name="max_output_tokens"><%- renderPresetOptions(tokenPresets, config.max_output_tokens) %></select><span class="hint">Normal assistant requests use the class budgets below.</span></div>
|
||||
<div class="field"><label>Navigation/help tokens</label><select name="output_budget_navigation_help"><%- renderPresetOptions(tokenPresets, config.output_budgets.navigation_help) %></select></div>
|
||||
<div class="field"><label>Simple answer tokens</label><select name="output_budget_simple_answer"><%- renderPresetOptions(tokenPresets, config.output_budgets.simple_answer) %></select></div>
|
||||
<div class="field"><label>Code/custom command tokens</label><select name="output_budget_code_custom_command"><%- renderPresetOptions(tokenPresets, config.output_budgets.code_custom_command) %></select></div>
|
||||
<div class="field"><label>Admin debug tokens</label><select name="output_budget_admin_debug"><%- renderPresetOptions(tokenPresets, config.output_budgets.admin_debug) %></select></div>
|
||||
<div class="field"><label>Explicit long-answer tokens</label><select name="output_budget_explicit_long"><%- renderPresetOptions(tokenPresets, config.output_budgets.explicit_long) %></select></div>
|
||||
<div class="field"><label>Batch size</label><input type="number" name="batch_size" min="32" max="4096" value="<%= config.batch_size %>" /></div>
|
||||
<div class="field"><label>Micro-batch size</label><input type="number" name="ubatch_size" min="16" max="4096" value="<%= config.ubatch_size %>" /></div>
|
||||
<div class="field"><label>Requests per user/minute</label><input type="number" name="per_user_requests_per_minute" min="1" max="120" value="<%= config.per_user_requests_per_minute %>" /></div>
|
||||
@ -270,7 +340,7 @@
|
||||
<select name="gate_model_id"><% models.forEach((model) => { %><option value="<%= model.id %>" <%= model.id === config.gate.model_id ? "selected" : "" %>><%= model.label %></option><% }) %></select>
|
||||
<span class="hint">Use the smallest downloaded model that can reliably return JSON classifications.</span>
|
||||
</div>
|
||||
<div class="field"><label>Gate context size</label><input type="number" name="gate_context_size" min="512" max="4096" value="<%= config.gate.context_size %>" /></div>
|
||||
<div class="field"><label>Gate context size</label><select name="gate_context_size"><%- renderPresetOptions(gateContextOptions, config.gate.context_size) %></select></div>
|
||||
<div class="field"><label>Gate CPU threads</label><input type="number" name="gate_threads" min="1" max="16" value="<%= config.gate.threads %>" /></div>
|
||||
<div class="field"><label>Gate timeout (ms)</label><input type="number" name="gate_timeout_ms" min="1000" max="5000" step="250" value="<%= config.gate.timeout_ms %>" /><span class="hint">Timeout or errors immediately escalate to the main model.</span></div>
|
||||
<div class="field"><label>High-confidence threshold</label><input type="number" name="gate_high_confidence_threshold" min="0.5" max="0.99" step="0.01" value="<%= config.gate.high_confidence_threshold %>" /></div>
|
||||
@ -467,7 +537,7 @@
|
||||
<input type="hidden" name="source" value="local" />
|
||||
<button class="button subtle" type="submit">Refresh local</button>
|
||||
</form>
|
||||
<form method="post" action="/plugins/lumi_ai/repo-index/refresh" data-confirm-form="Download and index the approved public Lumi repository?">
|
||||
<form method="post" action="/plugins/lumi_ai/repo-index/refresh" data-confirm-title="Refresh public repository index" data-confirm-text="Download and index the approved public Lumi repository?" data-confirm-label="Refresh public">
|
||||
<input type="hidden" name="source" value="public" />
|
||||
<button class="button subtle" type="submit">Refresh public</button>
|
||||
</form>
|
||||
@ -563,7 +633,7 @@
|
||||
<td class="ai-table-actions">
|
||||
<a class="button subtle" href="/plugins/lumi_ai/logs/<%= encodeURIComponent(file.name) %>">View</a>
|
||||
<a class="button subtle" href="/plugins/lumi_ai/logs/<%= encodeURIComponent(file.name) %>/download">Download</a>
|
||||
<form method="post" action="/plugins/lumi_ai/logs/<%= encodeURIComponent(file.name) %>/delete" data-confirm-form="Delete <%= file.name %>?"><button class="button danger" type="submit">Delete</button></form>
|
||||
<form method="post" action="/plugins/lumi_ai/logs/<%= encodeURIComponent(file.name) %>/delete" data-confirm-title="Delete AI runtime log" data-confirm-text="Delete <%= file.name %>?" data-confirm-label="Delete log"><button class="button danger" type="submit">Delete</button></form>
|
||||
</td>
|
||||
</tr>
|
||||
<% }) %>
|
||||
|
||||
@ -4,11 +4,15 @@
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title><%= title %></title>
|
||||
<link rel="stylesheet" href="/styles.css" />
|
||||
<link rel="stylesheet" href="/lumi-tokens.css?v=<%= assetVersion %>" />
|
||||
<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 %>" />
|
||||
<%- include("../../../src/web/views/partials/theme-vars", { theme }) %>
|
||||
</head>
|
||||
<body>
|
||||
<div class="page" style="min-height: 100vh; display: flex; align-items: center; justify-content: center;">
|
||||
<div class="card" style="max-width: 720px; width: 100%;">
|
||||
<body data-theme-id="<%= theme ? theme.id : '' %>">
|
||||
<main class="standalone-page">
|
||||
<div class="card standalone-card">
|
||||
<h1>Access restricted</h1>
|
||||
<p class="hint">Your account is currently restricted by moderation.</p>
|
||||
<div class="stat-grid">
|
||||
@ -29,7 +33,7 @@
|
||||
<span class="stat-value"><%= sanction.expires_at ? new Date(sanction.expires_at).toLocaleString() : 'Permanent' %></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card" style="margin-top: 16px;">
|
||||
<div class="card standalone-detail">
|
||||
<h2>Summary</h2>
|
||||
<p><%= sanction.reason_short %></p>
|
||||
<h2>Details</h2>
|
||||
@ -37,6 +41,6 @@
|
||||
<p class="hint">Moderator: <%= sanction.created_by_name || 'Staff' %></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
</html>
|
||||
|
||||
@ -21,7 +21,7 @@
|
||||
|
||||
.diagnostic-grid > div,
|
||||
.destination-panel,
|
||||
.template-panel {
|
||||
.template-panel:not(.lumi-expandable-settings) {
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface-2);
|
||||
padding: 14px;
|
||||
@ -99,7 +99,7 @@
|
||||
padding-top: 12px;
|
||||
}
|
||||
|
||||
.template-panel {
|
||||
.template-panel:not(.lumi-expandable-settings) {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
@ -164,10 +164,22 @@
|
||||
<p>No active platform templates are available.</p>
|
||||
<% } %>
|
||||
<% activePlatforms.forEach((platform) => { const template = templateMap.get(eventType + ":" + platform); const status = statusMap.get(platform); %>
|
||||
<form method="post" action="/plugins/throne_wishlist/templates" class="template-panel">
|
||||
<details class="template-panel lumi-expandable-settings" data-lumi-expandable-settings>
|
||||
<summary>
|
||||
<span>
|
||||
<strong><%= status?.label || platform %></strong>
|
||||
<span class="lumi-preview-line" data-placeholder-preview="#template-<%= eventType %>-<%= platform %>" data-fallback="<%= template?.template || "" %>"></span>
|
||||
</span>
|
||||
<label class="switch" onclick="event.stopPropagation()">
|
||||
<input type="checkbox" class="switch-input" disabled <%= template?.enabled ? "checked" : "" %> />
|
||||
<span class="switch-track" aria-hidden="true"></span>
|
||||
<span class="switch-text"><%= template?.enabled ? "On" : "Off" %></span>
|
||||
</label>
|
||||
</summary>
|
||||
<form method="post" action="/plugins/throne_wishlist/templates" class="lumi-expandable-body form-grid" data-lumi-settings-form>
|
||||
<input type="hidden" name="event_type" value="<%= eventType %>" />
|
||||
<input type="hidden" name="platform" value="<%= platform %>" />
|
||||
<div class="template-heading">
|
||||
<div class="template-heading field full">
|
||||
<strong><%= status?.label || platform %></strong>
|
||||
<label class="switch">
|
||||
<input type="checkbox" class="switch-input" name="enabled" <%= template?.enabled ? "checked" : "" %> />
|
||||
@ -175,10 +187,13 @@
|
||||
<span class="switch-text"><%= template?.enabled ? "On" : "Off" %></span>
|
||||
</label>
|
||||
</div>
|
||||
<label for="template-<%= eventType %>-<%= platform %>">Message</label>
|
||||
<textarea id="template-<%= eventType %>-<%= platform %>" name="template" rows="4"><%= template?.template || "" %></textarea>
|
||||
<div class="field full">
|
||||
<label for="template-<%= eventType %>-<%= platform %>">Message</label>
|
||||
<textarea id="template-<%= eventType %>-<%= platform %>" name="template" rows="4"><%= template?.template || "" %></textarea>
|
||||
</div>
|
||||
<button type="submit" class="button subtle">Save <%= status?.label || platform %></button>
|
||||
</form>
|
||||
</details>
|
||||
<% }) %>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
11
run.js
11
run.js
@ -6,6 +6,7 @@ const safeModeEntry = path.join(__dirname, "safe-mode.js");
|
||||
const maxRestarts = Number(process.env.MAX_RESTARTS || 25);
|
||||
const restartDelayMs = Number(process.env.RESTART_DELAY_MS || 1500);
|
||||
const restartCodes = new Set([10, 100]);
|
||||
const safeModeFlag = path.join(__dirname, "data", "recovery", "safe-mode.flag");
|
||||
|
||||
let restarts = 0;
|
||||
let safeModeStarted = false;
|
||||
@ -62,4 +63,12 @@ function startChild() {
|
||||
});
|
||||
}
|
||||
|
||||
startChild();
|
||||
if (
|
||||
process.env.LUMI_SAFE_MODE === "1" ||
|
||||
process.argv.includes("--safe-mode") ||
|
||||
require("fs").existsSync(safeModeFlag)
|
||||
) {
|
||||
startSafeMode();
|
||||
} else {
|
||||
startChild();
|
||||
}
|
||||
|
||||
77
safe-mode.js
77
safe-mode.js
@ -14,6 +14,11 @@ const {
|
||||
const { getRoleFlags, hasAccess } = require("./src/services/rbac");
|
||||
const { listSnapshots, restoreSnapshot } = require("./src/services/update-manager");
|
||||
const { requestRestart } = require("./src/services/updater");
|
||||
const {
|
||||
safeModeStatus,
|
||||
clearRecoveryMarker
|
||||
} = require("./src/services/recovery-mode");
|
||||
const { setPluginEnabled } = require("./src/services/plugins");
|
||||
|
||||
function ensureSessionSecret() {
|
||||
let secret = getSetting("session_secret");
|
||||
@ -45,8 +50,13 @@ function renderPage(title, content) {
|
||||
main { padding: 24px 28px; max-width: 900px; margin: 0 auto; }
|
||||
.card { background: #fff; border-radius: 12px; padding: 18px 20px; margin-bottom: 16px; box-shadow: 0 10px 25px rgba(20, 24, 30, 0.08); }
|
||||
.button { background: #0f6a78; color: #fff; border: none; padding: 8px 14px; border-radius: 8px; cursor: pointer; text-decoration: none; display: inline-block; }
|
||||
.button.subtle { background: #eef5f6; color: #173238; }
|
||||
.button.danger { background: #c24b3b; }
|
||||
.muted { color: #5a616a; }
|
||||
.warning { border-left: 4px solid #d28a24; }
|
||||
.danger-card { border-left: 4px solid #c24b3b; }
|
||||
form { display: inline-block; margin: 4px 6px 4px 0; }
|
||||
code { background: #f1ede7; padding: 2px 5px; border-radius: 5px; }
|
||||
table { width: 100%; border-collapse: collapse; }
|
||||
th, td { text-align: left; padding: 8px; border-bottom: 1px solid #e1ddd7; }
|
||||
</style>
|
||||
@ -99,6 +109,47 @@ function buildSnapshotTable(snapshots) {
|
||||
`;
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value ?? "")
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
function buildRecoveryPanel() {
|
||||
const status = safeModeStatus();
|
||||
const marker = status.marker;
|
||||
if (!marker) {
|
||||
return `<section class="card"><h2>Recovery state</h2><p class="muted">No recovery marker is present.</p></section>`;
|
||||
}
|
||||
const pluginActions = marker.target_kind === "plugin" && marker.target_id
|
||||
? `<form method="post" action="/plugins/${encodeURIComponent(marker.target_id)}/disable"><button class="button danger" type="submit">Disable plugin</button></form>`
|
||||
: "";
|
||||
return `
|
||||
<section class="card ${marker.status === "failed" || marker.status === "stale" ? "danger-card" : "warning"}">
|
||||
<h2>Last update marker</h2>
|
||||
<p class="muted">Status: <strong>${escapeHtml(marker.status)}</strong></p>
|
||||
<table>
|
||||
<tbody>
|
||||
<tr><th>Target</th><td>${escapeHtml(marker.target_kind || "unknown")} ${escapeHtml(marker.target_id || "")}</td></tr>
|
||||
<tr><th>Version</th><td>${escapeHtml(marker.from_version || "?")} -> ${escapeHtml(marker.to_version || "?")}</td></tr>
|
||||
<tr><th>Method</th><td>${escapeHtml(marker.update_method || "?")}</td></tr>
|
||||
<tr><th>Source branch</th><td>${escapeHtml(marker.source_branch || "?")}</td></tr>
|
||||
<tr><th>Snapshot</th><td>${escapeHtml(marker.snapshot_id || marker.recovery_marker_id || "pending")}</td></tr>
|
||||
<tr><th>Updated</th><td>${escapeHtml(marker.updated_at || marker.created_at || "?")}</td></tr>
|
||||
<tr><th>Error</th><td>${escapeHtml(marker.error || "")}</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div>
|
||||
${pluginActions}
|
||||
<form method="post" action="/recovery/clear-marker"><button class="button danger" type="submit">Clear stale marker</button></form>
|
||||
<form method="post" action="/recovery/retry-normal-startup"><button class="button subtle" type="submit">Retry normal startup</button></form>
|
||||
</div>
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
|
||||
const app = express();
|
||||
const sessionStore = new BetterSqlite3Store({ client: db });
|
||||
app.use(
|
||||
@ -141,7 +192,7 @@ app.get("/", (req, res) => {
|
||||
res.send(
|
||||
renderPage(
|
||||
"Safe Mode",
|
||||
`<section class="card"><h2>Rollback snapshots</h2><p class="muted">Use these snapshots to roll back failed updates. The server will restart after rollback.</p>${table}</section>`
|
||||
`${buildRecoveryPanel()}<section class="card"><h2>Rollback snapshots</h2><p class="muted">Use these snapshots to roll back failed updates. Major-version rollback is blocked unless the snapshot is marked rollback safe. The server will restart after rollback.</p>${table}</section>`
|
||||
)
|
||||
);
|
||||
});
|
||||
@ -210,6 +261,30 @@ app.post("/rollback/:id", (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
app.post("/plugins/:id/disable", (req, res) => {
|
||||
if (!req.session.user || !hasAccess(req.session.user, "admin")) {
|
||||
return res.status(403).send(renderPage("Safe Mode", "<section class=\"card\">Access denied.</section>"));
|
||||
}
|
||||
setPluginEnabled(req.params.id, false);
|
||||
res.redirect("/");
|
||||
});
|
||||
|
||||
app.post("/recovery/clear-marker", (req, res) => {
|
||||
if (!req.session.user || !hasAccess(req.session.user, "admin")) {
|
||||
return res.status(403).send(renderPage("Safe Mode", "<section class=\"card\">Access denied.</section>"));
|
||||
}
|
||||
clearRecoveryMarker();
|
||||
res.redirect("/");
|
||||
});
|
||||
|
||||
app.post("/recovery/retry-normal-startup", (req, res) => {
|
||||
if (!req.session.user || !hasAccess(req.session.user, "admin")) {
|
||||
return res.status(403).send(renderPage("Safe Mode", "<section class=\"card\">Access denied.</section>"));
|
||||
}
|
||||
res.send(renderPage("Safe Mode", "<section class=\"card\"><h2>Retrying normal startup</h2><p class=\"muted\">Safe mode is exiting. The wrapper will restart Lumi normally.</p></section>"));
|
||||
requestRestart();
|
||||
});
|
||||
|
||||
const port = Number(process.env.SAFE_MODE_PORT || 3001);
|
||||
app.listen(port, () => {
|
||||
console.log(`Safe mode listening on http://localhost:${port}`);
|
||||
|
||||
181
scripts/verify-webui.js
Normal file
181
scripts/verify-webui.js
Normal file
@ -0,0 +1,181 @@
|
||||
const assert = require("assert");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const ejs = require("ejs");
|
||||
|
||||
const root = path.join(__dirname, "..");
|
||||
|
||||
function listFiles(directory, extension, output = []) {
|
||||
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
|
||||
const target = path.join(directory, entry.name);
|
||||
if (entry.isDirectory()) listFiles(target, extension, output);
|
||||
else if (target.endsWith(extension)) output.push(target);
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
function verifyViews() {
|
||||
const viewRoots = [path.join(root, "src", "web", "views"), path.join(root, "plugins")];
|
||||
const files = viewRoots.flatMap((directory) => listFiles(directory, ".ejs"));
|
||||
for (const file of files) {
|
||||
ejs.compile(fs.readFileSync(file, "utf8"), { filename: file });
|
||||
}
|
||||
return files.length;
|
||||
}
|
||||
|
||||
function verifyThemeService() {
|
||||
const sandbox = fs.mkdtempSync(path.join(root, ".tmp-lumi-theme-test-"));
|
||||
const serviceDir = path.join(sandbox, "src", "services");
|
||||
let database = null;
|
||||
fs.mkdirSync(serviceDir, { recursive: true });
|
||||
for (const file of ["config.js", "db.js", "settings.js", "themes.js"]) {
|
||||
fs.copyFileSync(
|
||||
path.join(root, "src", "services", file),
|
||||
path.join(serviceDir, file)
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
database = require(path.join(serviceDir, "db.js"));
|
||||
database.migrate();
|
||||
require(path.join(serviceDir, "settings.js")).ensureDefaults();
|
||||
const themes = require(path.join(serviceDir, "themes.js"));
|
||||
|
||||
assert.strictEqual(themes.BUILTIN_THEMES.length, 6);
|
||||
for (const theme of themes.BUILTIN_THEMES) {
|
||||
assert.deepStrictEqual(themes.validateThemeValues(theme), []);
|
||||
}
|
||||
|
||||
const library = themes.listThemes();
|
||||
assert(library.every((theme) => theme.builtin));
|
||||
assert.throws(
|
||||
() => themes.saveCustomTheme("builtin:lumi-default", library[0]),
|
||||
/read-only/
|
||||
);
|
||||
|
||||
const copy = themes.duplicateTheme("builtin:midnight", "Verification Theme");
|
||||
assert.strictEqual(copy.builtin, false);
|
||||
assert.strictEqual(copy.typography.bodyFont, "lumi");
|
||||
themes.setActiveTheme(copy.id);
|
||||
assert.strictEqual(themes.getActiveTheme().id, copy.id);
|
||||
|
||||
const invalid = JSON.parse(JSON.stringify(copy));
|
||||
invalid.light.text = "not-a-color";
|
||||
assert.throws(() => themes.saveCustomTheme(copy.id, invalid), /hex color/);
|
||||
invalid.light.text = copy.light.text;
|
||||
invalid.typography.bodyFont = "external-css";
|
||||
assert.throws(() => themes.saveCustomTheme(copy.id, invalid), /font preset/);
|
||||
|
||||
const renamed = themes.renameCustomTheme(copy.id, "Verified Theme");
|
||||
assert.strictEqual(renamed.name, "Verified Theme");
|
||||
|
||||
const repaired = themes.normalizeThemeValues({
|
||||
...renamed,
|
||||
light: { ...renamed.light, text: "#ffffff", surface: "#ffffff" }
|
||||
});
|
||||
assert.notStrictEqual(repaired.light.text, repaired.light.surface);
|
||||
|
||||
const themeView = path.join(root, "src", "web", "views", "admin-theme.ejs");
|
||||
const rendered = ejs.render(fs.readFileSync(themeView, "utf8"), {
|
||||
title: "Theming",
|
||||
siteTitle: "Lumi Bot",
|
||||
assetVersion: "verify",
|
||||
theme: renamed,
|
||||
activeTheme: renamed,
|
||||
themes: themes.listThemes(),
|
||||
editingTheme: renamed,
|
||||
editingBaseTheme: themes.getThemeById(renamed.baseThemeId),
|
||||
fontStacks: themes.FONT_STACKS,
|
||||
botAvatar: null,
|
||||
navSections: [],
|
||||
user: { username: "Admin" },
|
||||
userAvatar: null,
|
||||
userInitial: "A",
|
||||
platformLogins: [],
|
||||
flash: null,
|
||||
softError: null
|
||||
}, { filename: themeView });
|
||||
assert(rendered.includes("data-theme-editor"));
|
||||
assert(rendered.includes("data-theme-font"));
|
||||
assert(rendered.includes("data-theme-popout"));
|
||||
assert(rendered.includes("data-lumi-state-button"));
|
||||
assert(rendered.includes("Built-in · read-only"));
|
||||
|
||||
const homeView = path.join(root, "src", "web", "views", "home.ejs");
|
||||
const homeRendered = ejs.render(fs.readFileSync(homeView, "utf8"), {
|
||||
title: "Home",
|
||||
siteTitle: "Lumi Bot",
|
||||
assetVersion: "verify",
|
||||
theme: renamed,
|
||||
botAvatar: null,
|
||||
navSections: [],
|
||||
user: { username: "Admin" },
|
||||
userAvatar: null,
|
||||
userInitial: "A",
|
||||
platformLogins: [],
|
||||
flash: null,
|
||||
softError: null,
|
||||
homepageLinks: [{
|
||||
label: "Twitch",
|
||||
description: "Watch live",
|
||||
url: "https://example.com",
|
||||
fallback_icon: "T"
|
||||
}],
|
||||
homepageHero: {
|
||||
type: "static_image",
|
||||
title: "Featured",
|
||||
description: "Featured content",
|
||||
image_url: "https://example.com/hero.png",
|
||||
source_url: "https://example.com"
|
||||
}
|
||||
}, { filename: homeView });
|
||||
assert(homeRendered.includes("homepage-link-button"));
|
||||
assert(homeRendered.includes("homepage-dynamic-hero"));
|
||||
|
||||
const loginView = path.join(root, "src", "web", "views", "localhost-login.ejs");
|
||||
const loginRendered = ejs.render(fs.readFileSync(loginView, "utf8"), {
|
||||
title: "Localhost Login",
|
||||
username: "admin",
|
||||
siteTitle: "Lumi Bot",
|
||||
assetVersion: "verify",
|
||||
theme: renamed,
|
||||
botAvatar: null,
|
||||
navSections: [],
|
||||
user: null,
|
||||
userAvatar: null,
|
||||
userInitial: "",
|
||||
platformLogins: [{ id: "localhost", label: "Localhost Login", configured: true, loginPath: "/auth/localhost" }],
|
||||
flash: null,
|
||||
softError: null
|
||||
}, { filename: loginView });
|
||||
assert(loginRendered.includes("admin / admin"));
|
||||
|
||||
const statusView = path.join(root, "plugins", "moderation", "views", "status.ejs");
|
||||
const statusRendered = ejs.render(fs.readFileSync(statusView, "utf8"), {
|
||||
title: "Access restricted",
|
||||
assetVersion: "verify",
|
||||
theme: renamed,
|
||||
sanction: {
|
||||
action_type: "timeout",
|
||||
status: "active",
|
||||
created_at: Date.now(),
|
||||
expires_at: Date.now() + 60000,
|
||||
reason_short: "Verification",
|
||||
reason_detail: "Standalone themed view verification.",
|
||||
created_by_name: "Admin"
|
||||
}
|
||||
}, { filename: statusView });
|
||||
assert(statusRendered.includes("/lumi-components.css"));
|
||||
assert(statusRendered.includes('data-theme-id="custom:'));
|
||||
|
||||
themes.deleteCustomTheme(copy.id);
|
||||
assert.strictEqual(themes.getActiveTheme().id, themes.DEFAULT_THEME_ID);
|
||||
} finally {
|
||||
database?.db.close();
|
||||
fs.rmSync(sandbox, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
const viewCount = verifyViews();
|
||||
verifyThemeService();
|
||||
console.log(`WebUI verification passed: ${viewCount} EJS views and theme CRUD.`);
|
||||
18
src/main.js
18
src/main.js
@ -15,11 +15,20 @@ const { createCommandRouter } = require("./services/command-router");
|
||||
const { registerTopCommand } = require("./services/top");
|
||||
const logger = require("./services/logger");
|
||||
const { isPlatformEnabled } = require("./services/platforms");
|
||||
const {
|
||||
isSafeModeRequested,
|
||||
markStartupVerification
|
||||
} = require("./services/recovery-mode");
|
||||
|
||||
async function main() {
|
||||
migrate();
|
||||
ensureDefaults();
|
||||
logger.hookConsole();
|
||||
const safeModeRequested = isSafeModeRequested();
|
||||
const startupMarker = markStartupVerification();
|
||||
if (startupMarker?.status === "stale") {
|
||||
console.warn("Recovery marker detected from incomplete update; start with LUMI_SAFE_MODE=1 for recovery tools.");
|
||||
}
|
||||
|
||||
const settingsApi = { getSetting, setSetting };
|
||||
const commandRouter = createCommandRouter({ settings: settingsApi });
|
||||
@ -28,7 +37,7 @@ async function main() {
|
||||
let twitchClient = null;
|
||||
let youtubeClient = null;
|
||||
|
||||
if (isPlatformEnabled("discord")) {
|
||||
if (!safeModeRequested && isPlatformEnabled("discord")) {
|
||||
try {
|
||||
discordClient = await startBot({ commandRouter });
|
||||
} catch (error) {
|
||||
@ -36,7 +45,7 @@ async function main() {
|
||||
}
|
||||
}
|
||||
|
||||
if (isPlatformEnabled("twitch")) {
|
||||
if (!safeModeRequested && isPlatformEnabled("twitch")) {
|
||||
try {
|
||||
twitchClient = await startTwitchBot({ commandRouter });
|
||||
} catch (error) {
|
||||
@ -44,7 +53,7 @@ async function main() {
|
||||
}
|
||||
}
|
||||
|
||||
if (isPlatformEnabled("youtube")) {
|
||||
if (!safeModeRequested && isPlatformEnabled("youtube")) {
|
||||
try {
|
||||
youtubeClient = await startYouTubeBot({ commandRouter });
|
||||
} catch (error) {
|
||||
@ -55,6 +64,7 @@ async function main() {
|
||||
const app = createWebServer({
|
||||
discordClient,
|
||||
loadPlugins: (appInstance, web, webhooks) => {
|
||||
if (safeModeRequested) return;
|
||||
loadEnabled({
|
||||
app: appInstance,
|
||||
discordClient,
|
||||
@ -75,7 +85,7 @@ async function main() {
|
||||
|
||||
const autoUpdateEnabled = getSetting("auto_update_enabled", false);
|
||||
const intervalMinutes = getSetting("auto_update_interval_minutes", 60);
|
||||
if (autoUpdateEnabled) {
|
||||
if (!safeModeRequested && autoUpdateEnabled) {
|
||||
const intervalMs = Math.max(5, Number(intervalMinutes)) * 60 * 1000;
|
||||
setInterval(() => {
|
||||
try {
|
||||
|
||||
@ -18,6 +18,15 @@ function migrate() {
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS custom_themes (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
base_theme_id TEXT NOT NULL,
|
||||
values_json TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id TEXT PRIMARY KEY,
|
||||
username TEXT NOT NULL,
|
||||
|
||||
132
src/services/recovery-mode.js
Normal file
132
src/services/recovery-mode.js
Normal file
@ -0,0 +1,132 @@
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const crypto = require("crypto");
|
||||
|
||||
const repoRoot = path.join(__dirname, "..", "..");
|
||||
const recoveryDir = path.join(repoRoot, "data", "recovery");
|
||||
const markerPath = path.join(recoveryDir, "update-marker.json");
|
||||
const safeModeFlagPath = path.join(recoveryDir, "safe-mode.flag");
|
||||
|
||||
function ensureRecoveryDir() {
|
||||
fs.mkdirSync(recoveryDir, { recursive: true });
|
||||
}
|
||||
|
||||
function readRecoveryMarker() {
|
||||
try {
|
||||
if (!fs.existsSync(markerPath)) return null;
|
||||
const marker = JSON.parse(fs.readFileSync(markerPath, "utf8"));
|
||||
return marker && typeof marker === "object" ? marker : null;
|
||||
} catch {
|
||||
return {
|
||||
id: "unreadable",
|
||||
status: "failed",
|
||||
error: "Recovery marker could not be parsed.",
|
||||
marker_path: markerPath
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function writeRecoveryMarker(marker) {
|
||||
ensureRecoveryDir();
|
||||
const next = {
|
||||
id: marker.id || crypto.randomUUID(),
|
||||
status: marker.status || "pending",
|
||||
updated_at: new Date().toISOString(),
|
||||
...marker
|
||||
};
|
||||
fs.writeFileSync(markerPath, JSON.stringify(next, null, 2), "utf8");
|
||||
return next;
|
||||
}
|
||||
|
||||
function createRecoveryMarker(details) {
|
||||
return writeRecoveryMarker({
|
||||
id: crypto.randomUUID(),
|
||||
status: "pending",
|
||||
created_at: new Date().toISOString(),
|
||||
...details
|
||||
});
|
||||
}
|
||||
|
||||
function updateRecoveryMarker(values) {
|
||||
const current = readRecoveryMarker() || {};
|
||||
return writeRecoveryMarker({ ...current, ...values });
|
||||
}
|
||||
|
||||
function markRecoveryMarkerFailed(error) {
|
||||
return updateRecoveryMarker({
|
||||
status: "failed",
|
||||
error: error?.message || String(error || "Update failed.")
|
||||
});
|
||||
}
|
||||
|
||||
function markRecoveryMarkerComplete(values = {}) {
|
||||
return updateRecoveryMarker({
|
||||
...values,
|
||||
status: "completed",
|
||||
completed_at: new Date().toISOString()
|
||||
});
|
||||
}
|
||||
|
||||
function clearRecoveryMarker() {
|
||||
try {
|
||||
fs.rmSync(markerPath, { force: true });
|
||||
} catch {
|
||||
// ignore cleanup failures
|
||||
}
|
||||
}
|
||||
|
||||
function markStartupVerification() {
|
||||
const marker = readRecoveryMarker();
|
||||
if (!marker) return null;
|
||||
if (marker.status === "completed") {
|
||||
clearRecoveryMarker();
|
||||
return { ...marker, cleared: true };
|
||||
}
|
||||
if (["pending", "applying", "verifying"].includes(marker.status)) {
|
||||
return writeRecoveryMarker({
|
||||
...marker,
|
||||
status: "stale",
|
||||
stale_at: new Date().toISOString(),
|
||||
error: marker.error || "Previous update did not complete startup verification."
|
||||
});
|
||||
}
|
||||
return marker;
|
||||
}
|
||||
|
||||
function isSafeModeRequested(argv = process.argv, env = process.env) {
|
||||
return env.LUMI_SAFE_MODE === "1" ||
|
||||
env.SAFE_MODE === "1" ||
|
||||
argv.includes("--safe-mode") ||
|
||||
fs.existsSync(safeModeFlagPath);
|
||||
}
|
||||
|
||||
function safeModeStatus() {
|
||||
const marker = readRecoveryMarker();
|
||||
const requested = isSafeModeRequested();
|
||||
const incomplete = marker && ["pending", "applying", "verifying", "failed", "stale"].includes(marker.status);
|
||||
return {
|
||||
requested,
|
||||
active: process.env.SAFE_MODE === "1" || process.env.LUMI_SAFE_MODE === "1",
|
||||
marker,
|
||||
has_incomplete_marker: Boolean(incomplete),
|
||||
safe_mode_flag: fs.existsSync(safeModeFlagPath),
|
||||
marker_path: markerPath,
|
||||
flag_path: safeModeFlagPath
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
recoveryDir,
|
||||
markerPath,
|
||||
safeModeFlagPath,
|
||||
readRecoveryMarker,
|
||||
writeRecoveryMarker,
|
||||
createRecoveryMarker,
|
||||
updateRecoveryMarker,
|
||||
markRecoveryMarkerFailed,
|
||||
markRecoveryMarkerComplete,
|
||||
clearRecoveryMarker,
|
||||
markStartupVerification,
|
||||
isSafeModeRequested,
|
||||
safeModeStatus
|
||||
};
|
||||
223
src/services/repo-update.js
Normal file
223
src/services/repo-update.js
Normal file
@ -0,0 +1,223 @@
|
||||
const path = require("path");
|
||||
const fs = require("fs");
|
||||
const { spawnSync } = require("child_process");
|
||||
const { syncPluginRegistry, setPluginEnabled } = require("./plugins");
|
||||
const {
|
||||
createSnapshot,
|
||||
finalizeSnapshot,
|
||||
discardSnapshot,
|
||||
restoreSnapshot
|
||||
} = require("./update-manager");
|
||||
const { getUpdateStatus } = require("./update-index");
|
||||
const {
|
||||
createRecoveryMarker,
|
||||
updateRecoveryMarker,
|
||||
markRecoveryMarkerFailed,
|
||||
markRecoveryMarkerComplete
|
||||
} = require("./recovery-mode");
|
||||
|
||||
const repoRoot = path.join(__dirname, "..", "..");
|
||||
const activeOperations = new Set();
|
||||
|
||||
function runGit(args) {
|
||||
const result = spawnSync("git", args, {
|
||||
cwd: repoRoot,
|
||||
encoding: "utf8"
|
||||
});
|
||||
if (result.status !== 0) {
|
||||
throw new Error((result.stderr || result.stdout || "Git command failed.").trim());
|
||||
}
|
||||
return result.stdout.trim();
|
||||
}
|
||||
|
||||
function withOperation(key, fn) {
|
||||
if (activeOperations.has(key)) {
|
||||
throw new Error("An update is already running for this target.");
|
||||
}
|
||||
activeOperations.add(key);
|
||||
return Promise.resolve()
|
||||
.then(fn)
|
||||
.finally(() => activeOperations.delete(key));
|
||||
}
|
||||
|
||||
function emitProgress(publish, event, payload) {
|
||||
if (typeof publish === "function") {
|
||||
publish(event, payload, { role: "admin" });
|
||||
}
|
||||
}
|
||||
|
||||
function verifyCoreFiles() {
|
||||
for (const file of ["package.json", "src/main.js", "src/web/server.js"]) {
|
||||
if (!fs.existsSync(path.join(repoRoot, file))) {
|
||||
throw new Error(`Core verification failed: ${file} is missing.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function verifyPluginFiles(pluginId) {
|
||||
const manifest = path.join(repoRoot, "plugins", pluginId, "plugin.json");
|
||||
if (!fs.existsSync(manifest)) {
|
||||
throw new Error(`Plugin verification failed: ${pluginId}/plugin.json is missing.`);
|
||||
}
|
||||
JSON.parse(fs.readFileSync(manifest, "utf8"));
|
||||
}
|
||||
|
||||
async function applyCoreUpdate({ source = "stable", publish } = {}) {
|
||||
return withOperation("core", async () => {
|
||||
const status = getUpdateStatus({ source });
|
||||
const target = status.core;
|
||||
if (target.blocked) throw new Error(target.blocked_reason || "Core update is blocked.");
|
||||
if (!target.update_available) throw new Error("No safe core update target is available.");
|
||||
const marker = createRecoveryMarker({
|
||||
target_kind: "core",
|
||||
target_id: "core",
|
||||
from_version: target.current_version,
|
||||
to_version: target.safe_target_version,
|
||||
source_branch: target.source_branch,
|
||||
update_method: "git",
|
||||
rollback_safe: target.rollback_safe,
|
||||
major_crossing: target.major_crossing
|
||||
});
|
||||
let snapshot = null;
|
||||
try {
|
||||
emitProgress(publish, "update:queued", { target: "core" });
|
||||
emitProgress(publish, "update:checking", { target: "core" });
|
||||
emitProgress(publish, "update:metadata", target);
|
||||
updateRecoveryMarker({ status: "applying" });
|
||||
snapshot = await createSnapshot({
|
||||
type: "bot",
|
||||
metadata: {
|
||||
target_kind: "core",
|
||||
target_id: "core",
|
||||
from_version: target.current_version,
|
||||
to_version: target.safe_target_version,
|
||||
source_branch: target.source_branch,
|
||||
update_method: "git",
|
||||
rollback_safe: target.rollback_safe,
|
||||
recovery_marker_id: marker.id,
|
||||
major_crossing: target.major_crossing,
|
||||
migration_notes: target.migration_notes,
|
||||
danger_notes: target.dangers
|
||||
}
|
||||
});
|
||||
emitProgress(publish, "update:snapshot", { target: "core", snapshot_id: snapshot.id });
|
||||
emitProgress(publish, "update:recovery_marker", { target: "core", marker_id: marker.id });
|
||||
emitProgress(publish, "update:download", { target: "core", branch: target.source_branch });
|
||||
runGit(["fetch", status.remote, target.source_branch]);
|
||||
emitProgress(publish, "update:apply", { target: "core" });
|
||||
runGit(["pull", "--ff-only", status.remote, target.source_branch]);
|
||||
updateRecoveryMarker({ status: "verifying" });
|
||||
emitProgress(publish, "update:verify", { target: "core" });
|
||||
verifyCoreFiles();
|
||||
const record = finalizeSnapshot(snapshot);
|
||||
markRecoveryMarkerComplete({ snapshot_id: record.id });
|
||||
emitProgress(publish, "update:restart_required", { target: "core" });
|
||||
emitProgress(publish, "update:complete", { target: "core", snapshot_id: record.id });
|
||||
return { status: "complete", restart_required: true, snapshot: record, target };
|
||||
} catch (error) {
|
||||
if (snapshot) discardSnapshot(snapshot);
|
||||
markRecoveryMarkerFailed(error);
|
||||
emitProgress(publish, "update:failed", { target: "core", error: error.message });
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function applyPluginUpdateFromRepo(pluginId, { source = "stable", publish } = {}) {
|
||||
return withOperation(`plugin:${pluginId}`, async () => {
|
||||
const status = getUpdateStatus({ source });
|
||||
const target = status.plugins.find((plugin) => plugin.id === pluginId);
|
||||
if (!target) throw new Error("Plugin is not installed.");
|
||||
if (target.blocked) throw new Error(target.blocked_reason || "Plugin update is blocked.");
|
||||
if (!target.update_available) throw new Error("No safe plugin update target is available.");
|
||||
const marker = createRecoveryMarker({
|
||||
target_kind: "plugin",
|
||||
target_id: pluginId,
|
||||
from_version: target.current_version,
|
||||
to_version: target.safe_target_version,
|
||||
source_branch: target.source_branch,
|
||||
update_method: "git",
|
||||
rollback_safe: target.rollback_safe,
|
||||
major_crossing: target.major_crossing
|
||||
});
|
||||
let snapshot = null;
|
||||
try {
|
||||
emitProgress(publish, "update:queued", { target: "plugin", plugin_id: pluginId });
|
||||
emitProgress(publish, "update:metadata", target);
|
||||
updateRecoveryMarker({ status: "applying" });
|
||||
snapshot = await createSnapshot({
|
||||
type: "plugin",
|
||||
pluginId,
|
||||
metadata: {
|
||||
target_kind: "plugin",
|
||||
target_id: pluginId,
|
||||
from_version: target.current_version,
|
||||
to_version: target.safe_target_version,
|
||||
source_branch: target.source_branch,
|
||||
update_method: "git",
|
||||
rollback_safe: target.rollback_safe,
|
||||
recovery_marker_id: marker.id,
|
||||
major_crossing: target.major_crossing,
|
||||
migration_notes: target.migration_notes,
|
||||
danger_notes: target.dangers
|
||||
}
|
||||
});
|
||||
emitProgress(publish, "update:snapshot", { target: "plugin", plugin_id: pluginId, snapshot_id: snapshot.id });
|
||||
emitProgress(publish, "update:download", { target: "plugin", plugin_id: pluginId, branch: target.source_branch });
|
||||
runGit(["fetch", status.remote, target.source_branch]);
|
||||
emitProgress(publish, "update:apply", { target: "plugin", plugin_id: pluginId });
|
||||
runGit(["checkout", `${status.remote}/${target.source_branch}`, "--", `plugins/${pluginId}`]);
|
||||
updateRecoveryMarker({ status: "verifying" });
|
||||
emitProgress(publish, "update:verify", { target: "plugin", plugin_id: pluginId });
|
||||
verifyPluginFiles(pluginId);
|
||||
syncPluginRegistry();
|
||||
const record = finalizeSnapshot(snapshot);
|
||||
markRecoveryMarkerComplete({ snapshot_id: record.id });
|
||||
emitProgress(publish, "update:complete", { target: "plugin", plugin_id: pluginId, snapshot_id: record.id });
|
||||
return { status: "complete", restart_required: false, snapshot: record, target };
|
||||
} catch (error) {
|
||||
if (snapshot) discardSnapshot(snapshot);
|
||||
markRecoveryMarkerFailed(error);
|
||||
emitProgress(publish, "update:failed", { target: "plugin", plugin_id: pluginId, error: error.message });
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function revertCoreSnapshot(snapshotId, { currentVersion, publish } = {}) {
|
||||
return withOperation("core:revert", async () => {
|
||||
emitProgress(publish, "update:revert", { target: "core", snapshot_id: snapshotId });
|
||||
const entry = restoreSnapshot(snapshotId, {
|
||||
expectedType: "bot",
|
||||
currentVersion
|
||||
});
|
||||
return { status: "reverted", restart_required: true, snapshot: entry };
|
||||
});
|
||||
}
|
||||
|
||||
async function revertPluginSnapshot(pluginId, snapshotId, { currentVersion, publish } = {}) {
|
||||
return withOperation(`plugin:${pluginId}:revert`, async () => {
|
||||
emitProgress(publish, "update:revert", { target: "plugin", plugin_id: pluginId, snapshot_id: snapshotId });
|
||||
const entry = restoreSnapshot(snapshotId, {
|
||||
expectedType: "plugin",
|
||||
expectedPluginId: pluginId,
|
||||
currentVersion
|
||||
});
|
||||
syncPluginRegistry();
|
||||
return { status: "reverted", restart_required: true, snapshot: entry };
|
||||
});
|
||||
}
|
||||
|
||||
function disablePluginForRecovery(pluginId, publish) {
|
||||
setPluginEnabled(pluginId, false);
|
||||
emitProgress(publish, "recovery:plugin_disabled", { plugin_id: pluginId });
|
||||
return { status: "disabled", plugin_id: pluginId };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
applyCoreUpdate,
|
||||
applyPluginUpdateFromRepo,
|
||||
revertCoreSnapshot,
|
||||
revertPluginSnapshot,
|
||||
disablePluginForRecovery
|
||||
};
|
||||
@ -86,6 +86,8 @@ function ensureDefaults() {
|
||||
youtube_redirect_uri: envString("YOUTUBE_REDIRECT_URI", ""),
|
||||
youtube_bot_refresh_token: envString("YOUTUBE_BOT_REFRESH_TOKEN", ""),
|
||||
youtube_bot_channel_id: envString("YOUTUBE_BOT_CHANNEL_ID", ""),
|
||||
localhost_login_username: "admin",
|
||||
localhost_login_password: "admin",
|
||||
theme_light_bg_1: "#ffe5c4",
|
||||
theme_light_bg_2: "#f4efe8",
|
||||
theme_light_bg_3: "#e9f3f1",
|
||||
|
||||
750
src/services/themes.js
Normal file
750
src/services/themes.js
Normal file
@ -0,0 +1,750 @@
|
||||
const crypto = require("crypto");
|
||||
const { db } = require("./db");
|
||||
const { getSetting, setSetting } = require("./settings");
|
||||
|
||||
const DEFAULT_THEME_ID = "builtin:lumi-default";
|
||||
const THEME_SYSTEM_VERSION = 1;
|
||||
const COLOR_PATTERN = /^#[0-9a-f]{6}$/i;
|
||||
const FONT_STACKS = Object.freeze({
|
||||
lumi: {
|
||||
label: "Lumi Sans",
|
||||
stack: '"Source Sans 3", Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif'
|
||||
},
|
||||
system: {
|
||||
label: "System UI",
|
||||
stack: 'Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif'
|
||||
},
|
||||
rounded: {
|
||||
label: "Rounded",
|
||||
stack: 'ui-rounded, "Nunito Sans", "Aptos", Inter, ui-sans-serif, system-ui, sans-serif'
|
||||
},
|
||||
editorial: {
|
||||
label: "Editorial",
|
||||
stack: 'Georgia, "Times New Roman", ui-serif, serif'
|
||||
},
|
||||
mono: {
|
||||
label: "Mono",
|
||||
stack: '"Cascadia Code", "SFMono-Regular", Consolas, "Liberation Mono", monospace'
|
||||
}
|
||||
});
|
||||
|
||||
const TYPOGRAPHY_FIELDS = ["bodyFont", "displayFont", "monoFont", "baseSize", "headingScale", "controlScale"];
|
||||
|
||||
const MODE_COLOR_FIELDS = [
|
||||
"bg1",
|
||||
"bg2",
|
||||
"bg3",
|
||||
"text",
|
||||
"muted",
|
||||
"accent",
|
||||
"accentAlt",
|
||||
"success",
|
||||
"warning",
|
||||
"danger",
|
||||
"info",
|
||||
"surface",
|
||||
"surface2",
|
||||
"surface3",
|
||||
"border",
|
||||
"link",
|
||||
"buttonBg",
|
||||
"buttonText",
|
||||
"buttonHover",
|
||||
"inputBg",
|
||||
"inputBorder",
|
||||
"inputText",
|
||||
"focusRing"
|
||||
];
|
||||
|
||||
const ROLE_COLOR_FIELDS = ["public", "mod", "admin"];
|
||||
|
||||
const DEFAULT_MODE_LIGHT = {
|
||||
bg1: "#dff4f2",
|
||||
bg2: "#f5f7f8",
|
||||
bg3: "#fff1dc",
|
||||
text: "#182026",
|
||||
muted: "#5a6872",
|
||||
accent: "#176b75",
|
||||
accentAlt: "#e58b2b",
|
||||
success: "#23845b",
|
||||
warning: "#a96612",
|
||||
danger: "#bd4d4d",
|
||||
info: "#3479a8",
|
||||
surface: "#ffffff",
|
||||
surface2: "#f4f7f8",
|
||||
surface3: "#edf2f3",
|
||||
border: "#d8e0e3",
|
||||
link: "#0d6470",
|
||||
buttonBg: "#176b75",
|
||||
buttonText: "#ffffff",
|
||||
buttonHover: "#0f5660",
|
||||
inputBg: "#ffffff",
|
||||
inputBorder: "#c8d3d7",
|
||||
inputText: "#182026",
|
||||
focusRing: "#2f98a5"
|
||||
};
|
||||
|
||||
const DEFAULT_MODE_DARK = {
|
||||
bg1: "#102c31",
|
||||
bg2: "#11171b",
|
||||
bg3: "#261e18",
|
||||
text: "#f2f6f7",
|
||||
muted: "#aebbc1",
|
||||
accent: "#63c4cf",
|
||||
accentAlt: "#f0b45f",
|
||||
success: "#59c894",
|
||||
warning: "#e4b35d",
|
||||
danger: "#ef7b78",
|
||||
info: "#74b9e6",
|
||||
surface: "#1a2227",
|
||||
surface2: "#202b31",
|
||||
surface3: "#27343b",
|
||||
border: "#35434a",
|
||||
link: "#7bd3dc",
|
||||
buttonBg: "#4dafba",
|
||||
buttonText: "#08191c",
|
||||
buttonHover: "#68c8d2",
|
||||
inputBg: "#151d21",
|
||||
inputBorder: "#42535b",
|
||||
inputText: "#f2f6f7",
|
||||
focusRing: "#7bd3dc"
|
||||
};
|
||||
|
||||
const DEFAULT_THEME_VALUES = {
|
||||
light: DEFAULT_MODE_LIGHT,
|
||||
dark: DEFAULT_MODE_DARK,
|
||||
role: {
|
||||
public: "#ffffff",
|
||||
mod: "#23845b",
|
||||
admin: "#bd4d6d"
|
||||
},
|
||||
metrics: {
|
||||
radius: 14,
|
||||
shadowStrength: 0.14,
|
||||
spacingScale: 1
|
||||
},
|
||||
typography: {
|
||||
bodyFont: "lumi",
|
||||
displayFont: "system",
|
||||
monoFont: "mono",
|
||||
baseSize: 16,
|
||||
headingScale: 1,
|
||||
controlScale: 1,
|
||||
bodyFontStack: FONT_STACKS.lumi.stack,
|
||||
displayFontStack: FONT_STACKS.system.stack,
|
||||
monoFontStack: FONT_STACKS.mono.stack
|
||||
}
|
||||
};
|
||||
|
||||
function mergeMode(base, override = {}) {
|
||||
return Object.fromEntries(
|
||||
MODE_COLOR_FIELDS.map((field) => [field, override[field] || base[field]])
|
||||
);
|
||||
}
|
||||
|
||||
function createBuiltin(id, name, description, overrides = {}) {
|
||||
return Object.freeze({
|
||||
id: `builtin:${id}`,
|
||||
name,
|
||||
description,
|
||||
builtin: true,
|
||||
readOnly: true,
|
||||
baseThemeId: null,
|
||||
light: mergeMode(DEFAULT_MODE_LIGHT, overrides.light),
|
||||
dark: mergeMode(DEFAULT_MODE_DARK, overrides.dark),
|
||||
role: { ...DEFAULT_THEME_VALUES.role, ...(overrides.role || {}) },
|
||||
metrics: { ...DEFAULT_THEME_VALUES.metrics, ...(overrides.metrics || {}) },
|
||||
typography: normalizeTypography(overrides.typography, DEFAULT_THEME_VALUES.typography)
|
||||
});
|
||||
}
|
||||
|
||||
const BUILTIN_THEMES = [
|
||||
createBuiltin(
|
||||
"lumi-default",
|
||||
"Lumi Default",
|
||||
"Balanced teal and warm accents with automatic light and dark modes."
|
||||
),
|
||||
createBuiltin("lumi-dark", "Lumi Dark", "A deep, low-glare theme for dark workspaces.", {
|
||||
light: {
|
||||
bg1: "#18242a",
|
||||
bg2: "#11171b",
|
||||
bg3: "#241d19",
|
||||
text: "#f3f6f7",
|
||||
muted: "#b4c0c5",
|
||||
surface: "#1c252a",
|
||||
surface2: "#222d33",
|
||||
surface3: "#29363d",
|
||||
border: "#3b4a52",
|
||||
inputBg: "#141c20",
|
||||
inputBorder: "#465860",
|
||||
inputText: "#f3f6f7",
|
||||
accent: "#67c6d0",
|
||||
link: "#7bd3dc",
|
||||
buttonBg: "#51b4bf",
|
||||
buttonText: "#08191c",
|
||||
buttonHover: "#71d0da",
|
||||
focusRing: "#7bd3dc"
|
||||
}
|
||||
}),
|
||||
createBuiltin("lumi-light", "Lumi Light", "A crisp, bright theme with restrained shadows.", {
|
||||
dark: DEFAULT_MODE_LIGHT,
|
||||
metrics: { shadowStrength: 0.08 }
|
||||
}),
|
||||
createBuiltin("high-contrast", "High Contrast", "Maximum clarity with strong focus and status colors.", {
|
||||
light: {
|
||||
bg1: "#ffffff",
|
||||
bg2: "#ffffff",
|
||||
bg3: "#f2f2f2",
|
||||
text: "#000000",
|
||||
muted: "#303030",
|
||||
accent: "#004f5a",
|
||||
accentAlt: "#8a4300",
|
||||
success: "#006b3c",
|
||||
warning: "#7a4700",
|
||||
danger: "#a00000",
|
||||
info: "#004b88",
|
||||
surface: "#ffffff",
|
||||
surface2: "#f5f5f5",
|
||||
surface3: "#e8e8e8",
|
||||
border: "#555555",
|
||||
link: "#003f99",
|
||||
buttonBg: "#003f49",
|
||||
buttonText: "#ffffff",
|
||||
buttonHover: "#002c33",
|
||||
inputBg: "#ffffff",
|
||||
inputBorder: "#333333",
|
||||
inputText: "#000000",
|
||||
focusRing: "#005fcc"
|
||||
},
|
||||
dark: {
|
||||
bg1: "#000000",
|
||||
bg2: "#000000",
|
||||
bg3: "#101010",
|
||||
text: "#ffffff",
|
||||
muted: "#d6d6d6",
|
||||
accent: "#67e8f9",
|
||||
accentAlt: "#ffd166",
|
||||
success: "#65e6a3",
|
||||
warning: "#ffd166",
|
||||
danger: "#ff8c8c",
|
||||
info: "#8fd3ff",
|
||||
surface: "#080808",
|
||||
surface2: "#151515",
|
||||
surface3: "#222222",
|
||||
border: "#aaaaaa",
|
||||
link: "#8fd3ff",
|
||||
buttonBg: "#a5f3fc",
|
||||
buttonText: "#000000",
|
||||
buttonHover: "#ffffff",
|
||||
inputBg: "#000000",
|
||||
inputBorder: "#dddddd",
|
||||
inputText: "#ffffff",
|
||||
focusRing: "#ffffff"
|
||||
},
|
||||
metrics: { radius: 8, shadowStrength: 0, spacingScale: 1.05 }
|
||||
}),
|
||||
createBuiltin("midnight", "Midnight", "Cool blue surfaces with violet highlights.", {
|
||||
light: {
|
||||
bg1: "#dce8ff",
|
||||
bg2: "#f4f6fb",
|
||||
bg3: "#eee8ff",
|
||||
accent: "#4457a6",
|
||||
accentAlt: "#8258b7",
|
||||
link: "#354a9b",
|
||||
buttonBg: "#4457a6",
|
||||
buttonHover: "#34448a",
|
||||
focusRing: "#6f82d8"
|
||||
},
|
||||
dark: {
|
||||
bg1: "#10182f",
|
||||
bg2: "#0b1020",
|
||||
bg3: "#211630",
|
||||
surface: "#141c32",
|
||||
surface2: "#19233d",
|
||||
surface3: "#202c49",
|
||||
border: "#334160",
|
||||
accent: "#91a4ff",
|
||||
accentAlt: "#c49aff",
|
||||
link: "#aab8ff",
|
||||
buttonBg: "#91a4ff",
|
||||
buttonText: "#0b1020",
|
||||
buttonHover: "#b0bcff",
|
||||
focusRing: "#c49aff"
|
||||
}
|
||||
}),
|
||||
createBuiltin("soft-aurora", "Soft Aurora", "A gentle mint, lavender, and coral palette.", {
|
||||
light: {
|
||||
bg1: "#dcf8ee",
|
||||
bg2: "#f8f5fb",
|
||||
bg3: "#ffe9e4",
|
||||
accent: "#397f70",
|
||||
accentAlt: "#986aa8",
|
||||
link: "#306f63",
|
||||
buttonBg: "#397f70",
|
||||
buttonHover: "#2d665a",
|
||||
focusRing: "#8a6fa8"
|
||||
},
|
||||
dark: {
|
||||
bg1: "#17352f",
|
||||
bg2: "#171a22",
|
||||
bg3: "#38242e",
|
||||
surface: "#20262d",
|
||||
surface2: "#283038",
|
||||
surface3: "#313b44",
|
||||
border: "#43505a",
|
||||
accent: "#82d7c1",
|
||||
accentAlt: "#d4a7e1",
|
||||
link: "#9ce7d4",
|
||||
buttonBg: "#72c9b3",
|
||||
buttonText: "#10231f",
|
||||
buttonHover: "#96e3d0",
|
||||
focusRing: "#d4a7e1"
|
||||
},
|
||||
metrics: { radius: 18, shadowStrength: 0.1, spacingScale: 1.05 }
|
||||
})
|
||||
];
|
||||
|
||||
const BUILTIN_MAP = new Map(BUILTIN_THEMES.map((theme) => [theme.id, theme]));
|
||||
|
||||
function cloneTheme(theme) {
|
||||
return JSON.parse(JSON.stringify(theme));
|
||||
}
|
||||
|
||||
function getBuiltinTheme(id = DEFAULT_THEME_ID) {
|
||||
return BUILTIN_MAP.get(id) || BUILTIN_MAP.get(DEFAULT_THEME_ID);
|
||||
}
|
||||
|
||||
function customKey(id) {
|
||||
return `custom:${id}`;
|
||||
}
|
||||
|
||||
function customId(themeId) {
|
||||
return String(themeId || "").startsWith("custom:")
|
||||
? String(themeId).slice("custom:".length)
|
||||
: null;
|
||||
}
|
||||
|
||||
function normalizeThemeValues(values, baseTheme = getBuiltinTheme()) {
|
||||
const source = values && typeof values === "object" ? values : {};
|
||||
const normalized = {
|
||||
light: mergeMode(baseTheme.light, source.light),
|
||||
dark: mergeMode(baseTheme.dark, source.dark),
|
||||
role: { ...baseTheme.role, ...(source.role || {}) },
|
||||
metrics: { ...baseTheme.metrics, ...(source.metrics || {}) },
|
||||
typography: normalizeTypography(source.typography, baseTheme.typography)
|
||||
};
|
||||
|
||||
for (const mode of ["light", "dark"]) {
|
||||
for (const field of MODE_COLOR_FIELDS) {
|
||||
if (!COLOR_PATTERN.test(normalized[mode][field])) {
|
||||
normalized[mode][field] = baseTheme[mode][field];
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const field of ROLE_COLOR_FIELDS) {
|
||||
if (!COLOR_PATTERN.test(normalized.role[field])) {
|
||||
normalized.role[field] = baseTheme.role[field];
|
||||
}
|
||||
}
|
||||
normalized.metrics.radius = clampNumber(
|
||||
normalized.metrics.radius,
|
||||
0,
|
||||
32,
|
||||
baseTheme.metrics.radius
|
||||
);
|
||||
normalized.metrics.shadowStrength = clampNumber(
|
||||
normalized.metrics.shadowStrength,
|
||||
0,
|
||||
0.35,
|
||||
baseTheme.metrics.shadowStrength
|
||||
);
|
||||
normalized.metrics.spacingScale = clampNumber(
|
||||
normalized.metrics.spacingScale,
|
||||
0.75,
|
||||
1.35,
|
||||
baseTheme.metrics.spacingScale
|
||||
);
|
||||
for (const mode of ["light", "dark"]) {
|
||||
if (contrastRatio(normalized[mode].text, normalized[mode].surface) < 4.5) {
|
||||
normalized[mode].text = baseTheme[mode].text;
|
||||
normalized[mode].surface = baseTheme[mode].surface;
|
||||
}
|
||||
if (contrastRatio(normalized[mode].buttonText, normalized[mode].buttonBg) < 4.5) {
|
||||
normalized[mode].buttonText = baseTheme[mode].buttonText;
|
||||
normalized[mode].buttonBg = baseTheme[mode].buttonBg;
|
||||
}
|
||||
if (contrastRatio(normalized[mode].inputText, normalized[mode].inputBg) < 4.5) {
|
||||
normalized[mode].inputText = baseTheme[mode].inputText;
|
||||
normalized[mode].inputBg = baseTheme[mode].inputBg;
|
||||
}
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function normalizeFontKey(value, fallback) {
|
||||
return Object.prototype.hasOwnProperty.call(FONT_STACKS, value) ? value : fallback;
|
||||
}
|
||||
|
||||
function normalizeTypography(values = {}, fallback = DEFAULT_THEME_VALUES.typography) {
|
||||
const bodyFont = normalizeFontKey(values.bodyFont, fallback.bodyFont);
|
||||
const displayFont = normalizeFontKey(values.displayFont, fallback.displayFont);
|
||||
const monoFont = normalizeFontKey(values.monoFont, fallback.monoFont);
|
||||
return {
|
||||
bodyFont,
|
||||
displayFont,
|
||||
monoFont,
|
||||
baseSize: clampNumber(values.baseSize, 14, 19, fallback.baseSize),
|
||||
headingScale: clampNumber(values.headingScale, 0.9, 1.2, fallback.headingScale),
|
||||
controlScale: clampNumber(values.controlScale, 0.9, 1.12, fallback.controlScale),
|
||||
bodyFontStack: FONT_STACKS[bodyFont].stack,
|
||||
displayFontStack: FONT_STACKS[displayFont].stack,
|
||||
monoFontStack: FONT_STACKS[monoFont].stack
|
||||
};
|
||||
}
|
||||
|
||||
function clampNumber(value, min, max, fallback) {
|
||||
const parsed = Number(value);
|
||||
if (!Number.isFinite(parsed)) return fallback;
|
||||
return Math.min(max, Math.max(min, parsed));
|
||||
}
|
||||
|
||||
function parseHex(value) {
|
||||
const raw = String(value || "").slice(1);
|
||||
return [0, 2, 4].map((index) => Number.parseInt(raw.slice(index, index + 2), 16));
|
||||
}
|
||||
|
||||
function relativeLuminance(value) {
|
||||
const channels = parseHex(value).map((channel) => {
|
||||
const normalized = channel / 255;
|
||||
return normalized <= 0.03928
|
||||
? normalized / 12.92
|
||||
: Math.pow((normalized + 0.055) / 1.055, 2.4);
|
||||
});
|
||||
return 0.2126 * channels[0] + 0.7152 * channels[1] + 0.0722 * channels[2];
|
||||
}
|
||||
|
||||
function contrastRatio(left, right) {
|
||||
const a = relativeLuminance(left);
|
||||
const b = relativeLuminance(right);
|
||||
return (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05);
|
||||
}
|
||||
|
||||
function validateThemeValues(values) {
|
||||
const errors = [];
|
||||
for (const mode of ["light", "dark"]) {
|
||||
for (const field of MODE_COLOR_FIELDS) {
|
||||
if (!COLOR_PATTERN.test(String(values?.[mode]?.[field] || ""))) {
|
||||
errors.push(`${mode}.${field} must be a six-digit hex color.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const field of ROLE_COLOR_FIELDS) {
|
||||
if (!COLOR_PATTERN.test(String(values?.role?.[field] || ""))) {
|
||||
errors.push(`role.${field} must be a six-digit hex color.`);
|
||||
}
|
||||
}
|
||||
|
||||
const metricRules = [
|
||||
["radius", 0, 32],
|
||||
["shadowStrength", 0, 0.35],
|
||||
["spacingScale", 0.75, 1.35]
|
||||
];
|
||||
for (const [field, min, max] of metricRules) {
|
||||
const value = Number(values?.metrics?.[field]);
|
||||
if (!Number.isFinite(value) || value < min || value > max) {
|
||||
errors.push(`metrics.${field} must be between ${min} and ${max}.`);
|
||||
}
|
||||
}
|
||||
|
||||
for (const field of ["bodyFont", "displayFont", "monoFont"]) {
|
||||
if (!Object.prototype.hasOwnProperty.call(FONT_STACKS, values?.typography?.[field])) {
|
||||
errors.push(`typography.${field} must be a supported font preset.`);
|
||||
}
|
||||
}
|
||||
|
||||
const typographyRules = [
|
||||
["baseSize", 14, 19],
|
||||
["headingScale", 0.9, 1.2],
|
||||
["controlScale", 0.9, 1.12]
|
||||
];
|
||||
for (const [field, min, max] of typographyRules) {
|
||||
const value = Number(values?.typography?.[field]);
|
||||
if (!Number.isFinite(value) || value < min || value > max) {
|
||||
errors.push(`typography.${field} must be between ${min} and ${max}.`);
|
||||
}
|
||||
}
|
||||
|
||||
if (!errors.length) {
|
||||
for (const mode of ["light", "dark"]) {
|
||||
if (contrastRatio(values[mode].text, values[mode].surface) < 4.5) {
|
||||
errors.push(`${mode} text and surface colors need at least 4.5:1 contrast.`);
|
||||
}
|
||||
if (contrastRatio(values[mode].buttonText, values[mode].buttonBg) < 4.5) {
|
||||
errors.push(`${mode} button text and background need at least 4.5:1 contrast.`);
|
||||
}
|
||||
if (contrastRatio(values[mode].inputText, values[mode].inputBg) < 4.5) {
|
||||
errors.push(`${mode} input text and background need at least 4.5:1 contrast.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
|
||||
function legacyThemeValues() {
|
||||
const legacy = {
|
||||
light: {
|
||||
bg1: getSetting("theme_light_bg_1", "#ffe5c4"),
|
||||
bg2: getSetting("theme_light_bg_2", "#f4efe8"),
|
||||
bg3: getSetting("theme_light_bg_3", "#e9f3f1"),
|
||||
text: getSetting("theme_light_text", "#121518"),
|
||||
muted: getSetting("theme_light_text_muted", "#2c3137"),
|
||||
accent: getSetting("theme_light_accent", "#0f6a78"),
|
||||
accentAlt: getSetting("theme_light_accent_alt", "#f4a340"),
|
||||
danger: getSetting("theme_light_danger", "#d66d5c"),
|
||||
surface: getSetting("theme_light_surface", "#ffffff"),
|
||||
surface2: getSetting("theme_light_surface_2", "#fbf9f6"),
|
||||
surface3: getSetting("theme_light_surface_3", "#f9f5ef"),
|
||||
border: getSetting("theme_light_border", "#e3ddd6")
|
||||
},
|
||||
dark: {
|
||||
bg1: getSetting("theme_dark_bg_1", "#1b1d1f"),
|
||||
bg2: getSetting("theme_dark_bg_2", "#16181b"),
|
||||
bg3: getSetting("theme_dark_bg_3", "#0f1113"),
|
||||
text: getSetting("theme_dark_text", "#f2f0ec"),
|
||||
muted: getSetting("theme_dark_text_muted", "#c5bfb7"),
|
||||
accent: getSetting("theme_dark_accent", "#4fb6c2"),
|
||||
accentAlt: getSetting("theme_dark_accent_alt", "#f1b765"),
|
||||
danger: getSetting("theme_dark_danger", "#e08173"),
|
||||
surface: getSetting("theme_dark_surface", "#232629"),
|
||||
surface2: getSetting("theme_dark_surface_2", "#2b2f33"),
|
||||
surface3: getSetting("theme_dark_surface_3", "#30353a"),
|
||||
border: getSetting("theme_dark_border", "#34393d")
|
||||
},
|
||||
role: {
|
||||
public: getSetting("theme_role_public", "#ffffff"),
|
||||
mod: getSetting("theme_role_mod", "#2cb678"),
|
||||
admin: getSetting("theme_role_admin", "#e35678")
|
||||
}
|
||||
};
|
||||
return normalizeThemeValues(legacy, getBuiltinTheme());
|
||||
}
|
||||
|
||||
function legacyWasCustomized(values) {
|
||||
const defaults = {
|
||||
light: {
|
||||
bg1: "#ffe5c4", bg2: "#f4efe8", bg3: "#e9f3f1", text: "#121518",
|
||||
muted: "#2c3137", accent: "#0f6a78", accentAlt: "#f4a340",
|
||||
danger: "#d66d5c", surface: "#ffffff", surface2: "#fbf9f6",
|
||||
surface3: "#f9f5ef", border: "#e3ddd6"
|
||||
},
|
||||
dark: {
|
||||
bg1: "#1b1d1f", bg2: "#16181b", bg3: "#0f1113", text: "#f2f0ec",
|
||||
muted: "#c5bfb7", accent: "#4fb6c2", accentAlt: "#f1b765",
|
||||
danger: "#e08173", surface: "#232629", surface2: "#2b2f33",
|
||||
surface3: "#30353a", border: "#34393d"
|
||||
},
|
||||
role: { public: "#ffffff", mod: "#2cb678", admin: "#e35678" }
|
||||
};
|
||||
return ["light", "dark", "role"].some((group) =>
|
||||
Object.entries(defaults[group]).some(([key, value]) => values[group][key] !== value)
|
||||
);
|
||||
}
|
||||
|
||||
function ensureThemeMigration() {
|
||||
if (Number(getSetting("theme_system_version", 0)) >= THEME_SYSTEM_VERSION) return;
|
||||
const legacy = legacyThemeValues();
|
||||
setSetting("theme_system_version", THEME_SYSTEM_VERSION);
|
||||
if (legacyWasCustomized(legacy)) {
|
||||
const theme = insertCustomTheme("Migrated Theme", DEFAULT_THEME_ID, legacy);
|
||||
setSetting("theme_active_id", theme.id);
|
||||
} else {
|
||||
setSetting("theme_active_id", DEFAULT_THEME_ID);
|
||||
}
|
||||
}
|
||||
|
||||
function rowToTheme(row) {
|
||||
const base = getBuiltinTheme(row.base_theme_id);
|
||||
let stored = {};
|
||||
try {
|
||||
stored = JSON.parse(row.values_json);
|
||||
} catch {
|
||||
stored = {};
|
||||
}
|
||||
const values = normalizeThemeValues(stored, base);
|
||||
return {
|
||||
id: customKey(row.id),
|
||||
name: row.name,
|
||||
description: `Custom theme based on ${base.name}.`,
|
||||
builtin: false,
|
||||
readOnly: false,
|
||||
baseThemeId: base.id,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
...values
|
||||
};
|
||||
}
|
||||
|
||||
function getThemeById(themeId) {
|
||||
ensureThemeMigration();
|
||||
if (BUILTIN_MAP.has(themeId)) return cloneTheme(BUILTIN_MAP.get(themeId));
|
||||
const id = customId(themeId);
|
||||
if (!id) return null;
|
||||
const row = db.prepare("SELECT * FROM custom_themes WHERE id = ?").get(id);
|
||||
return row ? rowToTheme(row) : null;
|
||||
}
|
||||
|
||||
function listThemes() {
|
||||
ensureThemeMigration();
|
||||
const custom = db
|
||||
.prepare("SELECT * FROM custom_themes ORDER BY lower(name), created_at")
|
||||
.all()
|
||||
.map(rowToTheme);
|
||||
return [...BUILTIN_THEMES.map(cloneTheme), ...custom];
|
||||
}
|
||||
|
||||
function getActiveTheme() {
|
||||
ensureThemeMigration();
|
||||
const requested = getSetting("theme_active_id", DEFAULT_THEME_ID);
|
||||
const theme = getThemeById(requested) || cloneTheme(getBuiltinTheme());
|
||||
if (theme.id !== requested) setSetting("theme_active_id", theme.id);
|
||||
return theme;
|
||||
}
|
||||
|
||||
function setActiveTheme(themeId) {
|
||||
const theme = getThemeById(themeId);
|
||||
if (!theme) throw new Error("Theme not found.");
|
||||
setSetting("theme_active_id", theme.id);
|
||||
return theme;
|
||||
}
|
||||
|
||||
function cleanName(value) {
|
||||
const name = String(value || "").trim().replace(/\s+/g, " ");
|
||||
if (name.length < 2 || name.length > 60) {
|
||||
throw new Error("Theme name must be between 2 and 60 characters.");
|
||||
}
|
||||
return name;
|
||||
}
|
||||
|
||||
function insertCustomTheme(name, baseThemeId, values) {
|
||||
const clean = cleanName(name);
|
||||
const base = getBuiltinTheme(baseThemeId);
|
||||
const normalized = normalizeThemeValues(values, base);
|
||||
const errors = validateThemeValues(normalized);
|
||||
if (errors.length) throw new Error(errors[0]);
|
||||
const id = crypto.randomUUID();
|
||||
const now = Date.now();
|
||||
db.prepare(
|
||||
"INSERT INTO custom_themes (id, name, base_theme_id, values_json, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)"
|
||||
).run(id, clean, base.id, JSON.stringify(normalized), now, now);
|
||||
return getThemeById(customKey(id));
|
||||
}
|
||||
|
||||
function duplicateTheme(themeId, name) {
|
||||
const source = getThemeById(themeId);
|
||||
if (!source) throw new Error("Theme not found.");
|
||||
return insertCustomTheme(
|
||||
name || `${source.name} Copy`,
|
||||
source.builtin ? source.id : source.baseThemeId,
|
||||
source
|
||||
);
|
||||
}
|
||||
|
||||
function saveCustomTheme(themeId, values) {
|
||||
const id = customId(themeId);
|
||||
if (!id) throw new Error("Built-in themes are read-only.");
|
||||
const current = getThemeById(themeId);
|
||||
if (!current) throw new Error("Theme not found.");
|
||||
const normalized = normalizeThemeValues(values, getBuiltinTheme(current.baseThemeId));
|
||||
const errors = validateThemeValues(values);
|
||||
if (errors.length) {
|
||||
const error = new Error(errors[0]);
|
||||
error.validationErrors = errors;
|
||||
throw error;
|
||||
}
|
||||
db.prepare(
|
||||
"UPDATE custom_themes SET values_json = ?, updated_at = ? WHERE id = ?"
|
||||
).run(JSON.stringify(normalized), Date.now(), id);
|
||||
return getThemeById(themeId);
|
||||
}
|
||||
|
||||
function renameCustomTheme(themeId, name) {
|
||||
const id = customId(themeId);
|
||||
if (!id) throw new Error("Built-in themes cannot be renamed.");
|
||||
const result = db
|
||||
.prepare("UPDATE custom_themes SET name = ?, updated_at = ? WHERE id = ?")
|
||||
.run(cleanName(name), Date.now(), id);
|
||||
if (!result.changes) throw new Error("Theme not found.");
|
||||
return getThemeById(themeId);
|
||||
}
|
||||
|
||||
function deleteCustomTheme(themeId) {
|
||||
const id = customId(themeId);
|
||||
if (!id) throw new Error("Built-in themes cannot be deleted.");
|
||||
const activeId = getSetting("theme_active_id", DEFAULT_THEME_ID);
|
||||
const result = db.prepare("DELETE FROM custom_themes WHERE id = ?").run(id);
|
||||
if (!result.changes) throw new Error("Theme not found.");
|
||||
if (activeId === themeId) setSetting("theme_active_id", DEFAULT_THEME_ID);
|
||||
}
|
||||
|
||||
function valuesFromRequest(body, fallbackTheme = getBuiltinTheme()) {
|
||||
const values = { light: {}, dark: {}, role: {}, metrics: {}, typography: {} };
|
||||
for (const mode of ["light", "dark"]) {
|
||||
for (const field of MODE_COLOR_FIELDS) {
|
||||
values[mode][field] = String(
|
||||
body?.[`${mode}_${field}`] ?? fallbackTheme[mode][field]
|
||||
).trim();
|
||||
}
|
||||
}
|
||||
for (const field of ROLE_COLOR_FIELDS) {
|
||||
values.role[field] = String(
|
||||
body?.[`role_${field}`] ?? fallbackTheme.role[field]
|
||||
).trim();
|
||||
}
|
||||
values.metrics.radius = Number(body?.metrics_radius ?? fallbackTheme.metrics.radius);
|
||||
values.metrics.shadowStrength = Number(
|
||||
body?.metrics_shadowStrength ?? fallbackTheme.metrics.shadowStrength
|
||||
);
|
||||
values.metrics.spacingScale = Number(
|
||||
body?.metrics_spacingScale ?? fallbackTheme.metrics.spacingScale
|
||||
);
|
||||
values.typography.bodyFont = String(
|
||||
body?.typography_bodyFont ?? fallbackTheme.typography.bodyFont
|
||||
);
|
||||
values.typography.displayFont = String(
|
||||
body?.typography_displayFont ?? fallbackTheme.typography.displayFont
|
||||
);
|
||||
values.typography.monoFont = String(
|
||||
body?.typography_monoFont ?? fallbackTheme.typography.monoFont
|
||||
);
|
||||
values.typography.baseSize = Number(
|
||||
body?.typography_baseSize ?? fallbackTheme.typography.baseSize
|
||||
);
|
||||
values.typography.headingScale = Number(
|
||||
body?.typography_headingScale ?? fallbackTheme.typography.headingScale
|
||||
);
|
||||
values.typography.controlScale = Number(
|
||||
body?.typography_controlScale ?? fallbackTheme.typography.controlScale
|
||||
);
|
||||
return values;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
BUILTIN_THEMES,
|
||||
DEFAULT_THEME_ID,
|
||||
FONT_STACKS,
|
||||
MODE_COLOR_FIELDS,
|
||||
ROLE_COLOR_FIELDS,
|
||||
TYPOGRAPHY_FIELDS,
|
||||
contrastRatio,
|
||||
deleteCustomTheme,
|
||||
duplicateTheme,
|
||||
getActiveTheme,
|
||||
getThemeById,
|
||||
listThemes,
|
||||
normalizeThemeValues,
|
||||
renameCustomTheme,
|
||||
saveCustomTheme,
|
||||
setActiveTheme,
|
||||
validateThemeValues,
|
||||
valuesFromRequest
|
||||
};
|
||||
290
src/services/update-index.js
Normal file
290
src/services/update-index.js
Normal file
@ -0,0 +1,290 @@
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const { spawnSync } = require("child_process");
|
||||
const { getSetting } = require("./settings");
|
||||
const { scanPluginDirectories, getPlugins } = require("./plugins");
|
||||
const {
|
||||
parseSemver,
|
||||
compareSemver,
|
||||
findSafeTarget,
|
||||
collectChangelogRange,
|
||||
normalizeVersions
|
||||
} = require("./versioning");
|
||||
const { listSnapshots } = require("./update-manager");
|
||||
const { safeModeStatus } = require("./recovery-mode");
|
||||
|
||||
const repoRoot = path.join(__dirname, "..", "..");
|
||||
|
||||
function runGit(args, options = {}) {
|
||||
const result = spawnSync("git", args, {
|
||||
cwd: repoRoot,
|
||||
encoding: "utf8",
|
||||
timeout: options.timeout || 20000
|
||||
});
|
||||
if (result.status !== 0) {
|
||||
throw new Error((result.stderr || result.stdout || "Git command failed.").trim());
|
||||
}
|
||||
return result.stdout.trim();
|
||||
}
|
||||
|
||||
function tryGit(args, fallback = "") {
|
||||
try {
|
||||
return runGit(args);
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
function fetchRemote(remote) {
|
||||
runGit(["fetch", "--prune", remote]);
|
||||
}
|
||||
|
||||
function remoteRef(remote, branch) {
|
||||
return `${remote}/${branch}`;
|
||||
}
|
||||
|
||||
function readGitFile(ref, filePath) {
|
||||
const output = tryGit(["show", `${ref}:${filePath}`], "");
|
||||
return output || null;
|
||||
}
|
||||
|
||||
function readJsonGitFile(ref, filePath) {
|
||||
const raw = readGitFile(ref, filePath);
|
||||
if (!raw) return null;
|
||||
try {
|
||||
return JSON.parse(raw);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function readLocalJson(filePath) {
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(path.join(repoRoot, filePath), "utf8"));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveSourceBranch(remote, requested = "stable") {
|
||||
if (requested === "experimental") {
|
||||
const refs = tryGit([
|
||||
"for-each-ref",
|
||||
"--format=%(refname:short)|%(committerdate:iso8601)",
|
||||
`refs/remotes/${remote}/experimental-*`
|
||||
], "");
|
||||
const branches = refs
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
.map((line) => {
|
||||
const [ref, date] = line.split("|");
|
||||
return { branch: ref.replace(`${remote}/`, ""), date };
|
||||
})
|
||||
.sort((a, b) => String(b.date).localeCompare(String(a.date)));
|
||||
if (branches[0]) return branches[0].branch;
|
||||
}
|
||||
if (requested && requested !== "stable" && requested !== "main") {
|
||||
return String(requested).replace(/^origin\//, "");
|
||||
}
|
||||
return "main";
|
||||
}
|
||||
|
||||
function parseMarkdownChangelog(raw) {
|
||||
if (!raw) return [];
|
||||
const entries = [];
|
||||
const lines = raw.split(/\r?\n/);
|
||||
let current = null;
|
||||
for (const line of lines) {
|
||||
const heading = line.match(/^#{1,3}\s+\[?v?(\d+\.\d+\.\d+)\]?(.+)?$/i);
|
||||
if (heading) {
|
||||
if (current) entries.push(current);
|
||||
current = { version: heading[1], title: line.replace(/^#+\s+/, ""), changes: [] };
|
||||
} else if (current && line.trim()) {
|
||||
current.changes.push(line.trim().replace(/^[-*]\s*/, ""));
|
||||
}
|
||||
}
|
||||
if (current) entries.push(current);
|
||||
return entries;
|
||||
}
|
||||
|
||||
function changelogEntries(ref, basePath = "") {
|
||||
const json = readJsonGitFile(ref, path.posix.join(basePath, "changelog.json"));
|
||||
if (Array.isArray(json)) return json;
|
||||
if (Array.isArray(json?.versions)) return json.versions;
|
||||
const md = readGitFile(ref, path.posix.join(basePath, "CHANGELOG.md"));
|
||||
return parseMarkdownChangelog(md);
|
||||
}
|
||||
|
||||
function manifestVersions(manifest, fallbackVersion) {
|
||||
const entries = [];
|
||||
if (Array.isArray(manifest?.versions)) entries.push(...manifest.versions);
|
||||
if (manifest?.version) entries.push({ ...manifest, version: manifest.version });
|
||||
if (fallbackVersion) entries.push({ version: fallbackVersion });
|
||||
return normalizeVersions(entries);
|
||||
}
|
||||
|
||||
function manifestRawVersion(manifest, fallbackVersion) {
|
||||
return String(manifest?.version || fallbackVersion || "").trim();
|
||||
}
|
||||
|
||||
function coreManifest(ref) {
|
||||
return readJsonGitFile(ref, "update-manifest.json") ||
|
||||
readJsonGitFile(ref, "lumi.manifest.json") ||
|
||||
readJsonGitFile(ref, "package.json") ||
|
||||
{};
|
||||
}
|
||||
|
||||
function localCoreVersion() {
|
||||
return readLocalJson("package.json")?.version || "0.0.0";
|
||||
}
|
||||
|
||||
function latestEntry(entries) {
|
||||
return entries.length ? entries[entries.length - 1] : null;
|
||||
}
|
||||
|
||||
function snapshotAvailability(kind, id = null) {
|
||||
const snapshots = listSnapshots()
|
||||
.filter((snap) => snap.type === kind || (kind === "core" && snap.type === "bot"))
|
||||
.filter((snap) => !id || snap.pluginId === id)
|
||||
.sort((a, b) => b.createdAt - a.createdAt);
|
||||
const latest = snapshots[0] || null;
|
||||
return {
|
||||
available: Boolean(latest),
|
||||
latest_snapshot_id: latest?.id || null,
|
||||
rollback_safe: latest?.rollback_safe !== false,
|
||||
latest
|
||||
};
|
||||
}
|
||||
|
||||
function buildStatus({ kind, id, name, currentVersion, manifest, changelog, sourceBranch, channel }) {
|
||||
const versions = manifestVersions(manifest, manifest?.version);
|
||||
const rawVersion = manifestRawVersion(manifest);
|
||||
const targetVersion = rawVersion || latestEntry(versions)?.version || "";
|
||||
const currentIsVersioned = Boolean(parseSemver(currentVersion));
|
||||
const targetIsVersioned = Boolean(parseSemver(targetVersion));
|
||||
const unversionedUpdate = !currentIsVersioned || !targetIsVersioned;
|
||||
const latest = latestEntry(versions);
|
||||
const unversionedTarget = {
|
||||
...(latest || manifest || {}),
|
||||
version: targetVersion || "unversioned",
|
||||
rollback_safe: false,
|
||||
unversioned: true
|
||||
};
|
||||
const targetResult = unversionedUpdate
|
||||
? {
|
||||
target: unversionedTarget,
|
||||
latest: latest || unversionedTarget,
|
||||
blocked: false,
|
||||
warning: "This update involves an unversioned source or target. It is available as a manual repo update, but version ordering, changelog range, and rollback safety cannot be verified."
|
||||
}
|
||||
: findSafeTarget(currentVersion, versions);
|
||||
const target = targetResult.target;
|
||||
const range = target && !unversionedUpdate
|
||||
? collectChangelogRange(currentVersion, target.version, changelog.length ? changelog : versions)
|
||||
: changelog;
|
||||
const warnings = [];
|
||||
const dangers = [];
|
||||
if (!changelog.length) warnings.push("Changelog metadata is missing.");
|
||||
if (targetResult.warning) warnings.push(targetResult.warning);
|
||||
if (targetResult.blocked) dangers.push(targetResult.reason);
|
||||
if (target?.rollback_safe === false) warnings.push("Target metadata marks rollback as unsafe after migration.");
|
||||
return {
|
||||
kind,
|
||||
id: id || kind,
|
||||
name: name || manifest?.name || id || "Lumi core",
|
||||
current_version: currentVersion,
|
||||
latest_available_version: latest?.version || rawVersion || currentVersion,
|
||||
safe_target_version: target?.version || null,
|
||||
update_available: Boolean(
|
||||
target && (unversionedUpdate || compareSemver(target.version, currentVersion) > 0)
|
||||
),
|
||||
blocked: Boolean(targetResult.blocked),
|
||||
blocked_reason: targetResult.reason || null,
|
||||
source_branch: sourceBranch,
|
||||
channel: manifest?.channel || channel || "stable",
|
||||
version_description: target
|
||||
? `${currentVersion} -> ${target.version}`
|
||||
: targetResult.reason || "No safe update target available.",
|
||||
changelog_range: range,
|
||||
size_delta: target?.size || manifest?.size || null,
|
||||
size_delta_label: target?.size || manifest?.size ? String(target.size || manifest.size) : "unknown",
|
||||
warnings,
|
||||
dangers,
|
||||
unversioned_update: unversionedUpdate,
|
||||
requires_manual_confirmation: unversionedUpdate,
|
||||
requirements: target?.requirements || manifest?.requirements || [],
|
||||
migration_notes: target?.migration_notes || manifest?.migration_notes || "",
|
||||
rollback_safe: target?.rollback_safe !== false,
|
||||
major_crossing: target && !unversionedUpdate ? target.version.split(".")[0] !== String(currentVersion).split(".")[0] : false,
|
||||
snapshot: snapshotAvailability(kind === "plugin" ? "plugin" : "bot", id),
|
||||
raw_target: target || null
|
||||
};
|
||||
}
|
||||
|
||||
function remotePluginDirs(ref) {
|
||||
const output = tryGit(["ls-tree", "-d", "--name-only", `${ref}:plugins`], "");
|
||||
return output
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
.map((item) => item.replace(/^plugins\//, ""));
|
||||
}
|
||||
|
||||
function getUpdateStatus(options = {}) {
|
||||
const remote = options.remote || getSetting("git_remote", "origin");
|
||||
const requestedSource = options.source || "stable";
|
||||
fetchRemote(remote);
|
||||
const sourceBranch = resolveSourceBranch(remote, requestedSource);
|
||||
const ref = remoteRef(remote, sourceBranch);
|
||||
const core = buildStatus({
|
||||
kind: "core",
|
||||
currentVersion: localCoreVersion(),
|
||||
manifest: coreManifest(ref),
|
||||
changelog: changelogEntries(ref),
|
||||
sourceBranch,
|
||||
channel: requestedSource === "experimental" ? "experimental" : "stable"
|
||||
});
|
||||
const installed = scanPluginDirectories();
|
||||
const registry = new Map(getPlugins().map((plugin) => [plugin.id, plugin]));
|
||||
const remoteDirs = new Set(remotePluginDirs(ref));
|
||||
const plugins = installed.map((plugin) => {
|
||||
const basePath = `plugins/${plugin.id}`;
|
||||
const manifest = readJsonGitFile(ref, `${basePath}/plugin.json`) || {};
|
||||
const changelog = changelogEntries(ref, basePath);
|
||||
const dbPlugin = registry.get(plugin.id);
|
||||
return buildStatus({
|
||||
kind: "plugin",
|
||||
id: plugin.id,
|
||||
name: plugin.name,
|
||||
currentVersion: dbPlugin?.version || plugin.version || "0.0.0",
|
||||
manifest: remoteDirs.has(plugin.id) ? manifest : { version: plugin.version, name: plugin.name },
|
||||
changelog,
|
||||
sourceBranch,
|
||||
channel: requestedSource === "experimental" ? "experimental" : "stable"
|
||||
});
|
||||
});
|
||||
return {
|
||||
generated_at: new Date().toISOString(),
|
||||
source_branch: sourceBranch,
|
||||
requested_source: requestedSource,
|
||||
remote,
|
||||
core,
|
||||
plugins,
|
||||
plugins_summary: {
|
||||
installed_plugins: installed.length,
|
||||
total_plugins: remoteDirs.size || installed.length,
|
||||
updatable_plugins: plugins.filter((plugin) => plugin.update_available).length,
|
||||
blocked_plugins: plugins.filter((plugin) => plugin.blocked).length
|
||||
},
|
||||
recovery: safeModeStatus()
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getUpdateStatus,
|
||||
resolveSourceBranch,
|
||||
fetchRemote,
|
||||
runGit,
|
||||
readGitFile
|
||||
};
|
||||
@ -9,6 +9,12 @@ try {
|
||||
AdmZip = null;
|
||||
}
|
||||
const { db } = require("./db");
|
||||
const {
|
||||
createRecoveryMarker,
|
||||
updateRecoveryMarker,
|
||||
markRecoveryMarkerFailed,
|
||||
markRecoveryMarkerComplete
|
||||
} = require("./recovery-mode");
|
||||
|
||||
const repoRoot = path.join(__dirname, "..", "..");
|
||||
const dataDir = path.join(repoRoot, "data");
|
||||
@ -49,7 +55,7 @@ async function backupDatabase(targetPath) {
|
||||
}
|
||||
}
|
||||
|
||||
async function createSnapshot({ type, pluginId }) {
|
||||
async function createSnapshot({ type, pluginId, metadata = {} }) {
|
||||
ensureSnapshotsDir();
|
||||
const id = `${Date.now()}-${crypto.randomUUID()}`;
|
||||
const snapshotPath = path.join(snapshotsDir, id);
|
||||
@ -77,7 +83,7 @@ async function createSnapshot({ type, pluginId }) {
|
||||
}
|
||||
}
|
||||
|
||||
return { id, type, pluginId, pluginExisted, pluginZip, snapshotPath };
|
||||
return { id, type, pluginId, pluginExisted, pluginZip, snapshotPath, metadata };
|
||||
}
|
||||
|
||||
function finalizeSnapshot(snapshot) {
|
||||
@ -89,7 +95,18 @@ function finalizeSnapshot(snapshot) {
|
||||
pluginExisted: snapshot.pluginExisted || false,
|
||||
createdAt: Date.now(),
|
||||
status: "available",
|
||||
path: snapshot.snapshotPath
|
||||
path: snapshot.snapshotPath,
|
||||
target_kind: snapshot.metadata?.target_kind || snapshot.type,
|
||||
target_id: snapshot.metadata?.target_id || snapshot.pluginId || null,
|
||||
from_version: snapshot.metadata?.from_version || null,
|
||||
to_version: snapshot.metadata?.to_version || null,
|
||||
source_branch: snapshot.metadata?.source_branch || null,
|
||||
update_method: snapshot.metadata?.update_method || null,
|
||||
rollback_safe: snapshot.metadata?.rollback_safe !== false,
|
||||
recovery_marker_id: snapshot.metadata?.recovery_marker_id || null,
|
||||
major_crossing: Boolean(snapshot.metadata?.major_crossing),
|
||||
migration_notes: snapshot.metadata?.migration_notes || "",
|
||||
danger_notes: snapshot.metadata?.danger_notes || []
|
||||
};
|
||||
entries.push(record);
|
||||
saveIndex(pruneEntries(entries));
|
||||
@ -384,16 +401,38 @@ async function applyBotUpdate(zipPath, options = {}) {
|
||||
verifyBotPackage(rootPath);
|
||||
}
|
||||
|
||||
const snapshot = await createSnapshot({ type: "bot" });
|
||||
const marker = createRecoveryMarker({
|
||||
target_kind: "core",
|
||||
target_id: "core",
|
||||
from_version: options.metadata?.from_version || null,
|
||||
to_version: options.metadata?.to_version || null,
|
||||
source_branch: options.metadata?.source_branch || "manual_zip",
|
||||
update_method: options.mode === "patch" ? "zip_patch" : "zip",
|
||||
rollback_safe: options.metadata?.rollback_safe !== false,
|
||||
major_crossing: Boolean(options.metadata?.major_crossing)
|
||||
});
|
||||
updateRecoveryMarker({ status: "applying" });
|
||||
const snapshot = await createSnapshot({
|
||||
type: "bot",
|
||||
metadata: {
|
||||
target_kind: "core",
|
||||
update_method: options.mode === "patch" ? "zip_patch" : "zip",
|
||||
...options.metadata,
|
||||
recovery_marker_id: marker.id
|
||||
}
|
||||
});
|
||||
try {
|
||||
if (mode === "patch") {
|
||||
applyCorePatch(rootPath);
|
||||
} else {
|
||||
applyCoreUpdate(rootPath);
|
||||
}
|
||||
return finalizeSnapshot(snapshot);
|
||||
const record = finalizeSnapshot(snapshot);
|
||||
markRecoveryMarkerComplete({ snapshot_id: record.id });
|
||||
return record;
|
||||
} catch (error) {
|
||||
discardSnapshot(snapshot);
|
||||
markRecoveryMarkerFailed(error);
|
||||
throw error;
|
||||
}
|
||||
} finally {
|
||||
@ -401,21 +440,48 @@ async function applyBotUpdate(zipPath, options = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
async function applyPluginUpdate(zipPath) {
|
||||
async function applyPluginUpdate(zipPath, options = {}) {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "lumi-plugin-"));
|
||||
try {
|
||||
extractZip(zipPath, tempDir);
|
||||
const rootPath = resolvePluginRoot(tempDir);
|
||||
const manifest = verifyPluginPackage(rootPath);
|
||||
if (options.expectedPluginId && manifest.id !== options.expectedPluginId) {
|
||||
throw new Error(`Plugin ZIP id ${manifest.id} does not match ${options.expectedPluginId}.`);
|
||||
}
|
||||
|
||||
const snapshot = await createSnapshot({ type: "plugin", pluginId: manifest.id });
|
||||
const marker = createRecoveryMarker({
|
||||
target_kind: "plugin",
|
||||
target_id: manifest.id,
|
||||
from_version: options.metadata?.from_version || null,
|
||||
to_version: manifest.version || options.metadata?.to_version || null,
|
||||
source_branch: options.metadata?.source_branch || "manual_zip",
|
||||
update_method: "zip",
|
||||
rollback_safe: options.metadata?.rollback_safe !== false,
|
||||
major_crossing: Boolean(options.metadata?.major_crossing)
|
||||
});
|
||||
updateRecoveryMarker({ status: "applying" });
|
||||
const snapshot = await createSnapshot({
|
||||
type: "plugin",
|
||||
pluginId: manifest.id,
|
||||
metadata: {
|
||||
target_kind: "plugin",
|
||||
target_id: manifest.id,
|
||||
update_method: "zip",
|
||||
...options.metadata,
|
||||
recovery_marker_id: marker.id
|
||||
}
|
||||
});
|
||||
try {
|
||||
applyPluginFiles(rootPath, manifest.id, {
|
||||
preserveData: snapshot.pluginExisted
|
||||
});
|
||||
return finalizeSnapshot(snapshot);
|
||||
const record = finalizeSnapshot(snapshot);
|
||||
markRecoveryMarkerComplete({ snapshot_id: record.id });
|
||||
return record;
|
||||
} catch (error) {
|
||||
discardSnapshot(snapshot);
|
||||
markRecoveryMarkerFailed(error);
|
||||
throw error;
|
||||
}
|
||||
} finally {
|
||||
@ -436,7 +502,7 @@ function restoreDatabase(snapshotPath) {
|
||||
fs.rmSync(shm, { force: true });
|
||||
}
|
||||
|
||||
function restoreSnapshot(id) {
|
||||
function restoreSnapshot(id, options = {}) {
|
||||
const entries = loadIndex();
|
||||
const entry = entries.find((item) => item.id === id);
|
||||
if (!entry) {
|
||||
@ -445,6 +511,18 @@ function restoreSnapshot(id) {
|
||||
if (entry.status !== "available") {
|
||||
throw new Error("Snapshot is no longer available.");
|
||||
}
|
||||
if (options.expectedType && entry.type !== options.expectedType) {
|
||||
throw new Error("Snapshot target type does not match this revert action.");
|
||||
}
|
||||
if (options.expectedPluginId && entry.pluginId !== options.expectedPluginId) {
|
||||
throw new Error("Snapshot target plugin does not match this revert action.");
|
||||
}
|
||||
if (entry.major_crossing && entry.rollback_safe === false && !options.allowUnsafeMajorRollback) {
|
||||
throw new Error("This snapshot crossed a major version and is not marked rollback safe.");
|
||||
}
|
||||
if (options.currentVersion && entry.to_version && entry.to_version !== options.currentVersion) {
|
||||
throw new Error("Only the previous version snapshot can be reverted from this action.");
|
||||
}
|
||||
|
||||
if (entry.type === "bot") {
|
||||
const coreZip = path.join(entry.path, "core.zip");
|
||||
@ -484,6 +562,9 @@ function restoreSnapshot(id) {
|
||||
module.exports = {
|
||||
applyBotUpdate,
|
||||
applyPluginUpdate,
|
||||
createSnapshot,
|
||||
finalizeSnapshot,
|
||||
discardSnapshot,
|
||||
applyPluginFiles,
|
||||
resetPluginCode,
|
||||
replacePluginDirectory,
|
||||
|
||||
118
src/services/versioning.js
Normal file
118
src/services/versioning.js
Normal file
@ -0,0 +1,118 @@
|
||||
function parseSemver(value) {
|
||||
const match = String(value || "").trim().match(/^v?(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/);
|
||||
if (!match) return null;
|
||||
return {
|
||||
major: Number(match[1]),
|
||||
minor: Number(match[2]),
|
||||
patch: Number(match[3]),
|
||||
raw: `${Number(match[1])}.${Number(match[2])}.${Number(match[3])}`
|
||||
};
|
||||
}
|
||||
|
||||
function compareSemver(left, right) {
|
||||
const a = parseSemver(left);
|
||||
const b = parseSemver(right);
|
||||
if (!a && !b) return 0;
|
||||
if (!a) return -1;
|
||||
if (!b) return 1;
|
||||
for (const key of ["major", "minor", "patch"]) {
|
||||
if (a[key] > b[key]) return 1;
|
||||
if (a[key] < b[key]) return -1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
function isMajorJump(current, target) {
|
||||
const from = parseSemver(current);
|
||||
const to = parseSemver(target);
|
||||
return Boolean(from && to && from.major !== to.major);
|
||||
}
|
||||
|
||||
function versionInRange(version, from, to) {
|
||||
return compareSemver(version, from) > 0 && compareSemver(version, to) <= 0;
|
||||
}
|
||||
|
||||
function normalizeVersionEntry(entry, fallback = {}) {
|
||||
if (typeof entry === "string") return { version: entry, ...fallback };
|
||||
if (!entry || typeof entry !== "object") return null;
|
||||
const version = entry.version || entry.name || entry.tag;
|
||||
if (!parseSemver(version)) return null;
|
||||
return { ...fallback, ...entry, version: parseSemver(version).raw };
|
||||
}
|
||||
|
||||
function normalizeVersions(entries, fallback = {}) {
|
||||
return (Array.isArray(entries) ? entries : [])
|
||||
.map((entry) => normalizeVersionEntry(entry, fallback))
|
||||
.filter(Boolean)
|
||||
.sort((a, b) => compareSemver(a.version, b.version));
|
||||
}
|
||||
|
||||
function compatibleFromAllows(entry, current) {
|
||||
if (!entry?.compatible_from) return false;
|
||||
return compareSemver(current, entry.compatible_from) >= 0;
|
||||
}
|
||||
|
||||
function isBridgeTarget(entry, current) {
|
||||
const from = parseSemver(current);
|
||||
const to = parseSemver(entry?.version);
|
||||
if (!from || !to || from.major === to.major) return false;
|
||||
if (entry.compatibility_bridge === true || entry.migration_kind === "compatibility_bridge") {
|
||||
return compatibleFromAllows(entry, current) || !entry.compatible_from;
|
||||
}
|
||||
if (to.major === from.major + 1 && to.minor === 0 && to.patch === 0) {
|
||||
return compatibleFromAllows(entry, current) || entry.compatible_from === undefined;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function findSafeTarget(currentVersion, versionEntries) {
|
||||
const current = parseSemver(currentVersion);
|
||||
const versions = normalizeVersions(versionEntries);
|
||||
if (!current) {
|
||||
return {
|
||||
target: null,
|
||||
blocked: true,
|
||||
reason: "Current version is not valid semver."
|
||||
};
|
||||
}
|
||||
const newer = versions.filter((entry) => compareSemver(entry.version, current.raw) > 0);
|
||||
if (!newer.length) {
|
||||
return { target: null, blocked: false, reason: "Already up to date." };
|
||||
}
|
||||
const latest = newer[newer.length - 1];
|
||||
const sameMajor = newer.filter((entry) => parseSemver(entry.version)?.major === current.major);
|
||||
if (sameMajor.length) {
|
||||
return { target: sameMajor[sameMajor.length - 1], latest, blocked: false };
|
||||
}
|
||||
const bridge = newer.find((entry) => isBridgeTarget(entry, current.raw));
|
||||
if (bridge) {
|
||||
return {
|
||||
target: bridge,
|
||||
latest,
|
||||
blocked: false,
|
||||
warning: `Latest ${latest.version} crosses a major boundary; targeting compatibility bridge ${bridge.version}.`
|
||||
};
|
||||
}
|
||||
return {
|
||||
target: null,
|
||||
latest,
|
||||
blocked: true,
|
||||
reason: `Latest ${latest.version} crosses a major boundary and no compatible bridge target was found.`
|
||||
};
|
||||
}
|
||||
|
||||
function collectChangelogRange(currentVersion, targetVersion, changelogEntries) {
|
||||
if (!targetVersion) return [];
|
||||
return normalizeVersions(changelogEntries)
|
||||
.filter((entry) => versionInRange(entry.version, currentVersion, targetVersion))
|
||||
.sort((a, b) => compareSemver(b.version, a.version));
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
parseSemver,
|
||||
compareSemver,
|
||||
isMajorJump,
|
||||
normalizeVersions,
|
||||
findSafeTarget,
|
||||
collectChangelogRange
|
||||
};
|
||||
63
src/services/web-events.js
Normal file
63
src/services/web-events.js
Normal file
@ -0,0 +1,63 @@
|
||||
const clients = new Map();
|
||||
|
||||
function subscribe(req, res) {
|
||||
const id = `${Date.now()}:${Math.random().toString(16).slice(2)}`;
|
||||
const user = req.session?.user || null;
|
||||
const client = { id, res, user, connectedAt: Date.now() };
|
||||
clients.set(id, client);
|
||||
|
||||
res.writeHead(200, {
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-store, no-transform",
|
||||
Connection: "keep-alive",
|
||||
"X-Accel-Buffering": "no"
|
||||
});
|
||||
send(client, "server:status", {
|
||||
status: "connected",
|
||||
message: "Lumi event stream connected.",
|
||||
connected_at: client.connectedAt
|
||||
});
|
||||
|
||||
const keepAlive = setInterval(() => {
|
||||
send(client, "server:status", { status: "heartbeat", at: Date.now() });
|
||||
}, 25000);
|
||||
|
||||
req.on("close", () => {
|
||||
clearInterval(keepAlive);
|
||||
clients.delete(id);
|
||||
});
|
||||
}
|
||||
|
||||
function publish(event, payload = {}, options = {}) {
|
||||
let delivered = 0;
|
||||
for (const client of clients.values()) {
|
||||
if (!canReceive(client.user, options)) continue;
|
||||
send(client, event, payload);
|
||||
delivered += 1;
|
||||
}
|
||||
return delivered;
|
||||
}
|
||||
|
||||
function send(client, event, payload) {
|
||||
try {
|
||||
client.res.write(`event: ${event}\n`);
|
||||
client.res.write(`data: ${JSON.stringify({ ...payload, event, at: Date.now() })}\n\n`);
|
||||
} catch {
|
||||
clients.delete(client.id);
|
||||
}
|
||||
}
|
||||
|
||||
function canReceive(user, options = {}) {
|
||||
const role = options.role || "public";
|
||||
if (role === "public") return true;
|
||||
if (!user) return false;
|
||||
if (role === "user") return true;
|
||||
if (role === "mod") return Boolean(user.isMod || user.isAdmin);
|
||||
if (role === "admin") return Boolean(user.isAdmin);
|
||||
return false;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
publishWebEvent: publish,
|
||||
subscribeWebEvents: subscribe
|
||||
};
|
||||
@ -1,6 +1,11 @@
|
||||
(() => {
|
||||
const body = document.body;
|
||||
const media = window.matchMedia("(max-width: 900px)");
|
||||
const sidebarPreferenceKey = "lumi-sidebar-collapsed";
|
||||
|
||||
if (!media.matches && window.localStorage.getItem(sidebarPreferenceKey) === "true") {
|
||||
body.classList.add("sidebar-collapsed");
|
||||
}
|
||||
|
||||
document.querySelectorAll("[data-sidebar-toggle]").forEach((button) => {
|
||||
button.addEventListener("click", () => {
|
||||
@ -8,10 +13,18 @@
|
||||
body.classList.toggle("sidebar-open");
|
||||
} else {
|
||||
body.classList.toggle("sidebar-collapsed");
|
||||
window.localStorage.setItem(
|
||||
sidebarPreferenceKey,
|
||||
body.classList.contains("sidebar-collapsed") ? "true" : "false"
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
document.querySelector("[data-sidebar-dismiss]")?.addEventListener("click", () => {
|
||||
body.classList.remove("sidebar-open");
|
||||
});
|
||||
|
||||
document.querySelectorAll(".nav-link").forEach((link) => {
|
||||
link.addEventListener("click", () => {
|
||||
if (body.classList.contains("sidebar-open")) {
|
||||
@ -20,6 +33,30 @@
|
||||
});
|
||||
});
|
||||
|
||||
document.querySelectorAll(".nav-section").forEach((section) => {
|
||||
const summary = section.querySelector("summary");
|
||||
summary?.setAttribute("aria-expanded", section.open ? "true" : "false");
|
||||
section.addEventListener("toggle", () => {
|
||||
summary?.setAttribute("aria-expanded", section.open ? "true" : "false");
|
||||
if (!section.open) return;
|
||||
document.querySelectorAll(".nav-section[open]").forEach((other) => {
|
||||
if (other !== section) {
|
||||
other.open = false;
|
||||
other.querySelector("summary")?.setAttribute("aria-expanded", "false");
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
media.addEventListener?.("change", () => {
|
||||
body.classList.remove("sidebar-open");
|
||||
if (media.matches) {
|
||||
body.classList.remove("sidebar-collapsed");
|
||||
} else if (window.localStorage.getItem(sidebarPreferenceKey) === "true") {
|
||||
body.classList.add("sidebar-collapsed");
|
||||
}
|
||||
});
|
||||
|
||||
const editToggles = Array.from(
|
||||
document.querySelectorAll("[data-edit-toggle]")
|
||||
);
|
||||
@ -467,8 +504,9 @@
|
||||
const response = await fetch(healthEndpoint, { cache: "no-store" });
|
||||
if (response.ok) {
|
||||
if (connectionLost) {
|
||||
window.location.reload();
|
||||
return;
|
||||
window.LumiInteractions?.showEventNotice?.({
|
||||
message: "Connection restored. Refresh manually if you need newer page data."
|
||||
}, "info");
|
||||
}
|
||||
connectionLost = false;
|
||||
} else {
|
||||
@ -499,19 +537,34 @@
|
||||
const destructiveConfirm = destructiveModal?.querySelector("[data-destructive-confirm]");
|
||||
const destructiveStates = new WeakMap();
|
||||
let activeDestructive = null;
|
||||
let activeCallbackConfirm = null;
|
||||
|
||||
const destructiveAction = (form) => {
|
||||
const destructiveAction = (form, submitter = null) => {
|
||||
try {
|
||||
return new URL(form.action, window.location.origin).pathname;
|
||||
const action = submitter?.formAction || form.action;
|
||||
return new URL(action, window.location.origin).pathname;
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
};
|
||||
|
||||
const isDestructiveForm = (form) => {
|
||||
const actionCopy = (action) => {
|
||||
const normalized = String(action || "").toLowerCase();
|
||||
if (normalized.includes("/delete")) return { title: "Confirm deletion", label: "Delete" };
|
||||
if (normalized.includes("/uninstall")) return { title: "Confirm uninstall", label: "Uninstall" };
|
||||
if (normalized.includes("/cleanup")) return { title: "Confirm cleanup", label: "Clean selected" };
|
||||
if (normalized.includes("/reset")) return { title: "Confirm reset", label: "Reset" };
|
||||
if (normalized.includes("/remove")) return { title: "Confirm removal", label: "Remove" };
|
||||
if (normalized.includes("/update")) return { title: "Confirm update", label: "Update" };
|
||||
if (normalized.includes("/restart")) return { title: "Confirm restart", label: "Restart" };
|
||||
return { title: "Confirm action", label: "Confirm" };
|
||||
};
|
||||
|
||||
const isDestructiveForm = (form, submitter = null) => {
|
||||
if (!form || form.dataset.noDestructiveConfirm !== undefined) return false;
|
||||
if (form.dataset.updateAction !== undefined) return false;
|
||||
return String(form.method || "get").toLowerCase() === "post" &&
|
||||
destructivePattern.test(destructiveAction(form));
|
||||
destructivePattern.test(destructiveAction(form, submitter));
|
||||
};
|
||||
|
||||
const resetDestructive = (form) => {
|
||||
@ -529,6 +582,41 @@
|
||||
if (form.dataset.syntheticConfirmation === "true") form.remove();
|
||||
};
|
||||
|
||||
const resetCallbackConfirm = (result = false) => {
|
||||
const active = activeCallbackConfirm;
|
||||
if (!active) return;
|
||||
activeCallbackConfirm = null;
|
||||
destructiveModal?.classList.remove("is-open");
|
||||
destructiveModal?.setAttribute("aria-hidden", "true");
|
||||
destructiveConfirm?.removeEventListener("click", active.onConfirm);
|
||||
active.resolve(result);
|
||||
active.returnFocus?.focus?.();
|
||||
};
|
||||
|
||||
window.LumiConfirm = {
|
||||
destructive({ title = "Confirm action", text = "This action cannot be undone.", label = "Confirm", danger = true } = {}) {
|
||||
if (!destructiveModal || !destructiveConfirm) {
|
||||
return Promise.resolve(window.confirm(text));
|
||||
}
|
||||
if (activeDestructive?.form) resetDestructive(activeDestructive.form);
|
||||
if (activeCallbackConfirm) resetCallbackConfirm(false);
|
||||
return new Promise((resolve) => {
|
||||
const returnFocus = document.activeElement;
|
||||
destructiveTitle.textContent = title;
|
||||
destructiveDescription.textContent = text;
|
||||
destructiveConfirm.disabled = false;
|
||||
destructiveConfirm.textContent = label;
|
||||
destructiveConfirm.classList.toggle("danger", danger);
|
||||
destructiveModal.classList.add("is-open");
|
||||
destructiveModal.setAttribute("aria-hidden", "false");
|
||||
const onConfirm = () => resetCallbackConfirm(true);
|
||||
activeCallbackConfirm = { resolve, returnFocus, onConfirm };
|
||||
destructiveConfirm.addEventListener("click", onConfirm);
|
||||
destructiveConfirm.focus();
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const submitDestructive = (form, submitter, token) => {
|
||||
let tokenField = form.querySelector('input[name="confirmation_token"]');
|
||||
if (!tokenField) {
|
||||
@ -544,12 +632,14 @@
|
||||
form.requestSubmit(submitter?.form === form ? submitter : undefined);
|
||||
};
|
||||
|
||||
const confirmLabel = (form, submitter = null) => form.dataset.confirmLabel || submitter?.dataset?.confirmLabel || actionCopy(destructiveAction(form, submitter)).label;
|
||||
|
||||
const startCountdown = ({ form, button, token, notBefore, expiresAt, submitter }) => {
|
||||
const state = destructiveStates.get(form) || {};
|
||||
const update = () => {
|
||||
const remaining = Math.max(0, Math.ceil((notBefore - Date.now()) / 1000));
|
||||
button.disabled = remaining > 0;
|
||||
button.textContent = remaining > 0 ? `Confirm in ${remaining}` : "Confirm";
|
||||
button.textContent = remaining > 0 ? `${confirmLabel(form, submitter)} in ${remaining}` : confirmLabel(form, submitter);
|
||||
if (!remaining && state.timer) {
|
||||
window.clearInterval(state.timer);
|
||||
state.timer = null;
|
||||
@ -566,19 +656,21 @@
|
||||
|
||||
const issueDestructiveConfirmation = async (form, submitter) => {
|
||||
if (destructiveStates.has(form)) return;
|
||||
const action = destructiveAction(form);
|
||||
const action = destructiveAction(form, submitter);
|
||||
const state = { confirmed: false, inline: null, timer: null, expiryTimer: null };
|
||||
destructiveStates.set(form, state);
|
||||
const message = form.dataset.confirmText || "This action cannot be undone.";
|
||||
const copy = actionCopy(action);
|
||||
const message = submitter?.dataset?.confirmText || form.dataset.confirmText || form.dataset.confirmForm || "This action cannot be undone.";
|
||||
const mode = form.dataset.confirmMode || (highImpactPattern.test(action) ? "modal" : "inline");
|
||||
let confirmButton;
|
||||
|
||||
if (mode === "modal" && destructiveModal && destructiveConfirm) {
|
||||
if (activeDestructive?.form) resetDestructive(activeDestructive.form);
|
||||
activeDestructive = { form };
|
||||
destructiveTitle.textContent = form.dataset.confirmTitle || "Confirm destructive action";
|
||||
destructiveTitle.textContent = submitter?.dataset?.confirmTitle || form.dataset.confirmTitle || copy.title;
|
||||
destructiveDescription.textContent = message;
|
||||
destructiveConfirm.disabled = true;
|
||||
destructiveConfirm.classList.add("danger");
|
||||
destructiveConfirm.textContent = "Preparing...";
|
||||
destructiveModal.classList.add("is-open");
|
||||
destructiveModal.setAttribute("aria-hidden", "false");
|
||||
@ -630,7 +722,7 @@
|
||||
|
||||
document.addEventListener("submit", (event) => {
|
||||
const form = event.target;
|
||||
if (!(form instanceof HTMLFormElement) || !isDestructiveForm(form)) return;
|
||||
if (!(form instanceof HTMLFormElement) || !isDestructiveForm(form, event.submitter)) return;
|
||||
const state = destructiveStates.get(form);
|
||||
if (state?.confirmed) {
|
||||
state.confirmed = false;
|
||||
@ -655,6 +747,7 @@
|
||||
form.dataset.syntheticConfirmation = "true";
|
||||
form.dataset.confirmTitle = button.dataset.confirmTitle || "Confirm destructive action";
|
||||
form.dataset.confirmText = button.dataset.confirmText || "This action cannot be undone.";
|
||||
form.dataset.confirmLabel = button.dataset.confirmLabel || "Confirm";
|
||||
document.body.append(form);
|
||||
issueDestructiveConfirmation(form, null);
|
||||
}, true);
|
||||
@ -662,19 +755,115 @@
|
||||
document.querySelectorAll("[data-destructive-cancel]").forEach((button) => {
|
||||
button.addEventListener("click", () => {
|
||||
if (activeDestructive?.form) resetDestructive(activeDestructive.form);
|
||||
else resetCallbackConfirm(false);
|
||||
});
|
||||
});
|
||||
destructiveModal?.addEventListener("click", (event) => {
|
||||
if (event.target === destructiveModal && activeDestructive?.form) {
|
||||
resetDestructive(activeDestructive.form);
|
||||
} else if (event.target === destructiveModal) {
|
||||
resetCallbackConfirm(false);
|
||||
}
|
||||
});
|
||||
window.addEventListener("keydown", (event) => {
|
||||
if (event.key === "Escape" && activeDestructive?.form) {
|
||||
resetDestructive(activeDestructive.form);
|
||||
} else if (event.key === "Escape") {
|
||||
resetCallbackConfirm(false);
|
||||
}
|
||||
});
|
||||
|
||||
const updateLog = document.querySelector("[data-update-progress-log]");
|
||||
if (updateLog) {
|
||||
const appendUpdateLog = (message, level = "info") => {
|
||||
const row = document.createElement("div");
|
||||
row.className = `update-progress-entry ${level}`;
|
||||
row.textContent = message;
|
||||
updateLog.prepend(row);
|
||||
};
|
||||
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");
|
||||
}
|
||||
|
||||
document.querySelectorAll("form[data-update-action]").forEach((form) => {
|
||||
form.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
const submitter = event.submitter || form.querySelector("button[type='submit']");
|
||||
const confirmed = form.dataset.confirmMode === "modal"
|
||||
? await window.LumiConfirm?.destructive?.({
|
||||
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"
|
||||
})
|
||||
: 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"}.`);
|
||||
try {
|
||||
const response = await fetch(form.action, {
|
||||
method: form.method || "POST",
|
||||
body: new FormData(form),
|
||||
headers: { Accept: "application/json" }
|
||||
});
|
||||
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");
|
||||
if (result.refresh_after_ms) {
|
||||
appendUpdateLog(`Lumi will refresh in ${Math.round(result.refresh_after_ms / 1000)} seconds.`, "success");
|
||||
window.setTimeout(() => window.location.reload(), Number(result.refresh_after_ms));
|
||||
}
|
||||
} catch (error) {
|
||||
if (isStateButton) window.LumiStateButton?.error?.(submitter);
|
||||
else if (submitter) submitter.textContent = "Failed";
|
||||
appendUpdateLog(error.message, "danger");
|
||||
} finally {
|
||||
if (!isStateButton && submitter) {
|
||||
window.setTimeout(() => {
|
||||
submitter.disabled = false;
|
||||
submitter.textContent = originalText;
|
||||
}, 2500);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
document.querySelectorAll("[data-copy]").forEach((button) => {
|
||||
button.addEventListener("click", async () => {
|
||||
const text = button.getAttribute("data-copy") || "";
|
||||
|
||||
82
src/web/public/dashboard.js
Normal file
82
src/web/public/dashboard.js
Normal file
@ -0,0 +1,82 @@
|
||||
(() => {
|
||||
const root = document.querySelector("[data-dashboard-metrics]");
|
||||
if (!root) return;
|
||||
const memoryChart = root.querySelector("[data-memory-chart]");
|
||||
const logChart = root.querySelector("[data-log-chart]");
|
||||
const status = root.querySelector("[data-metrics-status]");
|
||||
const history = [];
|
||||
|
||||
const bytes = (value) => {
|
||||
const mb = Number(value || 0) / 1048576;
|
||||
return mb >= 1024 ? `${(mb / 1024).toFixed(1)} GB` : `${mb.toFixed(0)} MB`;
|
||||
};
|
||||
|
||||
const duration = (seconds) => {
|
||||
const total = Number(seconds || 0);
|
||||
const hours = Math.floor(total / 3600);
|
||||
const minutes = Math.floor((total % 3600) / 60);
|
||||
return hours ? `${hours}h ${minutes}m` : `${minutes}m`;
|
||||
};
|
||||
|
||||
const setMetric = (name, value) => {
|
||||
const target = root.querySelector(`[data-metric="${name}"]`);
|
||||
if (target) target.textContent = value;
|
||||
};
|
||||
|
||||
const line = (values) => {
|
||||
const max = Math.max(...values, 1);
|
||||
return values.map((value, index) => {
|
||||
const x = values.length === 1 ? 0 : (index / (values.length - 1)) * 280 + 10;
|
||||
const y = 108 - (value / max) * 96;
|
||||
return `${x.toFixed(1)},${y.toFixed(1)}`;
|
||||
}).join(" ");
|
||||
};
|
||||
|
||||
const drawMemory = () => {
|
||||
if (!memoryChart) return;
|
||||
const values = history.map((item) => item.memory.rss);
|
||||
memoryChart.innerHTML = `<polyline points="${line(values)}" fill="none" stroke="var(--lumi-primary)" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"></polyline>`;
|
||||
};
|
||||
|
||||
const drawLogs = (logs) => {
|
||||
if (!logChart) return;
|
||||
const entries = [["error", logs.error], ["warn", logs.warn], ["info", logs.info], ["debug", logs.debug]];
|
||||
const max = Math.max(...entries.map(([, value]) => value), 1);
|
||||
logChart.innerHTML = entries.map(([label, value], index) => {
|
||||
const height = Math.max(4, (value / max) * 86);
|
||||
const x = 24 + index * 68;
|
||||
const y = 100 - height;
|
||||
return `<rect x="${x}" y="${y}" width="38" height="${height}" rx="6" fill="var(--lumi-${label === "error" ? "danger" : label === "warn" ? "warning" : label === "info" ? "info" : "text-muted"})"></rect><text x="${x + 19}" y="116" text-anchor="middle" fill="var(--lumi-text-muted)" font-size="10">${label}</text>`;
|
||||
}).join("");
|
||||
};
|
||||
|
||||
const refresh = async () => {
|
||||
try {
|
||||
const response = await fetch("/api/admin/dashboard-metrics", { cache: "no-store" });
|
||||
const data = await response.json();
|
||||
if (!response.ok) throw new Error(data.error || "Metrics unavailable.");
|
||||
history.push(data);
|
||||
while (history.length > 24) history.shift();
|
||||
setMetric("uptime", duration(data.uptime_seconds));
|
||||
setMetric("rss", bytes(data.memory.rss));
|
||||
setMetric("heap", `${bytes(data.memory.heap_used)} / ${bytes(data.memory.heap_total)}`);
|
||||
setMetric("plugins", `${data.plugins.enabled} / ${data.plugins.total}`);
|
||||
setMetric("users", data.counts.users);
|
||||
setMetric("commands", data.counts.commands);
|
||||
if (status) {
|
||||
status.textContent = "Live";
|
||||
status.className = "status-indicator status-success";
|
||||
}
|
||||
drawMemory();
|
||||
drawLogs(data.logs);
|
||||
} catch (error) {
|
||||
if (status) {
|
||||
status.textContent = error.message;
|
||||
status.className = "status-indicator status-danger";
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
refresh();
|
||||
window.setInterval(refresh, 10000);
|
||||
})();
|
||||
368
src/web/public/homepage-builder.js
Normal file
368
src/web/public/homepage-builder.js
Normal file
@ -0,0 +1,368 @@
|
||||
(() => {
|
||||
const builders = document.querySelectorAll("[data-homepage-builder]");
|
||||
if (!builders.length) return;
|
||||
|
||||
const permissions = ["public", "user", "mod", "admin"];
|
||||
const heroTypes = [
|
||||
["static_image", "Static image"],
|
||||
["custom_embed", "Custom embed"],
|
||||
["custom_link", "Custom link"],
|
||||
["youtube_video", "YouTube video"],
|
||||
["youtube_channel", "YouTube channel"],
|
||||
["twitch_stream", "Twitch stream"],
|
||||
["discord_server_overview", "Discord server overview"],
|
||||
["none", "Fallback message"]
|
||||
];
|
||||
const availabilityModes = [
|
||||
["always", "Always available"],
|
||||
["live_only", "Only while live"],
|
||||
["scheduled", "Scheduled/manual"]
|
||||
];
|
||||
const autoplayModes = [
|
||||
["off", "No autoplay"],
|
||||
["muted", "Autoplay muted"],
|
||||
["sound", "Autoplay with sound"]
|
||||
];
|
||||
|
||||
const heroTypeLabel = (value) => heroTypes.find(([id]) => id === value)?.[1] || value || "Hero";
|
||||
|
||||
const parseRows = (source) => {
|
||||
try {
|
||||
const parsed = JSON.parse(source.value || "[]");
|
||||
return Array.isArray(parsed) ? parsed : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
const field = (label, input, options = {}) => {
|
||||
const wrapper = document.createElement("label");
|
||||
wrapper.className = "homepage-builder-field";
|
||||
if (options.relevance) wrapper.dataset.relevance = options.relevance;
|
||||
const span = document.createElement("span");
|
||||
span.textContent = label;
|
||||
wrapper.append(span, input);
|
||||
return wrapper;
|
||||
};
|
||||
|
||||
const textInput = (value = "", placeholder = "") => {
|
||||
const input = document.createElement("input");
|
||||
input.value = value || "";
|
||||
input.placeholder = placeholder;
|
||||
return input;
|
||||
};
|
||||
|
||||
const numberInput = (value = 0, min = 0) => {
|
||||
const input = document.createElement("input");
|
||||
input.type = "number";
|
||||
input.min = String(min);
|
||||
input.value = Number.isFinite(Number(value)) ? String(value) : String(min);
|
||||
return input;
|
||||
};
|
||||
|
||||
const selectInput = (value, values) => {
|
||||
const select = document.createElement("select");
|
||||
values.forEach((item) => {
|
||||
const [id, label] = Array.isArray(item) ? item : [item, item];
|
||||
const option = document.createElement("option");
|
||||
option.value = id;
|
||||
option.textContent = label;
|
||||
option.selected = id === value;
|
||||
select.append(option);
|
||||
});
|
||||
return select;
|
||||
};
|
||||
|
||||
const checkbox = (checked = true) => {
|
||||
const input = document.createElement("input");
|
||||
input.type = "checkbox";
|
||||
input.checked = checked !== false;
|
||||
return input;
|
||||
};
|
||||
|
||||
const linkDefaults = () => ({
|
||||
enabled: true,
|
||||
label: "",
|
||||
description: "",
|
||||
url: "",
|
||||
icon_mode: "favicon",
|
||||
icon_url: "",
|
||||
fetched_favicon_url: "",
|
||||
permission: "public",
|
||||
sort_order: 0
|
||||
});
|
||||
|
||||
const heroDefaults = () => ({
|
||||
enabled: true,
|
||||
type: "static_image",
|
||||
title: "",
|
||||
description: "",
|
||||
priority: 0,
|
||||
permission: "public",
|
||||
source_url: "",
|
||||
image_url: "",
|
||||
embed_url: "",
|
||||
video_id: "",
|
||||
availability_mode: "always",
|
||||
autoplay_mode: "off",
|
||||
duration_seconds: 0,
|
||||
fallback_behavior: "message"
|
||||
});
|
||||
|
||||
const firstLetter = (value) => {
|
||||
try {
|
||||
return new URL(value || "").hostname.replace(/^www\./, "").slice(0, 1).toUpperCase() || "L";
|
||||
} catch {
|
||||
return "L";
|
||||
}
|
||||
};
|
||||
|
||||
const confirmation = async (options) => {
|
||||
if (window.LumiConfirm?.destructive) return window.LumiConfirm.destructive(options);
|
||||
return window.confirm(options.text);
|
||||
};
|
||||
|
||||
builders.forEach((builder) => {
|
||||
const kind = builder.dataset.homepageBuilder;
|
||||
const source = builder.querySelector(".homepage-json-source");
|
||||
const list = builder.querySelector(`[data-homepage-list="${kind}"]`);
|
||||
const addButton = document.querySelector(`[data-homepage-add="${kind}"]`);
|
||||
if (!source || !list) return;
|
||||
let rows = parseRows(source);
|
||||
|
||||
const sync = () => {
|
||||
const next = Array.from(list.querySelectorAll("[data-homepage-row]")).map((row, index) => {
|
||||
if (kind === "links") {
|
||||
return {
|
||||
enabled: row.querySelector("[data-field='enabled']").checked,
|
||||
label: row.querySelector("[data-field='label']").value.trim(),
|
||||
description: row.querySelector("[data-field='description']").value.trim(),
|
||||
url: row.querySelector("[data-field='url']").value.trim(),
|
||||
icon_mode: row.querySelector("[data-field='icon_mode']").value,
|
||||
icon_url: row.querySelector("[data-field='icon_url']").value.trim(),
|
||||
fetched_favicon_url: row.querySelector("[data-field='fetched_favicon_url']").value.trim(),
|
||||
permission: row.querySelector("[data-field='permission']").value,
|
||||
sort_order: Number(row.querySelector("[data-field='sort_order']").value) || index
|
||||
};
|
||||
}
|
||||
return {
|
||||
enabled: row.querySelector("[data-field='enabled']").checked,
|
||||
type: row.querySelector("[data-field='type']").value,
|
||||
title: row.querySelector("[data-field='title']").value.trim(),
|
||||
description: row.querySelector("[data-field='description']").value.trim(),
|
||||
priority: Number(row.querySelector("[data-field='priority']").value) || index,
|
||||
permission: row.querySelector("[data-field='permission']").value,
|
||||
source_url: row.querySelector("[data-field='source_url']").value.trim(),
|
||||
image_url: row.querySelector("[data-field='image_url']").value.trim(),
|
||||
embed_url: row.querySelector("[data-field='embed_url']").value.trim(),
|
||||
video_id: row.querySelector("[data-field='video_id']").value.trim(),
|
||||
availability_mode: row.querySelector("[data-field='availability_mode']").value,
|
||||
autoplay_mode: row.querySelector("[data-field='autoplay_mode']").value,
|
||||
duration_seconds: Number(row.querySelector("[data-field='duration_seconds']").value) || 0,
|
||||
fallback_behavior: row.querySelector("[data-field='fallback_behavior']").value
|
||||
};
|
||||
});
|
||||
rows = next;
|
||||
source.value = JSON.stringify(next, null, 2);
|
||||
source.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
renderPreviews();
|
||||
};
|
||||
|
||||
const addField = (row, labelText, element, name, options = {}) => {
|
||||
element.dataset.field = name;
|
||||
row.append(field(labelText, element, options));
|
||||
return element;
|
||||
};
|
||||
|
||||
const updateHeroRelevance = (row) => {
|
||||
if (kind !== "heroes") return;
|
||||
const type = row.querySelector("[data-field='type']")?.value || "static_image";
|
||||
const videoLike = ["youtube_video", "youtube_channel", "twitch_stream"].includes(type);
|
||||
const embedded = ["custom_embed", "youtube_video", "youtube_channel", "twitch_stream", "discord_server_overview"].includes(type);
|
||||
const source = ["custom_link", "static_image", "custom_embed", "youtube_video", "youtube_channel", "twitch_stream", "discord_server_overview"].includes(type);
|
||||
const image = type === "static_image";
|
||||
const fallback = type === "none";
|
||||
row.querySelectorAll("[data-relevance]").forEach((item) => {
|
||||
const relevance = item.dataset.relevance;
|
||||
const visible =
|
||||
relevance === "video" ? videoLike :
|
||||
relevance === "embed" ? embedded :
|
||||
relevance === "source" ? source :
|
||||
relevance === "image" ? image :
|
||||
relevance === "fallback" ? fallback :
|
||||
true;
|
||||
item.hidden = !visible;
|
||||
});
|
||||
};
|
||||
|
||||
const updateLinkRelevance = (row) => {
|
||||
if (kind !== "links") return;
|
||||
const mode = row.querySelector("[data-field='icon_mode']")?.value || "favicon";
|
||||
row.querySelectorAll("[data-relevance]").forEach((item) => {
|
||||
const relevance = item.dataset.relevance;
|
||||
item.hidden = !(
|
||||
(relevance === "manual-icon" && mode === "manual") ||
|
||||
(relevance === "fetched-icon" && mode === "favicon")
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
const renderPreviews = () => {
|
||||
list.querySelectorAll("[data-homepage-row]").forEach((row) => {
|
||||
const preview = row.querySelector("[data-homepage-preview]");
|
||||
if (!preview) return;
|
||||
if (kind === "links") {
|
||||
const label = row.querySelector("[data-field='label']").value.trim() || "Homepage link";
|
||||
const description = row.querySelector("[data-field='description']").value.trim() || "Open external link";
|
||||
const url = row.querySelector("[data-field='url']").value.trim();
|
||||
const iconMode = row.querySelector("[data-field='icon_mode']").value;
|
||||
const iconUrl =
|
||||
iconMode === "manual" ? row.querySelector("[data-field='icon_url']").value.trim() :
|
||||
iconMode === "favicon" ? row.querySelector("[data-field='fetched_favicon_url']").value.trim() :
|
||||
"";
|
||||
preview.innerHTML = `
|
||||
<span class="homepage-preview-icon">${iconUrl ? `<img src="${escapeHtml(iconUrl)}" alt="">` : escapeHtml(firstLetter(url))}</span>
|
||||
<span><strong>${escapeHtml(label)}</strong><small>${escapeHtml(description)}</small></span>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
const type = row.querySelector("[data-field='type']").value;
|
||||
const title = row.querySelector("[data-field='title']").value.trim() || heroTypeLabel(type);
|
||||
const description = row.querySelector("[data-field='description']").value.trim() || "Hero preview";
|
||||
preview.innerHTML = `
|
||||
<span class="eyebrow">${escapeHtml(heroTypeLabel(type))}</span>
|
||||
<strong>${escapeHtml(title)}</strong>
|
||||
<small>${escapeHtml(description)}</small>
|
||||
`;
|
||||
});
|
||||
};
|
||||
|
||||
const moveRow = (from, direction) => {
|
||||
const to = from + direction;
|
||||
if (to < 0 || to >= rows.length) return;
|
||||
const [item] = rows.splice(from, 1);
|
||||
rows.splice(to, 0, item);
|
||||
rows.forEach((row, index) => {
|
||||
if (kind === "links") row.sort_order = index;
|
||||
else row.priority = index;
|
||||
});
|
||||
render();
|
||||
};
|
||||
|
||||
const render = () => {
|
||||
list.replaceChildren();
|
||||
rows.forEach((item, index) => {
|
||||
const row = document.createElement("article");
|
||||
row.className = "homepage-builder-row";
|
||||
row.dataset.homepageRow = "";
|
||||
const header = document.createElement("div");
|
||||
header.className = "homepage-builder-row-header";
|
||||
const title = document.createElement("strong");
|
||||
title.textContent = item.label || item.title || `${kind === "links" ? "Link" : "Hero"} ${index + 1}`;
|
||||
const enabled = checkbox(item.enabled);
|
||||
enabled.dataset.field = "enabled";
|
||||
const enabledLabel = field("Enabled", enabled);
|
||||
header.append(title, enabledLabel);
|
||||
row.append(header);
|
||||
|
||||
if (kind === "links") {
|
||||
addField(row, "Label", textInput(item.label, "Commands"), "label");
|
||||
addField(row, "Description", textInput(item.description, "Open command list"), "description");
|
||||
addField(row, "URL", textInput(item.url, "/commands"), "url");
|
||||
const iconMode = addField(row, "Icon mode", selectInput(item.icon_mode || "favicon", [["favicon", "Fetched favicon/logo"], ["manual", "Manual icon URL"], ["letter", "Fallback letter"]]), "icon_mode");
|
||||
addField(row, "Manual icon/logo URL", textInput(item.icon_url, "/assets/icon.svg"), "icon_url", { relevance: "manual-icon" });
|
||||
addField(row, "Fetched favicon/logo preview", textInput(item.fetched_favicon_url, "https://example.com/favicon.ico"), "fetched_favicon_url", { relevance: "fetched-icon" });
|
||||
addField(row, "Permission", selectInput(item.permission || "public", permissions), "permission");
|
||||
addField(row, "Sort order", numberInput(item.sort_order ?? index, 0), "sort_order");
|
||||
iconMode.addEventListener("change", () => updateLinkRelevance(row));
|
||||
} else {
|
||||
const typeSelect = addField(row, "Type", selectInput(item.type || "static_image", heroTypes), "type");
|
||||
addField(row, "Title", textInput(item.title, "Featured stream"), "title");
|
||||
addField(row, "Description", textInput(item.description, "What's happening now"), "description");
|
||||
addField(row, "Priority/order", numberInput(item.priority ?? index, 0), "priority");
|
||||
addField(row, "Permission", selectInput(item.permission || "public", permissions), "permission");
|
||||
addField(row, "Source URL or platform ID", textInput(item.source_url, "https://..."), "source_url", { relevance: "source" });
|
||||
addField(row, "Image URL", textInput(item.image_url, "https://.../image.png"), "image_url", { relevance: "image" });
|
||||
addField(row, "Embed URL", textInput(item.embed_url, "https://.../embed"), "embed_url", { relevance: "embed" });
|
||||
addField(row, "Video ID", textInput(item.video_id, "Optional platform ID"), "video_id", { relevance: "video" });
|
||||
addField(row, "Availability mode", selectInput(item.availability_mode || "always", availabilityModes), "availability_mode", { relevance: "video" });
|
||||
addField(row, "Autoplay mode", selectInput(item.autoplay_mode || "off", autoplayModes), "autoplay_mode", { relevance: "video" });
|
||||
addField(row, "Duration timer seconds", numberInput(item.duration_seconds, 0), "duration_seconds", { relevance: "video" });
|
||||
addField(row, "Fallback behavior", selectInput(item.fallback_behavior || "message", [["message", "Show message"], ["hide", "Hide hero"]]), "fallback_behavior", { relevance: "fallback" });
|
||||
typeSelect.addEventListener("change", () => updateHeroRelevance(row));
|
||||
}
|
||||
|
||||
const preview = document.createElement("div");
|
||||
preview.className = kind === "links" ? "homepage-link-button homepage-builder-preview" : "homepage-builder-preview hero";
|
||||
preview.dataset.homepagePreview = "";
|
||||
row.append(preview);
|
||||
|
||||
const actions = document.createElement("div");
|
||||
actions.className = "homepage-builder-actions";
|
||||
const up = document.createElement("button");
|
||||
up.type = "button";
|
||||
up.className = "button subtle";
|
||||
up.textContent = "Move up";
|
||||
up.disabled = index === 0;
|
||||
up.addEventListener("click", () => moveRow(index, -1));
|
||||
const down = document.createElement("button");
|
||||
down.type = "button";
|
||||
down.className = "button subtle";
|
||||
down.textContent = "Move down";
|
||||
down.disabled = index === rows.length - 1;
|
||||
down.addEventListener("click", () => moveRow(index, 1));
|
||||
const duplicate = document.createElement("button");
|
||||
duplicate.type = "button";
|
||||
duplicate.className = "button subtle";
|
||||
duplicate.textContent = "Duplicate";
|
||||
duplicate.addEventListener("click", () => {
|
||||
rows.splice(index + 1, 0, { ...rows[index] });
|
||||
render();
|
||||
});
|
||||
const remove = document.createElement("button");
|
||||
remove.type = "button";
|
||||
remove.className = "button danger";
|
||||
remove.textContent = "Remove";
|
||||
remove.addEventListener("click", async () => {
|
||||
const name = item.label || item.title || `${kind === "links" ? "link" : "hero"} ${index + 1}`;
|
||||
const confirmed = await confirmation({
|
||||
title: kind === "links" ? "Remove homepage link" : "Remove homepage hero",
|
||||
text: `Remove ${name}? This only becomes permanent after you save settings.`,
|
||||
label: "Remove"
|
||||
});
|
||||
if (!confirmed) return;
|
||||
rows.splice(index, 1);
|
||||
render();
|
||||
});
|
||||
actions.append(up, down, duplicate, remove);
|
||||
row.append(actions);
|
||||
row.addEventListener("input", sync);
|
||||
row.addEventListener("change", () => {
|
||||
updateLinkRelevance(row);
|
||||
updateHeroRelevance(row);
|
||||
sync();
|
||||
});
|
||||
list.append(row);
|
||||
updateLinkRelevance(row);
|
||||
updateHeroRelevance(row);
|
||||
});
|
||||
sync();
|
||||
};
|
||||
|
||||
addButton?.addEventListener("click", () => {
|
||||
rows.push(kind === "links" ? linkDefaults() : heroDefaults());
|
||||
render();
|
||||
});
|
||||
|
||||
render();
|
||||
});
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value || "")
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'");
|
||||
}
|
||||
})();
|
||||
1101
src/web/public/lumi-components.css
Normal file
1101
src/web/public/lumi-components.css
Normal file
File diff suppressed because it is too large
Load Diff
299
src/web/public/lumi-interactions.js
Normal file
299
src/web/public/lumi-interactions.js
Normal file
@ -0,0 +1,299 @@
|
||||
(() => {
|
||||
const initializedForms = new WeakSet();
|
||||
const initializedExpandables = new WeakSet();
|
||||
const REFRESH_COOLDOWN_MS = 3000;
|
||||
let saveBar = null;
|
||||
let stream = null;
|
||||
let lastScrollY = window.scrollY;
|
||||
|
||||
const init = (root = document) => {
|
||||
initSettingsDirty(root);
|
||||
initExpandables(root);
|
||||
initSoftNavigation(root);
|
||||
};
|
||||
|
||||
const ensureSaveBar = () => {
|
||||
if (saveBar) return saveBar;
|
||||
saveBar = document.createElement("div");
|
||||
saveBar.className = "lumi-savebar";
|
||||
saveBar.hidden = true;
|
||||
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>
|
||||
</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 });
|
||||
return saveBar;
|
||||
};
|
||||
|
||||
const formFields = (form) => Array.from(form.elements).filter((field) =>
|
||||
field.name && !field.disabled && !["submit", "button", "reset", "file"].includes(field.type)
|
||||
);
|
||||
|
||||
const fieldValue = (field) => {
|
||||
if (field.type === "checkbox") return field.checked ? "on" : "";
|
||||
if (field.type === "radio") return field.checked ? field.value : "";
|
||||
return field.value;
|
||||
};
|
||||
|
||||
const snapshotForm = (form) => {
|
||||
const snapshot = new Map();
|
||||
for (const field of formFields(form)) {
|
||||
if (field.type === "radio") {
|
||||
if (!snapshot.has(field.name)) snapshot.set(field.name, "");
|
||||
if (field.checked) snapshot.set(field.name, field.value);
|
||||
} else {
|
||||
snapshot.set(field.name, fieldValue(field));
|
||||
}
|
||||
}
|
||||
return snapshot;
|
||||
};
|
||||
|
||||
const isFieldDirty = (field, snapshot) => {
|
||||
const original = snapshot.get(field.name) || "";
|
||||
if (field.type === "radio") {
|
||||
return field.checked && field.value !== original;
|
||||
}
|
||||
return fieldValue(field) !== original;
|
||||
};
|
||||
|
||||
const updateDirtyState = () => {
|
||||
const forms = Array.from(document.querySelectorAll("form[data-lumi-settings-form]"));
|
||||
let dirtyCount = 0;
|
||||
for (const form of forms) {
|
||||
const snapshot = form._lumiSnapshot || snapshotForm(form);
|
||||
let formDirty = false;
|
||||
for (const field of formFields(form)) {
|
||||
const dirty = isFieldDirty(field, snapshot);
|
||||
const container = field.closest(".field, .theme-color-control, .theme-range-control, .theme-select-control, fieldset");
|
||||
container?.classList.toggle("is-unsaved", dirty);
|
||||
formDirty = formDirty || dirty;
|
||||
if (dirty) dirtyCount += 1;
|
||||
}
|
||||
form.classList.toggle("has-unsaved-settings", formDirty);
|
||||
}
|
||||
const bar = ensureSaveBar();
|
||||
bar.hidden = dirtyCount === 0;
|
||||
bar.classList.toggle("is-visible", dirtyCount > 0);
|
||||
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) {
|
||||
root.querySelectorAll?.("form[data-lumi-settings-form]").forEach((form) => {
|
||||
if (initializedForms.has(form)) return;
|
||||
initializedForms.add(form);
|
||||
form._lumiSnapshot = snapshotForm(form);
|
||||
form.addEventListener("input", updateDirtyState);
|
||||
form.addEventListener("change", updateDirtyState);
|
||||
form.addEventListener("submit", () => {
|
||||
form._lumiSnapshot = snapshotForm(form);
|
||||
window.setTimeout(updateDirtyState, 0);
|
||||
});
|
||||
});
|
||||
if (document.querySelector("form[data-lumi-settings-form]")) {
|
||||
ensureSaveBar();
|
||||
updateDirtyState();
|
||||
window.addEventListener("beforeunload", warnDirtyNavigation);
|
||||
}
|
||||
}
|
||||
|
||||
async function saveDirtyForms() {
|
||||
const bar = ensureSaveBar();
|
||||
const button = bar.querySelector("[data-savebar-submit]");
|
||||
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;
|
||||
button.disabled = true;
|
||||
status.textContent = "Saving...";
|
||||
try {
|
||||
for (const form of forms) {
|
||||
const response = await fetch(form.action || window.location.href, {
|
||||
method: form.method || "POST",
|
||||
body: new FormData(form),
|
||||
headers: { Accept: "text/html,application/json" },
|
||||
redirect: "follow"
|
||||
});
|
||||
if (!response.ok) throw new Error(`Save failed for ${form.action || "settings form"}.`);
|
||||
form._lumiSnapshot = snapshotForm(form);
|
||||
}
|
||||
status.textContent = "Saved.";
|
||||
updateDirtyState();
|
||||
} 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);
|
||||
}
|
||||
}
|
||||
|
||||
function warnDirtyNavigation(event) {
|
||||
if (!document.querySelector("form[data-lumi-settings-form].has-unsaved-settings")) return;
|
||||
event.preventDefault();
|
||||
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;
|
||||
initializedExpandables.add(item);
|
||||
item.querySelectorAll("[data-placeholder-preview]").forEach(updatePlaceholderPreview);
|
||||
item.addEventListener("input", () => {
|
||||
item.querySelectorAll("[data-placeholder-preview]").forEach(updatePlaceholderPreview);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function updatePlaceholderPreview(target) {
|
||||
const source = target.closest("[data-lumi-expandable-settings]")?.querySelector(target.dataset.placeholderPreview);
|
||||
const text = source?.value || target.dataset.fallback || "";
|
||||
const replacements = {
|
||||
gifter_username: "SomeUser123",
|
||||
item_name: "Cool Item",
|
||||
creator_username: "CreatorName",
|
||||
amount_display: "$12.34",
|
||||
username: "SomeUser123",
|
||||
platform: "Twitch"
|
||||
};
|
||||
target.textContent = text.replace(/\{([^{}]+)\}/g, (full, key) => replacements[key] || full);
|
||||
}
|
||||
|
||||
function connectEvents() {
|
||||
if (!window.EventSource || stream) return;
|
||||
stream = new EventSource("/api/events");
|
||||
stream.addEventListener("server:warning", (event) => showEventNotice(readEvent(event), "warning"));
|
||||
stream.addEventListener("server:status", (event) => {
|
||||
const data = readEvent(event);
|
||||
if (data.status === "connected") document.body.dataset.eventStream = "connected";
|
||||
});
|
||||
stream.addEventListener("ai:model_status", (event) => showEventNotice(readEvent(event), "danger"));
|
||||
stream.addEventListener("data:new_available", (event) => showRefreshPrompt(readEvent(event)));
|
||||
stream.onerror = () => {
|
||||
document.body.dataset.eventStream = "disconnected";
|
||||
};
|
||||
}
|
||||
|
||||
function readEvent(event) {
|
||||
try { return JSON.parse(event.data || "{}"); } catch { return {}; }
|
||||
}
|
||||
|
||||
function noticeRoot() {
|
||||
let root = document.querySelector("[data-lumi-event-notices]");
|
||||
if (!root) {
|
||||
root = document.createElement("div");
|
||||
root.className = "lumi-event-notices";
|
||||
root.dataset.lumiEventNotices = "";
|
||||
document.body.append(root);
|
||||
}
|
||||
return root;
|
||||
}
|
||||
|
||||
function showEventNotice(data, tone = "info") {
|
||||
const item = document.createElement("div");
|
||||
item.className = `lumi-event-notice ${tone}`;
|
||||
item.setAttribute("role", tone === "danger" ? "alert" : "status");
|
||||
item.textContent = data.message || data.status || "Lumi status changed.";
|
||||
noticeRoot().append(item);
|
||||
window.setTimeout(() => item.remove(), 9000);
|
||||
}
|
||||
|
||||
function showRefreshPrompt(data) {
|
||||
const item = document.createElement("div");
|
||||
item.className = "lumi-refresh-prompt";
|
||||
item.setAttribute("role", "status");
|
||||
const label = document.createElement("span");
|
||||
label.textContent = data.message || "New data is available.";
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.className = "button subtle";
|
||||
button.textContent = "Refresh";
|
||||
button.addEventListener("click", () => {
|
||||
button.disabled = true;
|
||||
window.setTimeout(() => { button.disabled = false; }, REFRESH_COOLDOWN_MS);
|
||||
if (data.url) window.location.assign(data.url);
|
||||
else window.location.reload();
|
||||
});
|
||||
item.append(label, button);
|
||||
noticeRoot().append(item);
|
||||
}
|
||||
|
||||
function initSoftNavigation(root) {
|
||||
root.querySelectorAll?.("a[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;
|
||||
link.dataset.softNavBound = "true";
|
||||
link.addEventListener("click", (event) => {
|
||||
if (event.defaultPrevented || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
|
||||
const main = document.querySelector("main.content");
|
||||
if (!main || document.querySelector("form[data-lumi-settings-form].has-unsaved-settings")) return;
|
||||
event.preventDefault();
|
||||
softNavigate(url.href);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function softNavigate(url, push = true) {
|
||||
const main = document.querySelector("main.content");
|
||||
if (!main) return window.location.assign(url);
|
||||
main.classList.add("is-soft-loading");
|
||||
try {
|
||||
const response = await fetch(url, { headers: { "X-Lumi-Soft-Navigation": "1" } });
|
||||
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.");
|
||||
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);
|
||||
init(main);
|
||||
executePageScripts(main);
|
||||
window.scrollTo({ top: 0, behavior: "auto" });
|
||||
} catch {
|
||||
window.location.assign(url);
|
||||
}
|
||||
}
|
||||
|
||||
function updateActiveNavigation(pathname) {
|
||||
document.querySelectorAll(".nav-link").forEach((link) => {
|
||||
const url = new URL(link.href, window.location.href);
|
||||
link.classList.toggle("active", url.pathname === pathname || (url.pathname !== "/" && pathname.startsWith(`${url.pathname}/`)));
|
||||
});
|
||||
}
|
||||
|
||||
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.LumiInteractions = { init, connectEvents, showEventNotice, showRefreshPrompt };
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
init(document);
|
||||
connectEvents();
|
||||
});
|
||||
})();
|
||||
275
src/web/public/lumi-layout.css
Normal file
275
src/web/public/lumi-layout.css
Normal file
@ -0,0 +1,275 @@
|
||||
html {
|
||||
min-width: 320px;
|
||||
background: var(--bg-2);
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: var(--lumi-font-body);
|
||||
font-size: var(--lumi-font-size-base);
|
||||
line-height: 1.55;
|
||||
background:
|
||||
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;
|
||||
}
|
||||
|
||||
.app-shell {
|
||||
grid-template-columns: 17rem minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
width: 17rem;
|
||||
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;
|
||||
}
|
||||
|
||||
.sidebar-brand {
|
||||
gap: var(--lumi-space-2);
|
||||
}
|
||||
|
||||
.brand-link {
|
||||
min-height: 3rem;
|
||||
padding: var(--lumi-space-2);
|
||||
}
|
||||
|
||||
.sidebar-nav {
|
||||
gap: var(--lumi-space-2);
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--lumi-border) transparent;
|
||||
}
|
||||
|
||||
.nav-section {
|
||||
padding: var(--lumi-space-2);
|
||||
border-radius: var(--lumi-radius-md);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.nav-section[open] {
|
||||
background: var(--lumi-surface-subtle);
|
||||
}
|
||||
|
||||
.nav-section summary {
|
||||
min-height: 2.5rem;
|
||||
padding: var(--lumi-space-2);
|
||||
border-radius: var(--lumi-radius-sm);
|
||||
}
|
||||
|
||||
.nav-section summary:hover {
|
||||
background: var(--lumi-surface-raised);
|
||||
}
|
||||
|
||||
.nav-links {
|
||||
gap: var(--lumi-space-1);
|
||||
padding: var(--lumi-space-2) 0 0 1.75rem;
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
min-height: 2.5rem;
|
||||
padding: var(--lumi-space-2) var(--lumi-space-3);
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--lumi-radius-sm);
|
||||
}
|
||||
|
||||
.nav-link:hover {
|
||||
border-color: var(--lumi-border);
|
||||
}
|
||||
|
||||
.nav-link.active {
|
||||
background: color-mix(in srgb, var(--lumi-primary) 14%, var(--lumi-surface));
|
||||
border-color: color-mix(in srgb, var(--lumi-primary) 28%, var(--lumi-border));
|
||||
color: var(--lumi-text);
|
||||
}
|
||||
|
||||
.page {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.content {
|
||||
width: 100%;
|
||||
max-width: var(--lumi-content-max);
|
||||
margin: 0 auto;
|
||||
padding: clamp(1rem, 2.5vw, 2.5rem);
|
||||
gap: var(--lumi-space-5);
|
||||
}
|
||||
|
||||
.content > * {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.site-footer {
|
||||
width: 100%;
|
||||
max-width: var(--lumi-content-max);
|
||||
margin: auto auto 0;
|
||||
padding: var(--lumi-space-5) clamp(1rem, 2.5vw, 2.5rem);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.grid,
|
||||
.lumi-grid {
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 16rem), 1fr));
|
||||
gap: var(--lumi-space-4);
|
||||
}
|
||||
|
||||
.lumi-stack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--lumi-space-4);
|
||||
}
|
||||
|
||||
.lumi-cluster,
|
||||
.button-group,
|
||||
.page-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--lumi-space-2);
|
||||
}
|
||||
|
||||
.lumi-split {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--lumi-space-4);
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--lumi-space-4);
|
||||
}
|
||||
|
||||
.page-header h1,
|
||||
.section-header h1,
|
||||
.section-header h2 {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.sidebar-scrim {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
display: none;
|
||||
background: rgba(5, 10, 12, 0.52);
|
||||
backdrop-filter: blur(2px);
|
||||
z-index: 25;
|
||||
}
|
||||
|
||||
.standalone-page {
|
||||
min-height: 100vh;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: var(--lumi-space-4);
|
||||
}
|
||||
|
||||
.standalone-card {
|
||||
width: min(100%, 45rem);
|
||||
}
|
||||
|
||||
.standalone-detail {
|
||||
margin-top: var(--lumi-space-4);
|
||||
}
|
||||
|
||||
@media (min-width: 901px) {
|
||||
body.sidebar-collapsed .app-shell {
|
||||
grid-template-columns: 5.5rem minmax(0, 1fr);
|
||||
}
|
||||
|
||||
body.sidebar-collapsed .sidebar {
|
||||
width: 5.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) and (min-width: 901px) {
|
||||
.app-shell {
|
||||
grid-template-columns: 14rem minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
width: 14rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
body.sidebar-collapsed .app-shell,
|
||||
.app-shell {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
body.sidebar-collapsed .sidebar,
|
||||
.sidebar {
|
||||
width: min(19rem, 88vw);
|
||||
padding-top: var(--lumi-space-3);
|
||||
}
|
||||
|
||||
body.sidebar-open {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
body.sidebar-open .sidebar-scrim {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.mobile-topbar {
|
||||
min-height: 4rem;
|
||||
padding: var(--lumi-space-3) var(--lumi-space-4);
|
||||
background: color-mix(in srgb, var(--lumi-surface) 92%, transparent);
|
||||
backdrop-filter: blur(18px);
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: var(--lumi-space-4);
|
||||
}
|
||||
}
|
||||
|
||||
@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);
|
||||
}
|
||||
|
||||
.content {
|
||||
gap: var(--lumi-space-3);
|
||||
padding: max(0.75rem, env(safe-area-inset-top)) max(0.75rem, env(safe-area-inset-right)) max(0.75rem, env(safe-area-inset-bottom)) max(0.75rem, env(safe-area-inset-left));
|
||||
}
|
||||
|
||||
.site-footer {
|
||||
padding: var(--lumi-space-5) var(--lumi-space-4);
|
||||
}
|
||||
|
||||
.section-header,
|
||||
.commands-header,
|
||||
.stats-header,
|
||||
.lumi-split {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.section-header > *,
|
||||
.commands-header > *,
|
||||
.stats-header > * {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.button-group,
|
||||
.page-actions {
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.button-group .button,
|
||||
.page-actions .button {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
}
|
||||
64
src/web/public/lumi-state-button.js
Normal file
64
src/web/public/lumi-state-button.js
Normal file
@ -0,0 +1,64 @@
|
||||
(() => {
|
||||
const buttons = new Set();
|
||||
|
||||
const getViews = (button) => Array.from(button.querySelectorAll("[data-state-view]"));
|
||||
|
||||
const setState = (button, state, options = {}) => {
|
||||
if (!button) return;
|
||||
const nextState = state || button.dataset.defaultState || "idle";
|
||||
const busyState = button.dataset.loadingState || "loading";
|
||||
const isBusy = nextState === busyState || options.busy === true;
|
||||
|
||||
button.dataset.state = nextState;
|
||||
button.setAttribute("aria-busy", isBusy ? "true" : "false");
|
||||
|
||||
getViews(button).forEach((view) => {
|
||||
const isVisible = view.dataset.stateView === nextState;
|
||||
view.dataset.stateHidden = isVisible ? "false" : "true";
|
||||
view.setAttribute("aria-hidden", isVisible ? "false" : "true");
|
||||
});
|
||||
|
||||
if (button.dataset.disableWhileBusy === "true") {
|
||||
button.disabled = isBusy;
|
||||
}
|
||||
};
|
||||
|
||||
const reset = (button) => {
|
||||
setState(button, button?.dataset.defaultState || "idle");
|
||||
};
|
||||
|
||||
const scheduleReset = (button) => {
|
||||
const delay = Number(button?.dataset.resetDelay || 0);
|
||||
if (delay > 0) window.setTimeout(() => reset(button), delay);
|
||||
};
|
||||
|
||||
const initButton = (button) => {
|
||||
if (buttons.has(button)) return;
|
||||
buttons.add(button);
|
||||
button.classList.add("lumi-state-btn");
|
||||
setState(button, button.dataset.state || button.dataset.defaultState || "idle");
|
||||
};
|
||||
|
||||
document.querySelectorAll("[data-lumi-state-button]").forEach(initButton);
|
||||
|
||||
document.addEventListener("submit", (event) => {
|
||||
const submitter = event.submitter;
|
||||
if (!submitter?.matches?.("[data-lumi-state-button]")) return;
|
||||
initButton(submitter);
|
||||
setState(submitter, submitter.dataset.loadingState || "loading", { busy: true });
|
||||
}, true);
|
||||
|
||||
window.LumiStateButton = {
|
||||
init: initButton,
|
||||
setState,
|
||||
reset,
|
||||
success(button) {
|
||||
setState(button, button?.dataset.successState || "success");
|
||||
scheduleReset(button);
|
||||
},
|
||||
error(button) {
|
||||
setState(button, button?.dataset.errorState || "error");
|
||||
scheduleReset(button);
|
||||
}
|
||||
};
|
||||
})();
|
||||
81
src/web/public/lumi-tokens.css
Normal file
81
src/web/public/lumi-tokens.css
Normal file
@ -0,0 +1,81 @@
|
||||
:root {
|
||||
color-scheme: light dark;
|
||||
--lumi-font-body: "Source Sans 3", Inter, ui-sans-serif, system-ui, -apple-system,
|
||||
BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
--lumi-font-display: "Space Grotesk", Inter, ui-sans-serif, system-ui, sans-serif;
|
||||
--lumi-font-mono: "Cascadia Code", "SFMono-Regular", Consolas, monospace;
|
||||
--lumi-font-size-base: 16px;
|
||||
--lumi-heading-scale: 1;
|
||||
--lumi-control-scale: 1;
|
||||
|
||||
--lumi-text: var(--ink, #182026);
|
||||
--lumi-text-muted: var(--ink-soft, #5a6872);
|
||||
--lumi-primary: var(--sea, #176b75);
|
||||
--lumi-accent: var(--sun, #e58b2b);
|
||||
--lumi-danger: var(--rose, #bd4d4d);
|
||||
--lumi-success: #23845b;
|
||||
--lumi-warning: #a96612;
|
||||
--lumi-info: #3479a8;
|
||||
--lumi-link: var(--lumi-primary);
|
||||
--lumi-surface: var(--card, #ffffff);
|
||||
--lumi-surface-subtle: var(--surface-2, #f4f7f8);
|
||||
--lumi-surface-raised: var(--surface-3, #edf2f3);
|
||||
--lumi-border: var(--border, #d8e0e3);
|
||||
--lumi-input-bg: var(--lumi-surface);
|
||||
--lumi-input-border: var(--lumi-border);
|
||||
--lumi-input-text: var(--lumi-text);
|
||||
--lumi-button-bg: var(--lumi-primary);
|
||||
--lumi-button-text: #ffffff;
|
||||
--lumi-button-hover: color-mix(in srgb, var(--lumi-button-bg) 86%, black);
|
||||
--lumi-focus: color-mix(in srgb, var(--lumi-primary) 72%, white);
|
||||
--lumi-color-unsaved-bg: color-mix(in srgb, var(--lumi-warning) 13%, var(--lumi-surface));
|
||||
--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-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);
|
||||
|
||||
--lumi-space-scale: 1;
|
||||
--lumi-space-1: calc(0.25rem * var(--lumi-space-scale));
|
||||
--lumi-space-2: calc(0.5rem * var(--lumi-space-scale));
|
||||
--lumi-space-3: calc(0.75rem * var(--lumi-space-scale));
|
||||
--lumi-space-4: calc(1rem * var(--lumi-space-scale));
|
||||
--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-radius-sm: calc(var(--lumi-radius, 14px) * 0.58);
|
||||
--lumi-radius-md: var(--lumi-radius, 14px);
|
||||
--lumi-radius-lg: calc(var(--lumi-radius, 14px) * 1.42);
|
||||
--lumi-radius-pill: 999px;
|
||||
--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-control-height: calc(2.75rem * var(--lumi-control-scale));
|
||||
--lumi-content-max: 1600px;
|
||||
|
||||
/* Compatibility aliases for existing core and plugin styles. */
|
||||
--text: var(--lumi-text);
|
||||
--muted: var(--lumi-text-muted);
|
||||
--primary: var(--lumi-primary);
|
||||
--accent: var(--lumi-accent);
|
||||
--danger: var(--lumi-danger);
|
||||
--success: var(--lumi-success);
|
||||
--warning: var(--lumi-warning);
|
||||
--info: var(--lumi-info);
|
||||
--panel: var(--lumi-surface);
|
||||
--panel-2: var(--lumi-surface-subtle);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
scroll-behavior: auto !important;
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
539
src/web/public/theme-editor.css
Normal file
539
src/web/public/theme-editor.css
Normal file
@ -0,0 +1,539 @@
|
||||
.theme-library {
|
||||
display: grid;
|
||||
gap: var(--lumi-space-4);
|
||||
}
|
||||
|
||||
.theme-card-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 18rem), 1fr));
|
||||
gap: var(--lumi-space-4);
|
||||
}
|
||||
|
||||
.theme-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--lumi-space-3);
|
||||
padding: var(--lumi-space-4);
|
||||
border: 1px solid var(--lumi-border);
|
||||
border-radius: var(--lumi-radius-md);
|
||||
background: var(--lumi-surface);
|
||||
box-shadow: var(--lumi-shadow-sm);
|
||||
}
|
||||
|
||||
.theme-card.is-active {
|
||||
border-color: var(--lumi-primary);
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--lumi-primary) 14%, transparent);
|
||||
}
|
||||
|
||||
.theme-swatch {
|
||||
height: 7rem;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 2fr;
|
||||
grid-template-rows: 1fr 1fr;
|
||||
gap: var(--lumi-space-2);
|
||||
padding: var(--lumi-space-3);
|
||||
border: 1px solid color-mix(in srgb, var(--swatch-primary) 25%, transparent);
|
||||
border-radius: var(--lumi-radius-sm);
|
||||
background: var(--swatch-bg);
|
||||
}
|
||||
|
||||
.theme-swatch span {
|
||||
border-radius: calc(var(--lumi-radius-sm) * 0.7);
|
||||
background: var(--swatch-surface);
|
||||
box-shadow: var(--lumi-shadow-sm);
|
||||
}
|
||||
|
||||
.theme-swatch span:first-child {
|
||||
grid-row: 1 / -1;
|
||||
background: var(--swatch-primary);
|
||||
}
|
||||
|
||||
.theme-swatch span:last-child {
|
||||
width: 58%;
|
||||
background: var(--swatch-accent);
|
||||
}
|
||||
|
||||
.theme-card-copy {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.theme-card-copy p {
|
||||
margin: var(--lumi-space-2) 0 0;
|
||||
color: var(--lumi-text-muted);
|
||||
}
|
||||
|
||||
.theme-card-title {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: var(--lumi-space-2);
|
||||
}
|
||||
|
||||
.theme-card-title h3 {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.theme-kind {
|
||||
flex: 0 0 auto;
|
||||
padding: 0.25rem 0.45rem;
|
||||
border: 1px solid var(--lumi-border);
|
||||
border-radius: var(--lumi-radius-pill);
|
||||
color: var(--lumi-text-muted);
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.theme-card-actions,
|
||||
.theme-card-more-body {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--lumi-space-2);
|
||||
}
|
||||
|
||||
.theme-card-actions form {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.theme-card-more {
|
||||
border-top: 1px solid var(--lumi-border);
|
||||
padding-top: var(--lumi-space-3);
|
||||
}
|
||||
|
||||
.theme-card-more summary {
|
||||
cursor: pointer;
|
||||
color: var(--lumi-link);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.theme-card-more-body {
|
||||
align-items: flex-end;
|
||||
margin-top: var(--lumi-space-3);
|
||||
}
|
||||
|
||||
.compact-form {
|
||||
flex: 1 1 100%;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
align-items: end;
|
||||
gap: var(--lumi-space-2);
|
||||
}
|
||||
|
||||
.compact-form label {
|
||||
display: grid;
|
||||
gap: var(--lumi-space-1);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.theme-editor-shell {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(18rem, 25rem);
|
||||
align-items: start;
|
||||
gap: var(--lumi-space-5);
|
||||
scroll-margin-top: var(--lumi-space-4);
|
||||
}
|
||||
|
||||
.theme-editor-main {
|
||||
display: grid;
|
||||
gap: var(--lumi-space-5);
|
||||
}
|
||||
|
||||
.theme-edit-form {
|
||||
display: grid;
|
||||
gap: var(--lumi-space-5);
|
||||
}
|
||||
|
||||
.theme-fieldset {
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
padding: var(--lumi-space-4);
|
||||
border: 1px solid var(--lumi-border);
|
||||
border-radius: var(--lumi-radius-md);
|
||||
background: var(--lumi-surface-subtle);
|
||||
}
|
||||
|
||||
.theme-fieldset legend {
|
||||
padding: 0 var(--lumi-space-2);
|
||||
font: 700 1.05rem/1 var(--lumi-font-display);
|
||||
}
|
||||
|
||||
.theme-control-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(12rem, 1fr));
|
||||
gap: var(--lumi-space-3);
|
||||
}
|
||||
|
||||
.theme-color-control,
|
||||
.theme-select-control,
|
||||
.theme-range-control {
|
||||
display: grid;
|
||||
gap: var(--lumi-space-2);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.theme-color-input {
|
||||
min-height: var(--lumi-control-height);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--lumi-space-2);
|
||||
padding: 0.35rem;
|
||||
border: 1px solid var(--lumi-border);
|
||||
border-radius: var(--lumi-radius-sm);
|
||||
background: var(--lumi-surface);
|
||||
}
|
||||
|
||||
.theme-color-input input {
|
||||
width: 3.2rem;
|
||||
height: 2.2rem;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.theme-color-input output {
|
||||
color: var(--lumi-text-muted);
|
||||
font: 600 0.8rem/1 var(--lumi-font-mono);
|
||||
}
|
||||
|
||||
.theme-range-control {
|
||||
padding: var(--lumi-space-3);
|
||||
border: 1px solid var(--lumi-border);
|
||||
border-radius: var(--lumi-radius-sm);
|
||||
background: var(--lumi-surface);
|
||||
}
|
||||
|
||||
.theme-range-control > span {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: var(--lumi-space-2);
|
||||
}
|
||||
|
||||
.theme-range-control output {
|
||||
color: var(--lumi-text-muted);
|
||||
}
|
||||
|
||||
.theme-select-control {
|
||||
padding: var(--lumi-space-3);
|
||||
border: 1px solid var(--lumi-border);
|
||||
border-radius: var(--lumi-radius-sm);
|
||||
background: var(--lumi-surface);
|
||||
}
|
||||
|
||||
.theme-inline-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--lumi-space-2);
|
||||
margin-top: var(--lumi-space-4);
|
||||
}
|
||||
|
||||
.theme-validation-panel {
|
||||
display: grid;
|
||||
gap: var(--lumi-space-2);
|
||||
}
|
||||
|
||||
.theme-validation-panel:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.theme-warning {
|
||||
padding: var(--lumi-space-2) var(--lumi-space-3);
|
||||
border: 1px solid color-mix(in srgb, var(--lumi-warning) 40%, var(--lumi-border));
|
||||
border-radius: var(--lumi-radius-sm);
|
||||
background: color-mix(in srgb, var(--lumi-warning) 11%, var(--lumi-surface));
|
||||
color: var(--lumi-text);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.advanced-theme-controls {
|
||||
margin-top: var(--lumi-space-4);
|
||||
}
|
||||
|
||||
.advanced-theme-controls summary {
|
||||
cursor: pointer;
|
||||
color: var(--lumi-link);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.advanced-theme-controls[open] summary {
|
||||
margin-bottom: var(--lumi-space-3);
|
||||
}
|
||||
|
||||
.theme-editor-actions {
|
||||
position: sticky;
|
||||
bottom: var(--lumi-space-3);
|
||||
z-index: 5;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--lumi-space-3);
|
||||
padding: var(--lumi-space-3);
|
||||
border: 1px solid var(--lumi-border);
|
||||
border-radius: var(--lumi-radius-md);
|
||||
background: color-mix(in srgb, var(--lumi-surface) 92%, transparent);
|
||||
box-shadow: var(--lumi-shadow-md);
|
||||
backdrop-filter: blur(16px);
|
||||
}
|
||||
|
||||
.theme-preview {
|
||||
position: sticky;
|
||||
top: var(--lumi-space-4);
|
||||
display: grid;
|
||||
gap: var(--lumi-space-3);
|
||||
}
|
||||
|
||||
.theme-preview-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--lumi-space-2);
|
||||
}
|
||||
|
||||
.theme-preview-header .eyebrow {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.theme-popout-button {
|
||||
min-height: 2.25rem;
|
||||
padding: 0.45rem 0.7rem;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.theme-mobile-preview-action {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.theme-preview-window {
|
||||
min-height: 26rem;
|
||||
display: grid;
|
||||
grid-template-columns: 4.5rem minmax(0, 1fr);
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--lumi-border);
|
||||
border-radius: var(--lumi-radius-md);
|
||||
background:
|
||||
radial-gradient(circle at 20% 10%, var(--bg-1), transparent 55%),
|
||||
var(--bg-2);
|
||||
}
|
||||
|
||||
.theme-preview-nav {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--lumi-space-3);
|
||||
padding: var(--lumi-space-3);
|
||||
border-right: 1px solid var(--lumi-border);
|
||||
background: var(--lumi-surface);
|
||||
}
|
||||
|
||||
.theme-preview-nav > span:not(.theme-preview-logo) {
|
||||
width: 1.8rem;
|
||||
height: 0.38rem;
|
||||
border-radius: var(--lumi-radius-pill);
|
||||
background: var(--lumi-border);
|
||||
}
|
||||
|
||||
.theme-preview-nav > span.is-active {
|
||||
width: 2.35rem;
|
||||
background: var(--lumi-primary);
|
||||
}
|
||||
|
||||
.theme-preview-logo {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: var(--lumi-radius-sm);
|
||||
background: var(--lumi-primary);
|
||||
color: var(--lumi-button-text);
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.theme-preview-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--lumi-space-3);
|
||||
padding: var(--lumi-space-4);
|
||||
}
|
||||
|
||||
.theme-preview-content h2 {
|
||||
margin-bottom: 0;
|
||||
font-size: calc(1.25rem * var(--lumi-heading-scale));
|
||||
}
|
||||
|
||||
.theme-preview-content p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.theme-preview-pill {
|
||||
width: max-content;
|
||||
padding: calc(var(--lumi-space-1) * 0.85) var(--lumi-space-2);
|
||||
border: 1px solid color-mix(in srgb, var(--lumi-primary) 30%, var(--lumi-border));
|
||||
border-radius: var(--lumi-radius-pill);
|
||||
background: color-mix(in srgb, var(--lumi-primary) 10%, var(--lumi-surface));
|
||||
}
|
||||
|
||||
.theme-preview-sample-card {
|
||||
margin-top: var(--lumi-space-2);
|
||||
padding: var(--lumi-space-4);
|
||||
border: 1px solid var(--lumi-border);
|
||||
border-radius: var(--lumi-radius-md);
|
||||
background: var(--lumi-surface);
|
||||
box-shadow: var(--lumi-shadow-md);
|
||||
}
|
||||
|
||||
.theme-preview-sample-card p {
|
||||
color: var(--lumi-text-muted);
|
||||
}
|
||||
|
||||
.theme-preview-sample-card input {
|
||||
margin-top: var(--lumi-space-3);
|
||||
}
|
||||
|
||||
.theme-preview-sample-card .switch,
|
||||
.theme-preview-badges,
|
||||
.theme-preview-modal-sample,
|
||||
.theme-spacing-sample {
|
||||
margin-top: var(--lumi-space-3);
|
||||
}
|
||||
|
||||
.theme-preview-statuses {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--lumi-space-3);
|
||||
margin-top: var(--lumi-space-3);
|
||||
font-size: 0.8rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.theme-preview-badges {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--lumi-space-2);
|
||||
}
|
||||
|
||||
.theme-preview-badges .badge,
|
||||
.theme-preview-badges .pill {
|
||||
padding: 0.25rem 0.55rem;
|
||||
border: 1px solid var(--lumi-border);
|
||||
background: var(--lumi-surface-subtle);
|
||||
}
|
||||
|
||||
.theme-preview-dirty {
|
||||
padding: var(--lumi-space-2);
|
||||
border-radius: var(--lumi-radius-sm);
|
||||
font-size: 0.85rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.theme-preview-table {
|
||||
margin-top: var(--lumi-space-3);
|
||||
}
|
||||
|
||||
.theme-preview-table .table {
|
||||
min-width: 16rem;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.theme-preview-table th,
|
||||
.theme-preview-table td {
|
||||
padding: 0.45rem;
|
||||
}
|
||||
|
||||
.theme-preview-modal-sample {
|
||||
padding: var(--lumi-space-3);
|
||||
border: 1px solid var(--lumi-border);
|
||||
border-radius: var(--lumi-radius-md);
|
||||
background: var(--lumi-surface-subtle);
|
||||
box-shadow: var(--lumi-shadow-sm);
|
||||
}
|
||||
|
||||
.theme-spacing-sample {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--lumi-space-2);
|
||||
}
|
||||
|
||||
.theme-spacing-sample span {
|
||||
width: var(--lumi-space-4);
|
||||
height: var(--lumi-space-4);
|
||||
border-radius: var(--lumi-radius-sm);
|
||||
background: var(--lumi-primary);
|
||||
}
|
||||
|
||||
.theme-spacing-sample span:nth-child(2) {
|
||||
width: var(--lumi-space-5);
|
||||
height: var(--lumi-space-5);
|
||||
background: var(--lumi-accent);
|
||||
}
|
||||
|
||||
.theme-spacing-sample span:nth-child(3) {
|
||||
width: var(--lumi-space-6);
|
||||
height: var(--lumi-space-6);
|
||||
background: var(--lumi-info);
|
||||
}
|
||||
|
||||
.is-selected {
|
||||
border-color: var(--lumi-primary) !important;
|
||||
box-shadow: 0 0 0 2px color-mix(in srgb, var(--lumi-primary) 14%, transparent) !important;
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.theme-editor-shell {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.theme-preview {
|
||||
position: static;
|
||||
order: -1;
|
||||
}
|
||||
|
||||
.theme-preview-window {
|
||||
min-height: 20rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.theme-library,
|
||||
.theme-editor-main,
|
||||
.theme-edit-form,
|
||||
.theme-editor-shell {
|
||||
gap: var(--lumi-space-3);
|
||||
}
|
||||
|
||||
.theme-card-grid,
|
||||
.theme-control-grid {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: var(--lumi-space-2);
|
||||
}
|
||||
|
||||
.theme-card,
|
||||
.theme-fieldset,
|
||||
.theme-range-control,
|
||||
.theme-select-control {
|
||||
padding: var(--lumi-space-3);
|
||||
}
|
||||
|
||||
.theme-swatch {
|
||||
height: 5.5rem;
|
||||
}
|
||||
|
||||
.theme-mobile-preview-action {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.theme-preview {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.compact-form {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.theme-editor-actions {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.theme-editor-actions .button-group,
|
||||
.theme-editor-actions .button {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
270
src/web/public/theme-editor.js
Normal file
270
src/web/public/theme-editor.js
Normal file
@ -0,0 +1,270 @@
|
||||
(() => {
|
||||
const editor = document.querySelector("[data-theme-editor]");
|
||||
const form = editor?.querySelector("[data-theme-form]");
|
||||
if (!editor || !form) return;
|
||||
|
||||
const previewRoots = () => Array.from(editor.querySelectorAll("[data-theme-preview-root]"));
|
||||
const tokenVariables = {
|
||||
bg1: "--bg-1",
|
||||
bg2: "--bg-2",
|
||||
bg3: "--bg-3",
|
||||
text: "--ink",
|
||||
muted: "--ink-soft",
|
||||
accent: "--sea",
|
||||
accentAlt: "--sun",
|
||||
success: "--lumi-success",
|
||||
warning: "--lumi-warning",
|
||||
danger: "--rose",
|
||||
info: "--lumi-info",
|
||||
surface: "--card",
|
||||
surface2: "--surface-2",
|
||||
surface3: "--surface-3",
|
||||
border: "--border",
|
||||
link: "--lumi-link",
|
||||
buttonBg: "--lumi-button-bg",
|
||||
buttonText: "--lumi-button-text",
|
||||
buttonHover: "--lumi-button-hover",
|
||||
inputBg: "--lumi-input-bg",
|
||||
inputBorder: "--lumi-input-border",
|
||||
inputText: "--lumi-input-text",
|
||||
focusRing: "--lumi-focus"
|
||||
};
|
||||
const metricVariables = {
|
||||
radius: ["--lumi-radius", "px"],
|
||||
shadowStrength: ["--lumi-shadow-strength", ""],
|
||||
spacingScale: ["--lumi-space-scale", ""]
|
||||
};
|
||||
const typographyVariables = {
|
||||
body: "--lumi-font-body",
|
||||
display: "--lumi-font-display",
|
||||
mono: "--lumi-font-mono",
|
||||
baseSize: ["--lumi-font-size-base", "px"],
|
||||
headingScale: ["--lumi-heading-scale", ""],
|
||||
controlScale: ["--lumi-control-scale", ""]
|
||||
};
|
||||
let popout = null;
|
||||
let previewMode = "light";
|
||||
|
||||
const updateOutputs = () => {
|
||||
form.querySelectorAll('input[type="color"]').forEach((input) => {
|
||||
const output = input.closest("label")?.querySelector("output");
|
||||
if (output) output.value = input.value.toUpperCase();
|
||||
});
|
||||
form.querySelectorAll("[data-theme-metric]").forEach((input) => {
|
||||
const output = input.closest("label")?.querySelector("[data-range-output]");
|
||||
if (output) output.value = `${input.value}${input.dataset.unit || ""}`;
|
||||
});
|
||||
form.querySelectorAll("[data-theme-typography]").forEach((input) => {
|
||||
const output = input.closest("label")?.querySelector("[data-range-output]");
|
||||
if (output) output.value = `${input.value}${input.dataset.unit || ""}`;
|
||||
});
|
||||
};
|
||||
|
||||
const buildPreviewVariables = () => {
|
||||
const variables = [];
|
||||
form.querySelectorAll(`[data-theme-mode="${previewMode}"]`).forEach((input) => {
|
||||
const variable = tokenVariables[input.dataset.themeToken];
|
||||
if (variable) variables.push([variable, input.value]);
|
||||
});
|
||||
form.querySelectorAll("[data-theme-role]").forEach((input) => {
|
||||
variables.push([`--role-${input.dataset.themeRole}`, input.value]);
|
||||
});
|
||||
form.querySelectorAll("[data-theme-metric]").forEach((input) => {
|
||||
const config = metricVariables[input.dataset.themeMetric];
|
||||
if (config) variables.push([config[0], `${input.value}${config[1]}`]);
|
||||
});
|
||||
form.querySelectorAll("[data-theme-font]").forEach((select) => {
|
||||
const variable = typographyVariables[select.dataset.themeFont];
|
||||
const stack = select.selectedOptions[0]?.dataset.fontStack;
|
||||
if (variable && stack) variables.push([variable, stack]);
|
||||
});
|
||||
form.querySelectorAll("[data-theme-typography]").forEach((input) => {
|
||||
const config = typographyVariables[input.dataset.themeTypography];
|
||||
if (config) variables.push([config[0], `${input.value}${config[1]}`]);
|
||||
});
|
||||
return variables;
|
||||
};
|
||||
|
||||
const applyVariables = (target, variables) => {
|
||||
if (!target) return;
|
||||
target.dataset.colorScheme = previewMode;
|
||||
variables.forEach(([name, value]) => {
|
||||
target.style.setProperty(name, value);
|
||||
});
|
||||
};
|
||||
|
||||
const applyPreview = () => {
|
||||
const variables = buildPreviewVariables();
|
||||
previewRoots().forEach((target) => applyVariables(target, variables));
|
||||
updateOutputs();
|
||||
updateWarnings();
|
||||
syncPopout(variables);
|
||||
};
|
||||
|
||||
const parseHex = (value) => {
|
||||
const hex = String(value || "").replace("#", "");
|
||||
return [0, 2, 4].map((index) => Number.parseInt(hex.slice(index, index + 2), 16));
|
||||
};
|
||||
|
||||
const luminance = (value) => {
|
||||
const channels = parseHex(value).map((channel) => {
|
||||
const normalized = channel / 255;
|
||||
return normalized <= 0.03928
|
||||
? normalized / 12.92
|
||||
: Math.pow((normalized + 0.055) / 1.055, 2.4);
|
||||
});
|
||||
return 0.2126 * channels[0] + 0.7152 * channels[1] + 0.0722 * channels[2];
|
||||
};
|
||||
|
||||
const contrastRatio = (left, right) => {
|
||||
const a = luminance(left);
|
||||
const b = luminance(right);
|
||||
return (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05);
|
||||
};
|
||||
|
||||
const getModeValue = (token) =>
|
||||
form.querySelector(`[data-theme-mode="${previewMode}"][data-theme-token="${token}"]`)?.value;
|
||||
|
||||
const updateWarnings = () => {
|
||||
const panel = editor.querySelector("[data-theme-warnings]");
|
||||
if (!panel) return;
|
||||
const checks = [
|
||||
["Text on surface", getModeValue("text"), getModeValue("surface")],
|
||||
["Button text", getModeValue("buttonText"), getModeValue("buttonBg")],
|
||||
["Input text", getModeValue("inputText"), getModeValue("inputBg")]
|
||||
];
|
||||
const warnings = checks
|
||||
.map(([label, foreground, background]) => [label, contrastRatio(foreground, background)])
|
||||
.filter(([, ratio]) => Number.isFinite(ratio) && ratio < 4.5);
|
||||
panel.replaceChildren(
|
||||
...warnings.map(([label, ratio]) => {
|
||||
const item = document.createElement("div");
|
||||
item.className = "theme-warning";
|
||||
item.textContent = `${label} contrast is ${ratio.toFixed(2)}:1. Save will reject values below 4.5:1.`;
|
||||
return item;
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const syncPopout = (variables = buildPreviewVariables()) => {
|
||||
if (!popout || popout.closed) return;
|
||||
popout.document.documentElement.dataset.colorScheme = previewMode;
|
||||
variables.forEach(([name, value]) => {
|
||||
popout.document.documentElement.style.setProperty(name, value);
|
||||
});
|
||||
};
|
||||
|
||||
const previewShell = () => `<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Lumi Theme Preview</title>
|
||||
<link rel="stylesheet" href="/lumi-tokens.css">
|
||||
<link rel="stylesheet" href="/styles.css">
|
||||
<link rel="stylesheet" href="/lumi-layout.css">
|
||||
<link rel="stylesheet" href="/lumi-components.css">
|
||||
<link rel="stylesheet" href="/theme-editor.css">
|
||||
<style>
|
||||
body{margin:0;background:radial-gradient(circle at 16% 8%,var(--bg-1),transparent 38%),var(--bg-2);font-family:var(--lumi-font-body);font-size:var(--lumi-font-size-base);color:var(--lumi-text)}
|
||||
.preview-app{display:grid;grid-template-columns:15rem minmax(0,1fr);min-height:100vh}
|
||||
.preview-sidebar{padding:var(--lumi-space-4);border-right:1px solid var(--lumi-border);background:var(--lumi-surface)}
|
||||
.preview-brand{display:flex;align-items:center;gap:var(--lumi-space-2);font:800 1rem var(--lumi-font-display)}
|
||||
.preview-logo{width:2.4rem;height:2.4rem;display:grid;place-items:center;border-radius:var(--lumi-radius-sm);background:var(--lumi-primary);color:var(--lumi-button-text)}
|
||||
.preview-nav{display:grid;gap:var(--lumi-space-2);margin-top:var(--lumi-space-5)}
|
||||
.preview-nav a{padding:var(--lumi-space-2);border-radius:var(--lumi-radius-sm);color:var(--lumi-text);text-decoration:none}
|
||||
.preview-nav a.active,.preview-nav a:hover{background:var(--lumi-surface-subtle);box-shadow:var(--lumi-shadow-sm)}
|
||||
.preview-page{display:grid;gap:var(--lumi-space-4);align-content:start;padding:var(--lumi-space-5)}
|
||||
.preview-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:var(--lumi-space-4)}
|
||||
.preview-metric{display:grid;gap:var(--lumi-space-1)}
|
||||
.preview-modal-sample{position:relative;min-height:9rem;display:grid;place-items:center;border:1px dashed var(--lumi-border);border-radius:var(--lumi-radius-md);background:color-mix(in srgb,var(--lumi-surface-subtle) 80%,transparent)}
|
||||
.preview-modal-card{width:min(20rem,90%);padding:var(--lumi-space-4);border-radius:var(--lumi-radius-md);background:var(--lumi-surface);box-shadow:var(--lumi-shadow-lg)}
|
||||
@media (max-width:760px){.preview-app{grid-template-columns:1fr}.preview-sidebar{position:static;border-right:0;border-bottom:1px solid var(--lumi-border)}.preview-grid{grid-template-columns:1fr}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="preview-app">
|
||||
<aside class="preview-sidebar">
|
||||
<div class="preview-brand"><span class="preview-logo">L</span><span>Lumi Bot</span></div>
|
||||
<nav class="preview-nav" aria-label="Preview navigation">
|
||||
<a class="active" href="#dashboard">Dashboard</a>
|
||||
<a href="#settings">Settings</a>
|
||||
<a href="#logs">Logs</a>
|
||||
<a href="#ai">Lumi AI</a>
|
||||
</nav>
|
||||
</aside>
|
||||
<main class="preview-page">
|
||||
<section class="hero">
|
||||
<span class="eyebrow">Faithful preview</span>
|
||||
<h1>Community control center</h1>
|
||||
<p>Draft theme values are isolated to this preview until you save and apply the theme.</p>
|
||||
<div class="button-group"><button class="button">Primary action</button><button class="button subtle">Secondary</button></div>
|
||||
</section>
|
||||
<section class="preview-grid">
|
||||
<article class="card preview-metric"><span class="hint">Messages</span><strong>12,480</strong><span class="status-success">Healthy stream</span></article>
|
||||
<article class="card preview-metric"><span class="hint">Warnings</span><strong>3</strong><span class="status-warning">Needs review</span></article>
|
||||
<article class="card">
|
||||
<h2>Settings panel</h2>
|
||||
<label>Channel title<input value="Cozy Carnage" readonly></label>
|
||||
<label class="switch"><input class="switch-input" type="checkbox" checked><span class="switch-track"></span><span class="switch-text">Enabled toggle</span></label>
|
||||
<div class="is-unsaved" style="padding:var(--lumi-space-2);border-radius:var(--lumi-radius-sm)">Unsaved marker sample</div>
|
||||
</article>
|
||||
<article class="card">
|
||||
<h2>Logs table</h2>
|
||||
<div class="table-wrap"><table class="table"><thead><tr><th>Level</th><th>Message</th></tr></thead><tbody><tr><td><span class="status-danger">error</span></td><td>Webhook retry failed</td></tr><tr><td><span class="status-success">info</span></td><td>Command synced</td></tr></tbody></table></div>
|
||||
</article>
|
||||
<article class="card">
|
||||
<h2>State button</h2>
|
||||
<button class="button lumi-state-btn"><span class="lumi-state-btn-content"><span data-state-view="idle">Download</span><span data-state-view="loading" data-state-hidden="true"><span class="lumi-state-btn-spinner"></span>Downloading 64%</span><span data-state-view="success" data-state-hidden="true">Downloaded</span></span></button>
|
||||
<p><span class="badge">Badge</span> <span class="pill">Preview pill</span></p>
|
||||
</article>
|
||||
<article class="card preview-modal-sample">
|
||||
<div class="preview-modal-card"><h3>Modal sample</h3><p class="hint">Confirmation panels inherit theme surface, shadow, radius, and button tokens.</p><button class="button danger">Delete sample</button></div>
|
||||
</article>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
form.addEventListener("input", applyPreview);
|
||||
form.addEventListener("change", applyPreview);
|
||||
editor.querySelectorAll("[data-theme-preview-mode]").forEach((button) => {
|
||||
button.addEventListener("click", () => {
|
||||
previewMode = button.dataset.themePreviewMode;
|
||||
editor.querySelectorAll("[data-theme-preview-mode]").forEach((item) => {
|
||||
item.classList.toggle("is-selected", item === button);
|
||||
});
|
||||
applyPreview();
|
||||
});
|
||||
});
|
||||
|
||||
editor.querySelector("[data-theme-reset]")?.addEventListener("click", () => {
|
||||
form.reset();
|
||||
applyPreview();
|
||||
window.LumiStateButton?.success(editor.querySelector("[data-theme-reset]"));
|
||||
});
|
||||
|
||||
editor.querySelector("[data-theme-reset-to-base]")?.addEventListener("click", () => {
|
||||
form.querySelectorAll("[data-base-value]").forEach((input) => {
|
||||
input.value = input.dataset.baseValue;
|
||||
});
|
||||
applyPreview();
|
||||
});
|
||||
|
||||
editor.querySelector("[data-theme-popout]")?.addEventListener("click", () => {
|
||||
popout = window.open("", "lumi-theme-preview", "width=430,height=760,menubar=no,toolbar=no,location=no,status=no");
|
||||
const status = editor.querySelector("[data-theme-popout-status]");
|
||||
if (!popout) {
|
||||
if (status) status.textContent = "Pop-out preview was blocked by the browser.";
|
||||
return;
|
||||
}
|
||||
popout.document.open();
|
||||
popout.document.write(previewShell());
|
||||
popout.document.close();
|
||||
if (status) status.textContent = "Pop-out preview is open and updates with this editor.";
|
||||
syncPopout();
|
||||
});
|
||||
|
||||
applyPreview();
|
||||
})();
|
||||
@ -14,6 +14,18 @@ const BetterSqlite3Store = require("better-sqlite3-session-store")(session);
|
||||
|
||||
const { db } = require("../services/db");
|
||||
const { getSetting, setSetting, getAllSettings } = require("../services/settings");
|
||||
const {
|
||||
deleteCustomTheme,
|
||||
duplicateTheme,
|
||||
FONT_STACKS,
|
||||
getActiveTheme,
|
||||
getThemeById,
|
||||
listThemes,
|
||||
renameCustomTheme,
|
||||
saveCustomTheme,
|
||||
setActiveTheme,
|
||||
valuesFromRequest
|
||||
} = require("../services/themes");
|
||||
const { getRoleFlags, hasAccess } = require("../services/rbac");
|
||||
const {
|
||||
buildDiscordAuthUrl,
|
||||
@ -71,6 +83,19 @@ const {
|
||||
applyPluginUpdate,
|
||||
listSnapshots
|
||||
} = require("../services/update-manager");
|
||||
const { getUpdateStatus } = require("../services/update-index");
|
||||
const {
|
||||
applyCoreUpdate,
|
||||
applyPluginUpdateFromRepo,
|
||||
revertCoreSnapshot,
|
||||
revertPluginSnapshot,
|
||||
disablePluginForRecovery
|
||||
} = require("../services/repo-update");
|
||||
const {
|
||||
safeModeStatus,
|
||||
clearRecoveryMarker,
|
||||
updateRecoveryMarker
|
||||
} = require("../services/recovery-mode");
|
||||
const {
|
||||
generateCommandPreview,
|
||||
previewParts
|
||||
@ -81,6 +106,10 @@ const {
|
||||
consumeConfirmation,
|
||||
normalizeAction
|
||||
} = require("../services/destructive-confirm");
|
||||
const {
|
||||
publishWebEvent,
|
||||
subscribeWebEvents
|
||||
} = require("../services/web-events");
|
||||
|
||||
function ensureSessionSecret() {
|
||||
let secret = getSetting("session_secret");
|
||||
@ -101,7 +130,39 @@ function isConfigured() {
|
||||
return platforms.some((platform) => platform.configured);
|
||||
}
|
||||
|
||||
function getPrimaryLoginPlatform() {
|
||||
function normalizeHostName(value) {
|
||||
const raw = String(value || "").toLowerCase();
|
||||
if (raw.startsWith("[")) {
|
||||
return raw.slice(1, raw.indexOf("]"));
|
||||
}
|
||||
if (raw === "::1" || raw === "::ffff:127.0.0.1") {
|
||||
return raw;
|
||||
}
|
||||
return raw.replace(/:\d+$/, "");
|
||||
}
|
||||
|
||||
function isLocalhostLoginAvailable(req) {
|
||||
if (!req) return false;
|
||||
const host = normalizeHostName(req.hostname || req.get("host"));
|
||||
return host === "localhost" || host === "::1" || host === "::ffff:127.0.0.1" || host === "127.0.0.1" || host.startsWith("127.");
|
||||
}
|
||||
|
||||
function getLocalhostLoginPlatform(req) {
|
||||
if (!isLocalhostLoginAvailable(req)) return null;
|
||||
return {
|
||||
id: "localhost",
|
||||
label: "Localhost Login",
|
||||
configured: true,
|
||||
enabled: true,
|
||||
supported: true,
|
||||
supportsLogin: true,
|
||||
loginPath: "/auth/localhost"
|
||||
};
|
||||
}
|
||||
|
||||
function getPrimaryLoginPlatform(req) {
|
||||
const localhostPlatform = getLocalhostLoginPlatform(req);
|
||||
if (localhostPlatform) return localhostPlatform;
|
||||
const platforms = getPlatformStatus().filter(
|
||||
(platform) =>
|
||||
platform.supported && platform.enabled && platform.supportsLogin
|
||||
@ -112,13 +173,13 @@ function getPrimaryLoginPlatform() {
|
||||
return platforms.find((platform) => platform.configured) || platforms[0];
|
||||
}
|
||||
|
||||
function getLoginRedirectPath() {
|
||||
const platform = getPrimaryLoginPlatform();
|
||||
function getLoginRedirectPath(req) {
|
||||
const platform = getPrimaryLoginPlatform(req);
|
||||
return platform?.loginPath || "/setup";
|
||||
}
|
||||
|
||||
function requireConfigured(req, res, next) {
|
||||
if (!isConfigured() && !req.path.startsWith("/setup")) {
|
||||
if (!isConfigured() && !isLocalhostLoginAvailable(req) && !req.path.startsWith("/setup")) {
|
||||
return res.redirect("/setup");
|
||||
}
|
||||
next();
|
||||
@ -126,7 +187,7 @@ function requireConfigured(req, res, next) {
|
||||
|
||||
function requireAuth(req, res, next) {
|
||||
if (!req.session.user) {
|
||||
return res.redirect(getLoginRedirectPath());
|
||||
return res.redirect(getLoginRedirectPath(req));
|
||||
}
|
||||
next();
|
||||
}
|
||||
@ -170,7 +231,7 @@ function formatDuration(totalMs) {
|
||||
function requireRole(role) {
|
||||
return (req, res, next) => {
|
||||
if (!req.session.user) {
|
||||
return res.redirect(getLoginRedirectPath());
|
||||
return res.redirect(getLoginRedirectPath(req));
|
||||
}
|
||||
if (!hasAccess(req.session.user, role)) {
|
||||
return res.status(403).render("error", {
|
||||
@ -1373,6 +1434,12 @@ function buildCustomPageSrcdoc(page, theme) {
|
||||
` --bg-1: ${theme.light.bg1};`,
|
||||
` --bg-2: ${theme.light.bg2};`,
|
||||
` --bg-3: ${theme.light.bg3};`,
|
||||
` --success: ${theme.light.success};`,
|
||||
` --warning: ${theme.light.warning};`,
|
||||
` --info: ${theme.light.info};`,
|
||||
` --link: ${theme.light.link};`,
|
||||
` --radius: ${theme.metrics.radius}px;`,
|
||||
` --spacing-scale: ${theme.metrics.spacingScale};`,
|
||||
"}",
|
||||
"@media (prefers-color-scheme: dark) {",
|
||||
" :root {",
|
||||
@ -1388,6 +1455,10 @@ function buildCustomPageSrcdoc(page, theme) {
|
||||
` --bg-1: ${theme.dark.bg1};`,
|
||||
` --bg-2: ${theme.dark.bg2};`,
|
||||
` --bg-3: ${theme.dark.bg3};`,
|
||||
` --success: ${theme.dark.success};`,
|
||||
` --warning: ${theme.dark.warning};`,
|
||||
` --info: ${theme.dark.info};`,
|
||||
` --link: ${theme.dark.link};`,
|
||||
" }",
|
||||
"}"
|
||||
].join("\n")
|
||||
@ -1457,41 +1528,120 @@ function setFlash(req, type, message) {
|
||||
}
|
||||
|
||||
function getThemeSettings() {
|
||||
return {
|
||||
light: {
|
||||
bg1: getSetting("theme_light_bg_1", "#ffe5c4"),
|
||||
bg2: getSetting("theme_light_bg_2", "#f4efe8"),
|
||||
bg3: getSetting("theme_light_bg_3", "#e9f3f1"),
|
||||
text: getSetting("theme_light_text", "#121518"),
|
||||
muted: getSetting("theme_light_text_muted", "#2c3137"),
|
||||
accent: getSetting("theme_light_accent", "#0f6a78"),
|
||||
accentAlt: getSetting("theme_light_accent_alt", "#f4a340"),
|
||||
danger: getSetting("theme_light_danger", "#d66d5c"),
|
||||
surface: getSetting("theme_light_surface", "#ffffff"),
|
||||
surface2: getSetting("theme_light_surface_2", "#fbf9f6"),
|
||||
surface3: getSetting("theme_light_surface_3", "#f9f5ef"),
|
||||
border: getSetting("theme_light_border", "#e3ddd6")
|
||||
},
|
||||
dark: {
|
||||
bg1: getSetting("theme_dark_bg_1", "#1b1d1f"),
|
||||
bg2: getSetting("theme_dark_bg_2", "#16181b"),
|
||||
bg3: getSetting("theme_dark_bg_3", "#0f1113"),
|
||||
text: getSetting("theme_dark_text", "#f2f0ec"),
|
||||
muted: getSetting("theme_dark_text_muted", "#c5bfb7"),
|
||||
accent: getSetting("theme_dark_accent", "#4fb6c2"),
|
||||
accentAlt: getSetting("theme_dark_accent_alt", "#f1b765"),
|
||||
danger: getSetting("theme_dark_danger", "#e08173"),
|
||||
surface: getSetting("theme_dark_surface", "#232629"),
|
||||
surface2: getSetting("theme_dark_surface_2", "#2b2f33"),
|
||||
surface3: getSetting("theme_dark_surface_3", "#30353a"),
|
||||
border: getSetting("theme_dark_border", "#34393d")
|
||||
},
|
||||
role: {
|
||||
public: getSetting("theme_role_public", "#ffffff"),
|
||||
mod: getSetting("theme_role_mod", "#2cb678"),
|
||||
admin: getSetting("theme_role_admin", "#e35678")
|
||||
return getActiveTheme();
|
||||
}
|
||||
|
||||
function parseJsonSetting(key, fallback) {
|
||||
const value = getSetting(key, fallback);
|
||||
if (Array.isArray(value)) return value;
|
||||
if (typeof value === "string" && value.trim()) {
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
return Array.isArray(parsed) ? parsed : fallback;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
};
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function safeExternalUrl(value) {
|
||||
try {
|
||||
const url = new URL(String(value || ""));
|
||||
return ["http:", "https:"].includes(url.protocol) ? url.toString() : "";
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function permissionAllows(user, permission = "public") {
|
||||
const role = ["public", "user", "mod", "admin"].includes(permission) ? permission : "public";
|
||||
return role === "public" ? true : hasAccess(user, role);
|
||||
}
|
||||
|
||||
function fallbackIconForUrl(url) {
|
||||
try {
|
||||
const host = new URL(url).hostname.replace(/^www\./, "");
|
||||
return host.slice(0, 1).toUpperCase();
|
||||
} catch {
|
||||
return "↗";
|
||||
}
|
||||
}
|
||||
|
||||
function homepageLinksForUser(user) {
|
||||
return parseJsonSetting("homepage_link_buttons", [])
|
||||
.filter((item) => item && item.enabled !== false)
|
||||
.filter((item) => permissionAllows(user, item.permission))
|
||||
.map((item, index) => {
|
||||
const url = safeExternalUrl(item.url);
|
||||
if (!url) return null;
|
||||
const iconMode = String(item.icon_mode || "").trim();
|
||||
const iconUrl =
|
||||
iconMode === "manual" ? safeExternalUrl(item.icon_url) :
|
||||
iconMode === "favicon" ? safeExternalUrl(item.fetched_favicon_url) :
|
||||
iconMode === "letter" ? "" :
|
||||
safeExternalUrl(item.icon_url || item.fetched_favicon_url);
|
||||
return {
|
||||
id: String(item.id || `link-${index}`),
|
||||
label: String(item.label || item.description || "External link").slice(0, 80),
|
||||
description: String(item.description || item.label || "Open link").slice(0, 160),
|
||||
url,
|
||||
icon_url: iconUrl,
|
||||
fallback_icon: fallbackIconForUrl(url),
|
||||
permission: item.permission || "public",
|
||||
sort_order: Number(item.sort_order) || index
|
||||
};
|
||||
})
|
||||
.filter(Boolean)
|
||||
.sort((a, b) => a.sort_order - b.sort_order);
|
||||
}
|
||||
|
||||
function homepageHeroForUser(user) {
|
||||
const entries = parseJsonSetting("homepage_hero_entries", [])
|
||||
.filter((item) => item && item.enabled !== false)
|
||||
.filter((item) => permissionAllows(user, item.permission))
|
||||
.sort((a, b) => (Number(a.priority) || 0) - (Number(b.priority) || 0));
|
||||
for (const item of entries) {
|
||||
const hero = normalizeHomepageHero(item);
|
||||
if (hero?.available) return hero;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function normalizeHomepageHero(item) {
|
||||
const type = String(item.type || "none");
|
||||
if (type === "none") {
|
||||
return item.fallback_behavior === "message"
|
||||
? { type, available: true, title: item.title || "No featured content", description: item.description || "" }
|
||||
: null;
|
||||
}
|
||||
const sourceUrl = safeExternalUrl(item.source_url);
|
||||
const embedUrl = safeExternalUrl(item.embed_url);
|
||||
const imageUrl = safeExternalUrl(item.image_url);
|
||||
const title = String(item.title || "Featured content").slice(0, 120);
|
||||
const description = String(item.description || "").slice(0, 500);
|
||||
if (type === "static_image" && imageUrl) return { type, available: true, title, description, image_url: imageUrl, source_url: sourceUrl };
|
||||
if (type === "custom_link" && sourceUrl) return { type, available: true, title, description, source_url: sourceUrl };
|
||||
if (type === "custom_embed" && embedUrl) return { type, available: true, title, description, embed_url: embedUrl };
|
||||
if (type === "youtube_video") {
|
||||
const videoId = item.video_id || youtubeVideoId(sourceUrl);
|
||||
if (videoId) return { type, available: true, title, description, embed_url: `https://www.youtube-nocookie.com/embed/${videoId}`, source_url: sourceUrl };
|
||||
}
|
||||
if (["youtube_channel", "twitch_stream", "discord_server_overview"].includes(type) && (embedUrl || sourceUrl)) {
|
||||
if (type === "twitch_stream" && item.availability_mode === "live_only" && item.live_now !== true) return null;
|
||||
return { type, available: true, title, description, embed_url: embedUrl, source_url: sourceUrl };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function youtubeVideoId(value) {
|
||||
try {
|
||||
const url = new URL(value || "");
|
||||
if (url.hostname.includes("youtu.be")) return url.pathname.slice(1);
|
||||
return url.searchParams.get("v") || "";
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function getDiscordSettings() {
|
||||
@ -1922,10 +2072,12 @@ function createWebServer({ loadPlugins, discordClient }) {
|
||||
assistantPanels.splice(index, 1);
|
||||
}
|
||||
};
|
||||
}
|
||||
},
|
||||
emitEvent: publishWebEvent
|
||||
};
|
||||
|
||||
app.use(requireConfigured);
|
||||
app.get("/api/events", requireAuth, subscribeWebEvents);
|
||||
app.post("/api/destructive-confirmations", requireAuth, (req, res) => {
|
||||
try {
|
||||
res.json(issueConfirmation(req, req.body.action));
|
||||
@ -1956,9 +2108,14 @@ function createWebServer({ loadPlugins, discordClient }) {
|
||||
res.locals.botAvatar = getSetting("bot_avatar_url", null);
|
||||
const platformStatus = getPlatformStatus();
|
||||
res.locals.platforms = platformStatus;
|
||||
res.locals.platformLogins = platformStatus.filter(
|
||||
(platform) => platform.supported && platform.enabled && platform.supportsLogin
|
||||
);
|
||||
const localhostLogin = getLocalhostLoginPlatform(req);
|
||||
res.locals.localhostLoginAvailable = Boolean(localhostLogin);
|
||||
res.locals.platformLogins = [
|
||||
...(localhostLogin ? [localhostLogin] : []),
|
||||
...platformStatus.filter(
|
||||
(platform) => platform.supported && platform.enabled && platform.supportsLogin
|
||||
)
|
||||
];
|
||||
res.locals.platformLinks = platformStatus.filter(
|
||||
(platform) => platform.supported && platform.enabled && platform.supportsLink
|
||||
);
|
||||
@ -2047,7 +2204,9 @@ function createWebServer({ loadPlugins, discordClient }) {
|
||||
|
||||
app.get("/", (req, res) => {
|
||||
res.render("home", {
|
||||
title: "Home"
|
||||
title: "Home",
|
||||
homepageLinks: homepageLinksForUser(req.session.user),
|
||||
homepageHero: homepageHeroForUser(req.session.user)
|
||||
});
|
||||
});
|
||||
|
||||
@ -2577,6 +2736,53 @@ function createWebServer({ loadPlugins, discordClient }) {
|
||||
}
|
||||
});
|
||||
|
||||
app.get("/auth/localhost", (req, res) => {
|
||||
if (!isLocalhostLoginAvailable(req)) {
|
||||
return res.status(404).render("error", {
|
||||
title: "Login unavailable",
|
||||
message: "Localhost Login is only available from a localhost request."
|
||||
});
|
||||
}
|
||||
res.render("localhost-login", {
|
||||
title: "Localhost Login",
|
||||
username: getSetting("localhost_login_username", "admin")
|
||||
});
|
||||
});
|
||||
|
||||
app.post("/auth/localhost", (req, res) => {
|
||||
if (!isLocalhostLoginAvailable(req)) {
|
||||
return res.status(404).render("error", {
|
||||
title: "Login unavailable",
|
||||
message: "Localhost Login is only available from a localhost request."
|
||||
});
|
||||
}
|
||||
const username = String(req.body.username || "").trim();
|
||||
const password = String(req.body.password || "");
|
||||
const expectedUsername = String(getSetting("localhost_login_username", "admin"));
|
||||
const expectedPassword = String(getSetting("localhost_login_password", "admin"));
|
||||
if (username !== expectedUsername || password !== expectedPassword) {
|
||||
setFlash(req, "error", "Invalid localhost username or password.");
|
||||
return res.redirect("/auth/localhost");
|
||||
}
|
||||
const profile = ensureUserForIdentity({
|
||||
provider: "localhost",
|
||||
providerUserId: expectedUsername,
|
||||
displayName: expectedUsername,
|
||||
fallbackName: "Localhost Admin"
|
||||
});
|
||||
req.session.user = {
|
||||
id: profile.id,
|
||||
username: profile.internal_username,
|
||||
avatar: null,
|
||||
roles: ["localhost-admin"],
|
||||
isAdmin: true,
|
||||
isMod: true,
|
||||
isLocalhost: true
|
||||
};
|
||||
setFlash(req, "success", "Logged in with Localhost Login.");
|
||||
res.redirect("/");
|
||||
});
|
||||
|
||||
app.post("/auth/logout", (req, res) => {
|
||||
req.session.destroy(() => {
|
||||
res.redirect("/");
|
||||
@ -3488,11 +3694,51 @@ function createWebServer({ loadPlugins, discordClient }) {
|
||||
});
|
||||
});
|
||||
|
||||
app.get("/api/admin/dashboard-metrics", requireRole("admin"), (req, res) => {
|
||||
const plugins = getPlugins();
|
||||
const logs = listLogs({ limit: 500 });
|
||||
const memory = process.memoryUsage();
|
||||
const count = (table) => {
|
||||
try {
|
||||
return db.prepare(`SELECT COUNT(*) AS count FROM ${table}`).get().count;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
};
|
||||
res.set("Cache-Control", "no-store");
|
||||
res.json({
|
||||
uptime_seconds: Math.round(process.uptime()),
|
||||
memory: {
|
||||
rss: memory.rss,
|
||||
heap_used: memory.heapUsed,
|
||||
heap_total: memory.heapTotal
|
||||
},
|
||||
plugins: {
|
||||
total: plugins.length,
|
||||
enabled: plugins.filter((plugin) => plugin.enabled).length
|
||||
},
|
||||
counts: {
|
||||
users: count("user_profiles"),
|
||||
commands: count("custom_commands"),
|
||||
pages: count("custom_pages"),
|
||||
logs: count("logs")
|
||||
},
|
||||
logs: {
|
||||
error: logs.filter((entry) => entry.level === "error").length,
|
||||
warn: logs.filter((entry) => entry.level === "warn").length,
|
||||
info: logs.filter((entry) => entry.level === "info").length,
|
||||
debug: logs.filter((entry) => entry.level === "debug").length
|
||||
},
|
||||
sampled_at: Date.now()
|
||||
});
|
||||
});
|
||||
|
||||
app.get("/admin/settings", requireRole("admin"), (req, res) => {
|
||||
res.render("admin-settings", {
|
||||
title: "Settings",
|
||||
settings: getAllSettings(),
|
||||
platforms: getPlatformStatus(),
|
||||
localhostLoginAvailable: isLocalhostLoginAvailable(req),
|
||||
navIconItems: buildNavIconItems(req.session.user, navItems, req.path)
|
||||
});
|
||||
});
|
||||
@ -3518,6 +3764,32 @@ function createWebServer({ loadPlugins, discordClient }) {
|
||||
}
|
||||
}
|
||||
}
|
||||
if (isLocalhostLoginAvailable(req)) {
|
||||
const localhostUsername = String(req.body.localhost_login_username || "").trim();
|
||||
if (localhostUsername) {
|
||||
setSetting("localhost_login_username", localhostUsername);
|
||||
}
|
||||
const localhostPassword = String(req.body.localhost_login_password || "");
|
||||
if (localhostPassword) {
|
||||
setSetting("localhost_login_password", localhostPassword);
|
||||
}
|
||||
}
|
||||
for (const field of ["homepage_link_buttons", "homepage_hero_entries"]) {
|
||||
if (req.body[field] === undefined) continue;
|
||||
const raw = String(req.body[field] || "").trim();
|
||||
if (!raw) {
|
||||
setSetting(field, []);
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (!Array.isArray(parsed)) throw new Error("Expected an array.");
|
||||
setSetting(field, parsed);
|
||||
} catch (error) {
|
||||
setFlash(req, "error", `${field.replaceAll("_", " ")} JSON is invalid: ${error.message}`);
|
||||
return res.redirect("/admin/settings");
|
||||
}
|
||||
}
|
||||
const platformStatus = getPlatformStatus();
|
||||
const nextPlatformValues = new Map();
|
||||
for (const platform of platformStatus) {
|
||||
@ -3529,7 +3801,7 @@ function createWebServer({ loadPlugins, discordClient }) {
|
||||
platform.supportsLogin &&
|
||||
nextPlatformValues.get(platform.id)
|
||||
);
|
||||
if (!hasLoginPlatform) {
|
||||
if (!hasLoginPlatform && !isLocalhostLoginAvailable(req)) {
|
||||
setFlash(
|
||||
req,
|
||||
"error",
|
||||
@ -3969,48 +4241,142 @@ function createWebServer({ loadPlugins, discordClient }) {
|
||||
});
|
||||
|
||||
app.get("/admin/theming", requireRole("admin"), (req, res) => {
|
||||
const activeTheme = getActiveTheme();
|
||||
const requestedEdit = String(req.query.edit || "");
|
||||
const editingTheme = requestedEdit
|
||||
? getThemeById(requestedEdit)
|
||||
: activeTheme.builtin
|
||||
? null
|
||||
: activeTheme;
|
||||
const editingBaseTheme = editingTheme && !editingTheme.builtin
|
||||
? getThemeById(editingTheme.baseThemeId)
|
||||
: null;
|
||||
res.render("admin-theme", {
|
||||
title: "Theming",
|
||||
theme: getThemeSettings()
|
||||
theme: activeTheme,
|
||||
activeTheme,
|
||||
themes: listThemes(),
|
||||
editingTheme: editingTheme && !editingTheme.builtin ? editingTheme : null,
|
||||
editingBaseTheme,
|
||||
fontStacks: FONT_STACKS
|
||||
});
|
||||
});
|
||||
|
||||
app.post("/admin/theming", requireRole("admin"), (req, res) => {
|
||||
const fields = [
|
||||
"theme_light_bg_1",
|
||||
"theme_light_bg_2",
|
||||
"theme_light_bg_3",
|
||||
"theme_light_text",
|
||||
"theme_light_text_muted",
|
||||
"theme_light_accent",
|
||||
"theme_light_accent_alt",
|
||||
"theme_light_danger",
|
||||
"theme_light_surface",
|
||||
"theme_light_surface_2",
|
||||
"theme_light_surface_3",
|
||||
"theme_light_border",
|
||||
"theme_dark_bg_1",
|
||||
"theme_dark_bg_2",
|
||||
"theme_dark_bg_3",
|
||||
"theme_dark_text",
|
||||
"theme_dark_text_muted",
|
||||
"theme_dark_accent",
|
||||
"theme_dark_accent_alt",
|
||||
"theme_dark_danger",
|
||||
"theme_dark_surface",
|
||||
"theme_dark_surface_2",
|
||||
"theme_dark_surface_3",
|
||||
"theme_dark_border",
|
||||
"theme_role_public",
|
||||
"theme_role_mod",
|
||||
"theme_role_admin"
|
||||
];
|
||||
for (const field of fields) {
|
||||
if (req.body[field] !== undefined) {
|
||||
setSetting(field, req.body[field].trim());
|
||||
}
|
||||
app.post("/admin/theming/select", requireRole("admin"), (req, res) => {
|
||||
try {
|
||||
const theme = setActiveTheme(String(req.body.theme_id || ""));
|
||||
setFlash(req, "success", `${theme.name} is now active.`);
|
||||
} catch (error) {
|
||||
setFlash(req, "error", error.message);
|
||||
}
|
||||
res.redirect("/admin/theming");
|
||||
});
|
||||
|
||||
app.post("/admin/theming/duplicate", requireRole("admin"), (req, res) => {
|
||||
try {
|
||||
const theme = duplicateTheme(req.body.theme_id, req.body.name);
|
||||
setFlash(req, "success", `${theme.name} created. You can edit it below.`);
|
||||
return res.redirect(`/admin/theming?edit=${encodeURIComponent(theme.id)}#theme-editor`);
|
||||
} catch (error) {
|
||||
setFlash(req, "error", error.message);
|
||||
return res.redirect("/admin/theming");
|
||||
}
|
||||
});
|
||||
|
||||
app.post("/admin/theming/custom/:id/save", requireRole("admin"), (req, res) => {
|
||||
const themeId = `custom:${req.params.id}`;
|
||||
try {
|
||||
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);
|
||||
setFlash(
|
||||
req,
|
||||
"success",
|
||||
req.body.apply === "on"
|
||||
? `${theme.name} saved and applied.`
|
||||
: `${theme.name} saved.`
|
||||
);
|
||||
} catch (error) {
|
||||
const detail = error.validationErrors?.join(" ") || error.message;
|
||||
setFlash(req, "error", detail);
|
||||
}
|
||||
res.redirect(`/admin/theming?edit=${encodeURIComponent(themeId)}#theme-editor`);
|
||||
});
|
||||
|
||||
app.post("/admin/theming/custom/:id/rename", requireRole("admin"), (req, res) => {
|
||||
const themeId = `custom:${req.params.id}`;
|
||||
try {
|
||||
const theme = renameCustomTheme(themeId, req.body.name);
|
||||
setFlash(req, "success", `Theme renamed to ${theme.name}.`);
|
||||
} catch (error) {
|
||||
setFlash(req, "error", error.message);
|
||||
}
|
||||
res.redirect(`/admin/theming?edit=${encodeURIComponent(themeId)}`);
|
||||
});
|
||||
|
||||
app.post("/admin/theming/custom/:id/delete", requireRole("admin"), (req, res) => {
|
||||
try {
|
||||
deleteCustomTheme(`custom:${req.params.id}`);
|
||||
setFlash(req, "success", "Custom theme deleted.");
|
||||
} catch (error) {
|
||||
setFlash(req, "error", error.message);
|
||||
}
|
||||
res.redirect("/admin/theming");
|
||||
});
|
||||
|
||||
app.post("/admin/theming", requireRole("admin"), (req, res) => {
|
||||
let createdTarget = null;
|
||||
try {
|
||||
let target = getActiveTheme();
|
||||
if (target.builtin) {
|
||||
target = duplicateTheme(target.id, "Legacy Custom");
|
||||
createdTarget = target.id;
|
||||
}
|
||||
const values = JSON.parse(JSON.stringify(target));
|
||||
const legacyUpdates = [];
|
||||
const legacyMap = {
|
||||
theme_light_bg_1: ["light", "bg1"],
|
||||
theme_light_bg_2: ["light", "bg2"],
|
||||
theme_light_bg_3: ["light", "bg3"],
|
||||
theme_light_text: ["light", "text"],
|
||||
theme_light_text_muted: ["light", "muted"],
|
||||
theme_light_accent: ["light", "accent"],
|
||||
theme_light_accent_alt: ["light", "accentAlt"],
|
||||
theme_light_danger: ["light", "danger"],
|
||||
theme_light_surface: ["light", "surface"],
|
||||
theme_light_surface_2: ["light", "surface2"],
|
||||
theme_light_surface_3: ["light", "surface3"],
|
||||
theme_light_border: ["light", "border"],
|
||||
theme_dark_bg_1: ["dark", "bg1"],
|
||||
theme_dark_bg_2: ["dark", "bg2"],
|
||||
theme_dark_bg_3: ["dark", "bg3"],
|
||||
theme_dark_text: ["dark", "text"],
|
||||
theme_dark_text_muted: ["dark", "muted"],
|
||||
theme_dark_accent: ["dark", "accent"],
|
||||
theme_dark_accent_alt: ["dark", "accentAlt"],
|
||||
theme_dark_danger: ["dark", "danger"],
|
||||
theme_dark_surface: ["dark", "surface"],
|
||||
theme_dark_surface_2: ["dark", "surface2"],
|
||||
theme_dark_surface_3: ["dark", "surface3"],
|
||||
theme_dark_border: ["dark", "border"],
|
||||
theme_role_public: ["role", "public"],
|
||||
theme_role_mod: ["role", "mod"],
|
||||
theme_role_admin: ["role", "admin"]
|
||||
};
|
||||
for (const [field, [group, key]] of Object.entries(legacyMap)) {
|
||||
if (req.body[field] === undefined) continue;
|
||||
values[group][key] = String(req.body[field]).trim();
|
||||
legacyUpdates.push([field, values[group][key]]);
|
||||
}
|
||||
saveCustomTheme(target.id, values);
|
||||
for (const [field, value] of legacyUpdates) setSetting(field, value);
|
||||
setActiveTheme(target.id);
|
||||
setFlash(req, "success", "Theme updated.");
|
||||
} catch (error) {
|
||||
if (createdTarget) deleteCustomTheme(createdTarget);
|
||||
setFlash(req, "error", error.message);
|
||||
}
|
||||
setFlash(req, "success", "Theme updated.");
|
||||
res.redirect("/admin/theming");
|
||||
});
|
||||
|
||||
@ -4520,13 +4886,134 @@ function createWebServer({ loadPlugins, discordClient }) {
|
||||
}
|
||||
});
|
||||
|
||||
function wantsJson(req) {
|
||||
return req.xhr ||
|
||||
req.get("accept")?.includes("application/json") ||
|
||||
req.get("content-type")?.includes("application/json");
|
||||
}
|
||||
|
||||
function updateSourceFrom(req) {
|
||||
const value = req.body?.source || req.query?.source || "stable";
|
||||
return value === "experimental" ? "experimental" : String(value || "stable");
|
||||
}
|
||||
|
||||
function sendUpdateResult(req, res, result, redirectPath = "/admin/updates") {
|
||||
if (wantsJson(req)) {
|
||||
return res.json({ ok: true, ...result });
|
||||
}
|
||||
setFlash(req, "success", result.message || "Update action completed.");
|
||||
return res.redirect(redirectPath);
|
||||
}
|
||||
|
||||
function sendUpdateError(req, res, error, redirectPath = "/admin/updates") {
|
||||
if (wantsJson(req)) {
|
||||
return res.status(400).json({ ok: false, error: error.message });
|
||||
}
|
||||
setFlash(req, "error", error.message);
|
||||
return res.redirect(redirectPath);
|
||||
}
|
||||
|
||||
function scheduleRestartAfterNotice() {
|
||||
setTimeout(() => requestRestart(), 5000);
|
||||
}
|
||||
|
||||
app.get("/admin/updates/events", requireRole("admin"), subscribeWebEvents);
|
||||
|
||||
app.get("/admin/updates/status", requireRole("admin"), (req, res) => {
|
||||
try {
|
||||
res.json({ ok: true, status: getUpdateStatus({ source: updateSourceFrom(req) }) });
|
||||
} catch (error) {
|
||||
res.status(500).json({ ok: false, error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.get("/admin/updates/recovery", requireRole("admin"), (req, res) => {
|
||||
res.json({ ok: true, recovery: safeModeStatus(), snapshots: listSnapshots() });
|
||||
});
|
||||
|
||||
app.post("/admin/updates/recovery/clear-marker", requireRole("admin"), (req, res) => {
|
||||
clearRecoveryMarker();
|
||||
publishWebEvent("recovery:marker_detected", { status: "cleared" }, { role: "admin" });
|
||||
sendUpdateResult(req, res, { message: "Recovery marker cleared." });
|
||||
});
|
||||
|
||||
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." });
|
||||
scheduleRestartAfterNotice();
|
||||
});
|
||||
|
||||
app.get("/admin/updates", requireRole("admin"), (req, res) => {
|
||||
let updateStatus = null;
|
||||
let updateStatusError = null;
|
||||
try {
|
||||
updateStatus = getUpdateStatus({ source: updateSourceFrom(req) });
|
||||
} catch (error) {
|
||||
updateStatusError = error.message;
|
||||
}
|
||||
res.render("admin-updates", {
|
||||
title: "Updates",
|
||||
snapshots: listSnapshots()
|
||||
snapshots: listSnapshots(),
|
||||
updateStatus,
|
||||
updateStatusError,
|
||||
recoveryStatus: safeModeStatus()
|
||||
});
|
||||
});
|
||||
|
||||
app.post("/admin/updates/core/check", requireRole("admin"), (req, res) => {
|
||||
try {
|
||||
const status = getUpdateStatus({ source: updateSourceFrom(req) });
|
||||
sendUpdateResult(req, res, {
|
||||
status,
|
||||
message: status.core.update_available
|
||||
? `Safe core target ${status.core.safe_target_version} is available.`
|
||||
: status.core.blocked
|
||||
? status.core.blocked_reason
|
||||
: "No core updates found."
|
||||
});
|
||||
} catch (error) {
|
||||
sendUpdateError(req, res, error);
|
||||
}
|
||||
});
|
||||
|
||||
app.post("/admin/updates/core/apply", requireRole("admin"), async (req, res) => {
|
||||
try {
|
||||
const result = await applyCoreUpdate({
|
||||
source: updateSourceFrom(req),
|
||||
publish: publishWebEvent
|
||||
});
|
||||
sendUpdateResult(req, res, {
|
||||
...result,
|
||||
refresh_after_ms: 5000,
|
||||
message: "Core update applied. Lumi will restart after the confirmation 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) });
|
||||
const snapshotId = req.body.snapshot_id || status.core.snapshot.latest_snapshot_id;
|
||||
if (!snapshotId) throw new Error("No core snapshot is available to revert.");
|
||||
const result = await revertCoreSnapshot(snapshotId, {
|
||||
currentVersion: status.core.current_version,
|
||||
publish: publishWebEvent
|
||||
});
|
||||
sendUpdateResult(req, res, {
|
||||
...result,
|
||||
refresh_after_ms: 5000,
|
||||
message: "Core snapshot reverted. Lumi will restart after the confirmation notice."
|
||||
});
|
||||
scheduleRestartAfterNotice();
|
||||
} catch (error) {
|
||||
sendUpdateError(req, res, error);
|
||||
}
|
||||
});
|
||||
|
||||
app.post(
|
||||
"/admin/updates/bot",
|
||||
requireRole("admin"),
|
||||
@ -4565,6 +5052,38 @@ function createWebServer({ loadPlugins, discordClient }) {
|
||||
}
|
||||
);
|
||||
|
||||
app.post(
|
||||
"/admin/updates/core/zip",
|
||||
requireRole("admin"),
|
||||
uploadSingle("update_zip"),
|
||||
async (req, res) => {
|
||||
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 {
|
||||
const patchMode = req.body.patch_mode === "1";
|
||||
const snapshot = await applyBotUpdate(req.file.path, {
|
||||
mode: patchMode ? "patch" : "full",
|
||||
metadata: {
|
||||
update_method: patchMode ? "zip_patch" : "zip",
|
||||
source_branch: "manual_zip",
|
||||
rollback_safe: req.body.rollback_safe === "1"
|
||||
}
|
||||
});
|
||||
sendUpdateResult(req, res, {
|
||||
snapshot,
|
||||
restart_required: true,
|
||||
refresh_after_ms: 5000,
|
||||
message: patchMode ? "Core patch ZIP applied. Restarting..." : "Core ZIP update applied. Restarting..."
|
||||
});
|
||||
scheduleRestartAfterNotice();
|
||||
} catch (error) {
|
||||
sendUpdateError(req, res, error);
|
||||
} finally {
|
||||
try { fs.rmSync(req.file?.path, { force: true }); } catch {}
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
app.post(
|
||||
"/admin/updates/plugin",
|
||||
requireRole("admin"),
|
||||
@ -4596,6 +5115,97 @@ function createWebServer({ loadPlugins, discordClient }) {
|
||||
}
|
||||
);
|
||||
|
||||
app.post("/admin/updates/plugins/:id/check", requireRole("admin"), (req, res) => {
|
||||
try {
|
||||
const status = getUpdateStatus({ source: updateSourceFrom(req) });
|
||||
const plugin = status.plugins.find((item) => item.id === req.params.id);
|
||||
if (!plugin) throw new Error("Plugin not found.");
|
||||
sendUpdateResult(req, res, {
|
||||
plugin,
|
||||
message: plugin.update_available
|
||||
? `Safe plugin target ${plugin.safe_target_version} is available.`
|
||||
: plugin.blocked
|
||||
? plugin.blocked_reason
|
||||
: "No plugin updates found."
|
||||
});
|
||||
} catch (error) {
|
||||
sendUpdateError(req, res, error);
|
||||
}
|
||||
});
|
||||
|
||||
app.post("/admin/updates/plugins/:id/apply", requireRole("admin"), async (req, res) => {
|
||||
try {
|
||||
const result = await applyPluginUpdateFromRepo(req.params.id, {
|
||||
source: updateSourceFrom(req),
|
||||
publish: publishWebEvent
|
||||
});
|
||||
sendUpdateResult(req, res, {
|
||||
...result,
|
||||
message: "Plugin update applied."
|
||||
});
|
||||
} catch (error) {
|
||||
sendUpdateError(req, res, error);
|
||||
}
|
||||
});
|
||||
|
||||
app.post("/admin/updates/plugins/:id/revert", requireRole("admin"), async (req, res) => {
|
||||
try {
|
||||
const status = getUpdateStatus({ source: updateSourceFrom(req) });
|
||||
const plugin = status.plugins.find((item) => item.id === req.params.id);
|
||||
if (!plugin) throw new Error("Plugin not found.");
|
||||
const snapshotId = req.body.snapshot_id || plugin.snapshot.latest_snapshot_id;
|
||||
if (!snapshotId) throw new Error("No plugin snapshot is available to revert.");
|
||||
const result = await revertPluginSnapshot(req.params.id, snapshotId, {
|
||||
currentVersion: plugin.current_version,
|
||||
publish: publishWebEvent
|
||||
});
|
||||
sendUpdateResult(req, res, {
|
||||
...result,
|
||||
message: "Plugin snapshot reverted. Restarting..."
|
||||
});
|
||||
scheduleRestartAfterNotice();
|
||||
} catch (error) {
|
||||
sendUpdateError(req, res, error);
|
||||
}
|
||||
});
|
||||
|
||||
app.post("/admin/updates/plugins/:id/disable", requireRole("admin"), (req, res) => {
|
||||
try {
|
||||
const result = disablePluginForRecovery(req.params.id, publishWebEvent);
|
||||
sendUpdateResult(req, res, { ...result, message: "Plugin disabled for recovery." });
|
||||
} catch (error) {
|
||||
sendUpdateError(req, res, error);
|
||||
}
|
||||
});
|
||||
|
||||
app.post(
|
||||
"/admin/updates/plugins/:id/zip",
|
||||
requireRole("admin"),
|
||||
uploadSingle("plugin_zip"),
|
||||
async (req, res) => {
|
||||
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 {
|
||||
const snapshot = await applyPluginUpdate(req.file.path, {
|
||||
expectedPluginId: req.params.id,
|
||||
metadata: {
|
||||
target_id: req.params.id,
|
||||
source_branch: "manual_zip",
|
||||
rollback_safe: req.body.rollback_safe === "1"
|
||||
}
|
||||
});
|
||||
sendUpdateResult(req, res, {
|
||||
snapshot,
|
||||
message: "Plugin ZIP update applied."
|
||||
});
|
||||
} catch (error) {
|
||||
sendUpdateError(req, res, error);
|
||||
} finally {
|
||||
try { fs.rmSync(req.file?.path, { force: true }); } catch {}
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
app.post("/admin/update", requireRole("admin"), (req, res) => {
|
||||
try {
|
||||
const remote = getSetting("git_remote", "origin");
|
||||
|
||||
@ -1,6 +1,10 @@
|
||||
<%- include("partials/layout-top", { title }) %>
|
||||
<section class="card">
|
||||
<h1>Admin dashboard</h1>
|
||||
<%- include("partials/page-header", {
|
||||
eyebrow: "Administration",
|
||||
pageTitle: "Admin dashboard",
|
||||
description: "Configure Lumi, manage community tools, and maintain the installation."
|
||||
}) %>
|
||||
<div class="grid">
|
||||
<div class="card">
|
||||
<h2>Settings</h2>
|
||||
@ -9,8 +13,8 @@
|
||||
</div>
|
||||
<div class="card">
|
||||
<h2>Theming</h2>
|
||||
<p>Adjust light and dark mode colors.</p>
|
||||
<a href="/admin/theming" class="link">Edit theme</a>
|
||||
<p>Select protected presets or create editable custom themes.</p>
|
||||
<a href="/admin/theming" class="link">Open theme studio</a>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h2>Commands</h2>
|
||||
@ -39,18 +43,71 @@
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="card admin-metrics" data-dashboard-metrics>
|
||||
<div class="section-header">
|
||||
<div>
|
||||
<h2>Live metrics</h2>
|
||||
<p class="hint">Process health, content counts, plugin status, and recent log severity.</p>
|
||||
</div>
|
||||
<span class="status-indicator status-success" data-metrics-status>Loading</span>
|
||||
</div>
|
||||
<div class="dashboard-metric-grid">
|
||||
<div><span>Uptime</span><strong data-metric="uptime">-</strong></div>
|
||||
<div><span>Memory RSS</span><strong data-metric="rss">-</strong></div>
|
||||
<div><span>Heap used</span><strong data-metric="heap">-</strong></div>
|
||||
<div><span>Plugins enabled</span><strong data-metric="plugins">-</strong></div>
|
||||
<div><span>Users</span><strong data-metric="users">-</strong></div>
|
||||
<div><span>Commands</span><strong data-metric="commands">-</strong></div>
|
||||
</div>
|
||||
<div class="dashboard-chart-grid">
|
||||
<figure class="dashboard-chart-card">
|
||||
<figcaption>Memory trend</figcaption>
|
||||
<svg viewBox="0 0 300 120" role="img" aria-label="Memory trend" data-memory-chart></svg>
|
||||
</figure>
|
||||
<figure class="dashboard-chart-card">
|
||||
<figcaption>Recent logs by severity</figcaption>
|
||||
<svg viewBox="0 0 300 120" role="img" aria-label="Recent logs by severity" data-log-chart></svg>
|
||||
</figure>
|
||||
</div>
|
||||
</section>
|
||||
<section class="card">
|
||||
<h2>Maintenance</h2>
|
||||
<form method="post" action="/admin/check-update" class="inline-form">
|
||||
<button type="submit" class="button subtle">Check for updates</button>
|
||||
</form>
|
||||
<form method="post" action="/admin/update" class="inline-form">
|
||||
<button type="submit" class="button">Update from git</button>
|
||||
</form>
|
||||
<form method="post" action="/admin/restart" class="inline-form">
|
||||
<button type="submit" class="button subtle">Restart bot</button>
|
||||
</form>
|
||||
<div class="button-group">
|
||||
<form method="post" action="/admin/check-update" class="inline-form">
|
||||
<%- include("partials/state-button", {
|
||||
type: "submit",
|
||||
classes: "subtle",
|
||||
states: [
|
||||
{ id: "idle", text: "Check for updates" },
|
||||
{ id: "loading", text: "Checking", spinner: true },
|
||||
{ id: "success", text: "Checked" }
|
||||
]
|
||||
}) %>
|
||||
</form>
|
||||
<form method="post" action="/admin/update" class="inline-form" data-confirm-mode="modal" data-confirm-title="Update from git" data-confirm-text="Pull updates from the configured remote and branch, then restart Lumi if the update succeeds." data-confirm-label="Update from git">
|
||||
<%- include("partials/state-button", {
|
||||
type: "submit",
|
||||
states: [
|
||||
{ id: "idle", text: "Update from git" },
|
||||
{ id: "loading", text: "Updating", spinner: true },
|
||||
{ id: "success", text: "Updated" }
|
||||
]
|
||||
}) %>
|
||||
</form>
|
||||
<form method="post" action="/admin/restart" class="inline-form">
|
||||
<%- include("partials/state-button", {
|
||||
type: "submit",
|
||||
classes: "subtle",
|
||||
states: [
|
||||
{ id: "idle", text: "Restart bot" },
|
||||
{ id: "loading", text: "Restarting", spinner: true },
|
||||
{ id: "success", text: "Restarting" }
|
||||
]
|
||||
}) %>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
<script src="/dashboard.js?v=<%= assetVersion %>" defer></script>
|
||||
<%- include("partials/layout-bottom") %>
|
||||
|
||||
|
||||
|
||||
@ -7,13 +7,18 @@
|
||||
<p class="command-subtitle">Core system logs with severity, timestamps, and details.</p>
|
||||
</div>
|
||||
<div class="log-controls">
|
||||
<input
|
||||
class="table-search"
|
||||
type="search"
|
||||
placeholder="Search logs"
|
||||
aria-label="Search logs"
|
||||
data-log-search
|
||||
/>
|
||||
<label>
|
||||
<span>Search</span>
|
||||
<input
|
||||
class="table-search"
|
||||
type="search"
|
||||
placeholder="Search logs"
|
||||
aria-label="Search logs"
|
||||
data-log-search
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>Severity</span>
|
||||
<select class="table-search" data-log-level aria-label="Filter log severity">
|
||||
<option value="all" <%= filters.level === 'all' ? 'selected' : '' %>>All severities</option>
|
||||
<option value="error" <%= filters.level === 'error' ? 'selected' : '' %>>Error</option>
|
||||
@ -21,6 +26,9 @@
|
||||
<option value="info" <%= filters.level === 'info' ? 'selected' : '' %>>Info</option>
|
||||
<option value="debug" <%= filters.level === 'debug' ? 'selected' : '' %>>Debug</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>Range</span>
|
||||
<select class="table-search" data-log-range aria-label="Filter by time range">
|
||||
<option value="all" <%= filters.range === 'all' ? 'selected' : '' %>>All time</option>
|
||||
<option value="<%= 60 * 60 * 1000 %>" <%= filters.range === `${60 * 60 * 1000}` ? 'selected' : '' %>>Last hour</option>
|
||||
@ -28,12 +36,18 @@
|
||||
<option value="<%= 7 * 24 * 60 * 60 * 1000 %>" <%= filters.range === `${7 * 24 * 60 * 60 * 1000}` ? 'selected' : '' %>>Last week</option>
|
||||
<option value="<%= 30 * 24 * 60 * 60 * 1000 %>" <%= filters.range === `${30 * 24 * 60 * 60 * 1000}` ? 'selected' : '' %>>Last month</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>Entries</span>
|
||||
<select class="table-search" data-log-limit aria-label="Limit log entries">
|
||||
<option value="50" <%= filters.limit === '50' ? 'selected' : '' %>>50 most recent</option>
|
||||
<option value="100" <%= filters.limit === '100' ? 'selected' : '' %>>100 most recent</option>
|
||||
<option value="250" <%= filters.limit === '250' ? 'selected' : '' %>>250 most recent</option>
|
||||
<option value="500" <%= filters.limit === '500' ? 'selected' : '' %>>500 most recent</option>
|
||||
</select>
|
||||
</label>
|
||||
<a class="button subtle" href="/admin/logs">Reset</a>
|
||||
<a class="button subtle" href="<%= `/admin/logs?range=${encodeURIComponent(filters.range)}&level=${encodeURIComponent(filters.level)}&limit=${encodeURIComponent(filters.limit)}` %>">Refresh</a>
|
||||
<button type="button" class="button subtle" data-log-download>Download logs</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -1,6 +1,10 @@
|
||||
<%- include("partials/layout-top", { title }) %>
|
||||
<section class="card">
|
||||
<h1>Custom pages</h1>
|
||||
<%- include("partials/page-header", {
|
||||
eyebrow: "Content",
|
||||
pageTitle: "Custom pages",
|
||||
description: "Publish role-aware HTML or Markdown pages without changing routes."
|
||||
}) %>
|
||||
<form method="post" action="/admin/pages" class="form-grid" data-page-form>
|
||||
<div class="field">
|
||||
<label>Slug</label>
|
||||
@ -49,8 +53,9 @@
|
||||
</form>
|
||||
<h2>Existing pages</h2>
|
||||
<% if (!pages.length) { %>
|
||||
<p>No pages created yet.</p>
|
||||
<div class="empty-state">No pages created yet.</div>
|
||||
<% } else { %>
|
||||
<div class="table-wrap">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
@ -144,6 +149,7 @@
|
||||
<% }) %>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<% } %>
|
||||
</section>
|
||||
<script>
|
||||
|
||||
@ -1,10 +1,15 @@
|
||||
<%- include("partials/layout-top", { title }) %>
|
||||
<section class="card">
|
||||
<h1>Plugins</h1>
|
||||
<%- include("partials/page-header", {
|
||||
eyebrow: "Extensions",
|
||||
pageTitle: "Plugins",
|
||||
description: "Install, update, enable, and remove Lumi modules."
|
||||
}) %>
|
||||
<h2>Installed plugins</h2>
|
||||
<% if (!plugins.length) { %>
|
||||
<p>No plugins installed.</p>
|
||||
<div class="empty-state">No plugins installed.</div>
|
||||
<% } else { %>
|
||||
<div class="table-wrap">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
@ -23,12 +28,28 @@
|
||||
<td>
|
||||
<form method="post" action="/admin/plugins/<%= plugin.id %>/toggle" class="inline-form">
|
||||
<input type="hidden" name="enabled" value="<%= plugin.enabled ? 'false' : 'true' %>" />
|
||||
<button type="submit" class="button subtle"><%= plugin.enabled ? "Disable" : "Enable" %></button>
|
||||
<%- include("partials/state-button", {
|
||||
type: "submit",
|
||||
classes: "subtle",
|
||||
states: [
|
||||
{ id: "idle", text: plugin.enabled ? "Disable" : "Enable" },
|
||||
{ id: "loading", text: "Saving", spinner: true },
|
||||
{ id: "success", text: "Saved" }
|
||||
]
|
||||
}) %>
|
||||
</form>
|
||||
<form method="post" action="/admin/plugins/<%= plugin.id %>/update" class="inline-form">
|
||||
<button type="submit" class="button subtle">Update</button>
|
||||
<%- include("partials/state-button", {
|
||||
type: "submit",
|
||||
classes: "subtle",
|
||||
states: [
|
||||
{ id: "idle", text: "Update" },
|
||||
{ id: "loading", text: "Updating", spinner: true },
|
||||
{ id: "success", text: "Updated" }
|
||||
]
|
||||
}) %>
|
||||
</form>
|
||||
<form method="post" action="/admin/plugins/<%= plugin.id %>/uninstall" class="inline-form" data-confirm-mode="modal" data-confirm-title="Uninstall <%= plugin.name %>?" data-confirm-text="This removes the plugin code and configuration records, then restarts Lumi.">
|
||||
<form method="post" action="/admin/plugins/<%= plugin.id %>/uninstall" class="inline-form" data-confirm-mode="modal" data-confirm-title="Uninstall <%= plugin.name %>?" data-confirm-text="This removes the plugin code and configuration records, then restarts Lumi." data-confirm-label="Uninstall plugin">
|
||||
<button type="submit" class="button danger">Uninstall</button>
|
||||
</form>
|
||||
</td>
|
||||
@ -36,15 +57,23 @@
|
||||
<% }) %>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<% } %>
|
||||
</section>
|
||||
<section class="card">
|
||||
<h2>Install plugin from ZIP</h2>
|
||||
<form method="post" action="/admin/plugins/upload" enctype="multipart/form-data" class="form-grid">
|
||||
<div class="field full">
|
||||
<div class="field full input-action-row">
|
||||
<input type="file" name="plugin_zip" accept=".zip" required />
|
||||
<%- include("partials/state-button", {
|
||||
type: "submit",
|
||||
states: [
|
||||
{ id: "idle", text: "Upload plugin" },
|
||||
{ id: "loading", text: "Uploading", spinner: true },
|
||||
{ id: "success", text: "Uploaded" }
|
||||
]
|
||||
}) %>
|
||||
</div>
|
||||
<button type="submit" class="button">Upload plugin</button>
|
||||
</form>
|
||||
</section>
|
||||
<section class="card">
|
||||
@ -54,7 +83,14 @@
|
||||
<label>Repository URL</label>
|
||||
<input name="url" placeholder="https://gitea.example.com/org/plugin.git" />
|
||||
</div>
|
||||
<button type="submit" class="button">Install plugin</button>
|
||||
<%- include("partials/state-button", {
|
||||
type: "submit",
|
||||
states: [
|
||||
{ id: "idle", text: "Install plugin" },
|
||||
{ id: "loading", text: "Installing", spinner: true },
|
||||
{ id: "success", text: "Installed" }
|
||||
]
|
||||
}) %>
|
||||
</form>
|
||||
</section>
|
||||
<section class="card">
|
||||
@ -72,7 +108,14 @@
|
||||
<label>Description</label>
|
||||
<input name="description" />
|
||||
</div>
|
||||
<button type="submit" class="button">Create plugin</button>
|
||||
<%- include("partials/state-button", {
|
||||
type: "submit",
|
||||
states: [
|
||||
{ id: "idle", text: "Create plugin" },
|
||||
{ id: "loading", text: "Creating", spinner: true },
|
||||
{ id: "success", text: "Created" }
|
||||
]
|
||||
}) %>
|
||||
</form>
|
||||
</section>
|
||||
<%- include("partials/layout-bottom") %>
|
||||
|
||||
@ -1,7 +1,11 @@
|
||||
<%- include("partials/layout-top", { title }) %>
|
||||
<section class="card">
|
||||
<h1>Settings</h1>
|
||||
<form method="post" action="/admin/settings" class="form-grid">
|
||||
<%- include("partials/layout-top", { title }) %>
|
||||
<section class="card">
|
||||
<%- include("partials/page-header", {
|
||||
eyebrow: "Administration",
|
||||
pageTitle: "Settings",
|
||||
description: "Manage core behavior, updates, and platform integrations."
|
||||
}) %>
|
||||
<form method="post" action="/admin/settings" class="form-grid" data-lumi-settings-form>
|
||||
<div class="field">
|
||||
<label>Site title</label>
|
||||
<input name="site_title" value="<%= settings.site_title || '' %>" />
|
||||
@ -37,30 +41,41 @@
|
||||
</div>
|
||||
<div class="field full">
|
||||
<div class="inline-actions">
|
||||
<button type="submit" class="button">Save settings</button>
|
||||
<button
|
||||
type="submit"
|
||||
class="button subtle"
|
||||
formaction="/admin/check-update"
|
||||
formmethod="post"
|
||||
>
|
||||
Check for updates
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
class="button subtle"
|
||||
formaction="/admin/update"
|
||||
formmethod="post"
|
||||
>
|
||||
Update from git
|
||||
</button>
|
||||
<%- include("partials/state-button", {
|
||||
type: "submit",
|
||||
states: [
|
||||
{ id: "idle", text: "Save settings" },
|
||||
{ id: "loading", text: "Saving", spinner: true },
|
||||
{ id: "success", text: "Saved" }
|
||||
]
|
||||
}) %>
|
||||
<%- include("partials/state-button", {
|
||||
type: "submit",
|
||||
classes: "subtle",
|
||||
attrs: "formaction=\"/admin/check-update\" formmethod=\"post\"",
|
||||
states: [
|
||||
{ id: "idle", text: "Check for updates" },
|
||||
{ id: "loading", text: "Checking", spinner: true },
|
||||
{ id: "success", text: "Checked" }
|
||||
]
|
||||
}) %>
|
||||
<%- include("partials/state-button", {
|
||||
type: "submit",
|
||||
classes: "subtle",
|
||||
attrs: "formaction=\"/admin/update\" formmethod=\"post\" data-confirm-mode=\"modal\" data-confirm-title=\"Update from git\" data-confirm-text=\"Pull updates from the configured remote and branch, then restart Lumi if the update succeeds.\" data-confirm-label=\"Update from git\"",
|
||||
states: [
|
||||
{ id: "idle", text: "Update from git" },
|
||||
{ id: "loading", text: "Updating", spinner: true },
|
||||
{ id: "success", text: "Updated" }
|
||||
]
|
||||
}) %>
|
||||
</div>
|
||||
<p class="hint">Git update checks use the configured remote and branch.</p>
|
||||
</div>
|
||||
|
||||
<div class="field full">
|
||||
<h2>Platform Integration</h2>
|
||||
<p class="hint">Enable or disable platform adapters and run the setup wizards.</p>
|
||||
<div class="field full">
|
||||
<h2>Platform Integration</h2>
|
||||
<p class="hint">Enable or disable platform adapters and run the setup wizards.</p>
|
||||
<div class="platform-grid">
|
||||
<% (platforms || []).forEach((platform) => { %>
|
||||
<div class="platform-card">
|
||||
@ -91,11 +106,64 @@
|
||||
<% } %>
|
||||
</div>
|
||||
<% }) %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="button">Save settings</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<% if (localhostLoginAvailable) { %>
|
||||
<div class="field full">
|
||||
<h2>Localhost Login</h2>
|
||||
<p class="hint">Development-only login shown only on localhost. Defaults are admin / admin until changed.</p>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Localhost username</label>
|
||||
<input name="localhost_login_username" value="<%= settings.localhost_login_username || 'admin' %>" autocomplete="off" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>New localhost password</label>
|
||||
<input name="localhost_login_password" type="password" placeholder="Leave blank to keep current password" autocomplete="new-password" />
|
||||
</div>
|
||||
<% } %>
|
||||
|
||||
<div class="field full">
|
||||
<h2>Homepage content</h2>
|
||||
<p class="hint">Configure public homepage link buttons and the priority-based dynamic hero without editing raw JSON.</p>
|
||||
</div>
|
||||
<div class="field full homepage-builder" data-homepage-builder="links">
|
||||
<div class="section-header">
|
||||
<div>
|
||||
<h3>Homepage link buttons</h3>
|
||||
<p class="hint">Add public or role-limited links shown as cards on the homepage.</p>
|
||||
</div>
|
||||
<button type="button" class="button subtle" data-homepage-add="links">Add link</button>
|
||||
</div>
|
||||
<textarea name="homepage_link_buttons" class="homepage-json-source" rows="8" spellcheck="false"><%= JSON.stringify(settings.homepage_link_buttons || [], null, 2) %></textarea>
|
||||
<div class="homepage-builder-list" data-homepage-list="links"></div>
|
||||
</div>
|
||||
<div class="field full homepage-builder" data-homepage-builder="heroes">
|
||||
<div class="section-header">
|
||||
<div>
|
||||
<h3>Homepage hero entries</h3>
|
||||
<p class="hint">The first available enabled hero by priority is shown on the homepage.</p>
|
||||
</div>
|
||||
<button type="button" class="button subtle" data-homepage-add="heroes">Add hero</button>
|
||||
</div>
|
||||
<textarea name="homepage_hero_entries" class="homepage-json-source" rows="10" spellcheck="false"><%= JSON.stringify(settings.homepage_hero_entries || [], null, 2) %></textarea>
|
||||
<div class="homepage-builder-list" data-homepage-list="heroes"></div>
|
||||
<details class="advanced-theme-controls">
|
||||
<summary>Advanced JSON</summary>
|
||||
<p class="hint">The builder writes JSON into hidden fields before save. Edit only if you need a field the builder does not expose.</p>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<%- include("partials/state-button", {
|
||||
type: "submit",
|
||||
states: [
|
||||
{ id: "idle", text: "Save settings" },
|
||||
{ id: "loading", text: "Saving", spinner: true },
|
||||
{ id: "success", text: "Saved" }
|
||||
]
|
||||
}) %>
|
||||
</form>
|
||||
</section>
|
||||
<section class="card">
|
||||
<h2>Navigation icons</h2>
|
||||
@ -113,16 +181,19 @@
|
||||
<div class="nav-icon-actions">
|
||||
<form method="post" action="/admin/settings/nav-icons" enctype="multipart/form-data" class="inline-form">
|
||||
<input type="hidden" name="item_id" value="<%= item.id %>" />
|
||||
<input type="file" name="icon_file" accept="image/svg+xml,image/png" />
|
||||
<button type="submit" class="button subtle">Upload</button>
|
||||
</form>
|
||||
<form method="post" action="/admin/settings/nav-icons/reset" class="inline-form">
|
||||
<input type="hidden" name="item_id" value="<%= item.id %>" />
|
||||
<button type="submit" class="button subtle">Reset</button>
|
||||
</form>
|
||||
<div class="input-action-row">
|
||||
<input type="file" name="icon_file" accept="image/svg+xml,image/png" />
|
||||
<button type="submit" class="button subtle">Upload</button>
|
||||
</div>
|
||||
</form>
|
||||
<form method="post" action="/admin/settings/nav-icons/reset" class="inline-form" data-confirm-title="Reset navigation icon" data-confirm-text="Reset the custom icon for <%= item.label %> to its default." data-confirm-label="Reset icon">
|
||||
<input type="hidden" name="item_id" value="<%= item.id %>" />
|
||||
<button type="submit" class="button subtle">Reset</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<% }) %>
|
||||
</div>
|
||||
</section>
|
||||
<%- include("partials/layout-bottom") %>
|
||||
<script src="/homepage-builder.js?v=<%= assetVersion %>" defer></script>
|
||||
<%- include("partials/layout-bottom") %>
|
||||
|
||||
@ -1,122 +1,384 @@
|
||||
<%- include("partials/layout-top", { title }) %>
|
||||
<section class="card">
|
||||
<h1>Theming</h1>
|
||||
<p>Update light and dark mode colors used across the WebUI.</p>
|
||||
<form method="post" action="/admin/theming" class="form-grid theme-grid">
|
||||
<h2>Light mode</h2>
|
||||
<div class="field">
|
||||
<label>Background 1</label>
|
||||
<input type="color" name="theme_light_bg_1" value="<%= theme.light.bg1 %>" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Background 2</label>
|
||||
<input type="color" name="theme_light_bg_2" value="<%= theme.light.bg2 %>" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Background 3</label>
|
||||
<input type="color" name="theme_light_bg_3" value="<%= theme.light.bg3 %>" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Text</label>
|
||||
<input type="color" name="theme_light_text" value="<%= theme.light.text %>" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Muted text</label>
|
||||
<input type="color" name="theme_light_text_muted" value="<%= theme.light.muted %>" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Accent</label>
|
||||
<input type="color" name="theme_light_accent" value="<%= theme.light.accent %>" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Accent alt</label>
|
||||
<input type="color" name="theme_light_accent_alt" value="<%= theme.light.accentAlt %>" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Danger</label>
|
||||
<input type="color" name="theme_light_danger" value="<%= theme.light.danger %>" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Surface</label>
|
||||
<input type="color" name="theme_light_surface" value="<%= theme.light.surface %>" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Surface 2</label>
|
||||
<input type="color" name="theme_light_surface_2" value="<%= theme.light.surface2 %>" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Surface 3</label>
|
||||
<input type="color" name="theme_light_surface_3" value="<%= theme.light.surface3 %>" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Border</label>
|
||||
<input type="color" name="theme_light_border" value="<%= theme.light.border %>" />
|
||||
</div>
|
||||
<link rel="stylesheet" href="/theme-editor.css?v=<%= assetVersion %>" />
|
||||
<%
|
||||
const fieldLabels = {
|
||||
bg1: "Background glow",
|
||||
bg2: "Page background",
|
||||
bg3: "Accent glow",
|
||||
text: "Primary text",
|
||||
muted: "Muted text",
|
||||
accent: "Primary",
|
||||
accentAlt: "Accent",
|
||||
success: "Success",
|
||||
warning: "Warning",
|
||||
danger: "Danger",
|
||||
info: "Information",
|
||||
surface: "Surface",
|
||||
surface2: "Subtle surface",
|
||||
surface3: "Raised surface",
|
||||
border: "Border",
|
||||
link: "Link",
|
||||
buttonBg: "Button background",
|
||||
buttonText: "Button text",
|
||||
buttonHover: "Button hover",
|
||||
inputBg: "Input background",
|
||||
inputBorder: "Input border",
|
||||
inputText: "Input text",
|
||||
focusRing: "Focus ring"
|
||||
};
|
||||
const basicFields = [
|
||||
"accent", "accentAlt", "bg2", "surface", "surface2", "text", "muted",
|
||||
"border", "success", "warning", "danger", "info"
|
||||
];
|
||||
const advancedFields = [
|
||||
"bg1", "bg3", "surface3", "link", "buttonBg", "buttonText",
|
||||
"buttonHover", "inputBg", "inputBorder", "inputText", "focusRing"
|
||||
];
|
||||
const fontStackEntries = Object.entries((typeof fontStacks !== "undefined" && fontStacks) || {});
|
||||
const baseTheme = (typeof editingBaseTheme !== "undefined" && editingBaseTheme) || editingTheme || activeTheme;
|
||||
%>
|
||||
|
||||
<h2>Dark mode</h2>
|
||||
<div class="field">
|
||||
<label>Background 1</label>
|
||||
<input type="color" name="theme_dark_bg_1" value="<%= theme.dark.bg1 %>" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Background 2</label>
|
||||
<input type="color" name="theme_dark_bg_2" value="<%= theme.dark.bg2 %>" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Background 3</label>
|
||||
<input type="color" name="theme_dark_bg_3" value="<%= theme.dark.bg3 %>" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Text</label>
|
||||
<input type="color" name="theme_dark_text" value="<%= theme.dark.text %>" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Muted text</label>
|
||||
<input type="color" name="theme_dark_text_muted" value="<%= theme.dark.muted %>" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Accent</label>
|
||||
<input type="color" name="theme_dark_accent" value="<%= theme.dark.accent %>" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Accent alt</label>
|
||||
<input type="color" name="theme_dark_accent_alt" value="<%= theme.dark.accentAlt %>" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Danger</label>
|
||||
<input type="color" name="theme_dark_danger" value="<%= theme.dark.danger %>" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Surface</label>
|
||||
<input type="color" name="theme_dark_surface" value="<%= theme.dark.surface %>" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Surface 2</label>
|
||||
<input type="color" name="theme_dark_surface_2" value="<%= theme.dark.surface2 %>" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Surface 3</label>
|
||||
<input type="color" name="theme_dark_surface_3" value="<%= theme.dark.surface3 %>" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Border</label>
|
||||
<input type="color" name="theme_dark_border" value="<%= theme.dark.border %>" />
|
||||
</div>
|
||||
<header class="page-header">
|
||||
<div>
|
||||
<span class="eyebrow">Appearance</span>
|
||||
<h1>Theme studio</h1>
|
||||
<p class="command-subtitle">
|
||||
Select a protected Lumi preset or duplicate one into a custom theme.
|
||||
</p>
|
||||
</div>
|
||||
<span class="status-indicator status-success">
|
||||
Active: <strong><%= activeTheme.name %></strong>
|
||||
</span>
|
||||
</header>
|
||||
|
||||
<h2>Role colors</h2>
|
||||
<div class="field">
|
||||
<label>Public</label>
|
||||
<input type="color" name="theme_role_public" value="<%= theme.role.public %>" />
|
||||
<section class="theme-library" aria-labelledby="theme-library-title">
|
||||
<div class="section-header">
|
||||
<div>
|
||||
<h2 id="theme-library-title">Theme library</h2>
|
||||
<p class="hint">Built-in themes are read-only and always remain available.</p>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Moderator</label>
|
||||
<input type="color" name="theme_role_mod" value="<%= theme.role.mod %>" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Admin</label>
|
||||
<input type="color" name="theme_role_admin" value="<%= theme.role.admin %>" />
|
||||
</div>
|
||||
<button type="submit" class="button">Save theme</button>
|
||||
</form>
|
||||
</div>
|
||||
<div class="theme-card-grid">
|
||||
<% themes.forEach((item) => { %>
|
||||
<% const customId = item.builtin ? null : item.id.replace("custom:", ""); %>
|
||||
<article class="theme-card <%= item.id === activeTheme.id ? 'is-active' : '' %>">
|
||||
<div class="theme-swatch" style="--swatch-bg: <%= item.light.bg2 %>; --swatch-surface: <%= item.light.surface %>; --swatch-primary: <%= item.light.accent %>; --swatch-accent: <%= item.light.accentAlt %>;">
|
||||
<span></span><span></span><span></span>
|
||||
</div>
|
||||
<div class="theme-card-copy">
|
||||
<div class="theme-card-title">
|
||||
<h3><%= item.name %></h3>
|
||||
<span class="theme-kind"><%- item.builtin ? "Built-in · read-only" : "Custom" %></span>
|
||||
</div>
|
||||
<p><%= item.description %></p>
|
||||
</div>
|
||||
<div class="theme-card-actions">
|
||||
<% if (item.id !== activeTheme.id) { %>
|
||||
<form method="post" action="/admin/theming/select">
|
||||
<input type="hidden" name="theme_id" value="<%= item.id %>" />
|
||||
<%- include("partials/state-button", {
|
||||
type: "submit",
|
||||
classes: "subtle",
|
||||
states: [
|
||||
{ id: "idle", text: "Apply" },
|
||||
{ id: "loading", text: "Applying", spinner: true },
|
||||
{ id: "success", text: "Applied" }
|
||||
]
|
||||
}) %>
|
||||
</form>
|
||||
<% } else { %>
|
||||
<span class="button subtle disabled" aria-current="true">Active</span>
|
||||
<% } %>
|
||||
<% if (!item.builtin) { %>
|
||||
<a class="button subtle" href="/admin/theming?edit=<%= encodeURIComponent(item.id) %>#theme-editor">Edit</a>
|
||||
<% } %>
|
||||
</div>
|
||||
<details class="theme-card-more">
|
||||
<summary>More actions</summary>
|
||||
<div class="theme-card-more-body">
|
||||
<form method="post" action="/admin/theming/duplicate" class="compact-form">
|
||||
<input type="hidden" name="theme_id" value="<%= item.id %>" />
|
||||
<label>
|
||||
<span>Copy name</span>
|
||||
<input name="name" value="<%= item.name %> Copy" maxlength="60" required />
|
||||
</label>
|
||||
<%- include("partials/state-button", {
|
||||
type: "submit",
|
||||
states: [
|
||||
{ id: "idle", text: "Duplicate" },
|
||||
{ id: "loading", text: "Duplicating", spinner: true },
|
||||
{ id: "success", text: "Created" }
|
||||
]
|
||||
}) %>
|
||||
</form>
|
||||
<% if (!item.builtin) { %>
|
||||
<form method="post" action="/admin/theming/custom/<%= customId %>/rename" class="compact-form">
|
||||
<label>
|
||||
<span>Theme name</span>
|
||||
<input name="name" value="<%= item.name %>" maxlength="60" required />
|
||||
</label>
|
||||
<%- include("partials/state-button", {
|
||||
type: "submit",
|
||||
classes: "subtle",
|
||||
states: [
|
||||
{ id: "idle", text: "Rename" },
|
||||
{ id: "loading", text: "Renaming", spinner: true },
|
||||
{ id: "success", text: "Renamed" }
|
||||
]
|
||||
}) %>
|
||||
</form>
|
||||
<form
|
||||
method="post"
|
||||
action="/admin/theming/custom/<%= customId %>/delete"
|
||||
data-confirm-title="Delete custom theme"
|
||||
data-confirm-text="Delete <%= item.name %>? Built-in themes are not affected."
|
||||
data-confirm-label="Delete theme"
|
||||
>
|
||||
<button type="submit" class="button danger">Delete</button>
|
||||
</form>
|
||||
<% } %>
|
||||
</div>
|
||||
</details>
|
||||
</article>
|
||||
<% }) %>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<% if (editingTheme) { %>
|
||||
<section class="theme-editor-shell" id="theme-editor" data-theme-editor>
|
||||
<div class="theme-editor-main card">
|
||||
<div class="section-header">
|
||||
<div>
|
||||
<span class="eyebrow">Custom theme</span>
|
||||
<h2>Edit <%= editingTheme.name %></h2>
|
||||
<p class="hint">Based on <%= themes.find((item) => item.id === editingTheme.baseThemeId)?.name || "Lumi Default" %>.</p>
|
||||
</div>
|
||||
<div class="button-group" aria-label="Preview color scheme">
|
||||
<button type="button" class="button subtle is-selected" data-theme-preview-mode="light">Light preview</button>
|
||||
<button type="button" class="button subtle" data-theme-preview-mode="dark">Dark preview</button>
|
||||
<button type="button" class="button subtle theme-mobile-preview-action" data-theme-popout>Preview</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form
|
||||
method="post"
|
||||
action="/admin/theming/custom/<%= editingTheme.id.replace('custom:', '') %>/save"
|
||||
class="theme-edit-form"
|
||||
data-theme-form
|
||||
>
|
||||
<% ["light", "dark"].forEach((mode) => { %>
|
||||
<fieldset class="theme-fieldset" data-theme-mode-fields="<%= mode %>">
|
||||
<legend><%= mode === "light" ? "Light mode" : "Dark mode" %></legend>
|
||||
<div class="theme-control-grid">
|
||||
<% basicFields.forEach((field) => { %>
|
||||
<label class="theme-color-control">
|
||||
<span><%= fieldLabels[field] %></span>
|
||||
<span class="theme-color-input">
|
||||
<input
|
||||
type="color"
|
||||
name="<%= mode %>_<%= field %>"
|
||||
value="<%= editingTheme[mode][field] %>"
|
||||
data-theme-token="<%= field %>"
|
||||
data-theme-mode="<%= mode %>"
|
||||
data-base-value="<%= baseTheme[mode][field] %>"
|
||||
/>
|
||||
<output><%= editingTheme[mode][field] %></output>
|
||||
</span>
|
||||
</label>
|
||||
<% }) %>
|
||||
</div>
|
||||
<details class="advanced-theme-controls">
|
||||
<summary>Advanced <%= mode %> variables</summary>
|
||||
<div class="theme-control-grid">
|
||||
<% advancedFields.forEach((field) => { %>
|
||||
<label class="theme-color-control">
|
||||
<span><%= fieldLabels[field] %></span>
|
||||
<span class="theme-color-input">
|
||||
<input
|
||||
type="color"
|
||||
name="<%= mode %>_<%= field %>"
|
||||
value="<%= editingTheme[mode][field] %>"
|
||||
data-theme-token="<%= field %>"
|
||||
data-theme-mode="<%= mode %>"
|
||||
data-base-value="<%= baseTheme[mode][field] %>"
|
||||
/>
|
||||
<output><%= editingTheme[mode][field] %></output>
|
||||
</span>
|
||||
</label>
|
||||
<% }) %>
|
||||
</div>
|
||||
</details>
|
||||
</fieldset>
|
||||
<% }) %>
|
||||
|
||||
<fieldset class="theme-fieldset">
|
||||
<legend>Roles and shape</legend>
|
||||
<div class="theme-control-grid">
|
||||
<% ["public", "mod", "admin"].forEach((role) => { %>
|
||||
<label class="theme-color-control">
|
||||
<span><%= role.charAt(0).toUpperCase() + role.slice(1) %> role</span>
|
||||
<span class="theme-color-input">
|
||||
<input type="color" name="role_<%= role %>" value="<%= editingTheme.role[role] %>" data-theme-role="<%= role %>" data-base-value="<%= baseTheme.role[role] %>" />
|
||||
<output><%= editingTheme.role[role] %></output>
|
||||
</span>
|
||||
</label>
|
||||
<% }) %>
|
||||
<label class="theme-range-control">
|
||||
<span>Border radius <output data-range-output><%= editingTheme.metrics.radius %>px</output></span>
|
||||
<input type="range" name="metrics_radius" min="0" max="32" step="1" value="<%= editingTheme.metrics.radius %>" data-theme-metric="radius" data-unit="px" data-base-value="<%= baseTheme.metrics.radius %>" />
|
||||
</label>
|
||||
<label class="theme-range-control">
|
||||
<span>Shadow strength <output data-range-output><%= editingTheme.metrics.shadowStrength %></output></span>
|
||||
<input type="range" name="metrics_shadowStrength" min="0" max="0.35" step="0.01" value="<%= editingTheme.metrics.shadowStrength %>" data-theme-metric="shadowStrength" data-base-value="<%= baseTheme.metrics.shadowStrength %>" />
|
||||
</label>
|
||||
<label class="theme-range-control">
|
||||
<span>Spacing scale <output data-range-output><%= editingTheme.metrics.spacingScale %></output></span>
|
||||
<input type="range" name="metrics_spacingScale" min="0.75" max="1.35" step="0.05" value="<%= editingTheme.metrics.spacingScale %>" data-theme-metric="spacingScale" data-base-value="<%= baseTheme.metrics.spacingScale %>" />
|
||||
</label>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<fieldset class="theme-fieldset">
|
||||
<legend>Typography</legend>
|
||||
<div class="theme-control-grid">
|
||||
<% [
|
||||
["bodyFont", "Body font", "data-theme-font='body'"],
|
||||
["displayFont", "Heading font", "data-theme-font='display'"],
|
||||
["monoFont", "Monospace font", "data-theme-font='mono'"]
|
||||
].forEach(([field, label, dataAttr]) => { %>
|
||||
<label class="theme-select-control">
|
||||
<span><%= label %></span>
|
||||
<select
|
||||
name="typography_<%= field %>"
|
||||
<%- dataAttr %>
|
||||
data-base-value="<%= baseTheme.typography[field] %>"
|
||||
>
|
||||
<% fontStackEntries.forEach(([key, font]) => { %>
|
||||
<option value="<%= key %>" data-font-stack="<%= font.stack %>" <%= editingTheme.typography[field] === key ? "selected" : "" %>><%= font.label %></option>
|
||||
<% }) %>
|
||||
</select>
|
||||
</label>
|
||||
<% }) %>
|
||||
<label class="theme-range-control">
|
||||
<span>Base text size <output data-range-output><%= editingTheme.typography.baseSize %>px</output></span>
|
||||
<input type="range" name="typography_baseSize" min="14" max="19" step="1" value="<%= editingTheme.typography.baseSize %>" data-theme-typography="baseSize" data-unit="px" data-base-value="<%= baseTheme.typography.baseSize %>" />
|
||||
</label>
|
||||
<label class="theme-range-control">
|
||||
<span>Heading scale <output data-range-output><%= editingTheme.typography.headingScale %></output></span>
|
||||
<input type="range" name="typography_headingScale" min="0.9" max="1.2" step="0.01" value="<%= editingTheme.typography.headingScale %>" data-theme-typography="headingScale" data-base-value="<%= baseTheme.typography.headingScale %>" />
|
||||
</label>
|
||||
<label class="theme-range-control">
|
||||
<span>Control density <output data-range-output><%= editingTheme.typography.controlScale %></output></span>
|
||||
<input type="range" name="typography_controlScale" min="0.9" max="1.12" step="0.01" value="<%= editingTheme.typography.controlScale %>" data-theme-typography="controlScale" data-base-value="<%= baseTheme.typography.controlScale %>" />
|
||||
</label>
|
||||
</div>
|
||||
<div class="theme-inline-actions">
|
||||
<button type="button" class="button subtle" data-theme-reset-to-base>Reset changed controls to base theme</button>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<div class="theme-validation-panel" data-theme-warnings aria-live="polite"></div>
|
||||
|
||||
<div class="theme-editor-actions">
|
||||
<label class="switch">
|
||||
<input type="checkbox" class="switch-input" name="apply" <%= editingTheme.id === activeTheme.id ? "checked" : "" %> />
|
||||
<span class="switch-track" aria-hidden="true"></span>
|
||||
<span class="switch-text">Apply globally after saving</span>
|
||||
</label>
|
||||
<div class="button-group">
|
||||
<%- include("partials/state-button", {
|
||||
type: "button",
|
||||
classes: "subtle",
|
||||
attrs: "data-theme-reset",
|
||||
resetDelay: 1200,
|
||||
states: [
|
||||
{ id: "idle", text: "Revert unsaved" },
|
||||
{ id: "success", text: "Reverted" }
|
||||
]
|
||||
}) %>
|
||||
<a href="/admin/theming" class="button subtle">Cancel</a>
|
||||
<%- include("partials/state-button", {
|
||||
type: "submit",
|
||||
states: [
|
||||
{ id: "idle", text: "Save theme" },
|
||||
{ id: "loading", text: "Saving", spinner: true },
|
||||
{ id: "success", text: "Saved" }
|
||||
]
|
||||
}) %>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<aside class="theme-preview card" aria-label="Live theme preview">
|
||||
<div class="theme-preview-header">
|
||||
<span class="eyebrow">Live preview</span>
|
||||
<button type="button" class="button subtle theme-popout-button" data-theme-popout>Pop out</button>
|
||||
</div>
|
||||
<div class="theme-preview-window" data-theme-preview-root>
|
||||
<div class="theme-preview-nav">
|
||||
<span class="theme-preview-logo">L</span>
|
||||
<span class="is-active"></span><span></span><span></span>
|
||||
</div>
|
||||
<div class="theme-preview-content">
|
||||
<span class="eyebrow theme-preview-pill">Preview pill</span>
|
||||
<h2>Theme controlled heading</h2>
|
||||
<p class="hint">Typography, spacing, colors, radius, shadows, and state tokens update only inside this preview.</p>
|
||||
<div class="theme-preview-sample-card">
|
||||
<strong>Community overview</strong>
|
||||
<p>Preview text, surfaces, borders, and controls before saving.</p>
|
||||
<div class="button-group">
|
||||
<span class="button">Primary</span>
|
||||
<span class="button subtle">Secondary</span>
|
||||
<span class="button lumi-state-btn">
|
||||
<span class="lumi-state-btn-content">
|
||||
<span data-state-view="idle">Download</span>
|
||||
<span data-state-view="loading" data-state-hidden="true"><span class="lumi-state-btn-spinner" aria-hidden="true"></span>Downloading</span>
|
||||
<span data-state-view="success" data-state-hidden="true">Downloaded</span>
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<input value="Input preview" readonly aria-label="Input preview" />
|
||||
<label class="switch">
|
||||
<input class="switch-input" type="checkbox" checked />
|
||||
<span class="switch-track" aria-hidden="true"></span>
|
||||
<span class="switch-text">Toggle sample</span>
|
||||
</label>
|
||||
<div class="theme-preview-statuses">
|
||||
<span class="status-success">Success</span>
|
||||
<span class="status-warning">Warning</span>
|
||||
<span class="status-danger">Danger</span>
|
||||
</div>
|
||||
<div class="theme-preview-badges">
|
||||
<span class="badge">Badge</span>
|
||||
<span class="pill">Role <span style="color:var(--role-admin)">admin</span></span>
|
||||
</div>
|
||||
<div class="is-unsaved theme-preview-dirty">Dirty state marker</div>
|
||||
<div class="table-wrap theme-preview-table">
|
||||
<table class="table">
|
||||
<thead><tr><th>Level</th><th>Message</th></tr></thead>
|
||||
<tbody><tr><td>Info</td><td>Command synced</td></tr><tr><td>Warn</td><td>Queue growing</td></tr></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="theme-preview-modal-sample">
|
||||
<strong>Modal sample</strong>
|
||||
<p class="hint">Radius, border, surface, and shadow.</p>
|
||||
</div>
|
||||
<div class="theme-spacing-sample"><span></span><span></span><span></span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="hint" data-theme-popout-status>Preview changes are local to this page until you save.</p>
|
||||
</aside>
|
||||
</section>
|
||||
<% } else { %>
|
||||
<section class="empty-state">
|
||||
<div>
|
||||
<h2>Create a custom theme to edit</h2>
|
||||
<p>Open "More actions" on any built-in or custom theme, then duplicate it.</p>
|
||||
</div>
|
||||
</section>
|
||||
<% } %>
|
||||
|
||||
<script src="/theme-editor.js?v=<%= assetVersion %>"></script>
|
||||
<%- include("partials/layout-bottom") %>
|
||||
|
||||
@ -1,76 +1,267 @@
|
||||
<%- include("partials/layout-top", { title }) %>
|
||||
<section class="card">
|
||||
<h1>Updates</h1>
|
||||
<p>Upload ZIP archives for core bot updates or plugin updates. A snapshot is taken before each update.</p>
|
||||
<p class="hint">Rollback is handled from Safe Mode if something breaks.</p>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2>Git updates</h2>
|
||||
<p>Check or pull updates from the remote and branch configured in Settings.</p>
|
||||
<div class="inline-actions">
|
||||
<form method="post" action="/admin/check-update" class="inline-form">
|
||||
<button type="submit" class="button subtle">Check for updates</button>
|
||||
</form>
|
||||
<form method="post" action="/admin/update" class="inline-form">
|
||||
<button type="submit" class="button">Update from git</button>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2>Upload bot update</h2>
|
||||
<form method="post" action="/admin/updates/bot" enctype="multipart/form-data" class="form-grid">
|
||||
<div class="field full">
|
||||
<input type="file" name="update_zip" accept=".zip" required />
|
||||
</div>
|
||||
<div class="field full">
|
||||
<label>Patch mode (apply only files in ZIP, skip full package verification)</label>
|
||||
<label class="switch">
|
||||
<input type="checkbox" class="switch-input" name="patch_mode" value="1" />
|
||||
<span class="switch-track" aria-hidden="true"></span>
|
||||
<span class="switch-text">Patch mode</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="field full">
|
||||
<button type="submit" class="button">Upload bot update</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2>Upload plugin update</h2>
|
||||
<form method="post" action="/admin/updates/plugin" enctype="multipart/form-data" class="form-grid">
|
||||
<div class="field full">
|
||||
<input type="file" name="plugin_zip" accept=".zip" required />
|
||||
</div>
|
||||
<div class="field full">
|
||||
<button type="submit" class="button">Upload plugin update</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2>Snapshots</h2>
|
||||
<% if (!snapshots.length) { %>
|
||||
<p>No snapshots yet.</p>
|
||||
<% } else { %>
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Snapshot</th>
|
||||
<th>Created</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<% snapshots.forEach((snap) => { %>
|
||||
<tr>
|
||||
<td><%= snap.type === 'plugin' ? `Plugin: ${snap.pluginId}` : 'Bot core' %></td>
|
||||
<td><%= new Date(snap.createdAt).toLocaleString() %></td>
|
||||
</tr>
|
||||
<% }) %>
|
||||
</tbody>
|
||||
</table>
|
||||
<% } %>
|
||||
</section>
|
||||
<%- include("partials/layout-bottom") %>
|
||||
<%- include("partials/layout-top", { title }) %>
|
||||
<%
|
||||
const status = updateStatus || {};
|
||||
const core = status.core || null;
|
||||
const plugins = status.plugins || [];
|
||||
const summary = status.plugins_summary || { installed_plugins: 0, total_plugins: 0, updatable_plugins: 0, blocked_plugins: 0 };
|
||||
const recovery = recoveryStatus || status.recovery || {};
|
||||
const marker = recovery.marker || null;
|
||||
const selectedSource = status.requested_source || "stable";
|
||||
const badgeClass = (item) => item?.blocked ? "danger" : item?.update_available ? "warning" : "success";
|
||||
const badgeText = (item) => item?.blocked ? "Blocked" : item?.update_available ? "Update available" : "Current";
|
||||
const changelogItems = (item) => Array.isArray(item?.changelog_range) ? item.changelog_range : [];
|
||||
const applyLabel = (item) => item?.unversioned_update ? "Apply manual repo update" : "Apply safe target";
|
||||
const applyConfirmText = (item, label) => {
|
||||
const base = `${label}: create a snapshot, write a recovery marker, apply ${item?.safe_target_version || "the selected repo target"}, and verify before finishing.`;
|
||||
if (!item?.unversioned_update) return base;
|
||||
const warnings = (item.warnings || []).join(" ");
|
||||
return `${base} Warning: this update is to or from an unversioned install/target, so Lumi cannot verify version ordering, changelog range, or rollback safety. ${warnings}`;
|
||||
};
|
||||
%>
|
||||
|
||||
<section class="card">
|
||||
<%- include("partials/page-header", {
|
||||
eyebrow: "Maintenance",
|
||||
pageTitle: "Updates",
|
||||
description: "Version-aware core and plugin updates with snapshots, safe targets, recovery markers, revert, and advanced ZIP fallback."
|
||||
}) %>
|
||||
<% if (updateStatusError) { %>
|
||||
<div class="callout danger">Update metadata could not be loaded: <%= updateStatusError %></div>
|
||||
<% } %>
|
||||
<form method="get" action="/admin/updates" class="inline-actions">
|
||||
<label class="field compact">
|
||||
<span>Update source</span>
|
||||
<select name="source">
|
||||
<option value="stable" <%= selectedSource === "stable" ? "selected" : "" %>>Stable main</option>
|
||||
<option value="experimental" <%= selectedSource === "experimental" ? "selected" : "" %>>Newest experimental-*</option>
|
||||
</select>
|
||||
</label>
|
||||
<button class="button subtle" type="submit">Reload metadata</button>
|
||||
</form>
|
||||
<p class="hint">Stable checks read repo metadata from <code>main</code>. Experimental branches are considered only when selected here.</p>
|
||||
</section>
|
||||
|
||||
<% if (recovery.has_incomplete_marker || marker) { %>
|
||||
<section class="card update-recovery-banner">
|
||||
<h2>Safe Mode / Recovery</h2>
|
||||
<p>
|
||||
Recovery marker status:
|
||||
<strong><%= marker?.status || "none" %></strong>
|
||||
<% if (marker?.target_kind) { %>
|
||||
for <strong><%= marker.target_kind %><%= marker.target_id ? `:${marker.target_id}` : "" %></strong>
|
||||
<% } %>
|
||||
</p>
|
||||
<% if (marker) { %>
|
||||
<div class="update-meta-grid">
|
||||
<div><span>From</span><strong><%= marker.from_version || "unknown" %></strong></div>
|
||||
<div><span>To</span><strong><%= marker.to_version || "unknown" %></strong></div>
|
||||
<div><span>Method</span><strong><%= marker.update_method || "unknown" %></strong></div>
|
||||
<div><span>Source</span><strong><%= marker.source_branch || "unknown" %></strong></div>
|
||||
<div><span>Snapshot</span><strong><%= marker.snapshot_id || "pending" %></strong></div>
|
||||
<div><span>Updated</span><strong><%= marker.updated_at ? new Date(marker.updated_at).toLocaleString() : "unknown" %></strong></div>
|
||||
</div>
|
||||
<% 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">
|
||||
<button class="button danger" type="submit">Clear stale marker</button>
|
||||
</form>
|
||||
<form method="post" action="/admin/updates/recovery/retry-normal-startup">
|
||||
<button class="button subtle" type="submit">Retry normal startup</button>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
<% } %>
|
||||
|
||||
<section class="card" data-update-panel>
|
||||
<details class="lumi-expandable-settings" <%= core?.blocked || core?.update_available || recovery.has_incomplete_marker ? "open" : "" %>>
|
||||
<summary>
|
||||
<span>
|
||||
<strong>Core</strong>
|
||||
<span class="hint">Current <%= core?.current_version || "unknown" %> · Target <%= core?.safe_target_version || "none" %> · <%= core?.source_branch || "main" %></span>
|
||||
</span>
|
||||
<span class="badge <%= badgeClass(core) %>"><%= badgeText(core) %></span>
|
||||
</summary>
|
||||
<div class="lumi-expandable-body update-detail-grid">
|
||||
<% if (core) { %>
|
||||
<div class="update-meta-grid">
|
||||
<div><span>Current</span><strong><%= core.current_version %></strong></div>
|
||||
<div><span>Safe target</span><strong><%= core.safe_target_version || "None" %></strong></div>
|
||||
<div><span>Latest</span><strong><%= core.latest_available_version %></strong></div>
|
||||
<div><span>Source branch</span><strong><%= core.source_branch %></strong></div>
|
||||
<div><span>Size change</span><strong><%= core.size_delta_label %></strong></div>
|
||||
<div><span>Snapshot</span><strong><%= core.snapshot.available ? core.snapshot.latest_snapshot_id : "None" %></strong></div>
|
||||
</div>
|
||||
<p><%= core.version_description %></p>
|
||||
<% if (core.warnings?.length) { %><div class="callout"><strong>Warnings</strong><ul><% core.warnings.forEach((item) => { %><li><%= item %></li><% }) %></ul></div><% } %>
|
||||
<% if (core.unversioned_update) { %><div class="callout danger"><strong>Manual confirmation required</strong><p>This core update is to or from an unversioned state. It remains available, but rollback safety and version ordering cannot be verified from metadata.</p></div><% } %>
|
||||
<% if (core.dangers?.length) { %><div class="callout danger"><strong>Dangers</strong><ul><% core.dangers.forEach((item) => { %><li><%= item %></li><% }) %></ul></div><% } %>
|
||||
<% if (core.requirements?.length) { %><div class="callout"><strong>Requirements</strong><ul><% core.requirements.forEach((item) => { %><li><%= item %></li><% }) %></ul></div><% } %>
|
||||
<% if (core.migration_notes) { %><div class="callout"><strong>Migration notes</strong><p><%= core.migration_notes %></p></div><% } %>
|
||||
<div>
|
||||
<h3>Changelog to target</h3>
|
||||
<% if (!changelogItems(core).length) { %>
|
||||
<p class="hint">No changelog entries were found for this range.</p>
|
||||
<% } else { %>
|
||||
<ul class="update-changelog">
|
||||
<% changelogItems(core).forEach((entry) => { %>
|
||||
<li><strong><%= entry.version %></strong><span><%= Array.isArray(entry.changes) ? entry.changes.join(" ") : (entry.description || entry.title || "") %></span></li>
|
||||
<% }) %>
|
||||
</ul>
|
||||
<% } %>
|
||||
</div>
|
||||
<div class="inline-actions">
|
||||
<form method="post" action="/admin/updates/core/check" data-update-action>
|
||||
<input type="hidden" name="source" value="<%= selectedSource %>" />
|
||||
<button class="button subtle" type="submit">Check core</button>
|
||||
</form>
|
||||
<form method="post" action="/admin/updates/core/apply" data-update-action data-confirm-mode="modal" data-confirm-title="<%= core.unversioned_update ? "Apply unversioned core update" : "Apply core update" %>" data-confirm-text="<%= applyConfirmText(core, "Core update") %>" data-confirm-label="<%= applyLabel(core) %>">
|
||||
<input type="hidden" name="source" value="<%= selectedSource %>" />
|
||||
<button class="button" type="submit" <%= core.blocked || !core.update_available ? "disabled" : "" %>><%= applyLabel(core) %></button>
|
||||
</form>
|
||||
<% if (core.snapshot.available) { %>
|
||||
<form method="post" action="/admin/updates/core/revert" data-update-action data-confirm-mode="modal" data-confirm-title="Revert core snapshot" data-confirm-text="Revert only the previous core version snapshot. Major rollback is blocked unless metadata marks it rollback safe." data-confirm-label="Revert core">
|
||||
<input type="hidden" name="source" value="<%= selectedSource %>" />
|
||||
<input type="hidden" name="snapshot_id" value="<%= core.snapshot.latest_snapshot_id %>" />
|
||||
<button class="button danger" type="submit">Revert previous</button>
|
||||
</form>
|
||||
<% } %>
|
||||
</div>
|
||||
<details class="inline-details">
|
||||
<summary>Show advanced ZIP update options</summary>
|
||||
<div class="callout">ZIP updates create snapshots and recovery markers, but may bypass repo metadata and compatibility checks unless the ZIP includes valid manifest data.</div>
|
||||
<form method="post" action="/admin/updates/core/zip" enctype="multipart/form-data" class="form-grid">
|
||||
<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>
|
||||
</div>
|
||||
<label class="switch"><input type="checkbox" class="switch-input" name="patch_mode" value="1" /><span class="switch-track"></span><span class="switch-text">Patch mode</span></label>
|
||||
<label class="switch"><input type="checkbox" class="switch-input" name="rollback_safe" value="1" /><span class="switch-track"></span><span class="switch-text">ZIP manifest/notes mark rollback safe</span></label>
|
||||
</form>
|
||||
</details>
|
||||
<% } %>
|
||||
</div>
|
||||
</details>
|
||||
</section>
|
||||
|
||||
<section class="card" data-update-panel>
|
||||
<details class="lumi-expandable-settings" <%= summary.updatable_plugins || summary.blocked_plugins ? "open" : "" %>>
|
||||
<summary>
|
||||
<span>
|
||||
<strong>Plugins</strong>
|
||||
<span class="hint"><%= summary.installed_plugins %> installed · <%= summary.total_plugins %> known · <%= summary.updatable_plugins %> updatable</span>
|
||||
</span>
|
||||
<span class="badge <%= summary.blocked_plugins ? "danger" : summary.updatable_plugins ? "warning" : "success" %>"><%= summary.blocked_plugins ? `${summary.blocked_plugins} blocked` : `${summary.updatable_plugins} updates` %></span>
|
||||
</summary>
|
||||
<div class="lumi-expandable-body plugin-update-list">
|
||||
<% if (!plugins.length) { %>
|
||||
<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" : "" %>>
|
||||
<summary>
|
||||
<span>
|
||||
<strong><%= plugin.name %></strong>
|
||||
<span class="hint"><%= plugin.current_version %> -> <%= plugin.safe_target_version || "none" %> · <%= plugin.source_branch %></span>
|
||||
</span>
|
||||
<span class="badge <%= badgeClass(plugin) %>"><%= badgeText(plugin) %></span>
|
||||
</summary>
|
||||
<div class="lumi-expandable-body update-detail-grid">
|
||||
<div class="update-meta-grid">
|
||||
<div><span>Current</span><strong><%= plugin.current_version %></strong></div>
|
||||
<div><span>Safe target</span><strong><%= plugin.safe_target_version || "None" %></strong></div>
|
||||
<div><span>Latest</span><strong><%= plugin.latest_available_version %></strong></div>
|
||||
<div><span>Snapshot</span><strong><%= plugin.snapshot.available ? plugin.snapshot.latest_snapshot_id : "None" %></strong></div>
|
||||
</div>
|
||||
<p><%= plugin.version_description %></p>
|
||||
<% if (plugin.warnings?.length) { %><div class="callout"><strong>Warnings</strong><ul><% plugin.warnings.forEach((item) => { %><li><%= item %></li><% }) %></ul></div><% } %>
|
||||
<% if (plugin.unversioned_update) { %><div class="callout danger"><strong>Manual confirmation required</strong><p>This plugin update is to or from an unversioned state. It remains available, but rollback safety and version ordering cannot be verified from metadata.</p></div><% } %>
|
||||
<% if (plugin.dangers?.length) { %><div class="callout danger"><strong>Dangers</strong><ul><% plugin.dangers.forEach((item) => { %><li><%= item %></li><% }) %></ul></div><% } %>
|
||||
<% if (plugin.migration_notes) { %><div class="callout"><strong>Migration notes</strong><p><%= plugin.migration_notes %></p></div><% } %>
|
||||
<h3>Changelog to target</h3>
|
||||
<% if (!changelogItems(plugin).length) { %>
|
||||
<p class="hint">No changelog entries were found for this range.</p>
|
||||
<% } else { %>
|
||||
<ul class="update-changelog">
|
||||
<% changelogItems(plugin).forEach((entry) => { %>
|
||||
<li><strong><%= entry.version %></strong><span><%= Array.isArray(entry.changes) ? entry.changes.join(" ") : (entry.description || entry.title || "") %></span></li>
|
||||
<% }) %>
|
||||
</ul>
|
||||
<% } %>
|
||||
<div class="inline-actions">
|
||||
<form method="post" action="/admin/updates/plugins/<%= plugin.id %>/check" data-update-action>
|
||||
<input type="hidden" name="source" value="<%= selectedSource %>" />
|
||||
<button class="button subtle" type="submit">Check plugin</button>
|
||||
</form>
|
||||
<form method="post" action="/admin/updates/plugins/<%= plugin.id %>/apply" data-update-action data-confirm-mode="modal" data-confirm-title="<%= plugin.unversioned_update ? `Apply unversioned ${plugin.name} update` : "Apply plugin update" %>" data-confirm-text="<%= applyConfirmText(plugin, `${plugin.name} update`) %>" data-confirm-label="<%= applyLabel(plugin) %>">
|
||||
<input type="hidden" name="source" value="<%= selectedSource %>" />
|
||||
<button class="button" type="submit" <%= plugin.blocked || !plugin.update_available ? "disabled" : "" %>><%= applyLabel(plugin) %></button>
|
||||
</form>
|
||||
<% if (plugin.snapshot.available) { %>
|
||||
<form method="post" action="/admin/updates/plugins/<%= plugin.id %>/revert" data-update-action data-confirm-mode="modal" data-confirm-title="Revert plugin snapshot" data-confirm-text="Revert only the previous plugin version snapshot. Major rollback is blocked unless metadata marks it rollback safe." data-confirm-label="Revert plugin">
|
||||
<input type="hidden" name="source" value="<%= selectedSource %>" />
|
||||
<input type="hidden" name="snapshot_id" value="<%= plugin.snapshot.latest_snapshot_id %>" />
|
||||
<button class="button danger" type="submit">Revert previous</button>
|
||||
</form>
|
||||
<% } %>
|
||||
<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">
|
||||
<button class="button danger" type="submit">Disable for recovery</button>
|
||||
</form>
|
||||
</div>
|
||||
<details class="inline-details">
|
||||
<summary>Show advanced plugin ZIP options</summary>
|
||||
<div class="callout">Plugin ZIP updates create snapshots and recovery markers, but may bypass repo metadata and compatibility checks unless the ZIP includes valid manifest data.</div>
|
||||
<form method="post" action="/admin/updates/plugins/<%= plugin.id %>/zip" enctype="multipart/form-data" class="form-grid">
|
||||
<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>
|
||||
</div>
|
||||
<label class="switch"><input type="checkbox" class="switch-input" name="rollback_safe" value="1" /><span class="switch-track"></span><span class="switch-text">ZIP manifest/notes mark rollback safe</span></label>
|
||||
</form>
|
||||
</details>
|
||||
</div>
|
||||
</details>
|
||||
<% }) %>
|
||||
</div>
|
||||
</details>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2>Live Progress</h2>
|
||||
<div class="update-progress-log" data-update-progress-log>
|
||||
<div class="hint">Update and recovery progress appears here while actions run.</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2>Snapshots</h2>
|
||||
<% if (!snapshots.length) { %>
|
||||
<div class="empty-state">No snapshots yet.</div>
|
||||
<% } else { %>
|
||||
<div class="table-wrap">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Target</th>
|
||||
<th>Version</th>
|
||||
<th>Method</th>
|
||||
<th>Rollback</th>
|
||||
<th>Created</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<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" : "Allowed previous-version only" %></td>
|
||||
<td><%= new Date(snap.createdAt).toLocaleString() %></td>
|
||||
</tr>
|
||||
<% }) %>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<% } %>
|
||||
</section>
|
||||
<%- include("partials/layout-bottom") %>
|
||||
|
||||
@ -1,8 +1,12 @@
|
||||
<%- include("partials/layout-top", { title }) %>
|
||||
<section class="card">
|
||||
<h1>Users</h1>
|
||||
<%- include("partials/page-header", {
|
||||
eyebrow: "Community",
|
||||
pageTitle: "Users",
|
||||
description: "Review linked identities, notes, and internal usernames."
|
||||
}) %>
|
||||
<% if (!users.length) { %>
|
||||
<p>No users yet.</p>
|
||||
<div class="empty-state">No users yet.</div>
|
||||
<% } else { %>
|
||||
<div class="table-tools">
|
||||
<input
|
||||
@ -12,6 +16,7 @@
|
||||
data-table-filter="user-list"
|
||||
/>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<table class="table" data-table="user-list">
|
||||
<thead>
|
||||
<tr>
|
||||
@ -71,6 +76,7 @@
|
||||
<% }) %>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<% } %>
|
||||
</section>
|
||||
<div class="modal-backdrop" data-notes-modal aria-hidden="true">
|
||||
|
||||
@ -7,6 +7,35 @@
|
||||
<a class="button subtle" href="/leaderboards">Leaderboards</a>
|
||||
</div>
|
||||
</section>
|
||||
<% if (homepageHero) { %>
|
||||
<section class="homepage-dynamic-hero card" aria-label="Featured community content">
|
||||
<div>
|
||||
<span class="eyebrow"><%= homepageHero.type.replaceAll("_", " ") %></span>
|
||||
<h2><%= homepageHero.title %></h2>
|
||||
<% if (homepageHero.description) { %><p><%= homepageHero.description %></p><% } %>
|
||||
<% if (homepageHero.source_url) { %><a class="button subtle" href="<%= homepageHero.source_url %>" target="_blank" rel="noopener noreferrer">Open featured content</a><% } %>
|
||||
</div>
|
||||
<% if (homepageHero.image_url) { %>
|
||||
<img class="homepage-hero-media" src="<%= homepageHero.image_url %>" alt="" loading="lazy" />
|
||||
<% } else if (homepageHero.embed_url) { %>
|
||||
<iframe class="homepage-hero-media" src="<%= homepageHero.embed_url %>" title="<%= homepageHero.title %>" loading="lazy" referrerpolicy="strict-origin-when-cross-origin" allow="accelerometer; autoplay; clipboard-write; encrypted-media; picture-in-picture; web-share" allowfullscreen></iframe>
|
||||
<% } %>
|
||||
</section>
|
||||
<% } %>
|
||||
<% if ((homepageLinks || []).length) { %>
|
||||
<section class="homepage-link-strip" aria-label="Community links">
|
||||
<% homepageLinks.forEach((link) => { %>
|
||||
<a class="homepage-link-button" href="<%= link.url %>" target="_blank" rel="noopener noreferrer">
|
||||
<% if (link.icon_url) { %>
|
||||
<img src="<%= link.icon_url %>" alt="" loading="lazy" />
|
||||
<% } else { %>
|
||||
<span aria-hidden="true"><%= link.fallback_icon %></span>
|
||||
<% } %>
|
||||
<span><strong><%= link.label %></strong><small><%= link.description %></small></span>
|
||||
</a>
|
||||
<% }) %>
|
||||
</section>
|
||||
<% } %>
|
||||
<section class="grid">
|
||||
<div class="card">
|
||||
<h2>Bot control</h2>
|
||||
|
||||
@ -1,10 +1,14 @@
|
||||
<%- include("partials/layout-top", { title }) %>
|
||||
<section class="card">
|
||||
<h1>Leaderboards</h1>
|
||||
<%- include("partials/page-header", {
|
||||
eyebrow: "Community",
|
||||
pageTitle: "Leaderboards",
|
||||
description: "Browse activity across core features and installed plugins."
|
||||
}) %>
|
||||
</section>
|
||||
|
||||
<% if (!sections || !sections.length) { %>
|
||||
<section class="card">
|
||||
<section class="empty-state">
|
||||
<p>No activity recorded yet.</p>
|
||||
</section>
|
||||
<% } else { %>
|
||||
@ -20,6 +24,7 @@
|
||||
<% if (!board.rows || !board.rows.length) { %>
|
||||
<p><%= board.emptyMessage || "No data recorded yet." %></p>
|
||||
<% } else { %>
|
||||
<div class="table-wrap">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
@ -46,6 +51,7 @@
|
||||
<% }) %>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<% } %>
|
||||
<% }) %>
|
||||
<% } %>
|
||||
|
||||
23
src/web/views/localhost-login.ejs
Normal file
23
src/web/views/localhost-login.ejs
Normal file
@ -0,0 +1,23 @@
|
||||
<%- include("partials/layout-top", { title }) %>
|
||||
<section class="standalone-card card">
|
||||
<span class="eyebrow">Development access</span>
|
||||
<h1>Localhost Login</h1>
|
||||
<p class="hint">
|
||||
This login option is only available when the site is opened from localhost.
|
||||
The default credentials are admin / admin until changed in settings.
|
||||
</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 />
|
||||
</div>
|
||||
<div class="field full">
|
||||
<label>Password</label>
|
||||
<input name="password" type="password" autocomplete="current-password" required />
|
||||
</div>
|
||||
<div class="field full">
|
||||
<button type="submit" class="button">Login locally</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
<%- include("partials/layout-bottom") %>
|
||||
@ -10,6 +10,7 @@
|
||||
<% } %>
|
||||
</div>
|
||||
</div>
|
||||
<button class="sidebar-scrim" type="button" data-sidebar-dismiss aria-label="Close navigation"></button>
|
||||
<div class="modal-backdrop destructive-confirm-modal" data-destructive-modal aria-hidden="true">
|
||||
<div class="modal" role="dialog" aria-modal="true" aria-labelledby="destructive-confirm-title">
|
||||
<div class="modal-header">
|
||||
@ -24,6 +25,8 @@
|
||||
</div>
|
||||
</div>
|
||||
<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>
|
||||
<script src="/app.js?v=<%= assetVersion %>"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@ -4,46 +4,13 @@
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title><%= title %> - <%= siteTitle %></title>
|
||||
<link rel="stylesheet" href="/lumi-tokens.css?v=<%= assetVersion %>" />
|
||||
<link rel="stylesheet" href="/styles.css?v=<%= assetVersion %>" />
|
||||
<% if (theme) { %>
|
||||
<style>
|
||||
:root {
|
||||
--ink: <%= theme.light.text %>;
|
||||
--ink-soft: <%= theme.light.muted %>;
|
||||
--sea: <%= theme.light.accent %>;
|
||||
--sun: <%= theme.light.accentAlt %>;
|
||||
--rose: <%= theme.light.danger %>;
|
||||
--card: <%= theme.light.surface %>;
|
||||
--surface-2: <%= theme.light.surface2 %>;
|
||||
--surface-3: <%= theme.light.surface3 %>;
|
||||
--border: <%= theme.light.border %>;
|
||||
--bg-1: <%= theme.light.bg1 %>;
|
||||
--bg-2: <%= theme.light.bg2 %>;
|
||||
--bg-3: <%= theme.light.bg3 %>;
|
||||
--role-public: <%= theme.role.public %>;
|
||||
--role-mod: <%= theme.role.mod %>;
|
||||
--role-admin: <%= theme.role.admin %>;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--ink: <%= theme.dark.text %>;
|
||||
--ink-soft: <%= theme.dark.muted %>;
|
||||
--sea: <%= theme.dark.accent %>;
|
||||
--sun: <%= theme.dark.accentAlt %>;
|
||||
--rose: <%= theme.dark.danger %>;
|
||||
--card: <%= theme.dark.surface %>;
|
||||
--surface-2: <%= theme.dark.surface2 %>;
|
||||
--surface-3: <%= theme.dark.surface3 %>;
|
||||
--border: <%= theme.dark.border %>;
|
||||
--bg-1: <%= theme.dark.bg1 %>;
|
||||
--bg-2: <%= theme.dark.bg2 %>;
|
||||
--bg-3: <%= theme.dark.bg3 %>;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<% } %>
|
||||
<link rel="stylesheet" href="/lumi-layout.css?v=<%= assetVersion %>" />
|
||||
<link rel="stylesheet" href="/lumi-components.css?v=<%= assetVersion %>" />
|
||||
<%- include("theme-vars", { theme }) %>
|
||||
</head>
|
||||
<body>
|
||||
<body data-theme-id="<%= theme ? theme.id : '' %>">
|
||||
<% 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>',
|
||||
|
||||
11
src/web/views/partials/page-header.ejs
Normal file
11
src/web/views/partials/page-header.ejs
Normal file
@ -0,0 +1,11 @@
|
||||
<header class="page-header">
|
||||
<div>
|
||||
<% if (typeof eyebrow !== "undefined" && eyebrow) { %>
|
||||
<span class="eyebrow"><%= eyebrow %></span>
|
||||
<% } %>
|
||||
<h1><%= pageTitle %></h1>
|
||||
<% if (typeof description !== "undefined" && description) { %>
|
||||
<p class="command-subtitle"><%= description %></p>
|
||||
<% } %>
|
||||
</div>
|
||||
</header>
|
||||
36
src/web/views/partials/state-button.ejs
Normal file
36
src/web/views/partials/state-button.ejs
Normal file
@ -0,0 +1,36 @@
|
||||
<%
|
||||
const stateButtonStates = (typeof states !== "undefined" && states) || [
|
||||
{ id: "idle", text: (typeof text !== "undefined" && text) || "Submit" },
|
||||
{ id: "loading", text: (typeof loadingText !== "undefined" && loadingText) || "Working...", spinner: true },
|
||||
{ id: "success", text: (typeof successText !== "undefined" && successText) || "Done" }
|
||||
];
|
||||
const stateButtonDefault = (typeof defaultState !== "undefined" && defaultState) || "idle";
|
||||
const stateButtonAttrs = (typeof attrs !== "undefined" && attrs) || "";
|
||||
const stateButtonClass = `button lumi-state-btn ${(typeof classes !== "undefined" && classes) || ""}`.trim();
|
||||
%>
|
||||
<button
|
||||
type="<%= (typeof type !== "undefined" && type) || 'button' %>"
|
||||
class="<%= stateButtonClass %>"
|
||||
data-lumi-state-button
|
||||
data-state="<%= stateButtonDefault %>"
|
||||
data-default-state="<%= stateButtonDefault %>"
|
||||
data-loading-state="<%= (typeof loadingState !== "undefined" && loadingState) || 'loading' %>"
|
||||
data-success-state="<%= (typeof successState !== "undefined" && successState) || 'success' %>"
|
||||
data-error-state="<%= (typeof errorState !== "undefined" && errorState) || 'error' %>"
|
||||
data-reset-delay="<%= (typeof resetDelay !== "undefined" && resetDelay) || 0 %>"
|
||||
data-disable-while-busy="<%= typeof disableWhileBusy !== "undefined" && disableWhileBusy === false ? 'false' : 'true' %>"
|
||||
<%- typeof name !== "undefined" && name ? `name="${name}"` : "" %>
|
||||
<%- typeof value !== "undefined" && value ? `value="${value}"` : "" %>
|
||||
<%- typeof ariaLabel !== "undefined" && ariaLabel ? `aria-label="${ariaLabel}"` : "" %>
|
||||
<%- typeof disabled !== "undefined" && disabled ? "disabled" : "" %>
|
||||
<%- stateButtonAttrs %>
|
||||
>
|
||||
<span class="lumi-state-btn-content">
|
||||
<% stateButtonStates.forEach((state) => { %>
|
||||
<span data-state-view="<%= state.id %>" data-state-hidden="<%= state.id === stateButtonDefault ? 'false' : 'true' %>" aria-hidden="<%= state.id === stateButtonDefault ? 'false' : 'true' %>">
|
||||
<% if (state.spinner) { %><span class="lumi-state-btn-spinner" aria-hidden="true"></span><% } %>
|
||||
<span><%= state.text %></span>
|
||||
</span>
|
||||
<% }) %>
|
||||
</span>
|
||||
</button>
|
||||
97
src/web/views/partials/theme-vars.ejs
Normal file
97
src/web/views/partials/theme-vars.ejs
Normal file
@ -0,0 +1,97 @@
|
||||
<% if (theme) { %>
|
||||
<style>
|
||||
:root {
|
||||
--ink: <%= theme.light.text %>;
|
||||
--ink-soft: <%= theme.light.muted %>;
|
||||
--sea: <%= theme.light.accent %>;
|
||||
--sun: <%= theme.light.accentAlt %>;
|
||||
--rose: <%= theme.light.danger %>;
|
||||
--card: <%= theme.light.surface %>;
|
||||
--surface-2: <%= theme.light.surface2 %>;
|
||||
--surface-3: <%= theme.light.surface3 %>;
|
||||
--border: <%= theme.light.border %>;
|
||||
--bg-1: <%= theme.light.bg1 %>;
|
||||
--bg-2: <%= theme.light.bg2 %>;
|
||||
--bg-3: <%= theme.light.bg3 %>;
|
||||
--lumi-success: <%= theme.light.success %>;
|
||||
--lumi-warning: <%= theme.light.warning %>;
|
||||
--lumi-info: <%= theme.light.info %>;
|
||||
--lumi-link: <%= theme.light.link %>;
|
||||
--lumi-button-bg: <%= theme.light.buttonBg %>;
|
||||
--lumi-button-text: <%= theme.light.buttonText %>;
|
||||
--lumi-button-hover: <%= theme.light.buttonHover %>;
|
||||
--lumi-input-bg: <%= theme.light.inputBg %>;
|
||||
--lumi-input-border: <%= theme.light.inputBorder %>;
|
||||
--lumi-input-text: <%= theme.light.inputText %>;
|
||||
--lumi-focus: <%= theme.light.focusRing %>;
|
||||
--lumi-radius: <%= theme.metrics.radius %>px;
|
||||
--lumi-shadow-strength: <%= theme.metrics.shadowStrength %>;
|
||||
--lumi-space-scale: <%= theme.metrics.spacingScale %>;
|
||||
--lumi-font-body: <%- theme.typography.bodyFontStack %>;
|
||||
--lumi-font-display: <%- theme.typography.displayFontStack %>;
|
||||
--lumi-font-mono: <%- theme.typography.monoFontStack %>;
|
||||
--lumi-font-size-base: <%= theme.typography.baseSize %>px;
|
||||
--lumi-heading-scale: <%= theme.typography.headingScale %>;
|
||||
--lumi-control-scale: <%= theme.typography.controlScale %>;
|
||||
--role-public: <%= theme.role.public %>;
|
||||
--role-mod: <%= theme.role.mod %>;
|
||||
--role-admin: <%= theme.role.admin %>;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root:not([data-color-scheme="light"]) {
|
||||
--ink: <%= theme.dark.text %>;
|
||||
--ink-soft: <%= theme.dark.muted %>;
|
||||
--sea: <%= theme.dark.accent %>;
|
||||
--sun: <%= theme.dark.accentAlt %>;
|
||||
--rose: <%= theme.dark.danger %>;
|
||||
--card: <%= theme.dark.surface %>;
|
||||
--surface-2: <%= theme.dark.surface2 %>;
|
||||
--surface-3: <%= theme.dark.surface3 %>;
|
||||
--border: <%= theme.dark.border %>;
|
||||
--bg-1: <%= theme.dark.bg1 %>;
|
||||
--bg-2: <%= theme.dark.bg2 %>;
|
||||
--bg-3: <%= theme.dark.bg3 %>;
|
||||
--lumi-success: <%= theme.dark.success %>;
|
||||
--lumi-warning: <%= theme.dark.warning %>;
|
||||
--lumi-info: <%= theme.dark.info %>;
|
||||
--lumi-link: <%= theme.dark.link %>;
|
||||
--lumi-button-bg: <%= theme.dark.buttonBg %>;
|
||||
--lumi-button-text: <%= theme.dark.buttonText %>;
|
||||
--lumi-button-hover: <%= theme.dark.buttonHover %>;
|
||||
--lumi-input-bg: <%= theme.dark.inputBg %>;
|
||||
--lumi-input-border: <%= theme.dark.inputBorder %>;
|
||||
--lumi-input-text: <%= theme.dark.inputText %>;
|
||||
--lumi-focus: <%= theme.dark.focusRing %>;
|
||||
}
|
||||
}
|
||||
:root[data-color-scheme="dark"] {
|
||||
color-scheme: dark;
|
||||
--ink: <%= theme.dark.text %>;
|
||||
--ink-soft: <%= theme.dark.muted %>;
|
||||
--sea: <%= theme.dark.accent %>;
|
||||
--sun: <%= theme.dark.accentAlt %>;
|
||||
--rose: <%= theme.dark.danger %>;
|
||||
--card: <%= theme.dark.surface %>;
|
||||
--surface-2: <%= theme.dark.surface2 %>;
|
||||
--surface-3: <%= theme.dark.surface3 %>;
|
||||
--border: <%= theme.dark.border %>;
|
||||
--bg-1: <%= theme.dark.bg1 %>;
|
||||
--bg-2: <%= theme.dark.bg2 %>;
|
||||
--bg-3: <%= theme.dark.bg3 %>;
|
||||
--lumi-success: <%= theme.dark.success %>;
|
||||
--lumi-warning: <%= theme.dark.warning %>;
|
||||
--lumi-info: <%= theme.dark.info %>;
|
||||
--lumi-link: <%= theme.dark.link %>;
|
||||
--lumi-button-bg: <%= theme.dark.buttonBg %>;
|
||||
--lumi-button-text: <%= theme.dark.buttonText %>;
|
||||
--lumi-button-hover: <%= theme.dark.buttonHover %>;
|
||||
--lumi-input-bg: <%= theme.dark.inputBg %>;
|
||||
--lumi-input-border: <%= theme.dark.inputBorder %>;
|
||||
--lumi-input-text: <%= theme.dark.inputText %>;
|
||||
--lumi-focus: <%= theme.dark.focusRing %>;
|
||||
}
|
||||
:root[data-color-scheme="light"] {
|
||||
color-scheme: light;
|
||||
}
|
||||
</style>
|
||||
<% } %>
|
||||
@ -1,7 +1,10 @@
|
||||
<%- include("partials/layout-top", { title }) %>
|
||||
<section class="card">
|
||||
<h1>Initial setup</h1>
|
||||
<p>Enable the platforms you plan to use, then run each wizard to configure credentials.</p>
|
||||
<%- include("partials/page-header", {
|
||||
eyebrow: "Get started",
|
||||
pageTitle: "Initial setup",
|
||||
description: "Enable the platforms you plan to use, then configure each connection."
|
||||
}) %>
|
||||
<p class="hint">Once at least one platform is configured, you can log in and manage everything from the WebUI.</p>
|
||||
<div class="grid">
|
||||
<% (platforms || []).forEach((platform) => { %>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user