feat(ui): Beginner/Advanced experience level + linked dev mode + setup-wizard step

Adds the install-time Beginner/Advanced choice the user described, with
the linked dev-mode escape hatch and global body-class machinery that
any surface can hang advanced/dev-only DOM off.

Three-tier mental model, two flags in the data model:

  Beginner            default. nothing extra shown.
  Advanced            .lp-advanced DOM revealed; advanced wizard steps shown
  Adv+Dev             .lp-dev DOM also revealed; dev-only fields visible

Linking rule (enforced inside LpUi):
  - enabling dev auto-enables advanced (dev w/o advanced is incoherent)
  - disabling advanced auto-disables dev

Wire shape:
  CFG_INSTALL_LEVEL                  beginner | advanced (general_basic)
  CFG_DEV_MODE                       existing, unchanged behaviour
  window.LpUi.{advanced,dev}         {get(), set(), apply()}
  localStorage keys                  lp.ui.advanced, lp.ui.dev, lp.ui.seeded
  body classes                       lp-ui--advanced, lp-ui--dev
  events                             lp-ui-advanced-changed, lp-ui-dev-changed
  global CSS gates                   body:not(.lp-ui--advanced) .lp-advanced { hide }
                                     body:not(.lp-ui--dev) .lp-dev { hide }

Setup wizard:
  - New step 1 "Choose your experience" with Beginner/Advanced cards.
    Beginner is preselected so race-through gets the safe default.
  - Picking a level updates totalSteps live (4 for beginner, 5 for
    advanced) so the progress bar reflects the choice.
  - Metrics step (Prometheus + Grafana) is gated to Advanced — beginner
    never sees it, never gets asked, never installs them by accident.
  - Submit payload now carries install_level; setup-routes.js validates
    it against the enum (beginner|advanced).
  - scripts/setup/setup_apply.sh writes it to CFG_INSTALL_LEVEL via
    updateConfigOption.
  - On submit, LpUi.advanced.set is called immediately so the next
    surface (running-tasks page) is already in the right mode — no
    refresh needed.

WebUI bootstrap:
  - js/utils/lp-ui.js loads first thing in index.html (before any other
    bootstrap) so body.lp-ui--advanced is applied pre-paint — no FOUC
    of advanced content on a fresh tab.
  - On first run, seeds lp.ui.advanced from CFG_INSTALL_LEVEL.
    Subsequent loads honour the user's per-browser override.
  - Mirrors CFG_DEV_MODE → lp.ui.dev on the seed pass.

Dev-mode unlock:
  - Existing 10-click LibrePortal-logo easter egg unchanged.
  - NEW: same 10-click unlock on the Advanced toggle (in services-manager).
    Reuses the countdown-toast pattern; on the 10th click delegates to
    the topbar's _setDevMode so there's one canonical setter and the
    config_update task path stays singular.
  - TopbarComponent now exposes its instance as window.topbar so the
    toggle's tap handler can reach _setDevMode.
  - topbar._setDevMode also calls LpUi.dev.set(enabled) so the body
    class flips immediately (no reload needed to see dev-only DOM).

Convention rolled out:
  - Services tab's .service-rich panel was already gated on
    body.lp-ui--advanced.
  - .lp-advanced / .lp-dev are now first-class hide classes any
    component can tag DOM with — see style.css globals.

Signed-off-by: librelad <librelad@digitalangels.vip>
This commit is contained in:
librelad 2026-05-27 23:31:50 +01:00
parent 51069ae05a
commit 9a92805bdb
10 changed files with 378 additions and 58 deletions

View File

@ -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]

View File

@ -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;

View File

@ -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;
}

View File

@ -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; }

View File

@ -84,6 +84,9 @@
<!-- Auth must load first — gates all other initialization -->
<script src="/js/system/auth-manager.js"></script>
<!-- Essential Bootstrap -->
<!-- LpUi runs first so body.lp-ui--advanced / lp-ui--dev are set
before any page/component renders → no FOUC of advanced sections. -->
<script src="/js/utils/lp-ui.js"></script>
<script src="/js/utils/dom-helpers.js"></script>
<script src="/js/utils/ui-helpers.js"></script>
<script src="/js/utils/router.js"></script>

