-
-
-
Tools
-
Self-hosted utilities running on the homelab.
-
-
`
-}
+}
\ No newline at end of file
diff --git a/frontend/src/main.js b/frontend/src/main.js
index 40d1d92..7920bb5 100644
--- a/frontend/src/main.js
+++ b/frontend/src/main.js
@@ -1,10 +1,14 @@
import './style.css'
-import { renderHeader } from './components/header.js'
-import { renderHero } from './components/hero.js'
+import { renderHeader } from './components/header.js'
+import { renderHero } from './components/hero.js'
+import { renderExpertise } from './components/expertise.js'
+import { renderAbout } from './components/about.js'
+import { renderExperience } from './components/experience.js'
import { renderProjects } from './components/projects.js'
-import { renderTools } from './components/tools.js'
-import { renderBlog, initBlog } from './components/blog.js'
-import { renderFooter } from './components/footer.js'
+import { renderTools } from './components/tools.js'
+import { renderArticles, initArticles } from './components/articles.js'
+import { renderContact } from './components/contact.js'
+import { renderFooter } from './components/footer.js'
const app = document.getElementById('app')
@@ -12,14 +16,18 @@ app.innerHTML = `
${renderHeader()}
${renderHero()}
+ ${renderExpertise()}
+ ${renderAbout()}
+ ${renderExperience()}
${renderProjects()}
${renderTools()}
- ${renderBlog()}
+ ${renderArticles()}
+ ${renderContact()}
${renderFooter()}
`
-// Scroll reveals
+// Intersection Observer for scroll reveals
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
@@ -31,7 +39,15 @@ const observer = new IntersectionObserver(
},
{ threshold: 0.1 }
)
+
document.querySelectorAll('.reveal').forEach((el) => observer.observe(el))
-// Blog interactivity
-initBlog()
\ No newline at end of file
+initArticles()
+
+// If the URL already points to an anchor (e.g. an article deep link),
+// jump to it now — the browser's native auto-scroll only works for
+// elements present at initial page load, and ours are inserted after.
+if (location.hash) {
+ const target = document.querySelector(location.hash)
+ if (target) target.scrollIntoView()
+}
\ No newline at end of file
diff --git a/frontend/src/style.css b/frontend/src/style.css
index 7d88d54..87b0e5a 100644
--- a/frontend/src/style.css
+++ b/frontend/src/style.css
@@ -1,15 +1,20 @@
/* ── Design tokens ── */
:root {
- --bg: #0a0a0a;
- --bg-card: #111111;
- --border: #1e1e1e;
- --accent: #c8ff00; /* electric lime */
- --accent-2: #ff6b35; /* orange pop */
- --text: #e8e8e8;
- --muted: #555;
+ --bg: #faf9f5;
+ --bg-card: #eae9e9;
+ --border: color-mix(in srgb, #201e1d 16%, transparent);
+ --border-strong: color-mix(in srgb, #201e1d 40%, transparent);
+ --accent: #ec3013;
+ --accent-600:#dd2b0f;
+ --accent-700:#ae1800;
+ --accent-100:#fff2ef;
+ --accent-800:#7c1405;
+ --text: #201e1d;
+ --muted: color-mix(in srgb, #201e1d 60%, transparent);
+ --muted-2: color-mix(in srgb, #201e1d 78%, transparent);
--font-mono: 'Space Mono', monospace;
- --font-sans: 'Syne', sans-serif;
- --radius: 4px;
+ --font-sans: 'Archivo', sans-serif;
+ --radius: 0px;
--max-w: 1100px;
}
@@ -28,17 +33,6 @@ body {
overflow-x: hidden;
}
-/* ── Noise overlay ── */
-body::before {
- content: '';
- position: fixed;
- inset: 0;
- background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noise'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noise)' opacity='0.04'/%3E%3C/svg%3E");
- pointer-events: none;
- z-index: 9999;
- opacity: 0.4;
-}
-
/* ── Layout ── */
.container {
max-width: var(--max-w);
@@ -47,8 +41,9 @@ body::before {
}
/* ── Typography ── */
-h1, h2, h3 { font-family: var(--font-sans); font-weight: 800; line-height: 1.1; }
+h1, h2, h3, h4 { font-family: var(--font-sans); font-weight: 800; line-height: 1.1; letter-spacing: -0.015em; }
code, .mono { font-family: var(--font-mono); font-size: 0.85em; }
+a { color: var(--accent); }
/* ── Scroll reveal ── */
.reveal {
@@ -61,23 +56,45 @@ code, .mono { font-family: var(--font-mono); font-size: 0.85em; }
transform: none;
}
-/* ── Accent line ── */
-.accent-line {
- display: inline-block;
- width: 40px;
- height: 3px;
- background: var(--accent);
- margin-bottom: 16px;
+/* ── Divider ── */
+.hr { height: 2px; border: 0; background: var(--border-strong); margin: 0 auto; max-width: var(--max-w); }
+
+/* ── Buttons ── */
+.btn {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: 6px;
+ cursor: pointer;
+ text-decoration: none;
+ font-family: var(--font-sans);
+ font-weight: 800;
+ font-size: 0.9rem;
+ line-height: 1.2;
+ color: var(--text);
+ background: transparent;
+ border: 1px solid transparent;
+ padding: 10px 18px;
+ border-radius: var(--radius);
+ white-space: nowrap;
+ transition: background 0.2s, color 0.2s, border-color 0.2s;
}
+.btn-primary { background: var(--accent); color: var(--bg); }
+.btn-primary:hover { background: var(--accent-600); }
+.btn-secondary { border-color: var(--border-strong); }
+.btn-secondary:hover { background: color-mix(in srgb, var(--text) 7%, transparent); }
+.btn-ghost { color: var(--accent); padding-inline: 4px; }
/* ── Tag pill ── */
.tag {
- display: inline-block;
+ display: inline-flex;
+ align-items: center;
font-family: var(--font-mono);
font-size: 0.7rem;
- padding: 2px 8px;
- border: 1px solid var(--border);
- border-radius: var(--radius);
- color: var(--muted);
- letter-spacing: 0.05em;
-}
\ No newline at end of file
+ padding: 3px 10px;
+ border-radius: 2px;
+ letter-spacing: 0.03em;
+}
+.tag-neutral { background: var(--bg-card); color: var(--muted-2); }
+.tag-accent { background: var(--accent-100); color: var(--accent-800); }
+.tag-outline { border: 1px solid var(--accent); color: var(--accent); }
\ No newline at end of file
diff --git a/hit-counter/Dockerfile b/hit-counter/Dockerfile
new file mode 100644
index 0000000..7ceb347
--- /dev/null
+++ b/hit-counter/Dockerfile
@@ -0,0 +1,6 @@
+FROM python:3.12-slim
+WORKDIR /app
+RUN pip install --no-cache-dir fastapi uvicorn[standard] prometheus_client
+COPY main.py .
+EXPOSE 8000
+CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
\ No newline at end of file
diff --git a/hit-counter/main.py b/hit-counter/main.py
new file mode 100644
index 0000000..3b7153f
--- /dev/null
+++ b/hit-counter/main.py
@@ -0,0 +1,120 @@
+"""
+hit-counter — minimal per-slug visit counter, with basic abuse protection
+and a Prometheus /metrics endpoint for Grafana.
+
+Throttling (in-memory, fine at this scale):
+ 1. Per (ip, slug) cooldown — same visitor re-hitting the same article
+ within COOLDOWN_SECONDS doesn't increment the count again.
+ 2. Per-ip global rate limit — caps total requests/minute from one IP.
+
+State resets on container restart — acceptable for this use case.
+"""
+import time
+from collections import defaultdict
+from pathlib import Path
+import sqlite3
+
+from fastapi import FastAPI, Request, HTTPException, Response
+from fastapi.middleware.cors import CORSMiddleware
+from prometheus_client import Gauge, generate_latest, CONTENT_TYPE_LATEST
+
+DB_PATH = Path("/data/hits.db")
+DB_PATH.parent.mkdir(parents=True, exist_ok=True)
+
+COOLDOWN_SECONDS = 30 * 60 # one counted hit per (ip, slug) per 30 min
+RATE_LIMIT_WINDOW = 60 # seconds
+RATE_LIMIT_MAX_REQUESTS = 20 # requests per ip per window, across all endpoints
+
+app = FastAPI()
+
+app.add_middleware(
+ CORSMiddleware,
+ allow_origins=["https://carloselugo.com"],
+ allow_methods=["GET", "POST"],
+ allow_headers=["*"],
+)
+
+# (ip, slug) -> last counted timestamp
+_last_hit: dict[tuple[str, str], float] = {}
+# ip -> list of recent request timestamps
+_request_log: dict[str, list[float]] = defaultdict(list)
+
+# Prometheus gauge, one series per article slug.
+# Gauge (not Counter) because we set it directly from the SQLite total
+# on every /metrics scrape — Prometheus itself builds the time series
+# from repeated scrapes, so we don't need to track history ourselves.
+article_hits_gauge = Gauge("article_hits_total", "Total counted views per article", ["slug"])
+
+
+def get_client_ip(request: Request) -> str:
+ # Caddy sets X-Forwarded-For when reverse-proxying.
+ forwarded = request.headers.get("x-forwarded-for")
+ if forwarded:
+ return forwarded.split(",")[0].strip()
+ return request.client.host if request.client else "unknown"
+
+
+def enforce_rate_limit(ip: str):
+ now = time.time()
+ log = _request_log[ip]
+ while log and now - log[0] > RATE_LIMIT_WINDOW:
+ log.pop(0)
+ if len(log) >= RATE_LIMIT_MAX_REQUESTS:
+ raise HTTPException(status_code=429, detail="Too many requests")
+ log.append(now)
+
+
+def get_db():
+ conn = sqlite3.connect(DB_PATH)
+ conn.execute(
+ "CREATE TABLE IF NOT EXISTS hits (slug TEXT PRIMARY KEY, count INTEGER NOT NULL DEFAULT 0)"
+ )
+ return conn
+
+
+@app.post("/hit/{slug}")
+def register_hit(slug: str, request: Request):
+ ip = get_client_ip(request)
+ enforce_rate_limit(ip)
+
+ now = time.time()
+ key = (ip, slug)
+ should_count = now - _last_hit.get(key, 0) > COOLDOWN_SECONDS
+
+ conn = get_db()
+ if should_count:
+ conn.execute(
+ "INSERT INTO hits (slug, count) VALUES (?, 1) "
+ "ON CONFLICT(slug) DO UPDATE SET count = count + 1",
+ (slug,),
+ )
+ conn.commit()
+ _last_hit[key] = now
+
+ total = conn.execute("SELECT count FROM hits WHERE slug = ?", (slug,)).fetchone()
+ conn.close()
+
+ count = total[0] if total else 0
+ article_hits_gauge.labels(slug=slug).set(count)
+ return {"slug": slug, "count": count, "counted": should_count}
+
+
+@app.get("/hits/{slug}")
+def get_hits(slug: str, request: Request):
+ enforce_rate_limit(get_client_ip(request))
+ conn = get_db()
+ row = conn.execute("SELECT count FROM hits WHERE slug = ?", (slug,)).fetchone()
+ conn.close()
+ return {"slug": slug, "count": row[0] if row else 0}
+
+
+@app.get("/metrics")
+def metrics():
+ # Refresh every gauge from the source of truth (SQLite) before
+ # Prometheus scrapes, so /metrics is correct even right after a restart.
+ conn = get_db()
+ rows = conn.execute("SELECT slug, count FROM hits").fetchall()
+ conn.close()
+ for slug, count in rows:
+ article_hits_gauge.labels(slug=slug).set(count)
+ return Response(content=generate_latest(), media_type=CONTENT_TYPE_LATEST)
\ No newline at end of file
diff --git a/prometheus/prometheus.yml b/prometheus/prometheus.yml
index 5e76f24..7f5f13f 100644
--- a/prometheus/prometheus.yml
+++ b/prometheus/prometheus.yml
@@ -14,3 +14,8 @@ scrape_configs:
- job_name: "cadvisor"
static_configs:
- targets: ["cadvisor:8080"]
+
+ - job_name: "hit-counter"
+ scrape_interval: 5m
+ static_configs:
+ - targets: ["hit-counter:8000"]