Compare commits

..

33 Commits

Author SHA1 Message Date
wh-leader 4ab6633cc2 feat: add Modbus TCP vs EEBUS SPINE technical comparison blog post
CI/CD Pipeline / Build & Deploy (push) Successful in 23s
2026-05-11 14:48:46 +02:00
wh-leader 904684e6b8 fix: remove TimeNet CLI from About page (internal tool)
CI/CD Pipeline / Build & Deploy (push) Successful in 24s
2026-05-11 14:04:29 +02:00
wh-leader 54cad6684c fix: add blog pages and fix project schema, remove internal TimeNet CLI
CI/CD Pipeline / Build & Deploy (push) Successful in 23s
2026-05-11 14:02:08 +02:00
wh-leader 3f7e54c72e feat: add Python Project Template and TimeNet CLI projects
CI/CD Pipeline / Build & Deploy (push) Failing after 21s
2026-05-11 13:58:40 +02:00
wh-leader 0b7021f827 feat: translate About page to English
CI/CD Pipeline / Build & Deploy (push) Successful in 21s
2026-05-11 13:57:05 +02:00
wh-leader 21a63b6947 fix(ci): remove Portainer deployment step (runner network isolated)
CI/CD Pipeline / Build & Deploy (push) Successful in 23s
2026-05-11 13:49:10 +02:00
wh-leader c514b3bce1 fix: remove empty github field causing Astro build error
CI/CD Pipeline / Build & Deploy (push) Failing after 2m32s
2026-05-11 13:42:15 +02:00
wh-leader 58f0a00d4c fix(ci): use plain docker build instead of buildx (runner /dev/null issue)
CI/CD Pipeline / Build & Deploy (push) Failing after 17s
2026-05-11 13:33:30 +02:00
wh-leader 83845f4894 fix(ci): use exact WarrantyHub working pattern with Docker actions
CI/CD Pipeline / Build & Deploy (push) Failing after 36s
2026-05-11 13:14:46 +02:00
wh-leader 7e90812373 fix(ci): use GITEATOKEN secret name
CI/CD Pipeline / Build & Deploy (push) Failing after 15s
2026-05-11 13:13:41 +02:00
wh-leader 1037f6a4ed fix(ci): add auth token to git clone + consolidate steps
CI/CD Pipeline / Build & Deploy (push) Failing after 16s
2026-05-11 13:13:12 +02:00
wh-leader 8176b0b09b fix(ci): use plain docker + git instead of GitHub actions
CI/CD Pipeline / Build & Deploy (push) Failing after 28s
2026-05-11 13:09:48 +02:00
wh-leader 5937425872 trigger: test CI with Portainer secrets
CI/CD Pipeline / Build & Deploy (push) Failing after 43s
2026-05-11 13:08:12 +02:00
wh-leader 42873ad9cc fix(ci): force image pull via Portainer API (stop + pull + start)
CI/CD Pipeline / Build & Deploy (push) Failing after 41s
2026-05-11 13:06:15 +02:00
wh-leader 35f3ba8767 fix: use exact working CI pattern from WarrantyHub
CI/CD Pipeline / Build & Deploy (push) Failing after 42s
2026-05-11 13:03:04 +02:00
wh-leader ce8415c26f security: remove IP addresses and internal hostnames from About page
CI/CD Pipeline / Build & Deploy (push) Failing after 43s
2026-05-11 12:57:05 +02:00
wh-leader 8cd0b2fa24 feat: complete content overhaul - projects, infrastructure, personal brand (no empire talk)
CI/CD Pipeline / Build & Deploy (push) Failing after 42s
2026-05-11 12:55:24 +02:00
wh-leader 8a30951a64 refactor: homepage with personal brand focus (no empire talk) + profile photo
CI/CD Pipeline / Build & Deploy (push) Failing after 47s
2026-05-11 12:51:14 +02:00
wh-leader f93ae22b77 feat: add first blog post - De Orfebre a Builder
CI/CD Pipeline / Build & Deploy (push) Failing after 47s
2026-05-11 12:39:48 +02:00
wh-leader 65f9defcdd remove: delete blog post with too much strategic detail
CI/CD Pipeline / Build & Deploy (push) Failing after 45s
2026-05-11 12:32:00 +02:00
wh-leader 15c946c0a1 feat: add first blog post - Arrancando el Viaje
CI/CD Pipeline / Build & Deploy (push) Failing after 45s
2026-05-11 12:29:43 +02:00
wh-leader ada08a31be feat: add David profile photo to About page
CI/CD Pipeline / Build & Deploy (push) Failing after 48s
2026-05-11 12:25:30 +02:00
wh-leader d11c9a6a1e feat: add Portainer production compose (stack ID 139)
CI/CD Pipeline / Build & Deploy (push) Failing after 51s
2026-05-11 12:15:30 +02:00
wh-leader 259fef1f95 fix: install all dependencies including devDependencies for build
CI/CD Pipeline / Build & Deploy (push) Failing after 49s
2026-05-11 12:04:09 +02:00
wh-leader 47bf985a79 fix: use official Docker actions like WarrantyHub workflow
CI/CD Pipeline / Build & Deploy (push) Failing after 41s
2026-05-11 11:25:29 +02:00
wh-leader 8d85589f0c fix: use direct Docker build to avoid runner filesystem issues
CI/CD Pipeline / Build & Deploy (push) Failing after 24s
Gitea Actions runner has filesystem mount issue preventing Node.js extraction.
Solution: Build entire image with Docker (includes Node) instead of setup-node action.
Simpler and more reliable - build happens inside container.
2026-05-11 09:32:53 +02:00
wh-leader c31692bae0 fix: remove npm cache from Gitea Actions
CI/CD Pipeline / Build Astro Site (push) Failing after 22s
CI/CD Pipeline / Deploy to Portainer (push) Has been skipped
Gitea Actions may not support npm cache like GitHub Actions does.
Simplify to just node version.
2026-05-11 09:29:28 +02:00
wh-leader 695dad2770 ci: add production docker-compose for Portainer stack
CI/CD Pipeline / Build Astro Site (push) Failing after 19s
CI/CD Pipeline / Deploy to Portainer (push) Has been skipped
Uses registry image instead of build context.
CI will push to gitlab.impresion3d.pro/root/davidaragon-portfolio:latest
2026-05-11 09:28:41 +02:00
wh-leader 01ce85a140 fix: docker-compose version must be quoted string for Portainer
CI/CD Pipeline / Build Astro Site (push) Failing after 21s
CI/CD Pipeline / Deploy to Portainer (push) Has been skipped
2026-05-11 09:27:04 +02:00
wh-leader e9be93f24d docs: add CI/CD setup instructions
CI/CD Pipeline / Build Astro Site (push) Failing after 1m8s
CI/CD Pipeline / Deploy to Portainer (push) Has been skipped
2026-05-11 09:24:08 +02:00
wh-leader cc7043148a ci: add Gitea Actions CI/CD with Portainer deployment
CI/CD Pipeline / Build Astro Site (push) Failing after 1m8s
CI/CD Pipeline / Deploy to Portainer (push) Has been skipped
Add complete CI/CD pipeline:
- Gitea Actions workflow (build + deploy)
- Multi-stage Dockerfile (Node build + nginx serve)
- nginx config with SPA routing and cache headers
- docker-compose.yml for local testing
- .dockerignore to optimize build

