Initial commit

This commit is contained in:
2025-10-22 10:23:49 -07:00
commit ed6cf5e2af
78 changed files with 14686 additions and 0 deletions

12
.env.example Normal file
View File

@@ -0,0 +1,12 @@
# Demo: Portfolio Site - Environment Configuration
# Copy this to .env and fill in your values
# Directus Infrastructure URL
# For local: http://localhost:8055
# For Coolify: https://directus.yourdomain.com
DIRECTUS_URL=http://localhost:8055
# Public API URL (accessible from browser)
# For local: http://localhost:3000
# For Coolify: https://api.yourdomain.com
PUBLIC_API_URL=http://localhost:3000

40
.gitignore vendored Normal file
View File

@@ -0,0 +1,40 @@
# Environment files
.env
.env.local
.env.production
# Dependencies
node_modules/
**/node_modules/
# Build outputs
dist/
**/dist/
.astro/
**/.astro/
build/
**/build/
# Logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
logs/
# OS files
.DS_Store
Thumbs.db
# IDE
.vscode/
.idea/
*.swp
*.swo
# Testing
coverage/
# Temporary files
*.tmp
.cache/

243
README.md Normal file
View File

@@ -0,0 +1,243 @@
# Demo: Portfolio Site
Personal portfolio website built with Astro (frontend) and Hono (API layer), consuming Directus CMS for content management.
## Tech Stack
- **Frontend**: [Astro](https://astro.build) - Fast, modern static site framework with SSR
- **API**: [Hono](https://hono.dev) - Lightweight, ultrafast web framework for TypeScript
- **CMS**: [Directus](https://directus.io) - Headless CMS (from infrastructure layer)
- **Styling**: Tailwind CSS
## Purpose
This demo showcases:
- ✅ Building a content-driven website with modern frameworks
- ✅ Clean API layer pattern between frontend and CMS
- ✅ Server-side rendering with Astro
- ✅ TypeScript throughout the stack
- ✅ Consuming shared infrastructure (Directus)
## Architecture
```
┌──────────────────────────────────────────────┐
│ Demo: Portfolio Site │
│ Domains: portfolio.b28.dev, api.b28.dev │
├──────────────────────────────────────────────┤
│ │
│ ┌──────────────┐ ┌──────────────┐ │
│ │ Astro │─────▶│ Hono API │ │
│ │ Frontend │ │ (middleware) │ │
│ └──────────────┘ └──────────────┘ │
│ │ │ │
│ └──────────────────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────┐ │
│ │ Directus │ (external) │
│ │ CMS/API │ │
│ └──────────────┘ │
│ │
└──────────────────────────────────────────────┘
```
## Features
### Content Pages
- **Home** (`/`) - Landing page with profile info
- **Work** (`/work`) - Portfolio projects showcase
- **Blog** (`/blog`) - Blog posts from Directus
- **Demos** (`/demos`) - Catalog of all demo projects
### API Endpoints
The Hono API acts as a middleware layer:
- `GET /health` - Health check
- `GET /api/posts` - Blog posts
- `GET /api/work` - Work projects
- `GET /api/demos` - Demo catalog
- `GET /api/profile` - Profile data
## Prerequisites
- Docker and Docker Compose
- Access to Directus infrastructure (see [portfolio-infrastructure](../../infrastructure/directus))
## Quick Start
### 1. Set up Infrastructure First
Before running this demo, ensure the Directus infrastructure is running:
```bash
cd ../../infrastructure/directus
cp .env.example .env
# Edit .env with your values
docker compose up -d
```
### 2. Configure Environment
```bash
# Copy environment template
cp .env.example .env
# Edit .env
DIRECTUS_URL=http://localhost:8055 # or https://directus.b28.dev
PUBLIC_API_URL=http://localhost:3000 # or https://api.b28.dev
```
### 3. Start Demo
```bash
docker compose up -d
```
### 4. Access
- **Frontend**: http://localhost:4321
- **API**: http://localhost:3000
## Development
### Running Locally (without Docker)
#### API Development
```bash
cd api
npm install
npm run dev # Runs on http://localhost:3000
```
#### Frontend Development
```bash
cd frontend
npm install
npm run dev # Runs on http://localhost:4321
```
### Making Changes
**Frontend**:
- Edit files in `frontend/src/`
- Astro supports hot reload
**API**:
- Edit files in `api/src/`
- Hono is lightweight and fast to restart
**Content**:
- Log into Directus (https://directus.b28.dev or http://localhost:8055)
- Edit collections directly
- Changes reflect immediately via API
## Deployment to Coolify
### Prerequisites
1. Directus infrastructure must be deployed first
2. Note the Directus URL (e.g., `https://directus.b28.dev`)
### Steps
1. **Create New Resource** in Coolify
- Type: Docker Compose
- Source: Git repository (this repo)
2. **Set Environment Variables**:
```
DIRECTUS_URL=https://directus.b28.dev
PUBLIC_API_URL=https://api.b28.dev
```
3. **Configure Domains**:
- API: `api.b28.dev`
- Frontend: `portfolio.b28.dev` or `b28.dev`
4. **Deploy**
## Project Structure
```
portfolio-site/
├── api/ # Hono API backend
│ ├── src/
│ │ ├── index.ts # Main API routes
│ │ └── directus.ts # Directus client
│ ├── Dockerfile
│ └── package.json
├── frontend/ # Astro frontend
│ ├── src/
│ │ ├── pages/ # Route pages
│ │ ├── components/ # Reusable components
│ │ ├── layouts/ # Page layouts
│ │ └── lib/ # Utilities
│ ├── Dockerfile
│ └── package.json
└── docker-compose.yml # Orchestration
```
## Why This Architecture?
### Hono as Middleware
Instead of calling Directus directly from the frontend:
- ✅ **Clean separation**: Frontend doesn't need to know about Directus details
- ✅ **Data transformation**: API can reshape data for frontend needs
- ✅ **Caching**: API layer can add caching
- ✅ **Security**: Directus credentials stay server-side
- ✅ **Flexibility**: Can add business logic without touching frontend
### Astro for Frontend
- ⚡ **Fast**: Static pages with optional SSR
- 🎨 **Modern**: Component-based with Tailwind
- 📦 **Portable**: Can integrate React, Vue, Svelte if needed
- 🔍 **SEO**: Great for content-driven sites
## Troubleshooting
### API can't connect to Directus
```bash
# Check Directus is running
curl ${DIRECTUS_URL}/server/health
# Check API logs
docker compose logs api
# Verify environment variable
docker compose exec api env | grep DIRECTUS_URL
```
### Frontend won't load
```bash
# Check API is responding
curl http://localhost:3000/health
# Check frontend logs
docker compose logs frontend
# Rebuild frontend
docker compose up -d --build frontend
```
### Content not showing
1. Verify Directus has data in collections
2. Check API endpoints directly: `curl http://localhost:3000/api/posts`
3. Check browser console for errors
## Related Repositories
- [portfolio-infrastructure](../../infrastructure/directus) - Shared Directus CMS
## Built By
John Chen - [Portfolio](https://b28.dev) - [GitHub](https://git.b28.dev)
## License
This demo is for portfolio purposes. Source code is available for reference.

31
api/Dockerfile Normal file
View File

@@ -0,0 +1,31 @@
# Multi-stage build for Hono API
FROM node:20-alpine AS base
WORKDIR /app
# Install dependencies
FROM base AS deps
COPY package.json ./
RUN npm install
# Build TypeScript
FROM base AS builder
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build
# Production image
FROM base AS runner
ENV NODE_ENV=production
# Copy built files and production dependencies
COPY --from=builder /app/dist ./dist
COPY --from=deps /app/node_modules ./node_modules
COPY package.json ./
# Health check
HEALTHCHECK --interval=30s --timeout=10s --retries=3 --start-period=40s \
CMD node -e "require('http').get('http://127.0.0.1:3000/health', (r) => r.statusCode === 200 ? process.exit(0) : process.exit(1))"
EXPOSE 3000
CMD ["npm", "start"]

20
api/package.json Normal file
View File

@@ -0,0 +1,20 @@
{
"name": "astro-hono-api",
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc",
"start": "node dist/index.js"
},
"dependencies": {
"hono": "^4.0.0",
"@hono/node-server": "^1.8.0",
"@directus/sdk": "^17.0.0"
},
"devDependencies": {
"@types/node": "^20.11.0",
"tsx": "^4.7.0",
"typescript": "^5.3.0"
}
}

189
api/src/index.ts Normal file
View File

@@ -0,0 +1,189 @@
import { serve } from '@hono/node-server'
import { Hono } from 'hono'
import { cors } from 'hono/cors'
import {
getPosts,
getPost,
getProfile,
getWorkProjects,
getWorkProject,
getSkills,
getSocialLinks
} from './lib/directus.js'
const app = new Hono()
// CORS for frontend
app.use('/*', cors({
origin: [
'http://astro-hono.homelab.local',
'http://localhost:4321',
'https://b28.dev',
'https://www.b28.dev'
],
credentials: true,
}))
// Health check
app.get('/health', (c) => {
return c.json({
status: 'ok',
timestamp: new Date().toISOString(),
service: 'astro-hono-api'
})
})
// Root endpoint
app.get('/', (c) => {
return c.json({
message: 'Astro-Hono API with Directus CMS',
version: '1.0.0',
endpoints: {
health: '/health',
greeting: '/api/greeting',
user: '/api/user/:name',
posts: '/api/posts',
post: '/api/posts/:id',
profile: '/api/profile',
workProjects: '/api/work-projects',
workProject: '/api/work-projects/:slug',
skills: '/api/skills',
socialLinks: '/api/social-links'
}
})
})
// API routes
app.get('/api/greeting', (c) => {
const time = new Date().getHours()
let greeting = 'Hello'
if (time < 12) greeting = 'Good morning'
else if (time < 18) greeting = 'Good afternoon'
else greeting = 'Good evening'
return c.json({
greeting,
timestamp: new Date().toISOString()
})
})
app.get('/api/user/:name', (c) => {
const name = c.req.param('name')
return c.json({
name,
message: `Hello, ${name}!`,
timestamp: new Date().toISOString()
})
})
// Directus CMS routes
app.get('/api/posts', async (c) => {
try {
const posts = await getPosts()
return c.json({
data: posts,
count: posts.length
})
} catch (error) {
console.error('Error fetching posts:', error)
return c.json({ error: 'Failed to fetch posts' }, 500)
}
})
app.get('/api/posts/:id', async (c) => {
try {
const id = parseInt(c.req.param('id'))
if (isNaN(id)) {
return c.json({ error: 'Invalid post ID' }, 400)
}
const post = await getPost(id)
return c.json({ data: post })
} catch (error) {
console.error('Error fetching post:', error)
return c.json({ error: 'Post not found' }, 404)
}
})
// Portfolio routes
app.get('/api/profile', async (c) => {
try {
const profile = await getProfile()
return c.json({ data: profile })
} catch (error) {
console.error('Error fetching profile:', error)
return c.json({ error: 'Failed to fetch profile' }, 500)
}
})
app.get('/api/work-projects', async (c) => {
try {
const workProjects = await getWorkProjects()
return c.json({
data: workProjects,
count: workProjects.length
})
} catch (error) {
console.error('Error fetching work projects:', error)
return c.json({ error: 'Failed to fetch work projects' }, 500)
}
})
app.get('/api/work-projects/:slug', async (c) => {
try {
const slug = c.req.param('slug')
const project = await getWorkProject(slug)
if (!project) {
return c.json({ error: 'Work project not found' }, 404)
}
return c.json({ data: project })
} catch (error) {
console.error('Error fetching work project:', error)
return c.json({ error: 'Work project not found' }, 404)
}
})
app.get('/api/skills', async (c) => {
try {
const skills = await getSkills()
return c.json({
data: skills,
count: skills.length
})
} catch (error) {
console.error('Error fetching skills:', error)
return c.json({ error: 'Failed to fetch skills' }, 500)
}
})
app.get('/api/social-links', async (c) => {
try {
const socialLinks = await getSocialLinks()
return c.json({
data: socialLinks,
count: socialLinks.length
})
} catch (error) {
console.error('Error fetching social links:', error)
return c.json({ error: 'Failed to fetch social links' }, 500)
}
})
// 404 handler
app.notFound((c) => {
return c.json({ error: 'Not found' }, 404)
})
// Error handler
app.onError((err, c) => {
console.error(`Error: ${err.message}`)
return c.json({ error: 'Internal server error' }, 500)
})
const port = 3000
console.log(`🚀 API server running on port ${port}`)
serve({
fetch: app.fetch,
port
})

196
api/src/lib/directus.ts Normal file
View File

@@ -0,0 +1,196 @@
import { createDirectus, rest, readItems, readItem, readSingleton } from '@directus/sdk'
// Directus schema types
interface Post {
id: number
title: string
content: string
published: boolean
date_created: string
date_updated: string | null
}
interface Profile {
id: number
full_name: string
first_name: string
last_name: string
title: string
description: string
email: string
location?: string
portfolio_year?: string
availability?: boolean
availability_text?: string
current_role_title?: string
current_role_company?: string
current_role_duration?: string
work_section_title?: string
work_section_date_range?: string
connect_title?: string
connect_description?: string
footer_copyright?: string
footer_attribution?: string
footer_year?: string
}
interface WorkProject {
id: number
status: string
title: string
slug: string
company: string
role: string
year: string
duration?: string
description: string
content?: string
technologies: string[]
featured: boolean
order?: number
team?: string
live_url?: string
case_study_url?: string
date_created?: string
date_updated?: string | null
}
interface Skill {
id: number
name: string
category?: string
proficiency?: string
order?: number
featured: boolean
}
interface SocialLink {
id: number
name: string
handle: string
url: string
icon?: string
order?: number
visible: boolean
}
interface Schema {
posts: Post[]
profile: Profile
work_projects: WorkProject[]
skills: Skill[]
social_links: SocialLink[]
}
// Directus client configuration
const directusUrl = process.env.DIRECTUS_URL || 'http://directus:8055'
export const directus = createDirectus<Schema>(directusUrl).with(rest())
// Helper functions for common queries
// Posts
export const getPosts = async () => {
try {
return await directus.request(
readItems('posts', {
filter: {
published: { _eq: true }
},
sort: ['-date_created'],
limit: 10
})
)
} catch (error) {
console.error('Error fetching posts:', error)
throw error
}
}
export const getPost = async (id: number) => {
try {
return await directus.request(readItem('posts', id))
} catch (error) {
console.error(`Error fetching post ${id}:`, error)
throw error
}
}
// Profile (singleton)
export const getProfile = async () => {
try {
return await directus.request(readSingleton('profile'))
} catch (error) {
console.error('Error fetching profile:', error)
throw error
}
}
// Work Projects
export const getWorkProjects = async () => {
try {
return await directus.request(
readItems('work_projects', {
filter: {
status: { _eq: 'published' }
},
sort: ['order'],
limit: -1
})
)
} catch (error) {
console.error('Error fetching work projects:', error)
throw error
}
}
export const getWorkProject = async (slug: string) => {
try {
const results = await directus.request(
readItems('work_projects', {
filter: {
slug: { _eq: slug },
status: { _eq: 'published' }
},
limit: 1
})
)
return results[0] || null
} catch (error) {
console.error(`Error fetching work project ${slug}:`, error)
throw error
}
}
// Skills
export const getSkills = async () => {
try {
return await directus.request(
readItems('skills', {
sort: ['order'],
limit: -1
})
)
} catch (error) {
console.error('Error fetching skills:', error)
throw error
}
}
// Social Links
export const getSocialLinks = async () => {
try {
return await directus.request(
readItems('social_links', {
filter: {
visible: { _eq: true }
},
sort: ['order'],
limit: -1
})
)
} catch (error) {
console.error('Error fetching social links:', error)
throw error
}
}

19
api/tsconfig.json Normal file
View File

@@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ES2022",
"moduleResolution": "bundler",
"lib": ["ES2022"],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"isolatedModules": true,
"types": ["node"]
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}

63
docker-compose.yml Normal file
View File

@@ -0,0 +1,63 @@
# Demo: Portfolio Site
# Astro + Hono frontend and API consuming shared Directus infrastructure
services:
# Hono API Backend
api:
build:
context: ./api
dockerfile: Dockerfile
restart: unless-stopped
environment:
# Connect to external Directus infrastructure
DIRECTUS_URL: ${DIRECTUS_URL}
PORT: 3000
expose:
- "3000"
healthcheck:
test: ["CMD", "node", "-e", "require('http').get('http://127.0.0.1:3000/health', (r) => r.statusCode === 200 ? process.exit(0) : process.exit(1))"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
networks:
- portfolio
# Astro Frontend
frontend:
build:
context: ./frontend
dockerfile: Dockerfile
restart: unless-stopped
depends_on:
api:
condition: service_healthy
environment:
# Internal API URL (within Docker network)
API_URL: http://api:3000
# Public API URL (for browser requests)
PUBLIC_API_URL: ${PUBLIC_API_URL}
expose:
- "4321"
healthcheck:
test: ["CMD", "node", "-e", "require('http').get('http://127.0.0.1:4321/', (r) => r.statusCode === 200 ? process.exit(0) : process.exit(1))"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
networks:
- portfolio
networks:
portfolio:
name: portfolio
external: false

34
frontend/Dockerfile Normal file
View File

@@ -0,0 +1,34 @@
# Multi-stage build for Astro SSR
FROM node:20-alpine AS base
WORKDIR /app
# Install dependencies
FROM base AS deps
RUN apk add --no-cache git
COPY package.json ./
RUN npm install
# Build Astro
FROM base AS builder
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build
# Production image
FROM base AS runner
ENV NODE_ENV=production
ENV HOST=0.0.0.0
ENV PORT=4321
# Copy built files and production dependencies
COPY --from=builder /app/dist ./dist
COPY --from=deps /app/node_modules ./node_modules
COPY package.json ./
# Health check
HEALTHCHECK --interval=30s --timeout=10s --retries=3 --start-period=40s \
CMD node -e "require('http').get('http://127.0.0.1:4321/', (r) => r.statusCode === 200 ? process.exit(0) : process.exit(1))"
EXPOSE 4321
CMD ["npm", "start"]

45
frontend/astro.config.mjs Normal file
View File

@@ -0,0 +1,45 @@
// @ts-check
import { defineConfig } from 'astro/config';
import node from '@astrojs/node';
import tailwindcss from '@tailwindcss/postcss';
import react from '@astrojs/react';
// https://astro.build/config
export default defineConfig({
output: 'server',
adapter: node({
mode: 'standalone'
}),
integrations: [react()],
vite: {
css: {
postcss: {
plugins: [tailwindcss()],
},
},
server: {
hmr: {
overlay: false
}
},
optimizeDeps: {
include: ['tailwindcss']
},
build: {
rollupOptions: {
output: {
manualChunks: {
vendor: ['astro']
}
}
}
}
},
server: {
port: 4321,
host: true
},
build: {
inlineStylesheets: 'auto'
}
});

8155
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

34
frontend/package.json Normal file
View File

@@ -0,0 +1,34 @@
{
"name": "astro-hono-frontend",
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "astro dev --host",
"build": "astro build",
"build:check": "astro check && astro build",
"preview": "astro preview --host",
"start": "node ./dist/server/entry.mjs",
"astro": "astro"
},
"dependencies": {
"@astrojs/react": "^4.4.0",
"@paper-design/shaders-react": "^0.0.60",
"@tailwindcss/typography": "^0.5.19",
"@types/qrcode": "^1.5.5",
"astro": "^5.14.1",
"geist": "^1.5.1",
"marked": "^11.0.0",
"qrcode": "^1.5.4",
"react": "^19.2.0",
"react-dom": "^19.2.0"
},
"devDependencies": {
"@astrojs/check": "^0.9.0",
"@astrojs/node": "^9.0.0",
"@tailwindcss/postcss": "^4.1.13",
"postcss": "^8.5.6",
"tailwindcss": "^4.1.13",
"tw-animate-css": "^1.4.0",
"typescript": "^5.3.0"
}
}

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" width="115" height="48"><path fill="#17191E" d="M7.77 36.35C6.4 35.11 6 32.51 6.57 30.62c.99 1.2 2.35 1.57 3.75 1.78 2.18.33 4.31.2 6.33-.78.23-.12.44-.27.7-.42.18.55.23 1.1.17 1.67a4.56 4.56 0 0 1-1.94 3.23c-.43.32-.9.61-1.34.91-1.38.94-1.76 2.03-1.24 3.62l.05.17a3.63 3.63 0 0 1-1.6-1.38 3.87 3.87 0 0 1-.63-2.1c0-.37 0-.74-.05-1.1-.13-.9-.55-1.3-1.33-1.32a1.56 1.56 0 0 0-1.63 1.26c0 .06-.03.12-.05.2Z"/><path fill="url(#a)" d="M7.77 36.35C6.4 35.11 6 32.51 6.57 30.62c.99 1.2 2.35 1.57 3.75 1.78 2.18.33 4.31.2 6.33-.78.23-.12.44-.27.7-.42.18.55.23 1.1.17 1.67a4.56 4.56 0 0 1-1.94 3.23c-.43.32-.9.61-1.34.91-1.38.94-1.76 2.03-1.24 3.62l.05.17a3.63 3.63 0 0 1-1.6-1.38 3.87 3.87 0 0 1-.63-2.1c0-.37 0-.74-.05-1.1-.13-.9-.55-1.3-1.33-1.32a1.56 1.56 0 0 0-1.63 1.26c0 .06-.03.12-.05.2Z"/><path fill="#17191E" d="M.02 30.31s4.02-1.95 8.05-1.95l3.04-9.4c.11-.45.44-.76.82-.76.37 0 .7.31.82.76l3.04 9.4c4.77 0 8.05 1.95 8.05 1.95L17 11.71c-.2-.56-.53-.91-.98-.91H7.83c-.44 0-.76.35-.97.9L.02 30.31Zm42.37-5.97c0 1.64-2.05 2.62-4.88 2.62-1.85 0-2.5-.45-2.5-1.41 0-1 .8-1.49 2.65-1.49 1.67 0 3.09.03 4.73.23v.05Zm.03-2.04a21.37 21.37 0 0 0-4.37-.36c-5.32 0-7.82 1.25-7.82 4.18 0 3.04 1.71 4.2 5.68 4.2 3.35 0 5.63-.84 6.46-2.92h.14c-.03.5-.05 1-.05 1.4 0 1.07.18 1.16 1.06 1.16h4.15a16.9 16.9 0 0 1-.36-4c0-1.67.06-2.93.06-4.62 0-3.45-2.07-5.64-8.56-5.64-2.8 0-5.9.48-8.26 1.19.22.93.54 2.83.7 4.06 2.04-.96 4.95-1.37 7.2-1.37 3.11 0 3.97.71 3.97 2.15v.57Zm11.37 3c-.56.07-1.33.07-2.12.07-.83 0-1.6-.03-2.12-.1l-.02.58c0 2.85 1.87 4.52 8.45 4.52 6.2 0 8.2-1.64 8.2-4.55 0-2.74-1.33-4.09-7.2-4.39-4.58-.2-4.99-.7-4.99-1.28 0-.66.59-1 3.65-1 3.18 0 4.03.43 4.03 1.35v.2a46.13 46.13 0 0 1 4.24.03l.02-.55c0-3.36-2.8-4.46-8.2-4.46-6.08 0-8.13 1.49-8.13 4.39 0 2.6 1.64 4.23 7.48 4.48 4.3.14 4.77.62 4.77 1.28 0 .7-.7 1.03-3.71 1.03-3.47 0-4.35-.48-4.35-1.47v-.13Zm19.82-12.05a17.5 17.5 0 0 1-6.24 3.48c.03.84.03 2.4.03 3.24l1.5.02c-.02 1.63-.04 3.6-.04 4.9 0 3.04 1.6 5.32 6.58 5.32 2.1 0 3.5-.23 5.23-.6a43.77 43.77 0 0 1-.46-4.13c-1.03.34-2.34.53-3.78.53-2 0-2.82-.55-2.82-2.13 0-1.37 0-2.65.03-3.84 2.57.02 5.13.07 6.64.11-.02-1.18.03-2.9.1-4.04-2.2.04-4.65.07-6.68.07l.07-2.93h-.16Zm13.46 6.04a767.33 767.33 0 0 1 .07-3.18H82.6c.07 1.96.07 3.98.07 6.92 0 2.95-.03 4.99-.07 6.93h5.18c-.09-1.37-.11-3.68-.11-5.65 0-3.1 1.26-4 4.12-4 1.33 0 2.28.16 3.1.46.03-1.16.26-3.43.4-4.43-.86-.25-1.81-.41-2.96-.41-2.46-.03-4.26.98-5.1 3.38l-.17-.02Zm22.55 3.65c0 2.5-1.8 3.66-4.64 3.66-2.81 0-4.61-1.1-4.61-3.66s1.82-3.52 4.61-3.52c2.82 0 4.64 1.03 4.64 3.52Zm4.71-.11c0-4.96-3.87-7.18-9.35-7.18-5.5 0-9.23 2.22-9.23 7.18 0 4.94 3.49 7.59 9.21 7.59 5.77 0 9.37-2.65 9.37-7.6Z"/><defs><linearGradient id="a" x1="6.33" x2="19.43" y1="40.8" y2="34.6" gradientUnits="userSpaceOnUse"><stop stop-color="#D83333"/><stop offset="1" stop-color="#F041FF"/></linearGradient></defs></svg>

After

Width:  |  Height:  |  Size: 2.8 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1440" height="1024" fill="none"><path fill="url(#a)" fill-rule="evenodd" d="M-217.58 475.75c91.82-72.02 225.52-29.38 341.2-44.74C240 415.56 372.33 315.14 466.77 384.9c102.9 76.02 44.74 246.76 90.31 366.31 29.83 78.24 90.48 136.14 129.48 210.23 57.92 109.99 169.67 208.23 155.9 331.77-13.52 121.26-103.42 264.33-224.23 281.37-141.96 20.03-232.72-220.96-374.06-196.99-151.7 25.73-172.68 330.24-325.85 315.72-128.6-12.2-110.9-230.73-128.15-358.76-12.16-90.14 65.87-176.25 44.1-264.57-26.42-107.2-167.12-163.46-176.72-273.45-10.15-116.29 33.01-248.75 124.87-320.79Z" clip-rule="evenodd" style="opacity:.154"/><path fill="url(#b)" fill-rule="evenodd" d="M1103.43 115.43c146.42-19.45 275.33-155.84 413.5-103.59 188.09 71.13 409 212.64 407.06 413.88-1.94 201.25-259.28 278.6-414.96 405.96-130 106.35-240.24 294.39-405.6 265.3-163.7-28.8-161.93-274.12-284.34-386.66-134.95-124.06-436-101.46-445.82-284.6-9.68-180.38 247.41-246.3 413.54-316.9 101.01-42.93 207.83 21.06 316.62 6.61Z" clip-rule="evenodd" style="opacity:.154"/><defs><linearGradient id="b" x1="373" x2="1995.44" y1="1100" y2="118.03" gradientUnits="userSpaceOnUse"><stop stop-color="#D83333"/><stop offset="1" stop-color="#F041FF"/></linearGradient><linearGradient id="a" x1="107.37" x2="1130.66" y1="1993.35" y2="1026.31" gradientUnits="userSpaceOnUse"><stop stop-color="#3245FF"/><stop offset="1" stop-color="#BC52EE"/></linearGradient></defs></svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@@ -0,0 +1,46 @@
---
export interface Props {
title: string;
excerpt: string;
date: string;
readTime: string;
slug: string;
}
const { title, excerpt, date, readTime, slug } = Astro.props;
---
<a
href={`/blog/${slug}`}
class="group block p-6 sm:p-8 border border-border rounded-lg hover:border-muted-foreground/50 transition-all duration-500 hover:shadow-lg"
>
<div class="space-y-4">
<div class="flex items-center justify-between text-xs text-muted-foreground font-mono">
<span>{date}</span>
<span>{readTime}</span>
</div>
<h3 class="text-lg sm:text-xl font-medium group-hover:text-muted-foreground transition-colors duration-300">
{title}
</h3>
<p class="text-muted-foreground leading-relaxed">{excerpt}</p>
<div class="flex items-center gap-2 text-sm text-muted-foreground group-hover:text-foreground transition-colors duration-300">
<span>Read more</span>
<svg
class="w-4 h-4 transform group-hover:translate-x-1 transition-transform duration-300"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width={2}
d="M17 8l4 4m0 0l-4 4m4-4H3"
/>
</svg>
</div>
</div>
</a>

View File

@@ -0,0 +1,38 @@
---
export interface Props {
actions: Array<{
href: string;
label: string;
primary?: boolean;
}>;
}
const { actions } = Astro.props;
---
<nav class="mt-20 pt-12 border-t border-border/50">
<div class="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
{actions.map((action, index) => (
<a
href={action.href}
class={`inline-flex items-center gap-2 px-5 py-3 text-sm font-medium rounded-lg transition-all duration-300 hover:shadow-sm ${
action.primary
? 'bg-foreground text-background hover:bg-foreground/90'
: 'text-muted-foreground hover:text-foreground border border-border hover:border-border'
}`}
>
{index === 0 && (
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"></path>
</svg>
)}
{action.label}
{action.primary && (
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path>
</svg>
)}
</a>
))}
</div>
</nav>

View File

@@ -0,0 +1,25 @@
---
export interface Props {
title: string;
type: 'work' | 'blog' | 'project';
backLink: { href: string; label: string };
}
const { title, type, backLink } = Astro.props;
---
<header class="mb-20">
<div class="mb-8">
<a
href={backLink.href}
class="inline-flex items-center text-sm text-muted-foreground hover:text-foreground transition-colors"
>
← {backLink.label}
</a>
</div>
<div class="space-y-6">
<h1 class="text-4xl sm:text-5xl lg:text-6xl font-light tracking-tight">{title}</h1>
<slot />
</div>
</header>

View File

@@ -0,0 +1,73 @@
---
import Navigation from '../layout/Navigation.astro';
import HeaderNavigation from '../layout/HeaderNavigation.astro';
import Layout from '../../layouts/Layout.astro';
import ContentHeader from './ContentHeader.astro';
import ContentMeta from './ContentMeta.astro';
import ContentActions from './ContentActions.astro';
import type { WorkMeta, BlogMeta, ProjectMeta } from './ContentMeta.astro';
import '../../styles/content-prose.css';
export interface Props {
title: string;
type: 'work' | 'blog' | 'project';
meta?: WorkMeta | BlogMeta | ProjectMeta;
backLink?: {
href: string;
label: string;
};
nextActions?: Array<{
href: string;
label: string;
primary?: boolean;
}>;
contentClass?: string;
}
const {
title,
type,
meta = {},
backLink = { href: '/', label: 'Back to Home' },
nextActions = [],
contentClass = ''
} = Astro.props;
// Default next actions based on content type
const defaultNextActions = {
work: [
{ href: '/#work', label: 'Back to Work' },
{ href: '/#connect', label: 'Get in Touch', primary: true }
],
blog: [
{ href: '/#thoughts', label: 'Back to Thoughts' },
{ href: '/#connect', label: 'Get in Touch', primary: true }
],
project: [
{ href: '/projects', label: 'All Projects' },
{ href: '/#connect', label: 'Get in Touch', primary: true }
]
};
const actions = nextActions.length > 0 ? nextActions : defaultNextActions[type];
---
<Layout title={`${title} | Portfolio`}>
<div class="min-h-screen bg-background text-foreground">
<HeaderNavigation />
<Navigation />
<main class="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-16 sm:py-20">
<ContentHeader title={title} type={type} backLink={backLink}>
<ContentMeta type={type} meta={meta} />
</ContentHeader>
<!-- Content -->
<article class={`max-w-none content-prose ${type}-content ${contentClass}`}>
<slot />
</article>
<ContentActions actions={actions} />
</main>
</div>
</Layout>

View File

@@ -0,0 +1,108 @@
---
import Tag from '../ui/Tag.astro';
export interface WorkMeta {
role?: string;
company?: string;
year?: string;
duration?: string;
technologies?: string[];
}
export interface BlogMeta {
date?: Date;
readTime?: string;
excerpt?: string;
tags?: string[];
}
export interface ProjectMeta {
status?: string;
category?: string;
technologies?: string[];
}
export interface Props {
type: 'work' | 'blog' | 'project';
meta: WorkMeta | BlogMeta | ProjectMeta;
}
const { type, meta } = Astro.props;
// Format date for blog posts
const formatDate = (date: Date) => {
return date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric'
});
};
---
<!-- Work Meta -->
{type === 'work' && 'role' in meta && 'company' in meta && meta.role && meta.company && (
<div class="flex flex-col sm:flex-row sm:items-center gap-2 sm:gap-6 text-muted-foreground">
<div class="flex items-center gap-3">
<span class="font-medium">{meta.role}</span>
<span class="text-muted-foreground/60">•</span>
<span>{meta.company}</span>
</div>
<div class="flex items-center gap-3 sm:before:content-['•'] sm:before:text-muted-foreground/60">
<span>{meta.year}</span>
{meta.duration && (
<>
<span class="text-muted-foreground/60">•</span>
<span>{meta.duration}</span>
</>
)}
</div>
</div>
)}
<!-- Blog Meta -->
{type === 'blog' && 'date' in meta && meta.date && (
<div class="flex flex-col sm:flex-row sm:items-center gap-2 sm:gap-6 text-muted-foreground">
<span>{formatDate(meta.date)}</span>
{'readTime' in meta && meta.readTime && (
<>
<span class="hidden sm:inline text-muted-foreground/60">•</span>
<span>{meta.readTime}</span>
</>
)}
</div>
)}
<!-- Project Meta -->
{type === 'project' && ('status' in meta || 'category' in meta) && (meta.status || meta.category) && (
<div class="flex flex-col sm:flex-row sm:items-center gap-2 sm:gap-6 text-muted-foreground">
{'status' in meta && meta.status && (
<span class="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-muted">
{meta.status}
</span>
)}
{'category' in meta && meta.category && (
<>
<span class="hidden sm:inline text-muted-foreground/60">•</span>
<span class="capitalize">{meta.category.replace('-', ' ')}</span>
</>
)}
</div>
)}
<!-- Technologies for work/projects -->
{(type === 'work' || type === 'project') && 'technologies' in meta && meta.technologies && (
<div class="flex flex-wrap gap-2 pt-2">
{meta.technologies.map((tech) => (
<Tag text={tech} variant="tech" />
))}
</div>
)}
<!-- Tags for blog posts -->
{type === 'blog' && 'tags' in meta && meta.tags && (
<div class="flex flex-wrap gap-2 pt-2">
{meta.tags.map((tag) => (
<Tag text={tag} variant="blog" />
))}
</div>
)}

View File

@@ -0,0 +1,18 @@
---
export interface Props {
role: string;
company: string;
duration: string;
}
const { role, company, duration } = Astro.props;
---
<div class="space-y-4">
<div class="text-sm text-muted-foreground font-mono">CURRENTLY</div>
<div class="space-y-2">
<div class="text-foreground">{role}</div>
<div class="text-muted-foreground">@ {company}</div>
<div class="text-xs text-muted-foreground">{duration}</div>
</div>
</div>

View File

@@ -0,0 +1,97 @@
---
import Tag from "../ui/Tag.astro";
import StatusBadge from "../ui/StatusBadge.astro";
import type { CollectionEntry } from "astro:content";
export interface Props {
project: CollectionEntry<"project">;
featured?: boolean;
}
const { project, featured = false } = Astro.props;
const { data, slug } = project;
const { title, oneLiner, media, category, status, tech, badge } = data;
// Format category for display
const categoryDisplay = category.replace('-', ' ').replace(/\b\w/g, l => l.toUpperCase());
---
<a
href={`/projects/${slug}`}
class={`group flex flex-col card-interactive overflow-hidden h-full ${
featured ? 'lg:col-span-1' : ''
}`}
>
<!-- Cover Image -->
<div class="relative overflow-hidden">
<img
src={media.coverImage.url}
alt={media.coverImage.alt}
class={`w-full object-cover transition-transform duration-300 group-hover:scale-105 ${
featured ? 'h-56' : 'h-48'
}`}
/>
{data.featured && (
<div class="absolute top-3 right-3">
<span class="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800 border border-yellow-200">
★ Featured
</span>
</div>
)}
{badge && (
<div class="absolute top-3 left-3">
<span class="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-background/90 text-foreground border border-border backdrop-blur-sm">
{badge}
</span>
</div>
)}
</div>
<!-- Content -->
<div class={`flex flex-col flex-grow p-6 ${featured ? 'p-8' : ''}`}>
<!-- Meta row -->
<div class="flex items-center justify-between mb-4">
<div class="flex items-center gap-3">
<span class="text-xs font-medium bg-muted text-muted-foreground px-3 py-1 rounded-full">
{categoryDisplay}
</span>
<StatusBadge status={status} />
</div>
</div>
<!-- Title and description -->
<div class="space-y-3 flex-grow">
<h3 class={`text-card-title ${
featured ? 'text-xl sm:text-2xl' : ''
}`}>
{title}
</h3>
<p class={`text-card-description ${
featured ? 'text-base' : ''
}`}>
{oneLiner}
</p>
</div>
<!-- Tech tags -->
<div class="flex flex-wrap gap-2 mt-6">
{tech.slice(0, featured ? 4 : 3).map((t) => (
<Tag text={t} variant="tech" />
))}
{tech.length > (featured ? 4 : 3) && (
<span class="tag-base bg-muted/50 text-muted-foreground">
+{tech.length - (featured ? 4 : 3)} more
</span>
)}
</div>
<!-- CTA hint -->
<div class="flex items-center gap-2 mt-6 text-sm text-muted-foreground group-hover:text-foreground transition-colors">
<span>View Project</span>
<svg class="w-4 h-4 transition-transform group-hover:translate-x-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path>
</svg>
</div>
</div>
</a>

View File

@@ -0,0 +1,66 @@
---
import Tag from '../ui/Tag.astro';
export interface Props {
year: string;
role: string;
company: string;
description: string;
tech: string[];
slug?: string;
}
const { year, role, company, description, tech, slug } = Astro.props;
---
{slug ? (
<a href={`/work/${slug}`} class="block">
<div
class="group grid lg:grid-cols-12 gap-4 sm:gap-8 py-6 sm:py-8 border-b border-border/50 hover:border-border transition-colors duration-500 cursor-pointer"
>
<div class="lg:col-span-2">
<div class="text-xl sm:text-2xl font-light text-muted-foreground group-hover:text-foreground transition-colors duration-500">
{year}
</div>
</div>
<div class="lg:col-span-6 space-y-3">
<div>
<h3 class="text-lg sm:text-xl font-medium">{role}</h3>
<div class="text-muted-foreground">{company}</div>
</div>
<p class="text-muted-foreground leading-relaxed max-w-lg">{description}</p>
</div>
<div class="lg:col-span-4 flex flex-wrap gap-2 lg:justify-end mt-2 lg:mt-0">
{tech.map((technology) => (
<Tag text={technology} variant="tech" />
))}
</div>
</div>
</a>
) : (
<div
class="group grid lg:grid-cols-12 gap-4 sm:gap-8 py-6 sm:py-8 border-b border-border/50 hover:border-border transition-colors duration-500"
>
<div class="lg:col-span-2">
<div class="text-xl sm:text-2xl font-light text-muted-foreground group-hover:text-foreground transition-colors duration-500">
{year}
</div>
</div>
<div class="lg:col-span-6 space-y-3">
<div>
<h3 class="text-lg sm:text-xl font-medium">{role}</h3>
<div class="text-muted-foreground">{company}</div>
</div>
<p class="text-muted-foreground leading-relaxed max-w-lg">{description}</p>
</div>
<div class="lg:col-span-4 flex flex-wrap gap-2 lg:justify-end mt-2 lg:mt-0">
{tech.map((technology) => (
<Tag text={technology} variant="tech" />
))}
</div>
</div>
)}

View File

@@ -0,0 +1,8 @@
export { default as ContentLayout } from './ContentLayout.astro';
export { default as ProjectCard } from './ProjectCard.astro';
export { default as BlogCard } from './BlogCard.astro';
export { default as WorkExperience } from './WorkExperience.astro';
export { default as CurrentRole } from './CurrentRole.astro';
export { default as ContentHeader } from './ContentHeader.astro';
export { default as ContentActions } from './ContentActions.astro';
export { default as ContentMeta } from './ContentMeta.astro';

View File

@@ -0,0 +1,22 @@
---
export interface Props {
email: string;
}
const { email } = Astro.props;
---
<a
href={`mailto:${email}`}
class="group flex items-center gap-3 text-foreground hover:text-muted-foreground transition-colors duration-300"
>
<span class="text-base sm:text-lg">{email}</span>
<svg
class="w-5 h-5 transform group-hover:translate-x-1 transition-transform duration-300"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 8l4 4m0 0l-4 4m4-4H3" />
</svg>
</a>

View File

@@ -0,0 +1,201 @@
---
import { getCollection } from "astro:content";
const projects = await getCollection("project");
const categories = [...new Set(projects.map((p) => p.data.category))];
const techs = [...new Set(projects.flatMap((p) => p.data.tech))].sort();
// Format category names for display
const formatCategory = (category: string) =>
category.replace('-', ' ').replace(/\b\w/g, l => l.toUpperCase());
---
<div class="bg-muted/30 border border-border/50 rounded-lg p-6">
<div class="mb-6">
<h3 class="text-lg font-medium text-foreground mb-2">Filter & Search</h3>
<p class="text-sm text-muted-foreground">
Refine your view to find specific types of projects
</p>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-4">
<!-- Search -->
<div class="lg:col-span-2">
<label for="search" class="block text-sm font-medium text-foreground mb-2">
Search Projects
</label>
<div class="relative">
<input
type="text"
id="search"
name="search"
class="w-full pl-10 pr-4 py-3 text-sm bg-background border border-border rounded-lg focus:ring-2 focus:ring-foreground/20 focus:border-foreground transition-colors"
placeholder="Search titles, descriptions, or tags..."
/>
<svg class="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-muted-foreground" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path>
</svg>
</div>
</div>
<!-- Category -->
<div>
<label for="category" class="block text-sm font-medium text-foreground mb-2">
Category
</label>
<select
id="category"
name="category"
class="w-full px-3 py-3 text-sm bg-background border border-border rounded-lg focus:ring-2 focus:ring-foreground/20 focus:border-foreground transition-colors"
>
<option value="all">All Categories</option>
{categories.map((c) => (
<option value={c}>{formatCategory(c)}</option>
))}
</select>
</div>
<!-- Technology -->
<div>
<label for="tech" class="block text-sm font-medium text-foreground mb-2">
Technology
</label>
<select
id="tech"
name="tech"
class="w-full px-3 py-3 text-sm bg-background border border-border rounded-lg focus:ring-2 focus:ring-foreground/20 focus:border-foreground transition-colors"
>
<option value="all">All Technologies</option>
{techs.map((t) => (
<option value={t}>{t}</option>
))}
</select>
</div>
<!-- Sort -->
<div>
<label for="sort" class="block text-sm font-medium text-foreground mb-2">
Sort By
</label>
<select
id="sort"
name="sort"
class="w-full px-3 py-3 text-sm bg-background border border-border rounded-lg focus:ring-2 focus:ring-foreground/20 focus:border-foreground transition-colors"
>
<option value="featured">Featured First</option>
<option value="date">Most Recent</option>
<option value="complexity">Complexity</option>
</select>
</div>
</div>
<!-- Status Filter Row -->
<div class="mt-6 pt-6 border-t border-border/50">
<label class="block text-sm font-medium text-foreground mb-3">
Project Status
</label>
<div class="flex flex-wrap gap-3">
<label class="flex items-center">
<input
type="radio"
name="status"
id="status"
value="all"
checked
class="sr-only"
/>
<span class="status-pill cursor-pointer px-4 py-2 text-sm font-medium rounded-full border transition-all">
All Projects
</span>
</label>
<label class="flex items-center">
<input
type="radio"
name="status"
value="live-baseline"
class="sr-only"
/>
<span class="status-pill cursor-pointer px-4 py-2 text-sm font-medium rounded-full border transition-all flex items-center gap-2">
<span class="w-2 h-2 rounded-full bg-green-500"></span>
Live Baseline
</span>
</label>
<label class="flex items-center">
<input
type="radio"
name="status"
value="replay"
class="sr-only"
/>
<span class="status-pill cursor-pointer px-4 py-2 text-sm font-medium rounded-full border transition-all flex items-center gap-2">
<span class="w-2 h-2 rounded-full bg-blue-500"></span>
Replay Demo
</span>
</label>
<label class="flex items-center">
<input
type="radio"
name="status"
value="snapshot-only"
class="sr-only"
/>
<span class="status-pill cursor-pointer px-4 py-2 text-sm font-medium rounded-full border transition-all flex items-center gap-2">
<span class="w-2 h-2 rounded-full bg-gray-500"></span>
Snapshot Only
</span>
</label>
</div>
</div>
</div>
<style>
.status-pill {
border-color: hsl(var(--border));
color: hsl(var(--muted-foreground));
background-color: hsl(var(--background));
}
input:checked + .status-pill {
border-color: hsl(var(--foreground));
color: hsl(var(--foreground));
background-color: hsl(var(--muted) / 0.5);
}
.status-pill:hover {
border-color: hsl(var(--foreground) / 0.5);
color: hsl(var(--foreground));
}
</style>
<script>
document.addEventListener("DOMContentLoaded", () => {
// Handle status filter radio buttons
const statusRadios = document.querySelectorAll('input[name="status"]');
const statusElement = { value: 'all' }; // Mock for compatibility
// Create a virtual status element for the existing filter logic
Object.defineProperty(window, 'statusFilter', {
get() { return statusElement; }
});
statusRadios.forEach(radio => {
radio.addEventListener('change', (e) => {
if (e.target.checked) {
statusElement.value = e.target.value;
// Trigger the filter function if it exists
if (typeof window.filterProjects === 'function') {
window.filterProjects();
}
// Also dispatch a change event for compatibility
document.getElementById('status')?.dispatchEvent(new Event('change'));
}
});
});
// Make sure the first option (All) is selected by default
const firstRadio = document.querySelector('input[name="status"][value="all"]');
if (firstRadio) {
firstRadio.checked = true;
}
});
</script>

View File

@@ -0,0 +1,2 @@
export { default as FilterPanel } from './FilterPanel.astro';
export { default as ContactEmail } from './ContactEmail.astro';

View File

@@ -0,0 +1,7 @@
// Re-export everything for backward compatibility
export * from './ui';
export * from './layout';
export * from './content';
export * from './sections';
export * from './forms';
export * from './shared';

View File

@@ -0,0 +1,52 @@
---
import ThemeToggle from '../shared/ThemeToggle.astro';
import Button from '../ui/Button.astro';
import { getEntry } from '../../content';
export interface Props {
copyright?: string;
attribution?: string;
year?: string;
}
// Fetch footer data from Directus
let footerData = {
copyright: "Felix Macaspac. All rights reserved.",
attribution: "Built with v0.dev by Felix Macaspac",
year: new Date().getFullYear().toString()
};
try {
const portfolioEntry = await getEntry('portfolio', 'main');
if (portfolioEntry?.data?.footer) {
footerData = {
copyright: portfolioEntry.data.footer.copyright || footerData.copyright,
attribution: portfolioEntry.data.footer.attribution || footerData.attribution,
year: portfolioEntry.data.footer.year || footerData.year
};
}
} catch (error) {
console.error('Error fetching footer data:', error);
// Use default fallback values
}
// Allow props to override Directus data
const {
copyright = footerData.copyright,
attribution = footerData.attribution,
year = footerData.year
} = Astro.props;
---
<footer class="py-12 sm:py-16 border-t border-border">
<div class="flex flex-col lg:flex-row justify-between items-start lg:items-center gap-6 sm:gap-8">
<div class="space-y-2">
<div class="text-sm text-muted-foreground">© {year} {copyright}</div>
<div class="text-xs text-muted-foreground">{attribution}</div>
</div>
<div class="flex items-center gap-4">
<ThemeToggle />
</div>
</div>
</footer>

View File

@@ -0,0 +1,19 @@
---
export interface Props {
cols?: string;
gap?: string;
class?: string;
}
const {
cols = "lg:grid-cols-5",
gap = "gap-12 sm:gap-16",
class: className = ""
} = Astro.props;
const gridClasses = `grid ${cols} ${gap} w-full ${className}`;
---
<div class={gridClasses}>
<slot />
</div>

View File

@@ -0,0 +1,178 @@
---
export interface Props {
showHome?: boolean;
currentPage?: string;
}
const {
showHome = true,
currentPage = ''
} = Astro.props;
const pageTitle = currentPage || 'Portfolio';
---
<header class="sticky top-0 z-50 bg-background/80 backdrop-blur-sm border-b border-border/50">
<div class="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex items-center justify-between h-16">
<!-- Logo/Title -->
{showHome && (
<a
href="/"
class="text-lg font-medium text-foreground hover:text-muted-foreground transition-colors"
aria-label="Go to homepage"
>
{pageTitle}
</a>
)}
<!-- Desktop Navigation -->
<nav class="hidden md:flex items-center gap-6 text-sm">
<a
href="/#projects"
class="text-muted-foreground hover:text-foreground transition-colors"
>
Projects
</a>
<a
href="/#work"
class="text-muted-foreground hover:text-foreground transition-colors"
>
Work
</a>
<a
href="/#thoughts"
class="text-muted-foreground hover:text-foreground transition-colors"
>
Thoughts
</a>
<a
href="/tools"
class="text-muted-foreground hover:text-foreground transition-colors"
>
Tools
</a>
<a
href="/#connect"
class="text-muted-foreground hover:text-foreground transition-colors"
>
Connect
</a>
</nav>
<!-- Mobile Hamburger Menu -->
<button
id="mobile-menu-button"
class="md:hidden p-2 rounded-lg hover:bg-muted/50 transition-colors"
aria-label="Toggle menu"
>
<svg
id="hamburger-icon"
class="w-6 h-6 text-foreground"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width={2}
d="M4 6h16M4 12h16M4 18h16"
/>
</svg>
<svg
id="close-icon"
class="w-6 h-6 text-foreground hidden"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
<!-- Mobile Menu Panel -->
<div
id="mobile-menu"
class="hidden md:hidden pb-4 space-y-2"
>
<a
href="/#projects"
class="block px-4 py-2 text-sm text-muted-foreground hover:text-foreground hover:bg-muted/50 rounded-lg transition-colors"
>
Projects
</a>
<a
href="/#work"
class="block px-4 py-2 text-sm text-muted-foreground hover:text-foreground hover:bg-muted/50 rounded-lg transition-colors"
>
Work
</a>
<a
href="/#thoughts"
class="block px-4 py-2 text-sm text-muted-foreground hover:text-foreground hover:bg-muted/50 rounded-lg transition-colors"
>
Thoughts
</a>
<a
href="/tools"
class="block px-4 py-2 text-sm text-muted-foreground hover:text-foreground hover:bg-muted/50 rounded-lg transition-colors"
>
Tools
</a>
<a
href="/#connect"
class="block px-4 py-2 text-sm text-muted-foreground hover:text-foreground hover:bg-muted/50 rounded-lg transition-colors"
>
Connect
</a>
</div>
</div>
</header>
<script>
function initMobileMenu() {
const menuButton = document.getElementById('mobile-menu-button');
const mobileMenu = document.getElementById('mobile-menu');
const hamburgerIcon = document.getElementById('hamburger-icon');
const closeIcon = document.getElementById('close-icon');
menuButton?.addEventListener('click', () => {
const isOpen = !mobileMenu?.classList.contains('hidden');
if (isOpen) {
// Close menu
mobileMenu?.classList.add('hidden');
hamburgerIcon?.classList.remove('hidden');
closeIcon?.classList.add('hidden');
} else {
// Open menu
mobileMenu?.classList.remove('hidden');
hamburgerIcon?.classList.add('hidden');
closeIcon?.classList.remove('hidden');
}
});
// Close menu when clicking on a link
const menuLinks = mobileMenu?.querySelectorAll('a');
menuLinks?.forEach(link => {
link.addEventListener('click', () => {
mobileMenu?.classList.add('hidden');
hamburgerIcon?.classList.remove('hidden');
closeIcon?.classList.add('hidden');
});
});
}
// Initialize on page load
document.addEventListener('DOMContentLoaded', initMobileMenu);
// Re-initialize after Astro page transitions
document.addEventListener('astro:page-load', initMobileMenu);
</script>

View File

@@ -0,0 +1,71 @@
---
import StatusIndicator from '../ui/StatusIndicator.astro';
import Tag from '../ui/Tag.astro';
import CurrentRole from '../content/CurrentRole.astro';
export interface Props {
portfolioYear?: string;
firstName?: string;
lastName?: string;
description?: string;
location?: string;
skills?: string[];
currentRole?: string;
currentCompany?: string;
currentDuration?: string;
}
const {
portfolioYear = "2025",
firstName = "Your",
lastName = "Name",
description = "Independent Software Engineer building AI-powered applications and fostering tech communities. Focused on rapid prototyping, LLM integration, and full-stack development.",
location = "Your Location",
skills = ["React", "TypeScript", "Astro", "Tailwind CSS", "Node.js"],
currentRole = "Software Engineer",
currentCompany = "Your Company",
currentDuration = "2021 — Present"
} = Astro.props;
---
<div class="grid lg:grid-cols-5 gap-12 sm:gap-16 w-full">
<div class="lg:col-span-3 space-y-6 sm:space-y-8">
<div class="space-y-3 sm:space-y-2">
<div class="text-sm text-muted-foreground font-mono tracking-wider">
PORTFOLIO / {portfolioYear}
</div>
<h1 class="text-5xl sm:text-6xl lg:text-7xl font-light tracking-tight">
{firstName}
<br />
<span class="text-muted-foreground">{lastName}</span>
</h1>
</div>
<div class="space-y-6 max-w-md">
<p class="text-lg sm:text-xl text-muted-foreground leading-relaxed" set:html={description}>
</p>
<div class="flex flex-col sm:flex-row sm:items-center gap-3 sm:gap-4 text-sm text-muted-foreground">
<StatusIndicator />
<div>{location}</div>
</div>
</div>
</div>
<div class="lg:col-span-2 flex flex-col justify-end space-y-6 sm:space-y-8 mt-8 lg:mt-0">
<CurrentRole
role={currentRole}
company={currentCompany}
duration={currentDuration}
/>
<div class="space-y-4">
<div class="text-sm text-muted-foreground font-mono">FOCUS</div>
<div class="flex flex-wrap gap-2">
{skills.map((skill) => (
<Tag text={skill} variant="skill" />
))}
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,62 @@
---
export interface Props {
sections?: string[];
}
const {
sections = ["intro", "projects", "work", "thoughts", "connect"]
} = Astro.props;
---
<nav class="fixed left-8 top-1/2 -translate-y-1/2 z-10 hidden lg:block">
<div class="flex flex-col gap-4">
{sections.map((section) => (
<button
data-nav-button={section}
class="w-2 h-8 rounded-full transition-all duration-500 bg-muted-foreground/30 hover:bg-muted-foreground/60"
aria-label={`Navigate to ${section}`}
>
</button>
))}
</div>
</nav>
<script>
document.addEventListener('DOMContentLoaded', function() {
// Add smooth scrolling behavior to navigation buttons
const navButtons = document.querySelectorAll('[data-nav-button]');
// Update active navigation state
function updateActiveNav(activeSection) {
navButtons.forEach(button => {
const sectionId = button.getAttribute('data-nav-button');
if (sectionId === activeSection) {
button.classList.remove('bg-muted-foreground/30', 'hover:bg-muted-foreground/60');
button.classList.add('bg-foreground');
} else {
button.classList.remove('bg-foreground');
button.classList.add('bg-muted-foreground/30', 'hover:bg-muted-foreground/60');
}
});
}
navButtons.forEach(button => {
button.addEventListener('click', () => {
const sectionId = button.getAttribute('data-nav-button');
const section = document.getElementById(sectionId);
if (section) {
section.scrollIntoView({
behavior: 'smooth',
block: 'start'
});
}
});
});
// Listen for active section changes
window.addEventListener('activeSectionChange', (event) => {
updateActiveNav(event.detail.sectionId);
});
});
</script>

View File

@@ -0,0 +1,25 @@
---
export interface Props {
id: string;
class?: string;
containerClass?: string;
}
const {
id,
class: className = "",
containerClass = "max-w-4xl mx-auto px-6 sm:px-8 lg:px-16"
} = Astro.props;
const sectionClasses = `opacity-0 ${className}`;
---
<section
id={id}
data-section={id}
class={sectionClasses}
>
<div class={containerClass}>
<slot />
</div>
</section>

View File

@@ -0,0 +1,5 @@
export { default as Navigation } from './Navigation.astro';
export { default as Footer } from './Footer.astro';
export { default as Hero } from './Hero.astro';
export { default as Section } from './Section.astro';
export { default as Grid } from './Grid.astro';

View File

@@ -0,0 +1,57 @@
---
import ContactEmail from '../forms/ContactEmail.astro';
import SocialLink from '../ui/SocialLink.astro';
export interface Props {
title?: string;
description?: string;
email?: string;
socialLinks?: Array<{
name: string;
handle: string;
url: string;
}>;
}
const {
title = "Let's Connect",
description = "Always interested in new opportunities, collaborations, and conversations about technology and design.",
email = "test@example.com",
socialLinks = [
{ name: "GitHub", handle: "@felixmacaspac", url: "#" },
{ name: "v0.dev", handle: "@felixmacaspac", url: "#" },
{ name: "HubSpot Community", handle: "@felixmacaspac", url: "#" },
{ name: "LinkedIn", handle: "felixmacaspac", url: "#" },
]
} = Astro.props;
---
<div class="grid lg:grid-cols-2 gap-12 sm:gap-16">
<div class="space-y-6 sm:space-y-8">
<h2 class="text-3xl sm:text-4xl font-light">{title}</h2>
<div class="space-y-6">
<p class="text-lg sm:text-xl text-muted-foreground leading-relaxed">
{description}
</p>
<div class="space-y-4">
<ContactEmail email={email} />
</div>
</div>
</div>
<div class="space-y-6 sm:space-y-8">
<div class="text-sm text-muted-foreground font-mono tracking-wider">ELSEWHERE</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
{socialLinks.map((social) => (
<SocialLink
name={social.name}
handle={social.handle}
url={social.url}
/>
))}
</div>
</div>
</div>

View File

@@ -0,0 +1,73 @@
---
import { getCollection } from "../../content";
import Tag from "../ui/Tag.astro";
const allProjects = await getCollection("work");
const featuredProjects = allProjects
.filter((p: any) => p.data.featured)
.sort((a: any, b: any) => (a.data.order || 999) - (b.data.order || 999))
.slice(0, 4); // Show top 4 featured projects
---
<div class="space-y-12 sm:space-y-16">
<div class="flex flex-col sm:flex-row sm:items-end sm:justify-between gap-4">
<h2 class="text-3xl sm:text-4xl font-light">Featured Projects</h2>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-8 items-stretch">
{featuredProjects.map((project: any) => (
<a
href={`/work/${project.slug}`}
class="group flex flex-col card-interactive overflow-hidden h-full"
>
<div class="flex flex-col flex-grow p-6">
<div class="flex items-center justify-between mb-4">
<span class="text-xs font-medium text-muted-foreground">
{project.data.year}
</span>
<span class="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800 border border-yellow-200">
★ Featured
</span>
</div>
<div class="space-y-3 flex-grow">
<div>
<h3 class="text-xl font-medium mb-1 group-hover:text-primary transition-colors">
{project.data.title}
</h3>
<p class="text-sm text-muted-foreground">
{project.data.role} @ {project.data.company}
</p>
</div>
<p class="text-sm text-muted-foreground leading-relaxed">
{project.data.description}
</p>
</div>
<div class="flex flex-wrap gap-2 mt-6">
{project.data.technologies.slice(0, 3).map((tech: string) => (
<Tag text={tech} variant="tech" />
))}
{project.data.technologies.length > 3 && (
<span class="tag-base bg-muted/50 text-muted-foreground text-xs">
+{project.data.technologies.length - 3} more
</span>
)}
</div>
<div class="flex items-center gap-2 mt-6 text-sm text-muted-foreground group-hover:text-foreground transition-colors">
<span>View Project</span>
<svg class="w-4 h-4 transition-transform group-hover:translate-x-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path>
</svg>
</div>
</div>
</a>
))}
</div>
<div class="text-center mt-12">
<a href="/projects" class="btn btn-secondary">View all projects</a>
</div>
</div>

View File

@@ -0,0 +1,23 @@
---
import { getCollection } from "astro:content";
import ProjectCard from "../content/ProjectCard.astro";
const projects = await getCollection("project");
---
<div class="space-y-12 sm:space-y-16">
<div class="flex flex-col sm:flex-row sm:items-end sm:justify-between gap-4">
<h2 class="text-3xl sm:text-4xl font-light">Projects</h2>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-8">
{projects.map((project) => (
<ProjectCard
title={project.data.title}
description={project.data.description}
technologies={project.data.technologies}
link={project.data.link}
/>
))}
</div>
</div>

View File

@@ -0,0 +1,60 @@
---
import BlogCard from '../content/BlogCard.astro';
export interface Props {
title?: string;
posts?: Array<{
title: string;
excerpt: string;
date: string;
readTime: string;
slug: string;
}>;
}
const {
title = "Recent Thoughts",
posts = [
{
title: "The Future of Web Development",
excerpt: "Exploring how AI and automation are reshaping the way we build for the web.",
date: "Dec 2024",
readTime: "5 min",
},
{
title: "Design Systems at Scale",
excerpt: "Lessons learned from building and maintaining design systems across multiple products.",
date: "Nov 2024",
readTime: "8 min",
},
{
title: "Performance-First Development",
excerpt: "Why performance should be a first-class citizen in your development workflow.",
date: "Oct 2024",
readTime: "6 min",
},
{
title: "The Art of Code Review",
excerpt: "Building better software through thoughtful and constructive code reviews.",
date: "Sep 2024",
readTime: "4 min",
},
]
} = Astro.props;
---
<div class="space-y-12 sm:space-y-16">
<h2 class="text-3xl sm:text-4xl font-light">{title}</h2>
<div class="grid gap-6 sm:gap-8 lg:grid-cols-2">
{posts.map((post) => (
<BlogCard
title={post.title}
excerpt={post.excerpt}
date={post.date}
readTime={post.readTime}
slug={post.slug}
/>
))}
</div>
</div>

View File

@@ -0,0 +1,210 @@
---
import astroLogo from '../../assets/astro.svg';
import background from '../../assets/background.svg';
---
<div id="container">
<img id="background" src={background.src} alt="" fetchpriority="high" />
<main>
<section id="hero">
<a href="https://astro.build"
><img src={astroLogo.src} width="115" height="48" alt="Astro Homepage" /></a
>
<h1>
To get started, open the <code><pre>src/pages</pre></code> directory in your project.
</h1>
<section id="links">
<a class="button" href="https://docs.astro.build">Read our docs</a>
<a href="https://astro.build/chat"
>Join our Discord <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 127.14 96.36"
><path
fill="currentColor"
d="M107.7 8.07A105.15 105.15 0 0 0 81.47 0a72.06 72.06 0 0 0-3.36 6.83 97.68 97.68 0 0 0-29.11 0A72.37 72.37 0 0 0 45.64 0a105.89 105.89 0 0 0-26.25 8.09C2.79 32.65-1.71 56.6.54 80.21a105.73 105.73 0 0 0 32.17 16.15 77.7 77.7 0 0 0 6.89-11.11 68.42 68.42 0 0 1-10.85-5.18c.91-.66 1.8-1.34 2.66-2a75.57 75.57 0 0 0 64.32 0c.87.71 1.76 1.39 2.66 2a68.68 68.68 0 0 1-10.87 5.19 77 77 0 0 0 6.89 11.1 105.25 105.25 0 0 0 32.19-16.14c2.64-27.38-4.51-51.11-18.9-72.15ZM42.45 65.69C36.18 65.69 31 60 31 53s5-12.74 11.43-12.74S54 46 53.89 53s-5.05 12.69-11.44 12.69Zm42.24 0C78.41 65.69 73.25 60 73.25 53s5-12.74 11.44-12.74S96.23 46 96.12 53s-5.04 12.69-11.43 12.69Z"
></path></svg
>
</a>
</section>
</section>
</main>
<a href="https://astro.build/blog/astro-5/" id="news" class="box">
<svg width="32" height="32" fill="none" xmlns="http://www.w3.org/2000/svg"
><path
d="M24.667 12c1.333 1.414 2 3.192 2 5.334 0 4.62-4.934 5.7-7.334 12C18.444 28.567 18 27.456 18 26c0-4.642 6.667-7.053 6.667-14Zm-5.334-5.333c1.6 1.65 2.4 3.43 2.4 5.333 0 6.602-8.06 7.59-6.4 17.334C13.111 27.787 12 25.564 12 22.666c0-4.434 7.333-8 7.333-16Zm-6-5.333C15.111 3.555 16 5.556 16 7.333c0 8.333-11.333 10.962-5.333 22-3.488-.774-6-4-6-8 0-8.667 8.666-10 8.666-20Z"
fill="#111827"></path></svg
>
<h2>What's New in Astro 5.0?</h2>
<p>
From content layers to server islands, click to learn more about the new features and
improvements in Astro 5.0
</p>
</a>
</div>
<style>
#background {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: -1;
filter: blur(100px);
}
#container {
font-family: Inter, Roboto, 'Helvetica Neue', 'Arial Nova', 'Nimbus Sans', Arial, sans-serif;
height: 100%;
}
main {
height: 100%;
display: flex;
justify-content: center;
}
#hero {
display: flex;
align-items: start;
flex-direction: column;
justify-content: center;
padding: 16px;
}
h1 {
font-size: 22px;
margin-top: 0.25em;
}
#links {
display: flex;
gap: 16px;
}
#links a {
display: flex;
align-items: center;
padding: 10px 12px;
color: #111827;
text-decoration: none;
transition: color 0.2s;
}
#links a:hover {
color: rgb(78, 80, 86);
}
#links a svg {
height: 1em;
margin-left: 8px;
}
#links a.button {
color: white;
background: linear-gradient(83.21deg, #3245ff 0%, #bc52ee 100%);
box-shadow:
inset 0 0 0 1px rgba(255, 255, 255, 0.12),
inset 0 -2px 0 rgba(0, 0, 0, 0.24);
border-radius: 10px;
}
#links a.button:hover {
color: rgb(230, 230, 230);
box-shadow: none;
}
pre {
font-family:
ui-monospace, 'Cascadia Code', 'Source Code Pro', Menlo, Consolas, 'DejaVu Sans Mono',
monospace;
font-weight: normal;
background: linear-gradient(14deg, #d83333 0%, #f041ff 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
margin: 0;
}
h2 {
margin: 0 0 1em;
font-weight: normal;
color: #111827;
font-size: 20px;
}
p {
color: #4b5563;
font-size: 16px;
line-height: 24px;
letter-spacing: -0.006em;
margin: 0;
}
code {
display: inline-block;
background:
linear-gradient(66.77deg, #f3cddd 0%, #f5cee7 100%) padding-box,
linear-gradient(155deg, #d83333 0%, #f041ff 18%, #f5cee7 45%) border-box;
border-radius: 8px;
border: 1px solid transparent;
padding: 6px 8px;
}
.box {
padding: 16px;
background: rgba(255, 255, 255, 1);
border-radius: 16px;
border: 1px solid white;
}
#news {
position: absolute;
bottom: 16px;
right: 16px;
max-width: 300px;
text-decoration: none;
transition: background 0.2s;
backdrop-filter: blur(50px);
}
#news:hover {
background: rgba(255, 255, 255, 0.55);
}
@media screen and (max-height: 368px) {
#news {
display: none;
}
}
@media screen and (max-width: 768px) {
#container {
display: flex;
flex-direction: column;
}
#hero {
display: block;
padding-top: 10%;
}
#links {
flex-wrap: wrap;
}
#links a.button {
padding: 14px 18px;
}
#news {
right: 16px;
left: 16px;
bottom: 2.5rem;
max-width: 100%;
}
h1 {
line-height: 1.5;
}
}
</style>

