Initial commit — migrate existing site

This commit is contained in:
ronin 2026-03-29 17:58:06 +00:00
commit 651d2f8dfc
9 changed files with 2053 additions and 0 deletions

758
admin.html Normal file
View file

@ -0,0 +1,758 @@
<!DOCTYPE html>
<html lang="da">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Admin — Rawand Lorentzen</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;700&family=Syne:wght@400;700;800&family=Inter:wght@300;400;500&display=swap" rel="stylesheet">
<style>
:root {
--bg: #f0f2f5;
--bg2: #e8ebef;
--surface: #ffffff;
--border: #d8dde6;
--border2: #c4ccd8;
--accent: #2563eb;
--accent-h: #1d4ed8;
--accent-lt: #eff6ff;
--text: #1e2530;
--text-mid: #4a5568;
--text-dim: #8896aa;
--mono: 'JetBrains Mono', monospace;
--sans: 'Syne', sans-serif;
--body: 'Inter', sans-serif;
--danger: #dc2626;
--danger-lt: #fef2f2;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body { background: var(--bg); color: var(--text); font-family: var(--body); min-height: 100vh; }
nav {
position: fixed; top: 0; left: 0; right: 0; z-index: 100;
padding: 0 48px; height: 60px;
display: flex; justify-content: space-between; align-items: center;
background: rgba(240,242,245,0.92); backdrop-filter: blur(16px);
border-bottom: 1px solid var(--border);
}
.nav-logo { font-family: var(--sans); font-size: 16px; font-weight: 800; color: var(--text); }
.nav-logo span { color: var(--accent); }
.nav-right { display: flex; align-items: center; gap: 12px; }
.nav-tag { font-family: var(--mono); font-size: 11px; color: var(--accent); background: var(--accent-lt); padding: 4px 10px; border-radius: 4px; border: 1px solid rgba(37,99,235,0.2); }
.page { padding: 80px 48px 60px; max-width: 960px; margin: 0 auto; }
/* LOGIN */
#loginView { min-height: 100vh; display: flex; align-items: center; justify-content: center; }
.login-card {
background: var(--surface); border: 1px solid var(--border); border-radius: 12px;
padding: 48px; width: 100%; max-width: 400px;
}
.login-eyebrow { font-family: var(--mono); font-size: 11px; color: var(--accent); letter-spacing: 0.15em; text-transform: uppercase; margin-bottom: 12px; }
.login-title { font-family: var(--sans); font-size: 28px; font-weight: 800; letter-spacing: -1px; margin-bottom: 32px; }
label { display: block; font-size: 12px; font-weight: 500; color: var(--text-mid); margin-bottom: 6px; }
input[type="password"], input[type="text"], input[type="url"], textarea, select {
width: 100%; padding: 10px 14px; font-family: var(--body); font-size: 14px;
background: var(--bg); border: 1.5px solid var(--border); border-radius: 8px;
color: var(--text); outline: none; transition: border-color 0.15s;
}
input[type="password"]:focus, input[type="text"]:focus, input[type="url"]:focus, textarea:focus, select:focus {
border-color: var(--accent);
}
textarea { resize: vertical; font-family: var(--mono); font-size: 13px; line-height: 1.7; min-height: 280px; }
.btn {
padding: 10px 20px; font-family: var(--body); font-size: 14px; font-weight: 500;
border-radius: 8px; border: 1.5px solid transparent; cursor: pointer; transition: all 0.15s;
display: inline-flex; align-items: center; gap: 8px; text-decoration: none;
}
.btn-primary { background: var(--accent); color: white; border-color: var(--accent); }
.btn-primary:hover { background: var(--accent-h); }
.btn-primary:disabled { opacity: 0.5; cursor: not-allowed; }
.btn-ghost { background: var(--surface); color: var(--text-mid); border-color: var(--border); }
.btn-ghost:hover { border-color: var(--accent); color: var(--accent); }
.btn-danger { background: var(--danger-lt); color: var(--danger); border-color: rgba(220,38,38,0.3); font-size: 12px; padding: 6px 12px; }
.btn-danger:hover { background: var(--danger); color: white; }
.btn-sm { font-size: 12px; padding: 6px 12px; }
.btn-full { width: 100%; justify-content: center; margin-top: 20px; }
.error-msg { font-size: 13px; color: var(--danger); margin-top: 10px; font-family: var(--mono); }
/* ADMIN */
#adminView { display: none; }
.page-header { margin-bottom: 32px; padding-top: 20px; }
.page-eyebrow { font-family: var(--mono); font-size: 11px; color: var(--accent); letter-spacing: 0.15em; text-transform: uppercase; margin-bottom: 8px; }
.page-title { font-family: var(--sans); font-size: 36px; font-weight: 800; letter-spacing: -1px; }
/* Section tabs (top level) */
.sections {
display: flex; gap: 4px; margin-bottom: 32px;
border-bottom: 1px solid var(--border); padding-bottom: 0;
}
.sec-btn {
font-size: 14px; font-weight: 600; padding: 10px 20px; cursor: pointer;
color: var(--text-mid); border-bottom: 2px solid transparent; margin-bottom: -1px;
transition: all 0.15s; background: none; border-top: none; border-left: none; border-right: none;
font-family: var(--sans);
}
.sec-btn.active { color: var(--accent); border-bottom-color: var(--accent); }
.sec-btn:hover:not(.active) { color: var(--text); }
.sec-panel { display: none; }
.sec-panel.active { display: block; }
/* Sub tabs */
.sub-tabs { display: flex; gap: 4px; margin-bottom: 24px; }
.sub-tab {
font-size: 12px; font-weight: 500; padding: 6px 14px; cursor: pointer;
border-radius: 6px; color: var(--text-mid); border: 1px solid transparent;
background: none; font-family: var(--body); transition: all 0.15s;
}
.sub-tab.active { background: var(--accent-lt); color: var(--accent); border-color: rgba(37,99,235,0.2); }
.sub-tab:hover:not(.active) { background: var(--bg2); color: var(--text); }
.sub-tab[style*="none"] { display: none !important; }
.sub-panel { display: none; }
.sub-panel.active { display: block; }
/* List */
.item-list { display: flex; flex-direction: column; gap: 10px; }
.item-row {
background: var(--surface); border: 1px solid var(--border); border-radius: 10px;
padding: 16px 20px; display: flex; align-items: center; gap: 14px;
}
.item-date { font-family: var(--mono); font-size: 11px; color: var(--text-dim); width: 86px; flex-shrink: 0; }
.item-title { font-size: 14px; font-weight: 500; flex: 1; color: var(--text); }
.item-meta { font-family: var(--mono); font-size: 11px; color: var(--text-dim); max-width: 140px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.item-actions { display: flex; gap: 8px; flex-shrink: 0; }
.list-empty { text-align: center; padding: 48px; color: var(--text-dim); font-size: 14px; background: var(--surface); border: 1px solid var(--border); border-radius: 10px; }
.list-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; }
.list-count { font-family: var(--mono); font-size: 11px; color: var(--text-dim); }
/* Editor card */
.editor-card { background: var(--surface); border: 1px solid var(--border); border-radius: 10px; padding: 32px; }
.field { margin-bottom: 20px; }
.field-row { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-bottom: 20px; }
.editor-actions { display: flex; gap: 10px; margin-top: 20px; align-items: center; }
.save-status { font-family: var(--mono); font-size: 11px; color: var(--text-dim); }
.save-status.ok { color: #16a34a; }
.save-status.err { color: var(--danger); }
/* Markdown editor tabs */
.md-tabs { display: flex; gap: 4px; margin-bottom: 8px; }
.md-tab { font-size: 12px; padding: 4px 10px; border-radius: 6px; cursor: pointer; font-family: var(--mono); border: 1px solid transparent; color: var(--text-dim); background: none; }
.md-tab.active { background: var(--accent-lt); color: var(--accent); border-color: rgba(37,99,235,0.2); }
.preview-area {
background: var(--bg); border: 1.5px solid var(--border); border-radius: 8px;
padding: 20px; min-height: 280px; font-size: 15px; line-height: 1.8; color: var(--text-mid);
}
.preview-area h1,.preview-area h2,.preview-area h3 { font-family: var(--sans); color: var(--text); margin: 20px 0 10px; letter-spacing: -0.5px; }
.preview-area h1 { font-size: 26px; } .preview-area h2 { font-size: 20px; } .preview-area h3 { font-size: 17px; }
.preview-area p { margin-bottom: 12px; }
.preview-area code { font-family: var(--mono); font-size: 13px; background: var(--bg2); padding: 2px 6px; border-radius: 4px; color: var(--accent); }
.preview-area pre { background: var(--text); color: #e2e8f0; border-radius: 8px; padding: 20px; overflow-x: auto; margin: 14px 0; }
.preview-area pre code { background: none; color: inherit; padding: 0; }
.preview-area ul,.preview-area ol { padding-left: 24px; margin-bottom: 12px; }
.preview-area li { margin-bottom: 4px; }
.preview-area blockquote { border-left: 3px solid var(--accent); padding-left: 16px; color: var(--text-dim); font-style: italic; margin: 14px 0; }
.preview-area a { color: var(--accent); }
.preview-area hr { border: none; border-top: 1px solid var(--border); margin: 20px 0; }
@media (max-width: 640px) {
nav { padding: 0 20px; }
.page { padding: 80px 20px 40px; }
.login-card { padding: 32px 24px; }
.field-row { grid-template-columns: 1fr; }
.sec-btn { padding: 8px 12px; font-size: 13px; }
}
</style>
</head>
<body>
<nav>
<div class="nav-logo">Rawand<span>.</span></div>
<div class="nav-right">
<span class="nav-tag" id="navTag" style="display:none">// admin</span>
<a href="/" class="btn btn-ghost btn-sm">← Tilbage</a>
<button id="logoutBtn" class="btn btn-ghost btn-sm" style="display:none">Log ud</button>
</div>
</nav>
<!-- LOGIN -->
<div id="loginView" class="page">
<div class="login-card">
<div class="login-eyebrow">Admin</div>
<div class="login-title">Log ind</div>
<div class="field">
<label for="usernameInput">Brugernavn</label>
<input type="text" id="usernameInput" placeholder="brugernavn" autofocus autocomplete="username">
</div>
<div class="field">
<label for="passwordInput">Adgangskode</label>
<input type="password" id="passwordInput" placeholder="••••••••" autocomplete="current-password">
</div>
<div id="loginError" class="error-msg" style="display:none"></div>
<button class="btn btn-primary btn-full" id="loginBtn">Log ind →</button>
</div>
</div>
<!-- ADMIN -->
<div id="adminView" class="page">
<div class="page-header">
<div class="page-eyebrow">// admin panel</div>
<div class="page-title">Administration</div>
</div>
<div class="sections">
<button class="sec-btn active" data-sec="blog">Blog</button>
<button class="sec-btn" data-sec="notes">Noter</button>
<button class="sec-btn" data-sec="projects">Projekter</button>
<button class="sec-btn" data-sec="links">Links</button>
</div>
<!-- BLOG -->
<div class="sec-panel active" id="sec-blog">
<div class="sub-tabs">
<button class="sub-tab active" data-stab="blog-list">Alle indlæg</button>
<button class="sub-tab" data-stab="blog-new">Nyt indlæg</button>
<button class="sub-tab" data-stab="blog-edit" id="blog-edit-tab" style="display:none">Rediger</button>
</div>
<div class="sub-panel active" id="stab-blog-list">
<div class="list-header">
<span class="list-count" id="blog-count"></span>
<button class="btn btn-primary btn-sm" onclick="switchSubTab('blog','new')">+ Nyt indlæg</button>
</div>
<div class="item-list" id="blog-list"></div>
</div>
<div class="sub-panel" id="stab-blog-new">
<div class="editor-card">
<div class="field">
<label>Titel</label>
<input type="text" id="blog-new-title" placeholder="Skriv en titel...">
</div>
<div class="field">
<div class="md-tabs">
<button class="md-tab active" onclick="mdTab(this,'blog-new-content','blog-new-preview','write')">Skriv</button>
<button class="md-tab" onclick="mdTab(this,'blog-new-content','blog-new-preview','preview')">Forhåndsvisning</button>
</div>
<textarea id="blog-new-content" placeholder="Skriv indlæg i Markdown..."></textarea>
<div id="blog-new-preview" class="preview-area" style="display:none"></div>
</div>
<div class="editor-actions">
<button class="btn btn-primary" onclick="createItem('blog')">Publicer</button>
<button class="btn btn-ghost" onclick="clearForm('blog','new'); switchSubTab('blog','list')">Annuller</button>
<span class="save-status" id="blog-new-status"></span>
</div>
</div>
</div>
<div class="sub-panel" id="stab-blog-edit">
<div class="editor-card">
<div class="field">
<label>Titel</label>
<input type="text" id="blog-edit-title">
</div>
<div class="field">
<div class="md-tabs">
<button class="md-tab active" onclick="mdTab(this,'blog-edit-content','blog-edit-preview','write')">Skriv</button>
<button class="md-tab" onclick="mdTab(this,'blog-edit-content','blog-edit-preview','preview')">Forhåndsvisning</button>
</div>
<textarea id="blog-edit-content"></textarea>
<div id="blog-edit-preview" class="preview-area" style="display:none"></div>
</div>
<div class="editor-actions">
<button class="btn btn-primary" onclick="saveEdit('blog')">Gem ændringer</button>
<button class="btn btn-ghost" onclick="switchSubTab('blog','list')">Annuller</button>
<span class="save-status" id="blog-edit-status"></span>
</div>
</div>
</div>
</div>
<!-- NOTES -->
<div class="sec-panel" id="sec-notes">
<div class="sub-tabs">
<button class="sub-tab active" data-stab="notes-list">Alle noter</button>
<button class="sub-tab" data-stab="notes-new">Ny note</button>
<button class="sub-tab" data-stab="notes-edit" id="notes-edit-tab" style="display:none">Rediger</button>
</div>
<div class="sub-panel active" id="stab-notes-list">
<div class="list-header">
<span class="list-count" id="notes-count"></span>
<button class="btn btn-primary btn-sm" onclick="switchSubTab('notes','new')">+ Ny note</button>
</div>
<div class="item-list" id="notes-list"></div>
</div>
<div class="sub-panel" id="stab-notes-new">
<div class="editor-card">
<div class="field">
<label>Titel</label>
<input type="text" id="notes-new-title" placeholder="Noteoverskrift...">
</div>
<div class="field">
<div class="md-tabs">
<button class="md-tab active" onclick="mdTab(this,'notes-new-content','notes-new-preview','write')">Skriv</button>
<button class="md-tab" onclick="mdTab(this,'notes-new-content','notes-new-preview','preview')">Forhåndsvisning</button>
</div>
<textarea id="notes-new-content" placeholder="Skriv note i Markdown..."></textarea>
<div id="notes-new-preview" class="preview-area" style="display:none"></div>
</div>
<div class="editor-actions">
<button class="btn btn-primary" onclick="createItem('notes')">Gem note</button>
<button class="btn btn-ghost" onclick="clearForm('notes','new'); switchSubTab('notes','list')">Annuller</button>
<span class="save-status" id="notes-new-status"></span>
</div>
</div>
</div>
<div class="sub-panel" id="stab-notes-edit">
<div class="editor-card">
<div class="field">
<label>Titel</label>
<input type="text" id="notes-edit-title">
</div>
<div class="field">
<div class="md-tabs">
<button class="md-tab active" onclick="mdTab(this,'notes-edit-content','notes-edit-preview','write')">Skriv</button>
<button class="md-tab" onclick="mdTab(this,'notes-edit-content','notes-edit-preview','preview')">Forhåndsvisning</button>
</div>
<textarea id="notes-edit-content"></textarea>
<div id="notes-edit-preview" class="preview-area" style="display:none"></div>
</div>
<div class="editor-actions">
<button class="btn btn-primary" onclick="saveEdit('notes')">Gem ændringer</button>
<button class="btn btn-ghost" onclick="switchSubTab('notes','list')">Annuller</button>
<span class="save-status" id="notes-edit-status"></span>
</div>
</div>
</div>
</div>
<!-- PROJECTS -->
<div class="sec-panel" id="sec-projects">
<div class="sub-tabs">
<button class="sub-tab active" data-stab="projects-list">Alle projekter</button>
<button class="sub-tab" data-stab="projects-new">Nyt projekt</button>
<button class="sub-tab" data-stab="projects-edit" id="projects-edit-tab" style="display:none">Rediger</button>
</div>
<div class="sub-panel active" id="stab-projects-list">
<div class="list-header">
<span class="list-count" id="projects-count"></span>
<button class="btn btn-primary btn-sm" onclick="switchSubTab('projects','new')">+ Nyt projekt</button>
</div>
<div class="item-list" id="projects-list"></div>
</div>
<div class="sub-panel" id="stab-projects-new">
<div class="editor-card">
<div class="field">
<label>Titel</label>
<input type="text" id="projects-new-title" placeholder="Projektnavn...">
</div>
<div class="field-row">
<div>
<label>URL (valgfri)</label>
<input type="text" id="projects-new-url" placeholder="https://...">
</div>
<div>
<label>Tags (kommaseparerede, valgfri)</label>
<input type="text" id="projects-new-tags" placeholder="react, node, design">
</div>
</div>
<div class="field">
<label>Beskrivelse (Markdown, valgfri)</label>
<div class="md-tabs">
<button class="md-tab active" onclick="mdTab(this,'projects-new-description','projects-new-preview','write')">Skriv</button>
<button class="md-tab" onclick="mdTab(this,'projects-new-description','projects-new-preview','preview')">Forhåndsvisning</button>
</div>
<textarea id="projects-new-description" placeholder="Beskriv projektet..."></textarea>
<div id="projects-new-preview" class="preview-area" style="display:none"></div>
</div>
<div class="editor-actions">
<button class="btn btn-primary" onclick="createItem('projects')">Gem projekt</button>
<button class="btn btn-ghost" onclick="clearForm('projects','new'); switchSubTab('projects','list')">Annuller</button>
<span class="save-status" id="projects-new-status"></span>
</div>
</div>
</div>
<div class="sub-panel" id="stab-projects-edit">
<div class="editor-card">
<div class="field">
<label>Titel</label>
<input type="text" id="projects-edit-title">
</div>
<div class="field-row">
<div>
<label>URL (valgfri)</label>
<input type="text" id="projects-edit-url">
</div>
<div>
<label>Tags (kommaseparerede, valgfri)</label>
<input type="text" id="projects-edit-tags">
</div>
</div>
<div class="field">
<label>Beskrivelse (Markdown, valgfri)</label>
<div class="md-tabs">
<button class="md-tab active" onclick="mdTab(this,'projects-edit-description','projects-edit-preview','write')">Skriv</button>
<button class="md-tab" onclick="mdTab(this,'projects-edit-description','projects-edit-preview','preview')">Forhåndsvisning</button>
</div>
<textarea id="projects-edit-description"></textarea>
<div id="projects-edit-preview" class="preview-area" style="display:none"></div>
</div>
<div class="editor-actions">
<button class="btn btn-primary" onclick="saveEdit('projects')">Gem ændringer</button>
<button class="btn btn-ghost" onclick="switchSubTab('projects','list')">Annuller</button>
<span class="save-status" id="projects-edit-status"></span>
</div>
</div>
</div>
</div>
<!-- LINKS -->
<div class="sec-panel" id="sec-links">
<div class="sub-tabs">
<button class="sub-tab active" data-stab="links-list">Alle links</button>
<button class="sub-tab" data-stab="links-new">Nyt link</button>
<button class="sub-tab" data-stab="links-edit" id="links-edit-tab" style="display:none">Rediger</button>
</div>
<div class="sub-panel active" id="stab-links-list">
<div class="list-header">
<span class="list-count" id="links-count"></span>
<button class="btn btn-primary btn-sm" onclick="switchSubTab('links','new')">+ Nyt link</button>
</div>
<div class="item-list" id="links-list"></div>
</div>
<div class="sub-panel" id="stab-links-new">
<div class="editor-card">
<div class="field">
<label>Titel</label>
<input type="text" id="links-new-title" placeholder="Linkets navn...">
</div>
<div class="field">
<label>URL</label>
<input type="text" id="links-new-url" placeholder="https://...">
</div>
<div class="field-row">
<div>
<label>Kategori (valgfri)</label>
<input type="text" id="links-new-category" placeholder="f.eks. design, tools, inspiration">
</div>
<div>
<label>Beskrivelse (valgfri)</label>
<input type="text" id="links-new-description" placeholder="Kort beskrivelse...">
</div>
</div>
<div class="editor-actions">
<button class="btn btn-primary" onclick="createItem('links')">Gem link</button>
<button class="btn btn-ghost" onclick="clearForm('links','new'); switchSubTab('links','list')">Annuller</button>
<span class="save-status" id="links-new-status"></span>
</div>
</div>
</div>
<div class="sub-panel" id="stab-links-edit">
<div class="editor-card">
<div class="field">
<label>Titel</label>
<input type="text" id="links-edit-title">
</div>
<div class="field">
<label>URL</label>
<input type="text" id="links-edit-url">
</div>
<div class="field-row">
<div>
<label>Kategori (valgfri)</label>
<input type="text" id="links-edit-category">
</div>
<div>
<label>Beskrivelse (valgfri)</label>
<input type="text" id="links-edit-description">
</div>
</div>
<div class="editor-actions">
<button class="btn btn-primary" onclick="saveEdit('links')">Gem ændringer</button>
<button class="btn btn-ghost" onclick="switchSubTab('links','list')">Annuller</button>
<span class="save-status" id="links-edit-status"></span>
</div>
</div>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
<script>
const TOKEN_KEY = 'admin_token';
const editSlugs = {};
function getToken() { return localStorage.getItem(TOKEN_KEY); }
function setToken(t) { localStorage.setItem(TOKEN_KEY, t); }
function clearToken() { localStorage.removeItem(TOKEN_KEY); }
async function api(method, path, body) {
const opts = {
method,
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + (getToken() || '') }
};
if (body) opts.body = JSON.stringify(body);
const res = await fetch('/api' + path, opts);
return { ok: res.ok, status: res.status, data: await res.json() };
}
function showAdmin() {
document.getElementById('loginView').style.display = 'none';
document.getElementById('adminView').style.display = 'block';
document.getElementById('navTag').style.display = 'inline-flex';
document.getElementById('logoutBtn').style.display = 'inline-flex';
loadList('blog');
loadList('notes');
loadList('projects');
loadList('links');
}
function showLogin() {
document.getElementById('loginView').style.display = 'flex';
document.getElementById('adminView').style.display = 'none';
document.getElementById('navTag').style.display = 'none';
document.getElementById('logoutBtn').style.display = 'none';
}
(async () => {
if (getToken()) {
const r = await api('GET', '/blog');
if (r.ok) { showAdmin(); return; }
clearToken();
}
showLogin();
})();
async function login() {
const username = document.getElementById('usernameInput').value;
const pw = document.getElementById('passwordInput').value;
const btn = document.getElementById('loginBtn');
const err = document.getElementById('loginError');
err.style.display = 'none';
btn.disabled = true; btn.textContent = 'Logger ind...';
const r = await api('POST', '/auth/login', { username, password: pw });
if (r.ok) {
setToken(r.data.token);
showAdmin();
} else {
err.textContent = r.data.error || 'Fejl ved login';
err.style.display = 'block';
btn.disabled = false; btn.textContent = 'Log ind →';
}
}
document.getElementById('loginBtn').addEventListener('click', login);
document.getElementById('usernameInput').addEventListener('keydown', e => { if (e.key === 'Enter') document.getElementById('passwordInput').focus(); });
document.getElementById('passwordInput').addEventListener('keydown', e => { if (e.key === 'Enter') login(); });
document.getElementById('logoutBtn').addEventListener('click', async () => {
await api('POST', '/auth/logout');
clearToken(); showLogin();
});
// Section tabs
document.querySelectorAll('.sec-btn').forEach(btn => {
btn.addEventListener('click', () => {
document.querySelectorAll('.sec-btn').forEach(b => b.classList.remove('active'));
document.querySelectorAll('.sec-panel').forEach(p => p.classList.remove('active'));
btn.classList.add('active');
document.getElementById('sec-' + btn.dataset.sec).classList.add('active');
});
});
// Sub tabs
document.querySelectorAll('.sub-tab').forEach(btn => {
btn.addEventListener('click', () => {
const id = btn.dataset.stab;
const [type] = id.split('-');
const container = document.getElementById('sec-' + type);
container.querySelectorAll('.sub-tab').forEach(b => b.classList.remove('active'));
container.querySelectorAll('.sub-panel').forEach(p => p.classList.remove('active'));
btn.classList.add('active');
document.getElementById('stab-' + id).classList.add('active');
});
});
function switchSubTab(type, sub) {
const container = document.getElementById('sec-' + type);
container.querySelectorAll('.sub-tab').forEach(b => b.classList.remove('active'));
container.querySelectorAll('.sub-panel').forEach(p => p.classList.remove('active'));
const tab = container.querySelector(`[data-stab="${type}-${sub}"]`);
if (tab) { tab.style.display = ''; tab.classList.add('active'); }
document.getElementById('stab-' + type + '-' + sub).classList.add('active');
}
function mdTab(btn, textareaId, previewId, mode) {
btn.closest('.md-tabs').querySelectorAll('.md-tab').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
const ta = document.getElementById(textareaId);
const pv = document.getElementById(previewId);
if (mode === 'preview') {
pv.innerHTML = marked.parse(ta.value || '*Intet indhold endnu...*');
pv.style.display = 'block'; ta.style.display = 'none';
} else {
pv.style.display = 'none'; ta.style.display = 'block';
}
}
function val(id) { return (document.getElementById(id) || {}).value || ''; }
function status(id, msg, type) {
const el = document.getElementById(id);
if (!el) return;
el.textContent = msg;
el.className = 'save-status ' + (type || '');
if (type === 'ok') setTimeout(() => { el.textContent = ''; }, 2000);
}
function clearForm(type, mode) {
['title','content','description','url','tags','category'].forEach(f => {
const el = document.getElementById(`${type}-${mode}-${f}`);
if (el) el.value = '';
});
}
// Load list
async function loadList(type) {
const r = await api('GET', '/' + type);
const list = document.getElementById(type + '-list');
const countEl = document.getElementById(type + '-count');
if (!r.ok) { list.innerHTML = '<div class="list-empty">Kunne ikke hente data.</div>'; return; }
const items = r.data;
if (countEl) countEl.textContent = items.length + ' ' + { blog: 'indlæg', notes: 'noter', projects: 'projekter', links: 'links' }[type];
if (!items.length) {
list.innerHTML = '<div class="list-empty">Ingen ' + { blog: 'indlæg', notes: 'noter', projects: 'projekter', links: 'links' }[type] + ' endnu.</div>';
return;
}
list.innerHTML = items.map(item => renderRow(type, item)).join('');
}
function renderRow(type, item) {
let meta = '';
let extraAction = '';
if (type === 'blog') {
meta = `<span class="item-meta">${item.date}</span>`;
extraAction = `<a href="/blog/${item.slug}" target="_blank" class="btn btn-ghost btn-sm">Vis ↗</a>`;
} else if (type === 'notes') {
meta = `<span class="item-meta">${item.date}</span>`;
} else if (type === 'projects') {
meta = `<span class="item-meta">${item.tags || item.date}</span>`;
if (item.url) extraAction = `<a href="${escHtml(item.url)}" target="_blank" rel="noopener" class="btn btn-ghost btn-sm">Åbn ↗</a>`;
} else if (type === 'links') {
meta = `<span class="item-meta">${item.category || '—'}</span>`;
extraAction = `<a href="${escHtml(item.url)}" target="_blank" rel="noopener" class="btn btn-ghost btn-sm">Besøg ↗</a>`;
}
return `
<div class="item-row">
${meta}
<span class="item-title">${escHtml(item.title)}</span>
<div class="item-actions">
${extraAction}
<button class="btn btn-ghost btn-sm" onclick="startEdit('${type}','${item.slug}')">Rediger</button>
<button class="btn btn-danger" onclick="deleteItem('${type}','${item.slug}',this)">Slet</button>
</div>
</div>`;
}
function escHtml(s) {
return (s || '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
// Create
async function createItem(type) {
let body = {};
if (type === 'blog' || type === 'notes') {
body.title = val(`${type}-new-title`);
body.content = val(`${type}-new-content`);
if (!body.title || !body.content) { status(`${type}-new-status`, 'Titel og indhold er påkrævet.', 'err'); return; }
} else if (type === 'projects') {
body.title = val('projects-new-title');
body.url = val('projects-new-url');
body.tags = val('projects-new-tags');
body.description = val('projects-new-description');
if (!body.title) { status('projects-new-status', 'Titel er påkrævet.', 'err'); return; }
} else if (type === 'links') {
body.title = val('links-new-title');
body.url = val('links-new-url');
body.category = val('links-new-category');
body.description = val('links-new-description');
if (!body.title || !body.url) { status('links-new-status', 'Titel og URL er påkrævet.', 'err'); return; }
}
const r = await api('POST', '/' + type, body);
if (r.ok) {
status(`${type}-new-status`, '✓ Gemt!', 'ok');
clearForm(type, 'new');
await loadList(type);
setTimeout(() => switchSubTab(type, 'list'), 1200);
} else {
status(`${type}-new-status`, r.data.error || 'Fejl.', 'err');
}
}
// Start edit
async function startEdit(type, slug) {
const r = await api('GET', '/' + type + '/' + slug);
if (!r.ok) return;
const item = r.data;
editSlugs[type] = slug;
if (type === 'blog' || type === 'notes') {
document.getElementById(`${type}-edit-title`).value = item.title || '';
document.getElementById(`${type}-edit-content`).value = item.content || '';
} else if (type === 'projects') {
document.getElementById('projects-edit-title').value = item.title || '';
document.getElementById('projects-edit-url').value = item.url || '';
document.getElementById('projects-edit-tags').value = item.tags || '';
document.getElementById('projects-edit-description').value = item.description || '';
} else if (type === 'links') {
document.getElementById('links-edit-title').value = item.title || '';
document.getElementById('links-edit-url').value = item.url || '';
document.getElementById('links-edit-category').value = item.category || '';
document.getElementById('links-edit-description').value = item.description || '';
}
document.getElementById(`${type}-edit-tab`).style.display = '';
switchSubTab(type, 'edit');
}
// Save edit
async function saveEdit(type) {
const slug = editSlugs[type];
if (!slug) return;
let body = {};
if (type === 'blog' || type === 'notes') {
body.title = val(`${type}-edit-title`);
body.content = val(`${type}-edit-content`);
} else if (type === 'projects') {
body.title = val('projects-edit-title');
body.url = val('projects-edit-url');
body.tags = val('projects-edit-tags');
body.description = val('projects-edit-description');
} else if (type === 'links') {
body.title = val('links-edit-title');
body.url = val('links-edit-url');
body.category = val('links-edit-category');
body.description = val('links-edit-description');
}
const r = await api('PUT', '/' + type + '/' + slug, body);
if (r.ok) {
status(`${type}-edit-status`, '✓ Gemt!', 'ok');
await loadList(type);
setTimeout(() => switchSubTab(type, 'list'), 1200);
} else {
status(`${type}-edit-status`, r.data.error || 'Fejl.', 'err');
}
}
// Delete
async function deleteItem(type, slug, btn) {
const labels = { blog: 'dette indlæg', notes: 'denne note', projects: 'dette projekt', links: 'dette link' };
if (!confirm('Er du sikker på, at du vil slette ' + labels[type] + '?')) return;
btn.disabled = true;
const r = await api('DELETE', '/' + type + '/' + slug);
if (r.ok) { await loadList(type); }
else { btn.disabled = false; alert('Fejl ved sletning.'); }
}
</script>
</body>
</html>

4
api/config.json Normal file
View file

@ -0,0 +1,4 @@
{
"username": "ronin",
"passwordHash": "8518f2bf8cc5ffc6fcf27f52fc6532641260982d1c6bfb05c03e4002dc421a00"
}

1
api/links.json Normal file
View file

@ -0,0 +1 @@
[]

1
api/notes.json Normal file
View file

@ -0,0 +1 @@
[]

26
api/posts.json Normal file
View file

@ -0,0 +1,26 @@
[
{
"slug": "setting-up-forgejo-on-hetzner",
"title": "Setting up a self-hosted Forgejo on Hetzner from scratch",
"content": "",
"date": "2026-03-29"
},
{
"slug": "cis-compliance-scoring-terraform",
"title": "CIS IMP2 compliance scoring with Terraform static analysis",
"content": "",
"date": "2026-03-15"
},
{
"slug": "databricks-workspace-entra-id",
"title": "Databricks workspace provisioning with Entra ID groups",
"content": "",
"date": "2026-02-28"
},
{
"slug": "azure-landing-zone-terraform",
"title": "Azure Landing Zone — a complete Terraform implementation guide",
"content": "",
"date": "2026-02-10"
}
]

58
api/projects.json Normal file
View file

@ -0,0 +1,58 @@
[
{
"slug": "azure-landing-zone",
"title": "Azure Landing Zone",
"description": "Contributed to a full Landing Zone implementation using Terraform at CIMT. Covering governance, policy, networking and RBAC across Azure environments.",
"url": "",
"tags": "Terraform, Azure, Governance, RBAC",
"date": "2026-03-29"
},
{
"slug": "dap-data-access-platform",
"title": "DAP — Data Access Platform",
"description": "Worked on Databricks workspace provisioning with Entra ID group-based access control and ADLS Gen2 integration at CIMT.",
"url": "",
"tags": "Databricks, Entra ID, Terraform, ADLS Gen2",
"date": "2026-03-29"
},
{
"slug": "cis-compliance-checker",
"title": "CIS Compliance Checker",
"description": "Contributed to a Python static compliance checker for Terraform files, comparing current vs predicted CIS IMP2 scores across Azure projects.",
"url": "",
"tags": "Python, CIS Controls, Terraform, OOP",
"date": "2026-03-29"
},
{
"slug": "self-hosted-forge",
"title": "Self-Hosted Forge",
"description": "Personal portfolio and self-hosted platform built from scratch. Running on Hetzner with Forgejo, Nginx reverse proxy, Let's Encrypt SSL and Docker Compose.",
"url": "",
"tags": "Forgejo, Docker, Nginx, Hetzner, Linux",
"date": "2026-03-29"
},
{
"slug": "fingerprint-pill-dispenser",
"title": "Fingerprint Pill Dispenser",
"description": "A proof of concept exploring embedded systems and access control. Built on an ESP32 microcontroller with an AS608 fingerprint sensor — patients register their fingerprint, which must be verified before a pill dispenser unlocks. The dispenser was a physical enclosure controlled by an MG90S servo motor. Developed in Python with object-oriented design.",
"url": "",
"tags": "ESP32, AS608, Python, OOP, Embedded, MG90S Servo",
"date": "2026-03-29"
},
{
"slug": "this-website",
"title": "This Website",
"description": "Personal portfolio site, self-hosted on Hetzner. Built from scratch with HTML, CSS and JavaScript — no frameworks, no dependencies.",
"url": "",
"tags": "HTML/CSS, JavaScript, Nginx, Hetzner",
"date": "2026-03-29"
},
{
"slug": "postgresql-database",
"title": "PostgreSQL Database",
"description": "Set up and administered a self-hosted PostgreSQL database as part of personal infrastructure. Includes schema design and integration with hosted services.",
"url": "",
"tags": "PostgreSQL, Linux, Docker",
"date": "2026-03-29"
}
]

247
api/server.js Normal file
View file

@ -0,0 +1,247 @@
const http = require('http');
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const url = require('url');
const PORT = 4000;
const CONFIG_FILE = path.join(__dirname, 'config.json');
const DATA_FILES = {
blog: path.join(__dirname, 'posts.json'),
notes: path.join(__dirname, 'notes.json'),
projects: path.join(__dirname, 'projects.json'),
links: path.join(__dirname, 'links.json'),
};
const sessions = new Map();
function loadConfig() {
if (!fs.existsSync(CONFIG_FILE)) {
const config = { username: 'admin', passwordHash: crypto.createHash('sha256').update('admin').digest('hex') };
fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2));
return config;
}
return JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8'));
}
function loadData(type) {
const file = DATA_FILES[type];
if (!fs.existsSync(file)) return [];
return JSON.parse(fs.readFileSync(file, 'utf8'));
}
function saveData(type, items) {
fs.writeFileSync(DATA_FILES[type], JSON.stringify(items, null, 2));
}
function generateToken() {
return crypto.randomBytes(32).toString('hex');
}
function isAuthenticated(req) {
const auth = req.headers['authorization'] || '';
const token = auth.replace('Bearer ', '').trim();
if (!token) return false;
const expiry = sessions.get(token);
if (!expiry || Date.now() > expiry) {
sessions.delete(token);
return false;
}
return true;
}
function readBody(req) {
return new Promise((resolve, reject) => {
let body = '';
req.on('data', chunk => { body += chunk; });
req.on('end', () => resolve(body));
req.on('error', reject);
});
}
function slugify(title) {
return title.toLowerCase()
.replace(/æ/g, 'ae').replace(/ø/g, 'oe').replace(/å/g, 'aa')
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '');
}
function uniqueSlug(base, items) {
if (!items.find(p => p.slug === base)) return base;
let i = 2;
while (items.find(p => p.slug === `${base}-${i}`)) i++;
return `${base}-${i}`;
}
const server = http.createServer(async (req, res) => {
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
res.setHeader('Content-Type', 'application/json');
if (req.method === 'OPTIONS') {
res.writeHead(204);
res.end();
return;
}
const parsed = url.parse(req.url, true);
const pathname = parsed.pathname;
// POST /api/auth/login
if (req.method === 'POST' && pathname === '/api/auth/login') {
try {
const body = await readBody(req);
const { username, password } = JSON.parse(body);
const config = loadConfig();
const hash = crypto.createHash('sha256').update(password || '').digest('hex');
if (username === config.username && hash === config.passwordHash) {
const token = generateToken();
sessions.set(token, Date.now() + 24 * 60 * 60 * 1000);
res.writeHead(200);
res.end(JSON.stringify({ token }));
} else {
res.writeHead(401);
res.end(JSON.stringify({ error: 'Forkert brugernavn eller adgangskode' }));
}
} catch {
res.writeHead(400);
res.end(JSON.stringify({ error: 'Bad request' }));
}
return;
}
// POST /api/auth/logout
if (req.method === 'POST' && pathname === '/api/auth/logout') {
const auth = req.headers['authorization'] || '';
const token = auth.replace('Bearer ', '').trim();
sessions.delete(token);
res.writeHead(200);
res.end(JSON.stringify({ ok: true }));
return;
}
const listMatch = pathname.match(/^\/api\/(blog|notes|projects|links)$/);
const itemMatch = pathname.match(/^\/api\/(blog|notes|projects|links)\/([^/]+)$/);
// GET list
if (req.method === 'GET' && listMatch) {
const type = listMatch[1];
const items = loadData(type);
if (type === 'blog') {
res.writeHead(200);
res.end(JSON.stringify(items.map(({ slug, title, date }) => ({ slug, title, date }))));
} else {
res.writeHead(200);
res.end(JSON.stringify(items));
}
return;
}
// GET single item
if (req.method === 'GET' && itemMatch) {
const [, type, slug] = itemMatch;
const item = loadData(type).find(p => p.slug === slug);
if (!item) { res.writeHead(404); res.end(JSON.stringify({ error: 'Not found' })); return; }
res.writeHead(200);
res.end(JSON.stringify(item));
return;
}
// POST create
if (req.method === 'POST' && listMatch) {
const type = listMatch[1];
if (!isAuthenticated(req)) { res.writeHead(401); res.end(JSON.stringify({ error: 'Unauthorized' })); return; }
try {
const body = await readBody(req);
const data = JSON.parse(body);
const items = loadData(type);
const today = new Date().toISOString().split('T')[0];
let item;
if (type === 'blog' || type === 'notes') {
const { title, content } = data;
if (!title || !content) { res.writeHead(400); res.end(JSON.stringify({ error: 'Titel og indhold er påkrævet' })); return; }
const slug = uniqueSlug(slugify(title), items);
item = { slug, title, content, date: today };
} else if (type === 'projects') {
const { title, description, url: projectUrl, tags } = data;
if (!title) { res.writeHead(400); res.end(JSON.stringify({ error: 'Titel er påkrævet' })); return; }
const slug = uniqueSlug(slugify(title), items);
item = { slug, title, description: description || '', url: projectUrl || '', tags: tags || '', date: today };
} else if (type === 'links') {
const { title, url: linkUrl, description, category } = data;
if (!title || !linkUrl) { res.writeHead(400); res.end(JSON.stringify({ error: 'Titel og URL er påkrævet' })); return; }
const slug = uniqueSlug(slugify(title), items);
item = { slug, title, url: linkUrl, description: description || '', category: category || '', date: today };
}
items.unshift(item);
saveData(type, items);
res.writeHead(201);
res.end(JSON.stringify(item));
} catch {
res.writeHead(400);
res.end(JSON.stringify({ error: 'Bad request' }));
}
return;
}
// PUT update
if (req.method === 'PUT' && itemMatch) {
const [, type, slug] = itemMatch;
if (!isAuthenticated(req)) { res.writeHead(401); res.end(JSON.stringify({ error: 'Unauthorized' })); return; }
try {
const body = await readBody(req);
const data = JSON.parse(body);
const items = loadData(type);
const idx = items.findIndex(p => p.slug === slug);
if (idx === -1) { res.writeHead(404); res.end(JSON.stringify({ error: 'Not found' })); return; }
if (type === 'blog' || type === 'notes') {
if (data.title !== undefined) items[idx].title = data.title;
if (data.content !== undefined) items[idx].content = data.content;
} else if (type === 'projects') {
if (data.title !== undefined) items[idx].title = data.title;
if (data.description !== undefined) items[idx].description = data.description;
if (data.url !== undefined) items[idx].url = data.url;
if (data.tags !== undefined) items[idx].tags = data.tags;
} else if (type === 'links') {
if (data.title !== undefined) items[idx].title = data.title;
if (data.url !== undefined) items[idx].url = data.url;
if (data.description !== undefined) items[idx].description = data.description;
if (data.category !== undefined) items[idx].category = data.category;
}
saveData(type, items);
res.writeHead(200);
res.end(JSON.stringify(items[idx]));
} catch {
res.writeHead(400);
res.end(JSON.stringify({ error: 'Bad request' }));
}
return;
}
// DELETE
if (req.method === 'DELETE' && itemMatch) {
const [, type, slug] = itemMatch;
if (!isAuthenticated(req)) { res.writeHead(401); res.end(JSON.stringify({ error: 'Unauthorized' })); return; }
const items = loadData(type);
const idx = items.findIndex(p => p.slug === slug);
if (idx === -1) { res.writeHead(404); res.end(JSON.stringify({ error: 'Not found' })); return; }
items.splice(idx, 1);
saveData(type, items);
res.writeHead(200);
res.end(JSON.stringify({ ok: true }));
return;
}
res.writeHead(404);
res.end(JSON.stringify({ error: 'Not found' }));
});
server.listen(PORT, '127.0.0.1', () => {
console.log(`API listening on port ${PORT}`);
});

171
blog-post.html Normal file
View file

@ -0,0 +1,171 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Blog — Rawand Lorentzen</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;700&family=Syne:wght@400;700;800&family=Inter:wght@300;400;500&display=swap" rel="stylesheet">
<style>
:root {
--bg: #f0f2f5;
--bg2: #e8ebef;
--surface: #ffffff;
--border: #d8dde6;
--accent: #2563eb;
--accent-lt: #eff6ff;
--text: #1e2530;
--text-mid: #4a5568;
--text-dim: #8896aa;
--mono: 'JetBrains Mono', monospace;
--sans: 'Syne', sans-serif;
--body: 'Inter', sans-serif;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
html { scroll-behavior: smooth; }
body { background: var(--bg); color: var(--text); font-family: var(--body); }
nav {
position: fixed; top: 0; left: 0; right: 0; z-index: 100;
padding: 0 48px; height: 60px;
display: flex; justify-content: space-between; align-items: center;
background: rgba(240,242,245,0.92); backdrop-filter: blur(16px);
border-bottom: 1px solid var(--border);
}
.nav-logo { font-family: var(--sans); font-size: 16px; font-weight: 800; color: var(--text); text-decoration: none; }
.nav-logo span { color: var(--accent); }
.nav-back {
color: var(--text-mid); text-decoration: none; font-size: 13px; font-weight: 500;
padding: 6px 14px; border-radius: 6px; transition: all 0.15s;
}
.nav-back:hover { background: var(--accent-lt); color: var(--accent); }
.container { max-width: 720px; margin: 0 auto; padding: 96px 48px 80px; }
.post-meta {
font-family: var(--mono); font-size: 11px; color: var(--accent);
letter-spacing: 0.15em; text-transform: uppercase; margin-bottom: 16px;
}
.post-title {
font-family: var(--sans); font-size: clamp(28px, 4vw, 48px);
font-weight: 800; letter-spacing: -1.5px; color: var(--text);
line-height: 1.1; margin-bottom: 40px;
}
.post-body { font-size: 16px; line-height: 1.9; color: var(--text-mid); }
.post-body h1, .post-body h2, .post-body h3, .post-body h4 {
font-family: var(--sans); color: var(--text); letter-spacing: -0.5px; margin: 40px 0 16px;
}
.post-body h1 { font-size: 32px; }
.post-body h2 { font-size: 26px; }
.post-body h3 { font-size: 20px; }
.post-body h4 { font-size: 16px; }
.post-body p { margin-bottom: 20px; }
.post-body strong { color: var(--text); font-weight: 500; }
.post-body em { font-style: italic; }
.post-body a { color: var(--accent); text-decoration: none; border-bottom: 1px solid rgba(37,99,235,0.3); transition: border-color 0.15s; }
.post-body a:hover { border-color: var(--accent); }
.post-body code {
font-family: var(--mono); font-size: 13px;
background: var(--bg2); padding: 2px 7px; border-radius: 4px;
color: var(--accent); border: 1px solid var(--border);
}
.post-body pre {
background: #1e2530; border-radius: 10px; padding: 24px;
overflow-x: auto; margin: 28px 0;
}
.post-body pre code {
background: none; color: #e2e8f0; padding: 0; border: none; font-size: 13.5px; line-height: 1.7;
}
.post-body ul, .post-body ol { padding-left: 28px; margin-bottom: 20px; }
.post-body li { margin-bottom: 6px; }
.post-body blockquote {
border-left: 3px solid var(--accent); padding: 12px 20px;
color: var(--text-dim); font-style: italic; margin: 24px 0;
background: var(--accent-lt); border-radius: 0 6px 6px 0;
}
.post-body hr { border: none; border-top: 1px solid var(--border); margin: 40px 0; }
.post-body img { max-width: 100%; border-radius: 8px; margin: 24px 0; }
.post-body table { width: 100%; border-collapse: collapse; margin: 24px 0; font-size: 14px; }
.post-body th, .post-body td { padding: 10px 14px; border: 1px solid var(--border); text-align: left; }
.post-body th { background: var(--bg2); font-weight: 500; color: var(--text); font-family: var(--mono); font-size: 12px; }
.not-found { text-align: center; padding: 80px 20px; }
.not-found .code { font-family: var(--mono); font-size: 48px; font-weight: 700; color: var(--accent); margin-bottom: 16px; }
.not-found p { color: var(--text-mid); margin-bottom: 24px; }
.not-found a { color: var(--accent); text-decoration: none; font-weight: 500; }
.empty-content {
background: var(--surface); border: 1px solid var(--border); border-radius: 10px;
padding: 48px; text-align: center; color: var(--text-dim);
}
.empty-content p { margin-top: 8px; font-size: 13px; }
footer {
border-top: 1px solid var(--border); padding: 24px 48px;
display: flex; justify-content: space-between; align-items: center;
font-size: 12px; color: var(--text-dim); font-family: var(--mono);
background: var(--surface);
}
@media (max-width: 640px) {
nav { padding: 0 20px; }
.container { padding: 80px 24px 60px; }
footer { padding: 20px 24px; flex-direction: column; gap: 8px; }
}
</style>
</head>
<body>
<nav>
<a href="/" class="nav-logo">Rawand<span>.</span></a>
<a href="/#blog" class="nav-back">← Blog</a>
</nav>
<div class="container" id="container">
<div class="not-found">
<div class="code">...</div>
<p>Indlæser...</p>
</div>
</div>
<footer>
<span>rawandlorentzen.com</span>
<span>// blog</span>
<span>© 2026</span>
</footer>
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
<script>
const slug = window.location.pathname.replace(/^\/blog\/?/, '').replace(/\/$/, '');
async function loadPost() {
const container = document.getElementById('container');
if (!slug) {
container.innerHTML = `<div class="not-found"><div class="code">404</div><p>Intet indlæg fundet.</p><a href="/#blog">← Tilbage til blog</a></div>`;
return;
}
try {
const res = await fetch('/api/blog/' + slug);
if (!res.ok) throw new Error('not found');
const post = await res.json();
document.title = post.title + ' — Rawand Lorentzen';
const bodyHtml = post.content
? marked.parse(post.content)
: `<div class="empty-content"><strong>Indholdet er på vej</strong><p>Dette indlæg er ikke skrevet endnu.</p></div>`;
container.innerHTML = `
<div class="post-meta">04 — Blog · ${post.date}</div>
<h1 class="post-title">${post.title}</h1>
<div class="post-body">${bodyHtml}</div>
`;
} catch {
container.innerHTML = `<div class="not-found"><div class="code">404</div><p>Indlægget blev ikke fundet.</p><a href="/#blog">← Tilbage til blog</a></div>`;
}
}
loadPost();
</script>
</body>
</html>

787
index.html Normal file
View file

@ -0,0 +1,787 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Rawand Lorentzen</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;700&family=Syne:wght@400;700;800&family=Inter:wght@300;400;500&display=swap" rel="stylesheet">
<style>
:root {
--bg: #f0f2f5;
--bg2: #e8ebef;
--surface: #ffffff;
--surface2: #f7f8fa;
--border: #d8dde6;
--border2: #c4ccd8;
--accent: #2563eb;
--accent-h: #1d4ed8;
--accent-lt: #eff6ff;
--text: #1e2530;
--text-mid: #4a5568;
--text-dim: #8896aa;
--mono: 'JetBrains Mono', monospace;
--sans: 'Syne', sans-serif;
--body: 'Inter', sans-serif;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
html { scroll-behavior: smooth; }
body {
background: var(--bg);
color: var(--text);
font-family: var(--body);
overflow-x: hidden;
cursor: none;
}
.cursor {
width: 8px; height: 8px;
background: var(--accent);
border-radius: 50%;
position: fixed;
pointer-events: none;
z-index: 9999;
transition: transform 0.15s;
}
.cursor-ring {
width: 30px; height: 30px;
border: 1.5px solid var(--accent);
border-radius: 50%;
position: fixed;
pointer-events: none;
z-index: 9998;
opacity: 0.35;
transition: all 0.12s ease;
}
nav {
position: fixed;
top: 0; left: 0; right: 0;
z-index: 100;
padding: 0 48px;
height: 60px;
display: flex;
justify-content: space-between;
align-items: center;
background: rgba(240,242,245,0.92);
backdrop-filter: blur(16px);
border-bottom: 1px solid var(--border);
}
.nav-logo {
font-family: var(--sans);
font-size: 16px;
font-weight: 800;
color: var(--text);
letter-spacing: -0.3px;
}
.nav-logo span { color: var(--accent); }
.nav-links { display: flex; gap: 4px; list-style: none; }
.nav-links a {
color: var(--text-mid);
text-decoration: none;
font-size: 13px;
font-weight: 500;
padding: 6px 14px;
border-radius: 6px;
transition: all 0.15s;
}
.nav-links a:hover { background: var(--accent-lt); color: var(--accent); }
.nav-links a.active { background: var(--accent); color: white; }
/* HERO */
.hero {
min-height: 100vh;
display: flex;
align-items: center;
padding: 100px 48px 80px;
gap: 80px;
}
.hero-content { max-width: 640px; flex: 1; }
.hero-badge {
display: inline-flex;
align-items: center;
gap: 8px;
background: var(--accent-lt);
color: var(--accent);
font-family: var(--mono);
font-size: 11px;
padding: 6px 14px;
border-radius: 20px;
margin-bottom: 28px;
border: 1px solid rgba(37,99,235,0.2);
}
.hero-badge::before { content: '●'; font-size: 8px; }
.hero h1 {
font-family: var(--sans);
font-size: clamp(48px, 6vw, 80px);
font-weight: 800;
line-height: 1;
letter-spacing: -2px;
color: var(--text);
margin-bottom: 8px;
}
.hero h1 .name-accent { color: var(--accent); }
.hero-sub {
font-family: var(--mono);
font-size: 13px;
color: var(--text-dim);
margin-bottom: 24px;
letter-spacing: 0.05em;
}
.hero-desc {
font-size: 16px;
line-height: 1.8;
color: var(--text-mid);
max-width: 480px;
margin-bottom: 40px;
font-weight: 300;
}
.hero-desc strong { color: var(--text); font-weight: 500; }
.hero-cta { display: flex; gap: 12px; flex-wrap: wrap; }
.btn {
padding: 12px 24px;
font-family: var(--body);
font-size: 14px;
font-weight: 500;
text-decoration: none;
border-radius: 8px;
border: 1.5px solid transparent;
transition: all 0.2s;
display: inline-flex;
align-items: center;
gap: 8px;
}
.btn-primary { background: var(--accent); color: white; border-color: var(--accent); }
.btn-primary:hover { background: var(--accent-h); border-color: var(--accent-h); }
.btn-ghost { background: var(--surface); color: var(--text-mid); border-color: var(--border); }
.btn-ghost:hover { border-color: var(--accent); color: var(--accent); background: var(--accent-lt); }
/* Terminal */
.terminal {
width: 360px;
flex-shrink: 0;
background: var(--text);
border-radius: 12px;
overflow: hidden;
box-shadow: 0 20px 60px rgba(0,0,0,0.15);
display: none;
}
@media (min-width: 1100px) { .terminal { display: block; } }
.terminal-bar {
padding: 12px 16px;
background: #2a3240;
display: flex;
align-items: center;
gap: 7px;
}
.dot { width: 11px; height: 11px; border-radius: 50%; }
.dot-r { background: #ff5f57; }
.dot-y { background: #febc2e; }
.dot-g { background: #28c840; }
.terminal-title { font-family: var(--mono); font-size: 11px; color: #8896aa; margin-left: 6px; }
.terminal-body { padding: 20px 24px; font-family: var(--mono); font-size: 12.5px; line-height: 2; color: #a8b4c8; }
.t-prompt { color: #4ade80; }
.t-cmd { color: #93c5fd; }
.t-out { color: #e2e8f0; }
.t-accent { color: #60a5fa; }
.blink { animation: blink 1.2s infinite; color: #4ade80; }
@keyframes blink { 0%,100%{opacity:1}50%{opacity:0} }
/* SECTIONS */
section { padding: 96px 48px; }
.section-header { margin-bottom: 56px; }
.section-eyebrow {
font-family: var(--mono);
font-size: 11px;
color: var(--accent);
letter-spacing: 0.15em;
text-transform: uppercase;
margin-bottom: 12px;
}
.section-title {
font-family: var(--sans);
font-size: clamp(28px, 3.5vw, 42px);
font-weight: 800;
color: var(--text);
letter-spacing: -1px;
}
.section-desc {
margin-top: 12px;
font-size: 15px;
color: var(--text-mid);
font-weight: 300;
max-width: 480px;
}
hr.divider { border: none; border-top: 1px solid var(--border); }
/* ABOUT */
.about-wrap { display: grid; grid-template-columns: 1fr; gap: 40px; }
.about-text { font-size: 15px; line-height: 1.9; color: var(--text-mid); font-weight: 300; max-width: 780px; }
.about-text p { margin-bottom: 18px; }
.about-text strong { color: var(--text); font-weight: 500; }
.about-chips { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 28px; }
.chip {
font-family: var(--mono);
font-size: 11px;
padding: 5px 12px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 6px;
color: var(--text-mid);
transition: all 0.2s;
}
.chip:hover { border-color: var(--accent); color: var(--accent); background: var(--accent-lt); }
/* REPOS */
.repos-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 16px;
}
.repo-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 10px;
padding: 24px;
text-decoration: none;
display: block;
transition: all 0.2s;
}
.repo-card:hover { border-color: var(--accent); box-shadow: 0 4px 20px rgba(37,99,235,0.08); transform: translateY(-2px); }
.repo-top { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 10px; }
.repo-name { font-family: var(--sans); font-size: 15px; font-weight: 700; color: var(--text); }
.repo-arrow { color: var(--text-dim); font-size: 16px; transition: color 0.2s, transform 0.2s; }
.repo-card:hover .repo-arrow { color: var(--accent); transform: translate(2px,-2px); }
.repo-desc { font-size: 13px; line-height: 1.7; color: var(--text-mid); margin-bottom: 18px; min-height: 38px; }
.repo-meta { display: flex; gap: 14px; font-family: var(--mono); font-size: 11px; color: var(--text-dim); }
.repo-lang::before { content: '● '; color: var(--accent); }
.repo-loading, .repo-empty {
grid-column: 1/-1; padding: 56px; text-align: center;
color: var(--text-dim); font-size: 14px;
background: var(--surface); border: 1px solid var(--border); border-radius: 10px;
}
/* PROJECTS */
.projects-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 16px;
}
.project-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 10px;
padding: 28px;
transition: all 0.2s;
display: flex;
flex-direction: column;
}
.project-card:hover { border-color: var(--border2); box-shadow: 0 4px 20px rgba(0,0,0,0.06); transform: translateY(-2px); }
.project-card.own { border-color: rgba(37,99,235,0.3); background: var(--accent-lt); }
.project-card.own:hover { border-color: var(--accent); }
.project-top { display: flex; justify-content: space-between; align-items: center; margin-bottom: 14px; }
.project-num { font-family: var(--mono); font-size: 10px; color: var(--accent); letter-spacing: 0.1em; }
.project-badge {
font-family: var(--mono);
font-size: 10px;
padding: 3px 8px;
border-radius: 4px;
background: var(--accent);
color: white;
}
.project-badge.internship { background: var(--bg2); color: var(--text-dim); border: 1px solid var(--border); }
.project-title { font-family: var(--sans); font-size: 18px; font-weight: 700; color: var(--text); margin-bottom: 8px; }
.project-desc { font-size: 13px; line-height: 1.7; color: var(--text-mid); margin-bottom: 20px; flex: 1; }
.tags { display: flex; flex-wrap: wrap; gap: 7px; }
.tag { font-family: var(--mono); font-size: 10px; padding: 3px 10px; border-radius: 4px; background: var(--bg2); color: var(--text-dim); border: 1px solid var(--border); }
.tag-accent { background: var(--accent-lt); color: var(--accent); border-color: rgba(37,99,235,0.2); }
/* BLOG */
.blog-list { display: flex; flex-direction: column; gap: 12px; }
.blog-item {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 10px;
padding: 22px 28px;
display: flex;
align-items: center;
gap: 24px;
text-decoration: none;
transition: all 0.2s;
cursor: none;
}
.blog-item:hover { border-color: var(--accent); background: var(--accent-lt); transform: translateX(4px); }
.blog-date { font-family: var(--mono); font-size: 11px; color: var(--text-dim); white-space: nowrap; width: 88px; flex-shrink: 0; }
.blog-title { font-size: 14px; color: var(--text); flex: 1; font-weight: 500; }
.blog-arrow { color: var(--text-dim); font-size: 16px; transition: color 0.2s, transform 0.2s; }
.blog-item:hover .blog-arrow { color: var(--accent); transform: translateX(4px); }
/* LINKS */
.links-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 14px; }
.link-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 10px;
padding: 24px;
text-decoration: none;
transition: all 0.2s;
display: block;
}
.link-card:hover { border-color: var(--accent); box-shadow: 0 4px 20px rgba(37,99,235,0.08); transform: translateY(-2px); }
.link-icon { font-size: 22px; margin-bottom: 12px; }
.link-name { font-family: var(--sans); font-size: 15px; font-weight: 700; color: var(--text); margin-bottom: 6px; }
.link-desc { font-size: 12px; color: var(--text-dim); line-height: 1.6; }
footer {
border-top: 1px solid var(--border);
padding: 28px 48px;
display: flex;
justify-content: space-between;
align-items: center;
font-size: 12px;
color: var(--text-dim);
font-family: var(--mono);
background: var(--surface);
}
.fade-in { opacity: 0; transform: translateY(16px); transition: opacity 0.6s ease, transform 0.6s ease; }
.fade-in.visible { opacity: 1; transform: translateY(0); }
@media (max-width: 640px) {
nav { padding: 0 20px; }
.hero { padding: 90px 24px 60px; flex-direction: column; gap: 40px; }
section { padding: 64px 24px; }
footer { padding: 20px 24px; flex-direction: column; gap: 8px; text-align: center; }
.nav-links { display: none; }
}
</style>
</head>
<body>
<div class="cursor" id="cursor"></div>
<div class="cursor-ring" id="cursorRing"></div>
<nav>
<div class="nav-logo">Rawand<span>.</span></div>
<ul class="nav-links">
<li><a href="#about">About</a></li>
<li><a href="#repos">Git</a></li>
<li><a href="#projects">Projects</a></li>
<li><a href="#blog">Blog</a></li>
<li><a href="/notes">Notes</a></li>
<li><a href="#links">Links</a></li>
</ul>
</nav>
<!-- HERO -->
<section class="hero">
<div class="hero-content fade-in">
<div class="hero-badge">IT-Teknolog — Cloud & Infrastructure</div>
<h1>Rawand <span class="name-accent">Lorentzen</span></h1>
<div class="hero-sub">// terraform apply && git push</div>
<p class="hero-desc">
Cloud engineer with a background in <strong>network protocols</strong>,
<strong>embedded systems</strong> and <strong>agile project work</strong>.
Working with Azure infrastructure and IaC in production — documenting everything along the way.
</p>
<div class="hero-cta">
<a href="#projects" class="btn btn-primary">View Projects →</a>
<a href="/git" class="btn btn-ghost">⌥ Forgejo</a>
</div>
</div>
<div class="terminal">
<div class="terminal-bar">
<div class="dot dot-r"></div>
<div class="dot dot-y"></div>
<div class="dot dot-g"></div>
<span class="terminal-title">rawand@hetzner ~ $</span>
</div>
<div class="terminal-body">
<div><span class="t-prompt"></span> <span class="t-cmd">whoami</span></div>
<div class="t-out">rawand_lorentzen</div>
<br>
<div><span class="t-prompt"></span> <span class="t-cmd">cat certs.txt</span></div>
<div class="t-accent">→ IT-Teknolog (Graduate)</div>
<div class="t-accent">→ CompTIA Network+</div>
<div class="t-accent">→ AZ-104</div>
<div class="t-accent">→ AZ-500 (in progress)</div>
<br>
<div><span class="t-prompt"></span> <span class="t-cmd">cat stack.txt</span></div>
<div class="t-out">→ Azure / Terraform / IaC</div>
<div class="t-out">→ OSPF / BGP / TCP-IP</div>
<div class="t-out">→ Python / OOP</div>
<div class="t-out">→ Docker / Kubernetes</div>
<div class="t-out">→ Databricks / Power BI</div>
<br>
<div><span class="t-prompt"></span> <span class="blink"></span></div>
</div>
</div>
</section>
<hr class="divider">
<!-- ABOUT -->
<section id="about">
<div class="section-header">
<div class="section-eyebrow">01 — About</div>
<h2 class="section-title">Who I am</h2>
<p class="section-desc">Engineer, problem solver, lifelong learner.</p>
</div>
<div class="about-wrap">
<div class="about-text fade-in">
<p>
Rawand Lorentzen is a qualified <strong>IT-Teknolog</strong> with a broad technical foundation spanning cloud infrastructure, network engineering, and software development. His education covered the full stack of modern IT — from low-level routing protocols like <strong>OSPF and BGP</strong>, communication architectures, and TCP/IP networking, to object-oriented <strong>Python programming</strong> in the context of embedded systems.
</p>
<p>
Since transitioning into cloud infrastructure, Rawand has gained hands-on experience with <strong>Microsoft Azure</strong> and <strong>Terraform-based Infrastructure as Code (IaC)</strong> — working in production environments during his internship at CIMT across governance, CI/CD pipelines, Defender for Cloud, and CIS compliance frameworks. He holds the <strong>AZ-104</strong> certification and is currently working toward <strong>AZ-500</strong>.
</p>
<p>
On the data side, he has worked with <strong>Databricks</strong> and <strong>Power BI</strong> — including workspace provisioning, access control, and integrating data platforms into cloud infrastructure. He has also worked with <strong>Docker</strong> and <strong>Kubernetes</strong>, gaining practical experience with containerised workloads in cloud-native environments.
</p>
<p>
Before IT, Rawand spent close to a decade in physical craftsmanship. That background shaped a mindset that carries directly into infrastructure work: methodical, detail-oriented, and always built to last.
</p>
<div class="about-chips">
<span class="chip">Terraform</span>
<span class="chip">Azure</span>
<span class="chip">IaC</span>
<span class="chip">OSPF / BGP</span>
<span class="chip">TCP/IP</span>
<span class="chip">Python</span>
<span class="chip">OOP</span>
<span class="chip">Embedded Systems</span>
<span class="chip">Docker</span>
<span class="chip">Kubernetes</span>
<span class="chip">Linux</span>
<span class="chip">CI/CD</span>
<span class="chip">CIS Controls</span>
<span class="chip">Agile / Scrum</span>
<span class="chip">Databricks</span>
<span class="chip">Power BI</span>
<span class="chip">AZ-104</span>
<span class="chip">AZ-500</span>
</div>
</div>
</div>
</section>
<hr class="divider">
<!-- REPOS -->
<section id="repos" style="background: var(--bg2);">
<div class="section-header">
<div class="section-eyebrow">02 — Git</div>
<h2 class="section-title">Public repositories</h2>
<p class="section-desc">Live from Forgejo — browse and explore.</p>
</div>
<div class="repos-grid" id="reposGrid">
<div class="repo-loading">Loading repositories from Forgejo...</div>
</div>
</section>
<hr class="divider">
<!-- PROJECTS -->
<section id="projects">
<div class="section-header">
<div class="section-eyebrow">03 — Projects</div>
<h2 class="section-title">What I've worked on</h2>
<p class="section-desc">A mix of internship work and personal projects.</p>
</div>
<div class="projects-grid">
<div class="project-card fade-in">
<div class="project-top">
<div class="project-num">001</div>
<span class="project-badge internship">Internship — CIMT</span>
</div>
<div class="project-title">Azure Landing Zone</div>
<p class="project-desc">Contributed to a full Landing Zone implementation using Terraform at CIMT. Covering governance, policy, networking and RBAC across Azure environments.</p>
<div class="tags">
<span class="tag tag-accent">Terraform</span>
<span class="tag">Azure</span>
<span class="tag">Governance</span>
<span class="tag">RBAC</span>
</div>
</div>
<div class="project-card fade-in">
<div class="project-top">
<div class="project-num">002</div>
<span class="project-badge internship">Internship — CIMT</span>
</div>
<div class="project-title">DAP — Data Access Platform</div>
<p class="project-desc">Worked on Databricks workspace provisioning with Entra ID group-based access control and ADLS Gen2 integration at CIMT.</p>
<div class="tags">
<span class="tag tag-accent">Databricks</span>
<span class="tag">Entra ID</span>
<span class="tag">Terraform</span>
<span class="tag">ADLS Gen2</span>
</div>
</div>
<div class="project-card fade-in">
<div class="project-top">
<div class="project-num">003</div>
<span class="project-badge internship">Internship — CIMT</span>
</div>
<div class="project-title">CIS Compliance Checker</div>
<p class="project-desc">Contributed to a Python static compliance checker for Terraform files, comparing current vs predicted CIS IMP2 scores across Azure projects.</p>
<div class="tags">
<span class="tag tag-accent">Python</span>
<span class="tag">CIS Controls</span>
<span class="tag">Terraform</span>
<span class="tag">OOP</span>
</div>
</div>
<div class="project-card own fade-in">
<div class="project-top">
<div class="project-num">004</div>
<span class="project-badge">Personal</span>
</div>
<div class="project-title">Self-Hosted Forge</div>
<p class="project-desc">Personal portfolio and self-hosted platform built from scratch. Running on Hetzner with Forgejo, Nginx reverse proxy, Let's Encrypt SSL and Docker Compose.</p>
<div class="tags">
<span class="tag tag-accent">Forgejo</span>
<span class="tag">Docker</span>
<span class="tag">Nginx</span>
<span class="tag">Hetzner</span>
<span class="tag">Linux</span>
</div>
</div>
<div class="project-card own fade-in">
<div class="project-top">
<div class="project-num">005</div>
<span class="project-badge">School Project</span>
</div>
<div class="project-title">Fingerprint Pill Dispenser</div>
<p class="project-desc">A proof of concept exploring embedded systems and access control. Built on an ESP32 microcontroller with an AS608 fingerprint sensor — patients register their fingerprint, which must be verified before a pill dispenser unlocks. The dispenser was a physical enclosure controlled by an MG90S servo motor. Developed in Python with object-oriented design.</p>
<div class="tags">
<span class="tag tag-accent">ESP32</span>
<span class="tag">AS608</span>
<span class="tag">Python</span>
<span class="tag">OOP</span>
<span class="tag">Embedded</span>
<span class="tag">MG90S Servo</span>
</div>
</div>
<div class="project-card own fade-in">
<div class="project-top">
<div class="project-num">006</div>
<span class="project-badge">Personal</span>
</div>
<div class="project-title">This Website</div>
<p class="project-desc">Personal portfolio site, self-hosted on Hetzner. Built from scratch with HTML, CSS and JavaScript — no frameworks, no dependencies.</p>
<div class="tags">
<span class="tag tag-accent">HTML/CSS</span>
<span class="tag">JavaScript</span>
<span class="tag">Nginx</span>
<span class="tag">Hetzner</span>
</div>
</div>
<div class="project-card own fade-in">
<div class="project-top">
<div class="project-num">007</div>
<span class="project-badge">Personal</span>
</div>
<div class="project-title">PostgreSQL Database</div>
<p class="project-desc">Set up and administered a self-hosted PostgreSQL database as part of personal infrastructure. Includes schema design and integration with hosted services.</p>
<div class="tags">
<span class="tag tag-accent">PostgreSQL</span>
<span class="tag">Linux</span>
<span class="tag">Docker</span>
</div>
</div>
</div>
</section>
<hr class="divider">
<!-- BLOG -->
<section id="blog" style="background: var(--bg2);">
<div class="section-header">
<div class="section-eyebrow">04 — Blog</div>
<h2 class="section-title">Articles & writeups</h2>
<p class="section-desc">Technical deep-dives and lessons learned.</p>
</div>
<div class="blog-list" id="blogList">
<div style="padding:40px;text-align:center;color:var(--text-dim);font-size:14px;">Indlæser...</div>
</div>
</section>
<hr class="divider">
<!-- LINKS -->
<section id="links">
<div class="section-header">
<div class="section-eyebrow">05 — Links</div>
<h2 class="section-title">Find me here</h2>
</div>
<div class="links-grid">
<a href="/git" class="link-card">
<div class="link-icon"></div>
<div class="link-name">Forgejo</div>
<p class="link-desc">Self-hosted git forge. Code, experiments and open source projects.</p>
</a>
<a href="/notes" class="link-card">
<div class="link-icon"></div>
<div class="link-name">Notes</div>
<p class="link-desc">Personal knowledge base, technical documentation and articles.</p>
</a>
<a href="#" class="link-card">
<div class="link-icon">in</div>
<div class="link-name">LinkedIn</div>
<p class="link-desc">Professional profile, certifications and career updates.</p>
</a>
<a href="mailto:rawandlorentzen@gmail.com" class="link-card">
<div class="link-icon"></div>
<div class="link-name">Email</div>
<p class="link-desc">rawandlorentzen@gmail.com</p>
</a>
</div>
</section>
<footer>
<span>rawandlorentzen.com</span>
<span>// built with Linux, Docker & caffeine</span>
<span>© 2026</span>
</footer>
<script>
// Cursor
const cursor = document.getElementById('cursor');
const ring = document.getElementById('cursorRing');
let mx = 0, my = 0, rx = 0, ry = 0;
document.addEventListener('mousemove', e => {
mx = e.clientX; my = e.clientY;
cursor.style.left = mx - 4 + 'px';
cursor.style.top = my - 4 + 'px';
});
(function animRing() {
rx += (mx - rx) * 0.12;
ry += (my - ry) * 0.12;
ring.style.left = rx - 15 + 'px';
ring.style.top = ry - 15 + 'px';
requestAnimationFrame(animRing);
})();
document.querySelectorAll('a, .btn').forEach(el => {
el.addEventListener('mouseenter', () => { cursor.style.transform = 'scale(2.5)'; ring.style.opacity = '0.6'; });
el.addEventListener('mouseleave', () => { cursor.style.transform = 'scale(1)'; ring.style.opacity = '0.35'; });
});
// Active nav on scroll
const sections = document.querySelectorAll('section[id]');
const navLinks = document.querySelectorAll('.nav-links a');
window.addEventListener('scroll', () => {
let current = '';
sections.forEach(s => { if (window.scrollY >= s.offsetTop - 80) current = s.id; });
navLinks.forEach(a => {
a.classList.remove('active');
if (a.getAttribute('href') === '#' + current) a.classList.add('active');
});
});
// Fade in
const observer = new IntersectionObserver(entries => {
entries.forEach((e, i) => {
if (e.isIntersecting) setTimeout(() => e.target.classList.add('visible'), i * 100);
});
}, { threshold: 0.1 });
document.querySelectorAll('.fade-in').forEach(el => observer.observe(el));
// Forgejo repos
async function loadRepos() {
const grid = document.getElementById('reposGrid');
try {
const res = await fetch('/git/api/v1/repos/search?limit=8&sort=updated');
const data = await res.json();
const repos = (data.data || []).filter(r => !r.private);
if (!repos.length) {
grid.innerHTML = '<div class="repo-empty">No public repositories yet.<br><span style="font-size:12px;opacity:0.6;margin-top:8px;display:block">Push your first repo to Forgejo to see it here.</span></div>';
return;
}
grid.innerHTML = repos.map(repo => `
<a href="/git/${repo.full_name}" class="repo-card fade-in">
<div class="repo-top">
<div class="repo-name">${repo.name}</div>
<span class="repo-arrow"></span>
</div>
<div class="repo-desc">${repo.description || 'No description provided.'}</div>
<div class="repo-meta">
${repo.language ? `<span class="repo-lang">${repo.language}</span>` : ''}
<span>★ ${repo.stars_count}</span>
<span>${repo.forks_count} forks</span>
</div>
</a>
`).join('');
grid.querySelectorAll('.fade-in').forEach(el => observer.observe(el));
} catch {
grid.innerHTML = '<div class="repo-empty">Could not load repositories.<br><span style="font-size:12px;opacity:0.6;margin-top:8px;display:block">Make sure Forgejo is running at /git</span></div>';
}
}
loadRepos();
// Blog posts
async function loadBlog() {
const list = document.getElementById('blogList');
try {
const res = await fetch('/api/blog');
const posts = await res.json();
if (!posts.length) {
list.innerHTML = '<div style="padding:40px;text-align:center;color:var(--text-dim);font-size:14px;">Ingen indlæg endnu.</div>';
return;
}
list.innerHTML = posts.map(p => `
<a href="/blog/${p.slug}" class="blog-item">
<span class="blog-date">${p.date}</span>
<span class="blog-title">${p.title}</span>
<span class="blog-arrow"></span>
</a>
`).join('');
} catch {
list.innerHTML = '<div style="padding:40px;text-align:center;color:var(--text-dim);font-size:14px;">Kunne ikke hente indlæg.</div>';
}
}
loadBlog();
</script>
</body>
</html>