Inital upload
@@ -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.
|
||||
|
||||
|
After Width: | Height: | Size: 684 KiB |
|
After Width: | Height: | Size: 1.6 MiB |
|
After Width: | Height: | Size: 2.9 MiB |
|
After Width: | Height: | Size: 3.7 MiB |
|
After Width: | Height: | Size: 2.3 MiB |
|
After Width: | Height: | Size: 1.8 MiB |
|
After Width: | Height: | Size: 851 KiB |
|
After Width: | Height: | Size: 357 KiB |
|
After Width: | Height: | Size: 2.7 MiB |
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 2.0 MiB |
|
After Width: | Height: | Size: 2.0 MiB |
|
After Width: | Height: | Size: 2.4 MiB |
|
After Width: | Height: | Size: 2.5 MiB |
|
After Width: | Height: | Size: 3.3 MiB |
|
After Width: | Height: | Size: 2.8 MiB |
|
After Width: | Height: | Size: 7.7 MiB |
|
After Width: | Height: | Size: 6.5 MiB |
|
After Width: | Height: | Size: 4.8 MiB |
|
After Width: | Height: | Size: 2.6 MiB |
|
After Width: | Height: | Size: 6.4 MiB |
|
After Width: | Height: | Size: 4.3 MiB |
|
After Width: | Height: | Size: 2.4 MiB |
|
After Width: | Height: | Size: 1.5 MiB |
|
After Width: | Height: | Size: 1.1 MiB |
@@ -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"
|
||||
]
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
@@ -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; }
|
||||
# }
|
||||
@@ -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()
|
||||
@@ -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%); }
|
||||
|
||||