View File

@@ -0,0 +1,44 @@
---
import WorkExperience from '../content/WorkExperience.astro';
export interface JobData {
year: string;
role: string;
company: string;
description: string;
tech: string[];
slug?: string;
}
export interface Props {
title?: string;
dateRange?: string;
jobs: JobData[];
}
const {
title = "Selected Work",
dateRange = "2019 — 2025",
jobs
} = Astro.props;
---
<div class="space-y-12 sm:space-y-16">
<div class="flex flex-col sm:flex-row sm:items-end sm:justify-between gap-4">
<h2 class="text-3xl sm:text-4xl font-light">{title}</h2>
<div class="text-sm text-muted-foreground font-mono">{dateRange}</div>
</div>
<div class="space-y-8 sm:space-y-12">
{jobs.map((job) => (
<WorkExperience
year={job.year}
role={job.role}
company={job.company}
description={job.description}
tech={job.tech}
slug={job.slug}
/>
))}
</div>
</div>

View File

@@ -0,0 +1,6 @@
export { default as FeaturedProjects } from './FeaturedProjects.astro';
export { default as ProjectSection } from './ProjectSection.astro';
export { default as WorkSection } from './WorkSection.astro';
export { default as ThoughtsSection } from './ThoughtsSection.astro';
export { default as ConnectSection } from './ConnectSection.astro';
export { default as Welcome } from './Welcome.astro';

