diff --git a/README.md b/README.md index 70c78c3..94f6df5 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/images/DSC_0083.jpg b/images/DSC_0083.jpg new file mode 100644 index 0000000..ff95aad Binary files /dev/null and b/images/DSC_0083.jpg differ diff --git a/images/DSC_0084.jpg b/images/DSC_0084.jpg new file mode 100644 index 0000000..73b1d33 Binary files /dev/null and b/images/DSC_0084.jpg differ diff --git a/images/DSC_0114.jpg b/images/DSC_0114.jpg new file mode 100644 index 0000000..8068dff Binary files /dev/null and b/images/DSC_0114.jpg differ diff --git a/images/DSC_0122.jpg b/images/DSC_0122.jpg new file mode 100644 index 0000000..5a542c1 Binary files /dev/null and b/images/DSC_0122.jpg differ diff --git a/images/DSC_0207.jpg b/images/DSC_0207.jpg new file mode 100644 index 0000000..e721875 Binary files /dev/null and b/images/DSC_0207.jpg differ diff --git a/images/DSC_0214.jpg b/images/DSC_0214.jpg new file mode 100644 index 0000000..19873b5 Binary files /dev/null and b/images/DSC_0214.jpg differ diff --git a/images/DSC_0224.jpg b/images/DSC_0224.jpg new file mode 100644 index 0000000..8c1a2f3 Binary files /dev/null and b/images/DSC_0224.jpg differ diff --git a/images/DSC_0234.jpg b/images/DSC_0234.jpg new file mode 100644 index 0000000..9f25859 Binary files /dev/null and b/images/DSC_0234.jpg differ diff --git a/images/DSC_0319.jpg b/images/DSC_0319.jpg new file mode 100644 index 0000000..3a4b6f2 Binary files /dev/null and b/images/DSC_0319.jpg differ diff --git a/images/DSC_0337.jpg b/images/DSC_0337.jpg new file mode 100644 index 0000000..8cf4258 Binary files /dev/null and b/images/DSC_0337.jpg differ diff --git a/images/DSC_0455.jpg b/images/DSC_0455.jpg new file mode 100644 index 0000000..f2e98fa Binary files /dev/null and b/images/DSC_0455.jpg differ diff --git a/images/DSC_0470.jpg b/images/DSC_0470.jpg new file mode 100644 index 0000000..a080098 Binary files /dev/null and b/images/DSC_0470.jpg differ diff --git a/images/_DSC0582.jpg b/images/_DSC0582.jpg new file mode 100644 index 0000000..03ba86c Binary files /dev/null and b/images/_DSC0582.jpg differ diff --git a/images/_DSC0621.jpg b/images/_DSC0621.jpg new file mode 100644 index 0000000..e822faf Binary files /dev/null and b/images/_DSC0621.jpg differ diff --git a/images/_DSC0646.jpg b/images/_DSC0646.jpg new file mode 100644 index 0000000..16bed59 Binary files /dev/null and b/images/_DSC0646.jpg differ diff --git a/images/_DSC0649.jpg b/images/_DSC0649.jpg new file mode 100644 index 0000000..bf79e5a Binary files /dev/null and b/images/_DSC0649.jpg differ diff --git a/images/_DSC0699.jpg b/images/_DSC0699.jpg new file mode 100644 index 0000000..5f69316 Binary files /dev/null and b/images/_DSC0699.jpg differ diff --git a/images/_DSC0707.jpg b/images/_DSC0707.jpg new file mode 100644 index 0000000..7feed02 Binary files /dev/null and b/images/_DSC0707.jpg differ diff --git a/images/_DSC0833.jpg b/images/_DSC0833.jpg new file mode 100644 index 0000000..fa0a3c0 Binary files /dev/null and b/images/_DSC0833.jpg differ diff --git a/images/_DSC0858.jpg b/images/_DSC0858.jpg new file mode 100644 index 0000000..056b8c2 Binary files /dev/null and b/images/_DSC0858.jpg differ diff --git a/images/_DSC1015.jpg b/images/_DSC1015.jpg new file mode 100644 index 0000000..906aca7 Binary files /dev/null and b/images/_DSC1015.jpg differ diff --git a/images/_DSC1028.jpg b/images/_DSC1028.jpg new file mode 100644 index 0000000..d058bf6 Binary files /dev/null and b/images/_DSC1028.jpg differ diff --git a/images/_DSC1152.jpg b/images/_DSC1152.jpg new file mode 100644 index 0000000..2a648e8 Binary files /dev/null and b/images/_DSC1152.jpg differ diff --git a/images/_DSC1174.jpg b/images/_DSC1174.jpg new file mode 100644 index 0000000..75f7acc Binary files /dev/null and b/images/_DSC1174.jpg differ diff --git a/images/_DSC1330.jpg b/images/_DSC1330.jpg new file mode 100644 index 0000000..dc09a8f Binary files /dev/null and b/images/_DSC1330.jpg differ diff --git a/images/manifest.json b/images/manifest.json new file mode 100644 index 0000000..8ad657e --- /dev/null +++ b/images/manifest.json @@ -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" +] diff --git a/index.html b/index.html new file mode 100644 index 0000000..f845b86 --- /dev/null +++ b/index.html @@ -0,0 +1,107 @@ + + + + + + Marcus Allison - Systems Administrator & Self-Hosting Portfolio + + + + + +
+
+

