Compare commits
41 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 70129b1e9f | |||
| ab6b4677b4 | |||
| e346e6500d | |||
| 6f3c58bfcd | |||
| 83db698f8d | |||
| f152379882 | |||
| d3aed167fa | |||
| 26f26c89f6 | |||
| 4ab6633cc2 | |||
| 904684e6b8 | |||
| 54cad6684c | |||
| 3f7e54c72e | |||
| 0b7021f827 | |||
| 21a63b6947 | |||
| c514b3bce1 | |||
| 58f0a00d4c | |||
| 83845f4894 | |||
| 7e90812373 | |||
| 1037f6a4ed | |||
| 8176b0b09b | |||
| 5937425872 | |||
| 42873ad9cc | |||
| 35f3ba8767 | |||
| ce8415c26f | |||
| 8cd0b2fa24 | |||
| 8a30951a64 | |||
| f93ae22b77 | |||
| 65f9defcdd | |||
| 15c946c0a1 | |||
| ada08a31be | |||
| d11c9a6a1e | |||
| 259fef1f95 | |||
| 47bf985a79 | |||
| 8d85589f0c | |||
| c31692bae0 | |||
| 695dad2770 | |||
| 01ce85a140 | |||
| e9be93f24d | |||
| cc7043148a | |||
| 600e9ac3b4 | |||
| 05036766e4 |
@@ -0,0 +1,11 @@
|
||||
node_modules
|
||||
.git
|
||||
.gitignore
|
||||
.vscode
|
||||
.idea
|
||||
*.md
|
||||
!README.md
|
||||
.env
|
||||
.env.*
|
||||
dist
|
||||
.astro
|
||||
@@ -0,0 +1,121 @@
|
||||
name: CI/CD Pipeline
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
deploy:
|
||||
description: "Recreate the davidaragon-portfolio stack on Portainer (deletes + creates with latest image)"
|
||||
type: boolean
|
||||
default: false
|
||||
required: false
|
||||
|
||||
# Serialise runs on the shared act_runner: prevents two simultaneous runs from
|
||||
# cancelling each other's in-progress steps (observed in older portfolio runs).
|
||||
# See python-project-template-internal/.gitea/workflows/ci.yaml for the same
|
||||
# pattern with full context (runs 794 and 799).
|
||||
concurrency:
|
||||
group: ci-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build & Deploy
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
# IMAGE_NAME used by the redeploy step + the deploy compose file.
|
||||
IMAGE_NAME: gitlab.impresion3d.pro/root/davidaragon-portfolio:0.0.1
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Build and push Docker image
|
||||
run: |
|
||||
# The build always tags and pushes both :latest (for ad-hoc inspection)
|
||||
# and :0.0.1 (pinned version that the stack redeploy step uses).
|
||||
# The runner's `docker buildx` is set up by the QNAP self-hosted runner;
|
||||
# no QEMU emulation required for the linux/amd64 build target here.
|
||||
docker login gitlab.impresion3d.pro -u "${{ secrets.DOCKER_USERNAME }}" -p "${{ secrets.DOCKER_PASSWORD }}"
|
||||
docker build \
|
||||
-t gitlab.impresion3d.pro/root/davidaragon-portfolio:latest \
|
||||
-t gitlab.impresion3d.pro/root/davidaragon-portfolio:0.0.1 \
|
||||
.
|
||||
docker push gitlab.impresion3d.pro/root/davidaragon-portfolio:latest
|
||||
docker push gitlab.impresion3d.pro/root/davidaragon-portfolio:0.0.1
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Optional redeploy step (gated by workflow_dispatch.deploy=true).
|
||||
#
|
||||
# Approach: delete the existing stack on Portainer (idempotent), then
|
||||
# create a new one from the docker-compose.prod.yml at the repo root.
|
||||
#
|
||||
# We DELETE + CREATE rather than PUT-edit because:
|
||||
# * Put-edit requires the same stack ID + PRUNE; the simpler recreate
|
||||
# works for our size (1 service, no inter-service references).
|
||||
# * Earlier the stack was "unhealthy"; this gives a clean slate.
|
||||
#
|
||||
# Required secrets:
|
||||
# PORTAINER_URL e.g. http://192.168.1.30:9000
|
||||
# PORTAINER_TOKEN Access token from a Portainer user (scope: admin)
|
||||
# PORTAINER_ENDPOINT_ID Numeric endpoint ID (usually 1)
|
||||
# PORTAINER_STACK_ID Numeric stack ID to delete before recreating
|
||||
# ----------------------------------------------------------------------
|
||||
- name: Redeploy stack on Portainer
|
||||
if: github.event_name == 'workflow_dispatch' && inputs.deploy == true
|
||||
env:
|
||||
PORTAINER_URL: ${{ secrets.PORTAINER_URL }}
|
||||
PORTAINER_TOKEN: ${{ secrets.PORTAINER_TOKEN }}
|
||||
PORTAINER_ENDPOINT_ID: ${{ secrets.PORTAINER_ENDPOINT_ID }}
|
||||
PORTAINER_STACK_ID: ${{ secrets.PORTAINER_STACK_ID }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
echo "--- Step 1: pre-flight (delete existing stack if present) ---"
|
||||
DELETE_HTTP_CODE=$(curl -sS -o /tmp/portainer-delete.json -w '%{http_code}' \
|
||||
-X DELETE \
|
||||
-H "X-API-Key: ${PORTAINER_TOKEN}" \
|
||||
"${PORTAINER_URL}/api/stacks/${PORTAINER_STACK_ID}?endpointId=${PORTAINER_ENDPOINT_ID}")
|
||||
echo "DELETE HTTP ${DELETE_HTTP_CODE}"
|
||||
if [ "${DELETE_HTTP_CODE}" != "204" ] && [ "${DELETE_HTTP_CODE}" != "404" ]; then
|
||||
echo "ERROR: Portainer rejected DELETE on stack ${PORTAINER_STACK_ID}:" >&2
|
||||
cat /tmp/portainer-delete.json >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "--- Step 2: read docker-compose.prod.yml ---"
|
||||
if [ ! -f docker-compose.prod.yml ]; then
|
||||
echo "ERROR: docker-compose.prod.yml is missing from the repo root" >&2
|
||||
exit 1
|
||||
fi
|
||||
# Inline the compose file. Portainer expects `composeFileContent` as raw text.
|
||||
COMPOSE_BODY=$(jq -Rs --arg compose "$(cat docker-compose.prod.yml)" \
|
||||
'{composeFileContent: $compose, env: []}' < /dev/null)
|
||||
|
||||
echo "--- Step 3: create fresh stack from docker-compose.prod.yml ---"
|
||||
CREATE_HTTP_CODE=$(curl -sS -o /tmp/portainer-create.json -w '%{http_code}' \
|
||||
-X POST \
|
||||
-H "X-API-Key: ${PORTAINER_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
--data "${COMPOSE_BODY}" \
|
||||
"${PORTAINER_URL}/api/stacks?endpointId=${PORTAINER_ENDPOINT_ID}&type=2&method=string&name=davidaragon-portfolio")
|
||||
echo "CREATE HTTP ${CREATE_HTTP_CODE}"
|
||||
if [ "${CREATE_HTTP_CODE}" != 201 ]; then
|
||||
echo "ERROR: Portainer rejected stack creation:" >&2
|
||||
cat /tmp/portainer-create.json >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "--- Step 4: smoke-test the freshly deployed stack ---"
|
||||
# Give the container a brief window to start before checking.
|
||||
sleep 8
|
||||
HEALTH=$(curl -sS -o /dev/null -w '%{http_code}' \
|
||||
--max-time 5 \
|
||||
"http://${PORTAINER_URL#http://}:3001/" 2>/dev/null || true)
|
||||
echo "Health check on http://<host>:3001/ returned: ${HEALTH:-<timeout/unreachable>}"
|
||||
# We log but don't fail the job if 3001 isn't reachable — the upstream
|
||||
# proxy (Nginx Proxy Manager → davidaragon.impresion3d.pro) is a better
|
||||
# place to wire a hard-fail check in a future iteration.
|
||||
|
||||
echo "--- Stack recreated successfully. ---"
|
||||
+145
@@ -0,0 +1,145 @@
|
||||
# CI/CD Setup Instructions
|
||||
|
||||
## ✅ What's Done
|
||||
|
||||
1. **Gitea Actions workflow** created (`.gitea/workflows/ci-cd.yaml`)
|
||||
- Build job: Verify Astro build works
|
||||
- Deploy job: Build Docker image + push to registry + trigger Portainer
|
||||
|
||||
2. **Docker files** created:
|
||||
- `Dockerfile` - Multi-stage build (Node + nginx)
|
||||
- `docker-compose.yml` - Local testing
|
||||
- `nginx.conf` - SPA routing + cache headers
|
||||
- `.dockerignore` - Optimize build
|
||||
|
||||
3. **Pushed to git** - CI will run on next push to main branch
|
||||
|
||||
---
|
||||
|
||||
## 🔧 Manual Steps Required
|
||||
|
||||
### 1. Configure Gitea Secrets
|
||||
|
||||
Go to: https://gitlab.impresion3d.pro/root/davidaragon-portfolio/settings/secrets
|
||||
|
||||
Add these secrets:
|
||||
|
||||
#### `DOCKER_USERNAME`
|
||||
```
|
||||
root
|
||||
```
|
||||
|
||||
#### `DOCKER_PASSWORD`
|
||||
```
|
||||
<your Gitea personal access token with registry permissions>
|
||||
```
|
||||
|
||||
#### `PORTAINER_WEBHOOK_URL`
|
||||
```
|
||||
<webhook URL from Portainer stack - see step 2>
|
||||
```
|
||||
|
||||
### 2. Create Portainer Stack
|
||||
|
||||
1. Go to Portainer: http://192.168.1.30:9000
|
||||
2. Navigate to: **Stacks** → **Add stack**
|
||||
3. Name: `davidaragon-portfolio`
|
||||
4. Build method: **Git Repository** or **Web editor**
|
||||
|
||||
#### Stack compose file:
|
||||
|
||||
```yaml
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
portfolio:
|
||||
image: gitlab.impresion3d.pro/root/davidaragon-portfolio:latest
|
||||
container_name: davidaragon-portfolio
|
||||
ports:
|
||||
- "8081:80"
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
networks:
|
||||
- web
|
||||
|
||||
networks:
|
||||
web:
|
||||
external: true
|
||||
```
|
||||
|
||||
5. Deploy the stack
|
||||
6. Go to stack **Webhooks** tab
|
||||
7. Create webhook for service `portfolio`
|
||||
8. Copy webhook URL (format: `http://192.168.1.30:9000/api/stacks/webhooks/...`)
|
||||
9. Add this URL as `PORTAINER_WEBHOOK_URL` secret in Gitea (step 1)
|
||||
|
||||
### 3. Optional: Configure Gitea Runner
|
||||
|
||||
If no runners registered yet:
|
||||
|
||||
```bash
|
||||
# On server 192.168.1.30 (port 2222)
|
||||
docker run -d \
|
||||
--name gitea-runner-portfolio \
|
||||
--restart unless-stopped \
|
||||
-e GITEA_INSTANCE_URL=https://gitlab.impresion3d.pro \
|
||||
-e GITEA_RUNNER_REGISTRATION_TOKEN=<your_token> \
|
||||
-e GITEA_RUNNER_NAME=ubuntu-latest \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
gitea/act_runner:latest
|
||||
```
|
||||
|
||||
Get registration token from: https://gitlab.impresion3d.pro/admin/actions/runners
|
||||
|
||||
---
|
||||
|
||||
## 🚀 How It Works
|
||||
|
||||
### On Every Push to Main:
|
||||
|
||||
1. **Build job** runs:
|
||||
- Install Node.js 20
|
||||
- Install dependencies (`npm ci`)
|
||||
- Build Astro site (`npm run build`)
|
||||
- Upload `dist/` artifact
|
||||
|
||||
2. **Deploy job** runs (only if build succeeds):
|
||||
- Download `dist/` artifact
|
||||
- Build Docker image (multi-stage)
|
||||
- Push to Gitea registry: `gitlab.impresion3d.pro/root/davidaragon-portfolio:latest`
|
||||
- Trigger Portainer webhook → Portainer pulls new image and restarts container
|
||||
|
||||
### Result:
|
||||
- Site auto-deployed to http://192.168.1.30:8081
|
||||
- No manual intervention needed after initial setup
|
||||
|
||||
---
|
||||
|
||||
## 🧪 Test Locally
|
||||
|
||||
```bash
|
||||
cd ~/code_ubuntu/davidaragon-portfolio
|
||||
|
||||
# Build and run with Docker Compose
|
||||
docker-compose up --build
|
||||
|
||||
# Visit: http://localhost:8080
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📝 Next Steps
|
||||
|
||||
1. Configure secrets in Gitea (step 1)
|
||||
2. Create Portainer stack (step 2)
|
||||
3. Add webhook URL to Gitea secrets
|
||||
4. Push any commit to main → CI/CD runs automatically!
|
||||
|
||||
---
|
||||
|
||||
## 🔍 Monitor CI/CD
|
||||
|
||||
- **Actions**: https://gitlab.impresion3d.pro/root/davidaragon-portfolio/actions
|
||||
- **Registry**: https://gitlab.impresion3d.pro/root/-/packages/container/davidaragon-portfolio
|
||||
- **Portainer**: http://192.168.1.30:9000/#!/stacks
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
# Multi-stage build for Astro static site
|
||||
|
||||
# Stage 1: Build
|
||||
FROM node:20-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files
|
||||
COPY package*.json ./
|
||||
|
||||
# Install ALL dependencies (build needs devDependencies)
|
||||
RUN npm ci
|
||||
|
||||
# Copy source
|
||||
COPY . .
|
||||
|
||||
# Build static site
|
||||
RUN npm run build
|
||||
|
||||
# Stage 2: Production - nginx
|
||||
FROM nginx:alpine
|
||||
|
||||
# Copy built site to nginx html directory
|
||||
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||
|
||||
# Copy nginx config (if needed for SPA routing)
|
||||
COPY nginx.conf /etc/nginx/nginx.conf
|
||||
|
||||
# Expose port
|
||||
EXPOSE 80
|
||||
|
||||
# Health check
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||
CMD wget --no-verbose --tries=1 --spider http://localhost/ || exit 1
|
||||
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
@@ -1,43 +1,171 @@
|
||||
# Astro Starter Kit: Minimal
|
||||
# David Aragón — Portfolio
|
||||
|
||||
```sh
|
||||
npm create astro@latest -- --template minimal
|
||||
Portfolio personal y blog técnico de David Aragón. Sitio estático construido con Astro 5, donde comparto proyectos en los que estoy trabajando (build in public), posts sobre IoT, desarrollo y decisiones técnicas, y una página de about con mi trayectoria.
|
||||
|
||||
- **URL pública:** https://davidaragon.impresion3d.pro
|
||||
- **Autor:** David Aragón — de orfebre a fotógrafo a developer
|
||||
- **Repositorio:** https://gitlab.impresion3d.pro/root/davidaragon-portfolio
|
||||
|
||||
## Características
|
||||
|
||||
- **Home** con hero, últimos 3 posts del blog, proyectos destacados y CTA de newsletter
|
||||
- **Blog** (`/blog`) con posts categorizados en `technical`, `business` y `personal`, con RSS en `/rss.xml`
|
||||
- **Projects** (`/projects`) con proyectos en estados `active`, `development` o `completed`
|
||||
- **About** (`/about`) con la trayectoria profesional completa
|
||||
- **Sitemap** autogenerado en `/sitemap-index.xml` por `@astrojs/sitemap`
|
||||
- **Newsletter** vía formulario en la home (acción `/api/subscribe`)
|
||||
- **Type-safe content** con Zod schemas en `src/content/config.ts`
|
||||
- **Búsqueda y SEO**: meta tags, Open Graph y RSS listos para compartir
|
||||
|
||||
## Stack técnico
|
||||
|
||||
- **Astro 5** — framework de sitios estáticos con islands
|
||||
- **TypeScript** — tipado estricto en todo el código
|
||||
- **Tailwind CSS 3** — utility-first CSS con `@tailwindcss/typography` para prose
|
||||
- **Zod** — validación de frontmatter en content collections
|
||||
- **RSS** vía `@astrojs/rss`
|
||||
- **Sitemap** vía `@astrojs/sitemap`
|
||||
- **Nginx** como servidor estático en el contenedor de producción
|
||||
- **Docker** multi-stage (Node para build, nginx para servir)
|
||||
- **CI/CD** con Gitea Actions en `.gitea/workflows/ci-cd.yaml`
|
||||
- **Deploy** automático a Portainer vía webhook
|
||||
|
||||
## Quick start (local)
|
||||
|
||||
Requisitos: Node.js >= 18.14.1 (recomendado 20).
|
||||
|
||||
```bash
|
||||
# 1. Instalar dependencias
|
||||
npm install
|
||||
|
||||
# 2. Arrancar dev server con HMR
|
||||
npm run dev
|
||||
# -> http://localhost:4321
|
||||
|
||||
# 3. Build de producción (genera ./dist/)
|
||||
npm run build
|
||||
|
||||
# 4. Preview local del build
|
||||
npm run preview
|
||||
```
|
||||
|
||||
> 🧑🚀 **Seasoned astronaut?** Delete this file. Have fun!
|
||||
## Despliegue (producción)
|
||||
|
||||
## 🚀 Project Structure
|
||||
El sitio se construye como imagen Docker y se publica en el registry de Gitea. Portainer hace pull y reinicia el contenedor cuando el CI dispara el webhook.
|
||||
|
||||
Inside of your Astro project, you'll see the following folders and files:
|
||||
```bash
|
||||
# Construir y servir localmente con Docker Compose
|
||||
docker compose up --build
|
||||
# -> http://localhost:8080
|
||||
|
||||
```text
|
||||
/
|
||||
├── public/
|
||||
├── src/
|
||||
│ └── pages/
|
||||
│ └── index.astro
|
||||
└── package.json
|
||||
# Producción: pull de la imagen publicada por CI
|
||||
docker compose -f docker-compose.prod.yml up -d
|
||||
# -> http://localhost:3001
|
||||
```
|
||||
|
||||
Astro looks for `.astro` or `.md` files in the `src/pages/` directory. Each page is exposed as a route based on its file name.
|
||||
La imagen final (`nginx:alpine`) solo contiene los estáticos generados en `./dist/`. Ver `Dockerfile` para detalles del multi-stage.
|
||||
|
||||
There's nothing special about `src/components/`, but that's where we like to put any Astro/React/Vue/Svelte/Preact components.
|
||||
## Cómo añadir contenido
|
||||
|
||||
Any static assets, like images, can be placed in the `public/` directory.
|
||||
### Nuevo post de blog
|
||||
|
||||
## 🧞 Commands
|
||||
Crea un archivo Markdown en `src/content/blog/` con nombre `YYYY-MM-DD-slug.md`. El frontmatter debe cumplir el schema de `src/content/config.ts`:
|
||||
|
||||
All commands are run from the root of the project, from a terminal:
|
||||
```markdown
|
||||
---
|
||||
title: "Título del post"
|
||||
description: "Resumen corto para SEO y previews"
|
||||
publishDate: 2026-07-09
|
||||
author: "David Aragón" # opcional, default: David Aragón
|
||||
tags: ["tag1", "tag2"]
|
||||
category: "technical" # technical | business | personal
|
||||
featured: false # true para destacar en home
|
||||
draft: false # true para ocultar del sitio público
|
||||
image: "/ruta/opcional.jpg" # opcional
|
||||
---
|
||||
|
||||
| Command | Action |
|
||||
| :------------------------ | :----------------------------------------------- |
|
||||
| `npm install` | Installs dependencies |
|
||||
| `npm run dev` | Starts local dev server at `localhost:4321` |
|
||||
| `npm run build` | Build your production site to `./dist/` |
|
||||
| `npm run preview` | Preview your build locally, before deploying |
|
||||
| `npm run astro ...` | Run CLI commands like `astro add`, `astro check` |
|
||||
| `npm run astro -- --help` | Get help using the Astro CLI |
|
||||
# Contenido del post en Markdown
|
||||
```
|
||||
|
||||
## 👀 Want to learn more?
|
||||
Aparecerá automáticamente en `/blog/<slug>` y, si no es draft, en la home.
|
||||
|
||||
Feel free to check [our documentation](https://docs.astro.build) or jump into our [Discord server](https://astro.build/chat).
|
||||
### Nuevo proyecto
|
||||
|
||||
Crea un archivo Markdown en `src/content/projects/` con frontmatter:
|
||||
|
||||
```markdown
|
||||
---
|
||||
title: "Nombre del proyecto"
|
||||
description: "Descripción de una línea"
|
||||
url: "https://proyecto.example.com"
|
||||
github: "https://github.com/user/repo" # opcional
|
||||
status: "development" # active | development | completed
|
||||
tags: ["React", "FastAPI"]
|
||||
startDate: 2026-07-01
|
||||
featured: true # true para mostrar en home
|
||||
image: "/ruta/opcional.jpg" # opcional
|
||||
---
|
||||
|
||||
# Descripción completa del proyecto en Markdown
|
||||
```
|
||||
|
||||
Aparecerá en `/projects/<slug>` y, si `featured: true`, también en la home.
|
||||
|
||||
## Estructura del proyecto
|
||||
|
||||
```
|
||||
.
|
||||
├── astro.config.mjs # Configuración Astro (site, integrations, markdown)
|
||||
├── tailwind.config.cjs # Tailwind theme y content paths
|
||||
├── tsconfig.json # TS estricto
|
||||
├── package.json # Deps y scripts
|
||||
├── Dockerfile # Multi-stage build (node -> nginx)
|
||||
├── docker-compose.yml # Local dev/test
|
||||
├── docker-compose.prod.yml # Producción (pull de registry)
|
||||
├── nginx.conf # Configuración nginx
|
||||
├── .gitea/workflows/ # Pipelines CI/CD
|
||||
│ └── ci-cd.yaml
|
||||
├── public/ # Assets estáticos servidos tal cual (favicon, david-aragon.jpg, etc.)
|
||||
└── src/
|
||||
├── components/
|
||||
│ ├── layout/ # Header, Footer
|
||||
│ └── ui/ # Card, Tag
|
||||
├── content/
|
||||
│ ├── config.ts # Schemas Zod para blog y projects
|
||||
│ ├── blog/ # Posts en Markdown
|
||||
│ └── projects/ # Proyectos en Markdown
|
||||
├── layouts/ # BaseLayout, BlogLayout, ProjectLayout
|
||||
├── pages/
|
||||
│ ├── index.astro # Home
|
||||
│ ├── about.astro # About
|
||||
│ ├── blog/ # Index + [slug].astro
|
||||
│ ├── projects/ # Index + [slug].astro
|
||||
│ └── rss.xml.ts # Feed RSS (si está generado)
|
||||
├── styles/ # CSS global
|
||||
└── utils/ # dateFormat.ts, readingTime.ts
|
||||
```
|
||||
|
||||
## CI/CD
|
||||
|
||||
Pipeline definido en `.gitea/workflows/ci-cd.yaml`. Trigger: push a `main`.
|
||||
|
||||
1. **Build**: checkout, login en el registry de Gitea, build de la imagen Docker multi-stage
|
||||
2. **Push**: publica `gitlab.impresion3d.pro/root/davidaragon-portfolio:latest`
|
||||
3. **Deploy**: el webhook configurado en Portainer hace pull de la nueva imagen y reinicia el contenedor
|
||||
|
||||
Secrets necesarios en el repo (Settings → Secrets):
|
||||
|
||||
- `DOCKER_USERNAME` — usuario del registry (normalmente `root`)
|
||||
- `DOCKER_PASSWORD` — token personal con permisos de registry
|
||||
|
||||
Ver `CI-CD-SETUP.md` para instrucciones detalladas de Portainer y registro de runners.
|
||||
|
||||
## Licencia
|
||||
|
||||
Código del sitio: ver `LICENSE` si existe en el repo. El contenido (posts y proyectos) es © David Aragón.
|
||||
|
||||
## Créditos
|
||||
|
||||
- Construido con [Astro](https://astro.build)
|
||||
- Estilado con [Tailwind CSS](https://tailwindcss.com)
|
||||
- Hospedado en infraestructura propia (Gitea + Portainer)
|
||||
@@ -0,0 +1,10 @@
|
||||
version: "3.8"
|
||||
|
||||
services:
|
||||
portfolio:
|
||||
image: gitlab.impresion3d.pro/root/davidaragon-portfolio:latest
|
||||
container_name: davidaragon-portfolio
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "3001:80"
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
version: "3.8"
|
||||
|
||||
services:
|
||||
portfolio:
|
||||
build: .
|
||||
container_name: davidaragon-portfolio
|
||||
ports:
|
||||
- "8080:80"
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost/"]
|
||||
interval: 30s
|
||||
timeout: 3s
|
||||
retries: 3
|
||||
start_period: 5s
|
||||
@@ -0,0 +1,326 @@
|
||||
# CI/CD optimization proposal — davidaragon-portfolio
|
||||
|
||||
**Status**: Draft v0.1 — initial scaffold, real measurements pending.
|
||||
**Last updated**: 2026-07-10
|
||||
**Owner**: David Aragón + Hermes
|
||||
**Related**: [`docs/architecture.md`](../architecture.md) · [`docs/ci-cd-setup.md`](../ci-cd-setup.md)
|
||||
|
||||
---
|
||||
|
||||
## 0. Why this document exists
|
||||
|
||||
The CI for `davidaragon-portfolio` is the second pipeline brought into the
|
||||
Gitea Actions + Portainer setup on the NAS (the first being
|
||||
`python-project-template-internal`). Both share the same QNAP-hosted
|
||||
`act_runner` self-hosted runner and the same Portainer instance — that means
|
||||
**an optimization here is partly informed by what we already learned on the
|
||||
Python template**, and partly needs its own measurements because the build is
|
||||
different (Node 20 + Astro static vs Python 3.10 + uv).
|
||||
|
||||
The first real deploy of the portfolio stack is the gating event: until that
|
||||
runs and we can read the timing breakdown, this document is **structural** +
|
||||
**conjectural** for any section that needs real numbers. Once we have one
|
||||
truthful run, the open `[METRIC]` placeholders get filled in.
|
||||
|
||||
---
|
||||
|
||||
## 1. Anatomy of the current pipeline
|
||||
|
||||
The workflow lives at `.gitea/workflows/ci-cd.yaml` and currently has:
|
||||
|
||||
```
|
||||
push / pull_request / workflow_dispatch(deploy:boolean)
|
||||
|
|
||||
+--> job: build (runs-on: ubuntu-latest)
|
||||
|
|
||||
+--> step 1: actions/checkout@v4
|
||||
+--> step 2: docker login + build + push
|
||||
| (tags: :latest and :0.0.1)
|
||||
+--> step 3 (conditional): Redeploy stack on Portainer
|
||||
|
|
||||
+--> a) DELETE existing stack on Portainer API
|
||||
+--> b) POST a fresh stack from docker-compose.prod.yml
|
||||
+--> c) sleep 8s, then smoke-test http://host:3001/
|
||||
```
|
||||
|
||||
### The runner
|
||||
|
||||
- `runs-on: ubuntu-latest` — no `self-hosted` label required.
|
||||
- The QNAP-resident `gitea/act_runner:latest` registers with these labels
|
||||
(per the existing CI config that runs successfully today):
|
||||
- `ubuntu-latest`
|
||||
- `linux`
|
||||
- `x64`
|
||||
- probably `act-runner` (informally used internally)
|
||||
- Single shared runner. Iteration `iter 5` of the Python template proved
|
||||
that without `concurrency:` group with `cancel-in-progress: false`, two
|
||||
parallel runs cancel each other's mid-flight steps. **This workflow
|
||||
inherited that fix from PR #2** (see `f152379 fix(ci): add concurrency
|
||||
guard + PR trigger + workflow_dispatch`). No regression expected on this
|
||||
repo.
|
||||
|
||||
### What runs on each step
|
||||
|
||||
| Step | Container / binary used | On the runner | Notes |
|
||||
|------|--------------------------|---------------|-------|
|
||||
| 1 (checkout) | `actions/checkout@v4` (Node) | Yes | Adds ~1s |
|
||||
| 2 (build) | local `docker buildx` | Yes | Pulls `node:20-alpine` + `nginx:alpine`. **Network bound.** |
|
||||
| 3 (deploy) | local `curl` + `jq` | Yes | Calls Portainer API at `http://${PORTAINER_URL}/api/stacks/...`. **Internal network bound.** |
|
||||
|
||||
The runner must therefore have:
|
||||
|
||||
- Outbound HTTPS to `registry.npmjs.org` (during build, npm ci)
|
||||
- Outbound HTTPS to `gitlab.impresion3d.pro:443` (during push)
|
||||
- Network reachability to the Portainer URL (`PORTAINER_URL` secret). When
|
||||
that URL is `http://192.168.1.30:9000`, the runner needs to be on the same
|
||||
LAN as the NAS. The QNAP self-hosted runner satisfies this only if the
|
||||
runner container has access to the LAN (either via `--network host` on
|
||||
the runner or via a docker network with a route to `192.168.1.0/24`).
|
||||
|
||||
The third point is the most likely silent failure: a `--network=some-bridge`
|
||||
runner cannot resolve `192.168.1.30` by default and the redeploy step will
|
||||
then fail with `connection refused` or `No route to host`. **Before the first
|
||||
real deploy**, the runner's network scope should be verified — see the
|
||||
[verification checklist](#7-verification-checklist-before-first-real-deploy).
|
||||
|
||||
---
|
||||
|
||||
## 2. Critical metrics to capture on the first deploy
|
||||
|
||||
The first real run will fill these in. Once captured, copy them into this
|
||||
section and update the subsequent analysis.
|
||||
|
||||
| Metric | Source | Target | Actual (first run) |
|
||||
|---|---|---|---|
|
||||
| `t_checkout` | logs of step 1 | < 5 s | `[METRIC]` |
|
||||
| `t_docker_pull_alpine_node` | logs of step 2 (first lines) | < 30 s | `[METRIC]` |
|
||||
| `t_npm_ci` | step 2 lines mentioning `npm ci` | < 60 s | `[METRIC]` |
|
||||
| `t_astro_build` | step 2 lines mentioning `astro build` | < 30 s | `[METRIC]` |
|
||||
| `t_docker_build_total` | step 2 end timestamp - step 2 start | < 180 s | `[METRIC]` |
|
||||
| `t_docker_push_latest` | step 2 lines mentioning `latest` push | < 20 s | `[METRIC]` |
|
||||
| `t_docker_push_pinned` | step 2 lines mentioning `:0.0.1` push | < 20 s | `[METRIC]` |
|
||||
| `t_step_3_delete` | step 3 DELETE line | < 5 s | `[METRIC]` |
|
||||
| `t_step_3_create` | step 3 POST line | < 5 s | `[METRIC]` |
|
||||
| `t_step_3_smoke_total` | step 3 sleep + curl | ~8 s + connect time | `[METRIC]` |
|
||||
| **Total wall-clock (with deploy)** | run-level duration | < 5 min | `[METRIC]` |
|
||||
| **Total wall-clock (without deploy)** | run-level duration | < 3 min | `[METRIC]` |
|
||||
| Runner queue waiting time | run started - run created | < 60 s | `[METRIC]` |
|
||||
|
||||
To capture these, the easiest path is to download the job logs via the
|
||||
Gitea API once the run is complete:
|
||||
|
||||
```bash
|
||||
# Get run_id from the Gitea UI (or /api/v1/repos/.../actions/runs)
|
||||
curl -s -H "Authorization: token $GITEA_TOKEN" \
|
||||
"$GITEA_URL/api/v1/repos/root/davidaragon-portfolio/actions/runs/<RUN_ID>/jobs" \
|
||||
| jq '.[].id'
|
||||
# Then fetch logs per job
|
||||
curl -s -H "Authorization: token $GITEA_TOKEN" \
|
||||
"$GITEA_URL/api/v1/repos/root/davidaragon-portfolio/actions/jobs/<JOB_ID>/logs"
|
||||
```
|
||||
|
||||
The `gitea-actions-ci-debugging` skill in the agent's repo has more on this
|
||||
pattern (see `references/api-endpoints.md`).
|
||||
|
||||
---
|
||||
|
||||
## 3. Hypothesised bottlenecks (to be verified against §2)
|
||||
|
||||
The following are **guesses** to be confirmed or refuted with the first run.
|
||||
They are listed in order of expected impact.
|
||||
|
||||
### H1. Docker layer cache cold on every run
|
||||
|
||||
- **Why**: The runner does not persist `docker buildx` cache between runs
|
||||
unless a layer cache is configured.
|
||||
- **Symptom**: `t_docker_pull_alpine_node` likely to be ~30s on cold cache.
|
||||
- **Possible fix (sized for the runner's disk)**:
|
||||
- Mount a host directory to `~/.cache/buildx` and export `BUILDX_CACHE` env.
|
||||
- Or use `docker/build-push-action@v6` with `cache-from: type=registry`.
|
||||
- **Effort / risk**: low. Touches only step 2.
|
||||
|
||||
### H2. Sequential `:latest` then `:0.0.1` pushes
|
||||
|
||||
- **Why**: Image bytes are uploaded twice (once per tag), doubling push time.
|
||||
- **Fix**: tag both and push only `:0.0.1`, then `:latest` is left for ad-hoc
|
||||
inspection. Or use `--all-tags` (`docker push --all-tags`) which sends
|
||||
each unique manifest only once if the registry supports it
|
||||
(Gitea container registry supports it since 1.20).
|
||||
- **Saving**: ~50% of push time (typically < 10s on LAN).
|
||||
|
||||
### H3. npm ci always re-downloads when `package-lock.json` changes
|
||||
|
||||
- **Why**: Without an `npm` cache, every push with a new lock entry is a fresh
|
||||
download from `registry.npmjs.org`.
|
||||
- **Symptom**: `t_npm_ci` proportional to dep delta. With the current lock
|
||||
file (Astro 5 + Tailwind + Zod), cold downloads are ~30s for the small
|
||||
tree, but as more plugins are added this grows.
|
||||
- **Fix**: use `actions/setup-node@v4` with `cache: 'npm'` (it caches based on
|
||||
`package-lock.json` hash automatically). Or use `cache@v4` directly with
|
||||
path `node_modules` and key derived from the lock hash.
|
||||
- **Saving**: 30s+ per run on a miss, ~5s on a hit.
|
||||
|
||||
### H4. Image pull-from-registry at container runtime
|
||||
|
||||
- **Symptom in step 3**: the first health check at `:3001/` may be slow on
|
||||
cold cache because the new container has to pull `...:0.0.1` from the
|
||||
registry. The compose file does not pin `pull_policy: always`, so the
|
||||
runner may use an older layer cache on the runner host.
|
||||
- **Fix**: add `pull_policy: always` to `docker-compose.prod.yml` so the new
|
||||
image is fetched every time.
|
||||
|
||||
### H5. Concurrency guard might be too coarse
|
||||
|
||||
- **Why**: The current group is `ci-${{ github.ref }}` — per ref. This
|
||||
serialises pushes to `main` AND pushes to PR branches on top of each other.
|
||||
With a single runner, this is correct. If a second runner comes online
|
||||
later, the guard may need to be re-evaluated.
|
||||
- **No action for now**. Re-evaluate when a second runner is registered.
|
||||
|
||||
---
|
||||
|
||||
## 4. Optimisation proposals (ranked by ROI)
|
||||
|
||||
These are **proposals** to apply after the first run, ranked by expected
|
||||
return on effort.
|
||||
|
||||
### Proposal A — Add `actions/setup-node@v4` with npm cache (effort: 5 min, expected saving: 30s/run on miss, 5s on hit)
|
||||
|
||||
Add as a new step between checkout and build:
|
||||
|
||||
```yaml
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20' # matches Dockerfile
|
||||
cache: 'npm' # caches ~/.npm based on package-lock.json hash
|
||||
```
|
||||
|
||||
This costs nothing in code maintenance and helps every PR build.
|
||||
|
||||
### Proposal B — Switch push to `--all-tags` (effort: 2 min, saving: ~10s/run)
|
||||
|
||||
Replace:
|
||||
|
||||
```bash
|
||||
docker push gitlab.impresion3d.pro/root/davidaragon-portfolio:latest
|
||||
docker push gitlab.impresion3d.pro/root/davidaragon-portfolio:0.0.1
|
||||
```
|
||||
|
||||
with:
|
||||
|
||||
```bash
|
||||
docker push --all-tags gitlab.impresion3d.pro/root/davidaragon-portfolio
|
||||
```
|
||||
|
||||
Both tags get pushed in one HTTP exchange; the registry deduplicates identical blobs.
|
||||
|
||||
### Proposal C — Add `pull_policy: always` to compose (effort: 1 min, saving: variable)
|
||||
|
||||
Add the line to `docker-compose.prod.yml`:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
portfolio:
|
||||
image: gitlab.impresion3d.pro/root/davidaragon-portfolio:0.0.1
|
||||
pull_policy: always # <-- new
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "3001:80"
|
||||
```
|
||||
|
||||
Without this, the runner host may serve a cached older layer to the new
|
||||
container even though the tag on the registry has changed.
|
||||
|
||||
### Proposal D — Drop the `:latest` tag entirely (effort: 5 min, saving: 10s/run, cost: 1 fewer tag for ad-hoc inspection)
|
||||
|
||||
Pure-only-`:0.0.1` means every pull is explicit (CI passes the pinned tag).
|
||||
Pros: zero ambiguity in `docker images` listings. Cons: less convenient for
|
||||
`docker exec -it foo /bin/sh` quick explorations.
|
||||
|
||||
Not recommended unless the user explicitly asks — the `:latest` is useful as
|
||||
a "what's currently live" pointer.
|
||||
|
||||
### Proposal E — Squash the image with `docker-slim` or `dive` optimisation (effort: 1 hour+, saving: 20-40 MB image size)
|
||||
|
||||
Probably **not worth it** for a portfolio site. Skip.
|
||||
|
||||
### Proposal F — Parallel runs (long-term)
|
||||
|
||||
Currently the pipeline has a single `build` job. If lint, type-check, and
|
||||
build grow apart in cost, splitting them into parallel jobs would help. But
|
||||
the Astro site has no type-checking step (vanilla JS), and a project this
|
||||
small does not benefit from parallelisation. Leave as-is.
|
||||
|
||||
---
|
||||
|
||||
## 5. Open questions for the user
|
||||
|
||||
- Should the build always push both `:latest` and `:0.0.1`, or only the
|
||||
pinned version? (Affects Proposal D)
|
||||
- Is there any reason to keep the smoke-test in step 3 (`http://host:3001/`)?
|
||||
The actual public URL is via NPM (`https://davidaragon.impresion3d.pro`).
|
||||
Smoke-testing the public URL would be more accurate but requires the
|
||||
runner to have outbound internet AND DNS for the FQDN.
|
||||
|
||||
---
|
||||
|
||||
## 6. Risks of the current design
|
||||
|
||||
- **Single runner bottleneck**. The QNAP has exactly one `act_runner`
|
||||
container; pushes to two repos (template + portfolio) serialise. If both
|
||||
repos ever have long-running builds, developer iteration slows. **No fix
|
||||
for now** (registering a second runner is on the long-run roadmap).
|
||||
- **`pull_policy` not set**. The compose does not force a pull-on-create.
|
||||
See Proposal C above.
|
||||
- **No `if: failure()` retry or notify step**. If the Portainer redeploy
|
||||
fails, the run ends with `failure` but no notification pings anyone. The
|
||||
user has to look at the Actions tab manually. **Add a Slack/email
|
||||
notification step in a future iteration** (low priority while there is
|
||||
one user).
|
||||
- **The `IMAGE_NAME` env var in the workflow is hardcoded**. When the
|
||||
version bumps from `0.0.1` to `0.0.2`, a separate edit is needed in
|
||||
`ci-cd.yaml`. Could be moved to a `VERSION` repo variable later.
|
||||
|
||||
---
|
||||
|
||||
## 7. Verification checklist before first real deploy
|
||||
|
||||
Run through this list before firing `workflow_dispatch(deploy=true)` to
|
||||
reduce surprise:
|
||||
|
||||
- [ ] `act_runner` is `Idle` (or `Online`) in `https://gitlab.impresion3d.pro/-/admin/actions/runners`. Not in `Restarting`.
|
||||
- [ ] `act_runner` has the labels `ubuntu-latest`, `linux`, `x64` at minimum.
|
||||
- [ ] `act_runner`'s docker network can resolve `192.168.1.30` (verify with a debug step that runs `getent hosts gitlab.impresion3d.pro`).
|
||||
- [ ] Portainer URL responds from the runner (verify with a debug step: `curl -I ${PORTAINER_URL}/api/status`).
|
||||
- [ ] All 4 secrets are visible in `https://gitlab.impresion3d.pro/root/davidaragon-portfolio/settings/secrets/actions` with values `(hidden)`.
|
||||
- [ ] `DOCKER_USERNAME` and `DOCKER_PASSWORD` work (verify with `docker login ... -u $U -p $P && docker push <empty-image>` once).
|
||||
- [ ] The `act_runner` host has at least 2-3 GB free disk (for buildx cache + pulled base images).
|
||||
|
||||
The first three items live at the NAS / runner level — see the
|
||||
`qnap-nas` skill for the diagnostics. Items 4-7 are repo-level.
|
||||
|
||||
---
|
||||
|
||||
## 8. Proposed workflow to apply A, B, C
|
||||
|
||||
After the first deploy succeeds and §2 is filled in, apply A, B, C in a
|
||||
single PR. The PR title can be `ci: apply optimisations A B C from
|
||||
docs/ci-optimization-proposal.md`. Estimated effort: 10 minutes. Estimated
|
||||
saving: ~30-50 s per run.
|
||||
|
||||
If §2 reveals a metric that disagrees with §3 (e.g. push is actually fast
|
||||
and `astro build` is the slow part), the proposal list should be re-ranked
|
||||
**before applying**, not after.
|
||||
|
||||
---
|
||||
|
||||
## Appendix A — Convention: when to write a separate skill for this
|
||||
|
||||
If the same discovery shows up 3 times across this repo and the Python
|
||||
template, it graduates from "proposal" to "skill". Candidates so far:
|
||||
|
||||
- **Image tag strategy for self-hosted GitLab Container Registry** — both
|
||||
repos push to the same registry. Worth a skill once the second project
|
||||
ships the same pattern.
|
||||
- **Concise Portainer DELETE+CREATE pattern** — the bash snippet in step 3
|
||||
is reusable. Worth a skill the second time it's copy-pasted.
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
user nginx;
|
||||
worker_processes auto;
|
||||
error_log /var/log/nginx/error.log warn;
|
||||
pid /var/run/nginx.pid;
|
||||
|
||||
events {
|
||||
worker_connections 1024;
|
||||
}
|
||||
|
||||
http {
|
||||
include /etc/nginx/mime.types;
|
||||
default_type application/octet-stream;
|
||||
|
||||
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
|
||||
'$status $body_bytes_sent "$http_referer" '
|
||||
'"$http_user_agent" "$http_x_forwarded_for"';
|
||||
|
||||
access_log /var/log/nginx/access.log main;
|
||||
|
||||
sendfile on;
|
||||
tcp_nopush on;
|
||||
keepalive_timeout 65;
|
||||
gzip on;
|
||||
gzip_vary on;
|
||||
gzip_proxied any;
|
||||
gzip_comp_level 6;
|
||||
gzip_types text/plain text/css text/xml text/javascript application/json application/javascript application/xml+rss application/rss+xml font/truetype font/opentype application/vnd.ms-fontobject image/svg+xml;
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# Serve static files with cache headers
|
||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
|
||||
# SPA fallback - serve index.html for all routes
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# Security headers
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
@@ -0,0 +1 @@
|
||||
Placeholder for favicon - use favicon.io or other generator to create a comprehensive favicon set.
|
||||
@@ -0,0 +1 @@
|
||||
Placeholder for avatar - replace with real photo
|
||||
@@ -0,0 +1 @@
|
||||
Placeholder for OG image - replace with a 1200x630px image
|
||||
@@ -0,0 +1,8 @@
|
||||
User-agent: *
|
||||
Allow: /
|
||||
|
||||
Sitemap: https://davidaragon.impresion3d.pro/sitemap-index.xml
|
||||
|
||||
# Disallow admin/private areas (if any)
|
||||
Disallow: /admin/
|
||||
Disallow: /api/
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "David Aragón - Indie Builder",
|
||||
"short_name": "DA Portfolio",
|
||||
"description": "Portfolio personal y blog de indie builder español",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#0a0e27",
|
||||
"theme_color": "#60a5fa",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/android-chrome-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "/android-chrome-512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
---
|
||||
const currentYear = new Date().getFullYear();
|
||||
const socialLinks = [
|
||||
{ href: 'https://twitter.com/davidaragon', label: 'X', icon: 'X' },
|
||||
{ href: 'https://linkedin.com/in/davidaragon', label: 'LinkedIn', icon: 'in' },
|
||||
{ href: 'https://github.com/davidaragon', label: 'GitHub', icon: 'GH' },
|
||||
];
|
||||
---
|
||||
|
||||
<footer class="mt-auto border-t border-text-tertiary/20 bg-surface">
|
||||
<div class="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<div class="flex flex-col md:flex-row justify-between items-center gap-4">
|
||||
<p class="text-sm text-text-secondary">
|
||||
© {currentYear} David Aragón. Construyendo en público.
|
||||
</p>
|
||||
|
||||
<div class="flex items-center gap-4">
|
||||
{socialLinks.map(link => (
|
||||
<a
|
||||
href={link.href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-text-secondary hover:text-primary transition-colors text-sm font-medium"
|
||||
aria-label={link.label}
|
||||
>
|
||||
{link.icon}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<a
|
||||
href="/rss.xml"
|
||||
class="text-sm text-text-secondary hover:text-primary transition-colors"
|
||||
>
|
||||
RSS Feed
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
@@ -0,0 +1,14 @@
|
||||
---
|
||||
import Navigation from './Navigation.astro';
|
||||
---
|
||||
|
||||
<header class="sticky top-0 z-50 bg-background/80 backdrop-blur-md border-b border-text-tertiary/20">
|
||||
<div class="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex items-center justify-between h-16">
|
||||
<a href="/" class="text-xl font-bold text-primary hover:text-secondary transition-colors">
|
||||
DA
|
||||
</a>
|
||||
<Navigation />
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
@@ -0,0 +1,24 @@
|
||||
---
|
||||
const navItems = [
|
||||
{ href: '/about', label: 'Sobre mí' },
|
||||
{ href: '/projects', label: 'Proyectos' },
|
||||
{ href: '/blog', label: 'Blog' },
|
||||
];
|
||||
|
||||
const currentPath = Astro.url.pathname;
|
||||
---
|
||||
|
||||
<nav class="flex items-center gap-6">
|
||||
{navItems.map(item => (
|
||||
<a
|
||||
href={item.href}
|
||||
class={`text-sm font-medium transition-colors hover:text-primary ${
|
||||
currentPath.startsWith(item.href)
|
||||
? 'text-primary'
|
||||
: 'text-text-secondary'
|
||||
}`}
|
||||
>
|
||||
{item.label}
|
||||
</a>
|
||||
))}
|
||||
</nav>
|
||||
@@ -0,0 +1,17 @@
|
||||
---
|
||||
export interface Props {
|
||||
href?: string;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
const { href, class: className } = Astro.props;
|
||||
const Component = href ? 'a' : 'div';
|
||||
---
|
||||
|
||||
<Component
|
||||
href={href}
|
||||
class={`bg-surface rounded-lg border border-text-tertiary/20 p-6 hover:border-primary/40
|
||||
transition-colors ${className || ''} ${href ? 'block' : ''}`}
|
||||
>
|
||||
<slot />
|
||||
</Component>
|
||||
@@ -0,0 +1,18 @@
|
||||
---
|
||||
export interface Props {
|
||||
label: string;
|
||||
variant?: 'primary' | 'secondary' | 'neutral';
|
||||
}
|
||||
|
||||
const { label, variant = 'neutral' } = Astro.props;
|
||||
|
||||
const variants = {
|
||||
primary: 'bg-primary/10 text-primary border-primary/20',
|
||||
secondary: 'bg-secondary/10 text-secondary border-secondary/20',
|
||||
neutral: 'bg-text-tertiary/10 text-text-secondary border-text-tertiary/20',
|
||||
};
|
||||
---
|
||||
|
||||
<span class={`inline-block px-3 py-1 rounded-full text-sm font-medium border ${variants[variant]}`}>
|
||||
{label}
|
||||
</span>
|
||||
@@ -0,0 +1,60 @@
|
||||
---
|
||||
title: "From Goldsmith to Builder: Why I'm Building in Public"
|
||||
description: "I didn't start as a developer. I started working metal with my hands. Today I build software. This is my story."
|
||||
publishDate: 2026-05-11
|
||||
author: "David Aragón"
|
||||
tags: ["build-in-public", "introduction", "background"]
|
||||
category: "personal"
|
||||
featured: true
|
||||
draft: false
|
||||
---
|
||||
|
||||
# From Goldsmith to Builder: Why I'm Building in Public
|
||||
|
||||
I didn't start in tech. I started working metal with my hands.
|
||||
|
||||
I was a goldsmith by trade. Then a professional photographer for 13 years. Later a trainer for LinkedIn Learning, teaching about NAS servers and storage. Then a Python developer at NTT Data. And now I work at KEO Connectivity with IoT protocols for energy systems.
|
||||
|
||||
Strange paths. But all connected by the same thing: **obsession with understanding how things work, and then making them work better**.
|
||||
|
||||
## The Pattern
|
||||
|
||||
When I was a photographer, I couldn't afford the equipment I needed. So I improvised. Built supports, modified accessories, made what I had work.
|
||||
|
||||
That same impulse led me to build my own 3D printer from scratch. To tinker with Arduino. To learn Python to automate workflows at Qloudea while managing social media and providing technical support.
|
||||
|
||||
**The pattern is always the same: identify a problem, obsess over solving it, learn whatever it takes to fix it.**
|
||||
|
||||
## Why Now
|
||||
|
||||
After years solving problems for others, I decided to solve problems for myself. Build my own products.
|
||||
|
||||
I don't have an MBA. I don't have funding. I don't have a team. I just have experience building things, teaching what I learn, and an abnormally high tolerance for not knowing what I'm doing.
|
||||
|
||||
And I decided to do it in public because:
|
||||
|
||||
1. **Accountability**: It's harder to quit when you share progress publicly
|
||||
2. **Teaching**: I've always been a trainer. Sharing what I learn is part of who I am
|
||||
3. **Community**: I want to connect with other Spanish builders on the same journey
|
||||
|
||||
## What I'm Working On
|
||||
|
||||
Currently focused on **WarrantyHub**, a digital warranty management platform for home users.
|
||||
|
||||
Why warranties? Because I saw the problem in real life: chaotic management with papers, Excel, lost emails. And because there's room for a Spanish solution that understands the local market.
|
||||
|
||||
I'm using AI aggressively to accelerate development. Not because it's trendy. Because when you're sole developer, sole marketer, sole product manager, sole support, you need every advantage you can get.
|
||||
|
||||
## What to Expect
|
||||
|
||||
I'll share launches, technical learnings, product decisions, and failures (because they teach more than successes).
|
||||
|
||||
Don't expect generic motivational posts or recycled X threads. This is documentation of a real journey.
|
||||
|
||||
If you're a builder, this is also for you.
|
||||
|
||||
See you in the next post. 🚀
|
||||
|
||||
---
|
||||
|
||||
_Follow me on [X](https://twitter.com/davidaragon) for more frequent updates._
|
||||
@@ -0,0 +1,44 @@
|
||||
---
|
||||
title: "Modbus TCP vs. EEBUS SPINE: A Technical Comparison for Device Architects"
|
||||
description: "Every device architect I talk to already knows Modbus. That's precisely where the comparison gets interesting."
|
||||
publishDate: 2026-05-11
|
||||
author: "David Aragón"
|
||||
tags: ["EEBUS", "Modbus", "IoT", "protocols", "energy-management"]
|
||||
category: "technical"
|
||||
featured: true
|
||||
draft: false
|
||||
---
|
||||
|
||||
*Originally published on [LinkedIn](https://www.linkedin.com/pulse/modbus-tcp-vs-eebus-spine-technical-comparison-device-arag%C3%B3n-galiana-qagje/)*
|
||||
|
||||
Every device architect I talk to already knows Modbus. That's precisely where the comparison gets interesting.
|
||||
|
||||
## Protocol vs. Data Model
|
||||
|
||||
Modbus TCP is a communication protocol. SPINE is a data model with communication semantics. Mixing those two categories is where most evaluations go wrong.
|
||||
|
||||
In Modbus, **meaning is external**. Register 40031 might hold the current setpoint temperature on one manufacturer's heat pump. On another manufacturer's product, that same value lives in a different register entirely. The register map lives in a vendor PDF, and every integration is a one-off mapping exercise.
|
||||
|
||||
In SPINE, **meaning is in the protocol**. A `HeatPumpAppliance` entity exposes features with defined data types and semantics. The EMS doesn't need a vendor manual to understand what `operationMode` means: it's specified, discoverable, and identical across every EEBUS-compatible manufacturer.
|
||||
|
||||
## Communication Model: Polling vs. Subscription
|
||||
|
||||
The communication model is also inverted. Modbus is **polling-based**: your EMS asks, the device answers, on a schedule you define. SPINE is **subscription-based**: you subscribe to the features you care about, and the device notifies you on change. For real-time energy coordination, the difference in latency and network load is noticeable.
|
||||
|
||||
## Security
|
||||
|
||||
Security is another axis. Modbus TCP has none natively. SHIP, the transport layer EEBUS runs on, includes TLS and device pairing as defined components of the spec, not optional additions.
|
||||
|
||||
## The Complexity Trade-off
|
||||
|
||||
The tradeoff is real: SPINE is more complex upfront. The data model takes time to internalize, and the SHIP pairing flow alone has caught more than one integration team off guard.
|
||||
|
||||
But the comparison isn't Modbus complexity versus EEBUS complexity for one device. It's **Modbus complexity per device, per manufacturer, per firmware version**, versus **EEBUS complexity once**.
|
||||
|
||||
## Why "Once" Matters
|
||||
|
||||
That "once" is where the stack choice matters. We built the KEO stack with both SHIP and SPINE fully abstracted: no pairing flow to implement, no data model to interpret, no spec ambiguity to chase down. The integration starts at the use case level.
|
||||
|
||||
---
|
||||
|
||||
If you're stuck on a SHIP pairing issue, a SPINE feature that's misbehaving, or sizing up the build-vs-buy decision for an EEBUS stack, feel free to reach out. Always up for comparing notes.
|
||||
@@ -0,0 +1,26 @@
|
||||
---
|
||||
title: "Welcome to My Portfolio"
|
||||
description: "First post to validate content collections setup"
|
||||
publishDate: 2026-05-08
|
||||
tags: ["meta", "announcement"]
|
||||
category: "personal"
|
||||
featured: true
|
||||
draft: true
|
||||
---
|
||||
|
||||
# Welcome
|
||||
|
||||
This is a sample blog post to validate that content collections are working correctly.
|
||||
|
||||
## Features
|
||||
- Markdown content with frontmatter validation
|
||||
- Type-safe schema with Zod
|
||||
- Automatic type generation for TypeScript
|
||||
|
||||
## Code Example
|
||||
```typescript
|
||||
const greeting = "Hello, World!";
|
||||
console.log(greeting);
|
||||
```
|
||||
|
||||
This post will be replaced with real content later.
|
||||
@@ -0,0 +1,31 @@
|
||||
import { defineCollection, z } from 'astro:content';
|
||||
|
||||
const blog = defineCollection({
|
||||
schema: z.object({
|
||||
title: z.string(),
|
||||
description: z.string(),
|
||||
publishDate: z.coerce.date(),
|
||||
author: z.string().default('David Aragón'),
|
||||
tags: z.array(z.string()),
|
||||
category: z.enum(['technical', 'business', 'personal']),
|
||||
featured: z.boolean().default(false),
|
||||
draft: z.boolean().default(false),
|
||||
image: z.string().optional(),
|
||||
}),
|
||||
});
|
||||
|
||||
const projects = defineCollection({
|
||||
schema: z.object({
|
||||
title: z.string(),
|
||||
description: z.string(),
|
||||
url: z.string().url(),
|
||||
github: z.string().url().optional(),
|
||||
status: z.enum(['active', 'development', 'completed']),
|
||||
tags: z.array(z.string()),
|
||||
startDate: z.coerce.date(),
|
||||
featured: z.boolean().default(false),
|
||||
image: z.string().optional(),
|
||||
}),
|
||||
});
|
||||
|
||||
export const collections = { blog, projects };
|
||||
@@ -0,0 +1,48 @@
|
||||
---
|
||||
title: "Python Project Template"
|
||||
description: "Opinionated Python project template with modern tooling: uv, ruff, mypy, pytest, Docker, and CI/CD ready"
|
||||
url: "https://gitlab.impresion3d.pro/root/python-project-template"
|
||||
status: "active"
|
||||
tags: ["Python", "Docker", "CI/CD", "DevOps"]
|
||||
startDate: 2026-05-01
|
||||
featured: true
|
||||
---
|
||||
|
||||
# Python Project Template
|
||||
|
||||
An opinionated Python project template designed for rapid development with modern tooling and infrastructure-as-code practices.
|
||||
|
||||
## Features
|
||||
|
||||
- **Modern Python tooling**: uv for fast dependency management, ruff for linting, mypy for type checking
|
||||
- **Testing**: pytest with coverage reports
|
||||
- **Docker ready**: Multi-stage Dockerfile optimized for Python projects
|
||||
- **CI/CD pipeline**: Gitea Actions workflows for automated testing and deployment
|
||||
- **Portainer integration**: Deploy to self-hosted infrastructure with one push
|
||||
- **Development environment**: nox for task automation
|
||||
|
||||
## Why I Built This
|
||||
|
||||
After setting up multiple Python projects from scratch, I noticed I was repeating the same patterns: uv for dependencies, ruff for code quality, Docker for deployment, Gitea Actions for CI/CD. Each time I'd spend hours configuring these tools.
|
||||
|
||||
This template captures those decisions so new projects start with production-ready infrastructure on day one.
|
||||
|
||||
## Stack
|
||||
|
||||
- **Python 3.12+** with uv package manager
|
||||
- **Quality tools**: ruff (linting), mypy (type checking), pytest (testing)
|
||||
- **Containerization**: Docker with multi-stage builds
|
||||
- **CI/CD**: Gitea Actions with automated testing and deployment
|
||||
- **Orchestration**: Portainer stack deployment
|
||||
|
||||
## Use Cases
|
||||
|
||||
Perfect for:
|
||||
- Web APIs (FastAPI, Flask)
|
||||
- CLI tools
|
||||
- Background workers
|
||||
- Self-hosted applications
|
||||
|
||||
## Status
|
||||
|
||||
Active and maintained. I use this template for all new Python projects.
|
||||
@@ -0,0 +1,21 @@
|
||||
---
|
||||
title: "Sample Project"
|
||||
description: "Test project for content collections validation"
|
||||
url: "https://example.com"
|
||||
status: "development"
|
||||
tags: ["test"]
|
||||
startDate: 2026-05-08
|
||||
featured: false
|
||||
draft: true
|
||||
---
|
||||
|
||||
# Sample Project
|
||||
|
||||
This is a placeholder project to validate content collections.
|
||||
|
||||
## Tech Stack
|
||||
- Astro
|
||||
- TypeScript
|
||||
- Tailwind CSS
|
||||
|
||||
This will be replaced with real project case studies.
|
||||
@@ -0,0 +1,72 @@
|
||||
---
|
||||
title: "WarrantyHub"
|
||||
description: "Plataforma digital para gestionar todas las garantías de tus productos del hogar desde un solo lugar"
|
||||
url: "https://warrantyhub.impresion3d.pro"
|
||||
status: "development"
|
||||
tags: ["React", "FastAPI", "PostgreSQL", "SaaS"]
|
||||
startDate: 2026-04-15
|
||||
featured: true
|
||||
image: ""
|
||||
---
|
||||
|
||||
# WarrantyHub
|
||||
|
||||
**Gestión digital de garantías para el hogar**
|
||||
|
||||
## El Problema
|
||||
|
||||
Todos tenemos el mismo problema: compramos electrodomésticos, gadgets, muebles... y guardamos los tickets y garantías en algún cajón. Cuando algo se rompe y necesitas reclamar:
|
||||
|
||||
- ❌ No encuentras el ticket
|
||||
- ❌ No sabes si todavía está en garantía
|
||||
- ❌ No tienes los datos del establecimiento
|
||||
- ❌ El proceso de reclamación es un caos
|
||||
|
||||
**WarrantyHub resuelve esto.**
|
||||
|
||||
## La Solución
|
||||
|
||||
Una plataforma donde guardas todas tus garantías digitalmente:
|
||||
|
||||
✅ **Digitalización rápida**: Foto del ticket y listo
|
||||
✅ **Recordatorios**: Te avisamos antes de que expire la garantía
|
||||
✅ **Organización**: Todos tus productos en un solo lugar
|
||||
✅ **Búsqueda fácil**: Encuentra cualquier garantía en segundos
|
||||
✅ **Proceso guiado**: Te ayudamos con el proceso de reclamación
|
||||
|
||||
## Stack Técnico
|
||||
|
||||
**Frontend:**
|
||||
- React + TypeScript
|
||||
- Tailwind CSS
|
||||
- Vite
|
||||
|
||||
**Backend:**
|
||||
- FastAPI (Python)
|
||||
- PostgreSQL
|
||||
- Docker + Portainer
|
||||
|
||||
**Features actuales:**
|
||||
- Autenticación de usuarios
|
||||
- Subida y digitalización de tickets (OCR)
|
||||
- Gestión de productos y garantías
|
||||
- Notificaciones de expiración
|
||||
|
||||
## Estado Actual
|
||||
|
||||
🚧 **En desarrollo activo**
|
||||
|
||||
Trabajando en:
|
||||
- Sistema de recordatorios automáticos
|
||||
- Mejora de OCR para extracción de datos
|
||||
- Integración con tiendas para importar garantías automáticamente
|
||||
|
||||
## Por Qué Este Proyecto
|
||||
|
||||
Es un problema que yo mismo tenía. Y después de hablar con amigos y familia, descubrí que **todos** tienen el mismo problema.
|
||||
|
||||
No es sexy. No es AI generativa. Pero resuelve un problema real que la gente tiene cada día.
|
||||
|
||||
---
|
||||
|
||||
**Status:** Pronto en beta privada. [Suscríbete](/blog) para recibir invitación.
|
||||
@@ -0,0 +1,49 @@
|
||||
---
|
||||
import '../styles/global.css';
|
||||
|
||||
export interface Props {
|
||||
title: string;
|
||||
description: string;
|
||||
image?: string;
|
||||
}
|
||||
|
||||
const { title, description, image } = Astro.props;
|
||||
const canonicalURL = new URL(Astro.url.pathname, Astro.site);
|
||||
const socialImage = image ? new URL(image, Astro.site) : new URL('/images/og-default.jpg', Astro.site);
|
||||
---
|
||||
|
||||
<!DOCTYPE html>
|
||||
<html lang="es">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{title} | David Aragón - Indie Builder</title>
|
||||
<meta name="description" content={description}>
|
||||
<link rel="canonical" href={canonicalURL} />
|
||||
|
||||
<!-- Open Graph -->
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:url" content={canonicalURL} />
|
||||
<meta property="og:title" content={title} />
|
||||
<meta property="og:description" content={description} />
|
||||
<meta property="og:image" content={socialImage} />
|
||||
|
||||
<!-- Twitter Card -->
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:url" content={canonicalURL} />
|
||||
<meta name="twitter:title" content={title} />
|
||||
<meta name="twitter:description" content={description} />
|
||||
<meta name="twitter:image" content={socialImage} />
|
||||
|
||||
<!-- Favicon -->
|
||||
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
|
||||
|
||||
<!-- Fonts -->
|
||||
<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=Inter:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
||||
</head>
|
||||
<body class="min-h-screen flex flex-col">
|
||||
<slot />
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,34 @@
|
||||
---
|
||||
const blogPostJsonLd = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "BlogPosting",
|
||||
"headline": "{title}",
|
||||
"description": "{description}",
|
||||
"image": "{socialImage}",
|
||||
"datePublished": "{publishDate}",
|
||||
"author": {
|
||||
"@type": "Person",
|
||||
"name": "David Aragón",
|
||||
"url": "https://davidaragon.impresion3d.pro"
|
||||
},
|
||||
"publisher": {
|
||||
"@type": "Person",
|
||||
"name": "David Aragón",
|
||||
"url": "https://davidaragon.impresion3d.pro"
|
||||
},
|
||||
"keywords": "{keywords}",
|
||||
"articleSection": "{category}",
|
||||
"inLanguage": "es-ES"
|
||||
};
|
||||
---
|
||||
|
||||
<!DOCTYPE html>
|
||||
<html lang="es">
|
||||
<head>
|
||||
<!-- JSON-LD Blog Structured Data -->
|
||||
<script type="application/ld+json" set:html={JSON.stringify(blogPostJsonLd)} />
|
||||
</head>
|
||||
<body>
|
||||
<!-- Blog content -->
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,95 @@
|
||||
---
|
||||
import BaseLayout from './BaseLayout.astro';
|
||||
import Header from '../components/layout/Header.astro';
|
||||
import Footer from '../components/layout/Footer.astro';
|
||||
import Tag from '../components/ui/Tag.astro';
|
||||
import { formatDate } from '../utils/dateFormat';
|
||||
|
||||
export interface Props {
|
||||
title: string;
|
||||
description: string;
|
||||
url: string;
|
||||
github?: string;
|
||||
status: string;
|
||||
tags: string[];
|
||||
startDate: Date;
|
||||
image?: string;
|
||||
}
|
||||
|
||||
const { title, description, url, github, status, tags, startDate, image } = Astro.props;
|
||||
|
||||
const statusColors = {
|
||||
active: 'text-accent-green',
|
||||
development: 'text-accent-yellow',
|
||||
completed: 'text-text-tertiary',
|
||||
};
|
||||
---
|
||||
|
||||
<BaseLayout title={title} description={description} image={image}>
|
||||
<Header />
|
||||
<main class="flex-1">
|
||||
<article class="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
||||
<header class="mb-12">
|
||||
<div class="flex items-center gap-3 mb-4">
|
||||
<span class={`text-sm font-semibold uppercase tracking-wide ${statusColors[status] || 'text-text-tertiary'}`}>
|
||||
{status}
|
||||
</span>
|
||||
<span class="text-text-tertiary">•</span>
|
||||
<time datetime={startDate.toISOString()} class="text-sm text-text-tertiary">
|
||||
Desde {formatDate(startDate)}
|
||||
</time>
|
||||
</div>
|
||||
|
||||
<h1 class="text-4xl md:text-5xl font-bold mb-4">{title}</h1>
|
||||
<p class="text-xl text-text-secondary mb-6">{description}</p>
|
||||
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<a
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="px-6 py-3 bg-primary hover:bg-primary/80 text-background rounded-lg
|
||||
font-semibold transition-colors"
|
||||
>
|
||||
Ver Proyecto →
|
||||
</a>
|
||||
{github && (
|
||||
<a
|
||||
href={github}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="px-6 py-3 bg-surface hover:bg-surface/80 text-text-primary rounded-lg
|
||||
font-semibold border border-text-tertiary/20 transition-colors"
|
||||
>
|
||||
GitHub →
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{image && (
|
||||
<div class="mb-12 rounded-xl overflow-hidden border border-text-tertiary/20">
|
||||
<img
|
||||
src={image}
|
||||
alt={title}
|
||||
class="w-full h-auto"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div class="prose prose-invert prose-lg max-w-none">
|
||||
<slot />
|
||||
</div>
|
||||
|
||||
<footer class="mt-12 pt-8 border-t border-text-tertiary/20">
|
||||
<h3 class="text-lg font-semibold mb-4">Tecnologías</h3>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{tags.map(tag => (
|
||||
<Tag label={tag} variant="neutral" />
|
||||
))}
|
||||
</div>
|
||||
</footer>
|
||||
</article>
|
||||
</main>
|
||||
<Footer />
|
||||
</BaseLayout>
|
||||
@@ -0,0 +1,302 @@
|
||||
---
|
||||
import BaseLayout from '../layouts/BaseLayout.astro';
|
||||
import Header from '../components/layout/Header.astro';
|
||||
import Footer from '../components/layout/Footer.astro';
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title="About"
|
||||
description="Spanish builder. From goldsmith to photographer to developer. Building products and sharing the journey."
|
||||
>
|
||||
<Header />
|
||||
<main class="flex-1 max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
||||
<article class="prose prose-invert prose-lg max-w-none">
|
||||
<h1 class="text-4xl font-bold mb-8">About Me</h1>
|
||||
|
||||
<div class="flex flex-col md:flex-row gap-8 mb-8 not-prose">
|
||||
<div class="flex-shrink-0">
|
||||
<img
|
||||
src="/david-aragon.jpg"
|
||||
alt="David Aragón"
|
||||
class="w-48 h-48 rounded-full object-cover border-4 border-primary/20"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<h2 class="text-2xl font-semibold mb-4 text-text-primary">Hi 👋</h2>
|
||||
<p class="text-text-secondary text-lg leading-relaxed">
|
||||
I'm David, a Spanish builder. I didn't start in tech — I was a goldsmith, then a professional
|
||||
photographer for 13 years, instructor for LinkedIn Learning, and developer. Now I build products
|
||||
that solve real problems.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="mb-12">
|
||||
<h2 class="text-3xl font-semibold mb-4">The Path</h2>
|
||||
<p class="text-text-secondary leading-relaxed mb-4">
|
||||
I started working with metal as a goldsmith. One day I got interested in photography,
|
||||
and that obsession led me to build my own brand for 13 years. When I couldn't
|
||||
afford the equipment I needed, I improvised. I built supports, modified
|
||||
accessories, made what I had work.
|
||||
</p>
|
||||
<p class="text-text-secondary leading-relaxed mb-4">
|
||||
That same drive led me to learn Python, build my own 3D printer from scratch,
|
||||
work at Qloudea developing internal tools while managing social media
|
||||
and providing technical support. Then Python developer at NTT Data, and now I work at KEO
|
||||
Connectivity with IoT protocols for energy systems (EEBUS).
|
||||
</p>
|
||||
<p class="text-text-secondary leading-relaxed mb-4">
|
||||
<strong class="text-text-primary">The pattern is always the same:</strong> identify a
|
||||
problem, obsess over solving it, learn whatever it takes to fix it.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="mb-12">
|
||||
<h2 class="text-3xl font-semibold mb-4">What I'm Doing Now</h2>
|
||||
<p class="text-text-secondary leading-relaxed mb-4">
|
||||
I work full-time at KEO as Field Application Engineer, where I handle technical
|
||||
documentation, workflow automation, internal tools, and customer-facing support.
|
||||
</p>
|
||||
<p class="text-text-secondary leading-relaxed mb-4">
|
||||
<em class="text-text-tertiary text-sm">
|
||||
Last site update: 2026-07-09 (build sha visible in the CI run that produced this
|
||||
version of the page).
|
||||
</em>
|
||||
</p>
|
||||
<p class="text-text-secondary leading-relaxed mb-4">
|
||||
In parallel, I'm building <strong class="text-primary">WarrantyHub</strong> — a platform
|
||||
for end users to manage warranties for their home products from one place.
|
||||
It's a problem I had myself, and I discovered all my friends and family have the same problem.
|
||||
</p>
|
||||
<p class="text-text-secondary leading-relaxed mb-4">
|
||||
I also work on other experimental projects: productivity CLIs, automation tools,
|
||||
Discord bots, and templates to accelerate Python development.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="mb-12">
|
||||
<h2 class="text-3xl font-semibold mb-4">Current Projects</h2>
|
||||
<div class="space-y-4">
|
||||
<div class="bg-surface p-5 rounded-lg border border-text-tertiary/20">
|
||||
<h3 class="text-lg font-semibold text-primary mb-2">WarrantyHub</h3>
|
||||
<p class="text-text-secondary text-sm">
|
||||
Digital home warranty manager. Self-hosted PWA with freemium model.
|
||||
Stack: React + FastAPI + PostgreSQL.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="bg-surface p-5 rounded-lg border border-text-tertiary/20">
|
||||
<h3 class="text-lg font-semibold text-primary mb-2">Python Project Template</h3>
|
||||
<p class="text-text-secondary text-sm">
|
||||
Opinionated template for Python projects: uv, ruff, mypy, pytest, Docker, Portainer.
|
||||
CI/CD with Gitea Actions.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="bg-surface p-5 rounded-lg border border-text-tertiary/20">
|
||||
<h3 class="text-lg font-semibold text-primary mb-2">Hermes Stack</h3>
|
||||
<p class="text-text-secondary text-sm">
|
||||
Hermes Agent gateway stack for QNAP/Portainer. Automation with AI agents
|
||||
for infrastructure management.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="bg-surface p-5 rounded-lg border border-text-tertiary/20">
|
||||
<h3 class="text-lg font-semibold text-primary mb-2">Other Experiments</h3>
|
||||
<p class="text-text-secondary text-sm">
|
||||
Discord bots (iRacing), automation tools (Portainer backups),
|
||||
utility scripts (FLAC tagging, cookie extraction), and more.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="mb-12">
|
||||
<h2 class="text-3xl font-semibold mb-4">Self-Hosted Infrastructure</h2>
|
||||
<p class="text-text-secondary leading-relaxed mb-4">
|
||||
All my projects run on local self-hosted infrastructure. I prefer to have complete
|
||||
control over the stack and keep costs low while experimenting.
|
||||
</p>
|
||||
<div class="bg-surface p-6 rounded-lg border border-primary/20">
|
||||
<h3 class="text-xl font-semibold mb-4 text-primary">Current Stack</h3>
|
||||
<ul class="space-y-2 text-text-secondary">
|
||||
<li class="flex items-start">
|
||||
<span class="text-primary mr-2">→</span>
|
||||
<span><strong class="text-text-primary">Local physical server</strong> -
|
||||
Main Docker host</span>
|
||||
</li>
|
||||
<li class="flex items-start">
|
||||
<span class="text-primary mr-2">→</span>
|
||||
<span><strong class="text-text-primary">Portainer</strong> - Stack and container
|
||||
orchestration</span>
|
||||
</li>
|
||||
<li class="flex items-start">
|
||||
<span class="text-primary mr-2">→</span>
|
||||
<span><strong class="text-text-primary">Gitea</strong> -
|
||||
Git hosting + Docker registry + CI/CD</span>
|
||||
</li>
|
||||
<li class="flex items-start">
|
||||
<span class="text-primary mr-2">→</span>
|
||||
<span><strong class="text-text-primary">MinIO</strong> - Object storage (S3-compatible)</span>
|
||||
</li>
|
||||
<li class="flex items-start">
|
||||
<span class="text-primary mr-2">→</span>
|
||||
<span><strong class="text-text-primary">Nginx Proxy Manager</strong> - Reverse proxy
|
||||
with automatic SSL (Let's Encrypt)</span>
|
||||
</li>
|
||||
<li class="flex items-start">
|
||||
<span class="text-primary mr-2">→</span>
|
||||
<span><strong class="text-text-primary">PostgreSQL</strong> - Main database
|
||||
for applications</span>
|
||||
</li>
|
||||
</ul>
|
||||
<p class="text-text-secondary text-sm mt-4">
|
||||
Fully automated CI/CD: push to main → build image → push to registry →
|
||||
deploy to Portainer. Everything on local infrastructure.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="mb-12">
|
||||
<h2 class="text-3xl font-semibold mb-4">Why Build in Public</h2>
|
||||
<ul class="space-y-3">
|
||||
<li class="flex items-start">
|
||||
<span class="text-primary mr-2">→</span>
|
||||
<span class="text-text-secondary">
|
||||
<strong class="text-text-primary">Accountability:</strong> It's harder to quit
|
||||
when you share progress publicly
|
||||
</span>
|
||||
</li>
|
||||
<li class="flex items-start">
|
||||
<span class="text-primary mr-2">→</span>
|
||||
<span class="text-text-secondary">
|
||||
<strong class="text-text-primary">Teaching:</strong> I've always been an instructor
|
||||
(LinkedIn Learning, Podcast CulturaNAS 2000+ subs). Sharing what I learn is part of who I am
|
||||
</span>
|
||||
</li>
|
||||
<li class="flex items-start">
|
||||
<span class="text-primary mr-2">→</span>
|
||||
<span class="text-text-secondary">
|
||||
<strong class="text-text-primary">Community:</strong> I want to connect with other
|
||||
Spanish builders on the same journey
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="mb-12">
|
||||
<h2 class="text-3xl font-semibold mb-4">Tech Stack</h2>
|
||||
<div class="grid grid-cols-2 md:grid-cols-3 gap-4 not-prose">
|
||||
<div class="bg-surface px-4 py-3 rounded-lg border border-text-tertiary/20">Python</div>
|
||||
<div class="bg-surface px-4 py-3 rounded-lg border border-text-tertiary/20">FastAPI</div>
|
||||
<div class="bg-surface px-4 py-3 rounded-lg border border-text-tertiary/20">React</div>
|
||||
<div class="bg-surface px-4 py-3 rounded-lg border border-text-tertiary/20">TypeScript</div>
|
||||
<div class="bg-surface px-4 py-3 rounded-lg border border-text-tertiary/20">PostgreSQL</div>
|
||||
<div class="bg-surface px-4 py-3 rounded-lg border border-text-tertiary/20">Docker</div>
|
||||
<div class="bg-surface px-4 py-3 rounded-lg border border-text-tertiary/20">Tailwind CSS</div>
|
||||
<div class="bg-surface px-4 py-3 rounded-lg border border-text-tertiary/20">Astro</div>
|
||||
<div class="bg-surface px-4 py-3 rounded-lg border border-text-tertiary/20">n8n</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="mb-12">
|
||||
<h2 class="text-3xl font-semibold mb-4">Experience</h2>
|
||||
<div class="space-y-6">
|
||||
<div class="bg-surface p-6 rounded-lg border border-text-tertiary/20">
|
||||
<div class="flex items-start justify-between mb-2">
|
||||
<h3 class="text-xl font-semibold text-primary">Field Application Engineer</h3>
|
||||
<span class="text-sm text-text-tertiary whitespace-nowrap ml-4">2023 - Present</span>
|
||||
</div>
|
||||
<p class="text-text-secondary mb-2">KEO Connectivity (Germany)</p>
|
||||
<p class="text-text-secondary text-sm">
|
||||
EEBUS protocol for IoT energy systems (e-mobility, smart homes,
|
||||
heat pumps). Technical documentation, automation with n8n, internal tools,
|
||||
technical support.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="bg-surface p-6 rounded-lg border border-text-tertiary/20">
|
||||
<div class="flex items-start justify-between mb-2">
|
||||
<h3 class="text-xl font-semibold text-primary">Python Developer</h3>
|
||||
<span class="text-sm text-text-tertiary whitespace-nowrap ml-4">2022 - 2023</span>
|
||||
</div>
|
||||
<p class="text-text-secondary mb-2">NTT Data</p>
|
||||
<p class="text-text-secondary text-sm">
|
||||
Python development, Google Cloud, Kubernetes, BigQuery. Data analysis and BigData.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="bg-surface p-6 rounded-lg border border-text-tertiary/20">
|
||||
<div class="flex items-start justify-between mb-2">
|
||||
<h3 class="text-xl font-semibold text-primary">ICT Instructor + Developer</h3>
|
||||
<span class="text-sm text-text-tertiary whitespace-nowrap ml-4">2013 - 2022</span>
|
||||
</div>
|
||||
<p class="text-text-secondary mb-2">Qloudea + LinkedIn Learning</p>
|
||||
<p class="text-text-secondary text-sm">
|
||||
Development of internal tools (HTML5, CSS, JavaScript, Python, Django, Flutter).
|
||||
Official instructor on NAS servers for professionals. Social media management, YouTube,
|
||||
product photography.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="bg-surface p-6 rounded-lg border border-text-tertiary/20">
|
||||
<div class="flex items-start justify-between mb-2">
|
||||
<h3 class="text-xl font-semibold text-primary">Professional Photographer</h3>
|
||||
<span class="text-sm text-text-tertiary whitespace-nowrap ml-4">2003 - 2016</span>
|
||||
</div>
|
||||
<p class="text-text-secondary mb-2">Own Brand</p>
|
||||
<p class="text-text-secondary text-sm">
|
||||
13 years managing my own photography brand. Creator of CulturaNAS Podcast
|
||||
(2000+ subscribers).
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 class="text-3xl font-semibold mb-4">Connect With Me</h2>
|
||||
<p class="text-text-secondary mb-6">
|
||||
Always open to talking with other builders, people interested in EEBUS/IoT,
|
||||
or just curious people about building products.
|
||||
</p>
|
||||
<div class="flex flex-wrap gap-4 not-prose">
|
||||
<a
|
||||
href="https://twitter.com/davidaragon"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="bg-primary/10 hover:bg-primary/20 text-primary px-6 py-3 rounded-lg
|
||||
font-medium transition-colors border border-primary/20"
|
||||
>
|
||||
X (Twitter)
|
||||
</a>
|
||||
<a
|
||||
href="https://linkedin.com/in/david-aragon-galiana"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="bg-primary/10 hover:bg-primary/20 text-primary px-6 py-3 rounded-lg
|
||||
font-medium transition-colors border border-primary/20"
|
||||
>
|
||||
LinkedIn
|
||||
</a>
|
||||
<a
|
||||
href="https://github.com/davidaragon"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="bg-primary/10 hover:bg-primary/20 text-primary px-6 py-3 rounded-lg
|
||||
font-medium transition-colors border border-primary/20"
|
||||
>
|
||||
GitHub
|
||||
</a>
|
||||
<a
|
||||
href="mailto:david@impresion3d.pro"
|
||||
class="bg-secondary/10 hover:bg-secondary/20 text-secondary px-6 py-3 rounded-lg
|
||||
font-medium transition-colors border border-secondary/20"
|
||||
>
|
||||
Email
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
</article>
|
||||
</main>
|
||||
<Footer />
|
||||
</BaseLayout>
|
||||
@@ -0,0 +1,65 @@
|
||||
---
|
||||
import { getCollection } from 'astro:content';
|
||||
import BaseLayout from '../../layouts/BaseLayout.astro';
|
||||
import Header from '../../components/layout/Header.astro';
|
||||
import Footer from '../../components/layout/Footer.astro';
|
||||
|
||||
export async function getStaticPaths() {
|
||||
const blogEntries = await getCollection('blog', ({ data }) => !data.draft);
|
||||
return blogEntries.map(entry => ({
|
||||
params: { slug: entry.slug },
|
||||
props: { entry },
|
||||
}));
|
||||
}
|
||||
|
||||
const { entry } = Astro.props;
|
||||
const { Content } = await entry.render();
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title={entry.data.title}
|
||||
description={entry.data.description}
|
||||
>
|
||||
<Header />
|
||||
<main class="flex-1 max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
||||
<article>
|
||||
<header class="mb-8">
|
||||
<time class="text-sm text-text-tertiary block mb-2">
|
||||
{entry.data.publishDate.toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
})}
|
||||
</time>
|
||||
|
||||
<h1 class="text-4xl font-bold mb-4">{entry.data.title}</h1>
|
||||
|
||||
<p class="text-xl text-text-secondary mb-4">
|
||||
{entry.data.description}
|
||||
</p>
|
||||
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{entry.data.tags.map((tag) => (
|
||||
<span class="inline-block px-3 py-1 rounded-full text-sm font-medium border bg-text-tertiary/10 text-text-secondary border-text-tertiary/20">
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="prose prose-invert prose-lg max-w-none">
|
||||
<Content />
|
||||
</div>
|
||||
|
||||
<footer class="mt-12 pt-8 border-t border-text-tertiary/20">
|
||||
<a
|
||||
href="/blog"
|
||||
class="text-primary hover:text-secondary transition-colors font-medium"
|
||||
>
|
||||
← Back to all posts
|
||||
</a>
|
||||
</footer>
|
||||
</article>
|
||||
</main>
|
||||
<Footer />
|
||||
</BaseLayout>
|
||||
@@ -0,0 +1,62 @@
|
||||
---
|
||||
import { getCollection } from 'astro:content';
|
||||
import BaseLayout from '../../layouts/BaseLayout.astro';
|
||||
import Header from '../../components/layout/Header.astro';
|
||||
import Footer from '../../components/layout/Footer.astro';
|
||||
|
||||
const allPosts = await getCollection('blog', ({ data }) => !data.draft);
|
||||
const sortedPosts = allPosts.sort(
|
||||
(a, b) => b.data.publishDate.valueOf() - a.data.publishDate.valueOf()
|
||||
);
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title="Blog"
|
||||
description="Thoughts on building products, technical learnings, and the indie builder journey"
|
||||
>
|
||||
<Header />
|
||||
<main class="flex-1 max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
||||
<h1 class="text-4xl font-bold mb-8">Blog</h1>
|
||||
|
||||
<div class="space-y-8">
|
||||
{sortedPosts.map((post) => (
|
||||
<article class="bg-surface rounded-lg border border-text-tertiary/20 p-6 hover:border-primary/40 transition-colors">
|
||||
<a href={`/blog/${post.slug}`} class="block">
|
||||
<time class="text-sm text-text-tertiary block mb-2">
|
||||
{post.data.publishDate.toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
})}
|
||||
</time>
|
||||
|
||||
<h2 class="text-2xl font-semibold mb-3 text-text-primary hover:text-primary transition-colors">
|
||||
{post.data.title}
|
||||
</h2>
|
||||
|
||||
<p class="text-text-secondary mb-4">
|
||||
{post.data.description}
|
||||
</p>
|
||||
|
||||
<div class="flex flex-wrap gap-2 mb-4">
|
||||
{post.data.tags.map((tag) => (
|
||||
<span class="inline-block px-3 py-1 rounded-full text-sm font-medium border bg-text-tertiary/10 text-text-secondary border-text-tertiary/20">
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<span class="text-primary font-medium">Read more →</span>
|
||||
</a>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{sortedPosts.length === 0 && (
|
||||
<p class="text-text-secondary text-center py-12">
|
||||
No posts yet. Check back soon!
|
||||
</p>
|
||||
)}
|
||||
</main>
|
||||
<Footer />
|
||||
</BaseLayout>
|
||||
+188
-14
@@ -1,17 +1,191 @@
|
||||
---
|
||||
import BaseLayout from '../layouts/BaseLayout.astro';
|
||||
import Header from '../components/layout/Header.astro';
|
||||
import Footer from '../components/layout/Footer.astro';
|
||||
import Card from '../components/ui/Card.astro';
|
||||
import Tag from '../components/ui/Tag.astro';
|
||||
import { getCollection } from 'astro:content';
|
||||
import { formatDate } from '../utils/dateFormat';
|
||||
|
||||
// Fetch featured projects and recent blog posts
|
||||
const projects = (await getCollection('projects'))
|
||||
.filter(p => !p.data.draft && p.data.featured)
|
||||
.slice(0, 3);
|
||||
|
||||
const blogPosts = (await getCollection('blog'))
|
||||
.filter(p => !p.data.draft)
|
||||
.sort((a, b) => b.data.publishDate.getTime() - a.data.publishDate.getTime())
|
||||
.slice(0, 3);
|
||||
---
|
||||
|
||||
---
|
||||
<BaseLayout
|
||||
title="Home"
|
||||
description="David Aragón's portfolio - Builder creating products and sharing the journey"
|
||||
>
|
||||
<Header />
|
||||
|
||||
<main class="flex-1">
|
||||
<!-- Hero Section -->
|
||||
<section class="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-20">
|
||||
<div class="flex flex-col md:flex-row items-center gap-12">
|
||||
<div class="flex-shrink-0">
|
||||
<img
|
||||
src="/david-aragon.jpg"
|
||||
alt="David Aragón"
|
||||
class="w-48 h-48 md:w-64 md:h-64 rounded-full object-cover border-4 border-primary/20"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex-1 text-center md:text-left">
|
||||
<h1 class="text-4xl md:text-5xl font-bold mb-6">
|
||||
Hi, I'm <span class="text-primary">David Aragón</span>
|
||||
</h1>
|
||||
<p class="text-xl text-text-secondary mb-6 leading-relaxed">
|
||||
Builder creating products that solve real problems.
|
||||
From goldsmith to photographer to developer. Now building in public.
|
||||
</p>
|
||||
<p class="text-lg text-text-secondary mb-8">
|
||||
Currently working on <strong class="text-primary">WarrantyHub</strong> -
|
||||
digital warranty management for home users.
|
||||
</p>
|
||||
<div class="flex flex-wrap justify-center md:justify-start gap-4">
|
||||
<a
|
||||
href="/blog"
|
||||
class="bg-primary hover:bg-primary/80 text-background px-8 py-3 rounded-lg
|
||||
font-semibold transition-colors"
|
||||
>
|
||||
Read Blog
|
||||
</a>
|
||||
<a
|
||||
href="/about"
|
||||
class="bg-surface hover:bg-surface/80 text-text-primary px-8 py-3 rounded-lg
|
||||
font-semibold border border-text-tertiary/20 transition-colors"
|
||||
>
|
||||
About Me
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
<meta name="viewport" content="width=device-width" />
|
||||
<meta name="generator" content={Astro.generator} />
|
||||
<title>Astro</title>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Astro</h1>
|
||||
</body>
|
||||
</html>
|
||||
<!-- Latest Blog Posts -->
|
||||
<section class="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-16">
|
||||
<div class="flex items-center justify-between mb-8">
|
||||
<h2 class="text-3xl font-bold">Latest Posts</h2>
|
||||
<a
|
||||
href="/blog"
|
||||
class="text-primary hover:text-secondary transition-colors font-medium"
|
||||
>
|
||||
View all →
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{blogPosts.length > 0 ? (
|
||||
blogPosts.map((post) => (
|
||||
<Card href={`/blog/${post.slug}`}>
|
||||
<div class="mb-4">
|
||||
<time class="text-sm text-text-tertiary">
|
||||
{formatDate(post.data.publishDate)}
|
||||
</time>
|
||||
</div>
|
||||
<h3 class="text-xl font-semibold mb-3 text-text-primary group-hover:text-primary transition-colors">
|
||||
{post.data.title}
|
||||
</h3>
|
||||
<p class="text-text-secondary mb-4 line-clamp-3">
|
||||
{post.data.description}
|
||||
</p>
|
||||
<div class="flex flex-wrap gap-2 mb-4">
|
||||
{post.data.tags?.slice(0, 3).map((tag: string) => (
|
||||
<Tag>{tag}</Tag>
|
||||
))}
|
||||
</div>
|
||||
<div class="text-sm">
|
||||
<span class="text-primary font-medium">Read more →</span>
|
||||
</div>
|
||||
</Card>
|
||||
))
|
||||
) : (
|
||||
<div class="col-span-full text-center py-12">
|
||||
<p class="text-text-secondary">
|
||||
Posts coming soon. Subscribe to receive updates.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Featured Projects (if any) -->
|
||||
{projects.length > 0 && (
|
||||
<section class="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-16">
|
||||
<div class="flex items-center justify-between mb-8">
|
||||
<h2 class="text-3xl font-bold">Projects</h2>
|
||||
<a
|
||||
href="/projects"
|
||||
class="text-primary hover:text-secondary transition-colors font-medium"
|
||||
>
|
||||
View all →
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{projects.map((project) => (
|
||||
<Card href={`/projects/${project.slug}`}>
|
||||
<div class="mb-4">
|
||||
<span class="text-sm text-text-tertiary capitalize">
|
||||
{project.data.status}
|
||||
</span>
|
||||
</div>
|
||||
<h3 class="text-xl font-semibold mb-3 text-text-primary group-hover:text-primary transition-colors">
|
||||
{project.data.title}
|
||||
</h3>
|
||||
<p class="text-text-secondary mb-4">
|
||||
{project.data.description}
|
||||
</p>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{project.data.tags?.slice(0, 4).map((tag: string) => (
|
||||
<Tag>{tag}</Tag>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<!-- Newsletter CTA -->
|
||||
<section class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-16">
|
||||
<div class="bg-gradient-to-r from-primary/10 to-secondary/10 border border-primary/20
|
||||
rounded-2xl p-8 md:p-12 text-center">
|
||||
<h2 class="text-3xl font-bold mb-4">Stay Updated</h2>
|
||||
<p class="text-text-secondary text-lg mb-8 max-w-2xl mx-auto">
|
||||
Get occasional updates about launches, learnings, and technical decisions.
|
||||
No spam, no BS - just real builder content.
|
||||
</p>
|
||||
<form
|
||||
class="max-w-md mx-auto flex flex-col sm:flex-row gap-4"
|
||||
method="POST"
|
||||
action="/api/subscribe"
|
||||
>
|
||||
<input
|
||||
type="email"
|
||||
name="email"
|
||||
placeholder="your@email.com"
|
||||
required
|
||||
class="flex-1 px-6 py-3 rounded-lg bg-surface border border-text-tertiary/20
|
||||
text-text-primary placeholder-text-tertiary focus:outline-none
|
||||
focus:border-primary transition-colors"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
class="bg-primary hover:bg-primary/80 text-background px-8 py-3 rounded-lg
|
||||
font-semibold transition-colors whitespace-nowrap"
|
||||
>
|
||||
Subscribe
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
</BaseLayout>
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
---
|
||||
import { getCollection } from 'astro:content';
|
||||
import ProjectLayout from '../../layouts/ProjectLayout.astro';
|
||||
|
||||
export async function getStaticPaths() {
|
||||
const projects = await getCollection('projects');
|
||||
return projects
|
||||
.filter(p => !p.data.draft)
|
||||
.map(project => ({
|
||||
params: { slug: project.slug },
|
||||
props: { project },
|
||||
}));
|
||||
}
|
||||
|
||||
const { project } = Astro.props;
|
||||
const { Content } = await project.render();
|
||||
---
|
||||
|
||||
<ProjectLayout {...project.data}>
|
||||
<Content />
|
||||
</ProjectLayout>
|
||||
@@ -0,0 +1,127 @@
|
||||
---
|
||||
import BaseLayout from '../../layouts/BaseLayout.astro';
|
||||
import Header from '../../components/layout/Header.astro';
|
||||
import Footer from '../../components/layout/Footer.astro';
|
||||
import Card from '../../components/ui/Card.astro';
|
||||
import Tag from '../../components/ui/Tag.astro';
|
||||
import { getCollection } from 'astro:content';
|
||||
|
||||
const allProjects = (await getCollection('projects'))
|
||||
.filter(p => !p.data.draft)
|
||||
.sort((a, b) => b.data.startDate.getTime() - a.data.startDate.getTime());
|
||||
|
||||
const featured = allProjects.filter(p => p.data.featured);
|
||||
const active = allProjects.filter(p => p.data.status === 'active' && !p.data.featured);
|
||||
const others = allProjects.filter(p => p.data.status !== 'active' && !p.data.featured);
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title="Proyectos"
|
||||
description="Portfolio de productos SaaS: WarrantyHub, Garage61 API, y más proyectos en desarrollo"
|
||||
>
|
||||
<Header />
|
||||
<main class="flex-1 max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
||||
<header class="mb-12">
|
||||
<h1 class="text-4xl font-bold mb-4">Proyectos</h1>
|
||||
<p class="text-xl text-text-secondary max-w-3xl">
|
||||
Portfolio de productos SaaS construidos en público. Desde ideas hasta
|
||||
productos escalables generando revenue recurrente.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{featured.length > 0 && (
|
||||
<section class="mb-16">
|
||||
<h2 class="text-2xl font-semibold mb-6 text-primary">🌟 Proyectos Destacados</h2>
|
||||
<div class="grid md:grid-cols-2 gap-6">
|
||||
{featured.map(project => (
|
||||
<Card href={`/projects/${project.slug}`} class="flex flex-col">
|
||||
{project.data.image && (
|
||||
<img
|
||||
src={project.data.image}
|
||||
alt={project.data.title}
|
||||
class="w-full h-48 object-cover rounded-t-lg -m-6 mb-4"
|
||||
/>
|
||||
)}
|
||||
<h3 class="text-2xl font-semibold mb-2 text-text-primary">
|
||||
{project.data.title}
|
||||
</h3>
|
||||
<p class="text-text-secondary mb-4 flex-1">
|
||||
{project.data.description}
|
||||
</p>
|
||||
<div class="flex flex-wrap gap-2 mb-4">
|
||||
{project.data.tags.slice(0, 4).map(tag => (
|
||||
<Tag label={tag} variant="primary" />
|
||||
))}
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm text-accent-green font-medium capitalize">
|
||||
{project.data.status}
|
||||
</span>
|
||||
<span class="text-primary text-sm font-medium">
|
||||
Ver proyecto →
|
||||
</span>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{active.length > 0 && (
|
||||
<section class="mb-16">
|
||||
<h2 class="text-2xl font-semibold mb-6">Proyectos Activos</h2>
|
||||
<div class="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{active.map(project => (
|
||||
<Card href={`/projects/${project.slug}`}>
|
||||
<h3 class="text-xl font-semibold mb-2 text-text-primary">
|
||||
{project.data.title}
|
||||
</h3>
|
||||
<p class="text-text-secondary mb-4">
|
||||
{project.data.description}
|
||||
</p>
|
||||
<div class="flex flex-wrap gap-2 mb-4">
|
||||
{project.data.tags.slice(0, 3).map(tag => (
|
||||
<Tag label={tag} variant="neutral" />
|
||||
))}
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm text-accent-green font-medium">Activo</span>
|
||||
<span class="text-primary text-sm font-medium">Ver →</span>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{others.length > 0 && (
|
||||
<section>
|
||||
<h2 class="text-2xl font-semibold mb-6">Otros Proyectos</h2>
|
||||
<div class="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{others.map(project => (
|
||||
<Card href={`/projects/${project.slug}`}>
|
||||
<h3 class="text-xl font-semibold mb-2 text-text-primary">
|
||||
{project.data.title}
|
||||
</h3>
|
||||
<p class="text-text-secondary mb-4">
|
||||
{project.data.description}
|
||||
</p>
|
||||
<div class="flex flex-wrap gap-2 mb-4">
|
||||
{project.data.tags.slice(0, 3).map(tag => (
|
||||
<Tag label={tag} variant="neutral" />
|
||||
))}
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm text-text-tertiary capitalize">
|
||||
{project.data.status}
|
||||
</span>
|
||||
<span class="text-primary text-sm font-medium">Ver →</span>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</main>
|
||||
<Footer />
|
||||
</BaseLayout>
|
||||
@@ -0,0 +1,7 @@
|
||||
export function formatDate(date: Date): string {
|
||||
return new Intl.DateTimeFormat('es-ES', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
}).format(date);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export function getReadingTime(content: string): string {
|
||||
const wordsPerMinute = 200;
|
||||
const words = content.trim().split(/\s+/).length;
|
||||
const minutes = Math.ceil(words / wordsPerMinute);
|
||||
return `${minutes} min lectura`;
|
||||
}
|
||||
Reference in New Issue
Block a user