View File

@@ -0,0 +1,184 @@
import { PulsingBorder } from '@paper-design/shaders-react';
import { useEffect, useState } from 'react';
export default function ShaderBackground() {
const [dimensions, setDimensions] = useState({
width: 1280,
height: 720,
top: 0,
left: 0,
});
const [isMobile, setIsMobile] = useState(false);
const [isDark, setIsDark] = useState(true);
const [isReady, setIsReady] = useState(false);
useEffect(() => {
// Detect theme changes
const updateTheme = () => {
setIsDark(document.documentElement.classList.contains('dark'));
};
updateTheme();
const observer = new MutationObserver(updateTheme);
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ['class'],
});
return () => observer.disconnect();
}, []);
useEffect(() => {
// Find the hero content element
const heroContent = document.querySelector('#hero-content');
const header = document.querySelector('#intro');
if (!heroContent || !header) return;
const updateDimensions = () => {
const heroRect = heroContent.getBoundingClientRect();
const headerRect = header.getBoundingClientRect();
// Detect mobile viewport
const isMobileView = window.innerWidth < 1024;
setIsMobile(isMobileView);
// Use different padding based on viewport size
// Mobile: less padding due to subtler effect
// Desktop: more padding for full glow
const padding = isMobileView ? 40 : 120;
// Calculate position relative to header
const calculatedWidth = heroRect.width + padding * 2;
setDimensions({
width: calculatedWidth,
height: heroRect.height + padding * 2,
top: heroRect.top - headerRect.top - padding,
left: heroRect.left - headerRect.left - padding,
});
};
// Initial measurement with delay to ensure DOM is ready
setTimeout(() => {
updateDimensions();
// Fade in after dimensions are set
setTimeout(() => setIsReady(true), 100);
}, 100);
// Update on resize
const resizeObserver = new ResizeObserver(updateDimensions);
resizeObserver.observe(heroContent);
resizeObserver.observe(header);
// Also listen for window resize
window.addEventListener('resize', updateDimensions);
return () => {
resizeObserver.disconnect();
window.removeEventListener('resize', updateDimensions);
};
}, []);
// Theme-specific configurations
const darkModeColors = ["#4c4796", "#77aa7d", "#12696a", "#0aff78", "#a733cc"];
const lightModeColors = ["#7ba3d4", "#b88ec4", "#6db3b8", "#d89cb8", "#94b8d4"];
const colors = isDark ? darkModeColors : lightModeColors;
// Configure parameters based on viewport size
const config = isMobile ? {
// Mobile-optimized: subtle, refined effect
roundness: 0.09,
thickness: 0.08,
softness: 1.00,
intensity: isDark? 0.08 : 0.05,
bloom: 0.15,
spotSize: 0.33,
spots: 3,
pulse: 0.30,
smoke: 0.12,
smokeSize: 0.63,
speed: 0.64,
scale: 0.96,
opacity: 0.7,
blendMode: isDark ? 'screen' : 'multiply',
marginLeft: 0.01,
marginRight: 0.01,
marginTop: 0.01,
marginBottom: 0.01,
} : {
// Desktop: full effect
roundness: 0.12,
thickness: 0.14,
softness: 1.00,
intensity: isDark ? 0.08 : 0.04,
bloom: isDark ? 0.15 : 0.10,
spotSize: 0.36,
spots: 4,
pulse: 0.35,
smoke: 0.17,
smokeSize: 0.63,
speed: 0.64,
scale: 0.9,
opacity: isDark ? 0.7 : 0.8,
blendMode: isDark ? 'screen' : 'multiply',
marginLeft: 0.02,
marginRight: 0.02,
marginTop: 0.06,
marginBottom: 0.06,
};
return (
<>
<style>{`
.shader-background-container,
.shader-background-container canvas {
background-color: transparent !important;
}
`}</style>
<div
className="absolute pointer-events-none shader-background-container"
style={{
top: dimensions.top,
left: dimensions.left,
width: dimensions.width,
height: dimensions.height,
zIndex: 0,
mixBlendMode: config.blendMode,
opacity: isReady ? config.opacity : 0,
transition: 'opacity 0.8s ease-in-out',
backgroundColor: 'transparent',
}}
>
<PulsingBorder
width={dimensions.width}
height={dimensions.height}
colors={colors}
colorBack="#00000000"
roundness={config.roundness}
thickness={config.thickness}
softness={config.softness}
aspectRatio="auto"
intensity={config.intensity}
bloom={config.bloom}
spots={config.spots}
spotSize={config.spotSize}
pulse={config.pulse}
smoke={config.smoke}
smokeSize={config.smokeSize}
speed={config.speed}
scale={config.scale}
rotation={0}
offsetX={0.00}
offsetY={0.00}
marginLeft={config.marginLeft}
marginRight={config.marginRight}
marginTop={config.marginTop}
marginBottom={config.marginBottom}
/>
</div>
</>
);
}

