Inital upload

This commit is contained in:
2026-07-09 01:49:59 +02:00
parent 3bf503d9eb
commit d9927618f5
32 changed files with 384 additions and 1 deletions
+54 -1
View File
@@ -1,2 +1,55 @@
# website-but-better
Marcus Allison — Portfolio (Static site)
Quick deploy instructions for an nginx host (Debian/Ubuntu):
1. Copy files to the web root on the server:
sudo mkdir -p /var/www/marcus
sudo cp -r "*" /var/www/marcus/ # run from the project folder or copy files individually
sudo chown -R www-data:www-data /var/www/marcus
2. Place the nginx server block (nginx/marcus_allison.conf) into `/etc/nginx/sites-available/` and enable it:
sudo cp "nginx/marcus_allison.conf" /etc/nginx/sites-available/marcus_allison
sudo ln -s /etc/nginx/sites-available/marcus_allison /etc/nginx/sites-enabled/
3. Test nginx config and reload:
sudo nginx -t
sudo systemctl reload nginx
4. (Optional) Provision HTTPS with Certbot (Let's Encrypt):
sudo apt update && sudo apt install certbot python3-certbot-nginx
sudo certbot --nginx -d portfolio.example.com -d www.portfolio.example.com
5. Docker alternative (serve static site with nginx container):
docker run --rm -p 80:80 -v "$(pwd):/usr/share/nginx/html:ro" nginx:stable
Customize:
- Replace `portfolio.example.com` with your real domain.
- Update contact email in `index.html` (now set to marcus.sailer@organic-server.org).
Photography gallery:
- To regenerate `images/manifest.json` after adding/removing photos, run:
```bash
python3 scripts/generate_manifest.py
```
- Alternatively, enable Nginx directory listing for `/images/` so the site can probe the directory. Example server block fragment for `/images/` only:
```
location /images/ {
alias /var/www/marcus/images/;
autoindex on;
}
```
Editing the Git link:
- Update the header Git link in `index.html` at the element with id `git-link` to your repository URL.
If you want, I can also:
- Add a Docker Compose service for a static site + automatic certs.
- Produce a small resume/JSON-LD metadata file for SEO.
Binary file not shown.

After

Width:  |  Height:  |  Size: 684 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 851 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 357 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

+27
View File
@@ -0,0 +1,27 @@
[
"DSC_0083.jpg",
"DSC_0084.jpg",
"DSC_0114.jpg",
"DSC_0122.jpg",
"DSC_0207.jpg",
"DSC_0214.jpg",
"DSC_0224.jpg",
"DSC_0234.jpg",
"DSC_0319.jpg",
"DSC_0337.jpg",
"DSC_0455.jpg",
"DSC_0470.jpg",
"_DSC0582.jpg",
"_DSC0621.jpg",
"_DSC0646.jpg",
"_DSC0649.jpg",
"_DSC0699.jpg",
"_DSC0707.jpg",
"_DSC0833.jpg",
"_DSC0858.jpg",
"_DSC1015.jpg",
"_DSC1028.jpg",
"_DSC1152.jpg",
"_DSC1174.jpg",
"_DSC1330.jpg"
]
+107
View File
@@ -0,0 +1,107 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Marcus Allison - Systems Administrator & Self-Hosting Portfolio</title>
<link rel="stylesheet" href="styles.css">
</head>
<body>
<header class="site-header">
<div class="container">
<h1>Marcus Allison</h1>
<p class="tag">Systems Administrator · Self-Hosting · Network Security</p>
<p class="header-links"><a id="git-link" href="https://git.organic-server.org">Gitea</a></p>
</div>
</header>
<main class="container">
<section id="summary">
<h2>Professional Summary</h2>
<p>I am a passionate and dedicated systems administrator and self-hosting enthusiast with a comprehensive, production-grade home lab. My infrastructure demonstrates a deep understanding of containerization, reverse proxy configuration, network security, and the deployment of a wide array of open-source services. I thrive on creating robust, private, and self-sufficient digital ecosystems.</p>
</section>
<section id="skills">
<h2>Technical Skills Showcase</h2>
<p>My portfolio is a living environment where I actively use and manage the technologies below.</p>
<div class="grid">
<div class="card">
<h3>Infrastructure & Orchestration</h3>
<p>Docker, LXC, Nginx Proxy Manager, DDNS</p>
</div>
<div class="card">
<h3>Network & Security</h3>
<p>WireGuard, Pi-hole, OpenWrt, Vaultwarden</p>
</div>
<div class="card">
<h3>Cloud & Storage</h3>
<p>Immich, Synology DSM, Zipline</p>
</div>
<div class="card">
<h3>Communication & Collaboration</h3>
<p>Matrix Synapse, Gitea</p>
</div>
<div class="card">
<h3>AI & Automation</h3>
<p>Ollama, Open WebUI, Home Assistant (HAOS)</p>
</div>
<div class="card">
<h3>Web & Data</h3>
<p>SearXNG, Kiwix, Trilium, Homarr</p>
</div>
<div class="card">
<h3>Monitoring & Time</h3>
<p>NTP server, UISP (UniFi OS Server)</p>
</div>
</div>
</section>
<section id="projects">
<h2>Key Infrastructure Projects</h2>
<ol>
<li><strong>Secure and Private Self-Hosted Ecosystem:</strong> Designed and deployed a comprehensive suite of 20+ services on a single server, replacing numerous third-party cloud services.</li>
<li><strong>Centralized Access and Security:</strong> Implemented Nginx Proxy Manager for SSL termination and reverse proxying, with WireGuard for secure admin access.</li>
<li><strong>Local AI Integration:</strong> Deployed Ollama and Open WebUI to create a private, offline AI playground.</li>
<li><strong>Resilient and Automated Network:</strong> Combined Pi-hole, OpenWrt, and DDNS to provide a stable, secure network on a dynamic IP.
</li>
<li><strong>Data Ownership and Management:</strong> Immich for photos, Vaultwarden for passwords, Gitea for code, and Synology DSM for centralized storage.</li>
</ol>
</section>
<section id="photography">
<h2>Photography</h2>
<p>A selection from my photography. Click a thumbnail for a fullscreen preview. The gallery updates when images are added or a manifest is regenerated.</p>
<div id="gallery-thumbs" class="gallery">Loading images…</div>
<div id="gallery-modal" class="modal hidden" aria-hidden="true">
<button class="modal-close" id="modal-close">×</button>
<button class="modal-prev" id="modal-prev"></button>
<img id="modal-image" src="" alt="" />
<button class="modal-next" id="modal-next"></button>
</div>
</section>
<section id="links">
<h2>Public Service Links</h2>
<p>May require authentication:</p>
<ul>
<li><a href="https://organic-server.org">organic-server.org</a></li>
<li><a href="https://ai.organic-server.org">ai.organic-server.org</a></li>
<li><a href="https://git.organic-server.org">git.organic-server.org</a></li>
<li><a href="https://share.organic-server.org">share.organic-server.org</a></li>
<li><a href="https://vault.organic-server.org">vault.organic-server.org</a></li>
</ul>
</section>
<section id="contact">
<h2>Contact</h2>
<p>If you'd like to discuss a role, contract, or collaboration, please reach out via email: <a href="mailto:marcus.sailer@organic-server.org">marcus.sailer@organic-server.org</a>.</p>
</section>
</main>
<footer class="site-footer">
<div class="container">© Marcus Allison - Self-hosting & Systems Administration</div>
</footer>
<script src="js/gallery.js"></script>
</body>
</html>
+103
View File
@@ -0,0 +1,103 @@
const IMAGE_DIR = 'images/';
const MANIFEST = IMAGE_DIR + 'manifest.json';
function parseIndexHtmlList(text) {
// crude parser to extract hrefs from an autoindex HTML listing
const hrefs = [];
const re = /href="([^"]+)"/g;
let m;
while ((m = re.exec(text)) !== null) {
const name = m[1];
if (name.match(/\.(jpe?g|png|webp|gif)$/i)) hrefs.push(name);
}
return hrefs;
}
async function loadImageList() {
// Try manifest first
try {
const r = await fetch(MANIFEST, {cache: 'no-cache'});
if (r.ok) return await r.json();
} catch (e) {
// continue to directory probe
}
try {
const r = await fetch(IMAGE_DIR, {cache: 'no-cache'});
if (r.ok) {
const text = await r.text();
return parseIndexHtmlList(text);
}
} catch (e) {
console.warn('Could not read image directory listing', e);
}
return [];
}
function createThumb(src, idx) {
const img = document.createElement('img');
img.src = src;
img.loading = 'lazy';
img.dataset.index = idx;
img.alt = `Photo ${idx+1}`;
img.addEventListener('click', () => openModal(idx));
return img;
}
let images = [];
let currentIndex = 0;
function openModal(idx) {
currentIndex = idx;
const modal = document.getElementById('gallery-modal');
const mimg = document.getElementById('modal-image');
mimg.src = IMAGE_DIR + images[idx];
modal.classList.remove('hidden');
modal.setAttribute('aria-hidden', 'false');
}
function closeModal() {
const modal = document.getElementById('gallery-modal');
modal.classList.add('hidden');
modal.setAttribute('aria-hidden', 'true');
document.getElementById('modal-image').src = '';
}
function showNext(delta=1) {
if (!images.length) return;
currentIndex = (currentIndex + delta + images.length) % images.length;
document.getElementById('modal-image').src = IMAGE_DIR + images[currentIndex];
}
function setupModalControls() {
document.getElementById('modal-close').addEventListener('click', closeModal);
document.getElementById('modal-prev').addEventListener('click', () => showNext(-1));
document.getElementById('modal-next').addEventListener('click', () => showNext(1));
document.addEventListener('keydown', (e) => {
if (document.getElementById('gallery-modal').classList.contains('hidden')) return;
if (e.key === 'Escape') closeModal();
if (e.key === 'ArrowRight') showNext(1);
if (e.key === 'ArrowLeft') showNext(-1);
});
document.getElementById('gallery-modal').addEventListener('click', (e)=>{
if (e.target.id === 'gallery-modal') closeModal();
});
}
async function initGallery() {
const list = await loadImageList();
images = list;
const container = document.getElementById('gallery-thumbs');
container.innerHTML = '';
if (!images.length) {
container.textContent = 'No images found. Create images/manifest.json or enable directory listing.';
return;
}
images.forEach((name, idx) => {
const thumb = createThumb(IMAGE_DIR + name, idx);
container.appendChild(thumb);
});
setupModalControls();
}
document.addEventListener('DOMContentLoaded', initGallery);
+27
View File
@@ -0,0 +1,27 @@
server {
listen 80;
server_name portfolio.example.com www.portfolio.example.com;
root /var/www/marcus;
index index.html;
location / {
try_files $uri $uri/ =404;
}
# Uncomment to redirect all HTTP to HTTPS if you provision certs separately:
# return 301 https://$host$request_uri;
}
# Example HTTPS block (use certbot or your CA to populate cert paths):
# server {
# listen 443 ssl http2;
# server_name portfolio.example.com;
# root /var/www/marcus;
# index index.html;
# ssl_certificate /etc/letsencrypt/live/portfolio.example.com/fullchain.pem;
# ssl_certificate_key /etc/letsencrypt/live/portfolio.example.com/privkey.pem;
# include /etc/letsencrypt/options-ssl-nginx.conf;
# ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
# location / { try_files $uri $uri/ =404; }
# }
+21
View File
@@ -0,0 +1,21 @@
#!/usr/bin/env python3
"""
Generate `images/manifest.json` from the files present in the images/ folder.
Run this in the project root to refresh the manifest after adding/removing photos.
"""
import os
import json
IMAGEDIR = os.path.join(os.path.dirname(__file__), '..', 'images')
IMAGEDIR = os.path.abspath(IMAGEDIR)
def main():
exts = ('.jpg', '.jpeg', '.png', '.webp', '.gif')
files = [f for f in sorted(os.listdir(IMAGEDIR)) if f.lower().endswith(exts)]
out = os.path.join(IMAGEDIR, 'manifest.json')
with open(out, 'w', encoding='utf-8') as fh:
json.dump(files, fh, indent=2)
print(f'Wrote {len(files)} entries to {out}')
if __name__ == '__main__':
main()
+45
View File
@@ -0,0 +1,45 @@
* { box-sizing: border-box; }
html { scroll-behavior: smooth; }
body { font-family: system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', Arial; margin:0; color:#16202A; background:#f7fafc; line-height:1.6; }
.container { max-width:980px; margin:0 auto; padding:24px; }
.site-header { background:linear-gradient(90deg,#0f172a,#0b1320); color:#fff; padding:20px 0; border-bottom:3px solid #0f62fe; }
.site-header .container { padding:20px 24px; }
.site-header h1 { margin:0; font-size:2.2rem; font-weight:700; letter-spacing:-0.5px; }
.tag { margin-top:6px; opacity:0.9; font-size:0.95rem; }
.grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(220px,1fr)); gap:16px; margin-top:16px; }
.card { background:#fff; padding:20px; border-radius:8px; box-shadow:0 1px 3px rgba(2,6,23,0.06); transition:all 0.3s ease; border:1px solid #e5e7eb; }
.card:hover { transform:translateY(-2px); box-shadow:0 8px 16px rgba(2,6,23,0.1); }
.site-footer { text-align:center; padding:24px; margin-top:40px; font-size:0.9rem; color:#6b7280; border-top:1px solid #e5e7eb; background:#fafafa; }
h2 { margin:32px 0 16px 0; font-size:1.8rem; font-weight:700; color:#0f172a; padding-bottom:12px; border-bottom:2px solid #f0f0f0; }
h3 { margin:0 0 8px 0; font-size:1.1rem; font-weight:600; color:#0f172a; }
section { margin-bottom:16px; }
p { margin:0 0 12px 0; }
ol, ul { margin:12px 0; padding-left:24px; }
li { margin-bottom:8px; }
a { color:#0f62fe; text-decoration:none; transition:color 0.2s ease; }
a:hover { color:#0b47b8; text-decoration:underline; }
#links ul { list-style:none; padding:0; display:grid; grid-template-columns:repeat(auto-fit,minmax(200px,1fr)); gap:12px; margin:16px 0; }
#links li { margin:0; }
#links a { display:block; padding:12px 16px; background:#fff; border:1px solid #e5e7eb; border-radius:6px; transition:all 0.3s ease; }
#links a:hover { border-color:#0f62fe; box-shadow:0 4px 12px rgba(15,98,254,0.15); }
@media (max-width:520px){ h2 { font-size:1.4rem; } .site-header h1 { font-size:1.6rem; } .container { padding:16px; } }
.header-links { margin-top:12px; }
.header-links a { color:#fff; background:rgba(255,255,255,0.12); padding:8px 14px; border-radius:6px; text-decoration:none; transition:all 0.2s ease; display:inline-block; font-size:0.95rem; font-weight:500; }
.header-links a:hover { background:rgba(255,255,255,0.25); }
.gallery { display:grid; grid-template-columns:repeat(auto-fill,minmax(150px,1fr)); gap:12px; margin-top:16px; }
.gallery img { width:100%; height:150px; object-fit:cover; border-radius:8px; cursor:pointer; display:block; transition:all 0.3s ease; border:2px solid transparent; }
.gallery img:hover { transform:scale(1.04); box-shadow:0 4px 12px rgba(2,6,23,0.15); border-color:#0f62fe; }
.modal { position:fixed; inset:0; background:rgba(2,6,23,0.95); display:flex; align-items:center; justify-content:center; z-index:60; backdrop-filter:blur(2px); }
.modal.hidden { display:none; }
.modal img { max-width:95%; max-height:90%; border-radius:8px; box-shadow:0 16px 48px rgba(2,6,23,0.4); animation:fadeInScale 0.3s ease; }
@keyframes fadeInScale { from { opacity:0; transform:scale(0.95); } to { opacity:1; transform:scale(1); } }
.modal-close, .modal-prev, .modal-next { position:fixed; background:rgba(255,255,255,0.1); color:#fff; border:0; font-size:36px; cursor:pointer; transition:all 0.2s ease; width:48px; height:48px; display:flex; align-items:center; justify-content:center; border-radius:6px; }
.modal-close:hover, .modal-prev:hover, .modal-next:hover { background:rgba(255,255,255,0.2); }
.modal-close { right:20px; top:20px; }
.modal-prev { left:20px; top:50%; transform:translateY(-50%); }
.modal-next { right:20px; top:50%; transform:translateY(-50%); }