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
This commit is contained in:
wh-leader
2026-05-11 07:43:04 +02:00
parent 05036766e4
commit 600e9ac3b4
6 changed files with 254 additions and 14 deletions
+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 };
+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.
+163 -14
View File
@@ -1,17 +1,166 @@
--- ---
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="Inicio"
description="Portfolio personal de David Aragón - Indie builder construyendo €4M ARR en productos SaaS españoles"
>
<Header />
<html lang="en"> <main class="flex-1">
<head> <!-- Hero Section -->
<meta charset="utf-8" /> <section class="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-20 text-center">
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <h1 class="text-5xl md:text-6xl font-bold mb-6">
<link rel="icon" href="/favicon.ico" /> Hola, soy <span class="text-primary">David Aragón</span>
<meta name="viewport" content="width=device-width" /> </h1>
<meta name="generator" content={Astro.generator} /> <p class="text-xl md:text-2xl text-text-secondary max-w-3xl mx-auto mb-8 leading-relaxed">
<title>Astro</title> Indie builder español construyendo en público. Trabajando hacia <strong class="text-primary">€4M ARR</strong>
</head> con un portfolio de productos SaaS enfocados en compliance y automatización.
<body> </p>
<h1>Astro</h1> <div class="flex flex-wrap justify-center gap-4">
</body> <a
</html> href="/projects"
class="bg-primary hover:bg-primary/80 text-background px-8 py-3 rounded-lg
font-semibold transition-colors"
>
Ver Proyectos
</a>
<a
href="/blog"
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"
>
Leer Blog
</a>
</div>
</section>
<!-- Featured Projects -->
<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">Proyectos Destacados</h2>
<a
href="/projects"
class="text-primary hover:text-secondary transition-colors font-medium"
>
Ver todos →
</a>
</div>
<div class="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
{projects.length > 0 ? (
projects.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="primary" />
))}
</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 proyecto →</span>
</div>
</Card>
))
) : (
<div class="col-span-full text-center py-12">
<p class="text-text-secondary">
Proyectos destacados próximamente. Mientras tanto,
<a href="/blog" class="text-primary hover:underline">lee el blog</a>.
</p>
</div>
)}
</div>
</section>
<!-- 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">Últimas Publicaciones</h2>
<a
href="/blog"
class="text-primary hover:text-secondary transition-colors font-medium"
>
Ver todas →
</a>
</div>
<div class="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
{blogPosts.length > 0 ? (
blogPosts.map(post => (
<Card href={`/blog/${post.slug}`}>
<div class="mb-3">
<Tag label={post.data.category} variant="secondary" />
</div>
<h3 class="text-xl font-semibold mb-2 text-text-primary">{post.data.title}</h3>
<p class="text-text-secondary mb-4">{post.data.description}</p>
<div class="flex items-center justify-between text-sm text-text-tertiary">
<time datetime={post.data.publishDate.toISOString()}>
{formatDate(post.data.publishDate)}
</time>
<span class="text-primary font-medium">Leer más →</span>
</div>
</Card>
))
) : (
<div class="col-span-full text-center py-12">
<p class="text-text-secondary">
Publicaciones próximamente. Suscríbete para recibir actualizaciones.
</p>
</div>
)}
</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 rounded-2xl p-8 md:p-12 text-center border border-primary/20">
<h2 class="text-3xl font-bold mb-4">Construyendo en Público</h2>
<p class="text-text-secondary mb-6 max-w-2xl mx-auto">
Suscríbete para recibir actualizaciones sobre nuevos proyectos, aprendizajes,
y transparencia total en el viaje hacia €4M ARR.
</p>
<form class="flex flex-col sm:flex-row gap-3 max-w-md mx-auto">
<input
type="email"
placeholder="tu@email.com"
required
class="flex-1 px-4 py-3 rounded-lg bg-background border border-text-tertiary/20
text-text-primary placeholder-text-tertiary focus:outline-none focus:border-primary"
/>
<button
type="submit"
class="bg-primary hover:bg-primary/80 text-background px-6 py-3 rounded-lg
font-semibold transition-colors whitespace-nowrap"
>
Suscribirse
</button>
</form>
<p class="text-xs text-text-tertiary mt-4">
Sin spam. Cancela cuando quieras.
</p>
</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`;
}