View File

@@ -0,0 +1,4 @@
---
// ThemeProvider is no longer needed with Tailwind's media strategy
// Dark mode now works automatically based on system preferences
---

View File

@@ -0,0 +1,62 @@
---
export interface Props {
class?: string;
}
const { class: className = "" } = Astro.props;
---
<button
id="theme-toggle"
class={`group p-3 rounded-lg border border-border hover:border-muted-foreground/50 transition-all duration-300 ${className}`}
aria-label="Toggle theme"
>
<!-- Show sun icon in dark mode -->
<svg
id="sun-icon"
class="w-4 h-4 text-muted-foreground group-hover:text-foreground transition-colors duration-300 hidden dark:block"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
fill-rule="evenodd"
d="M10 2a1 1 0 011 1v1a1 1 0 11-2 0V3a1 1 0 011-1zm4 8a4 4 0 11-8 0 4 4 0 018 0zm-.464 4.95l.707.707a1 1 0 001.414-1.414l-.707-.707a1 1 0 00-1.414 1.414zm2.12-10.607a1 1 0 010 1.414l-.706.707a1 1 0 11-1.414-1.414l.707-.707a1 1 0 011.414 0zM17 11a1 1 0 100-2h-1a1 1 0 100 2h1zm-7 4a1 1 0 011 1v1a1 1 0 11-2 0v-1a1 1 0 011-1zM5.05 6.464A1 1 0 106.465 5.05l-.708-.707a1 1 0 00-1.414 1.414l.707.707zm1.414 8.486l-.707.707a1 1 0 01-1.414-1.414l.707-.707a1 1 0 011.414 1.414zM4 11a1 1 0 100-2H3a1 1 0 000 2h1z"
clip-rule="evenodd"
/>
</svg>
<!-- Show moon icon in light mode -->
<svg
id="moon-icon"
class="w-4 h-4 text-muted-foreground group-hover:text-foreground transition-colors duration-300 block dark:hidden"
fill="currentColor"
viewBox="0 0 20 20"
>
<path d="M17.293 13.293A8 8 0 016.707 2.707a8.001 8.001 0 1010.586 10.586z" />
</svg>
</button>
<script>
function toggleTheme() {
const isDark = document.documentElement.classList.contains('dark');
if (isDark) {
document.documentElement.classList.remove('dark');
localStorage.theme = 'light';
} else {
document.documentElement.classList.add('dark');
localStorage.theme = 'dark';
}
}
// Initialize theme toggle
document.addEventListener('DOMContentLoaded', () => {
const themeToggle = document.getElementById('theme-toggle');
themeToggle?.addEventListener('click', toggleTheme);
});
// Handle Astro page transitions
document.addEventListener('astro:page-load', () => {
const themeToggle = document.getElementById('theme-toggle');
themeToggle?.addEventListener('click', toggleTheme);
});
</script>

View File

@@ -0,0 +1,2 @@
export { default as ThemeProvider } from './ThemeProvider.astro';
export { default as ThemeToggle } from './ThemeToggle.astro';

View File

@@ -0,0 +1,293 @@
---
// Pure Astro QR Code Generator Component
---
<div class="space-y-8" id="qr-generator">
<!-- Input Section -->
<div class="space-y-4">
<div>
<label
for="url-input"
class="block text-sm font-medium text-foreground mb-2"
>
Enter URL
</label>
<input
id="url-input"
type="text"
placeholder="https://example.com"
class="w-full px-4 py-3 bg-background text-foreground border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-ring focus:border-transparent transition-all"
autocomplete="off"
/>
</div>
<div id="error-message" class="hidden p-3 bg-destructive/10 border border-destructive/20 rounded-lg">
<p class="text-sm text-destructive"></p>
</div>
<button
id="clear-button"
class="hidden text-sm text-muted-foreground hover:text-foreground transition-colors"
>
Clear
</button>
</div>
<!-- QR Code Display -->
<div id="qr-display" class="hidden space-y-6">
<div class="flex justify-center">
<div class="bg-white p-6 rounded-lg shadow-lg">
<canvas id="qr-canvas"></canvas>
</div>
</div>
<!-- Action Buttons -->
<div class="flex items-center justify-center gap-4 flex-wrap">
<button
id="download-button"
class="button-primary"
>
<svg
class="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width={2}
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
/>
</svg>
Download PNG
</button>
<button
id="copy-button"
class="button-secondary"
>
<svg
id="copy-icon"
class="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width={2}
d="M8.684 13.342C8.886 12.938 9 12.482 9 12c0-.482-.114-.938-.316-1.342m0 2.684a3 3 0 110-2.684m0 2.684l6.632 3.316m-6.632-6l6.632-3.316m0 0a3 3 0 105.367-2.684 3 3 0 00-5.367 2.684zm0 9.316a3 3 0 105.368 2.684 3 3 0 00-5.368-2.684z"
/>
</svg>
<svg
id="check-icon"
class="w-4 h-4 hidden"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width={2}
d="M5 13l4 4L19 7"
/>
</svg>
<span id="copy-text">Share / Copy</span>
</button>
</div>
<!-- Info Card -->
<div class="card-base p-4">
<h3 class="text-sm font-medium text-foreground mb-2">
Usage Tips
</h3>
<ul class="text-sm text-muted-foreground space-y-1">
<li>• Scan with any QR code reader app</li>
<li>• Download as PNG for presentations</li>
<li>• Share button opens native share menu on mobile</li>
<li>• Works offline once generated</li>
<li>• No data is stored or transmitted</li>
</ul>
</div>
</div>
<!-- Empty State -->
<div id="empty-state" class="text-center py-12 space-y-3">
<div class="text-4xl">📱</div>
<p class="text-muted-foreground">
Enter a URL above to generate a QR code
</p>
</div>
</div>
<script>
import QRCode from 'qrcode';
let qrDataUrl = '';
const urlInput = document.getElementById('url-input') as HTMLInputElement;
const qrCanvas = document.getElementById('qr-canvas') as HTMLCanvasElement;
const qrDisplay = document.getElementById('qr-display');
const emptyState = document.getElementById('empty-state');
const errorMessage = document.getElementById('error-message');
const clearButton = document.getElementById('clear-button');
const downloadButton = document.getElementById('download-button');
const copyButton = document.getElementById('copy-button');
const copyIcon = document.getElementById('copy-icon');
const checkIcon = document.getElementById('check-icon');
const copyText = document.getElementById('copy-text');
async function generateQRCode(text: string) {
try {
errorMessage?.classList.add('hidden');
if (qrCanvas) {
// Generate QR code on canvas
await QRCode.toCanvas(qrCanvas, text, {
width: 300,
margin: 2,
color: {
dark: '#000000',
light: '#FFFFFF',
},
});
// Get data URL for download
qrDataUrl = qrCanvas.toDataURL('image/png');
// Show QR display, hide empty state
qrDisplay?.classList.remove('hidden');
emptyState?.classList.add('hidden');
}
} catch (err) {
const errorText = errorMessage?.querySelector('p');
if (errorText) {
errorText.textContent = 'Failed to generate QR code. Please check your URL.';
}
errorMessage?.classList.remove('hidden');
console.error(err);
}
}
function handleInput() {
const url = urlInput?.value.trim() || '';
if (url) {
clearButton?.classList.remove('hidden');
generateQRCode(url);
} else {
qrDisplay?.classList.add('hidden');
emptyState?.classList.remove('hidden');
errorMessage?.classList.add('hidden');
clearButton?.classList.add('hidden');
}
}
function handleClear() {
if (urlInput) urlInput.value = '';
qrDataUrl = '';
qrDisplay?.classList.add('hidden');
emptyState?.classList.remove('hidden');
errorMessage?.classList.add('hidden');
clearButton?.classList.add('hidden');
}
function handleDownload() {
if (qrDataUrl) {
const link = document.createElement('a');
link.download = `qr-code-${Date.now()}.png`;
link.href = qrDataUrl;
link.click();
}
}
async function handleCopy() {
if (!qrDataUrl) return;
try {
// Convert data URL to blob
const response = await fetch(qrDataUrl);
const blob = await response.blob();
// Try Web Share API first (works great on mobile)
if (navigator.share && navigator.canShare) {
const file = new File([blob], 'qr-code.png', { type: 'image/png' });
const shareData = {
files: [file],
title: 'QR Code',
text: 'Scan this QR code'
};
if (navigator.canShare(shareData)) {
await navigator.share(shareData);
// Show success state
copyIcon?.classList.add('hidden');
checkIcon?.classList.remove('hidden');
if (copyText) copyText.textContent = 'Shared!';
setTimeout(() => {
copyIcon?.classList.remove('hidden');
checkIcon?.classList.add('hidden');
if (copyText) copyText.textContent = 'Share / Copy';
}, 2000);
return;
}
}
// Fallback to Clipboard API (works on desktop)
if (navigator.clipboard && navigator.clipboard.write) {
await navigator.clipboard.write([
new ClipboardItem({ 'image/png': blob })
]);
// Show success state
copyIcon?.classList.add('hidden');
checkIcon?.classList.remove('hidden');
if (copyText) copyText.textContent = 'Copied!';
setTimeout(() => {
copyIcon?.classList.remove('hidden');
checkIcon?.classList.add('hidden');
if (copyText) copyText.textContent = 'Share / Copy';
}, 2000);
return;
}
// Final fallback: trigger download
handleDownload();
if (copyText) copyText.textContent = 'Downloaded!';
setTimeout(() => {
if (copyText) copyText.textContent = 'Share / Copy';
}, 2000);
} catch (err) {
console.error('Share/copy failed:', err);
// If share was cancelled by user, don't show error
if (err instanceof Error && err.name === 'AbortError') {
return;
}
// Show helpful error message
const errorText = errorMessage?.querySelector('p');
if (errorText) {
errorText.textContent = 'Unable to copy. Try the download button instead.';
}
errorMessage?.classList.remove('hidden');
setTimeout(() => {
errorMessage?.classList.add('hidden');
}, 3000);
}
}
// Event listeners
urlInput?.addEventListener('input', handleInput);
clearButton?.addEventListener('click', handleClear);
downloadButton?.addEventListener('click', handleDownload);
copyButton?.addEventListener('click', handleCopy);
</script>

View File

