mirror of
https://github.com/infinition/Bjorn.git
synced 2025-12-13 16:14:57 +00:00
1482 lines
60 KiB
HTML
1482 lines
60 KiB
HTML
<!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 →</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> |