Professional Summary

+

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.

+
+ +
+

Technical Skills Showcase

+

My portfolio is a living environment where I actively use and manage the technologies below.

+
+
+

Infrastructure & Orchestration

+

Docker, LXC, Nginx Proxy Manager, DDNS

+
+
+

Network & Security

+

WireGuard, Pi-hole, OpenWrt, Vaultwarden

+
+
+

Cloud & Storage

+

Immich, Synology DSM, Zipline

+
+
+

Communication & Collaboration

+

Matrix Synapse, Gitea

+
+
+

AI & Automation

+

Ollama, Open WebUI, Home Assistant (HAOS)

+
+
+

Web & Data

+

SearXNG, Kiwix, Trilium, Homarr

+
+
+

Monitoring & Time

+

NTP server, UISP (UniFi OS Server)

+
+
+
+ +
+

Key Infrastructure Projects

+
    +
  1. Secure and Private Self-Hosted Ecosystem: Designed and deployed a comprehensive suite of 20+ services on a single server, replacing numerous third-party cloud services.
  2. +
  3. Centralized Access and Security: Implemented Nginx Proxy Manager for SSL termination and reverse proxying, with WireGuard for secure admin access.
  4. +
  5. Local AI Integration: Deployed Ollama and Open WebUI to create a private, offline AI playground.
  6. +
  7. Resilient and Automated Network: Combined Pi-hole, OpenWrt, and DDNS to provide a stable, secure network on a dynamic IP. +
  8. +
  9. Data Ownership and Management: Immich for photos, Vaultwarden for passwords, Gitea for code, and Synology DSM for centralized storage.
  10. +
+
+ +
+

Photography

+

A selection from my photography. Click a thumbnail for a fullscreen preview. The gallery updates when images are added or a manifest is regenerated.

+ + + +
+ + + +
+

Contact

+

If you'd like to discuss a role, contract, or collaboration, please reach out via email: marcus.sailer@organic-server.org.

+
+
+ + + + + \ No newline at end of file diff --git a/js/gallery.js b/js/gallery.js new file mode 100644 index 0000000..2928f12 --- /dev/null +++ b/js/gallery.js @@ -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); diff --git a/nginx/marcus_allison.conf b/nginx/marcus_allison.conf new file mode 100644 index 0000000..1c74e7f --- /dev/null +++ b/nginx/marcus_allison.conf @@ -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; } +# } diff --git a/scripts/generate_manifest.py b/scripts/generate_manifest.py new file mode 100644 index 0000000..8c5c0d8 --- /dev/null +++ b/scripts/generate_manifest.py @@ -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() diff --git a/styles.css b/styles.css new file mode 100644 index 0000000..f474e87 --- /dev/null +++ b/styles.css @@ -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%); } +