@@ -0,0 +1,386 @@
---
// Pure Astro Tip Calculator Component
---
<div class="space-y-6" id="tip-calculator">
<!-- Receipt Info Section -->
<div class="space-y-4">
<div class="text-sm text-muted-foreground mb-4">
Enter the amounts from your receipt:
</div>
<!-- Amount to Tip On -->
<div>
<label
for="tip-base-input"
class="block text-sm font-medium text-foreground mb-2"
>
Amount to Tip On <span class="text-muted-foreground text-xs font-normal">(pre-tax food & drinks)</span>
</label>
<div class="relative">
<span class="absolute left-4 top-1/2 -translate-y-1/2 text-muted-foreground">$</span>
<input
id="tip-base-input"
type="number"
step="0.01"
min="0"
placeholder="0.00"
class="w-full pl-8 pr-4 py-3 bg-background text-foreground border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-ring focus:border-transparent transition-all text-lg"
autocomplete="off"
/>
</div>
</div>
<!-- Subtotal on Receipt -->
<div>
<label
for="receipt-subtotal-input"
class="block text-sm font-medium text-foreground mb-2"
>
Subtotal on Receipt <span class="text-muted-foreground text-xs font-normal">(amount above tip line)</span>
</label>
<div class="relative">
<span class="absolute left-4 top-1/2 -translate-y-1/2 text-muted-foreground">$</span>
<input
id="receipt-subtotal-input"
type="number"
step="0.01"
min="0"
placeholder="0.00"
class="w-full pl-8 pr-4 py-3 bg-background text-foreground border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-ring focus:border-transparent transition-all text-lg"
autocomplete="off"
/>
</div>
<p class="text-xs text-muted-foreground mt-1.5">
This includes tax & fees
</p>
</div>
<!-- Tip Percentage Buttons -->
<div>
<label class="block text-sm font-medium text-foreground mb-2">
Tip Percentage
</label>
<div class="grid grid-cols-4 gap-2">
<button
class="tip-btn px-4 py-3 text-sm font-medium rounded-lg transition-all duration-300 border border-border hover:border-muted-foreground text-foreground"
data-percent="15"
>
15%
</button>
<button
class="tip-btn px-4 py-3 text-sm font-medium rounded-lg transition-all duration-300 border border-border hover:border-muted-foreground text-foreground"
data-percent="18"
>
18%
</button>
<button
class="tip-btn px-4 py-3 text-sm font-medium rounded-lg transition-all duration-300 border border-border hover:border-muted-foreground text-foreground"
data-percent="20"
>
20%
</button>
<button
class="tip-btn px-4 py-3 text-sm font-medium rounded-lg transition-all duration-300 border border-border hover:border-muted-foreground text-foreground"
data-percent="custom"
>
Custom
</button>
</div>
</div>
<!-- Custom Tip Input (hidden by default) -->
<div id="custom-tip-container" class="hidden">
<label
for="custom-tip-input"
class="block text-sm font-medium text-foreground mb-2"
>
Custom Tip %
</label>
<div class="relative">
<input
id="custom-tip-input"
type="number"
step="0.5"
min="0"
max="100"
placeholder="0"
class="w-full pr-8 pl-4 py-3 bg-background text-foreground border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-ring focus:border-transparent transition-all"
autocomplete="off"
/>
<span class="absolute right-4 top-1/2 -translate-y-1/2 text-muted-foreground">%</span>
</div>
</div>
<!-- Divider with "or" -->
<div class="relative flex items-center justify-center">
<div class="section-divider"></div>
<span class="absolute px-3 bg-background text-xs text-muted-foreground uppercase tracking-wide">or</span>
</div>
<!-- Final Total Input (alternative input method) -->
<div>
<label
for="final-total-input"
class="block text-sm font-medium text-foreground mb-2"
>
Final Total <span class="text-muted-foreground text-xs font-normal">(to calculate tip %)</span>
</label>
<div class="relative">
<span class="absolute left-4 top-1/2 -translate-y-1/2 text-muted-foreground">$</span>
<input
id="final-total-input"
type="number"
step="0.01"
min="0"
placeholder="0.00"
class="w-full pl-8 pr-4 py-3 bg-background text-foreground border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-ring focus:border-transparent transition-all text-lg"
autocomplete="off"
/>
</div>
<p class="text-xs text-muted-foreground mt-1.5">
Enter the amount you want to pay (e.g., $50, $55, $60)
</p>
</div>
<!-- Round Up Option -->
<div class="flex items-center gap-3 p-4 border border-border rounded-lg">
<input
type="checkbox"
id="round-up-checkbox"
class="w-4 h-4 text-foreground bg-background border-border rounded focus:ring-2 focus:ring-ring transition-all cursor-pointer"
/>
<label
for="round-up-checkbox"
class="text-sm font-medium text-foreground cursor-pointer select-none"
>
Round up to nearest dollar
</label>
</div>
</div>
<!-- Results Display -->
<div id="results-display" class="hidden space-y-4">
<!-- What to Write on Receipt -->
<div class="card-base p-5 space-y-4">
<div class="text-sm font-semibold text-foreground mb-3">
Write on your receipt:
</div>
<div class="space-y-3">
<div class="flex justify-between items-baseline">
<span class="text-muted-foreground">Tip (<span id="breakdown-percent">0</span>%)</span>
<span id="breakdown-tip" class="text-foreground font-bold text-xl">$0.00</span>
</div>
<div class="section-divider"></div>
<div class="flex justify-between items-baseline">
<span class="text-foreground font-semibold">Total</span>
<span id="breakdown-total" class="text-foreground font-bold text-2xl">$0.00</span>
</div>
</div>
</div>
<!-- Bill Breakdown (for reference) -->
<div class="text-xs text-muted-foreground space-y-1">
<div class="flex justify-between">
<span>Amount tipped on:</span>
<span id="breakdown-tip-base">$0.00</span>
</div>
<div class="flex justify-between">
<span>Receipt subtotal:</span>
<span id="breakdown-receipt-subtotal">$0.00</span>
</div>
</div>
</div>
<!-- Empty State -->
<div id="empty-state" class="text-center py-12 space-y-3">
<div class="text-4xl">🧾</div>
<p class="text-muted-foreground">
Enter receipt amounts and tip % to calculate
</p>
</div>
</div>
<script>
const tipBaseInput = document.getElementById('tip-base-input') as HTMLInputElement;
const receiptSubtotalInput = document.getElementById('receipt-subtotal-input') as HTMLInputElement;
const customTipInput = document.getElementById('custom-tip-input') as HTMLInputElement;
const customTipContainer = document.getElementById('custom-tip-container');
const finalTotalInput = document.getElementById('final-total-input') as HTMLInputElement;
const roundUpCheckbox = document.getElementById('round-up-checkbox') as HTMLInputElement;
const tipButtons = document.querySelectorAll('.tip-btn');
const resultsDisplay = document.getElementById('results-display');
const emptyState = document.getElementById('empty-state');
const breakdownTipBase = document.getElementById('breakdown-tip-base');
const breakdownReceiptSubtotal = document.getElementById('breakdown-receipt-subtotal');
const breakdownPercent = document.getElementById('breakdown-percent');
const breakdownTip = document.getElementById('breakdown-tip');
const breakdownTotal = document.getElementById('breakdown-total');
let selectedTipPercent = 18; // Default to 18%
let isCustomTip = false;
let isReverseMode = false; // Track if user is entering final total
function formatCurrency(amount: number): string {
return `$${amount.toFixed(2)}`;
}
// Calculate tip from percentage (forward calculation)
function calculateFromTipPercent() {
const tipBase = parseFloat(tipBaseInput?.value || '0');
const receiptSubtotal = parseFloat(receiptSubtotalInput?.value || '0');
if (tipBase <= 0 || receiptSubtotal <= 0) {
resultsDisplay?.classList.add('hidden');
emptyState?.classList.remove('hidden');
return;
}
let tipPercent = selectedTipPercent;
if (isCustomTip) {
tipPercent = parseFloat(customTipInput?.value || '0');
}
// Calculate tip on the pre-tax amount (tipBase)
let tip = tipBase * (tipPercent / 100);
// Grand total = receipt subtotal + tip
let grandTotal = receiptSubtotal + tip;
// Apply rounding if enabled
if (roundUpCheckbox?.checked) {
const roundedTotal = Math.ceil(grandTotal);
const roundingAmount = roundedTotal - grandTotal;
grandTotal = roundedTotal;
tip += roundingAmount; // Add rounding to tip
}
// Calculate effective tip percentage (includes rounding)
const effectiveTipPercent = (tip / tipBase) * 100;
// Update breakdown
if (breakdownTipBase) breakdownTipBase.textContent = formatCurrency(tipBase);
if (breakdownReceiptSubtotal) breakdownReceiptSubtotal.textContent = formatCurrency(receiptSubtotal);
if (breakdownPercent) breakdownPercent.textContent = effectiveTipPercent.toFixed(1);
if (breakdownTip) breakdownTip.textContent = formatCurrency(tip);
if (breakdownTotal) breakdownTotal.textContent = formatCurrency(grandTotal);
// Show results, hide empty state
resultsDisplay?.classList.remove('hidden');
emptyState?.classList.add('hidden');
}
// Calculate tip percentage from final total (reverse calculation)
function calculateFromFinalTotal() {
const tipBase = parseFloat(tipBaseInput?.value || '0');
const receiptSubtotal = parseFloat(receiptSubtotalInput?.value || '0');
const grandTotal = parseFloat(finalTotalInput?.value || '0');
if (tipBase <= 0 || receiptSubtotal <= 0 || grandTotal <= 0) {
resultsDisplay?.classList.add('hidden');
emptyState?.classList.remove('hidden');
return;
}
if (grandTotal < receiptSubtotal) {
// Invalid: grand total can't be less than receipt subtotal
resultsDisplay?.classList.add('hidden');
emptyState?.classList.remove('hidden');
return;
}
const tip = grandTotal - receiptSubtotal;
const tipPercent = (tip / tipBase) * 100;
// Update breakdown
if (breakdownTipBase) breakdownTipBase.textContent = formatCurrency(tipBase);
if (breakdownReceiptSubtotal) breakdownReceiptSubtotal.textContent = formatCurrency(receiptSubtotal);
if (breakdownPercent) breakdownPercent.textContent = tipPercent.toFixed(1);
if (breakdownTip) breakdownTip.textContent = formatCurrency(tip);
if (breakdownTotal) breakdownTotal.textContent = formatCurrency(grandTotal);
// Show results, hide empty state
resultsDisplay?.classList.remove('hidden');
emptyState?.classList.add('hidden');
}
function handleTipButtonClick(event: Event) {
const button = event.currentTarget as HTMLButtonElement;
const percent = button.getAttribute('data-percent');
// Switch to forward mode
isReverseMode = false;
if (finalTotalInput) finalTotalInput.value = '';
// Update active state - toggle Tailwind classes
tipButtons.forEach(btn => {
btn.classList.remove('bg-foreground', 'text-background');
btn.classList.add('text-foreground');
});
button.classList.add('bg-foreground', 'text-background');
button.classList.remove('text-foreground');
if (percent === 'custom') {
isCustomTip = true;
customTipContainer?.classList.remove('hidden');
customTipInput?.focus();
} else {
isCustomTip = false;
customTipContainer?.classList.add('hidden');
selectedTipPercent = parseFloat(percent || '18');
}
calculateFromTipPercent();
}
function handleReceiptInput() {
if (isReverseMode) {
calculateFromFinalTotal();
} else {
calculateFromTipPercent();
}
}
function handleFinalTotalInput() {
// Switch to reverse mode
isReverseMode = true;
// Deactivate all tip buttons
tipButtons.forEach(btn => {
btn.classList.remove('bg-foreground', 'text-background');
btn.classList.add('text-foreground');
});
// Hide custom tip input
customTipContainer?.classList.add('hidden');
isCustomTip = false;
// Disable round-up checkbox in reverse mode
if (roundUpCheckbox) {
roundUpCheckbox.checked = false;
}
calculateFromFinalTotal();
}
// Event listeners
tipBaseInput?.addEventListener('input', handleReceiptInput);
receiptSubtotalInput?.addEventListener('input', handleReceiptInput);
customTipInput?.addEventListener('input', calculateFromTipPercent);
finalTotalInput?.addEventListener('input', handleFinalTotalInput);
roundUpCheckbox?.addEventListener('change', calculateFromTipPercent);
tipButtons.forEach(btn => {
btn.addEventListener('click', handleTipButtonClick);
});
// Set default tip to 18%
const defaultButton = document.querySelector('.tip-btn[data-percent="18"]') as HTMLButtonElement;
if (defaultButton) {
defaultButton.classList.add('bg-foreground', 'text-background');
defaultButton.classList.remove('text-foreground');
}
</script>

View File

@@ -0,0 +1,30 @@
---
export interface Props {
class?: string;
ariaLabel?: string;
onClick?: string;
variant?: 'outline' | 'primary' | 'secondary' | 'ghost';
}
const {
class: className = "",
ariaLabel,
onClick,
variant = 'outline'
} = Astro.props;
const variantClasses = {
outline: 'button-outline p-3',
primary: 'button-primary',
secondary: 'button-secondary',
ghost: 'button-ghost'
};
---
<button
class={`group ${variantClasses[variant]} ${className}`}
aria-label={ariaLabel}
onclick={onClick}
>
<slot />
</button>

View File

@@ -0,0 +1,25 @@
---
export interface Props {
name: string;
handle: string;
url: string;
}
const { name, handle, url } = Astro.props;
---
<a
href={url}
class="group p-4 border border-border rounded-lg hover:border-muted-foreground/50 transition-all duration-300 hover:shadow-sm block"
target="_blank"
rel="noopener noreferrer"
>
<div class="flex items-center justify-between">
<div class="text-foreground group-hover:text-muted-foreground transition-colors duration-300">
{name}
</div>
<svg class="w-4 h-4 text-muted-foreground group-hover:translate-x-1 transition-transform" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path>
</svg>
</div>
</a>

View File

@@ -0,0 +1,29 @@
---
export interface Props {
status: "live-baseline" | "replay" | "snapshot-only";
}
const { status } = Astro.props;
const statusMap = {
"live-baseline": {
text: "Live Baseline",
color: "bg-status-success",
},
replay: {
text: "Replay",
color: "bg-status-info",
},
"snapshot-only": {
text: "Snapshot",
color: "bg-status-neutral",
},
};
const { text, color } = statusMap[status];
---
<div class={`flex items-center gap-2 text-xs font-mono`}>
<span class={`w-2 h-2 rounded-full ${color}`}></span>
<span>{text}</span>
</div>

View File

@@ -0,0 +1,16 @@
---
export interface Props {
isAvailable?: boolean;
statusText?: string;
}
const {
isAvailable = true,
statusText = "Available for work"
} = Astro.props;
---
<div class="flex items-center gap-2">
<div class={`w-2 h-2 rounded-full ${isAvailable ? 'bg-green-500 animate-pulse' : 'bg-gray-400'}`}></div>
{statusText}
</div>

View File

@@ -0,0 +1,47 @@
---
export interface Props {
text: string;
variant?: 'tech' | 'skill' | 'blog' | 'status';
size?: 'sm' | 'md';
interactive?: boolean;
}
const {
text,
variant = 'tech',
size = 'sm',
interactive = false
} = Astro.props;
// Base styles
const baseStyles = 'text-xs transition-colors';
// Variant-specific styles
const variantStyles = {
tech: 'px-2 py-1 text-muted-foreground rounded group-hover:border-muted-foreground/50 duration-500',
skill: 'px-3 py-1 border border-border rounded-full hover:border-muted-foreground/50 duration-300',
blog: 'px-2 py-1 bg-muted text-muted-foreground rounded-md hover:bg-muted/80 duration-200',
status: 'px-2 py-1 bg-primary/10 text-primary border border-primary/20 rounded-md'
};
// Size-specific styles
const sizeStyles = {
sm: 'text-xs',
md: 'text-sm px-3 py-1.5'
};
// Interactive styles
const interactiveStyles = interactive ? 'cursor-pointer hover:scale-105' : '';
// Combine all styles
const className = [
baseStyles,
variantStyles[variant],
sizeStyles[size],
interactiveStyles
].filter(Boolean).join(' ');
---
<span class={className}>
{text}
</span>

View File

@@ -0,0 +1,5 @@
export { default as Button } from './Button.astro';
export { default as Tag } from './Tag.astro';
export { default as StatusBadge } from './StatusBadge.astro';
export { default as StatusIndicator } from './StatusIndicator.astro';
export { default as SocialLink } from './SocialLink.astro';

View File

@@ -0,0 +1,101 @@
/**
* Content Configuration
* This file contains static content that can later be moved to Directus
*/
export const portfolioData = {
name: "John",
firstName: "John",
lastName: "Chen",
title: "Full Stack Developer",
description: "loud infrastructure.",
location: "San Francisco",
email: "john.hk.chen@gmail.com",
portfolioYear: "2025",
availability: true,
availabilityText: "Looking for work",
currentRole: {
title: "AI Engineer",
company: "Your Company?",
duration: "2025 - Present"
},
skills: [
"Python",
"FastAPI",
"Rust",
"TypeScript",
"React",
"Astro",
"Node.js",
"Docker",
"Tailwind CSS"
],
workSection: {
title: "Selected Work",
dateRange: "2019 — 2025"
},
socialLinks: [
{
name: "GitHub",
handle: "@yourusername",
url: "https://github.com/yourusername"
},
{
name: "LinkedIn",
handle: "yourname",
url: "https://linkedin.com/in/yourname"
},
{
name: "Email",
handle: "your@email.com",
url: "mailto:your@email.com"
}
],
connectTitle: "Let's Connect",
connectDescription: "Always interested in new opportunities, collaborations, and conversations about technology and design.",
footer: {
copyright: "John Chen",
attribution: "Built with Astro and Cloudflare @ b28.dev",
year: "2025"
}
};
export const workProjects = [
{
slug: "project-1",
data: {
title: "Project One",
company: "Company Name",
role: "Full Stack Developer",
year: "2023",
description: "Built a modern web application with Astro, Hono, and Directus CMS.",
technologies: ["Astro", "TypeScript", "Hono", "Directus"],
featured: true,
order: 1,
status: "completed",
duration: "6 months"
}
},
{
slug: "project-2",
data: {
title: "Project Two",
company: "Another Company",
role: "Frontend Developer",
year: "2022",
description: "Created responsive user interfaces with modern frameworks.",
technologies: ["React", "Tailwind CSS", "Next.js"],
featured: true,
order: 2,
status: "completed",
duration: "4 months"
}
}
];

View File

