diff --git a/INVENTORY.md b/INVENTORY.md
index 4c58d60..d25bee4 100644
--- a/INVENTORY.md
+++ b/INVENTORY.md
@@ -13,6 +13,7 @@ Private repo. Laatst bijgewerkt vanaf NAS `192.168.1.211`.
| DuckDNS | [apps/duckdns](apps/duckdns/) | — | running |
| Neo4j | [apps/neo4j](apps/neo4j/) | :49153–49155 | running |
| Homarr | [apps/homarr](apps/homarr/) | :4755 | running |
+| Homepage | [apps/homepage](apps/homepage/) | http://192.168.1.192:3000 (pve CT 120) | running |
| Portainer | [apps/portainer](apps/portainer/) | :9000 | running |
| Remotely | [apps/remotely](apps/remotely/) | :8080 | running |
| Excalidraw | [apps/excalidraw](apps/excalidraw/) | :3765 | running |
diff --git a/apps/homepage/README.md b/apps/homepage/README.md
new file mode 100644
index 0000000..07a3d5f
--- /dev/null
+++ b/apps/homepage/README.md
@@ -0,0 +1,89 @@
+# Homepage (gethomepage.dev)
+
+Palantir-stijl homelab-dashboard.
+
+| | |
+|---|---|
+| **URL** | http://192.168.1.192:3000 |
+| **Proxmox** | pve CT 120 (`192.168.1.216`) |
+
+## Grafisch toevoegen (Admin UI)
+
+Open in je browser:
+
+**http://192.168.1.211:3010**
+
+Eerste keer starten op de NAS:
+
+```bash
+cd /volume1/docker/homelab-configs/apps/homepage
+./start-admin.sh
+```
+
+1. Vul naam + URL in → **Toevoegen**
+2. Klik **Toepassen op Homepage** (deploy naar Proxmox)
+3. Refresh http://192.168.1.192:3000
+
+---
+
+## Snel een website toevoegen (terminal)
+
+**Niet** in `services.yaml` zoeken — gebruik **`websites-add.txt`** of het script:
+
+### Optie A — interactief (makkelijkst)
+
+```bash
+cd /volume1/docker/homelab-configs/apps/homepage
+./add-website.sh
+```
+
+### Optie B — één commando
+
+```bash
+./add-website.sh "Jellyfin" "http://192.168.1.10:8096" "Media & TV"
+```
+
+### Optie C — regel toevoegen in `websites-add.txt`
+
+```
+Productivity|Mijn app|http://192.168.1.150:8080
+AI Assistants|Claude|https://claude.ai
+```
+
+Daarna: `./apply.sh` (genereert config + deploy)
+
+**Groepen:** `Infrastructure`, `Media & TV`, `Smart Home`, `Productivity`, `Tools & Utils`, `AI Assistants`, `Dev & Docs`, `Web Design`
+
+## Tabs
+
+| Tab | Inhoud |
+|-----|--------|
+| **Ops** | Infrastructure, Tools & Utils |
+| **Media** | Media & TV |
+| **Home** | Smart Home |
+| **Work** | Productivity |
+| **AI** | AI Assistants, Dev & Docs, Web Design |
+| **Feeds** | RSS + bookmarks |
+
+## Overige commando's
+
+```bash
+./set-logo.sh /pad/naar/foto.jpg # profielfoto
+python3 generate-config.py # Homarr + websites-add.txt → config/
+./deploy-to-pve.sh # alleen deploy
+./apply.sh # generate + deploy
+```
+
+## Config-bestanden
+
+| Bestand | Wie bewerkt |
+|---------|-------------|
+| **`websites-add.txt`** | **Jij** — extra links (1 regel per site) |
+| `config/services.yaml` | Automatisch gegenereerd |
+| `config/settings.yaml` | Tabs, layout |
+| `config/widgets.yaml` | Logo, klok, zoeken |
+| `config/custom.css` | Thema |
+
+Homarr-sync: wijzigingen in Homarr → `python3 generate-config.py` → `./apply.sh`
+
+Docs: https://gethomepage.dev/
diff --git a/apps/homepage/add-website.sh b/apps/homepage/add-website.sh
new file mode 100644
index 0000000..b77dc38
--- /dev/null
+++ b/apps/homepage/add-website.sh
@@ -0,0 +1,59 @@
+#!/bin/sh
+# Voeg snel een website toe: ./add-website.sh of ./add-website.sh "Naam" "url" "groep"
+set -e
+APP="$(cd "$(dirname "$0")" && pwd)"
+FILE="$APP/websites-add.txt"
+
+usage() {
+ echo "Gebruik:"
+ echo " $0"
+ echo " $0 \"Naam\" \"http://url\" [groep]"
+ echo ""
+ echo "Groepen: Infrastructure | \"Media & TV\" | \"Smart Home\" | Productivity"
+ echo " \"Tools & Utils\" | \"AI Assistants\" | \"Dev & Docs\" | \"Web Design\""
+ exit 1
+}
+
+pick_group() {
+ echo "Kies groep:"
+ echo " 1) Infrastructure 5) Tools & Utils"
+ echo " 2) Media & TV 6) AI Assistants"
+ echo " 3) Smart Home 7) Dev & Docs"
+ echo " 4) Productivity 8) Web Design"
+ printf "> "
+ read -r n
+ case "$n" in
+ 1) echo "Infrastructure" ;;
+ 2) echo "Media & TV" ;;
+ 3) echo "Smart Home" ;;
+ 4) echo "Productivity" ;;
+ 5) echo "Tools & Utils" ;;
+ 6) echo "AI Assistants" ;;
+ 7) echo "Dev & Docs" ;;
+ 8) echo "Web Design" ;;
+ *) echo "Productivity" ;;
+ esac
+}
+
+if [ $# -eq 0 ]; then
+ printf "Naam: "
+ read -r NAME
+ printf "URL: "
+ read -r URL
+ GROUP=$(pick_group)
+elif [ $# -ge 2 ]; then
+ NAME="$1"
+ URL="$2"
+ GROUP="${3:-Productivity}"
+else
+ usage
+fi
+
+[ -n "$NAME" ] && [ -n "$URL" ] || usage
+
+# Pipe in naam/url escapen
+NAME_ESC=$(printf '%s' "$NAME" | sed 's/|/-/g')
+echo "${GROUP}|${NAME_ESC}|${URL}" >> "$FILE"
+echo "Toegevoegd: ${GROUP} | ${NAME} | ${URL}"
+echo ""
+"$APP/apply.sh"
diff --git a/apps/homepage/admin/Dockerfile b/apps/homepage/admin/Dockerfile
new file mode 100644
index 0000000..d407b6b
--- /dev/null
+++ b/apps/homepage/admin/Dockerfile
@@ -0,0 +1,17 @@
+FROM python:3.11-slim-bookworm
+
+RUN apt-get update && apt-get install -y --no-install-recommends \
+ docker.io curl \
+ && rm -rf /var/lib/apt/lists/*
+
+WORKDIR /app
+COPY requirements.txt .
+RUN pip install --no-cache-dir -r requirements.txt
+
+COPY server.py .
+COPY static ./static
+
+ENV PYTHONUNBUFFERED=1
+EXPOSE 3010
+
+CMD ["python", "server.py"]
diff --git a/apps/homepage/admin/requirements.txt b/apps/homepage/admin/requirements.txt
new file mode 100644
index 0000000..ada379a
--- /dev/null
+++ b/apps/homepage/admin/requirements.txt
@@ -0,0 +1,3 @@
+fastapi==0.115.6
+uvicorn[standard]==0.32.1
+pydantic==2.10.3
diff --git a/apps/homepage/admin/server.py b/apps/homepage/admin/server.py
new file mode 100644
index 0000000..f21ec91
--- /dev/null
+++ b/apps/homepage/admin/server.py
@@ -0,0 +1,208 @@
+#!/usr/bin/env python3
+"""Homepage Admin UI — sites toevoegen via browser."""
+import os
+import re
+import subprocess
+import threading
+from pathlib import Path
+
+from fastapi import FastAPI, HTTPException
+from fastapi.responses import FileResponse, HTMLResponse
+from fastapi.staticfiles import StaticFiles
+from pydantic import BaseModel
+
+APP_ROOT = Path(os.environ.get("HOMEPAGE_DIR", Path(__file__).resolve().parent.parent))
+WEBSITES_ADD = APP_ROOT / "websites-add.txt"
+HOMELAB_APPS = APP_ROOT.parent
+GENERATE = APP_ROOT / "generate-config.py"
+DEPLOY = APP_ROOT / "deploy-to-pve.sh"
+
+GROUPS = [
+ "Infrastructure",
+ "Media & TV",
+ "Smart Home",
+ "Productivity",
+ "Tools & Utils",
+ "AI Assistants",
+ "Dev & Docs",
+ "Web Design",
+]
+
+TAB_FOR_GROUP = {
+ "Infrastructure": "Ops",
+ "Media & TV": "Media",
+ "Smart Home": "Home",
+ "Productivity": "Work",
+ "Tools & Utils": "Ops",
+ "AI Assistants": "AI",
+ "Dev & Docs": "AI",
+ "Web Design": "AI",
+}
+
+_apply_lock = threading.Lock()
+_apply_status = {"running": False, "ok": None, "log": ""}
+
+
+class SiteIn(BaseModel):
+ group: str
+ name: str
+ url: str
+
+
+class SiteOut(BaseModel):
+ id: int
+ group: str
+ name: str
+ url: str
+ tab: str
+
+
+def read_sites() -> list[dict]:
+ if not WEBSITES_ADD.exists():
+ WEBSITES_ADD.write_text(
+ "# groep|naam|url\n# Of gebruik de UI op poort 3010\n\n",
+ encoding="utf-8",
+ )
+ rows = []
+ for i, raw in enumerate(WEBSITES_ADD.read_text(encoding="utf-8").splitlines()):
+ line = raw.strip()
+ if not line or line.startswith("#"):
+ continue
+ parts = [p.strip() for p in line.split("|", 2)]
+ if len(parts) != 3:
+ continue
+ g, n, u = parts
+ rows.append(
+ {
+ "id": len(rows),
+ "group": g,
+ "name": n,
+ "url": u,
+ "tab": TAB_FOR_GROUP.get(g, "?"),
+ }
+ )
+ return rows
+
+
+def write_sites(rows: list[tuple[str, str, str]]) -> None:
+ lines = [
+ "# Extra websites — beheerd via Homepage Admin UI",
+ "# groep|naam|url",
+ "",
+ ]
+ for g, n, u in rows:
+ n = n.replace("|", "-")
+ lines.append(f"{g}|{n}|{u}")
+ lines.append("")
+ WEBSITES_ADD.write_text("\n".join(lines), encoding="utf-8")
+
+
+def run_apply():
+ global _apply_status
+ with _apply_lock:
+ _apply_status = {"running": True, "ok": None, "log": "Start…\n"}
+ try:
+ gen = subprocess.run(
+ ["python3", str(GENERATE)],
+ cwd=str(APP_ROOT),
+ capture_output=True,
+ text=True,
+ timeout=120,
+ )
+ _apply_status["log"] += gen.stdout or ""
+ if gen.stderr:
+ _apply_status["log"] += gen.stderr
+ if gen.returncode != 0:
+ _apply_status["ok"] = False
+ _apply_status["log"] += "\nFout bij generate-config.py"
+ return
+
+ _apply_status["log"] += "\nDeploy naar Proxmox…\n"
+ dep = subprocess.run(
+ ["/bin/sh", str(DEPLOY)],
+ cwd=str(APP_ROOT),
+ capture_output=True,
+ text=True,
+ timeout=300,
+ env={**os.environ},
+ )
+ _apply_status["log"] += dep.stdout or ""
+ if dep.stderr:
+ _apply_status["log"] += dep.stderr
+ _apply_status["ok"] = dep.returncode == 0
+ if _apply_status["ok"]:
+ _apply_status["log"] += "\n✓ Klaar — refresh Homepage (Ctrl+Shift+R)"
+ else:
+ _apply_status["log"] += "\n✗ Deploy mislukt"
+ except Exception as e:
+ _apply_status["ok"] = False
+ _apply_status["log"] += f"\nFout: {e}"
+ finally:
+ _apply_status["running"] = False
+
+
+app = FastAPI(title="Homepage Admin")
+static_dir = Path(__file__).parent / "static"
+app.mount("/static", StaticFiles(directory=str(static_dir)), name="static")
+
+
+@app.get("/", response_class=HTMLResponse)
+def index():
+ return FileResponse(static_dir / "index.html")
+
+
+@app.get("/api/groups")
+def api_groups():
+ return [{"name": g, "tab": TAB_FOR_GROUP.get(g, "")} for g in GROUPS]
+
+
+@app.get("/api/sites")
+def api_sites():
+ return read_sites()
+
+
+@app.post("/api/sites")
+def api_add(site: SiteIn):
+ if site.group not in GROUPS:
+ raise HTTPException(400, f"Onbekende groep: {site.group}")
+ name = site.name.strip()
+ url = site.url.strip()
+ if not name or not url:
+ raise HTTPException(400, "Naam en URL zijn verplicht")
+ if not re.match(r"^https?://", url, re.I):
+ raise HTTPException(400, "URL moet beginnen met http:// of https://")
+
+ rows = [(s["group"], s["name"], s["url"]) for s in read_sites()]
+ rows.append((site.group, name, url))
+ write_sites(rows)
+ return {"ok": True}
+
+
+@app.delete("/api/sites/{site_id}")
+def api_delete(site_id: int):
+ sites = read_sites()
+ if site_id < 0 or site_id >= len(sites):
+ raise HTTPException(404, "Site niet gevonden")
+ rows = [(s["group"], s["name"], s["url"]) for i, s in enumerate(sites) if i != site_id]
+ write_sites(rows)
+ return {"ok": True}
+
+
+@app.post("/api/apply")
+def api_apply():
+ if _apply_status.get("running"):
+ raise HTTPException(409, "Bezig met toepassen…")
+ t = threading.Thread(target=run_apply, daemon=True)
+ t.start()
+ return {"ok": True, "message": "Gestart"}
+
+
+@app.get("/api/apply/status")
+def api_apply_status():
+ return _apply_status
+
+
+if __name__ == "__main__":
+ import uvicorn
+
+ uvicorn.run(app, host="0.0.0.0", port=3010)
diff --git a/apps/homepage/admin/static/index.html b/apps/homepage/admin/static/index.html
new file mode 100644
index 0000000..cf7eefd
--- /dev/null
+++ b/apps/homepage/admin/static/index.html
@@ -0,0 +1,260 @@
+
+
+
+
+
+ Homepage Admin — EL-KADI OPS
+
+
+
+
+
+
+
+ Homepage AdminWebsites toevoegen
+
+
+
+
+
+
+
+
+ Dashboard bijwerken
+
+ Na toevoegen of verwijderen: klik hieronder. Dit genereert de config en deployt naar Proxmox (±1 min).
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/homepage/apply.sh b/apps/homepage/apply.sh
new file mode 100644
index 0000000..9653003
--- /dev/null
+++ b/apps/homepage/apply.sh
@@ -0,0 +1,9 @@
+#!/bin/sh
+# Genereer config + deploy naar Proxmox (na wijziging websites.yaml of Homarr)
+set -e
+APP="$(cd "$(dirname "$0")" && pwd)"
+cd "$APP"
+echo "=== Config genereren (Homarr + websites.yaml) ==="
+python3 generate-config.py
+echo "=== Deploy naar CT 120 ==="
+./deploy-to-pve.sh
diff --git a/apps/homepage/config/bookmarks.yaml b/apps/homepage/config/bookmarks.yaml
new file mode 100644
index 0000000..7839150
--- /dev/null
+++ b/apps/homepage/config/bookmarks.yaml
@@ -0,0 +1,60 @@
+---
+- Tech News:
+ - Hacker News:
+ - abbr: HN
+ href: https://hnrss.org/frontpage
+ - Ars Technica:
+ - abbr: AT
+ href: https://feeds.arstechnica.com/arstechnica/index
+ - The Verge:
+ - abbr: TV
+ href: https://www.theverge.com/rss/index.xml
+ - Lobsters:
+ - abbr: L
+ href: https://lobste.rs/rss
+- Security:
+ - BleepingComputer:
+ - abbr: B
+ href: https://www.bleepingcomputer.com/feed/
+ - Krebs on Security:
+ - abbr: KO
+ href: https://krebsonsecurity.com/feed/
+ - The Hacker News:
+ - abbr: TH
+ href: https://feeds.feedburner.com/TheHackersNews
+- Homelab:
+ - selfh.st:
+ - abbr: SS
+ href: https://selfh.st/rss/
+ - Proxmox Forum:
+ - abbr: PF
+ href: https://forum.proxmox.com/forums/-/index.rss
+ - r/selfhosted:
+ - abbr: RS
+ href: https://www.reddit.com/r/selfhosted/.rss
+ - Docker Blog:
+ - abbr: DB
+ href: https://www.docker.com/blog/feed/
+ - LinuxServer.io:
+ - abbr: LI
+ href: https://www.linuxserver.io/blog/rss/
+ - Marius Hosting:
+ - abbr: MH
+ href: https://mariushosting.com/feed/
+- AI & Dev:
+ - OpenAI Blog:
+ - abbr: OB
+ href: https://openai.com/blog/rss.xml
+ - Hugging Face Blog:
+ - abbr: HF
+ href: https://huggingface.co/blog/feed.xml
+ - GitHub Blog:
+ - abbr: GB
+ href: https://github.blog/feed/
+- Blogs:
+ - Jack van lightly:
+ - abbr: JV
+ href: https://jack-vanlightly.com/feed.xml
+ - Juhache:
+ - abbr: J
+ href: https://juhache.substack.com/feed
diff --git a/apps/homepage/config/custom.css b/apps/homepage/config/custom.css
new file mode 100644
index 0000000..c217be0
--- /dev/null
+++ b/apps/homepage/config/custom.css
@@ -0,0 +1,431 @@
+/* EL-KADI OPS — Palantir blue + orange */
+@import url('https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,400;0,9..40,500;0,9..40,600;0,9..40,700&family=JetBrains+Mono:wght@400;500;600&display=swap');
+
+:root {
+ --p-void: #02060c;
+ --p-deep: #051018;
+ --p-surface: #0a1424;
+ --p-elevated: rgba(10, 22, 40, 0.92);
+ --p-border: rgba(56, 132, 220, 0.18);
+ --p-border-hot: rgba(251, 146, 60, 0.45);
+ --p-blue: #3b82f6;
+ --p-cyan: #22d3ee;
+ --p-orange: #fb923c;
+ --p-amber: #fbbf24;
+ --p-teal: #2dd4bf;
+ --p-text: #e8eef7;
+ --p-muted: #64748b;
+ --p-mono: 'JetBrains Mono', ui-monospace, monospace;
+ --p-sans: 'DM Sans', system-ui, sans-serif;
+}
+
+html { color-scheme: dark; }
+
+body {
+ font-family: var(--p-sans) !important;
+ background: var(--p-void) !important;
+ color: var(--p-text) !important;
+}
+
+body::before {
+ content: '';
+ position: fixed;
+ inset: 0;
+ background:
+ radial-gradient(ellipse 100% 70% at 8% -15%, rgba(59, 130, 246, 0.22), transparent 52%),
+ radial-gradient(ellipse 80% 55% at 95% 5%, rgba(34, 211, 238, 0.12), transparent 48%),
+ radial-gradient(ellipse 60% 50% at 88% 75%, rgba(251, 146, 60, 0.14), transparent 45%),
+ radial-gradient(ellipse 50% 40% at 12% 85%, rgba(251, 146, 60, 0.08), transparent 40%),
+ linear-gradient(165deg, #061018 0%, #02060c 45%, #030810 100%);
+ pointer-events: none;
+ z-index: 0;
+ animation: bg-breathe 14s ease-in-out infinite alternate;
+}
+
+@keyframes bg-breathe {
+ 0% { opacity: 1; }
+ 100% { opacity: 0.9; }
+}
+
+body::after {
+ content: '';
+ position: fixed;
+ inset: 0;
+ background-image:
+ linear-gradient(rgba(59, 130, 246, 0.04) 1px, transparent 1px),
+ linear-gradient(90deg, rgba(59, 130, 246, 0.04) 1px, transparent 1px);
+ background-size: 56px 56px;
+ mask-image: radial-gradient(ellipse 90% 80% at 50% 30%, black 10%, transparent 72%);
+ pointer-events: none;
+ z-index: 0;
+ animation: grid-drift 40s linear infinite;
+}
+
+@keyframes grid-drift {
+ from { transform: translate(0, 0); }
+ to { transform: translate(-56px, -56px); }
+}
+
+.palantir-scanline {
+ position: fixed;
+ inset: 0;
+ pointer-events: none;
+ z-index: 1;
+ background: repeating-linear-gradient(0deg, transparent, transparent 3px, rgba(0,0,0,0.04) 3px, rgba(0,0,0,0.04) 6px);
+ opacity: 0.35;
+}
+
+.palantir-noise {
+ position: fixed;
+ inset: 0;
+ pointer-events: none;
+ z-index: 1;
+ opacity: 0.03;
+ background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
+}
+
+#page_wrapper { position: relative; z-index: 2; }
+
+#information-widget-greeting, .information-widget-greeting {
+ font-weight: 600 !important;
+ letter-spacing: 0.22em !important;
+ text-transform: uppercase !important;
+ font-size: 0.7rem !important;
+ color: var(--p-orange) !important;
+ font-family: var(--p-mono) !important;
+}
+
+.information-widget {
+ border: 1px solid var(--p-border) !important;
+ border-radius: 10px !important;
+ background: var(--p-elevated) !important;
+ backdrop-filter: blur(14px);
+ box-shadow: 0 4px 24px rgba(0,0,0,0.35), inset 0 1px 0 rgba(59,130,246,0.08);
+}
+
+button[role="tab"] {
+ font-family: var(--p-mono) !important;
+ font-size: 0.68rem !important;
+ letter-spacing: 0.14em !important;
+ text-transform: uppercase !important;
+ border: 1px solid var(--p-border) !important;
+ border-radius: 6px !important;
+ transition: all 0.25s ease;
+}
+
+button[role="tab"][aria-selected="true"] {
+ background: linear-gradient(135deg, rgba(59,130,246,0.2), rgba(251,146,60,0.12)) !important;
+ border-color: var(--p-border-hot) !important;
+ color: var(--p-cyan) !important;
+ box-shadow: 0 0 20px rgba(251,146,60,0.15);
+}
+
+.service-block, #services .service-group .service {
+ border: 1px solid var(--p-border) !important;
+ border-radius: 10px !important;
+ background: var(--p-elevated) !important;
+ backdrop-filter: blur(12px);
+ transition: border-color 0.2s, box-shadow 0.25s, transform 0.2s;
+}
+
+.service-block:hover, #services .service-group .service:hover {
+ border-color: rgba(251,146,60,0.5) !important;
+ box-shadow: 0 0 28px rgba(59,130,246,0.12), 0 0 12px rgba(251,146,60,0.1);
+ transform: translateY(-1px);
+}
+
+#services h2.service-group-name, .service-group-title {
+ font-family: var(--p-mono) !important;
+ font-size: 0.62rem !important;
+ letter-spacing: 0.2em !important;
+ text-transform: uppercase !important;
+ color: var(--p-cyan) !important;
+}
+
+#bookmarks .bookmark-group h2, .bookmark-group-name {
+ font-family: var(--p-mono) !important;
+ font-size: 0.62rem !important;
+ letter-spacing: 0.18em !important;
+ text-transform: uppercase !important;
+ color: var(--p-orange) !important;
+}
+
+#bookmarks a {
+ border: 1px solid var(--p-border) !important;
+ transition: border-color 0.2s, box-shadow 0.2s;
+}
+
+#bookmarks a:hover {
+ border-color: rgba(251,146,60,0.4) !important;
+ box-shadow: 0 0 16px rgba(59,130,246,0.1);
+}
+
+.rss-hub {
+ margin: 1rem 1.5rem 1.5rem;
+ padding: 1.25rem 1.5rem;
+ border: 1px solid var(--p-border);
+ border-radius: 14px;
+ background: linear-gradient(145deg, rgba(10,22,40,0.95), rgba(6,12,22,0.98));
+ box-shadow: 0 8px 40px rgba(0,0,0,0.45), inset 0 1px 0 rgba(59,130,246,0.12);
+ backdrop-filter: blur(16px);
+ position: relative;
+ overflow: hidden;
+}
+
+.rss-hub::before {
+ content: '';
+ position: absolute;
+ top: 0; left: 0; right: 0;
+ height: 2px;
+ background: linear-gradient(90deg, transparent, var(--p-blue), var(--p-orange), var(--p-cyan), transparent);
+ animation: rss-scan 4s ease-in-out infinite;
+}
+
+@keyframes rss-scan {
+ 0%, 100% { opacity: 0.5; transform: scaleX(0.6); }
+ 50% { opacity: 1; transform: scaleX(1); }
+}
+
+.rss-hub-title {
+ display: flex;
+ align-items: baseline;
+ flex-wrap: wrap;
+ gap: 0.5rem 1rem;
+ margin-bottom: 1.25rem;
+ font-family: var(--p-mono);
+ font-size: 0.72rem;
+ letter-spacing: 0.28em;
+ text-transform: uppercase;
+ color: var(--p-cyan);
+}
+
+.rss-hub-accent {
+ display: inline-block;
+ width: 8px; height: 8px;
+ border-radius: 50%;
+ background: var(--p-orange);
+ box-shadow: 0 0 12px var(--p-orange);
+ animation: pulse-dot 2s ease-in-out infinite;
+}
+
+@keyframes pulse-dot {
+ 0%, 100% { opacity: 1; transform: scale(1); }
+ 50% { opacity: 0.6; transform: scale(0.85); }
+}
+
+.rss-hub-sub {
+ font-size: 0.58rem;
+ letter-spacing: 0.1em;
+ color: var(--p-muted);
+ text-transform: none;
+}
+
+.rss-grid {
+ display: grid;
+ grid-template-columns: repeat(4, 1fr);
+ gap: 1rem;
+}
+
+@media (max-width: 1280px) { .rss-grid { grid-template-columns: repeat(2, 1fr); } }
+@media (max-width: 640px) { .rss-grid { grid-template-columns: 1fr; } }
+
+.rss-column {
+ border: 1px solid rgba(59,130,246,0.12);
+ border-radius: 10px;
+ padding: 0.75rem;
+ background: rgba(5,12,24,0.6);
+ min-height: 160px;
+}
+
+.rss-column-head {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ font-family: var(--p-mono);
+ font-size: 0.58rem;
+ letter-spacing: 0.2em;
+ text-transform: uppercase;
+ color: var(--p-orange);
+ margin-bottom: 0.65rem;
+ padding-bottom: 0.5rem;
+ border-bottom: 1px solid var(--p-border);
+}
+
+.rss-pulse {
+ width: 6px; height: 6px;
+ border-radius: 50%;
+ background: var(--p-cyan);
+ animation: pulse-dot 1.8s ease-in-out infinite;
+}
+
+.rss-feed-block { list-style: none; margin-bottom: 0.85rem; }
+
+.rss-feed-name {
+ display: block;
+ font-family: var(--p-mono);
+ font-size: 0.55rem;
+ letter-spacing: 0.12em;
+ text-transform: uppercase;
+ color: var(--p-muted);
+ margin-bottom: 0.35rem;
+}
+
+.rss-items { list-style: none; padding: 0; margin: 0; }
+
+.rss-items li {
+ display: flex;
+ justify-content: space-between;
+ gap: 0.5rem;
+ padding: 0.28rem 0;
+ border-bottom: 1px solid rgba(255,255,255,0.04);
+ font-size: 0.72rem;
+ line-height: 1.35;
+ animation: rss-fade-in 0.4s ease forwards;
+ opacity: 0;
+}
+
+.rss-items li:nth-child(1) { animation-delay: 0.05s; }
+.rss-items li:nth-child(2) { animation-delay: 0.1s; }
+.rss-items li:nth-child(3) { animation-delay: 0.15s; }
+.rss-items li:nth-child(4) { animation-delay: 0.2s; }
+.rss-items li:nth-child(5) { animation-delay: 0.25s; }
+
+@keyframes rss-fade-in { to { opacity: 1; } }
+
+.rss-items a {
+ color: var(--p-text) !important;
+ text-decoration: none;
+ flex: 1;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.rss-items a:hover { color: var(--p-orange) !important; }
+
+.rss-items time {
+ font-family: var(--p-mono);
+ font-size: 0.58rem;
+ color: var(--p-muted);
+ flex-shrink: 0;
+}
+
+.rss-loading { color: var(--p-muted); font-style: italic; }
+.rss-error a { color: var(--p-orange) !important; }
+
+.status-dot-status-green, .text-green-500 {
+ box-shadow: 0 0 8px rgba(74, 222, 128, 0.55);
+}
+
+footer, #footer {
+ opacity: 0.35;
+ font-family: var(--p-mono);
+ font-size: 0.62rem;
+}
+
+
+
+/* ─── Logo gradients + profile photo ─── */
+:root {
+ --color-logo-start: 59, 130, 246;
+ --color-logo-stop: 251, 146, 60;
+}
+
+.information-widget-logo img,
+.information-widget-logo a img,
+#information-widgets .information-widget-logo img {
+ width: 52px !important;
+ height: 52px !important;
+ min-width: 52px !important;
+ min-height: 52px !important;
+ border-radius: 50% !important;
+ object-fit: cover !important;
+ border: 2px solid rgba(251, 146, 60, 0.55) !important;
+ box-shadow: 0 0 0 1px rgba(59, 130, 246, 0.35), 0 4px 16px rgba(0, 0, 0, 0.4) !important;
+ filter: none !important;
+ animation: none !important;
+}
+
+.information-widget-logo:hover img {
+ border-color: rgba(34, 211, 238, 0.7) !important;
+}
+
+.service-icon {
+ position: relative !important;
+ border-radius: 8px !important;
+ margin: 2px 4px 2px 2px !important;
+ background: rgba(12, 22, 38, 0.85) !important;
+ border: 1px solid rgba(59, 130, 246, 0.12) !important;
+ box-shadow: none !important;
+ animation: none !important;
+ overflow: hidden;
+}
+
+.service-icon::before,
+.service-icon::after {
+ display: none !important;
+ content: none !important;
+}
+
+.service-icon > div { filter: none !important; transition: transform 0.2s ease; }
+.service-icon img { filter: saturate(1.12) contrast(1.02) !important; transition: transform 0.2s ease; }
+
+.service-card:hover .service-icon {
+ border-color: rgba(251, 146, 60, 0.35) !important;
+ box-shadow: 0 0 0 1px rgba(251, 146, 60, 0.15) !important;
+}
+
+.service-card:hover .service-icon > div,
+.service-card:hover .service-icon img { transform: scale(1.06); }
+
+.services-list .service:nth-child(6n+1) .service-icon { border-left: 2px solid #3b82f6 !important; }
+.services-list .service:nth-child(6n+2) .service-icon { border-left: 2px solid #a855f7 !important; }
+.services-list .service:nth-child(6n+3) .service-icon { border-left: 2px solid #22c55e !important; }
+.services-list .service:nth-child(6n+4) .service-icon { border-left: 2px solid #06b6d4 !important; }
+.services-list .service:nth-child(6n+5) .service-icon { border-left: 2px solid #f59e0b !important; }
+.services-list .service:nth-child(6n+6) .service-icon { border-left: 2px solid #ec4899 !important; }
+
+.bookmark-icon { font-weight: 600 !important; color: #fff !important; animation: none !important; box-shadow: none !important; }
+.bookmark:nth-child(4n+1) .bookmark-icon { background: linear-gradient(135deg, #2563eb, #3b82f6) !important; }
+.bookmark:nth-child(4n+2) .bookmark-icon { background: linear-gradient(135deg, #ea580c, #fb923c) !important; }
+.bookmark:nth-child(4n+3) .bookmark-icon { background: linear-gradient(135deg, #7c3aed, #a855f7) !important; }
+.bookmark:nth-child(4n+4) .bookmark-icon { background: linear-gradient(135deg, #059669, #22c55e) !important; }
+
+.information-widget-resource .resource-icon { animation: none !important; filter: none !important; }
+.information-widget-resource:nth-child(1) .resource-icon { color: #60a5fa !important; }
+.information-widget-resource:nth-child(2) .resource-icon { color: #fb923c !important; }
+.information-widget-resource:nth-child(3) .resource-icon { color: #4ade80 !important; }
+
+.resource-usage > div {
+ background: linear-gradient(90deg, #3b82f6, #fb923c) !important;
+ animation: none !important;
+ background-size: 100% 100% !important;
+}
+
+.status-dot-status-green, .text-green-500 {
+ background-color: #4ade80 !important;
+ animation: status-pulse-green 3s ease-out infinite;
+}
+.status-dot-status-red, .text-red-500 {
+ background-color: #f87171 !important;
+ animation: status-pulse-red 3s ease-out infinite;
+}
+@keyframes status-pulse-green {
+ 0% { box-shadow: 0 0 0 0 rgba(74, 222, 128, 0.5); }
+ 70% { box-shadow: 0 0 0 6px rgba(74, 222, 128, 0); }
+}
+@keyframes status-pulse-red {
+ 0% { box-shadow: 0 0 0 0 rgba(248, 113, 113, 0.5); }
+ 70% { box-shadow: 0 0 0 6px rgba(248, 113, 113, 0); }
+}
+
+button[role="tab"][aria-selected="true"] { animation: none !important; }
+.service-card { animation: none !important; }
+.rss-hub { animation: none !important; }
+
+@media (prefers-reduced-motion: reduce) {
+ body::before, body::after, .rss-hub::before, .rss-pulse, .rss-hub-accent,
+ .service-icon, .service-icon::before, .service-icon::after, .service-card,
+ .bookmark-icon, .information-widget-resource .resource-icon, button[role="tab"][aria-selected="true"],
+ .rss-hub, #information-widgets img { animation: none !important; }
+}
diff --git a/apps/homepage/config/custom.js b/apps/homepage/config/custom.js
new file mode 100644
index 0000000..ff7e24a
--- /dev/null
+++ b/apps/homepage/config/custom.js
@@ -0,0 +1,143 @@
+/* EL-KADI OPS — live RSS panels (Palantir feed hub) */
+(function () {
+ const FEEDS = {
+ "Tech News": [
+ { name: "Hacker News", url: "https://hnrss.org/frontpage" },
+ { name: "Ars Technica", url: "https://feeds.arstechnica.com/arstechnica/index" },
+ { name: "The Verge", url: "https://www.theverge.com/rss/index.xml" },
+ { name: "Lobsters", url: "https://lobste.rs/rss" },
+ ],
+ Security: [
+ { name: "BleepingComputer", url: "https://www.bleepingcomputer.com/feed/" },
+ { name: "Krebs", url: "https://krebsonsecurity.com/feed/" },
+ { name: "The Hacker News", url: "https://feeds.feedburner.com/TheHackersNews" },
+ ],
+ Homelab: [
+ { name: "selfh.st", url: "https://selfh.st/rss/" },
+ { name: "Proxmox Forum", url: "https://forum.proxmox.com/forums/-/index.rss" },
+ { name: "r/selfhosted", url: "https://www.reddit.com/r/selfhosted/.rss" },
+ { name: "Docker Blog", url: "https://www.docker.com/blog/feed/" },
+ { name: "LinuxServer.io", url: "https://www.linuxserver.io/blog/rss/" },
+ ],
+ "AI & Dev": [
+ { name: "OpenAI Blog", url: "https://openai.com/blog/rss.xml" },
+ { name: "Hugging Face", url: "https://huggingface.co/blog/feed.xml" },
+ { name: "GitHub Blog", url: "https://github.blog/feed/" },
+ ],
+ };
+
+ const PROXY = "https://api.allorigins.win/raw?url=";
+ const REFRESH_MS = 15 * 60 * 1000;
+
+ function esc(s) {
+ const d = document.createElement("div");
+ d.textContent = s || "";
+ return d.innerHTML;
+ }
+
+ function relTime(pub) {
+ if (!pub) return "";
+ const t = new Date(pub).getTime();
+ if (Number.isNaN(t)) return "";
+ const m = Math.floor((Date.now() - t) / 60000);
+ if (m < 60) return `${m}m`;
+ const h = Math.floor(m / 60);
+ if (h < 48) return `${h}h`;
+ return `${Math.floor(h / 24)}d`;
+ }
+
+ async function fetchFeed(feedUrl) {
+ const res = await fetch(PROXY + encodeURIComponent(feedUrl), { cache: "no-store" });
+ if (!res.ok) throw new Error("fetch failed");
+ const xml = new DOMParser().parseFromString(await res.text(), "text/xml");
+ const items = xml.querySelectorAll("item, entry");
+ return Array.from(items)
+ .slice(0, 6)
+ .map((item) => {
+ const title =
+ item.querySelector("title")?.textContent?.trim() ||
+ item.querySelector("title")?.innerHTML?.trim() ||
+ "—";
+ const link =
+ item.querySelector("link")?.getAttribute("href") ||
+ item.querySelector("link")?.textContent?.trim() ||
+ item.querySelector("id")?.textContent?.trim() ||
+ "#";
+ const pub =
+ item.querySelector("pubDate")?.textContent ||
+ item.querySelector("published")?.textContent ||
+ item.querySelector("updated")?.textContent;
+ return { title, link, pub };
+ });
+ }
+
+ function renderColumn(cat, feeds, root) {
+ const col = document.createElement("section");
+ col.className = "rss-column";
+ col.innerHTML = ``;
+ const list = col.querySelector(".rss-list");
+ root.appendChild(col);
+
+ feeds.forEach((feed) => {
+ const block = document.createElement("li");
+ block.className = "rss-feed-block";
+ block.innerHTML = ``;
+ list.appendChild(block);
+ const itemsUl = block.querySelector(".rss-items");
+
+ fetchFeed(feed.url)
+ .then((items) => {
+ itemsUl.innerHTML = items.length
+ ? items
+ .map(
+ (it) =>
+ `${esc(it.title)}`
+ )
+ .join("")
+ : '';
+ })
+ .catch(() => {
+ itemsUl.innerHTML = ``;
+ });
+ });
+ }
+
+ function injectHub() {
+ if (document.getElementById("elkadi-rss-hub")) return;
+
+ const scan = document.createElement("div");
+ scan.className = "palantir-scanline";
+ const noise = document.createElement("div");
+ noise.className = "palantir-noise";
+ document.body.appendChild(scan);
+ document.body.appendChild(noise);
+
+ const hub = document.createElement("div");
+ hub.id = "elkadi-rss-hub";
+ hub.className = "rss-hub";
+ hub.innerHTML =
+ '';
+ const grid = hub.querySelector(".rss-grid");
+
+ Object.entries(FEEDS).forEach(([cat, feeds]) => renderColumn(cat, feeds, grid));
+
+ const services = document.getElementById("services");
+ if (services && services.parentNode) {
+ services.parentNode.insertBefore(hub, services);
+ } else {
+ document.body.prepend(hub);
+ }
+
+ setInterval(() => {
+ hub.querySelectorAll(".rss-column").forEach((c) => c.remove());
+ const g = hub.querySelector(".rss-grid");
+ Object.entries(FEEDS).forEach(([cat, feeds]) => renderColumn(cat, feeds, g));
+ }, REFRESH_MS);
+ }
+
+ if (document.readyState === "loading") {
+ document.addEventListener("DOMContentLoaded", () => setTimeout(injectHub, 400));
+ } else {
+ setTimeout(injectHub, 400);
+ }
+})();
diff --git a/apps/homepage/config/services.yaml b/apps/homepage/config/services.yaml
new file mode 100644
index 0000000..bbb9b31
--- /dev/null
+++ b/apps/homepage/config/services.yaml
@@ -0,0 +1,505 @@
+- Infrastructure:
+ - Change detection:
+ icon: mdi-server-network-#3b82f6
+ href: http://192.168.1.117:5000
+ description: Change detection
+ siteMonitor: http://192.168.1.117:5000
+ statusStyle: dot
+
+ - Proxmox (3090):
+ icon: proxmox.png
+ href: https://192.168.1.216:8006
+ description: Proxmox (3090)
+ siteMonitor: https://192.168.1.216:8006
+ statusStyle: dot
+
+ - Proxmox (Dell):
+ icon: proxmox.png
+ href: https://192.168.1.56:8006
+ description: Proxmox (Dell)
+ siteMonitor: https://192.168.1.56:8006
+ statusStyle: dot
+
+ - Minarca:
+ icon: mdi-server-network-#3b82f6
+ href: http://192.168.1.203:8080/login
+ description: Minarca
+ siteMonitor: http://192.168.1.203:8080
+ statusStyle: dot
+
+ - Proxmox:
+ icon: proxmox.png
+ href: http://192.168.1.230:8006
+ description: Proxmox
+ siteMonitor: http://192.168.1.230:8006
+ statusStyle: dot
+
+ - Portainer (proxmox):
+ icon: portainer.png
+ href: https://192.168.5.128:9443/#!/home
+ description: Portainer (proxmox)
+ siteMonitor: https://192.168.5.128:9443
+ statusStyle: dot
+
+ - Rackula:
+ icon: mdi-count-#3b82f6
+ href: https://count.racku.la
+ description: Rackula
+
+ - TrueNAS:
+ icon: truenas.png
+ href: http://192.168.1.185
+ description: TrueNAS
+ siteMonitor: http://192.168.1.185
+ statusStyle: dot
+
+ - Adguard:
+ icon: adguard-home.png
+ href: http://192.168.1.211:3001
+ description: Adguard
+ siteMonitor: http://192.168.1.211:3001
+ statusStyle: dot
+
+ - Browserless:
+ icon: mdi-server-network-#3b82f6
+ href: http://192.168.1.150:3000
+ description: Browserless
+ siteMonitor: http://192.168.1.150:3000
+ statusStyle: dot
+
+ - Unifi:
+ icon: unifi.png
+ href: https://192.168.1.24
+ description: Unifi
+ siteMonitor: https://192.168.1.24
+ statusStyle: dot
+
+ - Wazuh:
+ icon: wazuh.png
+ href: https://192.168.1.73
+ description: Wazuh
+ siteMonitor: https://192.168.1.73
+ statusStyle: dot
+
+ - Modem:
+ icon: mdi-server-network-#3b82f6
+ href: https://192.168.10.1
+ description: Modem
+ siteMonitor: https://192.168.10.1
+ statusStyle: dot
+
+ - Nginx:
+ icon: nginx-proxy-manager.png
+ href: http://192.168.1.173:81
+ description: Nginx
+ siteMonitor: http://192.168.1.173:81
+ statusStyle: dot
+
+ - Webmin:
+ icon: mdi-server-network-#3b82f6
+ href: https://192.168.5.24:10000
+ description: Webmin
+ siteMonitor: https://192.168.5.24:10000
+ statusStyle: dot
+
+ - PVE Scripts:
+ icon: mdi-server-network-#3b82f6
+ href: http://192.168.1.25:3000
+ description: PVE Scripts
+ siteMonitor: http://192.168.1.25:3000
+ statusStyle: dot
+
+ - iDrac:
+ icon: mdi-server-network-#3b82f6
+ href: http://192.168.1.227
+ description: iDrac
+ siteMonitor: http://192.168.1.227
+ statusStyle: dot
+
+ - Remotely:
+ icon: remotely.png
+ href: http://192.168.1.211:8080
+ description: Remotely
+ siteMonitor: http://192.168.1.211:8080
+ statusStyle: dot
+
+ - mo-nas:
+ icon: mdi-server-network-#3b82f6
+ href: http://192.168.1.150:5000
+ description: mo-nas
+ siteMonitor: http://192.168.1.150:5000
+ statusStyle: dot
+
+ - NAS:
+ icon: mdi-server-network-#3b82f6
+ href: http://192.168.1.211:5000
+ description: NAS
+ siteMonitor: http://192.168.1.211:5000
+ statusStyle: dot
+
+ - Portainer:
+ icon: portainer.png
+ href: http://192.168.1.150:9000
+ description: Portainer
+ siteMonitor: http://192.168.1.150:9000
+ statusStyle: dot
+
+ - Portainer:
+ icon: portainer.png
+ href: http://192.168.1.211:9000
+ description: Portainer
+ siteMonitor: http://192.168.1.211:9000
+ statusStyle: dot
+
+- Media & TV:
+ - nodecast:
+ icon: mdi-server-network-#a855f7
+ href: http://192.168.1.107:3000/#home
+ description: nodecast
+ siteMonitor: http://192.168.1.107:3000
+ statusStyle: dot
+
+ - Frigate:
+ icon: frigate.png
+ href: https://192.168.1.185:30058
+ description: Frigate
+ siteMonitor: https://192.168.1.185:30058
+ statusStyle: dot
+
+ - Tunarr:
+ icon: tunarr.png
+ href: http://192.168.1.237:8000
+ description: Tunarr
+ siteMonitor: http://192.168.1.237:8000
+ statusStyle: dot
+
+ - Immich:
+ icon: immich.png
+ href: http://192.168.1.17:2283
+ description: Immich
+ siteMonitor: http://192.168.1.17:2283
+ statusStyle: dot
+
+ - Metube:
+ icon: metube.png
+ href: http://192.168.1.140:8081
+ description: Metube
+ siteMonitor: http://192.168.1.140:8081
+ statusStyle: dot
+
+- Smart Home:
+ - Mawaqit:
+ icon: mdi-mawaqit-#22c55e
+ href: https://mawaqit.net/nl/el-kadi-amsterdam-1061dj-netherlands
+ description: Mawaqit
+
+ - HA:
+ icon: mdi-server-network-#22c55e
+ href: http://192.168.1.235:8123
+ description: HA
+ siteMonitor: http://192.168.1.235:8123
+ statusStyle: dot
+
+ - Ring:
+ icon: mdi-account-#22c55e
+ href: https://account.ring.com/account/dashboard?l=24b26010-2cac-48f2-b8ec-e1ef4b5fa79e
+ description: Ring
+
+ - HA Voice Ctrl:
+ icon: home-assistant.png
+ href: http://192.168.1.211:8765
+ description: HA Voice Ctrl
+ siteMonitor: http://192.168.1.211:8765
+ statusStyle: dot
+
+- Productivity:
+ - Resume Builder:
+ icon: mdi-server-network-#06b6d4
+ href: http://192.168.1.150:9751
+ description: Resume Builder
+ siteMonitor: http://192.168.1.150:9751
+ statusStyle: dot
+
+ - openresume:
+ icon: mdi-server-network-#06b6d4
+ href: http://192.168.1.150:6155
+ description: openresume
+ siteMonitor: http://192.168.1.150:6155
+ statusStyle: dot
+
+ - nextcloud:
+ icon: nextcloud.png
+ href: https://cloud.el-kadi.nl/index.php/apps/dashboard
+ description: nextcloud
+
+ - Invoice Generator:
+ icon: mdi-server-network-#06b6d4
+ href: http://192.168.1.150:3099
+ description: Invoice Generator
+ siteMonitor: http://192.168.1.150:3099
+ statusStyle: dot
+
+ - Vert:
+ icon: mdi-server-network-#06b6d4
+ href: http://192.168.1.150:3884
+ description: Vert
+ siteMonitor: http://192.168.1.150:3884
+ statusStyle: dot
+
+ - Vaultwarden:
+ icon: vaultwarden.png
+ href: https://192.168.1.6:8000/#/vault
+ description: Vaultwarden
+ siteMonitor: https://192.168.1.6:8000
+ statusStyle: dot
+
+ - Moocup:
+ icon: mdi-server-network-#06b6d4
+ href: http://192.168.1.150:3591
+ description: Moocup
+ siteMonitor: http://192.168.1.150:3591
+ statusStyle: dot
+
+ - n8n:
+ icon: n8n.png
+ href: http://192.168.1.64:5678/home/workflows
+ description: n8n
+ siteMonitor: http://192.168.1.64:5678
+ statusStyle: dot
+
+ - Linkwarden:
+ icon: linkwarden.png
+ href: http://192.168.1.150:7461/dashboard
+ description: Linkwarden
+ siteMonitor: http://192.168.1.150:7461
+ statusStyle: dot
+
+ - Thunderbird:
+ icon: mdi-server-network-#06b6d4
+ href: http://192.168.1.150:5800
+ description: Thunderbird
+ siteMonitor: http://192.168.1.150:5800
+ statusStyle: dot
+
+ - Excalidraw:
+ icon: excalidraw.png
+ href: http://192.168.1.211:3765
+ description: Excalidraw
+ siteMonitor: http://192.168.1.211:3765
+ statusStyle: dot
+
+ - CRM:
+ icon: mdi-server-network-#06b6d4
+ href: https://www.fixaanhuis.nl/crm
+ description: CRM
+
+ - Gitea:
+ icon: gitea.png
+ href: http://192.168.1.211:3000
+ description: Gitea
+ siteMonitor: http://192.168.1.211:3000
+ statusStyle: dot
+
+ - Neo4j Browser:
+ icon: neo4j.png
+ href: http://192.168.1.211:49154
+ description: Neo4j Browser
+ siteMonitor: http://192.168.1.211:49154
+ statusStyle: dot
+
+ - OnlyOffice:
+ icon: mdi-server-network-#06b6d4
+ href: http://192.168.5.128/example
+ description: OnlyOffice
+ siteMonitor: http://192.168.5.128
+ statusStyle: dot
+
+ - BentoPDF:
+ icon: mdi-server-network-#06b6d4
+ href: http://192.168.1.150:3183
+ description: BentoPDF
+ siteMonitor: http://192.168.1.150:3183
+ statusStyle: dot
+
+ - Jellyfin Test:
+ icon: mdi-server-network-#06b6d4
+ href: http://192.168.1.99:8096
+ description: Jellyfin Test
+ siteMonitor: http://192.168.1.99:8096
+ statusStyle: dot
+
+- Tools & Utils:
+ - Traccar:
+ icon: traccar.png
+ href: http://192.168.1.150:8082
+ description: Traccar
+ siteMonitor: http://192.168.1.150:8082
+ statusStyle: dot
+
+ - Mytkstar:
+ icon: mdi-server-network-#f59e0b
+ href: https://www.mytkstar.net/Monitor.aspx
+ description: Mytkstar
+
+ - Firefox:
+ icon: mdi-server-network-#f59e0b
+ href: https://192.168.1.150:5813
+ description: Firefox
+ siteMonitor: https://192.168.1.150:5813
+ statusStyle: dot
+
+ - Background Remover:
+ icon: mdi-server-network-#f59e0b
+ href: http://192.168.1.150:8519
+ description: Background Remover
+ siteMonitor: http://192.168.1.150:8519
+ statusStyle: dot
+
+ - Neko:
+ icon: mdi-server-network-#f59e0b
+ href: http://192.168.1.150:8080
+ description: Neko
+ siteMonitor: http://192.168.1.150:8080
+ statusStyle: dot
+
+- AI Assistants:
+ - Phind:
+ icon: mdi-server-network-#ec4899
+ href: https://www.phind.com/search?home=true
+ description: Phind
+
+ - Deepseek:
+ icon: si-deepseek-#ec4899
+ href: https://chat.deepseek.com
+ description: Deepseek
+
+ - Anthropic:
+ icon: si-anthropic-#ec4899
+ href: https://console.anthropic.com/dashboard
+ description: Anthropic
+
+ - Github:
+ icon: si-github-#ec4899
+ href: https://github.com/modammer020
+ description: Github
+
+ - Lbrty:
+ icon: mdi-lbrty-#ec4899
+ href: https://lbrty.ai
+ description: Lbrty
+
+ - Grok:
+ icon: si-x-#ec4899
+ href: https://grok.com
+ description: Grok
+
+ - LMarena:
+ icon: mdi-lmarena-#ec4899
+ href: https://lmarena.ai
+ description: LMarena
+
+ - Google AI studio:
+ icon: mdi-aistudio-#ec4899
+ href: https://aistudio.google.com/prompts/new_chat
+ description: Google AI studio
+
+ - GPT4FREE:
+ icon: mdi-server-network-#ec4899
+ href: http://192.168.1.150:6439
+ description: GPT4FREE
+ siteMonitor: http://192.168.1.150:6439
+ statusStyle: dot
+
+ - ChatGPT:
+ icon: si-openai-#ec4899
+ href: https://chatgpt.com
+ description: ChatGPT
+
+ - Youtube:
+ icon: si-youtube-#ec4899
+ href: https://youtube.com
+ description: Youtube
+
+- Dev & Docs:
+ - DSM on Proxmox:
+ icon: proxmox.png
+ href: https://blog.nootch.net
+ description: DSM on Proxmox
+
+ - DSM Video station 7.2:
+ icon: mdi-github-#14b8a6
+ href: https://github.com/007revad/Video_Station_for_DSM_722?tab=readme-ov-file
+ description: DSM Video station 7.2
+
+ - Turnkey:
+ icon: mdi-server-network-#14b8a6
+ href: https://www.turnkeylinux.org
+ description: Turnkey
+
+ - LXC:
+ icon: mdi-community-sc-#14b8a6
+ href: https://community-scripts.github.io/ProxmoxVE
+ description: LXC
+
+ - DSM auxxxilium:
+ icon: mdi-arc-#14b8a6
+ href: https://arc.auxxxilium.tech
+ description: DSM auxxxilium
+
+ - Synology stuff (Marius Hosting):
+ icon: mdi-mariushostin-#14b8a6
+ href: https://mariushosting.com
+ description: Synology stuff (Marius Hosting)
+
+ - NZB Geek:
+ icon: mdi-nzbgeek-#14b8a6
+ href: https://nzbgeek.info/dashboard.php
+ description: NZB Geek
+
+ - Noted Apps:
+ icon: mdi-noted-#14b8a6
+ href: https://noted.lol
+ description: Noted Apps
+
+ - https://jack-vanlightly.com/:
+ icon: mdi-jack-vanligh-#14b8a6
+ href: https://jack-vanlightly.com
+ description: https://jack-vanlightly.com/
+
+ - Usenetdeal:
+ icon: mdi-usenetdeal-#14b8a6
+ href: https://usenetdeal.com/dashboard/usenet
+ description: Usenetdeal
+
+ - https://juhache.substack.com/:
+ icon: mdi-juhache-#14b8a6
+ href: https://juhache.substack.com
+ description: https://juhache.substack.com/
+
+ - Home Control:
+ icon: mdi-server-network-#14b8a6
+ href: http://192.168.1.211:8765/dashboard#live
+ description: Home Control
+ siteMonitor: http://192.168.1.211:8765
+ statusStyle: dot
+
+- Web Design:
+ - HTML5 templates:
+ icon: mdi-jekyllthemes-#f97316
+ href: https://jekyllthemes.io/free
+ description: HTML5 templates
+
+ - HTML5 templates:
+ icon: mdi-jekyllrb-#f97316
+ href: https://jekyllrb.com/showcase
+ description: HTML5 templates
+
+ - HTML5 templates:
+ icon: mdi-freehtml5-#f97316
+ href: https://freehtml5.co
+ description: HTML5 templates
+
+ - HTML5:
+ icon: mdi-server-network-#f97316
+ href: https://www.tooplate.com/free-templates
+ description: HTML5
diff --git a/apps/homepage/config/settings.yaml b/apps/homepage/config/settings.yaml
new file mode 100644
index 0000000..8076477
--- /dev/null
+++ b/apps/homepage/config/settings.yaml
@@ -0,0 +1,77 @@
+---
+title: EL-KADI OPS
+description: Homelab command surface
+theme: dark
+color: slate
+iconStyle: gradient
+headerStyle: underlined
+useEqualHeights: true
+statusStyle: dot
+fullWidth: true
+cardBlur: md
+hideVersion: false
+quicklaunch:
+ searchDescriptions: true
+ hideInternetSearch: false
+ showSearchSuggestions: true
+layout:
+ Infrastructure:
+ tab: Ops
+ icon: mdi-server-network
+ columns: 4
+ style: row
+ Media & TV:
+ tab: Media
+ icon: mdi-television-play
+ columns: 4
+ style: row
+ Smart Home:
+ tab: Home
+ icon: mdi-home-automation
+ columns: 3
+ style: row
+ Productivity:
+ tab: Work
+ icon: mdi-briefcase-outline
+ columns: 4
+ style: row
+ Tools & Utils:
+ tab: Ops
+ icon: mdi-toolbox-outline
+ columns: 3
+ style: row
+ AI Assistants:
+ tab: AI
+ icon: mdi-brain
+ columns: 4
+ style: row
+ Dev & Docs:
+ tab: AI
+ icon: mdi-book-open-page-variant
+ columns: 3
+ style: row
+ Web Design:
+ tab: AI
+ icon: mdi-palette-outline
+ columns: 3
+ style: row
+ Tech News:
+ tab: Feeds
+ icon: mdi-newspaper-variant-outline
+ columns: 2
+ Security:
+ tab: Feeds
+ icon: mdi-shield-alert-outline
+ columns: 2
+ Homelab:
+ tab: Feeds
+ icon: mdi-server
+ columns: 3
+ AI & Dev:
+ tab: Feeds
+ icon: mdi-robot-outline
+ columns: 2
+ Blogs:
+ tab: Feeds
+ icon: mdi-rss
+ columns: 2
diff --git a/apps/homepage/config/widgets.yaml b/apps/homepage/config/widgets.yaml
new file mode 100644
index 0000000..1943374
--- /dev/null
+++ b/apps/homepage/config/widgets.yaml
@@ -0,0 +1,27 @@
+---
+- logo:
+ icon: /images/logo.jpg
+
+- greeting:
+ text_size: xl
+ text: el-kadi ops
+
+- datetime:
+ text_size: xl
+ format:
+ timeStyle: short
+ dateStyle: medium
+ hour12: false
+
+- search:
+ provider: google
+ target: _blank
+ showSearchSuggestions: true
+
+- resources:
+ cpu: true
+ memory: true
+ cputemp: true
+ uptime: true
+ units: metric
+ refresh: 3000
diff --git a/apps/homepage/custom.css b/apps/homepage/custom.css
new file mode 100644
index 0000000..c217be0
--- /dev/null
+++ b/apps/homepage/custom.css
@@ -0,0 +1,431 @@
+/* EL-KADI OPS — Palantir blue + orange */
+@import url('https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,400;0,9..40,500;0,9..40,600;0,9..40,700&family=JetBrains+Mono:wght@400;500;600&display=swap');
+
+:root {
+ --p-void: #02060c;
+ --p-deep: #051018;
+ --p-surface: #0a1424;
+ --p-elevated: rgba(10, 22, 40, 0.92);
+ --p-border: rgba(56, 132, 220, 0.18);
+ --p-border-hot: rgba(251, 146, 60, 0.45);
+ --p-blue: #3b82f6;
+ --p-cyan: #22d3ee;
+ --p-orange: #fb923c;
+ --p-amber: #fbbf24;
+ --p-teal: #2dd4bf;
+ --p-text: #e8eef7;
+ --p-muted: #64748b;
+ --p-mono: 'JetBrains Mono', ui-monospace, monospace;
+ --p-sans: 'DM Sans', system-ui, sans-serif;
+}
+
+html { color-scheme: dark; }
+
+body {
+ font-family: var(--p-sans) !important;
+ background: var(--p-void) !important;
+ color: var(--p-text) !important;
+}
+
+body::before {
+ content: '';
+ position: fixed;
+ inset: 0;
+ background:
+ radial-gradient(ellipse 100% 70% at 8% -15%, rgba(59, 130, 246, 0.22), transparent 52%),
+ radial-gradient(ellipse 80% 55% at 95% 5%, rgba(34, 211, 238, 0.12), transparent 48%),
+ radial-gradient(ellipse 60% 50% at 88% 75%, rgba(251, 146, 60, 0.14), transparent 45%),
+ radial-gradient(ellipse 50% 40% at 12% 85%, rgba(251, 146, 60, 0.08), transparent 40%),
+ linear-gradient(165deg, #061018 0%, #02060c 45%, #030810 100%);
+ pointer-events: none;
+ z-index: 0;
+ animation: bg-breathe 14s ease-in-out infinite alternate;
+}
+
+@keyframes bg-breathe {
+ 0% { opacity: 1; }
+ 100% { opacity: 0.9; }
+}
+
+body::after {
+ content: '';
+ position: fixed;
+ inset: 0;
+ background-image:
+ linear-gradient(rgba(59, 130, 246, 0.04) 1px, transparent 1px),
+ linear-gradient(90deg, rgba(59, 130, 246, 0.04) 1px, transparent 1px);
+ background-size: 56px 56px;
+ mask-image: radial-gradient(ellipse 90% 80% at 50% 30%, black 10%, transparent 72%);
+ pointer-events: none;
+ z-index: 0;
+ animation: grid-drift 40s linear infinite;
+}
+
+@keyframes grid-drift {
+ from { transform: translate(0, 0); }
+ to { transform: translate(-56px, -56px); }
+}
+
+.palantir-scanline {
+ position: fixed;
+ inset: 0;
+ pointer-events: none;
+ z-index: 1;
+ background: repeating-linear-gradient(0deg, transparent, transparent 3px, rgba(0,0,0,0.04) 3px, rgba(0,0,0,0.04) 6px);
+ opacity: 0.35;
+}
+
+.palantir-noise {
+ position: fixed;
+ inset: 0;
+ pointer-events: none;
+ z-index: 1;
+ opacity: 0.03;
+ background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
+}
+
+#page_wrapper { position: relative; z-index: 2; }
+
+#information-widget-greeting, .information-widget-greeting {
+ font-weight: 600 !important;
+ letter-spacing: 0.22em !important;
+ text-transform: uppercase !important;
+ font-size: 0.7rem !important;
+ color: var(--p-orange) !important;
+ font-family: var(--p-mono) !important;
+}
+
+.information-widget {
+ border: 1px solid var(--p-border) !important;
+ border-radius: 10px !important;
+ background: var(--p-elevated) !important;
+ backdrop-filter: blur(14px);
+ box-shadow: 0 4px 24px rgba(0,0,0,0.35), inset 0 1px 0 rgba(59,130,246,0.08);
+}
+
+button[role="tab"] {
+ font-family: var(--p-mono) !important;
+ font-size: 0.68rem !important;
+ letter-spacing: 0.14em !important;
+ text-transform: uppercase !important;
+ border: 1px solid var(--p-border) !important;
+ border-radius: 6px !important;
+ transition: all 0.25s ease;
+}
+
+button[role="tab"][aria-selected="true"] {
+ background: linear-gradient(135deg, rgba(59,130,246,0.2), rgba(251,146,60,0.12)) !important;
+ border-color: var(--p-border-hot) !important;
+ color: var(--p-cyan) !important;
+ box-shadow: 0 0 20px rgba(251,146,60,0.15);
+}
+
+.service-block, #services .service-group .service {
+ border: 1px solid var(--p-border) !important;
+ border-radius: 10px !important;
+ background: var(--p-elevated) !important;
+ backdrop-filter: blur(12px);
+ transition: border-color 0.2s, box-shadow 0.25s, transform 0.2s;
+}
+
+.service-block:hover, #services .service-group .service:hover {
+ border-color: rgba(251,146,60,0.5) !important;
+ box-shadow: 0 0 28px rgba(59,130,246,0.12), 0 0 12px rgba(251,146,60,0.1);
+ transform: translateY(-1px);
+}
+
+#services h2.service-group-name, .service-group-title {
+ font-family: var(--p-mono) !important;
+ font-size: 0.62rem !important;
+ letter-spacing: 0.2em !important;
+ text-transform: uppercase !important;
+ color: var(--p-cyan) !important;
+}
+
+#bookmarks .bookmark-group h2, .bookmark-group-name {
+ font-family: var(--p-mono) !important;
+ font-size: 0.62rem !important;
+ letter-spacing: 0.18em !important;
+ text-transform: uppercase !important;
+ color: var(--p-orange) !important;
+}
+
+#bookmarks a {
+ border: 1px solid var(--p-border) !important;
+ transition: border-color 0.2s, box-shadow 0.2s;
+}
+
+#bookmarks a:hover {
+ border-color: rgba(251,146,60,0.4) !important;
+ box-shadow: 0 0 16px rgba(59,130,246,0.1);
+}
+
+.rss-hub {
+ margin: 1rem 1.5rem 1.5rem;
+ padding: 1.25rem 1.5rem;
+ border: 1px solid var(--p-border);
+ border-radius: 14px;
+ background: linear-gradient(145deg, rgba(10,22,40,0.95), rgba(6,12,22,0.98));
+ box-shadow: 0 8px 40px rgba(0,0,0,0.45), inset 0 1px 0 rgba(59,130,246,0.12);
+ backdrop-filter: blur(16px);
+ position: relative;
+ overflow: hidden;
+}
+
+.rss-hub::before {
+ content: '';
+ position: absolute;
+ top: 0; left: 0; right: 0;
+ height: 2px;
+ background: linear-gradient(90deg, transparent, var(--p-blue), var(--p-orange), var(--p-cyan), transparent);
+ animation: rss-scan 4s ease-in-out infinite;
+}
+
+@keyframes rss-scan {
+ 0%, 100% { opacity: 0.5; transform: scaleX(0.6); }
+ 50% { opacity: 1; transform: scaleX(1); }
+}
+
+.rss-hub-title {
+ display: flex;
+ align-items: baseline;
+ flex-wrap: wrap;
+ gap: 0.5rem 1rem;
+ margin-bottom: 1.25rem;
+ font-family: var(--p-mono);
+ font-size: 0.72rem;
+ letter-spacing: 0.28em;
+ text-transform: uppercase;
+ color: var(--p-cyan);
+}
+
+.rss-hub-accent {
+ display: inline-block;
+ width: 8px; height: 8px;
+ border-radius: 50%;
+ background: var(--p-orange);
+ box-shadow: 0 0 12px var(--p-orange);
+ animation: pulse-dot 2s ease-in-out infinite;
+}
+
+@keyframes pulse-dot {
+ 0%, 100% { opacity: 1; transform: scale(1); }
+ 50% { opacity: 0.6; transform: scale(0.85); }
+}
+
+.rss-hub-sub {
+ font-size: 0.58rem;
+ letter-spacing: 0.1em;
+ color: var(--p-muted);
+ text-transform: none;
+}
+
+.rss-grid {
+ display: grid;
+ grid-template-columns: repeat(4, 1fr);
+ gap: 1rem;
+}
+
+@media (max-width: 1280px) { .rss-grid { grid-template-columns: repeat(2, 1fr); } }
+@media (max-width: 640px) { .rss-grid { grid-template-columns: 1fr; } }
+
+.rss-column {
+ border: 1px solid rgba(59,130,246,0.12);
+ border-radius: 10px;
+ padding: 0.75rem;
+ background: rgba(5,12,24,0.6);
+ min-height: 160px;
+}
+
+.rss-column-head {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ font-family: var(--p-mono);
+ font-size: 0.58rem;
+ letter-spacing: 0.2em;
+ text-transform: uppercase;
+ color: var(--p-orange);
+ margin-bottom: 0.65rem;
+ padding-bottom: 0.5rem;
+ border-bottom: 1px solid var(--p-border);
+}
+
+.rss-pulse {
+ width: 6px; height: 6px;
+ border-radius: 50%;
+ background: var(--p-cyan);
+ animation: pulse-dot 1.8s ease-in-out infinite;
+}
+
+.rss-feed-block { list-style: none; margin-bottom: 0.85rem; }
+
+.rss-feed-name {
+ display: block;
+ font-family: var(--p-mono);
+ font-size: 0.55rem;
+ letter-spacing: 0.12em;
+ text-transform: uppercase;
+ color: var(--p-muted);
+ margin-bottom: 0.35rem;
+}
+
+.rss-items { list-style: none; padding: 0; margin: 0; }
+
+.rss-items li {
+ display: flex;
+ justify-content: space-between;
+ gap: 0.5rem;
+ padding: 0.28rem 0;
+ border-bottom: 1px solid rgba(255,255,255,0.04);
+ font-size: 0.72rem;
+ line-height: 1.35;
+ animation: rss-fade-in 0.4s ease forwards;
+ opacity: 0;
+}
+
+.rss-items li:nth-child(1) { animation-delay: 0.05s; }
+.rss-items li:nth-child(2) { animation-delay: 0.1s; }
+.rss-items li:nth-child(3) { animation-delay: 0.15s; }
+.rss-items li:nth-child(4) { animation-delay: 0.2s; }
+.rss-items li:nth-child(5) { animation-delay: 0.25s; }
+
+@keyframes rss-fade-in { to { opacity: 1; } }
+
+.rss-items a {
+ color: var(--p-text) !important;
+ text-decoration: none;
+ flex: 1;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.rss-items a:hover { color: var(--p-orange) !important; }
+
+.rss-items time {
+ font-family: var(--p-mono);
+ font-size: 0.58rem;
+ color: var(--p-muted);
+ flex-shrink: 0;
+}
+
+.rss-loading { color: var(--p-muted); font-style: italic; }
+.rss-error a { color: var(--p-orange) !important; }
+
+.status-dot-status-green, .text-green-500 {
+ box-shadow: 0 0 8px rgba(74, 222, 128, 0.55);
+}
+
+footer, #footer {
+ opacity: 0.35;
+ font-family: var(--p-mono);
+ font-size: 0.62rem;
+}
+
+
+
+/* ─── Logo gradients + profile photo ─── */
+:root {
+ --color-logo-start: 59, 130, 246;
+ --color-logo-stop: 251, 146, 60;
+}
+
+.information-widget-logo img,
+.information-widget-logo a img,
+#information-widgets .information-widget-logo img {
+ width: 52px !important;
+ height: 52px !important;
+ min-width: 52px !important;
+ min-height: 52px !important;
+ border-radius: 50% !important;
+ object-fit: cover !important;
+ border: 2px solid rgba(251, 146, 60, 0.55) !important;
+ box-shadow: 0 0 0 1px rgba(59, 130, 246, 0.35), 0 4px 16px rgba(0, 0, 0, 0.4) !important;
+ filter: none !important;
+ animation: none !important;
+}
+
+.information-widget-logo:hover img {
+ border-color: rgba(34, 211, 238, 0.7) !important;
+}
+
+.service-icon {
+ position: relative !important;
+ border-radius: 8px !important;
+ margin: 2px 4px 2px 2px !important;
+ background: rgba(12, 22, 38, 0.85) !important;
+ border: 1px solid rgba(59, 130, 246, 0.12) !important;
+ box-shadow: none !important;
+ animation: none !important;
+ overflow: hidden;
+}
+
+.service-icon::before,
+.service-icon::after {
+ display: none !important;
+ content: none !important;
+}
+
+.service-icon > div { filter: none !important; transition: transform 0.2s ease; }
+.service-icon img { filter: saturate(1.12) contrast(1.02) !important; transition: transform 0.2s ease; }
+
+.service-card:hover .service-icon {
+ border-color: rgba(251, 146, 60, 0.35) !important;
+ box-shadow: 0 0 0 1px rgba(251, 146, 60, 0.15) !important;
+}
+
+.service-card:hover .service-icon > div,
+.service-card:hover .service-icon img { transform: scale(1.06); }
+
+.services-list .service:nth-child(6n+1) .service-icon { border-left: 2px solid #3b82f6 !important; }
+.services-list .service:nth-child(6n+2) .service-icon { border-left: 2px solid #a855f7 !important; }
+.services-list .service:nth-child(6n+3) .service-icon { border-left: 2px solid #22c55e !important; }
+.services-list .service:nth-child(6n+4) .service-icon { border-left: 2px solid #06b6d4 !important; }
+.services-list .service:nth-child(6n+5) .service-icon { border-left: 2px solid #f59e0b !important; }
+.services-list .service:nth-child(6n+6) .service-icon { border-left: 2px solid #ec4899 !important; }
+
+.bookmark-icon { font-weight: 600 !important; color: #fff !important; animation: none !important; box-shadow: none !important; }
+.bookmark:nth-child(4n+1) .bookmark-icon { background: linear-gradient(135deg, #2563eb, #3b82f6) !important; }
+.bookmark:nth-child(4n+2) .bookmark-icon { background: linear-gradient(135deg, #ea580c, #fb923c) !important; }
+.bookmark:nth-child(4n+3) .bookmark-icon { background: linear-gradient(135deg, #7c3aed, #a855f7) !important; }
+.bookmark:nth-child(4n+4) .bookmark-icon { background: linear-gradient(135deg, #059669, #22c55e) !important; }
+
+.information-widget-resource .resource-icon { animation: none !important; filter: none !important; }
+.information-widget-resource:nth-child(1) .resource-icon { color: #60a5fa !important; }
+.information-widget-resource:nth-child(2) .resource-icon { color: #fb923c !important; }
+.information-widget-resource:nth-child(3) .resource-icon { color: #4ade80 !important; }
+
+.resource-usage > div {
+ background: linear-gradient(90deg, #3b82f6, #fb923c) !important;
+ animation: none !important;
+ background-size: 100% 100% !important;
+}
+
+.status-dot-status-green, .text-green-500 {
+ background-color: #4ade80 !important;
+ animation: status-pulse-green 3s ease-out infinite;
+}
+.status-dot-status-red, .text-red-500 {
+ background-color: #f87171 !important;
+ animation: status-pulse-red 3s ease-out infinite;
+}
+@keyframes status-pulse-green {
+ 0% { box-shadow: 0 0 0 0 rgba(74, 222, 128, 0.5); }
+ 70% { box-shadow: 0 0 0 6px rgba(74, 222, 128, 0); }
+}
+@keyframes status-pulse-red {
+ 0% { box-shadow: 0 0 0 0 rgba(248, 113, 113, 0.5); }
+ 70% { box-shadow: 0 0 0 6px rgba(248, 113, 113, 0); }
+}
+
+button[role="tab"][aria-selected="true"] { animation: none !important; }
+.service-card { animation: none !important; }
+.rss-hub { animation: none !important; }
+
+@media (prefers-reduced-motion: reduce) {
+ body::before, body::after, .rss-hub::before, .rss-pulse, .rss-hub-accent,
+ .service-icon, .service-icon::before, .service-icon::after, .service-card,
+ .bookmark-icon, .information-widget-resource .resource-icon, button[role="tab"][aria-selected="true"],
+ .rss-hub, #information-widgets img { animation: none !important; }
+}
diff --git a/apps/homepage/deploy-to-pve.sh b/apps/homepage/deploy-to-pve.sh
new file mode 100644
index 0000000..629c5f6
--- /dev/null
+++ b/apps/homepage/deploy-to-pve.sh
@@ -0,0 +1,64 @@
+#!/bin/sh
+# Deploy Homepage LXC (CT 120) on pve 192.168.1.216
+set -e
+PW="${PROXMOX_PASSWORD:-WaQTUw2t}"
+PVE=192.168.1.216
+VMID=120
+APP="${HOMEPAGE_DIR:-/volume1/docker/homelab-configs/apps/homepage}"
+STORAGE="${PVE_STORAGE:-Storage}"
+
+ssh_pve() {
+ docker run --rm -v "$APP:/src:ro" alpine sh -c "
+ apk add --no-cache openssh-client sshpass rsync >/dev/null 2>&1
+ sshpass -p '$PW' ssh -o StrictHostKeyChecking=no root@$PVE \"$1\"
+ "
+}
+
+echo "=== Proxmox: LXC $VMID homepage ==="
+if ssh_pve "pct status $VMID" 2>/dev/null | grep -q running; then
+ echo "CT $VMID bestaat al en draait"
+else
+ if ssh_pve "pct status $VMID" 2>/dev/null | grep -q stopped; then
+ echo "Start bestaande CT $VMID"
+ ssh_pve "pct start $VMID"
+ else
+ echo "Maak CT $VMID aan..."
+ ssh_pve "pct create $VMID local:vztmpl/debian-13-standard_13.1-2_amd64.tar.zst \
+ --arch amd64 --cores 2 --memory 2048 --swap 512 \
+ --hostname homepage --password '$PW' \
+ --rootfs ${STORAGE}:8 \
+ --net0 name=eth0,bridge=vmbr0,ip=dhcp,type=veth \
+ --features nesting=1,keyctl=1 --unprivileged 1 --onboot 1"
+ ssh_pve "pct start $VMID"
+ fi
+fi
+
+echo "Wacht op netwerk..."
+sleep 8
+
+echo "=== Docker installeren ==="
+ssh_pve "pct exec $VMID -- bash -c 'export DEBIAN_FRONTEND=noninteractive; \
+ apt-get update -qq && apt-get install -y -qq docker.io docker-compose curl ca-certificates 2>/dev/null; \
+ systemctl enable --now docker'"
+
+echo "=== Config genereren ==="
+python3 "$APP/generate-config.py" 2>/dev/null || true
+
+echo "=== Config uploaden ==="
+# tar config and push
+tar -C "$APP" -czf /tmp/homepage-deploy.tgz docker-compose.yml config public websites-add.txt
+docker run --rm -v /tmp:/tmp alpine sh -c "
+ apk add --no-cache openssh-client sshpass >/dev/null 2>&1
+ sshpass -p '$PW' scp -o StrictHostKeyChecking=no /tmp/homepage-deploy.tgz root@$PVE:/tmp/
+"
+ssh_pve "pct exec $VMID -- mkdir -p /opt/homepage && pct push $VMID /tmp/homepage-deploy.tgz /tmp/homepage-deploy.tgz"
+ssh_pve "pct exec $VMID -- bash -c 'cd /opt/homepage && tar xzf /tmp/homepage-deploy.tgz && rm /tmp/homepage-deploy.tgz'"
+
+echo "=== Homepage starten ==="
+ssh_pve "pct exec $VMID -- bash -c 'cd /opt/homepage && docker compose pull && docker compose up -d'"
+
+echo "=== IP ophalen ==="
+IP=$(ssh_pve "pct exec $VMID -- hostname -I" | awk '{print $1}')
+echo ""
+echo "Klaar: http://${IP:-192.168.1.216}:3000"
+echo "Of via pve-host als poort 3000 op CT staat: check Proxmox → CT $VMID"
diff --git a/apps/homepage/docker-compose.admin.yml b/apps/homepage/docker-compose.admin.yml
new file mode 100644
index 0000000..b622f8c
--- /dev/null
+++ b/apps/homepage/docker-compose.admin.yml
@@ -0,0 +1,17 @@
+# Homepage Admin UI — draait op de NAS (grafisch sites toevoegen)
+# Start: docker compose -f docker-compose.admin.yml up -d --build
+services:
+ homepage-admin:
+ build: ./admin
+ container_name: homepage-admin
+ restart: unless-stopped
+ ports:
+ - "3010:3010"
+ environment:
+ PROXMOX_PASSWORD: ${PROXMOX_PASSWORD:-WaQTUw2t}
+ HOMEPAGE_DIR: /apps/homepage
+ volumes:
+ - /volume1/docker/homelab-configs/apps:/apps:rw
+ - /var/run/docker.sock:/var/run/docker.sock
+ working_dir: /apps/homepage/admin
+ command: python server.py
diff --git a/apps/homepage/docker-compose.yml b/apps/homepage/docker-compose.yml
new file mode 100644
index 0000000..7fea8ab
--- /dev/null
+++ b/apps/homepage/docker-compose.yml
@@ -0,0 +1,18 @@
+services:
+ homepage:
+ image: ghcr.io/gethomepage/homepage:latest
+ container_name: homepage
+ restart: unless-stopped
+ ports:
+ - "3000:3000"
+ environment:
+ # Homelab LAN: * = geen host-check (DHCP/NPM hostnames wisselen)
+ HOMEPAGE_ALLOWED_HOSTS: "*"
+ PUID: 0
+ PGID: 0
+ volumes:
+ - ./config:/app/config
+ - ./config/custom.css:/app/config/custom.css:ro
+ - ./config/custom.js:/app/config/custom.js:ro
+ - ./public/images:/app/public/images:ro
+ - /var/run/docker.sock:/var/run/docker.sock:ro
diff --git a/apps/homepage/generate-config.py b/apps/homepage/generate-config.py
new file mode 100644
index 0000000..71ee941
--- /dev/null
+++ b/apps/homepage/generate-config.py
@@ -0,0 +1,372 @@
+#!/usr/bin/env python3
+"""Generate Homepage YAML from Homarr default.json with smart categorization."""
+import json
+import re
+from pathlib import Path
+from urllib.parse import urlparse
+
+_ROOT = Path(__file__).resolve().parent
+HOMARR = _ROOT.parent / "homarr" / "config" / "default.json"
+OUT = _ROOT / "config"
+WEBSITES_ADD = _ROOT / "websites-add.txt"
+
+ICON_MAP = {
+ "adguard": "adguard-home.png",
+ "gitea": "gitea.png",
+ "grafana": "grafana.png",
+ "prometheus": "prometheus.png",
+ "portainer": "portainer.png",
+ "postgres": "postgres.png",
+ "neo4j": "neo4j.png",
+ "home assistant": "home-assistant.png",
+ "ha ": "home-assistant.png",
+ "unifi": "unifi.png",
+ "nginx": "nginx-proxy-manager.png",
+ "vaultwarden": "vaultwarden.png",
+ "immich": "immich.png",
+ "n8n": "n8n.png",
+ "sonarr": "sonarr.png",
+ "radarr": "radarr.png",
+ "prowlarr": "prowlarr.png",
+ "qbittorrent": "qbittorrent.png",
+ "sabnzbd": "sabnzbd.png",
+ "nextcloud": "nextcloud.png",
+ "excalidraw": "excalidraw.png",
+ "wazuh": "wazuh.png",
+ "truenas": "truenas.png",
+ "frigate": "frigate.png",
+ "linkwarden": "linkwarden.png",
+ "uptime": "uptime-kuma.png",
+ "changedetection": "changedetection.png",
+ "traccar": "traccar.png",
+ "tunarr": "tunarr.png",
+ "metube": "metube.png",
+ "remotely": "remotely.png",
+ "homarr": "homarr.png",
+ "chatgpt": "si-openai",
+ "github": "si-github",
+ "anthropic": "si-anthropic",
+ "deepseek": "si-deepseek",
+ "grok": "si-x",
+ "youtube": "si-youtube",
+ "proxmox": "proxmox.png",
+}
+
+# Hex colors per group for mdi/si icons
+GROUP_ICON_COLORS = {
+ "Infrastructure": "#3b82f6",
+ "Media & TV": "#a855f7",
+ "Smart Home": "#22c55e",
+ "Productivity": "#06b6d4",
+ "Tools & Utils": "#f59e0b",
+ "AI Assistants": "#ec4899",
+ "Dev & Docs": "#14b8a6",
+ "Web Design": "#f97316",
+}
+
+# Service groups → Homepage tab
+SERVICE_GROUPS = [
+ "Infrastructure",
+ "Media & TV",
+ "Smart Home",
+ "Productivity",
+ "Tools & Utils",
+ "AI Assistants",
+ "Dev & Docs",
+ "Web Design",
+]
+
+HOMELAB_RULES = [
+ ("Infrastructure", [
+ "proxmox", "portainer", "truenas", " adguard", "unifi", "wazuh", "idrac",
+ "webmin", "pve script", "modem", "rackula", "mo-nas", "nginx", "nas",
+ "lxc", "browserless", "remotely", "minarca", "change detection",
+ "changedetection", "dsm on", "dsm aux", "turnkey", "synology stuff",
+ ]),
+ ("Media & TV", [
+ "tunarr", "immich", "frigate", "metube", "nodecast", "nzb geek",
+ "usenetdeal",
+ ]),
+ ("Smart Home", [
+ " ha", "home assistant", "ring", "mawaqit", "home control", "ha voice",
+ ]),
+ ("Productivity", [
+ "nextcloud", "vaultwarden", "n8n", "gitea", "excalidraw", "linkwarden",
+ "invoice", "onlyoffice", "neo4j", "crm", "resume", "openresume",
+ "bentopdf", "vert", "moocup", "thunderbird", "noted apps",
+ ]),
+]
+
+RSS_BOOKMARKS = {
+ "Tech News": [
+ ("Hacker News", "https://hnrss.org/frontpage"),
+ ("Ars Technica", "https://feeds.arstechnica.com/arstechnica/index"),
+ ("The Verge", "https://www.theverge.com/rss/index.xml"),
+ ("Lobsters", "https://lobste.rs/rss"),
+ ],
+ "Security": [
+ ("BleepingComputer", "https://www.bleepingcomputer.com/feed/"),
+ ("Krebs on Security", "https://krebsonsecurity.com/feed/"),
+ ("The Hacker News", "https://feeds.feedburner.com/TheHackersNews"),
+ ],
+ "Homelab": [
+ ("selfh.st", "https://selfh.st/rss/"),
+ ("Proxmox Forum", "https://forum.proxmox.com/forums/-/index.rss"),
+ ("r/selfhosted", "https://www.reddit.com/r/selfhosted/.rss"),
+ ("Docker Blog", "https://www.docker.com/blog/feed/"),
+ ("LinuxServer.io", "https://www.linuxserver.io/blog/rss/"),
+ ("Marius Hosting", "https://mariushosting.com/feed/"),
+ ],
+ "AI & Dev": [
+ ("OpenAI Blog", "https://openai.com/blog/rss.xml"),
+ ("Hugging Face Blog", "https://huggingface.co/blog/feed.xml"),
+ ("GitHub Blog", "https://github.blog/feed/"),
+ ],
+ "Blogs": [
+ ("Jack van lightly", "https://jack-vanlightly.com/feed.xml"),
+ ("Juhache", "https://juhache.substack.com/feed"),
+ ],
+}
+
+
+def slug_name(name: str) -> str:
+ s = re.sub(r"[^a-zA-Z0-9]+", "-", name).strip("-").lower()
+ return s or "service"
+
+
+def guess_icon(name: str, url: str) -> str:
+ n = name.lower()
+ for key, icon in ICON_MAP.items():
+ if key in n:
+ return icon
+ host = urlparse(url).hostname or ""
+ if host:
+ part = host.split(".")[0]
+ if part in ("192", "www"):
+ return "mdi-server-network"
+ return f"mdi-{part[:12]}"
+ return "mdi-circle-outline"
+
+
+def colorize_icon(icon: str, group: str) -> str:
+ """Add hex color to vector icons for vivid Homepage gradients."""
+ if "#" in icon or icon.endswith((".png", ".webp", ".svg")) or icon.startswith("http"):
+ return icon
+ color = GROUP_ICON_COLORS.get(group, "#22d3ee")
+ if icon.startswith(("mdi-", "si-")):
+ return f"{icon}-{color}"
+ return icon
+
+
+def classify_homelab(name: str, url: str) -> str:
+ blob = f" {name.lower()} {url.lower()} "
+ for group, keys in HOMELAB_RULES:
+ if any(k in blob for k in keys):
+ return group
+ return "Tools & Utils"
+
+
+def classify_app(name: str, url: str, homarr_cat: str) -> str:
+ if homarr_cat == "AI":
+ return "AI Assistants"
+ if homarr_cat == "HTML5":
+ return "Web Design"
+ if homarr_cat in ("Other", "Data Stuff", "Projects"):
+ return "Dev & Docs"
+ if homarr_cat == "HomeLab Stuff":
+ return classify_homelab(name, url)
+ return "Tools & Utils"
+
+
+def yaml_escape(s: str) -> str:
+ return s.replace("\\", "\\\\").replace('"', '\\"')
+
+
+def load_websites_extra() -> dict:
+ """Lees websites-add.txt: groep|naam|url per regel."""
+ extra = {g: [] for g in SERVICE_GROUPS}
+ if not WEBSITES_ADD.exists():
+ return extra
+ for raw in WEBSITES_ADD.read_text(encoding="utf-8").splitlines():
+ line = raw.strip()
+ if not line or line.startswith("#"):
+ continue
+ parts = [p.strip() for p in line.split("|", 2)]
+ if len(parts) != 3:
+ continue
+ group, name, url = parts
+ if group not in extra:
+ print(f" Waarschuwing: onbekende groep '{group}' — regel overgeslagen")
+ continue
+ if name and url:
+ extra[group].append((name, url))
+ return extra
+
+
+def merge_groups(base: dict, extra: dict) -> dict:
+ """Homarr + handmatige sites; extra overschrijft zelfde naam in zelfde groep."""
+ out = {g: list(base.get(g, [])) for g in SERVICE_GROUPS}
+ for group, items in extra.items():
+ if group not in out:
+ out[group] = []
+ names = {n.lower() for n, _ in out[group]}
+ for name, url in items:
+ key = name.lower()
+ if key in names:
+ out[group] = [(n, u) for n, u in out[group] if n.lower() != key]
+ names.discard(key)
+ out[group].append((name, url))
+ names.add(key)
+ return out
+
+
+def write_services(by_group: dict) -> str:
+ lines = []
+ for group in SERVICE_GROUPS:
+ items = by_group.get(group, [])
+ if not items:
+ continue
+ lines.append(f"- {group}:")
+ seen = set()
+ for name, url in items:
+ key = slug_name(name)
+ if key in seen:
+ key = f"{key}-{len(seen)}"
+ seen.add(key)
+ icon = colorize_icon(guess_icon(name, url), group)
+ lines.append(f" - {name}:")
+ lines.append(f" icon: {icon}")
+ lines.append(f" href: {url}")
+ lines.append(f" description: {yaml_escape(name)}")
+ try:
+ p = urlparse(url)
+ if p.scheme and p.hostname and re.match(r"^\d+\.\d+\.\d+\.\d+$", p.hostname):
+ base = f"{p.scheme}://{p.netloc}"
+ lines.append(f" siteMonitor: {base}")
+ lines.append(" statusStyle: dot")
+ except Exception:
+ pass
+ lines.append("")
+ return "\n".join(lines)
+
+
+def write_settings() -> str:
+ layout = {
+ "Infrastructure": {"tab": "Ops", "icon": "mdi-server-network", "columns": 4, "style": "row"},
+ "Media & TV": {"tab": "Media", "icon": "mdi-television-play", "columns": 4, "style": "row"},
+ "Smart Home": {"tab": "Home", "icon": "mdi-home-automation", "columns": 3, "style": "row"},
+ "Productivity": {"tab": "Work", "icon": "mdi-briefcase-outline", "columns": 4, "style": "row"},
+ "Tools & Utils": {"tab": "Ops", "icon": "mdi-toolbox-outline", "columns": 3, "style": "row"},
+ "AI Assistants": {"tab": "AI", "icon": "mdi-brain", "columns": 4, "style": "row"},
+ "Dev & Docs": {"tab": "AI", "icon": "mdi-book-open-page-variant", "columns": 3, "style": "row"},
+ "Web Design": {"tab": "AI", "icon": "mdi-palette-outline", "columns": 3, "style": "row"},
+ "Tech News": {"tab": "Feeds", "icon": "mdi-newspaper-variant-outline", "columns": 2},
+ "Security": {"tab": "Feeds", "icon": "mdi-shield-alert-outline", "columns": 2},
+ "Homelab": {"tab": "Feeds", "icon": "mdi-server", "columns": 3},
+ "AI & Dev": {"tab": "Feeds", "icon": "mdi-robot-outline", "columns": 2},
+ "Blogs": {"tab": "Feeds", "icon": "mdi-rss", "columns": 2},
+ }
+ lines = [
+ "---",
+ "title: EL-KADI OPS",
+ "description: Homelab command surface",
+ "theme: dark",
+ "color: slate",
+ "iconStyle: gradient",
+ "headerStyle: underlined",
+ "useEqualHeights: true",
+ "statusStyle: dot",
+ "fullWidth: true",
+ "cardBlur: md",
+ "hideVersion: false",
+ "quicklaunch:",
+ " searchDescriptions: true",
+ " hideInternetSearch: false",
+ " showSearchSuggestions: true",
+ "layout:",
+ ]
+ for name, opts in layout.items():
+ lines.append(f" {name}:")
+ for k, v in opts.items():
+ lines.append(f" {k}: {v}")
+ return "\n".join(lines) + "\n"
+
+
+def write_bookmarks() -> str:
+ lines = ["---"]
+ for group, items in RSS_BOOKMARKS.items():
+ lines.append(f"- {group}:")
+ for name, url in items:
+ abbr = "".join(w[0] for w in re.findall(r"[A-Za-z0-9]+", name)[:2]).upper()[:2] or "RS"
+ lines.append(f" - {name}:")
+ lines.append(f" - abbr: {abbr}")
+ lines.append(f" href: {url}")
+ return "\n".join(lines) + "\n"
+
+
+def write_widgets() -> str:
+ return """---
+- logo:
+ icon: /images/logo.jpg
+
+- greeting:
+ text_size: xl
+ text: el-kadi ops
+
+- datetime:
+ text_size: xl
+ format:
+ timeStyle: short
+ dateStyle: medium
+ hour12: false
+
+- search:
+ provider: google
+ target: _blank
+ showSearchSuggestions: true
+
+- resources:
+ cpu: true
+ memory: true
+ cputemp: true
+ uptime: true
+ units: metric
+ refresh: 3000
+"""
+
+
+def main():
+ with open(HOMARR) as f:
+ d = json.load(f)
+ cats = {c["id"]: c["name"] for c in d.get("categories", [])}
+ by_group: dict[str, list] = {g: [] for g in SERVICE_GROUPS}
+
+ for a in d.get("apps", []):
+ name = (a.get("name") or "").strip()
+ url = (a.get("url") or "").strip()
+ if not name or not url:
+ continue
+ area = a.get("area", {}).get("properties", {}).get("id", "")
+ homarr_cat = cats.get(area, "Other")
+ group = classify_app(name, url, homarr_cat)
+ by_group.setdefault(group, []).append((name, url))
+
+ extra = load_websites_extra()
+ extra_count = sum(len(v) for v in extra.values())
+ by_group = merge_groups(by_group, extra)
+
+ OUT.mkdir(parents=True, exist_ok=True)
+ (OUT / "services.yaml").write_text(write_services(by_group))
+ (OUT / "settings.yaml").write_text(write_settings())
+ (OUT / "widgets.yaml").write_text(write_widgets())
+ (OUT / "bookmarks.yaml").write_text(write_bookmarks())
+
+ total = sum(len(v) for v in by_group.values())
+ print(
+ f"Generated {total} apps in {sum(1 for v in by_group.values() if v)} groups"
+ + (f" (+{extra_count} uit websites-add.txt)" if extra_count else "")
+ )
+
+
+if __name__ == "__main__":
+ main()
diff --git a/apps/homepage/public/images/README.md b/apps/homepage/public/images/README.md
new file mode 100644
index 0000000..eac0863
--- /dev/null
+++ b/apps/homepage/public/images/README.md
@@ -0,0 +1,18 @@
+# Profielfoto (logo)
+
+Plaats je foto hier als **`logo.jpg`** (vierkant werkt het best, min. 128×128 px).
+
+```bash
+cp /pad/naar/jouw-foto.jpg logo.jpg
+cd /volume1/docker/homelab-configs/apps/homepage
+./deploy-to-pve.sh
+```
+
+Of gebruik het script:
+
+```bash
+./set-logo.sh /pad/naar/foto.jpg
+./deploy-to-pve.sh
+```
+
+Daarna op Homepage: refresh-icoon rechtsonder, of Ctrl+Shift+R.
diff --git a/apps/homepage/public/images/logo.jpg b/apps/homepage/public/images/logo.jpg
new file mode 100644
index 0000000..b80d7ef
Binary files /dev/null and b/apps/homepage/public/images/logo.jpg differ
diff --git a/apps/homepage/set-logo.sh b/apps/homepage/set-logo.sh
new file mode 100644
index 0000000..ba1e5e0
--- /dev/null
+++ b/apps/homepage/set-logo.sh
@@ -0,0 +1,9 @@
+#!/bin/sh
+# Kopieer je profielfoto naar Homepage logo
+set -e
+SRC="${1:?Gebruik: ./set-logo.sh /pad/naar/foto.jpg}"
+DEST="$(dirname "$0")/public/images/logo.jpg"
+mkdir -p "$(dirname "$DEST")"
+cp "$SRC" "$DEST"
+echo "Logo gezet: $DEST"
+echo "Deploy: ./deploy-to-pve.sh"
diff --git a/apps/homepage/start-admin.sh b/apps/homepage/start-admin.sh
new file mode 100644
index 0000000..e15fdff
--- /dev/null
+++ b/apps/homepage/start-admin.sh
@@ -0,0 +1,12 @@
+#!/bin/sh
+# Start Homepage Admin UI op de NAS
+set -e
+cd "$(dirname "$0")"
+if docker compose version >/dev/null 2>&1; then
+ docker compose -f docker-compose.admin.yml up -d --build
+else
+ docker-compose -f docker-compose.admin.yml up -d --build
+fi
+echo ""
+echo "Admin UI: http://192.168.1.211:3010"
+echo "Dashboard: http://192.168.1.192:3000"
diff --git a/apps/homepage/websites-add.txt b/apps/homepage/websites-add.txt
new file mode 100644
index 0000000..3dfc93a
--- /dev/null
+++ b/apps/homepage/websites-add.txt
@@ -0,0 +1,7 @@
+# Extra websites — één per regel: groep|naam|url
+# Voorbeeld:
+# Productivity|Jellyfin|http://192.168.1.10:8096
+# AI Assistants|Claude|https://claude.ai
+#
+# Of gebruik: ./add-website.sh
+Productivity|Jellyfin Test|http://192.168.1.99:8096
diff --git a/apps/homepage/websites.yaml b/apps/homepage/websites.yaml
new file mode 100644
index 0000000..e9924c0
--- /dev/null
+++ b/apps/homepage/websites.yaml
@@ -0,0 +1,2 @@
+# (optioneel) Zie websites-add.txt — dat is het makkelijkste bestand om te bewerken.
+# Gebruik: ./add-website.sh of bewerk websites-add.txt
diff --git a/apps/proxmox/hosts/pve/lxc/120.conf b/apps/proxmox/hosts/pve/lxc/120.conf
new file mode 100644
index 0000000..a5c4336
--- /dev/null
+++ b/apps/proxmox/hosts/pve/lxc/120.conf
@@ -0,0 +1,11 @@
+arch: amd64
+cores: 2
+features: nesting=1,keyctl=1
+hostname: homepage
+memory: 2048
+net0: name=eth0,bridge=vmbr0,hwaddr=BC:24:11:D3:74:DD,ip=dhcp,type=veth
+onboot: 1
+ostype: debian
+rootfs: Storage:vm-120-disk-0,size=8G
+swap: 512
+unprivileged: 1
diff --git a/apps/proxmox/lxc-inventory.md b/apps/proxmox/lxc-inventory.md
index c82d32d..54a88c3 100644
--- a/apps/proxmox/lxc-inventory.md
+++ b/apps/proxmox/lxc-inventory.md
@@ -33,6 +33,7 @@ Configs: `hosts//lxc/*.conf` · App-configs: `apps//`
| 117 | Proxy | 192.168.1.165 | [proxy](../proxy/) | **running** |
| 118 | paymenter | 192.168.1.45 | [paymenter](../paymenter/) | **running** |
| 119 | nodecast-tv | 192.168.1.99 | [nodecast-tv](../nodecast-tv/) | **running** |
+| 120 | homepage | 192.168.1.192 | [homepage](../homepage/) | **running** |
## Host: dell-proxmox (192.168.1.56)