Initial commit
This commit is contained in:
12
.env.example
Normal file
12
.env.example
Normal 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
40
.gitignore
vendored
Normal 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
243
README.md
Normal 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
31
api/Dockerfile
Normal 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
20
api/package.json
Normal 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
189
api/src/index.ts
Normal 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
196
api/src/lib/directus.ts
Normal 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
19
api/tsconfig.json
Normal 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
63
docker-compose.yml
Normal 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
34
frontend/Dockerfile
Normal 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
45
frontend/astro.config.mjs
Normal 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
8155
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
34
frontend/package.json
Normal file
34
frontend/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
1
frontend/src/assets/astro.svg
Normal file
1
frontend/src/assets/astro.svg
Normal 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 |
1
frontend/src/assets/background.svg
Normal file
1
frontend/src/assets/background.svg
Normal 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 |
46
frontend/src/components/content/BlogCard.astro
Normal file
46
frontend/src/components/content/BlogCard.astro
Normal 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>
|
||||||
38
frontend/src/components/content/ContentActions.astro
Normal file
38
frontend/src/components/content/ContentActions.astro
Normal 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>
|
||||||
25
frontend/src/components/content/ContentHeader.astro
Normal file
25
frontend/src/components/content/ContentHeader.astro
Normal 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>
|
||||||
73
frontend/src/components/content/ContentLayout.astro
Normal file
73
frontend/src/components/content/ContentLayout.astro
Normal 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>
|
||||||
108
frontend/src/components/content/ContentMeta.astro
Normal file
108
frontend/src/components/content/ContentMeta.astro
Normal 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>
|
||||||
|
)}
|
||||||
18
frontend/src/components/content/CurrentRole.astro
Normal file
18
frontend/src/components/content/CurrentRole.astro
Normal 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>
|
||||||
97
frontend/src/components/content/ProjectCard.astro
Normal file
97
frontend/src/components/content/ProjectCard.astro
Normal 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>
|
||||||
66
frontend/src/components/content/WorkExperience.astro
Normal file
66
frontend/src/components/content/WorkExperience.astro
Normal 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>
|
||||||
|
)}
|
||||||
8
frontend/src/components/content/index.ts
Normal file
8
frontend/src/components/content/index.ts
Normal 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';
|
||||||
22
frontend/src/components/forms/ContactEmail.astro
Normal file
22
frontend/src/components/forms/ContactEmail.astro
Normal 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>
|
||||||
201
frontend/src/components/forms/FilterPanel.astro
Normal file
201
frontend/src/components/forms/FilterPanel.astro
Normal 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>
|
||||||
2
frontend/src/components/forms/index.ts
Normal file
2
frontend/src/components/forms/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export { default as FilterPanel } from './FilterPanel.astro';
|
||||||
|
export { default as ContactEmail } from './ContactEmail.astro';
|
||||||
7
frontend/src/components/index.ts
Normal file
7
frontend/src/components/index.ts
Normal 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';
|
||||||
52
frontend/src/components/layout/Footer.astro
Normal file
52
frontend/src/components/layout/Footer.astro
Normal 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>
|
||||||
19
frontend/src/components/layout/Grid.astro
Normal file
19
frontend/src/components/layout/Grid.astro
Normal 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>
|
||||||
178
frontend/src/components/layout/HeaderNavigation.astro
Normal file
178
frontend/src/components/layout/HeaderNavigation.astro
Normal 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>
|
||||||
71
frontend/src/components/layout/Hero.astro
Normal file
71
frontend/src/components/layout/Hero.astro
Normal 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>
|
||||||
62
frontend/src/components/layout/Navigation.astro
Normal file
62
frontend/src/components/layout/Navigation.astro
Normal 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>
|
||||||
25
frontend/src/components/layout/Section.astro
Normal file
25
frontend/src/components/layout/Section.astro
Normal 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>
|
||||||
5
frontend/src/components/layout/index.ts
Normal file
5
frontend/src/components/layout/index.ts
Normal 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';
|
||||||
57
frontend/src/components/sections/ConnectSection.astro
Normal file
57
frontend/src/components/sections/ConnectSection.astro
Normal 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>
|
||||||
73
frontend/src/components/sections/FeaturedProjects.astro
Normal file
73
frontend/src/components/sections/FeaturedProjects.astro
Normal 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>
|
||||||
23
frontend/src/components/sections/ProjectSection.astro
Normal file
23
frontend/src/components/sections/ProjectSection.astro
Normal 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>
|
||||||
60
frontend/src/components/sections/ThoughtsSection.astro
Normal file
60
frontend/src/components/sections/ThoughtsSection.astro
Normal 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>
|
||||||
210
frontend/src/components/sections/Welcome.astro
Normal file
210
frontend/src/components/sections/Welcome.astro
Normal 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>
|
||||||
44
frontend/src/components/sections/WorkSection.astro
Normal file
44
frontend/src/components/sections/WorkSection.astro
Normal 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>
|
||||||
6
frontend/src/components/sections/index.ts
Normal file
6
frontend/src/components/sections/index.ts
Normal 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';
|
||||||
184
frontend/src/components/shader/ShaderBackground.tsx
Normal file
184
frontend/src/components/shader/ShaderBackground.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
4
frontend/src/components/shared/ThemeProvider.astro
Normal file
4
frontend/src/components/shared/ThemeProvider.astro
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
---
|
||||||
|
// ThemeProvider is no longer needed with Tailwind's media strategy
|
||||||
|
// Dark mode now works automatically based on system preferences
|
||||||
|
---
|
||||||
62
frontend/src/components/shared/ThemeToggle.astro
Normal file
62
frontend/src/components/shared/ThemeToggle.astro
Normal 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>
|
||||||
2
frontend/src/components/shared/index.ts
Normal file
2
frontend/src/components/shared/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export { default as ThemeProvider } from './ThemeProvider.astro';
|
||||||
|
export { default as ThemeToggle } from './ThemeToggle.astro';
|
||||||
293
frontend/src/components/tools/QRCodeGeneratorAstro.astro
Normal file
293
frontend/src/components/tools/QRCodeGeneratorAstro.astro
Normal 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>
|
||||||
386
frontend/src/components/tools/TipCalculatorAstro.astro
Normal file
386
frontend/src/components/tools/TipCalculatorAstro.astro
Normal 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>
|
||||||
30
frontend/src/components/ui/Button.astro
Normal file
30
frontend/src/components/ui/Button.astro
Normal 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>
|
||||||
25
frontend/src/components/ui/SocialLink.astro
Normal file
25
frontend/src/components/ui/SocialLink.astro
Normal 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>
|
||||||
29
frontend/src/components/ui/StatusBadge.astro
Normal file
29
frontend/src/components/ui/StatusBadge.astro
Normal 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>
|
||||||
16
frontend/src/components/ui/StatusIndicator.astro
Normal file
16
frontend/src/components/ui/StatusIndicator.astro
Normal 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>
|
||||||
47
frontend/src/components/ui/Tag.astro
Normal file
47
frontend/src/components/ui/Tag.astro
Normal 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>
|
||||||
5
frontend/src/components/ui/index.ts
Normal file
5
frontend/src/components/ui/index.ts
Normal 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';
|
||||||
101
frontend/src/content/config.ts
Normal file
101
frontend/src/content/config.ts
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
];
|
||||||
297
frontend/src/content/index.ts
Normal file
297
frontend/src/content/index.ts
Normal 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 };
|
||||||
77
frontend/src/layouts/Layout.astro
Normal file
77
frontend/src/layouts/Layout.astro
Normal 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>
|
||||||
70
frontend/src/layouts/PortfolioLayout.astro
Normal file
70
frontend/src/layouts/PortfolioLayout.astro
Normal 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>
|
||||||
75
frontend/src/layouts/ToolLayout.astro
Normal file
75
frontend/src/layouts/ToolLayout.astro
Normal 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
185
frontend/src/lib/api.ts
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
42
frontend/src/lib/markdown.ts
Normal file
42
frontend/src/lib/markdown.ts
Normal 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 }
|
||||||
|
}
|
||||||
79
frontend/src/pages/about.astro
Normal file
79
frontend/src/pages/about.astro
Normal 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>
|
||||||
38
frontend/src/pages/blog/[slug].astro
Normal file
38
frontend/src/pages/blog/[slug].astro
Normal 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>
|
||||||
135
frontend/src/pages/index.astro
Normal file
135
frontend/src/pages/index.astro
Normal 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>
|
||||||
144
frontend/src/pages/projects.astro
Normal file
144
frontend/src/pages/projects.astro
Normal 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>
|
||||||
91
frontend/src/pages/tools/index.astro
Normal file
91
frontend/src/pages/tools/index.astro
Normal 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>
|
||||||
12
frontend/src/pages/tools/qr.astro
Normal file
12
frontend/src/pages/tools/qr.astro
Normal 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>
|
||||||
12
frontend/src/pages/tools/tips.astro
Normal file
12
frontend/src/pages/tools/tips.astro
Normal 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>
|
||||||
198
frontend/src/pages/work/[slug].astro
Normal file
198
frontend/src/pages/work/[slug].astro
Normal 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>
|
||||||
114
frontend/src/styles/components.css
Normal file
114
frontend/src/styles/components.css
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
416
frontend/src/styles/content-prose.css
Normal file
416
frontend/src/styles/content-prose.css
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
244
frontend/src/styles/content.css
Normal file
244
frontend/src/styles/content.css
Normal 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;
|
||||||
|
}
|
||||||
155
frontend/src/styles/globals.css
Normal file
155
frontend/src/styles/globals.css
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
143
frontend/src/styles/layout.css
Normal file
143
frontend/src/styles/layout.css
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
84
frontend/src/styles/typography.css
Normal file
84
frontend/src/styles/typography.css
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
155
frontend/src/utils/theme-manager.ts
Normal file
155
frontend/src/utils/theme-manager.ts
Normal 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;
|
||||||
52
frontend/tailwind.config.js
Normal file
52
frontend/tailwind.config.js
Normal 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
5
frontend/tsconfig.json
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"extends": "astro/tsconfigs/strict",
|
||||||
|
"include": [".astro/types.d.ts", "src/**/*"],
|
||||||
|
"exclude": ["dist", "sample_site", "node_modules"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user