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:
parent
51069ae05a
commit
9a92805bdb
@ -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]
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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; }
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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…';
|
||||
|
||||
124
containers/libreportal/frontend/js/utils/lp-ui.js
Normal file
124
containers/libreportal/frontend/js/utils/lp-ui.js
Normal 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();
|
||||
}
|
||||
})();
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user