Files
Bjorn/index.html

1482 lines
60 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<html lang="en" class="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>BJORN // WIKI NODE</title>
<meta name="description" content="Official Documentation and Wiki for BJORN Cyber Viking">
<!-- Tailwind CSS (CDN) -->
<script src="https://cdn.tailwindcss.com"></script>
<!-- Icons: Lucide -->
<script src="https://unpkg.com/lucide@latest"></script>
<!-- Markdown Parser: Marked -->
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
<!-- Sanitizer: DOMPurify -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/dompurify/3.0.6/purify.min.js"></script>
<!-- Syntax Highlighting: Highlight.js -->
<link rel="stylesheet"
href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/atom-one-dark.min.css">
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script>
<!-- Fonts -->
<link
href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;700&family=Inter:wght@400;500;600&display=swap"
rel="stylesheet">
<style>
/* --- THEME ENGINE (CSS Variables) --- */
:root {
--bg-body: #0B0C0E;
--bg-sidebar: #111214;
--border-color: #2A2E35;
--text-main: #A0AAB8;
--text-heading: #E2E8F0;
--accent-green: #22c55e;
--accent-dim: rgba(34, 197, 94, 0.1);
--code-bg: #1e1e1e;
}
/* Light Mode Overrides */
html.light {
--bg-body: #F8FAFC;
--bg-sidebar: #FFFFFF;
--border-color: #E2E8F0;
--text-main: #475569;
--text-heading: #1E293B;
--accent-green: #16a34a;
--accent-dim: rgba(22, 163, 74, 0.1);
--code-bg: #f1f5f9;
}
/* Base Settings */
body {
background-color: var(--bg-body);
color: var(--text-main);
-webkit-tap-highlight-color: transparent;
transition: background-color 0.3s, color 0.3s;
}
/* Scrollbar Styling */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: var(--bg-body);
}
::-webkit-scrollbar-thumb {
background: var(--border-color);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--accent-green);
}
/* Utility Classes using vars */
.bg-hack-sidebar {
background-color: var(--bg-sidebar);
}
.bg-hack-bg {
background-color: var(--bg-body);
}
.border-hack-border {
border-color: var(--border-color);
}
.text-hack-heading {
color: var(--text-heading);
}
.text-hack-green {
color: var(--accent-green);
}
.bg-hack-greenDim {
background-color: var(--accent-dim);
}
/* Sidebar Animations */
.sidebar-mobile {
transition: transform 0.3s cubic-bezier(0.16, 1, 0.3, 1);
}
/* --- MARKDOWN STYLES --- */
.markdown-body h1 {
font-size: 2.2rem;
font-weight: 700;
color: var(--text-heading);
margin-bottom: 1.5rem;
border-bottom: 1px solid var(--border-color);
padding-bottom: 1rem;
line-height: 1.2;
}
.markdown-body h2 {
font-size: 1.5rem;
font-weight: 700;
color: var(--text-heading);
margin-top: 3rem;
margin-bottom: 1rem;
display: flex;
align-items: center;
scroll-margin-top: 80px;
}
.markdown-body h3 {
font-size: 1.25rem;
font-weight: 600;
color: var(--text-heading);
margin-top: 2rem;
margin-bottom: 0.75rem;
scroll-margin-top: 80px;
}
/* Anchor Links (#) */
.anchor-link {
margin-left: 0.5rem;
color: var(--accent-green);
opacity: 0;
transition: opacity 0.2s;
cursor: pointer;
font-family: 'JetBrains Mono', monospace;
font-size: 0.8em;
text-decoration: none;
user-select: none;
}
h2:hover .anchor-link,
h3:hover .anchor-link {
opacity: 1;
}
.markdown-body p {
margin-bottom: 1.25rem;
line-height: 1.7;
}
.markdown-body ul {
list-style: none;
margin-bottom: 1.25rem;
padding-left: 1rem;
}
.markdown-body li {
margin-bottom: 0.5rem;
position: relative;
padding-left: 1.5rem;
}
.markdown-body li::before {
content: "";
color: var(--accent-green);
position: absolute;
left: 0;
font-weight: bold;
}
/* Code Blocks */
.markdown-body pre {
background-color: var(--code-bg) !important;
padding: 0 !important;
border-radius: 8px;
overflow: hidden;
margin-bottom: 1.5rem;
border: 1px solid var(--border-color);
position: relative;
}
.markdown-body pre code.hljs {
padding: 1rem;
font-family: 'JetBrains Mono', monospace;
font-size: 0.875rem;
background-color: transparent;
display: block;
overflow-x: auto;
}
/* Inline Code */
.markdown-body :not(pre)>code {
background-color: var(--accent-dim);
padding: 0.2em 0.4em;
border-radius: 4px;
font-family: 'JetBrains Mono', monospace;
font-size: 0.875em;
color: var(--accent-green);
border: 1px solid var(--border-color);
}
.markdown-body a {
color: var(--accent-green);
text-decoration: none;
border-bottom: 1px solid transparent;
transition: border-color 0.2s;
font-weight: 500;
}
.markdown-body a:hover {
border-bottom-color: var(--accent-green);
}
.markdown-body blockquote {
border-left: 4px solid var(--accent-green);
padding-left: 1rem;
margin-left: 0;
font-style: italic;
background: var(--accent-dim);
padding: 1rem;
border-radius: 0 4px 4px 0;
color: var(--text-heading);
margin-bottom: 1.5rem;
}
.markdown-body strong {
color: var(--text-heading);
font-weight: 700;
}
/* Images & Lightbox */
.markdown-body img {
max-width: 100%;
border-radius: 8px;
border: 1px solid var(--border-color);
cursor: zoom-in;
transition: transform 0.2s;
}
.markdown-body img:hover {
border-color: var(--accent-green);
}
#lightbox {
transition: opacity 0.3s ease, visibility 0.3s;
}
#lightbox.active {
opacity: 1;
visibility: visible;
}
#lightbox img {
max-height: 90vh;
max-width: 90vw;
object-fit: contain;
box-shadow: 0 0 50px rgba(0, 0, 0, 0.5);
}
/* UI Elements */
.copy-btn {
position: absolute;
top: 0.5rem;
right: 0.5rem;
background-color: var(--bg-body);
color: var(--text-main);
border: 1px solid var(--border-color);
border-radius: 4px;
padding: 0.25rem 0.5rem;
font-size: 0.7rem;
font-family: 'JetBrains Mono', monospace;
cursor: pointer;
opacity: 0;
transition: all 0.2s;
z-index: 10;
}
.markdown-body pre:hover .copy-btn {
opacity: 1;
}
.copy-btn:hover {
border-color: var(--accent-green);
color: var(--accent-green);
}
/* Reading Progress Bar */
#reading-progress-bar {
position: fixed;
top: 0;
left: 0;
height: 3px;
background-color: var(--accent-green);
width: 0%;
z-index: 100;
transition: width 0.1s;
pointer-events: none;
}
/* Navigation */
.nav-list {
overflow: hidden;
transition: max-height 0.3s ease-out;
}
.nav-list.collapsed {
max-height: 0;
}
.nav-list.expanded {
max-height: 1000px;
}
.section-arrow {
transition: transform 0.2s;
}
.section-header.active .section-arrow {
transform: rotate(90deg);
}
.nav-link.active {
background-color: var(--accent-dim);
color: var(--accent-green);
border-left: 2px solid var(--accent-green);
padding-left: 0.5rem !important;
}
/* TOC & Scroll */
.toc-link.active {
color: var(--accent-green);
border-left-color: var(--accent-green);
}
#scroll-top-btn {
transition: opacity 0.3s, transform 0.3s;
}
#scroll-top-btn.hidden {
opacity: 0;
pointer-events: none;
transform: translateY(20px);
}
/* Animations */
@keyframes pulse-green {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}
.version-loading {
animation: pulse-green 1.5s cubic-bezier(0.4, 0, 0.6, 1) infinite;
}
/* Focus & Inputs */
input {
background-color: var(--bg-body);
color: var(--text-main);
border-color: var(--border-color);
}
input:focus {
border-color: var(--accent-green);
outline: none;
}
*:focus-visible {
outline: 2px solid var(--accent-green);
outline-offset: 2px;
}
/* --- PRINT STYLESHEET --- */
@media print {
body {
background-color: white !important;
color: black !important;
display: block;
}
#sidebar,
#mobile-toc-sidebar,
header,
#theme-toggle-desktop,
#scroll-top-btn,
#reading-progress-bar,
#overlay,
.copy-btn,
.toc-container {
display: none !important;
}
.markdown-body,
#scroll-container,
.flex-1,
.h-screen {
height: auto !important;
overflow: visible !important;
width: 100% !important;
margin: 0 !important;
padding: 0 !important;
max-width: 100% !important;
}
.markdown-body pre {
border: 1px solid #ccc;
background-color: #f5f5f5 !important;
page-break-inside: avoid;
}
.markdown-body a {
color: black !important;
text-decoration: underline;
border: none;
}
#breadcrumbs {
color: black !important;
border-bottom: 1px solid #ccc;
padding-bottom: 10px;
margin-bottom: 20px;
}
}
</style>
<script>
tailwind.config = {
darkMode: 'class',
theme: {
extend: {
colors: {
hack: {
green: 'var(--accent-green)',
bg: 'var(--bg-body)',
sidebar: 'var(--bg-sidebar)',
border: 'var(--border-color)'
}
},
fontFamily: {
mono: ['"JetBrains Mono"', 'monospace'],
sans: ['"Inter"', 'sans-serif'],
}
}
}
}
</script>
</head>
<body class="font-sans antialiased h-screen flex flex-col md:flex-row overflow-hidden transition-colors duration-300">
<div id="reading-progress-bar"></div>
<!-- Lightbox -->
<div id="lightbox"
class="fixed inset-0 z-[100] bg-black/90 backdrop-blur-sm flex items-center justify-center opacity-0 invisible cursor-zoom-out"
onclick="closeLightbox()">
<img id="lightbox-img" src="" alt="Full view"
class="rounded-lg border border-hack-border transform scale-95 transition-transform duration-300">
</div>
<!-- Mobile Header -->
<header
class="md:hidden flex-none bg-hack-sidebar border-b border-hack-border h-16 flex items-center justify-between px-4 z-[60] relative shadow-lg">
<div class="flex items-center gap-3">
<div class="w-10 h-10">
<img src="assets/bjorn.png" onerror="this.src='https://placehold.co/40x40/111214/22c55e?text=B'"
alt="Bjorn Icon" class="w-full h-full object-contain drop-shadow-[0_0_5px_rgba(34,197,94,0.3)]">
</div>
<span class="font-bold text-hack-heading tracking-wide flex items-center gap-2">
BJORN <span class="version-display text-hack-green text-[10px] font-mono opacity-80 pt-1">...</span>
</span>
</div>
<div class="flex items-center gap-3">
<button id="theme-toggle-mobile"
class="text-gray-300 dark:text-gray-200 hover:text-hack-green p-2 transition-colors">
<i data-lucide="sun" class="w-5 h-5 hidden dark:block"></i>
<i data-lucide="moon" class="w-5 h-5 block dark:hidden"></i>
</button>
<button id="menu-btn"
class="flex items-center gap-2 px-3 py-1.5 rounded bg-hack-bg border border-hack-border text-hack-heading active:bg-hack-greenDim transition-colors">
<span class="text-xs font-mono font-bold uppercase tracking-wider">Menu</span>
<i data-lucide="menu" class="w-5 h-5 text-hack-green"></i>
</button>
</div>
</header>
<!-- Overlay -->
<div id="overlay"
class="fixed inset-0 bg-black/80 backdrop-blur-[2px] z-[65] hidden transition-opacity opacity-0 md:hidden">
</div>
<!-- Mobile Sidebar (Left - Menu) -->
<aside id="sidebar"
class="sidebar-mobile fixed top-0 bottom-0 left-0 z-[70] w-[280px] bg-hack-sidebar border-r border-hack-border transform -translate-x-full md:translate-x-0 md:static md:h-full flex flex-col shadow-2xl md:shadow-none transition-transform duration-300">
<div class="p-5 border-b border-hack-border relative flex-none">
<button id="close-sidebar-btn" class="md:hidden absolute top-4 right-4 text-gray-400 hover:text-white">
<i data-lucide="x" class="w-6 h-6"></i>
</button>
<div class="flex items-center justify-between mb-4">
<div class="flex items-center gap-3">
<div class="w-12 h-12 shrink-0">
<img src="assets/bjorn.png" onerror="this.src='https://placehold.co/48x48/111214/22c55e?text=B'"
alt="Bjorn Icon"
class="w-full h-full object-contain drop-shadow-[0_0_8px_rgba(34,197,94,0.4)]">
</div>
<div>
<h1 class="font-bold text-hack-heading leading-none text-lg">BJORN</h1>
<p class="version-display text-[10px] text-gray-500 font-mono mt-1 flex items-center gap-2">
<span class="version-loading">checking...</span>
</p>
</div>
</div>
</div>
<div class="mt-4 relative group">
<i data-lucide="search" class="absolute left-2.5 top-2.5 w-4 h-4 text-gray-500"></i>
<input type="text" id="search-input" placeholder="Search (Ctrl+K)..."
class="w-full border rounded py-1.5 pl-8 pr-8 text-base md:text-sm shadow-sm transition-colors appearance-none">
<button id="search-clear"
class="absolute right-0 top-0 bottom-0 px-2 flex items-center justify-center text-hack-green hover:text-white transition-all z-20 cursor-pointer opacity-0 pointer-events-none"
title="Clear search (Esc)">
<i data-lucide="x" class="w-3 h-3"></i>
</button>
</div>
</div>
<div class="flex-1 overflow-y-auto flex flex-col" role="navigation" aria-label="Main Navigation">
<div class="px-3 pt-4">
<div class="nav-group mb-1">
<button id="btn-versions"
class="w-full text-left px-2 py-2 flex items-center justify-between text-gray-500 hover:text-hack-heading transition-colors cursor-pointer group rounded hover:bg-hack-bg focus:bg-hack-bg outline-none"
onclick="toggleVersionsPage(this)" tabindex="0">
<div
class="flex items-center gap-2 text-[11px] font-bold uppercase tracking-widest font-mono text-hack-green">
<i data-lucide="history" class="w-3 h-3"></i> Changelog
</div>
<i data-lucide="chevron-right"
class="w-3 h-3 opacity-0 group-hover:opacity-100 transition-opacity"></i>
</button>
</div>
</div>
<nav class="pb-4 px-3 space-y-2 flex-none" id="nav-container">
<div class="text-center text-xs text-gray-500 mt-4 p-4">
<div
class="w-4 h-4 border-2 border-hack-green border-t-transparent rounded-full animate-spin mx-auto mb-2">
</div>
Initializing...
</div>
</nav>
<div id="search-results-msg" class="hidden px-4 py-2 text-xs text-red-400 text-center italic">
No results found.
</div>
</div>
<div class="p-4 border-t border-hack-border bg-hack-bg/30 flex-none">
<div class="text-center">
<strong class="text-gray-500 text-sm block mb-3 font-mono tracking-wide">:: JOIN US ::</strong>
<div class="flex flex-col gap-2.5">
<a href="https://discord.gg/B3ZH9taVfT" target="_blank"
class="hover:opacity-80 transition-opacity block">
<img src="https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fdiscord.com%2Fapi%2Finvites%2FB3ZH9taVfT%3Fwith_counts%3Dtrue&query=%24.approximate_member_count&logo=discord&logoColor=white&style=for-the-badge&label=BJORN&color=5865F2&labelColor=2A2E35"
alt="Discord BJORN" class="w-full" />
</a>
<a href="https://www.reddit.com/r/Bjorn_CyberViking/" target="_blank"
class="hover:opacity-80 transition-opacity block">
<img src="https://img.shields.io/reddit/subreddit-subscribers/Bjorn_CyberViking?style=for-the-badge&logo=reddit&label=r/Bjorn&color=FF4500&labelColor=2A2E35&logoColor=white"
alt="Reddit" class="w-full" />
</a>
<a href="https://github.com/infinition/Bjorn" target="_blank"
class="hover:opacity-80 transition-opacity block">
<img src="https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fapi.github.com%2Frepos%2Finfinition%2FBjorn&query=%24.stargazers_count&style=for-the-badge&logo=github&color=0B0C0E&labelColor=2A2E35&label=BJORN&logoColor=white"
alt="GitHub BJORN" class="w-full" />
</a>
<a href="https://buymeacoffee.com/infinition" target="_blank"
class="hover:opacity-80 transition-opacity block pt-2 border-t border-hack-border/30 mt-1">
<img src="https://img.shields.io/badge/Buy%20Me%20a%20Coffee-ffdd00?style=for-the-badge&logo=buy-me-a-coffee&logoColor=black"
alt="Buy Me A Coffee" class="w-full" />
</a>
</div>
</div>
</div>
</aside>
<!-- Mobile Sidebar (Right - TOC) -->
<aside id="mobile-toc-sidebar"
class="sidebar-mobile fixed top-0 bottom-0 right-0 z-[70] w-[280px] bg-hack-sidebar border-l border-hack-border transform translate-x-full md:hidden flex flex-col shadow-2xl transition-transform duration-300">
<div class="p-4 border-b border-hack-border flex justify-between items-center bg-hack-bg/50">
<span class="text-xs font-bold text-gray-500 uppercase tracking-widest font-mono">Table of Contents</span>
<button id="close-toc-btn" class="text-gray-400 hover:text-white">
<i data-lucide="x" class="w-5 h-5"></i>
</button>
</div>
<div class="flex-1 overflow-y-auto p-4">
<ul id="mobile-toc-list" class="space-y-3 text-sm"></ul>
</div>
</aside>
<div class="flex-1 flex flex-col h-full overflow-hidden relative">
<main class="flex-1 overflow-y-auto bg-hack-bg scroll-smooth relative w-full" id="scroll-container">
<div class="max-w-6xl mx-auto px-5 py-8 md:py-12 md:px-10 flex gap-10">
<div class="flex-1 min-w-0">
<div
class="flex flex-col md:flex-row md:items-center justify-between mb-6 gap-2 border-b border-hack-border/50 pb-4">
<div class="flex items-center gap-2 text-xs font-mono text-gray-500 overflow-x-auto whitespace-nowrap pb-2 md:pb-0"
id="breadcrumbs"></div>
<div class="flex items-center gap-4">
<div
class="flex items-center gap-4 text-[10px] font-mono text-gray-500 uppercase tracking-wider shrink-0">
<span id="reading-time" class="hidden md:block"></span>
<span id="last-updated" class="hidden md:block text-hack-green opacity-80"></span>
</div>
<button id="theme-toggle-desktop"
class="text-gray-500 hover:text-hack-green transition-colors hidden md:block bg-hack-sidebar border border-hack-border rounded p-1.5"
title="Toggle Theme">
<i data-lucide="sun" class="w-4 h-4 hidden dark:block"></i>
<i data-lucide="moon" class="w-4 h-4 block dark:hidden"></i>
</button>
</div>
</div>
<div id="markdown-viewer" class="markdown-body min-h-[50vh]"></div>
<div id="page-nav"
class="grid grid-cols-1 md:grid-cols-2 gap-4 mt-12 pt-8 border-t border-hack-border empty:hidden">
</div>
<div class="h-20"></div>
</div>
<aside class="hidden xl:block w-64 shrink-0">
<div class="sticky top-6">
<h4
class="text-xs font-bold text-gray-500 uppercase tracking-widest mb-4 border-b border-hack-border pb-2">
On this page</h4>
<ul id="toc-container" class="space-y-2 text-xs border-l border-hack-border pl-3"></ul>
</div>
</aside>
</div>
</main>
<button id="scroll-top-btn"
class="absolute bottom-6 right-6 p-3 rounded-full bg-hack-greenDim border border-hack-border text-hack-green hover:bg-hack-green hover:text-white shadow-lg hidden z-30"
title="Back to Top">
<i data-lucide="arrow-up" class="w-5 h-5"></i>
</button>
</div>
<script>
// --- 1. CONFIG & STATE ---
const renderer = new marked.Renderer();
// --- FIXED IMAGE RENDERER (Handles Marked 5.0+ Object Token) ---
renderer.image = function (href, title, text) {
if (typeof href === 'object' && href !== null) {
const token = href;
href = token.href;
title = token.title;
text = token.text;
}
return `<img src="${href}" alt="${text || ''}" title="${title || ''}" loading="lazy" class="rounded-lg border border-hack-border bg-black/20">`;
};
renderer.table = function (header, body) {
return `<div class="overflow-x-auto my-4 border border-hack-border rounded-lg"><table class="w-full text-left text-sm border-collapse"><thead>${header}</thead><tbody>${body}</tbody></table></div>`;
};
marked.use({ renderer });
marked.setOptions({ breaks: true, gfm: true });
const STATE = {
wikiData: {},
contentCache: {},
searchIndex: [],
expandedSections: new Set(),
repo: "infinition/Bjorn", // YOUR REPO
branch: "wiki"
};
// --- 2. THEME ENGINE ---
function initTheme() {
const savedTheme = localStorage.getItem('theme') || 'dark';
const html = document.documentElement;
if (savedTheme === 'light') {
html.classList.remove('dark');
html.classList.add('light');
} else {
html.classList.add('dark');
html.classList.remove('light');
}
}
function toggleTheme() {
const html = document.documentElement;
const isDark = html.classList.contains('dark');
if (isDark) {
html.classList.remove('dark');
html.classList.add('light');
localStorage.setItem('theme', 'light');
} else {
html.classList.remove('light');
html.classList.add('dark');
localStorage.setItem('theme', 'dark');
}
lucide.createIcons();
}
document.getElementById('theme-toggle-desktop').onclick = toggleTheme;
document.getElementById('theme-toggle-mobile').onclick = toggleTheme;
initTheme();
// --- 3. FETCH DATA (GitHub API) ---
async function fetchLatestVersion() {
const CACHE_KEY = 'bjorn_ver_data';
const now = Date.now();
const ONE_HOUR = 3600 * 1000;
const cached = localStorage.getItem(CACHE_KEY);
if (cached) {
const { timestamp, version } = JSON.parse(cached);
if (now - timestamp < ONE_HOUR) {
updateVersionUI(version, true);
return;
}
}
try {
let ver = "v2.0";
let res = await fetch(`https://api.github.com/repos/${STATE.repo}/releases/latest`);
if (res.ok) {
const data = await res.json();
ver = data.tag_name;
} else {
res = await fetch(`https://api.github.com/repos/${STATE.repo}/tags`);
if (res.ok) {
const tags = await res.json();
if (tags.length > 0) ver = tags[0].name;
}
}
localStorage.setItem(CACHE_KEY, JSON.stringify({ timestamp: now, version: ver }));
updateVersionUI(ver, true);
} catch (e) {
console.warn("GitHub API Limit or Network Error", e);
if (cached) updateVersionUI(JSON.parse(cached).version, true);
else updateVersionUI("v2.0", false);
}
}
function updateVersionUI(ver, success) {
document.querySelectorAll('.version-display').forEach(el => {
el.innerHTML = ver;
if (success) el.classList.add('text-hack-green');
});
}
// --- 4. WIKI CORE ---
async function initWiki() {
try {
const res = await fetch('./wiki/structure.json');
if (!res.ok) {
if (res.status === 404) throw new Error("File not found: wiki/structure.json");
throw new Error("Config missing");
}
STATE.wikiData = await res.json();
const firstFolder = Object.keys(STATE.wikiData)[0];
if (firstFolder) STATE.expandedSections.add(firstFolder);
renderSidebar();
buildSearchIndex();
window.onpopstate = (event) => {
if (event.state) {
if (event.state.page === 'versions') toggleVersionsPage(null, false);
else loadContent(event.state.folder, event.state.title, event.state.filename, false);
} else {
handleInitialRoute();
}
};
handleInitialRoute();
} catch (e) {
console.error(e);
showErrorState(e.message);
}
}
function handleInitialRoute() {
const urlParams = new URLSearchParams(window.location.search);
const pageParam = urlParams.get('page');
if (pageParam === 'changelog') {
toggleVersionsPage(null, false);
} else if (pageParam) {
let found = false;
for (const [folder, files] of Object.entries(STATE.wikiData)) {
for (const [title, file] of Object.entries(files)) {
if (`${folder}/${file}` === pageParam || file === pageParam) {
loadContent(folder, title, file, false);
STATE.expandedSections.add(folder);
renderSidebar();
found = true;
break;
}
}
if (found) break;
}
if (!found) loadDefault();
} else {
loadDefault();
}
}
async function buildSearchIndex() {
const promises = [];
for (const [folder, files] of Object.entries(STATE.wikiData)) {
for (const [title, filename] of Object.entries(files)) {
if (!filename.endsWith('.md')) continue;
promises.push(
fetch(`./wiki/${folder}/${filename}`)
.then(res => { if (!res.ok) return ''; return res.text(); })
.then(text => {
if (!text) return;
STATE.searchIndex.push({
folder, title, filename,
content: text.toLowerCase(),
titleLower: title.toLowerCase()
});
})
.catch(err => console.log("Indexing skip:", filename))
);
}
}
await Promise.all(promises);
}
function loadDefault() {
const folders = Object.keys(STATE.wikiData);
if (folders.length === 0) return;
const firstFolder = folders[0];
const files = Object.keys(STATE.wikiData[firstFolder]);
if (files.length === 0) return;
const firstTitle = files[0];
loadContent(firstFolder, firstTitle, STATE.wikiData[firstFolder][firstTitle], true);
}
function showErrorState(msg) {
document.getElementById('nav-container').innerHTML = `
<div class="p-4 text-xs text-red-400 border border-red-900 bg-red-900/10 rounded m-2 font-mono">
<strong>[!] CONFIG ERROR</strong><br>${msg}
</div>`;
document.getElementById('markdown-viewer').innerHTML = `
<h1>Wiki Setup Guide</h1>
<p>Welcome to your new Wiki Node. To enable content, please ensure your repository structure matches the config.</p>
`;
hljs.highlightAll();
}
// --- 5. CONTENT LOADER ---
async function loadContent(folder, title, filename, pushHistory = true) {
const viewer = document.getElementById('markdown-viewer');
const pageNav = document.getElementById('page-nav');
const scrollContainer = document.getElementById('scroll-container');
const cleanFolder = folder.replace(/^\d+_/, '').replace(/_/g, ' ');
document.getElementById('breadcrumbs').innerHTML = `
<span class="hover:text-hack-heading cursor-pointer" onclick="loadDefault()">wiki</span> <span>/</span>
<span>${cleanFolder}</span> <span>/</span> <span class="text-hack-green font-bold">${title}</span>
`;
if (!STATE.contentCache[`${folder}/${filename}`]) {
viewer.innerHTML = `<div class="animate-pulse space-y-4 pt-10"><div class="h-8 bg-gray-800 rounded w-1/3 mb-6"></div><div class="h-4 bg-gray-800 rounded w-full"></div><div class="h-4 bg-gray-800 rounded w-5/6"></div><div class="h-4 bg-gray-800 rounded w-4/6"></div></div>`;
}
pageNav.innerHTML = '';
document.getElementById('reading-time').innerText = '';
document.getElementById('last-updated').innerText = '';
try {
let text;
const cacheKey = `${folder}/${filename}`;
if (STATE.contentCache[cacheKey]) {
text = STATE.contentCache[cacheKey];
} else {
try {
const path1 = `./wiki/${folder}/${filename}`;
const res = await fetch(path1);
if (res.ok) {
text = await res.text();
} else {
throw new Error("404");
}
} catch (e) {
const path2 = `./wiki/${filename}`;
const res2 = await fetch(path2);
if (res2.ok) {
text = await res2.text();
} else {
throw new Error(`Content not found.`);
}
}
STATE.contentCache[cacheKey] = text;
}
const cleanHTML = DOMPurify.sanitize(marked.parse(text));
viewer.innerHTML = cleanHTML;
const wordCount = text.replace(/[#*`]/g, '').split(/\s+/).length;
document.getElementById('reading-time').textContent = `~${Math.ceil(wordCount / 200)} min read`;
fetchLastUpdated(folder, filename);
enhanceMarkdownContent();
generateTOC();
renderPagination(folder, title);
if (pushHistory) {
const newUrl = `?page=${folder}/${filename}`;
window.history.pushState({ folder, title, filename }, "", newUrl);
}
STATE.expandedSections = new Set([folder]);
renderSidebar();
if (window.location.hash) {
const el = document.getElementById(window.location.hash.substring(1));
if (el) el.scrollIntoView();
} else {
scrollContainer.scrollTop = 0;
}
if (window.innerWidth < 768) closeMenu();
preloadAdjacent(folder, title);
} catch (e) {
console.error("Load failed", e);
const cleanError = e.message.replace(/\n/g, '<br>');
viewer.innerHTML = `
<h1 class="text-red-500 mb-2">Page Not Found</h1>
<div class="p-4 bg-red-900/20 border border-red-900 rounded text-sm font-mono text-red-300">
<strong>Error Details:</strong><br>${cleanError}
</div>`;
document.getElementById('breadcrumbs').innerHTML = `<span class="hover:text-hack-heading cursor-pointer" onclick="loadDefault()">wiki</span> <span>/</span> <span class="text-red-500">Error</span>`;
}
}
async function fetchLastUpdated(folder, filename) {
const CACHE_KEY = `bjorn_upd_${folder}_${filename}`;
const now = Date.now();
const cached = localStorage.getItem(CACHE_KEY);
if (cached) {
const data = JSON.parse(cached);
if (now - data.ts < 86400000) {
document.getElementById('last-updated').textContent = `Updated: ${data.date}`;
return;
}
}
try {
const path = `wiki/${folder}/${filename}`;
const res = await fetch(`https://api.github.com/repos/${STATE.repo}/commits?path=${path}&page=1&per_page=1`);
if (res.ok) {
const data = await res.json();
if (data.length > 0) {
const date = new Date(data[0].commit.committer.date).toLocaleDateString();
document.getElementById('last-updated').textContent = `Updated: ${date}`;
localStorage.setItem(CACHE_KEY, JSON.stringify({ ts: now, date }));
}
}
} catch (e) { }
}
function preloadAdjacent(currentFolder, currentTitle) {
let flatList = [];
for (const [folder, files] of Object.entries(STATE.wikiData)) {
for (const [title, file] of Object.entries(files)) {
flatList.push({ folder, title, file });
}
}
const idx = flatList.findIndex(item => item.folder === currentFolder && item.title === currentTitle);
[idx - 1, idx + 1].forEach(i => {
if (flatList[i]) {
const url = `./wiki/${flatList[i].folder}/${flatList[i].file}`;
fetch(url).then(r => r.text()).then(t => {
STATE.contentCache[`${flatList[i].folder}/${flatList[i].file}`] = t;
}).catch(() => { });
}
});
}
function enhanceMarkdownContent() {
document.querySelectorAll('#markdown-viewer h2, #markdown-viewer h3').forEach(h => {
if (!h.id) h.id = h.textContent.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '');
const anchor = document.createElement('a');
anchor.className = 'anchor-link';
anchor.innerHTML = '#';
anchor.onclick = (e) => {
e.preventDefault();
navigator.clipboard.writeText(window.location.href.split('#')[0] + '#' + h.id);
anchor.innerHTML = 'copied!';
setTimeout(() => anchor.innerHTML = '#', 1500);
history.replaceState(null, null, `#${h.id}`);
};
h.appendChild(anchor);
});
document.querySelectorAll('#markdown-viewer a[href^="#"]').forEach(link => {
if (link.classList.contains('anchor-link')) return;
link.onclick = (e) => {
e.preventDefault();
closeTOC(); // Close mobile TOC if open
const hash = link.getAttribute('href').substring(1);
let target = document.getElementById(hash);
if (!target) {
const cleanHash = hash.replace(/^-+/, '');
target = document.getElementById(cleanHash);
}
if (target) {
target.scrollIntoView({ behavior: 'smooth' });
history.pushState(null, null, `#${hash}`);
}
};
});
document.querySelectorAll('pre code').forEach((el) => hljs.highlightElement(el));
document.querySelectorAll('pre').forEach(pre => {
if (pre.querySelector('.copy-btn')) return;
const btn = document.createElement('button');
btn.className = 'copy-btn';
btn.textContent = 'Copy';
btn.onclick = () => {
navigator.clipboard.writeText(pre.querySelector('code').innerText);
btn.textContent = 'Copied!';
btn.style.color = 'var(--accent-green)';
setTimeout(() => { btn.textContent = 'Copy'; btn.style.color = ''; }, 2000);
}
pre.appendChild(btn);
});
document.querySelectorAll('#markdown-viewer img').forEach(img => {
img.onclick = () => openLightbox(img.src);
});
document.querySelectorAll('#markdown-viewer a').forEach(a => {
if (
a.hostname !== window.location.hostname &&
!a.classList.contains('anchor-link') &&
a.getAttribute('href') &&
!a.getAttribute('href').startsWith('#') &&
!a.querySelector('img')
) {
a.target = '_blank';
a.rel = 'noopener noreferrer';
a.innerHTML +=
' <svg xmlns="http://www.w3.org/2000/svg" width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="inline-block opacity-50"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"></path><polyline points="15 3 21 3 21 9"></polyline><line x1="10" y1="14" x2="21" y2="3"></line></svg>';
}
});
}
function renderPagination(currentFolder, currentTitle) {
const navContainer = document.getElementById('page-nav');
let flatList = [];
for (const [folder, files] of Object.entries(STATE.wikiData)) {
for (const [title, file] of Object.entries(files)) {
flatList.push({ folder, title, file });
}
}
const idx = flatList.findIndex(item => item.folder === currentFolder && item.title === currentTitle);
if (idx === -1) return;
if (idx > 0) {
const prev = flatList[idx - 1];
const btn = document.createElement('button');
btn.className = "text-left p-4 rounded border border-hack-border hover:border-hack-green group transition-colors bg-hack-sidebar/50 hover:bg-hack-sidebar w-full flex flex-col items-start";
btn.innerHTML = `<span class="text-[10px] text-gray-500 uppercase tracking-widest mb-1 group-hover:text-hack-green">Previous</span><span class="font-bold text-sm truncate text-hack-heading w-full">« ${prev.title}</span>`;
btn.onclick = () => loadContent(prev.folder, prev.title, prev.file);
navContainer.appendChild(btn);
} else { navContainer.appendChild(document.createElement('div')); }
if (idx < flatList.length - 1) {
const next = flatList[idx + 1];
const btn = document.createElement('button');
btn.className = "text-right p-4 rounded border border-hack-border hover:border-hack-green group transition-colors bg-hack-sidebar/50 hover:bg-hack-sidebar w-full flex flex-col items-end";
btn.innerHTML = `<span class="text-[10px] text-gray-500 uppercase tracking-widest mb-1 group-hover:text-hack-green">Next</span><span class="font-bold text-sm truncate text-hack-heading w-full">${next.title} »</span>`;
btn.onclick = () => loadContent(next.folder, next.title, next.file);
navContainer.appendChild(btn);
}
}
function generateTOC() {
const desktopToc = document.getElementById('toc-container');
const mobileToc = document.getElementById('mobile-toc-list');
const headings = document.querySelectorAll('#markdown-viewer h2, #markdown-viewer h3');
const createTOCItem = (h, isMobile) => {
const li = document.createElement('li');
const link = document.createElement('a');
link.textContent = h.childNodes[0].textContent;
link.href = `#${h.id}`;
// Different styling for desktop vs mobile
if (isMobile) {
link.className = `block py-2 text-gray-400 hover:text-hack-green border-l-2 border-transparent pl-3 transition-colors ${h.tagName === 'H3' ? 'ml-4 text-xs' : 'font-medium'}`;
} else {
link.className = `toc-link block py-1 pl-2 border-l-2 border-transparent hover:text-hack-green transition-colors truncate ${h.tagName === 'H3' ? 'ml-3 opacity-80' : ''}`;
}
link.onclick = (e) => {
e.preventDefault();
if (isMobile) closeTOC();
document.getElementById(h.id).scrollIntoView({ behavior: 'smooth' });
history.pushState(null, null, `#${h.id}`);
};
li.appendChild(link);
return li;
};
// Reset
desktopToc.innerHTML = '';
mobileToc.innerHTML = '';
if (headings.length === 0) {
desktopToc.innerHTML = '<li class="text-gray-600 italic">No sections</li>';
mobileToc.innerHTML = '<li class="text-gray-600 italic text-center py-4">No sections available for this page.</li>';
return;
}
headings.forEach(h => {
desktopToc.appendChild(createTOCItem(h, false));
mobileToc.appendChild(createTOCItem(h, true));
});
}
// --- 6. SIDEBAR ---
function renderSidebar(searchResults = null) {
const container = document.getElementById('nav-container');
const noResults = document.getElementById('search-results-msg');
container.innerHTML = '';
let hasContent = false;
Object.keys(STATE.wikiData).forEach(folder => {
if (searchResults && !searchResults[folder]) return;
const cleanName = folder.replace(/^\d+_/, '').replace(/_/g, ' ');
const group = document.createElement('div');
group.className = 'nav-group mb-1';
const btn = document.createElement('button');
const isExpanded = STATE.expandedSections.has(folder) || searchResults;
btn.className = `section-header w-full flex items-center justify-between px-2 py-2 text-gray-500 hover:text-hack-heading transition-colors rounded hover:bg-hack-bg focus:outline-none focus:bg-hack-bg ${isExpanded ? 'active' : ''}`;
btn.innerHTML = `
<span class="flex items-center gap-2 text-[11px] font-bold uppercase tracking-widest font-mono"><i data-lucide="folder" class="w-3 h-3"></i> ${cleanName}</span>
<i data-lucide="chevron-right" class="section-arrow w-3 h-3 transition-transform"></i>
`;
btn.onclick = () => {
if (STATE.expandedSections.has(folder)) STATE.expandedSections.delete(folder);
else STATE.expandedSections.add(folder);
renderSidebar(searchResults);
};
const list = document.createElement('div');
list.className = `nav-list pl-2 border-l border-hack-border/30 ml-1 ${isExpanded ? 'expanded' : 'collapsed'}`;
Object.keys(STATE.wikiData[folder]).forEach(title => {
if (searchResults && !searchResults[folder][title]) return;
hasContent = true;
const filename = STATE.wikiData[folder][title];
const link = document.createElement('a');
link.href = `?page=${folder}/${filename}`;
link.className = 'nav-link group flex items-center gap-3 px-2 py-1.5 rounded-md text-sm font-medium text-gray-400 hover:text-hack-heading hover:bg-hack-bg transition-all outline-none focus:bg-hack-bg';
link.innerHTML = `<span class="w-1.5 h-1.5 rounded-full bg-hack-border group-hover:bg-hack-green flex-shrink-0 transition-colors"></span> ${title}`;
link.onclick = (e) => {
e.preventDefault();
loadContent(folder, title, filename);
document.querySelectorAll('.nav-link').forEach(l => l.classList.remove('active'));
link.classList.add('active');
};
list.appendChild(link);
});
group.appendChild(btn);
group.appendChild(list);
container.appendChild(group);
});
if (!hasContent && searchResults) noResults.classList.remove('hidden');
else noResults.classList.add('hidden');
lucide.createIcons();
}
// --- 7. SEARCH ---
const searchInput = document.getElementById('search-input');
const searchClear = document.getElementById('search-clear');
let debounceTimeout;
searchInput.addEventListener('input', (e) => {
clearTimeout(debounceTimeout);
debounceTimeout = setTimeout(() => {
const q = e.target.value.toLowerCase();
if (!q) {
renderSidebar();
searchClear.classList.add('opacity-0', 'pointer-events-none');
return;
}
searchClear.classList.remove('opacity-0', 'pointer-events-none');
const results = {};
if (STATE.searchIndex.length > 0) {
STATE.searchIndex.forEach(item => {
if (item.titleLower.includes(q) || item.content.includes(q)) {
if (!results[item.folder]) results[item.folder] = {};
results[item.folder][item.title] = item.filename;
}
});
} else {
for (const [folder, files] of Object.entries(STATE.wikiData)) {
for (const [title, file] of Object.entries(files)) {
if (title.toLowerCase().includes(q)) {
if (!results[folder]) results[folder] = {};
results[folder][title] = file;
}
}
}
}
renderSidebar(results);
}, 300);
});
searchClear.onclick = () => {
searchInput.value = '';
searchInput.dispatchEvent(new Event('input'));
searchInput.focus();
};
// --- 8. CHANGELOG ---
async function toggleVersionsPage(btn, pushHistory = true) {
const viewer = document.getElementById('markdown-viewer');
document.getElementById('breadcrumbs').innerHTML = `<span class="hover:text-hack-heading cursor-pointer" onclick="loadDefault()">Bjorn</span> <span>/</span> <span class="text-hack-green font-bold">Changelog</span>`;
document.getElementById('page-nav').innerHTML = '';
document.getElementById('reading-time').innerHTML = '';
document.getElementById('last-updated').innerHTML = '';
viewer.innerHTML = `<h1>Changelog</h1><div id="versions-list" class="space-y-4 mt-6"><div class="animate-pulse text-hack-green">Fetching GitHub releases...</div></div>`;
if (pushHistory) window.history.pushState({ page: 'versions' }, "", "?page=changelog");
try {
const res = await fetch(`https://api.github.com/repos/${STATE.repo}/releases`);
if (!res.ok) throw new Error("API Limit");
const data = await res.json();
const list = document.getElementById('versions-list');
list.innerHTML = '';
if (data.length === 0) list.innerHTML = '<p>No releases found.</p>';
data.forEach(r => {
const div = document.createElement('div');
div.className = 'border-b border-hack-border pb-4 mb-4';
const body = DOMPurify.sanitize(marked.parse(r.body || 'No description'));
div.innerHTML = `
<div class="flex items-center justify-between mb-2">
<h2 class="text-xl font-bold !mt-0 !mb-0 flex items-center gap-2 text-hack-green">${r.tag_name}</h2>
<span class="text-xs font-mono text-gray-500">${new Date(r.published_at).toLocaleDateString()}</span>
</div>
<div class="markdown-body text-sm pl-2 border-l-2 border-hack-border/50">${body}</div>
<a href="${r.html_url}" target="_blank" class="text-xs mt-2 inline-block opacity-50 hover:opacity-100">View on GitHub &rarr;</a>
`;
list.appendChild(div);
});
} catch (e) {
document.getElementById('versions-list').innerHTML = `<p class="text-red-400">Unable to load changelog. Check back later.</p>`;
}
}
// --- 9. UTILS ---
const lightbox = document.getElementById('lightbox');
const lightboxImg = document.getElementById('lightbox-img');
function openLightbox(src) {
lightboxImg.src = src;
lightbox.classList.add('active');
lightboxImg.classList.remove('scale-95');
lightboxImg.classList.add('scale-100');
document.body.style.overflow = 'hidden';
}
function closeLightbox() {
lightbox.classList.remove('active');
lightboxImg.classList.remove('scale-100');
lightboxImg.classList.add('scale-95');
document.body.style.overflow = '';
}
// Mobile Menu & TOC Logic
const sidebar = document.getElementById('sidebar');
const tocSidebar = document.getElementById('mobile-toc-sidebar');
const overlay = document.getElementById('overlay');
function openMenu() {
closeTOC(); // Close TOC if open
sidebar.classList.remove('-translate-x-full');
overlay.classList.remove('hidden');
setTimeout(() => overlay.classList.remove('opacity-0'), 10);
}
function closeMenu() {
sidebar.classList.add('-translate-x-full');
checkOverlay();
}
function openTOC() {
// Only open if TOC has items
const list = document.getElementById('mobile-toc-list');
if (!list.hasChildNodes() || list.innerHTML.includes('No sections')) return;
closeMenu(); // Close Menu if open
tocSidebar.classList.remove('translate-x-full');
overlay.classList.remove('hidden');
setTimeout(() => overlay.classList.remove('opacity-0'), 10);
}
function closeTOC() {
tocSidebar.classList.add('translate-x-full');
checkOverlay();
}
function checkOverlay() {
// Hide overlay only if both panels are closed
if (sidebar.classList.contains('-translate-x-full') && tocSidebar.classList.contains('translate-x-full')) {
overlay.classList.add('opacity-0');
setTimeout(() => overlay.classList.add('hidden'), 300);
}
}
document.getElementById('menu-btn').onclick = openMenu;
document.getElementById('close-sidebar-btn').onclick = closeMenu;
document.getElementById('close-toc-btn').onclick = closeTOC;
// Unified overlay click closes whichever is open
overlay.onclick = () => {
closeMenu();
closeTOC();
};
// Events
document.addEventListener('keydown', (e) => {
if ((e.ctrlKey || e.metaKey) && e.key === 'k') { e.preventDefault(); searchInput.focus(); }
if (e.key === 'Escape') {
if (document.activeElement === searchInput) searchInput.blur();
else if (lightbox.classList.contains('active')) closeLightbox();
else {
closeMenu();
closeTOC();
}
}
});
// Initialize UI Logic (Scroll Bar) immediately
function initUI() {
const scrollContainer = document.getElementById('scroll-container');
const progressBar = document.getElementById('reading-progress-bar');
const scrollBtn = document.getElementById('scroll-top-btn');
if (scrollContainer && progressBar) {
scrollContainer.addEventListener('scroll', () => {
const scrollTop = scrollContainer.scrollTop;
const scrollHeight = scrollContainer.scrollHeight - scrollContainer.clientHeight;
const scrollPercent = (scrollHeight > 0) ? (scrollTop / scrollHeight) * 100 : 0;
progressBar.style.width = scrollPercent + '%';
if (scrollTop > 300) scrollBtn.classList.remove('hidden');
else scrollBtn.classList.add('hidden');
});
}
if (scrollBtn) {
scrollBtn.onclick = () => scrollContainer.scrollTo({ top: 0, behavior: 'smooth' });
}
}
// --- MOBILE SWIPE GESTURES ---
let touchStartX = 0;
let touchStartY = 0;
let touchEndX = 0;
let touchEndY = 0;
const MIN_SWIPE_DISTANCE = 50;
const MAX_VERTICAL_DRIFT = 80;
const EDGE_ZONE_LEFT = 200; // Swipe from left triggers Menu
const EDGE_ZONE_RIGHT = 100; // Distance from right edge to likely trigger TOC
document.addEventListener("touchstart", (e) => {
const t = e.changedTouches[0];
touchStartX = t.clientX;
touchStartY = t.clientY;
});
document.addEventListener("touchmove", (e) => {
const t = e.changedTouches[0];
touchEndX = t.clientX;
touchEndY = t.clientY;
});
document.addEventListener("touchend", () => {
const dx = touchEndX - touchStartX;
const dy = Math.abs(touchEndY - touchStartY);
const screenW = window.innerWidth;
// 1. SWIPE LEFT -> RIGHT (Open Menu or Close TOC)
if (dx > MIN_SWIPE_DISTANCE && dy < MAX_VERTICAL_DRIFT) {
// If TOC is open, close it
if (!tocSidebar.classList.contains('translate-x-full')) {
closeTOC();
}
// Else if swipe starts near left edge, open Menu
else if (touchStartX < EDGE_ZONE_LEFT) {
openMenu();
}
}
// 2. SWIPE RIGHT -> LEFT (Open TOC or Close Menu)
if (dx < -MIN_SWIPE_DISTANCE && dy < MAX_VERTICAL_DRIFT) {
// If Menu is open, close it
if (!sidebar.classList.contains('-translate-x-full')) {
closeMenu();
}
// Else if swipe starts anywhere (or strictly right side if preferred), open TOC
// User asked for "swipe right to left" generally, but preventing conflict with horizontal scroll tables is good
else {
openTOC();
}
}
});
// INIT
window.onload = () => {
initUI();
fetchLatestVersion();
initWiki();
lucide.createIcons();
};
</script>
</body>
</html>