@@ -0,0 +1,297 @@
/**
* Content API - Directus Integration
*
* This provides a similar API to Astro's content collections
* but fetches data from Directus CMS instead.
*/
import { api } from '../lib/api';
import { portfolioData, workProjects } from './config';
// Types matching the template expectations
export interface BlogPost {
slug: string;
data: {
title: string;
excerpt: string;
date: Date;
readTime: string;
published: boolean;
featured?: boolean;
tags?: string[];
};
body?: string;
}
export interface WorkProject {
slug: string;
data: {
title: string;
company: string;
role: string;
year: string;
description: string;
technologies: string[];
featured: boolean;
order: number;
status?: string;
duration?: string;
category?: string;
};
}
export interface PortfolioData {
data: typeof portfolioData;
}
/**
* Get portfolio/profile data
*/
export async function getEntry(collection: string, id: string) {
if (collection === 'portfolio' && id === 'main') {
try {
const response = await api.getProfile();
if (!response || !response.data) {
// Fallback to config file
return { data: portfolioData };
}
// Transform Directus profile to match template expectations
const profile = response.data;
return {
data: {
name: profile.full_name,
firstName: profile.first_name,
lastName: profile.last_name,
title: profile.title,
description: profile.description,
location: profile.location,
email: profile.email,
portfolioYear: profile.portfolio_year,
availability: profile.availability,
availabilityText: profile.availability_text,
currentRole: {
title: profile.current_role_title,
company: profile.current_role_company,
duration: profile.current_role_duration
},
workSection: {
title: profile.work_section_title,
dateRange: profile.work_section_date_range
},
connectTitle: profile.connect_title,
connectDescription: profile.connect_description,
footer: {
copyright: profile.footer_copyright,
attribution: profile.footer_attribution,
year: profile.footer_year
}
}
};
} catch (error) {
console.error('Error fetching profile from Directus:', error);
// Fallback to config file
return { data: portfolioData };
}
}
throw new Error(`Collection ${collection} not found`);
}
/**
* Get all blog posts from Directus
*/
export async function getCollection(collection: string) {
if (collection === 'blog') {
try {
// Fetch posts from Directus via our Hono API
const response = await api.getPosts();
if (!response || !response.data) {
return [];
}
// Helper to generate clean excerpt
const generateExcerpt = (content: string): string => {
if (!content) return '';
// Remove the main title (first h1)
let cleaned = content.replace(/^#\s+.+?\n+/m, '');
// Remove code blocks entirely (they don't work well in previews)
cleaned = cleaned.replace(/```[\s\S]*?```/g, '');
// Strip markdown syntax but preserve line structure
cleaned = cleaned
.replace(/#{1,6}\s+/g, '') // Remove headers
.replace(/\*\*(.+?)\*\*/g, '$1') // Remove bold
.replace(/\*(.+?)\*/g, '$1') // Remove italic
.replace(/\[(.+?)\]\(.+?\)/g, '$1') // Remove links
.replace(/`(.+?)`/g, '$1') // Remove inline code
.replace(/^\s*[-*+]\s+/gm, '') // Remove list markers
.replace(/^\s*\d+\.\s+/gm, '') // Remove numbered lists
.trim();
// Find the first paragraph (text before double newline or first 2-3 sentences)
const paragraphs = cleaned.split(/\n\s*\n/);
let excerpt = paragraphs[0] || cleaned;
// If first paragraph is too long, take first 2 sentences
if (excerpt.length > 200) {
const sentences = excerpt.match(/[^.!?]+[.!?]+/g) || [excerpt];
excerpt = sentences.slice(0, 2).join(' ');
}
// Limit to 200 characters with ellipsis if needed
if (excerpt.length > 200) {
excerpt = excerpt.substring(0, 197) + '...';
}
return excerpt;
};
// Helper to generate URL-friendly slug from title
const generateSlug = (title: string): string => {
return title
.toLowerCase()
.replace(/[^\w\s-]/g, '') // Remove special characters
.replace(/\s+/g, '-') // Replace spaces with hyphens
.replace(/-+/g, '-') // Replace multiple hyphens with single
.trim();
};
// Transform Directus posts to match template expectations
return response.data
.filter((post: any) => post.published)
.map((post: any, index: number) => ({
slug: generateSlug(post.title || `post-${post.id}`),
data: {
title: post.title || 'Untitled',
excerpt: generateExcerpt(post.content || ''),
date: post.date_created ? new Date(post.date_created) : new Date(),
readTime: '5 min', // Calculate or add to Directus
published: post.published,
featured: index === 0, // First post is featured
tags: post.tags || []
},
body: post.content || ''
}));
} catch (error) {
console.error('Error fetching blog posts:', error);
return [];
}
}
if (collection === 'work') {
try {
// Fetch work projects from Directus
const response = await api.getWorkProjects();
if (!response || !response.data) {
return workProjects; // Fallback
}
// Transform Directus work projects to match template expectations
return response.data.map((project: any) => ({
slug: project.slug,
data: {
title: project.title,
company: project.company,
role: project.role,
year: project.year,
description: project.description,
technologies: project.technologies || [],
featured: project.featured,
order: project.order,
status: project.status,
duration: project.duration,
category: project.category,
content: project.content,
team: project.team,
live_url: project.live_url,
case_study_url: project.case_study_url
}
}));
} catch (error) {
console.error('Error fetching work projects:', error);
return workProjects; // Fallback
}
}
if (collection === 'skills') {
try {
const response = await api.getSkills();
return response.data || [];
} catch (error) {
console.error('Error fetching skills:', error);
return [];
}
}
if (collection === 'social') {
try {
const response = await api.getSocialLinks();
if (!response || !response.data) {
return [];
}
// Transform to match existing format
return response.data.map((link: any) => ({
name: link.name,
handle: link.handle,
url: link.url,
icon: link.icon
}));
} catch (error) {
console.error('Error fetching social links:', error);
return [];
}
}
return [];
}
/**
* Get a single blog post by slug
*/
export async function getBlogPost(slug: string) {
const posts = await getCollection('blog');
return posts.find((post: BlogPost) => post.slug === slug);
}
/**
* Get a single work project by slug
*/
export async function getWorkProject(slug: string) {
try {
const response = await api.getWorkProject(slug);
if (response && response.data) {
const project = response.data;
return {
slug: project.slug,
data: {
title: project.title,
company: project.company,
role: project.role,
year: project.year,
description: project.description,
technologies: project.technologies || [],
featured: project.featured,
order: project.order,
status: project.status,
duration: project.duration,
category: project.category,
content: project.content,
team: project.team,
live_url: project.live_url,
case_study_url: project.case_study_url
}
};
}
} catch (error) {
console.error(`Error fetching work project ${slug}:`, error);
}
// Fallback
return workProjects.find(project => project.slug === slug);
}
// Re-export config for direct access
export { portfolioData, workProjects };

View File

@@ -0,0 +1,77 @@
---
import '../styles/globals.css';
import ThemeProvider from '../components/shared/ThemeProvider.astro';
export interface Props {
title?: string;
}
const { title = "Portfolio Template" } = Astro.props;
---
<!doctype html>
<html lang="en" class="">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="generator" content={Astro.generator} />
<title>{title}</title>
<script is:inline>
// Prevent FOUC by applying theme immediately (runs before bundling)
if (localStorage.theme === 'dark' || (!('theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
</script>
</head>
<body class="font-sans antialiased">
<ThemeProvider />
<slot />
<script>
// Animation and scroll functionality
document.addEventListener('DOMContentLoaded', function() {
// Smooth scrolling for the entire document
document.documentElement.style.scrollBehavior = 'smooth';
// Set up Intersection Observer for animations
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
entry.target.classList.add('animate-fade-in-up');
// Track active section for navigation
if (entry.target.id) {
window.activeSection = entry.target.id;
// Dispatch custom event for navigation updates
window.dispatchEvent(new CustomEvent('activeSectionChange', {
detail: { sectionId: entry.target.id }
}));
}
}
});
},
{ threshold: 0.3, rootMargin: '0px 0px -20% 0px' }
);
// Observe all sections
const sections = document.querySelectorAll('section, [data-section]');
sections.forEach((section) => {
if (section) observer.observe(section);
});
// Smooth scroll to section function
window.scrollToSection = function(sectionId) {
const element = document.getElementById(sectionId);
if (element) {
element.scrollIntoView({ behavior: 'smooth' });
}
};
// Initialize active section
window.activeSection = null;
});
</script>
</body>
</html>

View File

@@ -0,0 +1,70 @@
---
import '../styles/globals.css';
import ThemeProvider from '../components/shared/ThemeProvider.astro';
export interface Props {
title?: string;
description?: string;
}
const {
title = "Portfolio Template",
description = "A modern portfolio built with Astro and Tailwind CSS"
} = Astro.props;
---
<!doctype html>
<html lang="en" class="">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="generator" content={Astro.generator} />
<meta name="description" content={description} />
<title>{title}</title>
</head>
<body class="font-sans antialiased">
<ThemeProvider />
<div class="min-h-screen bg-background text-foreground relative">
<slot />
<div class="fixed bottom-0 left-0 right-0 h-24 bg-gradient-to-t from-background via-background/80 to-transparent pointer-events-none"></div>
</div>
<script>
// Intersection Observer for section visibility
document.addEventListener('DOMContentLoaded', function() {
const sections = document.querySelectorAll('[data-section]');
const navButtons = document.querySelectorAll('[data-nav-button]');
const observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
// Add fade-in animation
entry.target.classList.add('animate-fade-in-up');
// Update active navigation
const sectionId = entry.target.getAttribute('data-section');
navButtons.forEach(button => {
const buttonSection = button.getAttribute('data-nav-button');
if (buttonSection === sectionId) {
button.classList.remove('bg-muted-foreground/30', 'hover:bg-muted-foreground/60');
button.classList.add('bg-foreground');
} else {
button.classList.remove('bg-foreground');
button.classList.add('bg-muted-foreground/30', 'hover:bg-muted-foreground/60');
}
});
}
});
}, {
threshold: 0.3,
rootMargin: '0px 0px -20% 0px'
});
sections.forEach((section) => {
observer.observe(section);
});
});
</script>
</body>
</html>

View File

@@ -0,0 +1,75 @@
---
import Layout from './Layout.astro';
import HeaderNavigation from '../components/layout/HeaderNavigation.astro';
import Footer from '../components/layout/Footer.astro';
export interface Props {
title?: string;
description?: string;
maxWidth?: 'default' | 'wide' | 'full';
}
const {
title = "Tools",
description = "Utility tools built with Astro",
maxWidth = 'wide'
} = Astro.props;
const containerClasses = {
default: 'content-container',
wide: 'content-container-wide',
full: 'content-container-full'
};
---
<Layout title={title}>
<HeaderNavigation showHome={true} currentPage={title} />
<main class={containerClasses[maxWidth]}>
<div class="min-h-[calc(100vh-4rem)] py-12 sm:py-16">
<!-- Tool Header -->
<div class="mb-12 opacity-0" data-animate>
{description && (
<p class="text-body text-muted-foreground max-w-2xl mt-4">
{description}
</p>
)}
<div class="section-divider mt-6"></div>
</div>
<!-- Tool Content -->
<div class="opacity-0" data-animate data-delay="200">
<slot />
</div>
</div>
<!-- Footer with Theme Toggle -->
<Footer />
</main>
<script>
// Animate elements on page load
document.addEventListener('DOMContentLoaded', () => {
const animateElements = document.querySelectorAll('[data-animate]');
animateElements.forEach((element) => {
const delay = element.getAttribute('data-delay') || '0';
setTimeout(() => {
element.classList.add('animate-fade-in-up');
}, parseInt(delay));
});
});
// Handle Astro page transitions
document.addEventListener('astro:page-load', () => {
const animateElements = document.querySelectorAll('[data-animate]');
animateElements.forEach((element) => {
const delay = element.getAttribute('data-delay') || '0';
setTimeout(() => {
element.classList.add('animate-fade-in-up');
}, parseInt(delay));
});
});
</script>
</Layout>

185
frontend/src/lib/api.ts Normal file
View File

@@ -0,0 +1,185 @@
// API client for communicating with Hono backend
const API_BASE_URL = import.meta.env.API_URL || 'http://astro-hono-api:3000'
export interface GreetingResponse {
greeting: string
timestamp: string
}
export interface UserResponse {
name: string
message: string
timestamp: string
}
export interface HealthResponse {
status: string
timestamp: string
service: string
}
export interface Post {
id: number
title: string
content: string
published: boolean
date_created: string
date_updated: string | null
}
export interface PostsResponse {
data: Post[]
count: number
}
export interface PostResponse {
data: Post
}
export interface Profile {
id: number
full_name: string
first_name: string
last_name: string
title: string
description: string
email: string
location?: string
portfolio_year?: string
availability?: boolean
availability_text?: string
current_role_title?: string
current_role_company?: string
current_role_duration?: string
work_section_title?: string
work_section_date_range?: string
connect_title?: string
connect_description?: string
footer_copyright?: string
footer_attribution?: string
footer_year?: string
}
export interface ProfileResponse {
data: Profile
}
export interface WorkProject {
id: number
status: string
title: string
slug: string
company: string
role: string
year: string
duration?: string
description: string
content?: string
technologies: string[]
featured: boolean
order?: number
team?: string
live_url?: string
case_study_url?: string
}
export interface WorkProjectsResponse {
data: WorkProject[]
count: number
}
export interface WorkProjectResponse {
data: WorkProject
}
export interface Skill {
id: number
name: string
category?: string
proficiency?: string
order?: number
featured: boolean
}
export interface SkillsResponse {
data: Skill[]
count: number
}
export interface SocialLink {
id: number
name: string
handle: string
url: string
icon?: string
order?: number
visible: boolean
}
export interface SocialLinksResponse {
data: SocialLink[]
count: number
}
export const api = {
async getGreeting(): Promise<GreetingResponse> {
const response = await fetch(`${API_BASE_URL}/api/greeting`)
if (!response.ok) throw new Error('Failed to fetch greeting')
return response.json()
},
async getUser(name: string): Promise<UserResponse> {
const response = await fetch(`${API_BASE_URL}/api/user/${encodeURIComponent(name)}`)
if (!response.ok) throw new Error('Failed to fetch user')
return response.json()
},
async healthCheck(): Promise<HealthResponse> {
const response = await fetch(`${API_BASE_URL}/health`)
if (!response.ok) throw new Error('API health check failed')
return response.json()
},
async getPosts(): Promise<PostsResponse> {
const response = await fetch(`${API_BASE_URL}/api/posts`)
if (!response.ok) throw new Error('Failed to fetch posts')
return response.json()
},
async getPost(id: number): Promise<PostResponse> {
const response = await fetch(`${API_BASE_URL}/api/posts/${id}`)
if (!response.ok) throw new Error('Failed to fetch post')
return response.json()
},
async getProfile(): Promise<ProfileResponse> {
const response = await fetch(`${API_BASE_URL}/api/profile`)
if (!response.ok) throw new Error('Failed to fetch profile')
return response.json()
},
async getWorkProjects(): Promise<WorkProjectsResponse> {
const response = await fetch(`${API_BASE_URL}/api/work-projects`)
if (!response.ok) throw new Error('Failed to fetch work projects')
return response.json()
},
async getWorkProject(slug: string): Promise<WorkProjectResponse> {
const response = await fetch(`${API_BASE_URL}/api/work-projects/${encodeURIComponent(slug)}`)
if (!response.ok) throw new Error('Failed to fetch work project')
return response.json()
},
async getSkills(): Promise<SkillsResponse> {
const response = await fetch(`${API_BASE_URL}/api/skills`)
if (!response.ok) throw new Error('Failed to fetch skills')
return response.json()
},
async getSocialLinks(): Promise<SocialLinksResponse> {
const response = await fetch(`${API_BASE_URL}/api/social-links`)
if (!response.ok) throw new Error('Failed to fetch social links')
return response.json()
}
}

View File

@@ -0,0 +1,42 @@
import { marked } from 'marked'
// Configure marked for safe HTML rendering
marked.setOptions({
gfm: true, // GitHub Flavored Markdown
breaks: true, // Convert \n to <br>
})
/**
* Parse markdown content to HTML
*/
export function parseMarkdown(content: string): string {
return marked.parse(content) as string
}
/**
* Extract frontmatter from markdown (optional)
* Returns { frontmatter, content }
*/
export function extractFrontmatter(markdown: string): { frontmatter: Record<string, any> | null, content: string } {
const frontmatterRegex = /^---\n([\s\S]*?)\n---\n([\s\S]*)$/
const match = markdown.match(frontmatterRegex)
if (!match) {
return { frontmatter: null, content: markdown }
}
const [, frontmatterText, content] = match
const frontmatter: Record<string, any> = {}
// Simple YAML-like parsing (key: value)
frontmatterText.split('\n').forEach(line => {
const colonIndex = line.indexOf(':')
if (colonIndex > 0) {
const key = line.slice(0, colonIndex).trim()
const value = line.slice(colonIndex + 1).trim()
frontmatter[key] = value
}
})
return { frontmatter, content }
}

View File

@@ -0,0 +1,79 @@
---
import Layout from '../layouts/Layout.astro'
import { api } from '../lib/api'
const name = 'Homelab'
let userData = null
try {
userData = await api.getUser(name)
} catch (e) {
console.error('Failed to fetch user data:', e)
}
---
<Layout title="About - Astro + Hono">
<h1>About This Demo</h1>
<p>
This project demonstrates a modern full-stack TypeScript application using
Astro for server-side rendering and Hono for API endpoints.
</p>
{userData && (
<div class="card">
<h2>API Response</h2>
<p>{userData.message}</p>
<p><small>Generated at: {new Date(userData.timestamp).toLocaleString()}</small></p>
</div>
)}
<div class="card">
<h2>📦 Tech Stack</h2>
<h3 style="margin-top: 1rem; font-size: 1.1rem;">Frontend</h3>
<ul style="margin-left: 1.5rem;">
<li><strong>Astro 4:</strong> Server-side rendering framework</li>
<li><strong>TypeScript:</strong> Type safety</li>
<li><strong>Node.js Adapter:</strong> Standalone SSR server</li>
</ul>
<h3 style="margin-top: 1rem; font-size: 1.1rem;">Backend</h3>
<ul style="margin-left: 1.5rem;">
<li><strong>Hono:</strong> Ultrafast web framework</li>
<li><strong>TypeScript:</strong> Type-safe API</li>
<li><strong>@hono/node-server:</strong> Node.js runtime</li>
</ul>
<h3 style="margin-top: 1rem; font-size: 1.1rem;">Infrastructure</h3>
<ul style="margin-left: 1.5rem;">
<li><strong>Docker:</strong> Containerization</li>
<li><strong>Docker Compose:</strong> Multi-service orchestration</li>
<li><strong>Traefik:</strong> Reverse proxy and routing</li>
<li><strong>homelab-network:</strong> Internal Docker network</li>
</ul>
</div>
<div class="card">
<h2>🎯 Features</h2>
<ul style="margin-left: 1.5rem;">
<li>Server-side rendering with Astro</li>
<li>Type-safe API with Hono</li>
<li>Internal container-to-container communication</li>
<li>External routing via Traefik</li>
<li>Multi-stage Docker builds</li>
<li>Production-ready architecture</li>
</ul>
</div>
<div class="card">
<h2>🚀 Future Enhancements</h2>
<ul style="margin-left: 1.5rem;">
<li>Add Cloudflare Tunnel for external access</li>
<li>Implement authentication</li>
<li>Add database integration (PostgreSQL)</li>
<li>Set up monitoring with Uptime Kuma</li>
<li>Add CI/CD pipeline</li>
</ul>
</div>
</Layout>

View File

@@ -0,0 +1,38 @@
---
import ContentLayout from '../../components/content/ContentLayout.astro';
import { getBlogPost } from '../../content';
import { marked } from 'marked';
const { slug } = Astro.params;
// Fetch post from Directus via our content adapter
const post = await getBlogPost(slug as string);
if (!post) {
return Astro.redirect('/404');
}
// Render markdown content
const htmlContent = post.body ? marked(post.body) : '';
---
<ContentLayout
title={post.data.title}
type="blog"
meta={{
date: post.data.date,
readTime: post.data.readTime,
excerpt: post.data.excerpt,
tags: post.data.tags
}}
backLink={{
href: "/",
label: "Back to Portfolio"
}}
nextActions={[
{ href: "/", label: "Back to Portfolio" },
{ href: "/#connect", label: "Get in Touch", primary: true }
]}
>
<div class="prose prose-lg dark:prose-invert max-w-none prose-pre:whitespace-pre-wrap prose-pre:break-words prose-code:break-words" set:html={htmlContent} />
</ContentLayout>

View File

@@ -0,0 +1,135 @@
---
import Layout from '../layouts/Layout.astro';
import Hero from '../components/layout/Hero.astro';
import Navigation from '../components/layout/Navigation.astro';
import WorkSection from '../components/sections/WorkSection.astro';
import FeaturedProjects from '../components/sections/FeaturedProjects.astro';
import ThoughtsSection from '../components/sections/ThoughtsSection.astro';
import ConnectSection from '../components/sections/ConnectSection.astro';
import Footer from '../components/layout/Footer.astro';
import ShaderBackground from '../components/shader/ShaderBackground';
// Use our Directus content adapter instead of astro:content
import { getEntry, getCollection } from '../content';
// Get portfolio content
const portfolioEntry = await getEntry('portfolio', 'main');
const portfolio = portfolioEntry.data;
// Get blog posts for Recent Thoughts section (from Directus)
const allBlogPosts = await getCollection('blog');
const publishedPosts = allBlogPosts
.filter((post: any) => post.data.published)
.sort((a: any, b: any) => b.data.date.getTime() - a.data.date.getTime())
.slice(0, 4); // Get latest 4 posts
// Transform blog posts for ThoughtsSection component
const recentThoughts = publishedPosts.map((post: any) => ({
title: post.data.title,
excerpt: post.data.excerpt, // Already cleaned by content adapter
date: post.data.date.toLocaleDateString('en-US', { month: 'short', year: 'numeric' }),
readTime: post.data.readTime,
slug: post.slug,
}));
// Get work projects
const allWorkProjects = await getCollection('work');
// Filter for Selected Work: only major career positions (employment, research, education)
const careerCategories = ['employment', 'research', 'education'];
const selectedWork = allWorkProjects
.filter((work: any) => careerCategories.includes(work.data.category))
.sort((a: any, b: any) => (a.data.order || 999) - (b.data.order || 999));
// Get social links from Directus
const socialLinksData = await getCollection('social');
// Get featured skills from Directus
const allSkills = await getCollection('skills');
const featuredSkills = allSkills
.filter((skill: any) => skill.featured)
.sort((a: any, b: any) => (a.order || 999) - (b.order || 999))
.map((skill: any) => skill.name);
// Transform work data to match component expectations
const workData = selectedWork.map((work: any) => ({
year: work.data.year,
role: work.data.role,
company: work.data.company,
description: work.data.description,
tech: work.data.technologies,
slug: work.slug,
}));
---
<Layout title="Portfolio - b28.dev">
<div class="min-h-screen bg-background text-foreground relative">
<Navigation />
<main class="content-container">
<header
id="intro"
data-section="intro"
class="min-h-screen flex items-center opacity-0 relative p-8 lg:p-4"
>
<ShaderBackground client:idle />
<div id="hero-content" class="relative z-10 w-full">
<Hero
portfolioYear={portfolio.portfolioYear}
firstName={portfolio.firstName}
lastName={portfolio.lastName}
description={portfolio.description}
location={portfolio.location}
skills={featuredSkills.length > 0 ? featuredSkills : portfolio.skills}
currentRole={portfolio.currentRole.title}
currentCompany={portfolio.currentRole.company}
currentDuration={portfolio.currentRole.duration}
/>
</div>
</header>
<section
id="projects"
data-section="projects"
class="section-spacing opacity-0"
>
<FeaturedProjects />
</section>
<section
id="work"
data-section="work"
class="section-spacing opacity-0"
>
<WorkSection
title={portfolio.workSection.title}
dateRange={portfolio.workSection.dateRange}
jobs={workData}
/>
</section>
<section
id="thoughts"
data-section="thoughts"
class="section-spacing opacity-0"
>
<ThoughtsSection posts={recentThoughts} />
</section>
<section
id="connect"
data-section="connect"
class="py-20 sm:py-32 opacity-0"
>
<ConnectSection
title={portfolio.connectTitle}
description={portfolio.connectDescription}
email={portfolio.email}
socialLinks={socialLinksData}
/>
</section>
<Footer />
</main>
<div class="fade-border"></div>
</div>
</Layout>

View File

