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_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_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' });
|
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
|
// 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.
|
// 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;
|
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-back { order: 2; }
|
||||||
.setup-btn-next, .setup-launch { order: 1; }
|
.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%;
|
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 -->
|
<!-- Auth must load first — gates all other initialization -->
|
||||||
<script src="/js/system/auth-manager.js"></script>
|
<script src="/js/system/auth-manager.js"></script>
|
||||||
<!-- Essential Bootstrap -->
|
<!-- 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/dom-helpers.js"></script>
|
||||||
<script src="/js/utils/ui-helpers.js"></script>
|
<script src="/js/utils/ui-helpers.js"></script>
|
||||||
<script src="/js/utils/router.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.
|
// Services tab on the app detail page.
|
||||||
//
|
//
|
||||||
// Each row renders a single docker compose service with:
|
// Each row renders a single docker compose service with:
|
||||||
@ -548,6 +507,64 @@ class ServicesManager {
|
|||||||
if (!cb || !window.LpUi?.advanced) return;
|
if (!cb || !window.LpUi?.advanced) return;
|
||||||
window.LpUi.advanced.set(cb.checked);
|
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) {
|
async _restartService(serviceName, btn) {
|
||||||
|
|||||||
@ -55,8 +55,11 @@ class TopbarComponent {
|
|||||||
const html = await response.text();
|
const html = await response.text();
|
||||||
container.innerHTML = html;
|
container.innerHTML = html;
|
||||||
|
|
||||||
// Initialize component
|
// Initialize component. Stash on window so other surfaces can reach
|
||||||
new TopbarComponent();
|
// 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) {
|
} catch (error) {
|
||||||
console.error('Error loading topbar:', error);
|
console.error('Error loading topbar:', error);
|
||||||
@ -209,6 +212,13 @@ class TopbarComponent {
|
|||||||
// a full page refresh.
|
// a full page refresh.
|
||||||
if (!window.systemConfigs) window.systemConfigs = {};
|
if (!window.systemConfigs) window.systemConfigs = {};
|
||||||
window.systemConfigs.CFG_DEV_MODE = value;
|
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) {
|
if (enabled) {
|
||||||
try { localStorage.removeItem('lp.devBannerDismissed'); } catch {}
|
try { localStorage.removeItem('lp.devBannerDismissed'); } catch {}
|
||||||
this._updateDevBanner(true);
|
this._updateDevBanner(true);
|
||||||
|
|||||||
@ -13,12 +13,23 @@ class SetupWizard {
|
|||||||
this.dnsCheckController = null;
|
this.dnsCheckController = null;
|
||||||
this.onComplete = null;
|
this.onComplete = null;
|
||||||
this.currentStep = 0;
|
this.currentStep = 0;
|
||||||
this.stepNames = ['Identity', 'Domains', 'Recommended', 'Metrics'];
|
// Step 0 (Experience) chooses Beginner or Advanced. Beginner shrinks
|
||||||
this.stepIcons = ['🪐', '🛰️', '🛡️', '📊'];
|
// the wizard — the Metrics step is hidden and never asked — so the
|
||||||
this.totalSteps = this.stepNames.length;
|
// 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
|
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) {
|
initialize(setupDetector, onComplete = null) {
|
||||||
this.onComplete = onComplete;
|
this.onComplete = onComplete;
|
||||||
this.create();
|
this.create();
|
||||||
@ -78,8 +89,40 @@ class SetupWizard {
|
|||||||
<div class="setup-track-wrap">
|
<div class="setup-track-wrap">
|
||||||
<div class="setup-track" id="sw-track">
|
<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">
|
<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">
|
<div class="setup-field">
|
||||||
<label for="sw-name">
|
<label for="sw-name">
|
||||||
Install Name
|
Install Name
|
||||||
@ -120,10 +163,10 @@ class SetupWizard {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</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
|
routing for apps via Traefik. Skipping leaves the install
|
||||||
local-only (apps reachable by IP and Port, no SSL). -->
|
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">
|
<div class="setup-field">
|
||||||
<label>
|
<label>
|
||||||
Domains
|
Domains
|
||||||
@ -137,8 +180,8 @@ class SetupWizard {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Step 3: Recommended apps (Traefik + Fail2ban) -->
|
<!-- Step 4: Recommended apps (Traefik + Fail2ban) -->
|
||||||
<section class="setup-step" data-step="2">
|
<section class="setup-step" data-step="3">
|
||||||
<div class="setup-section">
|
<div class="setup-section">
|
||||||
<div class="setup-section-title">Recommended Apps</div>
|
<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>
|
<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>
|
</div>
|
||||||
</section>
|
</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
|
default — they're only useful if the user wants the MONITORING
|
||||||
toggle on apps to do anything. -->
|
toggle on apps to do anything. Advanced-only: this whole step
|
||||||
<section class="setup-step" data-step="3">
|
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">
|
||||||
<div class="setup-section-title">Metrics Apps</div>
|
<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>
|
<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-back').addEventListener('click', () => this.prev());
|
||||||
$('#sw-next').addEventListener('click', () => this.next());
|
$('#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) => {
|
$('#setup-form').addEventListener('submit', (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
console.log('[setup] form submit fired');
|
console.log('[setup] form submit fired');
|
||||||
@ -261,12 +318,13 @@ class SetupWizard {
|
|||||||
|
|
||||||
validateStep(idx) {
|
validateStep(idx) {
|
||||||
const $ = (id) => this.container.querySelector(id);
|
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();
|
const name = $('#sw-name').value.trim();
|
||||||
if (!/^[a-zA-Z0-9-]+$/.test(name)) return 'Install name must be letters, numbers, or hyphens only.';
|
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 (!$('#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.
|
// Domains are optional, but any non-empty input must be valid.
|
||||||
const inputs = Array.from(this.container.querySelectorAll('.setup-domain-input'));
|
const inputs = Array.from(this.container.querySelectorAll('.setup-domain-input'));
|
||||||
for (const input of inputs) {
|
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"]');
|
const traefikBox = this.container.querySelector('input[data-app="traefik"]');
|
||||||
if (traefikBox && traefikBox.checked) {
|
if (traefikBox && traefikBox.checked) {
|
||||||
const tEmail = $('#sw-traefik-email').value.trim();
|
const tEmail = $('#sw-traefik-email').value.trim();
|
||||||
@ -617,12 +675,24 @@ class SetupWizard {
|
|||||||
const payload = {
|
const payload = {
|
||||||
install_name: $('#sw-name').value.trim(),
|
install_name: $('#sw-name').value.trim(),
|
||||||
timezone: $('#sw-timezone').value,
|
timezone: $('#sw-timezone').value,
|
||||||
|
install_level: this.installLevel,
|
||||||
domains,
|
domains,
|
||||||
apps,
|
apps,
|
||||||
appOptions,
|
appOptions,
|
||||||
traefik_email: apps.includes('traefik') ? $('#sw-traefik-email').value.trim() : ''
|
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');
|
const submitBtn = $('#sw-submit');
|
||||||
submitBtn.disabled = true;
|
submitBtn.disabled = true;
|
||||||
submitBtn.querySelector('.setup-launch-text').textContent = 'Launching…';
|
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 install_name=$(echo "$payload" | jq -r '.install_name // empty')
|
||||||
local timezone=$(echo "$payload" | jq -r '.timezone // 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 traefik_email=$(echo "$payload" | jq -r '.traefik_email // empty')
|
||||||
local domains_json=$(echo "$payload" | jq -c '.domains // []')
|
local domains_json=$(echo "$payload" | jq -c '.domains // []')
|
||||||
|
|
||||||
@ -33,6 +34,15 @@ setupApplyConfig()
|
|||||||
isSuccessful "Timezone set to '$timezone'"
|
isSuccessful "Timezone set to '$timezone'"
|
||||||
fi
|
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')
|
local domains_count=$(echo "$domains_json" | jq -r 'length')
|
||||||
if [[ "$domains_count" -gt 0 ]]; then
|
if [[ "$domains_count" -gt 0 ]]; then
|
||||||
local i=0
|
local i=0
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user