video side: Music <-> Video sidebar toggle + video nav shell (isolated)
First slice of the video side, on the experimental branch. Purely additive and fully isolated from music: - A Music | Video toggle in the sidebar header; clicking flips body[data-side] (remembered in localStorage). The shared shell (logo, user, Support, Version) stays; only the nav set + subtitle swap. - A second sidebar nav (.video-nav) with the video pages — Dashboard, Search, Discover, Library, Calendar, Import, Settings, Issues, Help & Docs — shown via CSS off body[data-side]. Service Status is hidden on the video side. - A placeholder content host; real video pages land later. Isolation contract held: index.html is +51/-0 (no music markup changed), music JS/CSS untouched, nothing in music references the controller. The controller (webui/static/video/video-side.js) is a self-contained IIFE wired purely via addEventListener (no globals, no inline onclick) — so it can't affect music and doesn't trip the script-split-integrity contract. Tests: 6 video-shell structural/isolation tests + 64 script-integrity green.
This commit is contained in:
parent
2953c2c5d2
commit
3202197740
4 changed files with 359 additions and 0 deletions
86
tests/test_video_side_shell.py
Normal file
86
tests/test_video_side_shell.py
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
"""Video side shell: the Music ↔ Video toggle + video sidebar (experimental branch).
|
||||
|
||||
This is the first slice of the video side. It must be ADDITIVE and ISOLATED:
|
||||
the music sidebar/nav is untouched, the toggle + video nav are wired purely via
|
||||
data-attributes (no inline onclick — which would also break the script-split
|
||||
integrity contract), and the controller is a self-contained IIFE that adds no
|
||||
globals. These pin all of that so a regression can't silently couple the two
|
||||
sides or break the music shell.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
_ROOT = Path(__file__).resolve().parent.parent
|
||||
_INDEX = (_ROOT / "webui" / "index.html").read_text(encoding="utf-8", errors="replace")
|
||||
_JS = (_ROOT / "webui" / "static" / "video" / "video-side.js").read_text(encoding="utf-8")
|
||||
_CSS_PATH = _ROOT / "webui" / "static" / "video" / "video-side.css"
|
||||
|
||||
EXPECTED_VIDEO_PAGES = {
|
||||
"video-dashboard", "video-search", "video-discover", "video-library",
|
||||
"video-calendar", "video-import", "video-settings", "video-issues", "video-help",
|
||||
}
|
||||
|
||||
|
||||
def _block(html: str, open_tag_re: str, close_tag: str) -> str:
|
||||
"""Return the substring from the first match of open_tag_re to the next close_tag."""
|
||||
m = re.search(open_tag_re, html)
|
||||
assert m, f"could not find {open_tag_re!r}"
|
||||
end = html.index(close_tag, m.end())
|
||||
return html[m.start():end + len(close_tag)]
|
||||
|
||||
|
||||
# --- the toggle ------------------------------------------------------------
|
||||
|
||||
def test_side_toggle_has_music_and_video():
|
||||
block = _block(_INDEX, r'<div class="side-toggle"', "</div>")
|
||||
assert 'data-side-target="music"' in block
|
||||
assert 'data-side-target="video"' in block
|
||||
# Wired by JS, not inline handlers (isolation + integrity contract).
|
||||
assert "onclick" not in block
|
||||
|
||||
|
||||
# --- the video nav ---------------------------------------------------------
|
||||
|
||||
def test_video_nav_has_all_expected_pages():
|
||||
block = _block(_INDEX, r'<nav class="sidebar-nav video-nav"', "</nav>")
|
||||
found = set(re.findall(r'data-video-page="([^"]+)"', block))
|
||||
assert found == EXPECTED_VIDEO_PAGES, f"video nav pages mismatch: {found ^ EXPECTED_VIDEO_PAGES}"
|
||||
assert "onclick" not in block # data-attr wired, no inline handlers
|
||||
|
||||
|
||||
def test_video_page_host_present():
|
||||
assert 'id="video-page-host"' in _INDEX
|
||||
assert 'class="page video-page"' in _INDEX
|
||||
|
||||
|
||||
def test_video_assets_referenced():
|
||||
assert "video/video-side.js" in _INDEX
|
||||
assert "video/video-side.css" in _INDEX
|
||||
assert _CSS_PATH.exists() and _CSS_PATH.stat().st_size > 0
|
||||
|
||||
|
||||
# --- isolation: music side untouched --------------------------------------
|
||||
|
||||
def test_music_sidebar_nav_still_intact():
|
||||
# The original music nav (a sibling .sidebar-nav WITHOUT the video-nav class)
|
||||
# must still carry its pages — the video side is additive, not a rewrite.
|
||||
music_nav = _block(_INDEX, r'<nav class="sidebar-nav">', "</nav>")
|
||||
for page in ("dashboard", "sync", "library", "settings", "issues", "help"):
|
||||
assert f'data-page="{page}"' in music_nav, f"music nav lost '{page}'"
|
||||
# The music subtitle is still the default in markup (JS swaps it at runtime).
|
||||
assert "Music Sync & Manager" in _INDEX
|
||||
|
||||
|
||||
def test_controller_is_isolated_iife_with_no_globals():
|
||||
stripped = _JS.strip()
|
||||
# Wrapped in an IIFE → declares no module-level globals that could collide
|
||||
# with or shadow music functions.
|
||||
assert stripped.startswith("/*") or stripped.startswith("(function")
|
||||
assert "(function" in _JS and "})();" in _JS
|
||||
# No window.X assignments (the cross-file leak pattern) and no inline-handler
|
||||
# dependence — everything is addEventListener.
|
||||
assert "window." not in _JS or "window.location" in _JS # tolerate none; none expected
|
||||
assert "addEventListener" in _JS
|
||||
|
|
@ -13,6 +13,7 @@
|
|||
<link rel="stylesheet" href="{{ url_for('static', filename='basic-search-v2.css', v=static_v) }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='mobile.css', v=static_v) }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='setup-wizard.css', v=static_v) }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='video/video-side.css', v=static_v) }}">
|
||||
{{ vite_assets('head')|safe }}
|
||||
</head>
|
||||
|
||||
|
|
@ -219,6 +220,11 @@
|
|||
<p class="app-subtitle">Music Sync & Manager</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Music ↔ Video side toggle (shell-only; wired by video/video-side.js) -->
|
||||
<div class="side-toggle" role="group" aria-label="Switch between Music and Video">
|
||||
<button type="button" class="side-toggle-btn active" data-side-target="music">Music</button>
|
||||
<button type="button" class="side-toggle-btn" data-side-target="video">Video</button>
|
||||
</div>
|
||||
<div id="profile-indicator" class="profile-indicator" style="display: none;" title="Switch profile">
|
||||
<div id="profile-indicator-avatar" class="profile-indicator-avatar"></div>
|
||||
<span id="profile-indicator-name" class="profile-indicator-name"></span>
|
||||
|
|
@ -310,6 +316,46 @@
|
|||
</a>
|
||||
</nav>
|
||||
|
||||
<!-- Video Navigation (shell-only; shown when body[data-side="video"], wired by video/video-side.js) -->
|
||||
<nav class="sidebar-nav video-nav" aria-label="Video navigation">
|
||||
<a class="nav-button" data-video-page="video-dashboard" href="#">
|
||||
<span class="nav-icon"><svg class="nav-svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="4" rx="1"/><rect x="3" y="14" width="7" height="4" rx="1"/><rect x="14" y="11" width="7" height="7" rx="1"/></svg></span>
|
||||
<span class="nav-text">Dashboard</span>
|
||||
</a>
|
||||
<a class="nav-button" data-video-page="video-search" href="#">
|
||||
<span class="nav-icon"><svg class="nav-svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg></span>
|
||||
<span class="nav-text">Search</span>
|
||||
</a>
|
||||
<a class="nav-button" data-video-page="video-discover" href="#">
|
||||
<span class="nav-icon"><svg class="nav-svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><polygon points="16.24 7.76 14.12 14.12 7.76 16.24 9.88 9.88 16.24 7.76" fill="currentColor" opacity="0.2" stroke="currentColor"/></svg></span>
|
||||
<span class="nav-text">Discover</span>
|
||||
</a>
|
||||
<a class="nav-button" data-video-page="video-library" href="#">
|
||||
<span class="nav-icon"><svg class="nav-svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><polygon points="23 7 16 12 23 17 23 7"/><rect x="1" y="5" width="15" height="14" rx="2" ry="2"/></svg></span>
|
||||
<span class="nav-text">Library</span>
|
||||
</a>
|
||||
<a class="nav-button" data-video-page="video-calendar" href="#">
|
||||
<span class="nav-icon"><svg class="nav-svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg></span>
|
||||
<span class="nav-text">Calendar</span>
|
||||
</a>
|
||||
<a class="nav-button" data-video-page="video-import" href="#">
|
||||
<span class="nav-icon"><svg class="nav-svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M4 17v2a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-2"/><polyline points="7 9 12 4 17 9"/><line x1="12" y1="4" x2="12" y2="16"/></svg></span>
|
||||
<span class="nav-text">Import</span>
|
||||
</a>
|
||||
<a class="nav-button" data-video-page="video-settings" href="#">
|
||||
<span class="nav-icon"><svg class="nav-svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg></span>
|
||||
<span class="nav-text">Settings</span>
|
||||
</a>
|
||||
<a class="nav-button" data-video-page="video-issues" href="#">
|
||||
<span class="nav-icon"><svg class="nav-svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg></span>
|
||||
<span class="nav-text">Issues</span>
|
||||
</a>
|
||||
<a class="nav-button" data-video-page="video-help" href="#">
|
||||
<span class="nav-icon"><svg class="nav-svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg></span>
|
||||
<span class="nav-text">Help & Docs</span>
|
||||
</a>
|
||||
</nav>
|
||||
|
||||
<!-- Spacer -->
|
||||
<div class="sidebar-spacer"></div>
|
||||
|
||||
|
|
@ -350,6 +396,9 @@
|
|||
<canvas id="page-particles-canvas"></canvas>
|
||||
<div id="webui-react-root" class="page" data-react-app="router"></div>
|
||||
|
||||
<!-- Video side content host (shell-only; filled by video/video-side.js) -->
|
||||
<div class="page video-page" id="video-page-host"></div>
|
||||
|
||||
<!-- Dashboard Page -->
|
||||
<div class="page" id="dashboard-page">
|
||||
<div class="page-shell dashboard-container">
|
||||
|
|
@ -8473,6 +8522,8 @@
|
|||
<script src="{{ url_for('static', filename='helper.js', v=static_v) }}"></script>
|
||||
<script src="{{ url_for('static', filename='particles.js', v=static_v) }}"></script>
|
||||
<script src="{{ url_for('static', filename='worker-orbs.js', v=static_v) }}"></script>
|
||||
<!-- Video side shell controller (isolated; music never references it) -->
|
||||
<script src="{{ url_for('static', filename='video/video-side.js', v=static_v) }}"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
|
|
|
|||
86
webui/static/video/video-side.css
Normal file
86
webui/static/video/video-side.css
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
/*
|
||||
* SoulSync — Video side shell styling.
|
||||
*
|
||||
* Loaded AFTER style.css so these rules win where specificity ties. Everything
|
||||
* keys off body[data-side="music|video"] (set by video-side.js). The music side
|
||||
* is the default and is never affected — these rules only do something once the
|
||||
* body flips to data-side="video".
|
||||
*/
|
||||
|
||||
/* ── The Music ↔ Video header toggle ───────────────────────────────────── */
|
||||
.side-toggle {
|
||||
display: flex;
|
||||
gap: 3px;
|
||||
margin-top: 12px;
|
||||
padding: 3px;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 9px;
|
||||
}
|
||||
.side-toggle-btn {
|
||||
flex: 1;
|
||||
padding: 6px 8px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.03em;
|
||||
color: var(--text-secondary, #9aa0aa);
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease, color 0.15s ease;
|
||||
}
|
||||
.side-toggle-btn:hover {
|
||||
color: var(--text-primary, #e8e8ea);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
.side-toggle-btn.active {
|
||||
background: rgb(var(--accent-rgb, 88 101 242));
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* ── Nav swap: music nav vs video nav ──────────────────────────────────── */
|
||||
/* The video nav reuses .sidebar-nav layout (same class) — only visibility
|
||||
differs. Hidden by default (music side); shown when data-side="video". */
|
||||
.video-nav {
|
||||
display: none;
|
||||
}
|
||||
body[data-side="video"] nav.sidebar-nav:not(.video-nav) {
|
||||
display: none;
|
||||
}
|
||||
body[data-side="video"] .video-nav {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
/* ── Page visibility per side ──────────────────────────────────────────── */
|
||||
/* Music pages hidden on the video side; video pages hidden on the music side.
|
||||
The music router still controls .page.active among music pages; we just hide
|
||||
the whole set while the video side is showing. */
|
||||
body[data-side="music"] .page.video-page {
|
||||
display: none !important;
|
||||
}
|
||||
body[data-side="video"] .page:not(.video-page) {
|
||||
display: none !important;
|
||||
}
|
||||
body[data-side="video"] .page.video-page {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* ── Service status: not needed on the video side (for now) ─────────────── */
|
||||
body[data-side="video"] .status-section {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* ── Placeholder content (until the real video pages land) ─────────────── */
|
||||
.video-placeholder {
|
||||
padding: 48px 40px;
|
||||
}
|
||||
.video-placeholder .header-title {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.video-placeholder-note {
|
||||
margin-top: 10px;
|
||||
color: var(--text-secondary, #9aa0aa);
|
||||
font-size: 14px;
|
||||
}
|
||||
136
webui/static/video/video-side.js
Normal file
136
webui/static/video/video-side.js
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
/*
|
||||
* SoulSync — Video side shell controller.
|
||||
*
|
||||
* ISOLATION CONTRACT: the music side never imports or references anything here.
|
||||
* This file is a self-contained IIFE (no globals) wired entirely via
|
||||
* addEventListener (no inline onclick), so it cannot affect the music side and
|
||||
* a merge can't touch it. It only drives shared SHELL behaviour:
|
||||
* - the Music ↔ Video header toggle (+ remembers the side in localStorage)
|
||||
* - showing/hiding the video sidebar nav vs the music nav (CSS does the work
|
||||
* off body[data-side]; this just flips the attribute)
|
||||
* - a placeholder content host for the video pages (real pages land later)
|
||||
*
|
||||
* The actual video domain (data model, services, pages, DB) lives elsewhere and
|
||||
* is built on top of this shell.
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var SIDE_KEY = 'soulsync_side';
|
||||
var MUSIC_SUBTITLE = 'Music Sync & Manager';
|
||||
var VIDEO_SUBTITLE = 'Video Sync & Manager';
|
||||
var DEFAULT_VIDEO_PAGE = 'video-dashboard';
|
||||
|
||||
// The video sidebar pages. Pages flagged shared: true are "same as music"
|
||||
// (Import / Issues / Help) — wired to reuse the music pages in a later step;
|
||||
// for now every page renders the placeholder.
|
||||
var VIDEO_PAGES = [
|
||||
{ id: 'video-dashboard', label: 'Dashboard' },
|
||||
{ id: 'video-search', label: 'Search' },
|
||||
{ id: 'video-discover', label: 'Discover' },
|
||||
{ id: 'video-library', label: 'Library' },
|
||||
{ id: 'video-calendar', label: 'Calendar' },
|
||||
{ id: 'video-import', label: 'Import', shared: true },
|
||||
{ id: 'video-settings', label: 'Settings' },
|
||||
{ id: 'video-issues', label: 'Issues', shared: true },
|
||||
{ id: 'video-help', label: 'Help & Docs', shared: true },
|
||||
];
|
||||
|
||||
function readSide() {
|
||||
try {
|
||||
return localStorage.getItem(SIDE_KEY) === 'video' ? 'video' : 'music';
|
||||
} catch (e) {
|
||||
return 'music';
|
||||
}
|
||||
}
|
||||
|
||||
function persistSide(side) {
|
||||
try { localStorage.setItem(SIDE_KEY, side); } catch (e) { /* ignore */ }
|
||||
}
|
||||
|
||||
function pageMeta(pageId) {
|
||||
for (var i = 0; i < VIDEO_PAGES.length; i++) {
|
||||
if (VIDEO_PAGES[i].id === pageId) return VIDEO_PAGES[i];
|
||||
}
|
||||
return VIDEO_PAGES[0];
|
||||
}
|
||||
|
||||
function showVideoPage(pageId) {
|
||||
var meta = pageMeta(pageId);
|
||||
var navButtons = document.querySelectorAll('.video-nav .nav-button[data-video-page]');
|
||||
for (var i = 0; i < navButtons.length; i++) {
|
||||
navButtons[i].classList.toggle(
|
||||
'active', navButtons[i].getAttribute('data-video-page') === meta.id);
|
||||
}
|
||||
var host = document.getElementById('video-page-host');
|
||||
if (!host) return;
|
||||
// Built from our own static constants only — no user input.
|
||||
var shell = document.createElement('div');
|
||||
shell.className = 'page-shell video-placeholder';
|
||||
var h2 = document.createElement('h2');
|
||||
h2.className = 'header-title';
|
||||
var span = document.createElement('span');
|
||||
span.textContent = 'Video · ' + meta.label;
|
||||
h2.appendChild(span);
|
||||
var note = document.createElement('p');
|
||||
note.className = 'video-placeholder-note';
|
||||
note.textContent = 'The ' + meta.label + ' page for the video side is coming soon.';
|
||||
host.textContent = '';
|
||||
host.appendChild(h2);
|
||||
host.appendChild(note);
|
||||
}
|
||||
|
||||
function applySide(side) {
|
||||
document.body.setAttribute('data-side', side);
|
||||
var subtitle = document.querySelector('.sidebar-header .app-subtitle');
|
||||
if (subtitle) subtitle.textContent = side === 'video' ? VIDEO_SUBTITLE : MUSIC_SUBTITLE;
|
||||
var toggleButtons = document.querySelectorAll('.side-toggle-btn');
|
||||
for (var i = 0; i < toggleButtons.length; i++) {
|
||||
toggleButtons[i].classList.toggle(
|
||||
'active', toggleButtons[i].getAttribute('data-side-target') === side);
|
||||
}
|
||||
if (side === 'video') {
|
||||
var active = document.querySelector('.video-nav .nav-button.active');
|
||||
showVideoPage(active ? active.getAttribute('data-video-page') : DEFAULT_VIDEO_PAGE);
|
||||
}
|
||||
}
|
||||
|
||||
function switchSide(side) {
|
||||
if (side !== 'music' && side !== 'video') return;
|
||||
persistSide(side);
|
||||
applySide(side);
|
||||
}
|
||||
|
||||
function init() {
|
||||
var toggleButtons = document.querySelectorAll('.side-toggle-btn');
|
||||
for (var i = 0; i < toggleButtons.length; i++) {
|
||||
(function (btn) {
|
||||
btn.addEventListener('click', function () {
|
||||
switchSide(btn.getAttribute('data-side-target'));
|
||||
});
|
||||
})(toggleButtons[i]);
|
||||
}
|
||||
|
||||
var navButtons = document.querySelectorAll('.video-nav .nav-button[data-video-page]');
|
||||
for (var j = 0; j < navButtons.length; j++) {
|
||||
(function (btn) {
|
||||
btn.addEventListener('click', function (e) {
|
||||
e.preventDefault();
|
||||
showVideoPage(btn.getAttribute('data-video-page'));
|
||||
});
|
||||
})(navButtons[j]);
|
||||
}
|
||||
|
||||
var defaultNav = document.querySelector(
|
||||
'.video-nav .nav-button[data-video-page="' + DEFAULT_VIDEO_PAGE + '"]');
|
||||
if (defaultNav) defaultNav.classList.add('active');
|
||||
|
||||
applySide(readSide());
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', init);
|
||||
} else {
|
||||
init();
|
||||
}
|
||||
})();
|
||||
Loading…
Reference in a new issue