@@ -0,0 +1,144 @@
---
import Layout from '../layouts/Layout.astro';
import Navigation from '../components/layout/Navigation.astro';
import Footer from '../components/layout/Footer.astro';
import Tag from '../components/ui/Tag.astro';
import { getEntry, getCollection } from '../content';
// Get portfolio data for footer
const portfolioEntry = await getEntry('portfolio', 'main');
const portfolio = portfolioEntry.data;
// Get all work projects
const allProjects = await getCollection('work');
const publishedProjects = allProjects
.filter((project: any) => project.data.status === 'published')
.sort((a: any, b: any) => (a.data.order || 999) - (b.data.order || 999));
const featuredProjects = publishedProjects.filter((p: any) => p.data.featured);
const otherProjects = publishedProjects.filter((p: any) => !p.data.featured);
---
<Layout title="All Projects - b28.dev">
<div class="min-h-screen bg-background text-foreground">
<Navigation />
<main class="content-container py-20 sm:py-32">
<!-- Header -->
<div class="max-w-3xl mb-16">
<h1 class="text-4xl sm:text-5xl font-light mb-6">All Projects</h1>
<p class="text-lg text-muted-foreground">
A collection of professional work, hackathon projects, and technical explorations spanning AI/ML, full-stack development, and community building.
</p>
</div>
<!-- Featured Projects -->
{featuredProjects.length > 0 && (
<section class="mb-20">
<h2 class="text-2xl font-light mb-8 flex items-center gap-3">
<span>Featured Projects</span>
<span class="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800 border border-yellow-200">
★ Featured
</span>
</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-8">
{featuredProjects.map((project: any) => (
<a
href={`/work/${project.slug}`}
class="group flex flex-col card-interactive overflow-hidden h-full"
>
<div class="flex flex-col flex-grow p-6">
<div class="flex items-center justify-between mb-4">
<span class="text-xs font-medium text-muted-foreground">
{project.data.year}
</span>
</div>
<div class="space-y-3 flex-grow">
<div>
<h3 class="text-xl font-medium mb-1 group-hover:text-primary transition-colors">
{project.data.title}
</h3>
<p class="text-sm text-muted-foreground">
{project.data.role} @ {project.data.company}
</p>
</div>
<p class="text-sm text-muted-foreground leading-relaxed">
{project.data.description}
</p>
</div>
<div class="flex flex-wrap gap-2 mt-6">
{project.data.technologies.slice(0, 4).map((tech: string) => (
<Tag text={tech} variant="tech" />
))}
{project.data.technologies.length > 4 && (
<span class="tag-base bg-muted/50 text-muted-foreground text-xs">
+{project.data.technologies.length - 4} more
</span>
)}
</div>
</div>
</a>
))}
</div>
</section>
)}
<!-- All Projects -->
{otherProjects.length > 0 && (
<section>
<h2 class="text-2xl font-light mb-8">More Projects</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-8">
{otherProjects.map((project: any) => (
<a
href={`/work/${project.slug}`}
class="group flex flex-col card-interactive overflow-hidden h-full"
>
<div class="flex flex-col flex-grow p-6">
<div class="flex items-center justify-between mb-4">
<span class="text-xs font-medium text-muted-foreground">
{project.data.year}
</span>
</div>
<div class="space-y-3 flex-grow">
<div>
<h3 class="text-xl font-medium mb-1 group-hover:text-primary transition-colors">
{project.data.title}
</h3>
<p class="text-sm text-muted-foreground">
{project.data.role} @ {project.data.company}
</p>
</div>
<p class="text-sm text-muted-foreground leading-relaxed">
{project.data.description}
</p>
</div>
<div class="flex flex-wrap gap-2 mt-6">
{project.data.technologies.slice(0, 4).map((tech: string) => (
<Tag text={tech} variant="tech" />
))}
{project.data.technologies.length > 4 && (
<span class="tag-base bg-muted/50 text-muted-foreground text-xs">
+{project.data.technologies.length - 4} more
</span>
)}
</div>
</div>
</a>
))}
</div>
</section>
)}
</main>
<Footer
copyright={portfolio.footer.copyright}
attribution={portfolio.footer.attribution}
year={portfolio.footer.year}
/>
</div>
</Layout>

View File

@@ -0,0 +1,91 @@
---
import ToolLayout from '../../layouts/ToolLayout.astro';
---
<ToolLayout
title="Tools"
description="A collection of utility tools for developers and designers."
maxWidth="wide"
>
<div class="grid gap-6 md:grid-cols-2">
<!-- QR Code Generator Card -->
<a
href="/tools/qr"
class="card-interactive p-6 space-y-4"
>
<div class="flex items-start justify-between">
<div class="flex items-center gap-3">
<div class="text-3xl">📱</div>
<div>
<h2 class="text-lg font-semibold text-foreground">
QR Code Generator
</h2>
<p class="text-sm text-muted-foreground mt-1">
Generate QR codes for URLs
</p>
</div>
</div>
<svg
class="w-5 h-5 text-muted-foreground"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width={2}
d="M9 5l7 7-7 7"
/>
</svg>
</div>
<p class="text-sm text-muted-foreground">
Create shareable QR codes for presentations and screenshares. Works on mobile with native share support.
</p>
<div class="flex flex-wrap gap-2">
<span class="tag-tech">QR Codes</span>
<span class="tag-tech">Mobile-friendly</span>
</div>
</a>
<!-- Tip Calculator Card -->
<a
href="/tools/tips"
class="card-interactive p-6 space-y-4"
>
<div class="flex items-start justify-between">
<div class="flex items-center gap-3">
<div class="text-3xl">💵</div>
<div>
<h2 class="text-lg font-semibold text-foreground">
Tip Calculator
</h2>
<p class="text-sm text-muted-foreground mt-1">
Calculate tips with ease
</p>
</div>
</div>
<svg
class="w-5 h-5 text-muted-foreground"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width={2}
d="M9 5l7 7-7 7"
/>
</svg>
</div>
<p class="text-sm text-muted-foreground">
See effective tip percentage, amount to tip, and final total instantly. Includes round-up option for convenience.
</p>
<div class="flex flex-wrap gap-2">
<span class="tag-tech">Calculator</span>
<span class="tag-tech">Mobile-friendly</span>
</div>
</a>
</div>
</ToolLayout>

View File

@@ -0,0 +1,12 @@
---
import ToolLayout from '../../layouts/ToolLayout.astro';
import QRCodeGeneratorAstro from '../../components/tools/QRCodeGeneratorAstro.astro';
---
<ToolLayout
title="QR Code Generator"
description="Generate QR codes for URLs that can be shared via presentations, screenshares, and more."
maxWidth="default"
>
<QRCodeGeneratorAstro />
</ToolLayout>

View File

@@ -0,0 +1,12 @@
---
import ToolLayout from '../../layouts/ToolLayout.astro';
import TipCalculatorAstro from '../../components/tools/TipCalculatorAstro.astro';
---
<ToolLayout
title="Tip Calculator"
description="Calculate tips with ease. See the effective tip percentage, amount to tip, and final total instantly."
maxWidth="default"
>
<TipCalculatorAstro />
</ToolLayout>

View File

@@ -0,0 +1,198 @@
---
import Layout from '../../layouts/Layout.astro';
import Navigation from '../../components/layout/Navigation.astro';
import Footer from '../../components/layout/Footer.astro';
import Tag from '../../components/ui/Tag.astro';
import { getEntry, getWorkProject } from '../../content';
import { marked } from 'marked';
// Get the slug from the URL
const { slug } = Astro.params;
// Fetch the project data
const project = await getWorkProject(slug as string);
// If project not found, return 404
if (!project) {
return Astro.redirect('/404');
}
// Get portfolio data for footer
const portfolioEntry = await getEntry('portfolio', 'main');
const portfolio = portfolioEntry.data;
// Parse markdown content if it exists
const contentHtml = project.data.content ? marked.parse(project.data.content) : '';
---
<Layout title={`${project.data.title} - ${portfolio.name}`}>
<div class="min-h-screen bg-background text-foreground">
<Navigation />
<main class="content-container py-20 sm:py-32">
<!-- Back navigation -->
<div class="mb-8">
<a
href="/projects"
class="inline-flex items-center text-sm text-muted-foreground hover:text-foreground transition-colors"
>
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
</svg>
Back to Projects
</a>
</div>
<!-- Project header -->
<header class="max-w-4xl mb-16">
<div class="mb-6">
<h1 class="text-4xl sm:text-5xl font-light mb-4">{project.data.title}</h1>
<div class="flex flex-wrap items-center gap-4 text-muted-foreground">
<span class="text-lg">{project.data.role} @ {project.data.company}</span>
<span class="text-sm">•</span>
<span class="text-sm">{project.data.year}{project.data.duration ? ` (${project.data.duration})` : ''}</span>
</div>
</div>
<!-- Description -->
<p class="text-lg text-muted-foreground leading-relaxed mb-8">
{project.data.description}
</p>
<!-- Technologies -->
{project.data.technologies && project.data.technologies.length > 0 && (
<div class="flex flex-wrap gap-2 mb-8">
{project.data.technologies.map((tech: string) => (
<Tag text={tech} variant="tech" />
))}
</div>
)}
<!-- Links -->
{(project.data.live_url || project.data.case_study_url) && (
<div class="flex flex-wrap gap-4">
{project.data.live_url && (
<a
href={project.data.live_url}
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center px-4 py-2 rounded-lg bg-primary text-primary-foreground hover:bg-primary/90 transition-colors text-sm font-medium"
>
View Live Project
<svg class="w-4 h-4 ml-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
</a>
)}
{project.data.case_study_url && (
<a
href={project.data.case_study_url}
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center px-4 py-2 rounded-lg border border-border hover:bg-muted/50 transition-colors text-sm font-medium"
>
Case Study
<svg class="w-4 h-4 ml-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
</a>
)}
</div>
)}
<!-- Team info -->
{project.data.team && (
<div class="mt-8 p-4 rounded-lg bg-muted/30 border border-border">
<p class="text-sm text-muted-foreground">
<span class="font-medium text-foreground">Team:</span> {project.data.team}
</p>
</div>
)}
</header>
<!-- Project content (detailed case study) -->
{contentHtml && (
<article class="max-w-4xl">
<div class="prose prose-lg dark:prose-invert prose-headings:font-light prose-headings:text-foreground prose-p:text-muted-foreground prose-a:text-primary prose-strong:text-foreground prose-code:text-foreground prose-pre:bg-muted prose-li:text-muted-foreground max-w-none">
<Fragment set:html={contentHtml} />
</div>
</article>
)}
<!-- Navigation footer -->
<div class="max-w-4xl mt-20 pt-8 border-t border-border">
<a
href="/projects"
class="inline-flex items-center text-sm text-muted-foreground hover:text-foreground transition-colors"
>
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
</svg>
Back to Projects
</a>
</div>
</main>
<Footer
copyright={portfolio.footer.copyright}
attribution={portfolio.footer.attribution}
year={portfolio.footer.year}
/>
</div>
</Layout>
<style>
/* Additional styling for markdown content */
article {
line-height: 1.75;
}
article :global(h2) {
margin-top: 2.5rem;
margin-bottom: 1.25rem;
}
article :global(h3) {
margin-top: 2rem;
margin-bottom: 1rem;
}
article :global(p) {
margin-bottom: 1.5rem;
}
article :global(ul), article :global(ol) {
margin-bottom: 1.5rem;
padding-left: 1.5rem;
}
article :global(li) {
margin-bottom: 0.5rem;
}
article :global(code) {
padding: 0.2rem 0.4rem;
border-radius: 0.25rem;
background-color: rgba(0, 0, 0, 0.1);
font-size: 0.875em;
}
article :global(pre) {
padding: 1rem;
border-radius: 0.5rem;
overflow-x: auto;
margin-bottom: 1.5rem;
}
article :global(pre code) {
background-color: transparent;
padding: 0;
}
article :global(blockquote) {
padding-left: 1rem;
border-left: 3px solid var(--color-border);
font-style: italic;
margin-bottom: 1.5rem;
}
</style>

View File

@@ -0,0 +1,114 @@
/* Component utility classes for the design system */
@layer components {
/* Layout containers */
.section-spacing {
@apply min-h-screen py-20 sm:py-32;
}
.content-container {
@apply max-w-4xl mx-auto px-6 sm:px-8 lg:px-16;
}
.content-container-wide {
@apply max-w-5xl mx-auto px-6 sm:px-8 lg:px-16;
}
.content-container-full {
@apply max-w-6xl mx-auto px-4 sm:px-6 lg:px-8;
}
/* Button variants */
.button-base {
@apply inline-flex items-center gap-2 px-5 py-3 text-sm font-medium rounded-lg transition-all duration-300;
}
.button-primary {
@apply inline-flex items-center gap-2 px-5 py-3 text-sm font-medium rounded-lg transition-all duration-300 bg-foreground text-background hover:bg-foreground/90;
}
.button-secondary {
@apply inline-flex items-center gap-2 px-5 py-3 text-sm font-medium rounded-lg transition-all duration-300 text-muted-foreground hover:text-foreground border border-border hover:border-muted-foreground;
}
.button-ghost {
@apply inline-flex items-center gap-2 px-5 py-3 text-sm font-medium rounded-lg transition-all duration-300 text-muted-foreground hover:text-foreground hover:bg-muted/50;
}
.button-outline {
@apply inline-flex items-center gap-2 px-3 py-3 text-sm font-medium rounded-lg transition-all duration-300 border border-border hover:border-muted-foreground/50;
}
/* Card variants */
.card-base {
@apply border border-border rounded-lg hover:border-muted-foreground/50 transition-all duration-300 hover:shadow-md bg-background;
}
.card-interactive {
@apply border border-border rounded-lg hover:border-muted-foreground/50 transition-all duration-300 hover:shadow-md bg-background cursor-pointer hover:scale-[1.02] active:scale-[0.98];
}
.card-featured {
@apply border border-border rounded-lg hover:border-muted-foreground/50 transition-all duration-300 hover:shadow-md bg-background ring-1 ring-primary/20 hover:ring-primary/40;
}
/* Tag variants */
.tag-base {
@apply inline-flex items-center px-3 py-1 text-xs font-medium rounded-full transition-colors duration-300;
}
.tag-tech {
@apply inline-flex items-center px-3 py-1 text-xs font-medium rounded-full transition-colors duration-300 text-muted-foreground border border-border hover:border-muted-foreground/50;
}
.tag-skill {
@apply inline-flex items-center px-3 py-1 text-xs font-medium rounded-full transition-colors duration-300 border border-border hover:border-muted-foreground/50;
}
.tag-status {
@apply inline-flex items-center px-3 py-1 text-xs font-medium rounded-full transition-colors duration-300 bg-primary/10 text-primary border border-primary/20;
}
.tag-category {
@apply inline-flex items-center px-3 py-1 text-xs font-medium rounded-full transition-colors duration-300 bg-muted text-muted-foreground;
}
/* Utility patterns */
.gradient-text {
@apply bg-gradient-to-r from-foreground to-muted-foreground bg-clip-text text-transparent;
}
.section-divider {
@apply w-16 h-0.5 bg-foreground/20;
}
.fade-border {
@apply fixed bottom-0 left-0 right-0 h-24 bg-gradient-to-t from-background via-background/80 to-transparent pointer-events-none;
}
/* Status indicators */
.status-dot {
@apply w-2 h-2 rounded-full;
}
.status-dot-success {
@apply w-2 h-2 rounded-full bg-green-500;
}
.status-dot-info {
@apply w-2 h-2 rounded-full bg-blue-500;
}
.status-dot-neutral {
@apply w-2 h-2 rounded-full bg-gray-500;
}
/* Interactive elements */
.hover-lift {
@apply transition-transform duration-300 hover:scale-105;
}
.hover-glow {
@apply transition-shadow duration-300 hover:shadow-lg hover:shadow-primary/20;
}
}

View File

@@ -0,0 +1,416 @@
/* Centralized Content Styling System */
article.content-prose {
color: hsl(var(--muted-foreground));
line-height: 1.7;
}
/* Reset all heading styles to ensure our styles take precedence */
article.content-prose h1,
article.content-prose h2,
article.content-prose h3,
article.content-prose h4,
article.content-prose h5,
article.content-prose h6 {
margin: 0 !important;
padding: 0 !important;
font-size: inherit !important;
font-weight: inherit !important;
line-height: inherit !important;
color: inherit !important;
border: none !important;
background: none !important;
}
/* Main headings with strong visual hierarchy */
article.content-prose h1 {
font-size: 3rem !important;
font-weight: 300 !important;
color: hsl(var(--foreground)) !important;
margin-top: 4rem !important;
margin-bottom: 3rem !important;
border-bottom: 2px solid hsl(var(--border) / 0.3) !important;
padding-bottom: 2rem !important;
line-height: 1.1 !important;
}
article.content-prose h1:first-child {
margin-top: 0 !important;
}
article.content-prose h2 {
font-size: 2.25rem !important;
font-weight: 400 !important;
color: hsl(var(--foreground)) !important;
margin-top: 4rem !important;
margin-bottom: 2rem !important;
border-bottom: 1px solid hsl(var(--border) / 0.2) !important;
padding-bottom: 1rem !important;
position: relative !important;
line-height: 1.2 !important;
}
article.content-prose h2::before {
content: '' !important;
position: absolute !important;
left: 0 !important;
top: 0 !important;
width: 4rem !important;
height: 3px !important;
background-color: hsl(var(--foreground)) !important;
}
article.content-prose h3 {
font-size: 1.75rem !important;
font-weight: 500 !important;
color: hsl(var(--foreground)) !important;
margin-top: 3rem !important;
margin-bottom: 1.5rem !important;
padding-left: 0 !important;
position: relative !important;
line-height: 1.3 !important;
}
article.content-prose h4 {
font-size: 1.25rem !important;
font-weight: 600 !important;
color: hsl(var(--foreground)) !important;
margin-top: 2.5rem !important;
margin-bottom: 1rem !important;
background-color: hsl(var(--muted) / 0.5) !important;
padding: 0.875rem 1.25rem !important;
border-radius: 0.5rem !important;
border-left: 4px solid hsl(var(--foreground) / 0.6) !important;
line-height: 1.4 !important;
box-shadow: 0 1px 3px hsl(var(--foreground) / 0.1) !important;
}
article.content-prose h5 {
font-size: 1rem !important;
font-weight: 600 !important;
color: hsl(var(--foreground)) !important;
margin-top: 2rem !important;
margin-bottom: 0.75rem !important;
text-transform: uppercase !important;
letter-spacing: 0.05em !important;
line-height: 1.5 !important;
}
article.content-prose h6 {
font-size: 0.875rem !important;
font-weight: 600 !important;
color: hsl(var(--foreground)) !important;
margin-top: 1.5rem !important;
margin-bottom: 0.5rem !important;
line-height: 1.5 !important;
}
/* Enhanced paragraph styling with better contrast */
article.content-prose p {
line-height: 1.6 !important;
margin-bottom: 1.5rem !important;
color: hsl(var(--foreground) / 0.85) !important;
font-size: 1.125rem !important;
max-width: none !important;
}
/* Systematic list styling that works with browser defaults */
article.content-prose ul {
margin: 1.75rem 0 !important;
padding-left: 1.25rem !important;
list-style-type: disc !important;
list-style-position: outside !important;
}
article.content-prose ol {
margin: 1.75rem 0 !important;
padding-left: 1.75rem !important;
list-style-type: decimal !important;
list-style-position: outside !important;
}
article.content-prose li {
color: hsl(var(--foreground) / 0.85) !important;
font-size: 1.125rem !important;
line-height: 1.6 !important;
margin-bottom: 0.875rem !important;
padding-left: 0.5rem !important;
}
article.content-prose li::marker {
color: hsl(var(--foreground) / 0.7) !important;
font-weight: 600 !important;
}
/* Strong emphasis and inline formatting - standardized weight */
article.content-prose strong {
font-weight: 600 !important;
color: hsl(var(--foreground)) !important;
background-color: hsl(var(--muted) / 0.4) !important;
padding: 0.15rem 0.4rem !important;
border-radius: 0.25rem !important;
font-size: 1em !important;
}
article.content-prose em {
font-style: italic !important;
color: hsl(var(--foreground) / 0.9) !important;
font-weight: 500 !important;
}
/* Enhanced link styling */
article.content-prose a {
color: hsl(var(--foreground));
font-weight: 500;
text-decoration: underline;
text-decoration-thickness: 2px;
text-decoration-color: hsl(var(--foreground) / 0.3);
transition: all 0.3s ease;
padding: 0.125rem 0.25rem;
border-radius: 0.25rem;
}
article.content-prose a:hover {
text-decoration-color: hsl(var(--foreground));
background-color: hsl(var(--muted) / 0.2);
}
/* Code styling */
article.content-prose code {
background-color: hsl(var(--muted)) !important;
color: hsl(var(--foreground)) !important;
padding: 0.25rem 0.5rem !important;
border-radius: 0.375rem !important;
font-size: 0.9em !important;
font-family: ui-monospace, 'SF Mono', Consolas, monospace !important;
border: 1px solid hsl(var(--border) / 0.5) !important;
font-weight: 500 !important;
}
/* Enhanced styling for technical terms in strong tags */
article.content-prose strong code {
background-color: hsl(var(--foreground) / 0.08) !important;
color: hsl(var(--foreground)) !important;
font-weight: 600 !important;
border: 1px solid hsl(var(--foreground) / 0.15) !important;
}
article.content-prose pre {
background-color: hsl(var(--muted));
border: 1px solid hsl(var(--border));
border-radius: 0.75rem;
padding: 1.5rem;
overflow-x: auto;
box-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1);
margin: 2rem 0;
}
article.content-prose pre code {
background-color: transparent;
padding: 0;
border: none;
}
/* Enhanced blockquotes for solution sections */
article.content-prose blockquote {
border-left: 4px solid hsl(var(--foreground) / 0.6) !important;
padding: 1.5rem 1.75rem !important;
margin: 1.5rem 0 2.5rem 0 !important;
background-color: hsl(var(--muted) / 0.6) !important;
border-radius: 0.5rem !important;
font-style: normal !important;
color: hsl(var(--foreground) / 0.9) !important;
position: relative !important;
box-shadow: 0 3px 12px hsl(var(--foreground) / 0.08) !important;
border: 1px solid hsl(var(--border) / 0.3) !important;
}
article.content-prose blockquote p {
margin-bottom: 0 !important;
font-size: 1.125rem !important;
line-height: 1.6 !important;
}
/* Remove default quote styling for solution blocks */
article.content-prose blockquote::before {
display: none !important;
}
/* Better spacing between major sections */
article.content-prose h2 + * {
margin-top: 2rem !important;
}
article.content-prose h2 {
margin-top: 4.5rem !important;
margin-bottom: 2.5rem !important;
}
article.content-prose h3 {
margin-top: 3.5rem !important;
margin-bottom: 1.5rem !important;
}
/* Horizontal rules */
article.content-prose hr {
border: none;
height: 1px;
background: linear-gradient(to right, transparent, hsl(var(--border)), transparent);
margin: 4rem 0;
}
/* Tables */
article.content-prose table {
width: 100%;
border-collapse: collapse;
border: 1px solid hsl(var(--border));
margin: 2rem 0;
border-radius: 0.5rem;
overflow: hidden;
box-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1);
}
article.content-prose th {
border: 1px solid hsl(var(--border));
padding: 1rem 1.5rem;
text-align: left;
background-color: hsl(var(--muted));
font-weight: 600;
color: hsl(var(--foreground));
}
article.content-prose td {
border: 1px solid hsl(var(--border));
padding: 1rem 1.5rem;
color: hsl(var(--muted-foreground));
}
article.content-prose tr:nth-child(even) {
background-color: hsl(var(--muted) / 0.2);
}
/* Better spacing for nested lists */
article.content-prose ul ul,
article.content-prose ol ol,
article.content-prose ul ol,
article.content-prose ol ul {
margin-top: 0.5rem !important;
margin-bottom: 0.5rem !important;
padding-left: 1.5rem !important;
}
/* Nested list items should be slightly smaller and less spaced */
article.content-prose ul ul li,
article.content-prose ol ol li,
article.content-prose ul ol li,
article.content-prose ol ul li {
margin-bottom: 0.5rem !important;
font-size: 1rem !important;
}
/* Focus styles for accessibility */
article.content-prose a:focus {
outline: 2px solid hsl(var(--foreground) / 0.5);
outline-offset: 2px;
}
/* Blog-specific styling overrides */
article.content-prose.blog-content h2 {
font-size: 1.5rem;
margin-top: 3rem;
margin-bottom: 1.5rem;
}
article.content-prose.blog-content h3 {
font-size: 1.25rem;
margin-top: 2rem;
margin-bottom: 1rem;
}
/* Project-specific styling overrides */
article.content-prose.project-content h4 {
background-color: rgb(239 246 255);
border-left-color: rgb(59 130 246);
}
@media (prefers-color-scheme: dark) {
article.content-prose.project-content h4 {
background-color: rgb(30 58 138 / 0.2);
}
}
/* Responsive adjustments */
@media (max-width: 1024px) {
article.content-prose h1 {
font-size: 2.5rem !important;
}
article.content-prose h2 {
font-size: 2rem !important;
}
article.content-prose h3 {
font-size: 1.5rem !important;
}
}
@media (max-width: 768px) {
article.content-prose h1 {
font-size: 2.25rem !important;
margin-bottom: 2rem !important;
}
article.content-prose h2 {
font-size: 1.875rem !important;
margin-top: 3rem !important;
margin-bottom: 1.5rem !important;
}
article.content-prose h3 {
font-size: 1.5rem !important;
margin-top: 2.5rem !important;
}
article.content-prose h4 {
font-size: 1.25rem !important;
padding: 0.5rem 1rem !important;
margin-top: 2rem !important;
}
article.content-prose h5 {
font-size: 1rem !important;
}
article.content-prose h6 {
font-size: 0.875rem !important;
}
article.content-prose p {
font-size: 1rem !important;
}
article.content-prose li {
font-size: 1rem !important;
}
}
@media (max-width: 480px) {
article.content-prose h1 {
font-size: 2rem !important;
}
article.content-prose h2 {
font-size: 1.75rem !important;
}
article.content-prose h3 {
font-size: 1.375rem !important;
}
article.content-prose h4 {
font-size: 1.125rem !important;
padding: 0.5rem 0.75rem !important;
}
}

