From f510bec447233966ee0e5507e3ee14fa8966868d Mon Sep 17 00:00:00 2001 From: carlitosbond Date: Thu, 23 Apr 2026 18:57:20 +0000 Subject: [PATCH] =?UTF-8?q?Initial=20commit=20=E2=80=94=20docker=20stack?= =?UTF-8?q?=20carloselugo.com?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Services: Caddy, frontend, Uptime Kuma, Netdata, ntfy, Dozzle, Gitea --- .gitignore | 20 ++ Caddyfile | 112 +++++++++++ docker-compose.yml | 175 +++++++++++++++++ dozzle/data/__default__/profile.json | 22 +++ dozzle/data/notifications.yml | 26 +++ frontend/Dockerfile | 14 ++ frontend/index.html | 17 ++ frontend/nginx.conf | 20 ++ frontend/package.json | 14 ++ frontend/src/components/blog.js | 277 +++++++++++++++++++++++++++ frontend/src/components/footer.js | 24 +++ frontend/src/components/header.js | 60 ++++++ frontend/src/components/hero.js | 113 +++++++++++ frontend/src/components/post.js | 86 +++++++++ frontend/src/components/projects.js | 108 +++++++++++ frontend/src/main.js | 35 ++++ frontend/src/style.css | 83 ++++++++ frontend/vite.config.js | 14 ++ ntfy/config/server.yml | 18 ++ 19 files changed, 1238 insertions(+) create mode 100644 .gitignore create mode 100644 Caddyfile create mode 100644 docker-compose.yml create mode 100644 dozzle/data/__default__/profile.json create mode 100644 dozzle/data/notifications.yml create mode 100644 frontend/Dockerfile create mode 100644 frontend/index.html create mode 100644 frontend/nginx.conf create mode 100644 frontend/package.json create mode 100644 frontend/src/components/blog.js create mode 100644 frontend/src/components/footer.js create mode 100644 frontend/src/components/header.js create mode 100644 frontend/src/components/hero.js create mode 100644 frontend/src/components/post.js create mode 100644 frontend/src/components/projects.js create mode 100644 frontend/src/main.js create mode 100644 frontend/src/style.css create mode 100644 frontend/vite.config.js create mode 100644 ntfy/config/server.yml diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..14f025d --- /dev/null +++ b/.gitignore @@ -0,0 +1,20 @@ +# Caddy auto-generated certs/data (managed by Docker volume) +caddy_data/ +caddy_config/ + +# Logs +*.log +logs/ + +# Environment files +.env +*.env + +# ntfy cache +ntfy/cache/ + +# Dozzle session data +dozzle/data/users.yml + +# ntfy user database (contains credentials) +ntfy/config/user.db diff --git a/Caddyfile b/Caddyfile new file mode 100644 index 0000000..fd237bd --- /dev/null +++ b/Caddyfile @@ -0,0 +1,112 @@ +# ── Global options ───────────────────────────────────────────────────────────── +{ + debug +} + +# ── Snippets reutilizables ───────────────────────────────────────────────────── +(security_headers) { + header { + Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" + X-Content-Type-Options "nosniff" + X-Frame-Options "DENY" + Referrer-Policy "strict-origin-when-cross-origin" + -Server + } +} + +(internal_only) { + basic_auth { + carlos $2a$14$6MgHKR/jLD..MFkuNo0i1OBs.wwZOntIDiYsYpsyyH./OCKa8SoGS + } + @blocked not remote_ip 24.139.235.46/32 192.168.68.0/24 2605:ba00:3108::/48 74.115.203.2/32 + abort @blocked +} + +# ── Main site ────────────────────────────────────────────────────────────────── +carloselugo.com, www.carloselugo.com { + reverse_proxy frontend:80 + import security_headers + encode gzip + log { + output file /var/log/caddy/access.log + format json + } +} + +# ── Uptime Kuma ──────────────────────────────────────────────────────────────── +uptime.carloselugo.com { + import internal_only + import security_headers + reverse_proxy uptime-kuma:3001 + encode gzip + log { + output file /var/log/caddy/uptime.log + format json + } +} + +# ── Netdata — protegido, info sensible ──────────────────────────────────────── +metrics.carloselugo.com { + import internal_only + import security_headers + header X-Frame-Options "SAMEORIGIN" + reverse_proxy netdata:19999 + encode gzip + log { + output file /var/log/caddy/metrics.log + format json + } +} + +# ── Umami Analytics ─────────────────────────────────────────────────────────── +analytics.carloselugo.com { + import security_headers + encode gzip + log { + output file /var/log/caddy/analytics.log + format json + } + + @public path /script.js /api/send + handle @public { + reverse_proxy umami:3000 + } + + handle { + @blocked not remote_ip 24.139.235.46/32 192.168.68.0/24 2605:ba00:3108::/48 74.115.203.2/32 + abort @blocked + reverse_proxy umami:3000 + } +} + +# ── ntfy (notificaciones push) ──────────────────────────────────────────────── +ntfy.carloselugo.com { + reverse_proxy ntfy:80 { + # ntfy usa SSE (Server-Sent Events) para push en tiempo real. + # Sin esto, las notificaciones no llegan al celular. + flush_interval -1 + } +} + +# ── Gitea (git) ─────────────────────────────────────────────────────────────── +git.carloselugo.com { + import security_headers + reverse_proxy gitea:3000 + encode gzip + log { + output file /var/log/caddy/gitea.log + format json + } +} + +# ── Dozzle (logs de containers) — protegido ─────────────────────────────────── +logs.carloselugo.com { + import internal_only + import security_headers + reverse_proxy dozzle:8080 + encode gzip + log { + output file /var/log/caddy/dozzle.log + format json + } +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..6be5343 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,175 @@ +services: + # ─── Reverse Proxy ──────────────────────────────────────────────────────── + caddy: + image: caddy:2-alpine + container_name: caddy + restart: unless-stopped + ports: + - "80:80" + - "443:443" + - "443:443/udp" # HTTP/3 + volumes: + - ./Caddyfile:/etc/caddy/Caddyfile:ro + - caddy_data:/data + - caddy_config:/config + networks: + - web-net + depends_on: + - frontend + - ntfy + - gitea + + # ─── Frontend ───────────────────────────────────────────────────────────── + frontend: + build: + context: ./frontend + dockerfile: Dockerfile + container_name: frontend + restart: unless-stopped + labels: + - "com.centurylinklabs.watchtower.enable=false" + networks: + - web-net + + # ─── Uptime Kuma — uptime.carloselugo.com ───────────────────────────────── + uptime-kuma: + image: louislam/uptime-kuma:1 + container_name: uptime-kuma + restart: unless-stopped + volumes: + - uptime_data:/app/data + - /var/run/docker.sock:/var/run/docker.sock:ro + networks: + - web-net + cpuset: "0,1" + + # ─── Netdata — metrics.carloselugo.com ──────────────────────────────────── + netdata: + image: netdata/netdata:stable + container_name: netdata + restart: unless-stopped + pid: host + cap_add: + - SYS_PTRACE + - SYS_ADMIN + security_opt: + - apparmor:unconfined + environment: + - NETDATA_COLLECTOR_UPDATE_EVERY=5 # reduce polling de 1s → 5s + volumes: + - netdata_config:/etc/netdata + - netdata_lib:/var/lib/netdata + - netdata_cache:/var/cache/netdata + - /etc/passwd:/host/etc/passwd:ro + - /etc/group:/host/etc/group:ro + - /etc/localtime:/etc/localtime:ro + - /proc:/host/proc:ro + - /sys:/host/sys:ro + - /etc/os-release:/host/etc/os-release:ro + - /var/run/docker.sock:/var/run/docker.sock:ro + networks: + - web-net + cpuset: "0,1" + cpu_shares: 256 # baja prioridad relativa + + # ─── Umami — DESACTIVADO (liberar RAM/CPU para enshrouded) ──────────────── + # Para reactivar: descomentar umami, umami-db, y el volumen umami_db_data + # umami: + # image: ghcr.io/umami-software/umami:postgresql-latest + # container_name: umami + # restart: unless-stopped + # environment: + # DATABASE_URL: postgresql://umami:umami_password@umami-db:5432/umami + # APP_SECRET: change_this_to_a_random_string_32chars + # depends_on: + # umami-db: + # condition: service_healthy + # networks: + # - web-net + + # umami-db: + # image: postgres:15-alpine + # container_name: umami-db + # restart: unless-stopped + # environment: + # POSTGRES_DB: umami + # POSTGRES_USER: umami + # POSTGRES_PASSWORD: umami_password + # volumes: + # - umami_db_data:/var/lib/postgresql/data + # networks: + # - web-net + # healthcheck: + # test: ["CMD-SHELL", "pg_isready -U umami -d umami"] + # interval: 10s + # timeout: 5s + # retries: 5 + # sysctls: + # - net.ipv6.conf.all.disable_ipv6=1 + + # ─── ntfy — ntfy.carloselugo.com ───────────────────────────────────────────── + ntfy: + image: binwiederhier/ntfy + container_name: ntfy + restart: unless-stopped + command: serve + environment: + - TZ=America/Puerto_Rico + volumes: + - ./ntfy/config:/etc/ntfy + - ntfy_cache:/var/cache/ntfy + networks: + - web-net + healthcheck: + test: ["CMD-SHELL", "wget -q --tries=1 http://localhost:80/v1/health -O /dev/null || exit 1"] + interval: 60s + timeout: 10s + retries: 3 + + # ─── Dozzle — logs.carloselugo.com ──────────────────────────────────────── + dozzle: + image: amir20/dozzle:latest + container_name: dozzle + restart: unless-stopped + volumes: + - ./dozzle/data:/data + - /var/run/docker.sock:/var/run/docker.sock:ro + networks: + - web-net + + # ─── Gitea — git.carloselugo.com ────────────────────────────────────────── + gitea: + image: gitea/gitea:latest + container_name: gitea + restart: unless-stopped + environment: + - USER_UID=1000 + - USER_GID=1000 + - GITEA__database__DB_TYPE=sqlite3 + - GITEA__database__PATH=/data/gitea/gitea.db + - GITEA__server__DOMAIN=git.carloselugo.com + - GITEA__server__ROOT_URL=https://git.carloselugo.com + - GITEA__server__HTTP_PORT=3000 + - GITEA__server__SSH_DOMAIN=git.carloselugo.com + - GITEA__server__SSH_PORT=2222 + volumes: + - gitea_data:/data + ports: + - "2222:22" + networks: + - web-net + +volumes: + caddy_data: + caddy_config: + uptime_data: + netdata_config: + netdata_lib: + netdata_cache: + # umami_db_data: # descomentar si reactivas umami + ntfy_cache: + gitea_data: + +networks: + web-net: + driver: bridge diff --git a/dozzle/data/__default__/profile.json b/dozzle/data/__default__/profile.json new file mode 100644 index 0000000..62cf454 --- /dev/null +++ b/dozzle/data/__default__/profile.json @@ -0,0 +1,22 @@ +{ + "settings": { + "search": true, + "menuWidth": 15, + "smallerScrollbars": false, + "showTimestamp": true, + "showStd": false, + "showAllContainers": false, + "softWrap": true, + "collapseNav": true, + "automaticRedirect": "delayed", + "size": "medium", + "compact": true, + "lightTheme": "auto", + "hourStyle": "auto", + "dateLocale": "auto", + "locale": "", + "groupContainers": "at-least-2" + }, + "pinned": [], + "collapsedGroups": null +} \ No newline at end of file diff --git a/dozzle/data/notifications.yml b/dozzle/data/notifications.yml new file mode 100644 index 0000000..6f9ad7e --- /dev/null +++ b/dozzle/data/notifications.yml @@ -0,0 +1,26 @@ +subscriptions: + - id: 1 + name: Enshrouded Login + enabled: true + dispatcherId: 1 + logExpression: message contains "logged in with Permissions" + containerExpression: name == "enshrouded" + - id: 2 + name: Enshrouded Logout + enabled: true + dispatcherId: 1 + logExpression: message contains "Remove Player" + containerExpression: name == "enshrouded" +dispatchers: + - id: 1 + name: Local NTFY + type: webhook + url: https://ntfy.carloselugo.com + template: |- + { + "topic": "homelab", + "title": "{{ .Container.Name }}", + "message": "{{ .Detail }}" + } + headers: + Authorization: Basic Y2FybG9zOkZObVVzVHJHSHUjSk44dQ== diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..00b309b --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,14 @@ +# Stage 1: Build +FROM node:20-alpine AS builder +WORKDIR /app +COPY package*.json ./ +RUN npm install +COPY . . +RUN npm run build + +# Stage 2: Serve with nginx (lightweight) +FROM nginx:alpine AS production +COPY --from=builder /app/dist /usr/share/nginx/html +COPY nginx.conf /etc/nginx/conf.d/default.conf +EXPOSE 80 +CMD ["nginx", "-g", "daemon off;"] \ No newline at end of file diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..f7aa22f --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,17 @@ + + + + + + + + Carlos Lugo + + + + + +
+ + + \ No newline at end of file diff --git a/frontend/nginx.conf b/frontend/nginx.conf new file mode 100644 index 0000000..554f7e7 --- /dev/null +++ b/frontend/nginx.conf @@ -0,0 +1,20 @@ +server { + listen 80; + server_name _; + root /usr/share/nginx/html; + index index.html; + + # SPA fallback + location / { + try_files $uri $uri/ /index.html; + } + + # Cache static assets + location ~* \.(js|css|png|jpg|jpeg|gif|svg|ico|woff2?)$ { + expires 1y; + add_header Cache-Control "public, immutable"; + } + + gzip on; + gzip_types text/plain text/css application/javascript application/json image/svg+xml; +} \ No newline at end of file diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..743b1aa --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,14 @@ +{ + "name": "carloselugo-web", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "devDependencies": { + "vite": "^5.4.0" + } +} \ No newline at end of file diff --git a/frontend/src/components/blog.js b/frontend/src/components/blog.js new file mode 100644 index 0000000..ba0a2d6 --- /dev/null +++ b/frontend/src/components/blog.js @@ -0,0 +1,277 @@ +import { posts } from './post.js' + +// ── Helpers ───────────────────────────────────────────────────────────── +const categoryColor = { + homelab: '#c8ff00', // accent lime + personal: '#ff6b35', // orange + professional: '#5b9cf6', // blue + entertainment: '#b47cff', // purple +} + +function formatDate(iso) { + return new Date(iso).toLocaleDateString('en-US', { + year: 'numeric', month: 'short', day: 'numeric', + }) +} + +// ── Post card (list view) ──────────────────────────────────────────────── +function renderPostCard(post, index) { + const color = categoryColor[post.category] ?? '#c8ff00' + return ` +
+
+ ● ${post.category} + ${formatDate(post.date)} +
+

${post.title}

+

${post.excerpt}

+ +
+ ` +} + +// ── Full post (detail view) ────────────────────────────────────────────── +function renderPostDetail(post) { + const color = categoryColor[post.category] ?? '#c8ff00' + return ` +
+ +
+ ● ${post.category} + ${formatDate(post.date)} +
+

${post.title}

+
+ ${post.content} +
+
+ ` +} + +// ── Section shell ──────────────────────────────────────────────────────── +export function renderBlog() { + // Sort newest first + const sorted = [...posts].sort((a, b) => new Date(b.date) - new Date(a.date)) + + return ` +
+
+
+
+

Blog

+

+ Notes on homelab builds, things I'm working on, and whatever I'm into. +

+
+ + +
+ + + + + +
+ + +
+ ${sorted.map((p, i) => renderPostCard(p, i)).join('')} +
+ + + +
+
+ + + ` +} + +// ── Wire up interactivity (call after DOM insertion) ───────────────────── +export function initBlog() { + const listEl = document.getElementById('blog-list') + const detailEl = document.getElementById('blog-detail-wrapper') + + // ── Filter buttons ── + document.querySelectorAll('.filter-btn').forEach(btn => { + btn.addEventListener('click', () => { + document.querySelectorAll('.filter-btn').forEach(b => b.classList.remove('active')) + btn.classList.add('active') + + const filter = btn.dataset.filter + document.querySelectorAll('.blog-card').forEach(card => { + const match = filter === 'all' || card.dataset.postId && + posts.find(p => p.id === card.dataset.postId)?.category === filter + card.style.display = match ? '' : 'none' + }) + }) + }) + + // ── Open post ── + document.querySelectorAll('.blog-read-btn').forEach(btn => { + btn.addEventListener('click', () => { + const post = posts.find(p => p.id === btn.dataset.postId) + if (!post) return + listEl.style.display = 'none' + document.querySelector('.blog-filters').style.display = 'none' + detailEl.style.display = 'block' + detailEl.innerHTML = renderPostDetail(post) + detailEl.scrollIntoView({ behavior: 'smooth', block: 'start' }) + + // Back button + document.getElementById('blog-back').addEventListener('click', () => { + detailEl.style.display = 'none' + listEl.style.display = '' + document.querySelector('.blog-filters').style.display = '' + }) + }) + }) +} \ No newline at end of file diff --git a/frontend/src/components/footer.js b/frontend/src/components/footer.js new file mode 100644 index 0000000..2f8ea93 --- /dev/null +++ b/frontend/src/components/footer.js @@ -0,0 +1,24 @@ +export function renderFooter() { + const year = new Date().getFullYear() + return ` + + + + ` +} \ No newline at end of file diff --git a/frontend/src/components/header.js b/frontend/src/components/header.js new file mode 100644 index 0000000..2035ab0 --- /dev/null +++ b/frontend/src/components/header.js @@ -0,0 +1,60 @@ +export function renderHeader() { + return ` + + + + ` +} \ No newline at end of file diff --git a/frontend/src/components/hero.js b/frontend/src/components/hero.js new file mode 100644 index 0000000..d833873 --- /dev/null +++ b/frontend/src/components/hero.js @@ -0,0 +1,113 @@ +export function renderHero() { + return ` +
+
+
// hello world
+

+ Carlos
+ Lugo. +

+

+ Homelab tinkerer. Network nerd.
+ Building things that run 24/7 in a rack at home. +

+
+ docker + linux + networking + fortigate + game servers +
+ +
+
+ + + ` +} \ No newline at end of file diff --git a/frontend/src/components/post.js b/frontend/src/components/post.js new file mode 100644 index 0000000..b912768 --- /dev/null +++ b/frontend/src/components/post.js @@ -0,0 +1,86 @@ +// ───────────────────────────────────────────── +// BLOG POSTS — edit here to add / update posts +// Fields: +// id : unique slug, used in URL hash (e.g. 'my-first-post') +// date : ISO string (e.g. '2026-04-14') +// category : 'personal' | 'professional' | 'entertainment' | 'homelab' +// title : string +// excerpt : short summary shown on card +// content : full post body (HTML supported) +// ───────────────────────────────────────────── + +export const posts = [ + { + id: 'rescued-optiplex-rabbit-hole', + date: '2026-04-10', + category: 'homelab', + title: "Rescued a Dell Optiplex and Now I Can't Stop", + excerpt: "A decommissioned Optiplex 3000, Docker, and a rabbit hole I didn't expect.", + content: ` +

It started with a Dell Optiplex 3000 that was about to get wiped and trashed. I grabbed it + before it disappeared and figured I'd run a few things on it. That was a mistake — in the best way.

+ +

Docker opened a door I didn't know was there. The more I poked around, the more I realized + how much is already solved. Need a game server? There's a container for that. Need to monitor + your uptime, metrics, logs, and analytics? Container, container, container, container.

+ +

That's exactly what I ended up with: Uptime Kuma for service uptime, + Netdata for system metrics, Umami for page analytics, and + Dozzle for container logs. Simple stack, everything in one place, surprisingly complete.

+ +

The part that got me was publishing this site. I was genuinely a little nervous — I was exposing + something to the internet, inside my own network. Old me would've stopped there. Instead I thought: + there's probably a container for the security piece too. Turns out, yeah. Problem solved.

+ +

AI has a lot to do with this shift. Things that used to feel out of reach — self-hosting, + networking, monitoring — feel a lot more approachable when you can just ask questions and get unstuck fast.

+ +

Next goal: more Optiplexes, a Kubernetes cluster, and seeing how far this rabbit hole actually goes. + I'll keep you posted.

+ `, + }, + { + id: 'survival-sandbox-journey', + date: '2026-04-14', + category: 'entertainment', + title: "Survival Sandboxes and the Itch I Can't Scratch", + excerpt: "From Star Wars Galaxies to Enshrouded — apparently I've always been about building stuff and calling it home.", + content: ` +

I never connected the dots until recently, but there's a clear thread running through every game + I've actually sunk time into: you build something, and it feels like yours.

+ +

It started with Star Wars Galaxies — one of the first MMOs I played. Yeah there + was grinding, leveling, the full RPG loop. But what I kept coming back to was base building. There was + something about someone walking into your place and saying "this is cool" that hit different. + That little moment of validation from a stranger in a game. I get it now more than I did then.

+ +

Fast forward and I'm playing Minecraft with my kid. Graphics are what they are, + but the crafting system is genuinely deep. And watching a kid figure out resource chains and basic + survival logic — that's actually pretty solid for a young brain. It holds up.

+ +

A coworker told me he basically lived on Steam sales, buying whatever looked interesting at a + discount. I did the same and landed on No Man's Sky. That game broke my brain a + little. Procedurally generated solar systems, planets, flora, fauna — 18 quintillion planets. The + number is absurd. But strip that away and it's basically Minecraft in space: gather everything or + die trying. I put a lot of hours in, but a game that massive is hard to maintain intensity on. + Eventually I dialed back.

+ +

Steam does this thing where it watches what you play and suggests similar titles. That's how I + found Enshrouded. Fantasy RPG setting, traditional classes, fluid real-time combat + — no turn-based stuff, not my thing — with that same loop of crafting, foraging, and figuring things + out. The quest line actually guides you through different biomes in a way that feels structured without + being hand-holdy. Only complaint: I wish it was on Nintendo Switch so I could play it in traffic. + Half joking.

+ +

A coworker mentioned he ran a Minecraft server and something clicked — I wonder if there's a + container for Enshrouded. There was. Threw it on the Dell Optiplex that was headed to + decommission and it runs without breaking a sweat. A simple Docker Compose file and done. Sometimes + working in networking and infrastructure has its perks: you see how the sausage is made and suddenly + self-hosting a game server feels less like a project and more like a Tuesday.

+ +

How long will the Enshrouded phase last? No idea. But here's the takeaway: if something looks + interesting, Google it. There's probably a server image, a mod, a community, or at minimum a Reddit + thread that tells you if it's worth your time.

+ `, + }, +] \ No newline at end of file diff --git a/frontend/src/components/projects.js b/frontend/src/components/projects.js new file mode 100644 index 0000000..6da83ca --- /dev/null +++ b/frontend/src/components/projects.js @@ -0,0 +1,108 @@ +const projects = [ + { + id: '01', + title: 'Enshrouded Server', + desc: 'Dedicated game server running on bare metal. Docker Compose, Watchtower for auto-updates, daily backups.', + tags: ['docker', 'ubuntu', 'game server'], + status: 'live', + }, + { + id: '02', + title: 'Homelab Network', + desc: 'Fortigate firewall at the edge, VLANs, static IP. Built to host services publicly with proper segmentation.', + tags: ['fortigate', 'networking', 'vlan'], + status: 'wip', + }, + { + id: '03', + title: 'This Site', + desc: 'Vite + VanillaJS served behind Caddy with automatic SSL. Zero frameworks, full control.', + tags: ['vite', 'caddy', 'docker'], + status: 'live', + }, +] + +const statusLabel = { + live: { text: 'live', color: '#c8ff00' }, + wip: { text: 'wip', color: '#ff6b35' }, +} + +export function renderProjects() { + const cards = projects.map(p => { + const s = statusLabel[p.status] + return ` +
+
+ ${p.id} + ● ${s.text} +
+

${p.title}

+

${p.desc}

+
+ ${p.tags.map(t => `${t}`).join('')} +
+
+ ` + }).join('') + + return ` +
+
+
+
+

Projects

+
+
+ ${cards} +
+
+
+ + + ` +} \ No newline at end of file diff --git a/frontend/src/main.js b/frontend/src/main.js new file mode 100644 index 0000000..b62f63b --- /dev/null +++ b/frontend/src/main.js @@ -0,0 +1,35 @@ +import './style.css' +import { renderHeader } from './components/header.js' +import { renderHero } from './components/hero.js' +import { renderProjects } from './components/projects.js' +import { renderBlog, initBlog } from './components/blog.js' +import { renderFooter } from './components/footer.js' + +const app = document.getElementById('app') + +app.innerHTML = ` + ${renderHeader()} +
+ ${renderHero()} + ${renderProjects()} + ${renderBlog()} +
+ ${renderFooter()} +` + +// Scroll reveals +const observer = new IntersectionObserver( + (entries) => { + entries.forEach((entry) => { + if (entry.isIntersecting) { + entry.target.classList.add('visible') + observer.unobserve(entry.target) + } + }) + }, + { threshold: 0.1 } +) +document.querySelectorAll('.reveal').forEach((el) => observer.observe(el)) + +// Blog interactivity +initBlog() \ No newline at end of file diff --git a/frontend/src/style.css b/frontend/src/style.css new file mode 100644 index 0000000..7d88d54 --- /dev/null +++ b/frontend/src/style.css @@ -0,0 +1,83 @@ +/* ── Design tokens ── */ +:root { + --bg: #0a0a0a; + --bg-card: #111111; + --border: #1e1e1e; + --accent: #c8ff00; /* electric lime */ + --accent-2: #ff6b35; /* orange pop */ + --text: #e8e8e8; + --muted: #555; + --font-mono: 'Space Mono', monospace; + --font-sans: 'Syne', sans-serif; + --radius: 4px; + --max-w: 1100px; +} + +/* ── Reset ── */ +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } + +html { scroll-behavior: smooth; } + +body { + background: var(--bg); + color: var(--text); + font-family: var(--font-sans); + font-size: 16px; + line-height: 1.6; + -webkit-font-smoothing: antialiased; + 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); + margin: 0 auto; + padding: 0 24px; +} + +/* ── Typography ── */ +h1, h2, h3 { font-family: var(--font-sans); font-weight: 800; line-height: 1.1; } +code, .mono { font-family: var(--font-mono); font-size: 0.85em; } + +/* ── Scroll reveal ── */ +.reveal { + opacity: 0; + transform: translateY(24px); + transition: opacity 0.6s ease, transform 0.6s ease; +} +.reveal.visible { + opacity: 1; + transform: none; +} + +/* ── Accent line ── */ +.accent-line { + display: inline-block; + width: 40px; + height: 3px; + background: var(--accent); + margin-bottom: 16px; +} + +/* ── Tag pill ── */ +.tag { + display: inline-block; + 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 diff --git a/frontend/vite.config.js b/frontend/vite.config.js new file mode 100644 index 0000000..e81475e --- /dev/null +++ b/frontend/vite.config.js @@ -0,0 +1,14 @@ +import { defineConfig } from 'vite' + +export default defineConfig({ + build: { + outDir: 'dist', + assetsDir: 'assets', + sourcemap: false, + minify: 'esbuild', + }, + server: { + port: 5173, + host: true, + }, +}) \ No newline at end of file diff --git a/ntfy/config/server.yml b/ntfy/config/server.yml new file mode 100644 index 0000000..5254992 --- /dev/null +++ b/ntfy/config/server.yml @@ -0,0 +1,18 @@ +# ntfy server configuration +# Ubicación: ./ntfy/config/server.yml + +base-url: "https://ntfy.carloselugo.com" +listen-http: ":80" + +# Cache de mensajes (para cuando el celular no está conectado) +cache-file: "/var/cache/ntfy/cache.db" +cache-duration: "12h" + +# Autenticación — IMPORTANTE: protege tu servidor +auth-file: "/etc/ntfy/user.db" +auth-default-access: "deny-all" + +# Límites (ajusta según necesites) +global-topic-limit: 50 +visitor-request-limit-burst: 60 +visitor-request-limit-replenish: "5s" \ No newline at end of file