diff --git a/configs/general/general_basic b/configs/general/general_basic index caf8a3d..52702e3 100755 --- a/configs/general/general_basic +++ b/configs/general/general_basic @@ -3,3 +3,4 @@ # ================================================================================ CFG_INSTALL_NAME=Change-Me # Installation Name - The name for your LibrePortal instance CFG_TIMEZONE=Etc/UTC # System Timezone - Timezone for scheduled tasks and logging timestamps +CFG_INSTALL_LEVEL=beginner # Experience Level - Beginner hides technical detail and skips advanced setup steps. Advanced reveals everything by default. Set during the first-run wizard; can be flipped any time via the Advanced toggle in the WebUI. [beginner:Beginner — simple|advanced:Advanced — show everything] diff --git a/containers/libreportal/backend/routes/setup-routes.js b/containers/libreportal/backend/routes/setup-routes.js index 5b8d554..717875a 100644 --- a/containers/libreportal/backend/routes/setup-routes.js +++ b/containers/libreportal/backend/routes/setup-routes.js @@ -154,6 +154,15 @@ router.post('/save', requireAuth, async (req, res) => { return res.status(400).json({ error: 'timezone required' }); } + // Experience level seeds the WebUI's Beginner/Advanced UI mode default. + // Optional — old WebUIs may not send it — and constrained to the + // enum so a bad value can't smuggle anything into the bash applier. + if (payload.install_level !== undefined) { + if (payload.install_level !== 'beginner' && payload.install_level !== 'advanced') { + return res.status(400).json({ error: 'invalid install_level' }); + } + } + // Domains are optional but each entry must be a valid hostname. Cap at // 9 because the config schema only has CFG_DOMAIN_1..CFG_DOMAIN_9. const domainRe = /^([a-z0-9]+(-[a-z0-9]+)*\.)+[a-z]{2,}$/i; diff --git a/containers/libreportal/frontend/css/setup-wizard.css b/containers/libreportal/frontend/css/setup-wizard.css index 7bb9770..993c5d6 100755 --- a/containers/libreportal/frontend/css/setup-wizard.css +++ b/containers/libreportal/frontend/css/setup-wizard.css @@ -1052,3 +1052,67 @@ body.setup-wizard-open { .setup-btn-back { order: 2; } .setup-btn-next, .setup-launch { order: 1; } } + +/* ============================================================ + Step 1 — Experience choice (Beginner vs Advanced). + Two big tap targets so the choice feels deliberate; the + chosen card lights up with the project accent. + ============================================================ */ +.setup-level-field { gap: 12px; display: flex; flex-direction: column; } +.setup-level-note { margin: 0; } + +.setup-level-cards { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 14px; + margin-top: 6px; +} + +.setup-level-card { + position: relative; + padding: 18px 18px 16px; + background: rgba(var(--text-rgb), 0.04); + border: 1px solid rgba(var(--text-rgb), 0.14); + border-radius: 14px; + cursor: pointer; + transition: border-color .15s ease, background .15s ease, transform .15s ease, box-shadow .15s ease; + display: block; +} +.setup-level-card input { + position: absolute; + opacity: 0; + pointer-events: none; +} +.setup-level-card:hover { + border-color: rgba(var(--accent-rgb), 0.4); + transform: translateY(-1px); +} +.setup-level-card:has(input:checked) { + background: linear-gradient(135deg, rgba(var(--accent-rgb), 0.18) 0%, rgba(var(--accent-rgb), 0.04) 100%); + border-color: rgba(var(--accent-rgb), 0.7); + box-shadow: 0 6px 22px rgba(var(--accent-rgb), 0.18); +} +.setup-level-card:has(input:focus-visible) { + outline: 2px solid rgba(var(--accent-rgb), 0.6); + outline-offset: 2px; +} + +.setup-level-card-body { + display: flex; + flex-direction: column; + gap: 6px; +} +.setup-level-card-icon { + font-size: 1.6rem; + line-height: 1; +} +.setup-level-card-title { + font-size: 1.05rem; + font-weight: 700; + color: var(--text-primary); +} +.setup-level-card-desc { + font-size: 0.85rem; + color: rgba(var(--text-rgb), 0.65); + line-height: 1.45; +} diff --git a/containers/libreportal/frontend/css/style.css b/containers/libreportal/frontend/css/style.css index df42775..b83af7f 100755 --- a/containers/libreportal/frontend/css/style.css +++ b/containers/libreportal/frontend/css/style.css @@ -3814,3 +3814,15 @@ html[data-theme="nebula"]::after { width: 100%; } } + +/* ============================================================ + Global UI-mode gates — driven by window.LpUi (see js/utils/lp-ui.js). + Any DOM tagged with .lp-advanced is hidden unless body has the + lp-ui--advanced class; .lp-dev is hidden unless lp-ui--dev. + Use these classes on per-element surfaces (a button, a fieldset, a + tile, a column header) so beginners aren't shown operator detail and + non-devs aren't shown dev-only knobs. Pair with the per-page Advanced + toggle (also wired to LpUi.advanced) so flipping it is global. + ============================================================ */ +body:not(.lp-ui--advanced) .lp-advanced { display: none !important; } +body:not(.lp-ui--dev) .lp-dev { display: none !important; } diff --git a/containers/libreportal/frontend/index.html b/containers/libreportal/frontend/index.html index d4d1b2e..2403f36 100755 --- a/containers/libreportal/frontend/index.html +++ b/containers/libreportal/frontend/index.html @@ -84,6 +84,9 @@ + + diff --git a/containers/libreportal/frontend/js/components/app/services-manager.js b/containers/libreportal/frontend/js/components/app/services-manager.js index 9601032..088754f 100644 --- a/containers/libreportal/frontend/js/components/app/services-manager.js +++ b/containers/libreportal/frontend/js/components/app/services-manager.js @@ -1,44 +1,3 @@ -// Global "UI mode" — Beginner vs Advanced. First foothold of a -// project-wide pattern: any surface that wants to render extra-technical -// detail (mounts, limits, internals, raw IDs, …) gates it on -// body.lp-ui--advanced. Default is OFF (Beginner) so a newcomer isn't -// overwhelmed; flipping the toggle reveals everything site-wide. -// -// The flag persists per-browser via localStorage. The install will later -// seed the initial value from CFG_INSTALL_LEVEL (Beginner | Advanced) -// chosen on the very first setup screen — once that lands, the body -// class is set server-side at template render time so there's no FOUC. -// Until then, the user picks it from any surface that exposes a toggle -// (currently the Services tab). -(function setupLpUi() { - if (window.LpUi && window.LpUi.advanced) return; - const KEY = 'lp.ui.advanced'; - const advanced = { - get() { - try { return localStorage.getItem(KEY) === '1'; } catch { return false; } - }, - set(on) { - const v = !!on; - try { localStorage.setItem(KEY, v ? '1' : '0'); } catch { /* private mode */ } - document.body && document.body.classList.toggle('lp-ui--advanced', v); - window.dispatchEvent(new CustomEvent('lp-ui-advanced-changed', { detail: { advanced: v } })); - }, - apply() { - if (!document.body) return; - document.body.classList.toggle('lp-ui--advanced', this.get()); - }, - }; - window.LpUi = { ...(window.LpUi || {}), advanced }; - // Apply as early as possible — once the body exists, we drop the class - // in so any already-rendered surface (e.g. another tab re-mounted with - // .service-rich present) reflects the saved mode immediately. - if (document.readyState === 'loading') { - document.addEventListener('DOMContentLoaded', () => advanced.apply(), { once: true }); - } else { - advanced.apply(); - } -})(); - // Services tab on the app detail page. // // Each row renders a single docker compose service with: @@ -548,6 +507,64 @@ class ServicesManager { if (!cb || !window.LpUi?.advanced) return; window.LpUi.advanced.set(cb.checked); }); + // Dev-mode 10-tap unlock — mirrors the topbar LibrePortal-logo easter + // egg, but on the Advanced toggle's surrounding label. Captures clicks + // on the label or the slider track so toggling the checkbox and + // "tapping" the toggle both count. Counts reset after 3 s of idle and + // start showing a countdown toast at click 6. On the 10th click we + // ask the topbar to flip CFG_DEV_MODE the standard way (config_update + // task), which also writes the persistent config — same path the + // logo easter egg already uses, so dev-mode behaviour stays singular. + this._wireDevTapEasterEgg(root); + } + + _wireDevTapEasterEgg(root) { + const TARGET = 10; + const TOAST_FROM = 6; + const RESET_AFTER_MS = 3000; + let count = 0; + let resetTimer = null; + let currentToast = null; + root.addEventListener('click', (ev) => { + // Count clicks on the toggle's visible surface (label + track), + // not the hidden checkbox input (those bubble too but are already + // covered by the label click). + const inToggle = ev.target.closest('.lp-ui-advanced-toggle'); + if (!inToggle) return; + count++; + if (resetTimer) clearTimeout(resetTimer); + resetTimer = setTimeout(() => { count = 0; currentToast = null; }, RESET_AFTER_MS); + const remaining = TARGET - count; + const devOn = (window.systemConfigs?.CFG_DEV_MODE === 'true'); + const verb = devOn ? 'disabling' : 'being'; + const noun = devOn ? 'developer mode' : 'a developer'; + if (remaining > 0 && count >= TOAST_FROM) { + const msg = `You are ${remaining} click${remaining === 1 ? '' : 's'} away from ${verb} ${noun}.`; + const msgEl = currentToast && currentToast.parentElement + ? currentToast.querySelector('.notification-message') : null; + if (msgEl) { + msgEl.innerHTML = msg; + } else if (window.notificationSystem?.show) { + currentToast = window.notificationSystem.show(msg, 'info'); + } + } else if (remaining === 0) { + count = 0; + clearTimeout(resetTimer); + currentToast = null; + // Reuse the topbar's setter so there's one canonical path for + // toggling CFG_DEV_MODE (it handles the task-route, the cache + // update, the banner toggle, and now the LpUi.dev mirror). + const topbar = window.topbar || window.libreportalTopbar; + if (topbar && typeof topbar._setDevMode === 'function') { + topbar._setDevMode(!devOn); + } else if (window.LpUi?.dev) { + // Fallback for the rare case the topbar instance isn't on the + // window — flip the body class so the user gets immediate + // feedback; the next page load will re-sync from CFG. + window.LpUi.dev.set(!devOn); + } + } + }); } async _restartService(serviceName, btn) { diff --git a/containers/libreportal/frontend/js/components/topbar.js b/containers/libreportal/frontend/js/components/topbar.js index 8e32942..4694611 100755 --- a/containers/libreportal/frontend/js/components/topbar.js +++ b/containers/libreportal/frontend/js/components/topbar.js @@ -55,9 +55,12 @@ class TopbarComponent { const html = await response.text(); container.innerHTML = html; - // Initialize component - new TopbarComponent(); - + // Initialize component. Stash on window so other surfaces can reach + // the canonical _setDevMode path (e.g. the Advanced-toggle 10-tap + // unlock in services-manager). Otherwise dev-mode would have two + // setter paths and they could drift. + window.topbar = new TopbarComponent(); + } catch (error) { console.error('Error loading topbar:', error); } @@ -209,6 +212,13 @@ class TopbarComponent { // a full page refresh. if (!window.systemConfigs) window.systemConfigs = {}; window.systemConfigs.CFG_DEV_MODE = value; + // Mirror the change into the LpUi body-class machinery so CSS + // gates (e.g. body.lp-ui--dev) flip without a reload. Linking rule + // is enforced inside LpUi.dev.set: enabling dev auto-enables + // advanced too. + if (window.LpUi?.dev) { + try { window.LpUi.dev.set(enabled); } catch { /* belt + braces */ } + } if (enabled) { try { localStorage.removeItem('lp.devBannerDismissed'); } catch {} this._updateDevBanner(true); diff --git a/containers/libreportal/frontend/js/system/setup-wizard.js b/containers/libreportal/frontend/js/system/setup-wizard.js index da6a8b7..c541680 100755 --- a/containers/libreportal/frontend/js/system/setup-wizard.js +++ b/containers/libreportal/frontend/js/system/setup-wizard.js @@ -13,12 +13,23 @@ class SetupWizard { this.dnsCheckController = null; this.onComplete = null; this.currentStep = 0; - this.stepNames = ['Identity', 'Domains', 'Recommended', 'Metrics']; - this.stepIcons = ['🪐', '🛰️', '🛡️', '📊']; - this.totalSteps = this.stepNames.length; + // Step 0 (Experience) chooses Beginner or Advanced. Beginner shrinks + // the wizard — the Metrics step is hidden and never asked — so the + // visible step count is dynamic (4 for beginner, 5 for advanced). + // installLevel defaults to 'beginner' so a user who races through + // step 0 without touching anything gets the safe-default UX. + this.stepNames = ['Experience', 'Identity', 'Domains', 'Recommended', 'Metrics']; + this.stepIcons = ['🌱', '🪐', '🛰️', '🛡️', '📊']; + this.installLevel = 'beginner'; + this.totalSteps = this._effectiveTotalSteps(); this.domainCount = 0; // tracked dynamically as the user adds rows } + _effectiveTotalSteps() { + // Metrics (last step) is advanced-only; beginner skips it entirely. + return this.installLevel === 'advanced' ? this.stepNames.length : this.stepNames.length - 1; + } + initialize(setupDetector, onComplete = null) { this.onComplete = onComplete; this.create(); @@ -78,8 +89,40 @@ class SetupWizard {
- +
+
+ +

+ You can switch any time from the Advanced toggle inside the WebUI. +

+
+ + +
+
+
+ + +
- -
+
- -
+ +
Recommended Apps

Pre-selected to give you a working install out of the box.

@@ -158,10 +201,11 @@ class SetupWizard {
- -
+ toggle on apps to do anything. Advanced-only: this whole step + is skipped when the user chose Beginner on step 1. --> +
Metrics Apps

Optional. Install these to enable per-app "Export metrics to Grafana" later.

@@ -213,6 +257,19 @@ class SetupWizard { $('#sw-back').addEventListener('click', () => this.prev()); $('#sw-next').addEventListener('click', () => this.next()); + // Experience radio — updates installLevel and the visible-step count + // immediately so the progress bar reflects the choice (4 vs 5 steps). + // Selection cards visually toggle via the wrapping