Initial commit — docker stack carloselugo.com
Services: Caddy, frontend, Uptime Kuma, Netdata, ntfy, Dozzle, Gitea
This commit is contained in:
+20
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
@@ -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==
|
||||
@@ -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;"]
|
||||
@@ -0,0 +1,17 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="description" content="Carlos Lugo — homelab, games, and tech." />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<title>Carlos Lugo</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Space+Mono:wght@400;700&family=Syne:wght@400;600;800&display=swap" rel="stylesheet" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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 `
|
||||
<article class="blog-card reveal" data-post-id="${post.id}" style="transition-delay:${index * 0.08}s">
|
||||
<div class="blog-card-meta">
|
||||
<span class="blog-cat mono" style="color:${color}">● ${post.category}</span>
|
||||
<span class="blog-date mono">${formatDate(post.date)}</span>
|
||||
</div>
|
||||
<h3 class="blog-card-title">${post.title}</h3>
|
||||
<p class="blog-card-excerpt">${post.excerpt}</p>
|
||||
<button class="blog-read-btn mono" data-post-id="${post.id}">read →</button>
|
||||
</article>
|
||||
`
|
||||
}
|
||||
|
||||
// ── Full post (detail view) ──────────────────────────────────────────────
|
||||
function renderPostDetail(post) {
|
||||
const color = categoryColor[post.category] ?? '#c8ff00'
|
||||
return `
|
||||
<div class="blog-detail" id="blog-detail">
|
||||
<button class="blog-back mono" id="blog-back">← back to posts</button>
|
||||
<div class="blog-detail-meta">
|
||||
<span class="blog-cat mono" style="color:${color}">● ${post.category}</span>
|
||||
<span class="blog-date mono">${formatDate(post.date)}</span>
|
||||
</div>
|
||||
<h2 class="blog-detail-title">${post.title}</h2>
|
||||
<div class="blog-detail-body">
|
||||
${post.content}
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
|
||||
// ── Section shell ────────────────────────────────────────────────────────
|
||||
export function renderBlog() {
|
||||
// Sort newest first
|
||||
const sorted = [...posts].sort((a, b) => new Date(b.date) - new Date(a.date))
|
||||
|
||||
return `
|
||||
<section class="blog" id="blog">
|
||||
<div class="container">
|
||||
<div class="section-head reveal">
|
||||
<div class="accent-line"></div>
|
||||
<h2 class="section-title">Blog</h2>
|
||||
<p class="blog-intro">
|
||||
Notes on homelab builds, things I'm working on, and whatever I'm into.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Category filter -->
|
||||
<div class="blog-filters reveal">
|
||||
<button class="filter-btn mono active" data-filter="all">all</button>
|
||||
<button class="filter-btn mono" data-filter="homelab">homelab</button>
|
||||
<button class="filter-btn mono" data-filter="personal">personal</button>
|
||||
<button class="filter-btn mono" data-filter="professional">professional</button>
|
||||
<button class="filter-btn mono" data-filter="entertainment">entertainment</button>
|
||||
</div>
|
||||
|
||||
<!-- Post list -->
|
||||
<div class="blog-list" id="blog-list">
|
||||
${sorted.map((p, i) => renderPostCard(p, i)).join('')}
|
||||
</div>
|
||||
|
||||
<!-- Post detail (hidden by default) -->
|
||||
<div class="blog-detail-wrapper" id="blog-detail-wrapper" style="display:none"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
/* ── Section layout ── */
|
||||
.blog { padding: 100px 0; border-top: 1px solid var(--border); }
|
||||
.blog-intro {
|
||||
font-size: 0.95rem;
|
||||
color: var(--muted);
|
||||
margin-top: 8px;
|
||||
max-width: 480px;
|
||||
}
|
||||
|
||||
/* ── Filters ── */
|
||||
.blog-filters {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin: 32px 0 40px;
|
||||
}
|
||||
.filter-btn {
|
||||
background: transparent;
|
||||
border: 1px solid var(--border);
|
||||
color: var(--muted);
|
||||
font-size: 0.7rem;
|
||||
padding: 4px 14px;
|
||||
border-radius: 2px;
|
||||
cursor: pointer;
|
||||
letter-spacing: 0.05em;
|
||||
transition: all 0.18s;
|
||||
}
|
||||
.filter-btn:hover,
|
||||
.filter-btn.active {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
/* ── Post list ── */
|
||||
.blog-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||
gap: 1px;
|
||||
background: var(--border);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
/* ── Card ── */
|
||||
.blog-card {
|
||||
background: var(--bg-card);
|
||||
padding: 28px 32px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
transition: background 0.18s;
|
||||
cursor: default;
|
||||
}
|
||||
.blog-card:hover { background: #161616; }
|
||||
.blog-card[style*="display: none"] { display: none !important; }
|
||||
|
||||
.blog-card-meta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
.blog-cat { font-size: 0.68rem; letter-spacing: 0.06em; }
|
||||
.blog-date { font-size: 0.68rem; color: var(--muted); }
|
||||
|
||||
.blog-card-title {
|
||||
font-size: 1.15rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.02em;
|
||||
line-height: 1.25;
|
||||
}
|
||||
.blog-card-excerpt {
|
||||
font-size: 0.88rem;
|
||||
color: var(--muted);
|
||||
line-height: 1.65;
|
||||
flex: 1;
|
||||
}
|
||||
.blog-read-btn {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--accent);
|
||||
font-size: 0.78rem;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
letter-spacing: 0.04em;
|
||||
align-self: flex-start;
|
||||
transition: letter-spacing 0.2s;
|
||||
}
|
||||
.blog-read-btn:hover { letter-spacing: 0.1em; }
|
||||
|
||||
/* ── Detail view ── */
|
||||
.blog-detail {
|
||||
max-width: 720px;
|
||||
padding: 0 0 48px;
|
||||
animation: fadeUp 0.4s ease both;
|
||||
}
|
||||
.blog-back {
|
||||
background: transparent;
|
||||
border: 1px solid var(--border);
|
||||
color: var(--muted);
|
||||
font-size: 0.75rem;
|
||||
padding: 6px 16px;
|
||||
cursor: pointer;
|
||||
border-radius: 2px;
|
||||
margin-bottom: 32px;
|
||||
letter-spacing: 0.04em;
|
||||
transition: all 0.18s;
|
||||
}
|
||||
.blog-back:hover { border-color: var(--accent); color: var(--accent); }
|
||||
|
||||
.blog-detail-meta {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.blog-detail-title {
|
||||
font-size: clamp(1.8rem, 4vw, 2.8rem);
|
||||
letter-spacing: -0.03em;
|
||||
line-height: 1.1;
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
/* Post body typography */
|
||||
.blog-detail-body { font-size: 1rem; line-height: 1.8; color: var(--text); }
|
||||
.blog-detail-body p { margin-bottom: 20px; }
|
||||
.blog-detail-body h4 {
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
color: var(--accent);
|
||||
font-family: var(--font-mono);
|
||||
letter-spacing: 0.04em;
|
||||
margin: 28px 0 10px;
|
||||
}
|
||||
.blog-detail-body ul {
|
||||
padding-left: 20px;
|
||||
margin-bottom: 20px;
|
||||
color: var(--muted);
|
||||
}
|
||||
.blog-detail-body li { margin-bottom: 6px; }
|
||||
.blog-detail-body code {
|
||||
background: #1a1a1a;
|
||||
border: 1px solid var(--border);
|
||||
padding: 1px 6px;
|
||||
border-radius: 2px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.85em;
|
||||
color: var(--accent);
|
||||
}
|
||||
.blog-detail-body strong { color: var(--text); font-weight: 700; }
|
||||
</style>
|
||||
`
|
||||
}
|
||||
|
||||
// ── 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 = ''
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
export function renderFooter() {
|
||||
const year = new Date().getFullYear()
|
||||
return `
|
||||
<footer class="footer">
|
||||
<div class="container footer-inner">
|
||||
<span class="mono footer-copy">© ${year} Carlos Lugo</span>
|
||||
<span class="mono footer-copy" style="color:var(--muted)">carloselugo.com</span>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<style>
|
||||
.footer {
|
||||
border-top: 1px solid var(--border);
|
||||
padding: 24px 0;
|
||||
}
|
||||
.footer-inner {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
.footer-copy { font-size: 0.75rem; color: var(--muted); letter-spacing: 0.04em; }
|
||||
</style>
|
||||
`
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
export function renderHeader() {
|
||||
return `
|
||||
<header class="site-header">
|
||||
<div class="container header-inner">
|
||||
<a href="/" class="logo">
|
||||
<span class="logo-bracket mono">[</span>
|
||||
<span class="logo-name">clugo</span>
|
||||
<span class="logo-bracket mono">]</span>
|
||||
</a>
|
||||
<nav class="nav">
|
||||
<a href="#projects" class="nav-link">projects</a>
|
||||
<a href="#blog" class="nav-link">blog</a>
|
||||
<a href="#about" class="nav-link">about</a>
|
||||
<a href="https://github.com" target="_blank" rel="noopener" class="nav-link nav-link--ext">
|
||||
github ↗
|
||||
</a>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<style>
|
||||
.site-header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
padding: 16px 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: rgba(10, 10, 10, 0.85);
|
||||
backdrop-filter: blur(12px);
|
||||
}
|
||||
.header-inner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.logo {
|
||||
text-decoration: none;
|
||||
font-family: var(--font-sans);
|
||||
font-weight: 800;
|
||||
font-size: 1.1rem;
|
||||
letter-spacing: -0.02em;
|
||||
color: var(--text);
|
||||
transition: color 0.2s;
|
||||
}
|
||||
.logo:hover .logo-name { color: var(--accent); }
|
||||
.logo-bracket { color: var(--accent); }
|
||||
.logo-name { color: var(--text); }
|
||||
.nav { display: flex; gap: 28px; }
|
||||
.nav-link {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.8rem;
|
||||
color: var(--muted);
|
||||
text-decoration: none;
|
||||
letter-spacing: 0.03em;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
.nav-link:hover { color: var(--accent); }
|
||||
</style>
|
||||
`
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
export function renderHero() {
|
||||
return `
|
||||
<section class="hero" id="about">
|
||||
<div class="container hero-inner">
|
||||
<div class="hero-label mono">// hello world</div>
|
||||
<h1 class="hero-title">
|
||||
Carlos<br/>
|
||||
<span class="hero-title--accent">Lugo.</span>
|
||||
</h1>
|
||||
<p class="hero-sub">
|
||||
Homelab tinkerer. Network nerd.<br/>
|
||||
Building things that run 24/7 in a rack at home.
|
||||
</p>
|
||||
<div class="hero-stack">
|
||||
<span class="tag">docker</span>
|
||||
<span class="tag">linux</span>
|
||||
<span class="tag">networking</span>
|
||||
<span class="tag">fortigate</span>
|
||||
<span class="tag">game servers</span>
|
||||
</div>
|
||||
<div class="hero-gfx" aria-hidden="true">
|
||||
<div class="gfx-ring gfx-ring--1"></div>
|
||||
<div class="gfx-ring gfx-ring--2"></div>
|
||||
<div class="gfx-dot"></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.hero {
|
||||
min-height: 88vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
padding: 80px 0;
|
||||
}
|
||||
.hero::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: 0; left: 0; right: 0;
|
||||
height: 1px;
|
||||
background: var(--border);
|
||||
}
|
||||
.hero-inner { position: relative; z-index: 1; }
|
||||
.hero-label {
|
||||
color: var(--accent);
|
||||
font-size: 0.8rem;
|
||||
letter-spacing: 0.1em;
|
||||
margin-bottom: 24px;
|
||||
animation: fadeUp 0.8s ease both;
|
||||
}
|
||||
.hero-title {
|
||||
font-size: clamp(3.5rem, 10vw, 8rem);
|
||||
line-height: 0.95;
|
||||
letter-spacing: -0.04em;
|
||||
margin-bottom: 32px;
|
||||
animation: fadeUp 0.8s 0.1s ease both;
|
||||
}
|
||||
.hero-title--accent { color: var(--accent); }
|
||||
.hero-sub {
|
||||
font-size: 1.1rem;
|
||||
color: var(--muted);
|
||||
max-width: 380px;
|
||||
line-height: 1.7;
|
||||
margin-bottom: 32px;
|
||||
animation: fadeUp 0.8s 0.2s ease both;
|
||||
}
|
||||
.hero-stack {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
animation: fadeUp 0.8s 0.3s ease both;
|
||||
}
|
||||
.hero-gfx {
|
||||
position: absolute;
|
||||
right: -80px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 500px;
|
||||
height: 500px;
|
||||
pointer-events: none;
|
||||
}
|
||||
.gfx-ring {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
border: 1px solid var(--border);
|
||||
top: 50%; left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
.gfx-ring--1 { width: 300px; height: 300px; }
|
||||
.gfx-ring--2 { width: 480px; height: 480px; border-color: #151515; }
|
||||
.gfx-dot {
|
||||
position: absolute;
|
||||
top: 50%; left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 8px; height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
box-shadow: 0 0 20px var(--accent);
|
||||
animation: pulse 2s ease-in-out infinite;
|
||||
}
|
||||
@keyframes fadeUp {
|
||||
from { opacity: 0; transform: translateY(20px); }
|
||||
to { opacity: 1; transform: none; }
|
||||
}
|
||||
@keyframes pulse {
|
||||
0%, 100% { box-shadow: 0 0 20px var(--accent); }
|
||||
50% { box-shadow: 0 0 40px var(--accent), 0 0 80px var(--accent); }
|
||||
}
|
||||
</style>
|
||||
`
|
||||
}
|
||||
@@ -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: `
|
||||
<p>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.</p>
|
||||
|
||||
<p>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.</p>
|
||||
|
||||
<p>That's exactly what I ended up with: <strong>Uptime Kuma</strong> for service uptime,
|
||||
<strong>Netdata</strong> for system metrics, <strong>Umami</strong> for page analytics, and
|
||||
<strong>Dozzle</strong> for container logs. Simple stack, everything in one place, surprisingly complete.</p>
|
||||
|
||||
<p>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:
|
||||
<em>there's probably a container for the security piece too.</em> Turns out, yeah. Problem solved.</p>
|
||||
|
||||
<p>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.</p>
|
||||
|
||||
<p>Next goal: more Optiplexes, a Kubernetes cluster, and seeing how far this rabbit hole actually goes.
|
||||
I'll keep you posted.</p>
|
||||
`,
|
||||
},
|
||||
{
|
||||
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: `
|
||||
<p>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.</p>
|
||||
|
||||
<p>It started with <strong>Star Wars Galaxies</strong> — 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 <em>"this is cool"</em> that hit different.
|
||||
That little moment of validation from a stranger in a game. I get it now more than I did then.</p>
|
||||
|
||||
<p>Fast forward and I'm playing <strong>Minecraft</strong> 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.</p>
|
||||
|
||||
<p>A coworker told me he basically lived on Steam sales, buying whatever looked interesting at a
|
||||
discount. I did the same and landed on <strong>No Man's Sky</strong>. 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.</p>
|
||||
|
||||
<p>Steam does this thing where it watches what you play and suggests similar titles. That's how I
|
||||
found <strong>Enshrouded</strong>. 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.</p>
|
||||
|
||||
<p>A coworker mentioned he ran a Minecraft server and something clicked — <em>I wonder if there's a
|
||||
container for Enshrouded.</em> 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.</p>
|
||||
|
||||
<p>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.</p>
|
||||
`,
|
||||
},
|
||||
]
|
||||
@@ -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 `
|
||||
<article class="card reveal">
|
||||
<div class="card-top">
|
||||
<span class="card-id mono">${p.id}</span>
|
||||
<span class="card-status mono" style="color:${s.color}">● ${s.text}</span>
|
||||
</div>
|
||||
<h3 class="card-title">${p.title}</h3>
|
||||
<p class="card-desc">${p.desc}</p>
|
||||
<div class="card-tags">
|
||||
${p.tags.map(t => `<span class="tag">${t}</span>`).join('')}
|
||||
</div>
|
||||
</article>
|
||||
`
|
||||
}).join('')
|
||||
|
||||
return `
|
||||
<section class="projects" id="projects">
|
||||
<div class="container">
|
||||
<div class="section-head reveal">
|
||||
<div class="accent-line"></div>
|
||||
<h2 class="section-title">Projects</h2>
|
||||
</div>
|
||||
<div class="card-grid">
|
||||
${cards}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.projects { padding: 100px 0; }
|
||||
.section-head { margin-bottom: 48px; }
|
||||
.section-title {
|
||||
font-size: clamp(2rem, 5vw, 3.5rem);
|
||||
letter-spacing: -0.03em;
|
||||
}
|
||||
.card-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||
gap: 1px;
|
||||
background: var(--border);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
.card {
|
||||
background: var(--bg-card);
|
||||
padding: 32px;
|
||||
transition: background 0.2s;
|
||||
cursor: default;
|
||||
}
|
||||
.card:hover { background: #161616; }
|
||||
.card-top {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.card-id { font-size: 0.7rem; color: var(--muted); letter-spacing: 0.1em; }
|
||||
.card-status { font-size: 0.7rem; letter-spacing: 0.05em; }
|
||||
.card-title {
|
||||
font-size: 1.3rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.02em;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.card-desc {
|
||||
font-size: 0.9rem;
|
||||
color: var(--muted);
|
||||
line-height: 1.65;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.card-tags { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||
/* Staggered reveal for cards */
|
||||
.card-grid .card:nth-child(2) { transition-delay: 0.1s; }
|
||||
.card-grid .card:nth-child(3) { transition-delay: 0.2s; }
|
||||
</style>
|
||||
`
|
||||
}
|
||||
@@ -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()}
|
||||
<main>
|
||||
${renderHero()}
|
||||
${renderProjects()}
|
||||
${renderBlog()}
|
||||
</main>
|
||||
${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()
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user