View File

@ -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) {

View File

@ -55,8 +55,11 @@ 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);

View File

@ -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 {
<div class="setup-track-wrap">
<div class="setup-track" id="sw-track">
<!-- Step 1: Identity -->
<!-- Step 1: Experience Beginner vs Advanced. Seeds the WebUI's
Advanced UI mode default (CFG_INSTALL_LEVEL) so a newcomer
doesn't get a wall of operator detail and an experienced
user sees everything by default. Either choice is reversible
from the Advanced toggle in any page that exposes it. -->
<section class="setup-step" data-step="0">
<div class="setup-field setup-level-field">
<label>Choose your experience</label>
<p class="setup-step-note setup-level-note">
You can switch any time from the Advanced toggle inside the WebUI.
</p>
<div class="setup-level-cards">
<label class="setup-level-card" data-level="beginner">
<input type="radio" name="sw-level" value="beginner" checked>
<div class="setup-level-card-body">
<div class="setup-level-card-icon">🌱</div>
<div class="setup-level-card-title">Beginner</div>
<div class="setup-level-card-desc">A simple, friendly view. Advanced settings and developer fields are hidden until you ask for them. We'll skip the metrics setup.</div>
</div>
</label>
<label class="setup-level-card" data-level="advanced">
<input type="radio" name="sw-level" value="advanced">
<div class="setup-level-card-body">
<div class="setup-level-card-icon">🛠</div>
<div class="setup-level-card-title">Advanced</div>
<div class="setup-level-card-desc">Show everything. Container internals, advanced config fields, and the optional metrics stack (Prometheus + Grafana) all on by default.</div>
</div>
</label>
</div>
</div>
</section>
<!-- Step 2: Identity -->
<section class="setup-step" data-step="1">
<div class="setup-field">
<label for="sw-name">
Install Name
@ -120,10 +163,10 @@ class SetupWizard {
</div>
</section>
<!-- Step 2: Domains (optional, multi). Each domain enables HTTPS
<!-- Step 3: Domains (optional, multi). Each domain enables HTTPS
routing for apps via Traefik. Skipping leaves the install
local-only (apps reachable by IP and Port, no SSL). -->
<section class="setup-step" data-step="1">
<section class="setup-step" data-step="2">
<div class="setup-field">
<label>
Domains
@ -137,8 +180,8 @@ class SetupWizard {
</div>
</section>
<!-- Step 3: Recommended apps (Traefik + Fail2ban) -->
<section class="setup-step" data-step="2">
<!-- Step 4: Recommended apps (Traefik + Fail2ban) -->
<section class="setup-step" data-step="3">
<div class="setup-section">
<div class="setup-section-title">Recommended Apps</div>
<p class="setup-section-hint">Pre-selected to give you a working install out of the box.</p>
@ -158,10 +201,11 @@ class SetupWizard {
</div>
</section>
<!-- Step 4: Optional metrics apps (Prometheus + Grafana). Off by
<!-- Step 5: Optional metrics apps (Prometheus + Grafana). Off by
default they're only useful if the user wants the MONITORING
toggle on apps to do anything. -->
<section class="setup-step" data-step="3">
toggle on apps to do anything. Advanced-only: this whole step
is skipped when the user chose Beginner on step 1. -->
<section class="setup-step" data-step="4">
<div class="setup-section">
<div class="setup-section-title">Metrics Apps</div>
<p class="setup-section-hint">Optional. Install these to enable per-app "Export metrics to Grafana" later.</p>
@ -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 <label>'s :has().
this.container.querySelectorAll('input[name="sw-level"]').forEach((r) => {
r.addEventListener('change', () => {
if (!r.checked) return;
this.installLevel = (r.value === 'advanced') ? 'advanced' : 'beginner';
this.totalSteps = this._effectiveTotalSteps();
// Re-paint progress so "Step 1 of N" updates immediately.
this.showStep(this.currentStep);
});
});
$('#setup-form').addEventListener('submit', (e) => {
e.preventDefault();
console.log('[setup] form submit fired');
@ -261,12 +318,13 @@ class SetupWizard {
validateStep(idx) {
const $ = (id) => this.container.querySelector(id);
if (idx === 0) {
// Step 0 (Experience): always valid — radios are pre-checked.
if (idx === 1) {
const name = $('#sw-name').value.trim();
if (!/^[a-zA-Z0-9-]+$/.test(name)) return 'Install name must be letters, numbers, or hyphens only.';
if (!$('#sw-timezone').value) return 'Please select a timezone.';
}
if (idx === 1) {
if (idx === 2) {
// Domains are optional, but any non-empty input must be valid.
const inputs = Array.from(this.container.querySelectorAll('.setup-domain-input'));
for (const input of inputs) {
@ -276,7 +334,7 @@ class SetupWizard {
}
}
}
if (idx === 2) {
if (idx === 3) {
const traefikBox = this.container.querySelector('input[data-app="traefik"]');
if (traefikBox && traefikBox.checked) {
const tEmail = $('#sw-traefik-email').value.trim();
@ -617,12 +675,24 @@ class SetupWizard {
const payload = {
install_name: $('#sw-name').value.trim(),
timezone: $('#sw-timezone').value,
install_level: this.installLevel,
domains,
apps,
appOptions,
traefik_email: apps.includes('traefik') ? $('#sw-traefik-email').value.trim() : ''
};
// Apply the experience choice to the WebUI immediately so the next
// surface the user sees (the running-tasks page) is already in the
// right mode. The bash applier writes the CFG_* in the background
// for future sessions; this is just the in-process mirror.
if (window.LpUi?.advanced) {
try {
window.LpUi.advanced.set(this.installLevel === 'advanced');
try { localStorage.setItem('lp.ui.seeded', '1'); } catch {}
} catch {}
}
const submitBtn = $('#sw-submit');
submitBtn.disabled = true;
submitBtn.querySelector('.setup-launch-text').textContent = 'Launching…';

View File

@ -0,0 +1,124 @@
// window.LpUi — global UI-mode flags shared across the WebUI.
//
// Two orthogonal axes:
// advanced — show extra-technical detail (mounts, limits, internals,
// raw IDs, advanced config fields). Off by default
// ("Beginner"); user can flip on from any surface that
// exposes the Advanced toggle.
// dev — show developer-only fields and surfaces (the **DEV**
// config fields, Installation Mode picker, etc.). Locked
// behind the 10-tap unlock easter egg (LibrePortal logo
// OR the Advanced toggle). Auto-enables when on a git/
// local install (the existing topbar autodetect path).
//
// Linking rule: dev IMPLIES advanced.
// - Enabling dev auto-enables advanced (you can't see dev fields if
// the advanced layer is hidden).
// - Disabling advanced auto-disables dev (dev-without-advanced is
// an incoherent state — clean it up).
//
// Wire shape:
// localStorage keys lp.ui.advanced ('0' | '1')
// lp.ui.dev ('0' | '1') — UI-side mirror of CFG_DEV_MODE
// body classes lp-ui--advanced
// lp-ui--dev
// events window 'lp-ui-advanced-changed' { advanced }
// window 'lp-ui-dev-changed' { dev }
//
// Loaded eagerly (NOT lazy) via a <script> tag in index.html so the body
// class is applied before any page renders — no flash of unhidden content
// on a fresh load. Components that want to react to the flag should
// listen for the change events rather than poll.
(function setupLpUi() {
if (window.LpUi && window.LpUi.advanced && window.LpUi.dev) return;
const ADV_KEY = 'lp.ui.advanced';
const DEV_KEY = 'lp.ui.dev';
const SEED_KEY = 'lp.ui.seeded'; // marks that we've consulted CFG_INSTALL_LEVEL once
function safeGet(k) {
try { return localStorage.getItem(k); } catch { return null; }
}
function safeSet(k, v) {
try { localStorage.setItem(k, v); } catch { /* private mode */ }
}
function applyBodyClasses() {
if (!document.body) return;
document.body.classList.toggle('lp-ui--advanced', advanced.get());
document.body.classList.toggle('lp-ui--dev', dev.get());
}
const advanced = {
get() { return safeGet(ADV_KEY) === '1'; },
set(on) {
const v = !!on;
const before = this.get();
if (v === before) return;
safeSet(ADV_KEY, v ? '1' : '0');
document.body && document.body.classList.toggle('lp-ui--advanced', v);
// Dev implies advanced — turning advanced off also clears dev.
if (!v && dev.get()) dev.set(false);
window.dispatchEvent(new CustomEvent('lp-ui-advanced-changed', { detail: { advanced: v } }));
},
apply() { document.body && document.body.classList.toggle('lp-ui--advanced', this.get()); },
};
const dev = {
get() { return safeGet(DEV_KEY) === '1'; },
set(on) {
const v = !!on;
const before = this.get();
if (v === before) return;
safeSet(DEV_KEY, v ? '1' : '0');
document.body && document.body.classList.toggle('lp-ui--dev', v);
// Dev implies advanced — turning dev on lifts advanced too.
if (v && !advanced.get()) advanced.set(true);
window.dispatchEvent(new CustomEvent('lp-ui-dev-changed', { detail: { dev: v } }));
},
apply() { document.body && document.body.classList.toggle('lp-ui--dev', this.get()); },
};
// Seed lp.ui.advanced from CFG_INSTALL_LEVEL the first time we run.
// After that, the local flip is authoritative — we don't want the
// user's choice in the WebUI overridden every reload by the install-
// time default.
async function seedFromConfigIfNeeded() {
if (safeGet(SEED_KEY) === '1') return;
try {
const r = await fetch(`/data/config/generated/configs.json?t=${Date.now()}`);
if (!r.ok) { safeSet(SEED_KEY, '1'); return; }
const data = await r.json();
const level = (data?.config?.CFG_INSTALL_LEVEL?.value || 'beginner').toLowerCase();
const wantAdvanced = (level === 'advanced');
// Only seed if the user hasn't already set a value.
if (safeGet(ADV_KEY) === null) {
safeSet(ADV_KEY, wantAdvanced ? '1' : '0');
}
// Mirror CFG_DEV_MODE into the body class on first load so dev
// gating works before the topbar autodetect runs. The topbar
// still owns toggling CFG_DEV_MODE itself; we just read it.
const devOn = (data?.config?.CFG_DEV_MODE?.value === 'true' || data?.config?.CFG_DEV_MODE?.value === true);
safeSet(DEV_KEY, devOn ? '1' : '0');
safeSet(SEED_KEY, '1');
applyBodyClasses();
} catch {
safeSet(SEED_KEY, '1');
}
}
window.LpUi = { advanced, dev };
// Apply ASAP — once body exists drop the classes in. If the script
// loads before body, defer; otherwise do it immediately.
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => {
applyBodyClasses();
seedFromConfigIfNeeded();
}, { once: true });
} else {
applyBodyClasses();
seedFromConfigIfNeeded();
}
})();

View File

@ -20,6 +20,7 @@ setupApplyConfig()
local install_name=$(echo "$payload" | jq -r '.install_name // empty')
local timezone=$(echo "$payload" | jq -r '.timezone // empty')
local install_level=$(echo "$payload" | jq -r '.install_level // empty')
local traefik_email=$(echo "$payload" | jq -r '.traefik_email // empty')
local domains_json=$(echo "$payload" | jq -c '.domains // []')
@ -33,6 +34,15 @@ setupApplyConfig()
isSuccessful "Timezone set to '$timezone'"
fi
# Experience level — seeds the WebUI's Advanced UI mode on first paint
# so a Beginner gets a stripped-down view and an Advanced user sees
# everything by default. The WebUI also exposes a per-browser toggle
# that overrides this; we just provide the install-time default.
if [[ "$install_level" == "beginner" || "$install_level" == "advanced" ]]; then
updateConfigOption "CFG_INSTALL_LEVEL" "$install_level"
isSuccessful "Experience level set to '$install_level'"
fi
local domains_count=$(echo "$domains_json" | jq -r 'length')
if [[ "$domains_count" -gt 0 ]]; then
local i=0