Pipeline flow:
1. Build job: npm ci + npm build + upload artifact
2. Deploy job (main only): Docker build + push to registry + Portainer webhook

Requires Gitea secrets:
- DOCKER_USERNAME
- DOCKER_PASSWORD
- PORTAINER_WEBHOOK_URL
2026-05-11 09:23:29 +02:00
wh-leader 600e9ac3b4 feat: add homepage, content collections, and utils
- Homepage with hero, featured projects, latest posts sections
- Content collections config (blog + projects schemas)
- Date formatting and reading time utilities
- Sample blog post and project for validation
2026-05-11 07:43:04 +02:00
wh-leader 05036766e4 feat: recover portfolio pages from scratch workspaces
- Add base layouts (BaseLayout, BlogLayout, ProjectLayout)
- Add UI components (Header, Footer, Navigation, Card, Tag)
- Add About page with personal story
- Add Projects pages (index, detail)
- Add homepage content
- Add SEO files (robots.txt, webmanifest)

Work was done by agents in isolated workspaces.
Consolidated into main repo for proper git tracking.
2026-05-11 07:41:22 +02:00
36 changed files with 1684 additions and 14 deletions
+11
View File
@@ -0,0 +1,11 @@
node_modules
.git
.gitignore
.vscode
.idea
*.md
!README.md
.env
.env.*
dist
.astro
+18
View File
@@ -0,0 +1,18 @@
name: CI/CD Pipeline
on:
push:
branches: [main]
jobs:
deploy:
name: Build & Deploy
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Build and push Docker image
run: |
docker login gitlab.impresion3d.pro -u "${{ secrets.DOCKER_USERNAME }}" -p "${{ secrets.DOCKER_PASSWORD }}"
docker build -t gitlab.impresion3d.pro/root/davidaragon-portfolio:latest .
docker push gitlab.impresion3d.pro/root/davidaragon-portfolio:latest
+145
View File
@@ -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
View File
@@ -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;"]
+10
View File
@@ -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"
+17
View File
@@ -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
+51
View File
@@ -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

+1
View File
@@ -0,0 +1 @@
Placeholder for favicon - use favicon.io or other generator to create a comprehensive favicon set.
+1
View File
@@ -0,0 +1 @@
Placeholder for avatar - replace with real photo
+1
View File
@@ -0,0 +1 @@
Placeholder for OG image - replace with a 1200x630px image
+8
View File
@@ -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/
+21
View File
@@ -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"
}
]
}
+39
View File
@@ -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>
+14
View File
@@ -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>
+24
View File
@@ -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>
+17
View File
@@ -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>
+18
View File
@@ -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.
+26
View File
@@ -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.
+31
View File
@@ -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.
+21
View File
@@ -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.
+72
View File
@@ -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.
+49
View File
@@ -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>
+34
View File
@@ -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>
+95
View File
@@ -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>
+296
View File
@@ -0,0 +1,296 @@
---
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">
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>
+65
View File
@@ -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>
+62
View File
@@ -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
View File
@@ -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>
+21
View File
@@ -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>
+127
View File
@@ -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>
+7
View File
@@ -0,0 +1,7 @@
export function formatDate(date: Date): string {
return new Intl.DateTimeFormat('es-ES', {
year: 'numeric',
month: 'long',
day: 'numeric',
}).format(date);
}
+6
View File
@@ -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`;
}