Initial commit — migrate existing site
This commit is contained in:
commit
651d2f8dfc
9 changed files with 2053 additions and 0 deletions
758
admin.html
Normal file
758
admin.html
Normal 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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
4
api/config.json
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
{
|
||||||
|
"username": "ronin",
|
||||||
|
"passwordHash": "8518f2bf8cc5ffc6fcf27f52fc6532641260982d1c6bfb05c03e4002dc421a00"
|
||||||
|
}
|
||||||
1
api/links.json
Normal file
1
api/links.json
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
[]
|
||||||
1
api/notes.json
Normal file
1
api/notes.json
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
[]
|
||||||
26
api/posts.json
Normal file
26
api/posts.json
Normal 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
58
api/projects.json
Normal 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
247
api/server.js
Normal 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
171
blog-post.html
Normal 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
787
index.html
Normal 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>
|
||||||
Loading…
Reference in a new issue