View File

@@ -0,0 +1,244 @@
/*
* Centralized Content Styling System
*
* This file contains shared prose styling that can be used across:
* - Work project pages
* - Blog post pages
* - Future project pages
* - Any other content pages
*
* Usage: Apply the "content-prose" class to any content container
*/
.content-prose {
@apply text-muted-foreground;
line-height: 1.7;
}
/* ================================
TYPOGRAPHY HIERARCHY
================================ */
/* Main headings with strong visual hierarchy */
.content-prose h1 {
@apply text-4xl font-light text-foreground mt-20 mb-10 first:mt-0
border-b border-border/30 pb-6;
}
.content-prose h2 {
@apply text-3xl font-light text-foreground mt-16 mb-8
border-b border-border/20 pb-4
relative before:absolute before:left-0 before:top-0
before:w-12 before:h-0.5 before:bg-foreground;
}
.content-prose h3 {
@apply text-2xl font-medium text-foreground mt-12 mb-6
relative pl-6 before:absolute before:left-0 before:top-1.5
before:w-3 before:h-3 before:bg-foreground/20 before:rounded-full;
}
.content-prose h4 {
@apply text-xl font-semibold text-foreground mt-10 mb-4
bg-muted/30 px-4 py-2 rounded-lg border-l-4 border-foreground/30;
}
.content-prose h5 {
@apply text-lg font-semibold text-foreground mt-8 mb-3
uppercase tracking-wide text-sm;
}
.content-prose h6 {
@apply text-base font-semibold text-foreground mt-6 mb-2;
}
/* ================================
TEXT FORMATTING
================================ */
.content-prose p {
@apply leading-relaxed mb-6 text-muted-foreground text-base;
}
.content-prose strong {
@apply font-semibold text-foreground bg-muted/20 px-1.5 py-0.5 rounded;
}
.content-prose em {
@apply italic text-foreground/90;
}
.content-prose a {
@apply text-foreground font-medium underline decoration-2 decoration-foreground/30
hover:decoration-foreground transition-all duration-300
hover:bg-muted/20 px-1 py-0.5 rounded;
}
/* ================================
LISTS
================================ */
.content-prose ul {
@apply my-8 space-y-3;
}
.content-prose ol {
@apply my-8 space-y-3;
}
.content-prose li {
@apply text-muted-foreground relative pl-2;
}
.content-prose ul > li {
@apply relative pl-6 before:absolute before:left-1 before:top-2.5
before:w-2 before:h-2 before:bg-foreground/60 before:rounded-full;
}
.content-prose ol > li {
@apply relative pl-6;
}
.content-prose li::marker {
@apply text-foreground/60 font-medium;
}
/* Better spacing for nested lists */
.content-prose ul ul,
.content-prose ol ol,
.content-prose ul ol,
.content-prose ol ul {
@apply mt-3 mb-0;
}
/* ================================
CODE & TECHNICAL CONTENT
================================ */
.content-prose code {
@apply bg-muted text-foreground px-2 py-1 rounded-md text-sm font-mono
border border-border/50;
}
.content-prose pre {
@apply bg-muted border border-border rounded-xl p-6 overflow-x-auto
shadow-sm;
}
.content-prose pre code {
@apply bg-transparent p-0 border-0;
}
/* ================================
QUOTES & CALLOUTS
================================ */
.content-prose blockquote {
@apply border-l-4 border-foreground/30 pl-6 py-4 my-8
bg-muted/20 rounded-r-lg italic text-muted-foreground/90
relative before:absolute before:top-2 before:left-2
before:text-4xl before:text-foreground/20 before:content-['"'];
}
/* ================================
DIVIDERS & SPACING
================================ */
.content-prose hr {
@apply border-0 h-px bg-gradient-to-r from-transparent via-border to-transparent my-16;
}
/* ================================
TABLES
================================ */
.content-prose table {
@apply w-full border-collapse border border-border my-8 rounded-lg overflow-hidden
shadow-sm;
}
.content-prose th {
@apply border border-border px-6 py-4 text-left bg-muted font-semibold text-foreground;
}
.content-prose td {
@apply border border-border px-6 py-4 text-muted-foreground;
}
.content-prose tr:nth-child(even) {
@apply bg-muted/20;
}
/* ================================
SPECIAL CONTENT PATTERNS
================================ */
/* Style for definition-like content */
.content-prose p:has(strong:only-child) {
@apply text-lg font-medium text-foreground mt-8 mb-4;
}
.content-prose p:has(strong) strong:first-child {
@apply block text-foreground font-semibold mb-2 text-lg;
}
/* ================================
ACCESSIBILITY
================================ */
.content-prose a:focus {
@apply outline-2 outline-foreground/50 outline-offset-2;
}
/* ================================
RESPONSIVE ADJUSTMENTS
================================ */
@media (max-width: 768px) {
.content-prose h1 {
@apply text-3xl;
}
.content-prose h2 {
@apply text-2xl;
}
.content-prose h3 {
@apply text-xl;
}
.content-prose h4 {
@apply text-lg px-3 py-1.5;
}
}
/* ================================
THEME VARIANTS (for future use)
================================ */
/* Blog-specific styling overrides */
.content-prose.blog-content h2 {
@apply text-2xl mt-12 mb-6;
}
.content-prose.blog-content h3 {
@apply text-xl mt-8 mb-4;
}
/* Project-specific styling overrides */
.content-prose.project-content h4 {
@apply bg-blue-50 dark:bg-blue-950/20 border-l-blue-500;
}
/* Compact variant for shorter content */
.content-prose.compact h1 {
@apply mt-12 mb-6;
}
.content-prose.compact h2 {
@apply mt-10 mb-5;
}
.content-prose.compact p {
@apply mb-4;
}

View File

@@ -0,0 +1,155 @@
@import "tailwindcss";
@import "tw-animate-css";
@import url("https://fonts.googleapis.com/css2?family=Geist:wght@100..900&display=swap");
/* Design System Utilities */
@import "./components.css";
@import "./typography.css";
@import "./layout.css";
:root {
--font-geist: "Geist", ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--destructive-foreground: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--radius: 0.625rem;
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
--status-success: oklch(0.7 0.15 145);
--status-info: oklch(0.7 0.15 250);
--status-neutral: oklch(0.6 0.05 280);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.145 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.145 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.985 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.396 0.141 25.723);
--destructive-foreground: oklch(0.637 0.237 25.331);
--border: oklch(0.269 0 0);
--input: oklch(0.269 0 0);
--ring: oklch(0.439 0 0);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(0.269 0 0);
--sidebar-ring: oklch(0.439 0 0);
--status-success: oklch(0.8 0.12 145);
--status-info: oklch(0.8 0.12 250);
--status-neutral: oklch(0.7 0.03 280);
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
--color-status-success: var(--status-success);
--color-status-info: var(--status-info);
--color-status-neutral: var(--status-neutral);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
font-family: var(--font-geist), ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
}
}
@layer utilities {
.animate-fade-in-up {
animation: fade-in-up 0.8s cubic-bezier(0.16, 1, 0.3, 1) forwards;
}
@keyframes fade-in-up {
from {
opacity: 0;
transform: translateY(24px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
}

View File

@@ -0,0 +1,143 @@
/* Layout utility classes for the design system */
@layer utilities {
/* Animation utilities */
.animate-fade-in {
opacity: 0;
animation: fade-in 0.6s ease-out forwards;
}
.animate-fade-in-up-delay-1 {
animation-delay: 0.2s;
}
.animate-fade-in-up-delay-2 {
animation-delay: 0.4s;
}
.animate-fade-in-up-delay-3 {
animation-delay: 0.6s;
}
@keyframes fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
/* Grid layout utilities */
.grid-auto-fit {
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
}
.grid-auto-fill {
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
}
/* Spacing utilities */
.space-section {
@apply space-y-6 sm:space-y-8;
}
.space-content {
@apply space-y-4 sm:space-y-6;
}
.space-compact {
@apply space-y-2 sm:space-y-3;
}
/* Responsive grid gaps */
.gap-section {
@apply gap-8 sm:gap-12 lg:gap-16;
}
.gap-content {
@apply gap-4 sm:gap-6 lg:gap-8;
}
.gap-compact {
@apply gap-2 sm:gap-3 lg:gap-4;
}
/* Flexbox utilities */
.flex-center {
@apply flex items-center justify-center;
}
.flex-between {
@apply flex items-center justify-between;
}
.flex-start {
@apply flex items-center justify-start;
}
.flex-end {
@apply flex items-center justify-end;
}
/* Position utilities */
.absolute-center {
@apply absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2;
}
.absolute-top-right {
@apply absolute top-3 right-3;
}
.absolute-top-left {
@apply absolute top-3 left-3;
}
.absolute-bottom-right {
@apply absolute bottom-3 right-3;
}
.absolute-bottom-left {
@apply absolute bottom-3 left-3;
}
/* Transition utilities */
.transition-smooth {
@apply transition-all duration-300 ease-out;
}
.transition-fast {
@apply transition-all duration-200 ease-out;
}
.transition-slow {
@apply transition-all duration-500 ease-out;
}
/* Backdrop utilities */
.backdrop-light {
@apply bg-background/90 backdrop-blur-sm;
}
.backdrop-medium {
@apply bg-background/95 backdrop-blur-md;
}
.backdrop-heavy {
@apply bg-background/98 backdrop-blur-lg;
}
/* Scroll utilities */
.scroll-smooth {
scroll-behavior: smooth;
}
.scroll-hidden {
scrollbar-width: none;
-ms-overflow-style: none;
}
.scroll-hidden::-webkit-scrollbar {
display: none;
}
}

View File

@@ -0,0 +1,84 @@
/* Typography utility classes for the design system */
@layer components {
/* Heading scales */
.heading-display {
@apply text-4xl sm:text-5xl lg:text-6xl font-light tracking-tight;
}
.heading-section {
@apply text-3xl sm:text-4xl font-light tracking-tight;
}
.heading-subsection {
@apply text-2xl sm:text-3xl font-medium;
}
.heading-card {
@apply text-lg sm:text-xl font-medium;
}
/* Body text variants */
.text-body-lg {
@apply text-lg leading-relaxed;
}
.text-body {
@apply text-base leading-relaxed;
}
.text-caption {
@apply text-sm text-muted-foreground;
}
.text-label {
@apply text-xs text-muted-foreground font-mono tracking-wider uppercase;
}
/* Specialized text styles */
.text-portfolio-year {
@apply text-sm text-muted-foreground font-mono tracking-wider;
}
.text-hero-name {
@apply text-5xl sm:text-6xl lg:text-7xl font-light tracking-tight;
}
.text-hero-description {
@apply text-lg sm:text-xl text-muted-foreground leading-relaxed;
}
.text-section-title {
@apply text-2xl sm:text-3xl font-light;
}
.text-card-title {
@apply text-lg sm:text-xl font-medium group-hover:text-muted-foreground transition-colors duration-300;
}
.text-card-description {
@apply text-sm text-muted-foreground leading-relaxed;
}
.text-card-meta {
@apply text-xs text-muted-foreground;
}
/* Interactive text states */
.text-hover-lift {
@apply transition-colors duration-300 hover:text-foreground;
}
.text-link {
@apply text-muted-foreground hover:text-foreground transition-colors duration-300;
}
/* Utility combinations */
.prose-relaxed {
@apply leading-relaxed text-muted-foreground;
}
.prose-tight {
@apply leading-tight text-foreground;
}
}

View File

@@ -0,0 +1,155 @@
export type Theme = 'light' | 'dark';
export interface ThemeChangeEvent extends CustomEvent {
detail: {
theme: Theme;
previousTheme: Theme;
};
}
export class ThemeManager {
private theme: Theme = 'light';
private mediaQuery: MediaQueryList;
constructor() {
this.mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
this.initTheme();
this.setupListeners();
}
/**
* Initialize theme based on saved preference or system preference
*/
initTheme(): void {
const savedTheme = localStorage.getItem('theme') as Theme | null;
const prefersDark = this.mediaQuery.matches;
if (savedTheme) {
this.setTheme(savedTheme, false);
console.log(`🎨 Theme: Using saved preference (${savedTheme})`);
} else if (prefersDark) {
this.setTheme('dark', false);
console.log('🎨 Theme: Using system preference (dark)');
} else {
this.setTheme('light', false);
console.log('🎨 Theme: Using system preference (light)');
}
}
/**
* Set theme and optionally save to localStorage
*/
setTheme(theme: Theme, save: boolean = true): void {
const previousTheme = this.theme;
this.theme = theme;
// Update DOM
document.documentElement.classList.toggle('dark', theme === 'dark');
// Save to localStorage if requested
if (save) {
localStorage.setItem('theme', theme);
console.log(`🎨 Theme: Switched to ${theme} mode`);
}
// Dispatch custom event for components that need to react
this.dispatchThemeChange(theme, previousTheme);
}
/**
* Toggle between light and dark themes
*/
toggleTheme(): void {
const newTheme = this.theme === 'dark' ? 'light' : 'dark';
this.setTheme(newTheme);
}
/**
* Get current theme
*/
getCurrentTheme(): Theme {
return this.theme;
}
/**
* Reset to system preference
*/
resetToSystemTheme(): void {
localStorage.removeItem('theme');
const systemTheme = this.mediaQuery.matches ? 'dark' : 'light';
this.setTheme(systemTheme, false);
console.log('🎨 Theme: Reset to system preference');
}
/**
* Check if current theme matches system preference
*/
isUsingSystemTheme(): boolean {
const savedTheme = localStorage.getItem('theme');
return !savedTheme;
}
/**
* Get system preference
*/
getSystemTheme(): Theme {
return this.mediaQuery.matches ? 'dark' : 'light';
}
/**
* Set up event listeners for system theme changes
*/
private setupListeners(): void {
this.mediaQuery.addEventListener('change', (e) => {
// Only auto-switch if no manual preference is set
if (!localStorage.getItem('theme')) {
const systemTheme = e.matches ? 'dark' : 'light';
this.setTheme(systemTheme, false);
console.log(`🎨 Theme: System changed to ${systemTheme} mode`);
}
});
}
/**
* Dispatch theme change event
*/
private dispatchThemeChange(theme: Theme, previousTheme: Theme): void {
const event: ThemeChangeEvent = new CustomEvent('theme-changed', {
detail: { theme, previousTheme }
}) as ThemeChangeEvent;
window.dispatchEvent(event);
}
/**
* Add event listener for theme changes
*/
onThemeChange(callback: (event: ThemeChangeEvent) => void): void {
window.addEventListener('theme-changed', callback as EventListener);
}
/**
* Remove event listener for theme changes
*/
offThemeChange(callback: (event: ThemeChangeEvent) => void): void {
window.removeEventListener('theme-changed', callback as EventListener);
}
}
// Create and export global instance
export const themeManager = new ThemeManager();
// Expose functions globally for backward compatibility
declare global {
interface Window {
toggleTheme: () => void;
resetToSystemTheme: () => void;
setTheme: (theme: Theme) => void;
themeManager: ThemeManager;
}
}
window.toggleTheme = () => themeManager.toggleTheme();
window.resetToSystemTheme = () => themeManager.resetToSystemTheme();
window.setTheme = (theme: Theme) => themeManager.setTheme(theme);
window.themeManager = themeManager;

View File

@@ -0,0 +1,52 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
'./src/**/*.{astro,html,js,jsx,md,mdx,svelte,ts,tsx,vue}'
],
darkMode: 'selector', // Use class-based dark mode with manual toggle
theme: {
extend: {
spacing: {
'section': '5rem', // 20 * 0.25rem (py-20)
'section-sm': '8rem', // 32 * 0.25rem (py-32)
},
maxWidth: {
'content': '56rem', // 4xl equivalent
'content-wide': '80rem', // 5xl equivalent
'content-full': '96rem', // 6xl equivalent
},
fontSize: {
'heading-xl': ['3.5rem', { lineHeight: '1.1' }], // ~7xl
'heading-lg': ['2.5rem', { lineHeight: '1.2' }], // ~4xl
'heading-md': ['1.875rem', { lineHeight: '1.3' }], // ~3xl
'body-lg': ['1.125rem', { lineHeight: '1.7' }], // ~lg with relaxed leading
'body': ['1rem', { lineHeight: '1.7' }], // ~base with relaxed leading
},
animation: {
'fade-in': 'fade-in 0.6s ease-out forwards',
'fade-in-up': 'fade-in-up 0.8s cubic-bezier(0.16,1,0.3,1) forwards',
'fade-in-up-delay-1': 'fade-in-up 0.8s cubic-bezier(0.16,1,0.3,1) 0.2s forwards',
'fade-in-up-delay-2': 'fade-in-up 0.8s cubic-bezier(0.16,1,0.3,1) 0.4s forwards',
},
keyframes: {
'fade-in': {
'from': { opacity: '0' },
'to': { opacity: '1' }
},
'fade-in-up': {
'from': {
opacity: '0',
transform: 'translateY(24px)'
},
'to': {
opacity: '1',
transform: 'translateY(0)'
}
}
}
},
},
plugins: [
require('@tailwindcss/typography'),
],
}

5
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,5 @@
{
"extends": "astro/tsconfigs/strict",
"include": [".astro/types.d.ts", "src/**/*"],
"exclude": ["dist", "sample_site", "node_modules"]
}