refactor: clean up template structure and update documentation

- Delete legacy component files (BlogFilters, PostNavigation, ReadingProgress, TableOfContents, RelatedPosts, Experience, FeaturedProject, Hero, Skills)
- Remove obsolete init-template.js and template.config.json
- Update CLAUDE.md and README.md with new command list
- Delete SETUP.md and unused content files
- Refactor content config and blog index logic
- Adjust layout and page files for new structure
This commit is contained in:
Nicholai Vogel 2025-12-27 04:53:44 -07:00
parent d384abea33
commit c1f676c733
28 changed files with 340 additions and 4297 deletions

155
CLAUDE.md
View File

@ -1,130 +1,81 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
Development guidance for this Astro template repository.
## Development Commands
## Commands
### Core Development
```bash
pnpm dev # Run development server
pnpm build # Build the project
pnpm preview # Build and preview with Wrangler
pnpm deploy # Build and deploy to Cloudflare Pages
pnpm dev # Development server
pnpm build # Build for production
pnpm preview # Preview build with Wrangler
pnpm deploy # Deploy to Cloudflare Pages
```
### Utilities
```bash
# Git commit message automation
pnpm commit # Interactive: review, accept/edit, optionally push
# Image conversion to AVIF format
pnpm run convert:avif:all # Convert all images
pnpm run convert:avif:jpeg # Convert JPEG only
pnpm run convert:avif:png # Convert PNG only
# Cloudflare types generation
pnpm cf-typegen # Generate Cloudflare types for TypeScript
# Template initialization
node init-template.js # Interactive setup wizard
node init-template.js --config # Use template.config.json
pnpm commit # AI-powered commit messages
pnpm convert:avif:all # Convert all images to AVIF
pnpm convert:avif:jpeg
pnpm convert:avif:png
pnpm cf-typegen # Generate Cloudflare types
```
## Repository Change Documentation
## Change Documentation
**MANDATORY**: Whenever making any change to the repository (implementations, bug fixes, refactoring, etc.), you MUST update `dev/continuity.md` with:
- Details of the changes made
- Next steps or follow-up actions required
- Any context or decisions that should be preserved for future work
**IMPORTANT**: Update `dev/continuity.md` when making changes to document:
- What changed and why
- Decisions made
- Next steps
This ensures continuity of work and helps maintain context across development sessions.
## Architecture
## High-Level Architecture
Minimal Astro template with content-driven architecture:
This is an Astro-based template built for portfolio and blog sites deployed on Cloudflare Pages. The architecture follows a content-driven approach with three distinct layers:
### Content Layer (`src/content/`)
- **blog/** - MDX blog posts with schema validation
- **sections/** - Homepage sections (hero, experience, skills, featured-project)
- **pages/** - Page-specific content (contact)
### 1. Content Layer (`src/content/**`)
Content is managed via Astro's Content Collections API with schema validation defined in `src/content.config.ts`:
Schema defined in `src/content.config.ts`
- **`blog/`** - Blog posts as MDX files
- Schema: title, description, pubDate, updatedDate (optional), heroImage (optional), featured (boolean), category, tags
- Posts are sorted by pubDate (newest first)
### Component Layer
- **Core UI**: BlogCard, FormattedDate, Navigation, Footer, GridOverlay
- **Blog**: BlogFilters, ReadingProgress, TableOfContents, PostNavigation, RelatedPosts
- **Sections**: Hero, Experience, Skills, FeaturedProject
- **`sections/`** - Homepage section content (hero, experience, skills, featured-project)
- Each section has a custom schema for its specific data needs
- Experience entries include systemId, status, dates, company, role, tags, description, achievements, link
- Skills entries include id, domain, tools, proficiency
### Routes
- Static routes in `src/pages/`
- Dynamic blog routes via `[...slug].astro`
- Layouts in `src/layouts/`
- **`pages/`** - Page-specific content (contact form configuration)
- Includes form labels, social links, subject options
## Data Flow
### 2. Component Layer
Components are organized by purpose:
**Blog Index** (`src/pages/blog/index.astro`):
1. Fetch all posts via `getCollection('blog')`
2. Sort by pubDate (newest first)
3. Identify featured post (first with `featured: true`)
4. Render featured hero + filterable grid
5. Extract categories for filter UI
- **Core UI**: `BlogCard`, `FormattedDate`, `Navigation`, `Footer`, `GridOverlay`
- **Blog-specific**: `BlogFilters`, `ReadingProgress`, `TableOfContents`, `PostNavigation`, `RelatedPosts`
- **Section components**: `Hero`, `Experience`, `Skills`, `FeaturedProject`
**Individual Posts** (`src/pages/blog/[...slug].astro`):
1. `getStaticPaths()` generates routes
2. Calculate previous/next posts (by date)
3. Find related posts (matching category/tags, limit 3)
4. Calculate reading time (200 wpm)
5. Pass to `BlogPost` layout
### 3. Page & Layout Layer
- **Layouts**: `BaseLayout` (shared structure), `BlogPost` (blog template)
- **Routes**: Static routes in `src/pages/` with dynamic blog routes via `[...slug].astro`
## Image Handling
- `src/assets/` - Processed by Astro (relative paths)
- `public/media/` - Served as-is (absolute paths like `/media/file.mp4`)
- AVIF conversion utility available
## Data Flow Patterns
### Blog Index (`src/pages/blog/index.astro`)
1. Fetches all posts via `getCollection('blog')`
2. Sorts by pubDate (newest first)
3. Identifies featured post (first with `featured: true` or fallback to latest)
4. Renders featured hero + filterable grid of all posts
5. Extracts unique categories for filter UI
### Individual Blog Posts (`src/pages/blog/[...slug].astro`)
1. Uses `getStaticPaths()` to generate all blog post routes
2. For each post, calculates:
- Previous/next posts (by date)
- Related posts (matching category or shared tags, limited to 3)
- Reading time (based on word count, 200 wpm)
3. Passes everything to `BlogPost` layout which handles headings, navigation, related posts
### Content Collections
All content follows the schema validation pattern:
```
MDX file → src/content.config.ts schema → getCollection() → Component props
```
## Key Technical Patterns
### Image Handling
- Assets in `src/assets/` are processed by Astro (use relative paths in frontmatter)
- Static files in `public/media/` are served as-is (use absolute paths like `/media/file.mp4`)
- AVIF conversion utility available for optimization
### Deployment
- Cloudflare Pages adapter configured in `astro.config.mjs`
- Image service set to "compile" mode
## Deployment
- Cloudflare Pages adapter configured
- Image service: "compile" mode
- Platform proxy enabled for development
## Blog Post Creation Workflow
1. Create `.mdx` file in `src/content/blog/` (filename becomes URL slug)
2. Add required frontmatter: title, description, pubDate
3. Optionally add: heroImage, featured, category, tags
4. Write content using Markdown/MDX with embedded JSX/HTML
5. Images can reference `src/assets/` (relative) or `public/media/` (absolute)
## Utility Scripts
- **`init-template.js`** - Template initialization wizard (interactive and config modes)
- **`src/utils/convert-to-avif.js`** - Converts images to AVIF format with quality options
- **`src/utils/git-commit.js`** - AI-powered commit message generation using OpenRouter API
## Template Features
This template is designed to be easily customizable and includes:
- **Content Collections** - Type-safe MDX content with schema validation
- **Design System** - Comprehensive design documentation in `dev/design.json`
- **SEO Optimization** - Structured data, meta tags, Open Graph support
- **Image Optimization** - AVIF conversion utility for modern image formats
- **AI Tooling** - Optional AI-powered git commit messages
- **Deployment Ready** - Pre-configured for Cloudflare Pages
- **`src/utils/convert-to-avif.js`** - Image optimization
- **`src/utils/git-commit.js`** - AI commit message generation (requires OpenRouter API key in `src/utils/.env`)

301
README.md
View File

@ -1,276 +1,109 @@
# Astro Portfolio & Blog Template
# Astro Template
A modern, high-performance portfolio and blog template built with **Astro 5**, **React 19**, and **Tailwind CSS 4**. Features an industrial dark design system, MDX blog support, and deployment-ready for Cloudflare Pages.
Minimal Astro development environment with React, Tailwind CSS, and Cloudflare Pages deployment.
## Features
## Stack
### Content Management
- **Type-Safe Content Collections** - Schema validation with Astro's Content Collections API
- **MDX Blog Support** - Write posts in Markdown with embedded React components
- **Flexible Taxonomies** - Organize content with categories and tags
- **Featured Posts** - Highlight your best work
- **Related Posts** - Automatic suggestions based on content similarity
- **Reading Time Calculation** - Automatic based on word count
### Design & UX
- **Industrial Dark Design System** - Professional, modern aesthetic
- **Fully Responsive** - Mobile-first design approach
- **Custom Components** - Blog cards, filters, navigation, table of contents
- **Reading Progress Indicator** - Visual progress bar for long-form content
- **Theme Toggle** - Dark/light mode support
- **Grid Overlay** - Optional design grid for development
### Performance & SEO
- **Lightning Fast** - Built with Astro for optimal performance
- **SEO Optimized** - Structured data (JSON-LD), meta tags, Open Graph
- **AVIF Image Support** - Modern image format with conversion utility
- **RSS Feed** - Automatic feed generation
- **Sitemap** - Auto-generated for search engines
- **Cloudflare Pages Ready** - Optimized for edge deployment
### Developer Experience
- **TypeScript** - Full type safety
- **pnpm** - Fast, disk-efficient package manager
- **AI-Powered Git Commits** - Automatic commit message generation
- **AVIF Converter** - Built-in image optimization utility
- **Template Initialization** - Interactive setup script
- **Astro 5** - Static site framework
- **React 19** - Interactive components
- **Tailwind CSS 4** - Styling
- **MDX** - Markdown with JSX
- **TypeScript** - Type safety
- **Cloudflare Pages** - Deployment
- **pnpm** - Package manager
## Quick Start
### Prerequisites
```bash
# Install dependencies
pnpm install
- **Node.js** 18+
- **pnpm** (recommended) - `npm install -g pnpm`
# Start dev server
pnpm dev
### Installation
# Build
pnpm build
1. **Clone or download this template**
```bash
git clone <repository-url>
cd astro-template
```
2. **Install dependencies**
```bash
pnpm install
```
3. **Initialize your template**
```bash
node init-template.js
```
Follow the prompts to personalize with your information.
4. **Start development server**
```bash
pnpm dev
```
Open [http://localhost:4321](http://localhost:4321)
5. **Replace placeholder content**
- Update images in `src/assets/` and `public/media/`
- Customize sections in `src/content/sections/`
- Write your first blog post in `src/content/blog/`
See [SETUP.md](./SETUP.md) for detailed setup instructions.
# Deploy
pnpm deploy
```
## Project Structure
```
/
├── public/ # Static assets (served as-is)
│ ├── media/ # Videos, large images
│ └── fonts/ # Web fonts
├── src/
│ ├── assets/ # Optimized images (processed by Astro)
│ ├── components/ # Reusable UI components
│ │ ├── sections/ # Homepage section components
│ │ └── ...
│ ├── content/ # Content collections
│ │ ├── blog/ # Blog posts (MDX)
│ │ ├── sections/ # Homepage sections (MDX)
│ │ └── pages/ # Page-specific content
│ ├── layouts/ # Page layouts
│ ├── pages/ # File-based routing
│ ├── styles/ # Global styles
│ ├── utils/ # Utility scripts
│ └── consts.ts # Site-wide constants
├── dev/ # Development resources
│ ├── design.json # Design system documentation
│ └── continuity.md # Development log
├── template.config.json # Template configuration
└── init-template.js # Setup script
src/
├── assets/ # Images (processed by Astro)
├── components/ # React/Astro components
├── content/ # MDX content collections
│ ├── blog/ # Blog posts
│ ├── sections/ # Homepage sections
│ └── pages/ # Page content
├── layouts/ # Page layouts
├── pages/ # Routes
├── styles/ # Global CSS
└── utils/ # Utility scripts
public/
└── media/ # Static assets
dev/
├── design.json # Design system docs
└── continuity.md # Development log
```
## Content Collections
### Blog Posts
Create MDX files in `src/content/blog/`:
### Blog Posts (`src/content/blog/`)
```yaml
---
title: 'Your Post Title'
description: 'SEO description'
title: 'Post Title'
description: 'Description'
pubDate: 'Dec 27 2024'
heroImage: '../../assets/your-image.avif'
heroImage: '../../assets/image.avif'
featured: true
category: 'Tutorial'
tags: ['Tag1', 'Tag2']
category: 'Category'
tags: ['tag1', 'tag2']
---
Your content here...
Content here...
```
### Sections
### Sections (`src/content/sections/`)
Customize homepage sections in `src/content/sections/`:
- `hero.mdx` - Hero section with headline and bio
- `experience.mdx` - Work history and achievements
- `skills.mdx` - Technical skills and proficiencies
- `featured-project.mdx` - Showcase your best work
- `hero.mdx` - Hero section
- `experience.mdx` - Work history
- `skills.mdx` - Skills
- `featured-project.mdx` - Featured work
## Available Commands
See files for schema examples.
### Development
```bash
pnpm dev # Start dev server (localhost:4321)
pnpm build # Build for production
pnpm preview # Build and preview with Wrangler
```
### Deployment
```bash
pnpm deploy # Build and deploy to Cloudflare Pages
```
### Utilities
```bash
pnpm commit # AI-powered commit messages
pnpm convert:avif:all # Convert all images to AVIF
pnpm convert:avif:jpeg # Convert JPEG only
pnpm convert:avif:png # Convert PNG only
pnpm cf-typegen # Generate Cloudflare types
```
### Template Setup
```bash
node init-template.js # Interactive setup
node init-template.js --config # Use template.config.json
```
## Customization
### Design System
The design system is documented in `dev/design.json`. Key areas:
- **Colors**: Modify CSS custom properties in `src/styles/global.css`
- **Typography**: Adjust font scales and families
- **Spacing**: Grid system and spacing tokens
- **Components**: Design patterns and component specs
### Configuration Files
- **template.config.json** - Site-wide settings (name, URLs, social links)
- **astro.config.mjs** - Astro configuration
- **wrangler.jsonc** - Cloudflare Pages settings
- **src/consts.ts** - Global constants
### Adding Content
1. **New Blog Post**: Create `.mdx` file in `src/content/blog/`
2. **Update Sections**: Edit files in `src/content/sections/`
3. **Add Images**: Place in `src/assets/` (processed) or `public/media/` (static)
4. **Customize Components**: Modify files in `src/components/`
## Deployment
### Cloudflare Pages (Default)
1. **Build your site**
```bash
pnpm build
```
2. **Deploy**
```bash
pnpm deploy
```
Or connect your Git repository to Cloudflare Pages for automatic deployments.
### Other Platforms
This template can be adapted for other platforms:
- **Vercel**: Change adapter to `@astrojs/vercel`
- **Netlify**: Change adapter to `@astrojs/netlify`
- **Static**: Remove adapter for static site generation
## AI-Powered Commit Messages
This template includes an AI commit message generator:
1. **Setup**: Create `src/utils/.env` with your OpenRouter API key
```
OPENROUTER_API_KEY=your_key_here
```
2. **Use**: Stage changes and run
```bash
pnpm commit
```
See `src/utils/README.md` for details.
## Image Optimization
Convert images to AVIF format for optimal performance:
## Utilities
```bash
# Convert all images
# AI-powered commit messages
pnpm commit
# Convert images to AVIF
pnpm convert:avif:all
# Convert specific formats
pnpm convert:avif:jpeg
pnpm convert:avif:png
# Generate Cloudflare types
pnpm cf-typegen
```
The utility processes files in `src/assets/` and `public/media/`.
## Configuration
## Tech Stack
- `src/consts.ts` - Site constants
- `astro.config.mjs` - Astro config
- `wrangler.jsonc` - Cloudflare config
- `dev/design.json` - Design system
- **Framework**: [Astro 5](https://astro.build)
- **UI Library**: [React 19](https://react.dev)
- **Styling**: [Tailwind CSS 4](https://tailwindcss.com)
- **Deployment**: [Cloudflare Pages](https://pages.cloudflare.com)
- **Content**: MDX with Content Collections
- **Package Manager**: pnpm
## Development
## Browser Support
- Chrome/Edge 90+
- Firefox 88+
- Safari 14+
- Modern mobile browsers
## Contributing
This is a template repository. Feel free to fork and customize for your needs.
See `CLAUDE.md` for detailed architecture and development patterns.
## License
MIT License - see [LICENSE](./LICENSE) for details.
## Support
- [Documentation](./SETUP.md)
- [Astro Docs](https://docs.astro.build)
- [Cloudflare Pages Docs](https://developers.cloudflare.com/pages)
## Credits
Built with modern web technologies and best practices for performance, SEO, and developer experience.
---
**Ready to make it yours?** Run `node init-template.js` to get started!
MIT

503
SETUP.md
View File

@ -1,503 +0,0 @@
# Setup Guide
Complete step-by-step guide to setting up and customizing your Astro portfolio template.
## Table of Contents
1. [Initial Setup](#initial-setup)
2. [Template Personalization](#template-personalization)
3. [Content Customization](#content-customization)
4. [Media Assets](#media-assets)
5. [Design Customization](#design-customization)
6. [Deployment](#deployment)
7. [Optional Features](#optional-features)
8. [Troubleshooting](#troubleshooting)
## Initial Setup
### Prerequisites
Before you begin, make sure you have:
- **Node.js 18 or later** - [Download here](https://nodejs.org/)
- **pnpm** (recommended) - Install with `npm install -g pnpm`
- **Git** - [Download here](https://git-scm.com/)
- A **code editor** (VS Code recommended)
### Installation
1. **Clone or download the template**
```bash
git clone <repository-url>
cd astro-template
```
2. **Install dependencies**
```bash
pnpm install
```
3. **Verify installation**
```bash
pnpm dev
```
Open [http://localhost:4321](http://localhost:4321) - you should see the template with placeholder content.
## Template Personalization
### Option 1: Interactive Setup (Recommended)
Run the setup wizard to personalize all template files:
```bash
node init-template.js
```
The wizard will ask for:
- Your name and profession
- Site URL and description
- Contact information (email, location)
- Social media links
- Company/branding details
- Cloudflare project name
Review the summary and confirm to apply changes.
### Option 2: Manual Configuration
Edit `template.config.json` with your information, then run:
```bash
node init-template.js --config
```
### What Gets Updated
The setup script updates:
- `src/consts.ts` - Site-wide constants
- `src/components/BaseHead.astro` - SEO and structured data
- `src/components/Navigation.astro` - Branding
- `src/components/Footer.astro` - Contact and social links
- `src/content/sections/hero.mdx` - Hero section
- `src/content/sections/experience.mdx` - Work history
- `src/content/pages/contact.mdx` - Contact page
- `astro.config.mjs` - Site URL
- `wrangler.jsonc` - Cloudflare project name
- `package.json` - Package name
## Content Customization
### Homepage Sections
Edit files in `src/content/sections/`:
#### Hero Section (`hero.mdx`)
```yaml
---
headlineLine1: "YOUR NAME"
headlineLine2: "HERE"
portfolioYear: "Portfolio 2025"
location: "Your City, State"
locationLabel: "Location"
bio: "Your professional bio..."
---
```
#### Experience (`experience.mdx`)
```yaml
---
sectionTitle: "Experience"
sectionSubtitle: "History"
sectionLabel: "/// Your professional journey."
entries:
- systemId: "SYS.01"
status: "ACTIVE"
dates: "2020 — PRESENT"
company: "Company Name"
role: "Your Role"
tags: ["Skill 1", "Skill 2"]
description: "What you do..."
achievements:
- label: "Projects"
text: "Notable projects..."
link:
url: "https://company.com"
text: "Visit Website"
---
```
#### Skills (`skills.mdx`)
```yaml
---
sectionTitle: "Technical"
sectionSubtitle: "Arsenal"
description: "Your skills and specialties"
skills:
- id: "01"
domain: "Primary Skill"
tools: "Tool 1 • Tool 2 • Tool 3"
proficiency: "Expert"
---
```
#### Featured Project (`featured-project.mdx`)
```yaml
---
role: "Your Role"
client: "Client Name"
year: "2024"
region: "Global"
projectTitle: "Project"
projectSubtitle: "Name"
projectDescription: "Brief description..."
stats:
- label: "Category"
value: "Value"
videoUrl: "/media/your-video.mp4"
linkUrl: "/blog/your-case-study/"
---
```
### Blog Posts
#### Creating a New Post
1. Create a new MDX file in `src/content/blog/`:
```bash
touch src/content/blog/my-new-post.mdx
```
2. Add frontmatter and content:
```mdx
---
title: 'Your Post Title'
description: 'Brief description for SEO'
pubDate: 'Dec 27 2024'
heroImage: '../../assets/hero-image.avif'
featured: false
category: 'Tutorial'
tags: ['Web Dev', 'Astro']
---
## Introduction
Your content here...
```
3. The file name becomes the URL slug:
- `my-new-post.mdx``/blog/my-new-post/`
#### Blog Frontmatter Fields
**Required:**
- `title` - Post title
- `description` - SEO description
- `pubDate` - Publication date
**Optional:**
- `heroImage` - Header image path
- `featured` - Set to `true` to feature on blog index
- `category` - For filtering
- `tags` - Array of tags
- `updatedDate` - Last update date
#### Example Blog Post Template
See `dev/blog_template.mdx` for a complete example.
### Contact Page
Edit `src/content/pages/contact.mdx`:
```yaml
---
pageTitleLine1: "Get In"
pageTitleLine2: "Touch"
availabilityText: "Available for new projects"
email: "your@email.com"
location: "Your City"
coordinates: "XX.XXXX° N, XX.XXXX° W"
socialLinks:
- name: "LinkedIn"
url: "https://linkedin.com/in/yourprofile"
formLabels:
name: "/// Your Name"
email: "/// Your Email"
subject: "/// Subject"
message: "/// Message"
submit: "Send Message"
subjectOptions:
- value: "project"
label: "Project Inquiry"
---
```
## Media Assets
### Images
#### Placeholder Images to Replace
See `public/media/PLACEHOLDER_ASSETS.md` for a complete list.
**Priority replacements:**
1. Default OG image: `src/assets/nicholai-medium-portrait.avif`
2. Blog post hero images
3. Featured project video
4. Favicons
#### Adding Your Images
1. **For blog/content images** (optimized by Astro):
- Place in `src/assets/`
- Reference: `../../assets/image-name.jpg`
2. **For videos/large files** (static):
- Place in `public/media/`
- Reference: `/media/filename.mp4`
#### Converting to AVIF
AVIF provides superior compression. Convert your images:
```bash
# Convert all images
pnpm convert:avif:all
# Convert specific formats
pnpm convert:avif:jpeg
pnpm convert:avif:png
# Custom quality (0-100)
node src/utils/convert-to-avif.js --jpeg --quality 80
```
### Favicons
Generate a complete favicon set:
1. Use [favicon.io](https://favicon.io) or [RealFaviconGenerator](https://realfavicongenerator.net)
2. Replace files in `public/`:
- `favicon.ico`
- `favicon-32.png`
- `favicon-192.png`
- `apple-touch-icon.png`
- `favicon.svg`
## Design Customization
### Color Scheme
Edit CSS custom properties in `src/styles/global.css`:
```css
:root {
/* Background colors */
--theme-bg-primary: #0B0D11;
--theme-bg-secondary: #12141A;
/* Text colors */
--theme-text-primary: #E8E9ED;
--theme-text-muted: #9CA3B3;
/* Accent colors */
--brand-accent: #3D8374;
}
```
### Typography
Fonts are loaded in `src/components/BaseHead.astro`. To change:
1. Update Google Fonts link (line 124-137)
2. Update font families in `global.css`
### Design System
Full design documentation in `dev/design.json`:
- Color palettes
- Typography scales
- Spacing system
- Grid system
- Component patterns
## Deployment
### Cloudflare Pages (Default)
#### Option 1: CLI Deployment
```bash
pnpm deploy
```
#### Option 2: Git Integration
1. Push your code to GitHub/GitLab
2. Go to [Cloudflare Pages Dashboard](https://dash.cloudflare.com/pages)
3. Connect your repository
4. Configure build settings:
- **Build command**: `pnpm build`
- **Build output directory**: `dist`
- **Root directory**: `/`
- **Node version**: 18 or later
5. Deploy!
### Environment Variables
For Cloudflare Pages deployment:
1. Go to your project settings → Environment Variables
2. Add any required variables (e.g., API keys for forms)
### Custom Domain
1. In Cloudflare Pages, go to Custom Domains
2. Add your domain
3. Follow DNS configuration instructions
### Other Platforms
**Vercel:**
```bash
# Change adapter
pnpm remove @astrojs/cloudflare
pnpm add @astrojs/vercel
```
Update `astro.config.mjs`:
```js
import vercel from '@astrojs/vercel/serverless';
export default defineConfig({
adapter: vercel(),
// ...
});
```
**Netlify:**
```bash
pnpm remove @astrojs/cloudflare
pnpm add @astrojs/netlify
```
**Static (no adapter):**
Remove the `adapter` from `astro.config.mjs` for pure static site generation.
## Optional Features
### AI-Powered Git Commits
1. Get an API key from [OpenRouter.ai](https://openrouter.ai)
2. Create `src/utils/.env`:
```
OPENROUTER_API_KEY=your_key_here
```
3. Stage changes and run:
```bash
pnpm commit
```
The script generates a commit message, lets you edit it, and optionally pushes.
### Analytics
Add analytics to `src/components/BaseHead.astro`:
```astro
<!-- Example: Plausible -->
<script defer data-domain="yoursite.com" src="https://plausible.io/js/script.js"></script>
```
### Contact Form Integration
The template includes a contact form. To make it functional:
**Option 1: Cloudflare Forms** (Recommended)
- Forms are built-in with Cloudflare Pages
- View submissions in your Cloudflare Dashboard
**Option 2: External Service**
- [Formspree](https://formspree.io)
- [Web3Forms](https://web3forms.com)
- [Netlify Forms](https://www.netlify.com/products/forms/)
Update the form action in `src/pages/contact.astro`.
## Troubleshooting
### Build Errors
**"Cannot find module..."**
```bash
# Reinstall dependencies
rm -rf node_modules pnpm-lock.yaml
pnpm install
```
**TypeScript errors**
```bash
# Generate types
pnpm cf-typegen
```
### Dev Server Issues
**Port already in use**
```bash
# Use a different port
pnpm dev -- --port 3000
```
**Changes not reflecting**
- Hard reload: Ctrl+Shift+R (Windows) / Cmd+Shift+R (Mac)
- Clear `.astro` cache and restart
### Image Issues
**Images not loading**
- Check file paths (relative vs absolute)
- Ensure images are in correct directory
- Restart dev server
**AVIF conversion fails**
```bash
# Install Sharp dependencies
pnpm install sharp
```
### Deployment Issues
**Build fails on Cloudflare**
- Check Node version (should be 18+)
- Verify `pnpm build` works locally
- Check build logs for specific errors
**404 on deployed site**
- Verify build output directory is `dist`
- Check routing/links are correct
- Clear Cloudflare cache
## Next Steps
1. **Test your site locally**: `pnpm dev`
2. **Build for production**: `pnpm build`
3. **Preview build**: `pnpm preview`
4. **Deploy**: `pnpm deploy`
5. **Update `dev/continuity.md`** with your changes
## Support
- **Astro Documentation**: [docs.astro.build](https://docs.astro.build)
- **Cloudflare Pages**: [developers.cloudflare.com/pages](https://developers.cloudflare.com/pages)
- **Tailwind CSS**: [tailwindcss.com/docs](https://tailwindcss.com/docs)
---
**Need help?** Check the existing blog posts for examples of how to use various features.

View File

@ -1,69 +1,54 @@
# Continuity Log
This file tracks development changes, decisions, and next steps for maintaining project context across sessions.
## Instructions
When making changes to this template:
1. Add a new entry with today's date
2. Document what changed and why
3. Note any decisions made
4. List next steps or follow-up items
Development log for tracking changes, decisions, and next steps.
## Entry Template
```markdown
## YYYY-MM-DD - Brief Description
### Changes Made
- List what was changed
- Be specific about files and features
### Changes
- What changed
- Why it changed
### Decisions & Rationale
- Document why certain approaches were chosen
- Note trade-offs or alternatives considered
### Testing Steps
1. How to verify the changes work
2. What to check
### Decisions
- Key decisions made
- Trade-offs considered
### Next Steps
- [ ] Follow-up task 1
- [ ] Follow-up task 2
- [ ] Follow-up items
```
---
## 2024-12-27 - Template Initialization
## 2024-12-27 - Minimal Template Setup
### Changes Made
- Created as a reusable Astro portfolio/blog template
- Replaced all personal content with placeholders
- Added template initialization script (`init-template.js`)
- Created comprehensive documentation (README.md, SETUP.md)
- Included utility scripts for AVIF conversion and AI commit messages
### Changes
- Created minimal Astro development template
- Single example blog post showing MDX structure
- Minimal section examples (hero, experience, skills, featured-project)
- Simple contact page example
- Utility scripts: AVIF conversion, AI commit messages
- Design system documentation in `dev/design.json`
### Template Structure
- **Content**: MDX-based content collections (blog, sections, pages)
- **Design**: Industrial dark design system with full customization
- **Deployment**: Pre-configured for Cloudflare Pages
- **Tools**: pnpm, TypeScript, Tailwind CSS 4, React 19
### Stack
- Astro 5 + React 19 + Tailwind CSS 4
- TypeScript
- MDX content collections
- Cloudflare Pages deployment
- pnpm package manager
### Initial Setup Steps
1. Run `node init-template.js` to personalize
2. Replace placeholder images in `src/assets/` and `public/media/`
3. Update sections in `src/content/sections/`
4. Write first blog post in `src/content/blog/`
5. Deploy with `pnpm deploy`
### Structure
- Content-driven architecture with type-safe schemas
- Example content showing data structures
- Utility scripts for common tasks
- Clean development environment
### Next Steps
- [ ] Customize design system (colors, fonts) if needed
- [ ] Add your own content and media assets
- [ ] Configure analytics and contact form
- [ ] Connect domain and deploy
- [ ] Replace example content with your own
- [ ] Customize design system as needed
- [ ] Configure deployment
---
## Future Entries
Add your development log entries below...
Add new entries below...

View File

@ -1,260 +0,0 @@
#!/usr/bin/env node
/**
* Template Initialization Script
*
* This script personalizes the Astro portfolio template with your information.
*
* Usage:
* node init-template.js # Interactive mode (prompts for info)
* node init-template.js --config # Config mode (reads template.config.json)
* node init-template.js --help # Show help
*/
import fs from 'fs/promises';
import path from 'path';
import { fileURLToPath } from 'url';
import readline from 'readline';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// ANSI color codes for better terminal output
const colors = {
reset: '\x1b[0m',
bright: '\x1b[1m',
dim: '\x1b[2m',
green: '\x1b[32m',
yellow: '\x1b[33m',
blue: '\x1b[34m',
cyan: '\x1b[36m',
red: '\x1b[31m'
};
function log(message, color = 'reset') {
console.log(`${colors[color]}${message}${colors.reset}`);
}
function prompt(question) {
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout
});
return new Promise((resolve) => {
rl.question(`${colors.cyan}${question}${colors.reset} `, (answer) => {
rl.close();
resolve(answer.trim());
});
});
}
async function loadConfig() {
try {
const configPath = path.join(__dirname, 'template.config.json');
const configData = await fs.readFile(configPath, 'utf-8');
return JSON.parse(configData);
} catch (error) {
log('Warning: Could not load template.config.json', 'yellow');
return null;
}
}
async function interactiveMode() {
log('\n=== Astro Portfolio Template Setup ===\n', 'bright');
log('This wizard will help you personalize your template.\n', 'dim');
const config = {
site: {},
branding: {},
personal: {},
social: {},
seo: {},
cloudflare: {}
};
// Site information
log('\n--- Site Information ---\n', 'blue');
config.personal.fullName = await prompt('Your full name:') || 'Your Name';
config.personal.firstName = config.personal.fullName.split(' ')[0];
config.personal.lastName = config.personal.fullName.split(' ').slice(1).join(' ') || 'Name';
config.personal.jobTitle = await prompt('Your profession/job title:') || 'Your Profession';
config.site.title = `${config.personal.fullName}${config.personal.jobTitle}`;
config.site.description = await prompt('Brief description of what you do:') || 'Professional portfolio and blog';
config.site.url = await prompt('Your site URL (e.g., https://example.com):') || 'https://yoursite.com';
config.site.author = config.personal.fullName;
// Branding
log('\n--- Branding ---\n', 'blue');
const defaultInitials = config.personal.firstName[0] + (config.personal.lastName[0] || 'N');
config.branding.initials = await prompt(`Your initials (default: ${defaultInitials}):`) || defaultInitials;
config.branding.year = await prompt('Portfolio year (default: 2025):') || '2025';
config.branding.backgroundText = await prompt(`Large background text (default: ${config.personal.lastName.toUpperCase()}):`) || config.personal.lastName.toUpperCase();
config.branding.companyName = await prompt('Company name (optional):') || 'Your Company';
// Contact information
log('\n--- Contact Information ---\n', 'blue');
config.personal.email = await prompt('Email address:') || 'your@email.com';
config.personal.location = await prompt('Location (e.g., San Francisco, CA):') || 'Your City, State';
config.personal.locationCountry = await prompt('Country:') || 'Your Country';
config.personal.bio = await prompt('Professional bio (1-2 sentences):') || 'Professional bio here.';
// Social links
log('\n--- Social Links ---\n', 'blue');
config.social.linkedin = await prompt('LinkedIn URL (full URL):') || 'https://linkedin.com/in/yourprofile';
config.social.github = await prompt('GitHub URL:') || 'https://github.com/yourusername';
config.social.twitter = await prompt('Twitter URL:') || 'https://twitter.com/yourhandle';
const twitterHandle = config.social.twitter.split('/').pop();
config.social.twitterHandle = twitterHandle.startsWith('@') ? twitterHandle : `@${twitterHandle}`;
config.social.website = config.site.url;
// Cloudflare
log('\n--- Deployment ---\n', 'blue');
const defaultProjectName = config.site.url.replace(/https?:\/\//, '').replace(/[^a-z0-9-]/gi, '-').toLowerCase();
config.cloudflare.projectName = await prompt(`Cloudflare project name (default: ${defaultProjectName}):`) || defaultProjectName;
// SEO
config.seo.keywords = ['Portfolio', 'Blog', config.personal.jobTitle];
config.seo.serviceTypes = ['Service 1', 'Service 2', 'Service 3'];
config.seo.companyUrl = config.branding.companyName !== 'Your Company'
? await prompt('Company website URL (optional):')
: 'https://example.com';
return config;
}
async function applyConfig(config) {
log('\n🔧 Applying configuration...\n', 'bright');
const replacements = {
// Site-wide
'Your Name — Your Profession': config.site.title,
'Your Name': config.personal.fullName,
'Your Profession': config.personal.jobTitle,
'Your professional description here. Describe what you do, who you work with, and what makes you unique.': config.site.description,
'your professional description': config.site.description,
'https://yoursite.com': config.site.url,
'your@email.com': config.personal.email,
// Branding
'YN / 2025': `${config.branding.initials} / ${config.branding.year}`,
'NAME': config.branding.backgroundText,
// Personal
'Your': config.personal.firstName,
'Your City, State': config.personal.location,
'Your Country': config.personal.locationCountry,
'Your Company': config.branding.companyName,
// Social
'https://linkedin.com/in/yourprofile': config.social.linkedin,
'https://github.com/yourusername': config.social.github,
'https://twitter.com/yourhandle': config.social.twitter,
'@yourhandle': config.social.twitterHandle,
// Cloudflare
'astro-portfolio-template': config.cloudflare.projectName
};
const filesToUpdate = [
'src/consts.ts',
'src/components/BaseHead.astro',
'src/components/Navigation.astro',
'src/components/Footer.astro',
'src/content/sections/hero.mdx',
'src/content/sections/experience.mdx',
'src/content/pages/contact.mdx',
'astro.config.mjs',
'wrangler.jsonc',
'package.json'
];
for (const file of filesToUpdate) {
try {
const filePath = path.join(__dirname, file);
let content = await fs.readFile(filePath, 'utf-8');
for (const [search, replace] of Object.entries(replacements)) {
content = content.replaceAll(search, replace);
}
await fs.writeFile(filePath, content, 'utf-8');
log(`✓ Updated ${file}`, 'green');
} catch (error) {
log(`✗ Failed to update ${file}: ${error.message}`, 'red');
}
}
// Save config for reference
try {
const configPath = path.join(__dirname, 'template.config.json');
await fs.writeFile(
configPath,
JSON.stringify(config, null, 2),
'utf-8'
);
log('✓ Saved configuration to template.config.json', 'green');
} catch (error) {
log(`✗ Failed to save config: ${error.message}`, 'red');
}
log('\n✨ Template personalization complete!\n', 'bright');
log('Next steps:', 'cyan');
log(' 1. Review the changes made to your files', 'dim');
log(' 2. Replace placeholder images in src/assets/ and public/media/', 'dim');
log(' 3. Update content in src/content/sections/ with your info', 'dim');
log(' 4. Run `pnpm dev` to preview your site', 'dim');
log(' 5. Run `pnpm deploy` when ready to publish\n', 'dim');
}
async function showHelp() {
log('\n=== Astro Portfolio Template Setup ===\n', 'bright');
log('Usage:', 'cyan');
log(' node init-template.js Interactive mode (prompts for info)', 'dim');
log(' node init-template.js --config Config mode (reads template.config.json)', 'dim');
log(' node init-template.js --help Show this help message\n', 'dim');
log('Interactive mode will ask you questions and personalize the template.', 'dim');
log('Config mode reads from template.config.json file.\n', 'dim');
}
async function main() {
const args = process.argv.slice(2);
if (args.includes('--help') || args.includes('-h')) {
await showHelp();
return;
}
if (args.includes('--config')) {
log('\n📄 Running in config mode...\n', 'bright');
const config = await loadConfig();
if (!config) {
log('Error: template.config.json not found or invalid.', 'red');
log('Create one or run without --config flag for interactive mode.\n', 'yellow');
process.exit(1);
}
await applyConfig(config);
} else {
// Interactive mode
const config = await interactiveMode();
log('\n📋 Configuration summary:', 'yellow');
console.log(JSON.stringify(config, null, 2));
const confirm = await prompt('\nApply this configuration? (yes/no):');
if (confirm.toLowerCase() === 'yes' || confirm.toLowerCase() === 'y') {
await applyConfig(config);
} else {
log('\nSetup cancelled. No changes were made.\n', 'yellow');
}
}
}
main().catch((error) => {
log(`\n❌ Error: ${error.message}\n`, 'red');
console.error(error);
process.exit(1);
});

View File

@ -1,179 +0,0 @@
---
interface Props {
categories: string[];
class?: string;
}
const { categories, class: className = '' } = Astro.props;
---
<div class:list={['blog-filters', className]} data-blog-filters>
<!-- Filters row -->
<div class="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-6 mb-10">
<!-- Category chips -->
<div class="flex flex-wrap items-center gap-1">
<span class="text-[10px] font-mono text-[var(--theme-text-muted)] uppercase tracking-widest mr-4">
/// SECTOR SELECT
</span>
<button
type="button"
data-category="all"
class="filter-chip active px-4 py-2 text-[10px] font-mono font-bold uppercase tracking-widest border-b-2 border-brand-accent text-[var(--theme-text-primary)] bg-[var(--theme-hover-bg-strong)] transition-all duration-300 hover:bg-[var(--theme-hover-bg-strong)]"
>
All
</button>
{categories.map((category) => (
<button
type="button"
data-category={category}
class="filter-chip px-4 py-2 text-[10px] font-mono font-bold uppercase tracking-widest border-b-2 border-transparent text-[var(--theme-text-muted)] hover:text-[var(--theme-text-primary)] hover:border-brand-accent/50 hover:bg-[var(--theme-hover-bg-strong)] transition-all duration-300"
>
{category}
</button>
))}
</div>
<!-- Search input -->
<div class="relative lg:w-80 group">
<div class="absolute left-0 top-1/2 -translate-y-1/2 pointer-events-none text-brand-accent">
<span class="font-mono text-xs">></span>
</div>
<input
type="text"
id="blog-search"
placeholder="SEARCH_DATABASE..."
class="w-full pl-6 pr-4 py-2 text-sm font-mono bg-transparent border-b border-[var(--theme-text-subtle)] text-[var(--theme-text-primary)] placeholder:text-[var(--theme-text-subtle)] focus:border-brand-accent focus:outline-none transition-colors duration-300 uppercase"
/>
<button
type="button"
id="clear-search"
class="absolute right-0 top-1/2 -translate-y-1/2 text-[var(--theme-text-muted)] hover:text-brand-accent transition-colors hidden"
>
<span class="font-mono text-xs">[CLR]</span>
</button>
</div>
</div>
<!-- Results count -->
<div class="flex items-center gap-4 pb-6 border-b border-[var(--theme-border-primary)] mb-8">
<span class="text-[10px] font-mono text-[var(--theme-text-muted)] uppercase tracking-widest">
<span id="results-count">0</span> ARTICLES
</span>
<span class="h-px flex-grow bg-[var(--theme-border-secondary)]"></span>
</div>
</div>
<script>
function initBlogFilters() {
const filterContainer = document.querySelector('[data-blog-filters]');
if (!filterContainer) return;
const filterChips = filterContainer.querySelectorAll('.filter-chip');
const searchInput = document.getElementById('blog-search') as HTMLInputElement;
const clearSearchBtn = document.getElementById('clear-search');
const resultsCount = document.getElementById('results-count');
const postsGrid = document.querySelector('[data-posts-grid]');
const noResults = document.getElementById('no-results');
if (!postsGrid) return;
const allPosts = postsGrid.querySelectorAll('[data-post]');
let activeCategory = 'all';
let searchQuery = '';
function filterPosts() {
let visibleCount = 0;
allPosts.forEach((post) => {
const postEl = post as HTMLElement;
const postCategory = postEl.dataset.category || '';
const postTitle = postEl.dataset.title?.toLowerCase() || '';
const postDescription = postEl.dataset.description?.toLowerCase() || '';
const matchesCategory = activeCategory === 'all' || postCategory === activeCategory;
const matchesSearch = !searchQuery ||
postTitle.includes(searchQuery.toLowerCase()) ||
postDescription.includes(searchQuery.toLowerCase());
if (matchesCategory && matchesSearch) {
postEl.style.display = '';
visibleCount++;
} else {
postEl.style.display = 'none';
}
});
if (resultsCount) {
resultsCount.textContent = String(visibleCount);
}
// Show/hide no results message
if (noResults) {
noResults.classList.toggle('hidden', visibleCount > 0);
}
}
// Category filter click handlers
filterChips.forEach((chip) => {
chip.addEventListener('click', () => {
const chipEl = chip as HTMLElement;
activeCategory = chipEl.dataset.category || 'all';
// Update active state
// Reset all to inactive state
filterChips.forEach((c) => {
c.classList.remove('active', 'border-brand-accent', 'text-[var(--theme-text-primary)]', 'bg-[var(--theme-hover-bg-strong)]');
c.classList.add('border-transparent', 'text-[var(--theme-text-muted)]');
});
// Set clicked to active state
chipEl.classList.add('active', 'border-brand-accent', 'text-[var(--theme-text-primary)]', 'bg-[var(--theme-hover-bg-strong)]');
chipEl.classList.remove('border-transparent', 'text-[var(--theme-text-muted)]');
filterPosts();
});
});
// Search input handler
if (searchInput) {
searchInput.addEventListener('input', () => {
searchQuery = searchInput.value;
filterPosts();
// Show/hide clear button
if (clearSearchBtn) {
clearSearchBtn.classList.toggle('hidden', !searchQuery);
}
});
}
// Clear search button
if (clearSearchBtn) {
clearSearchBtn.addEventListener('click', () => {
if (searchInput) {
searchInput.value = '';
searchQuery = '';
filterPosts();
clearSearchBtn.classList.add('hidden');
}
});
}
// Initial count
filterPosts();
}
// Run on page load
initBlogFilters();
// Also run on Astro page transitions (View Transitions)
document.addEventListener('astro:page-load', initBlogFilters);
</script>
<style>
.filter-chip.active {
border-color: var(--color-brand-accent);
color: var(--theme-text-primary);
background-color: var(--theme-hover-bg-strong);
}
</style>

View File

@ -1,106 +0,0 @@
---
import { Image } from 'astro:assets';
import type { ImageMetadata } from 'astro';
interface NavPost {
title: string;
href: string;
heroImage?: ImageMetadata;
}
interface Props {
prevPost?: NavPost;
nextPost?: NavPost;
}
const { prevPost, nextPost } = Astro.props;
---
{(prevPost || nextPost) && (
<nav class="post-navigation mt-20 pt-12 border-t border-[var(--theme-border-primary)]" aria-label="Post navigation">
<div class="flex items-center gap-4 mb-8">
<span class="text-[10px] font-mono text-[var(--theme-text-muted)] uppercase tracking-widest font-bold">
/// NEXT_IN_SEQUENCE
</span>
<span class="h-px flex-grow bg-[var(--theme-border-primary)]"></span>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<!-- Previous Post -->
{prevPost ? (
<a
href={prevPost.href}
class="group relative flex items-center gap-6 p-6 border border-[var(--theme-border-primary)] bg-[var(--theme-hover-bg)] hover:border-brand-accent/40 hover:bg-[var(--theme-hover-bg-strong)] transition-all duration-500 overflow-hidden"
>
<div class="absolute top-0 left-0 w-[2px] h-full bg-[var(--theme-text-subtle)] opacity-50 group-hover:bg-brand-accent group-hover:opacity-100 transition-all duration-500"></div>
<div class="absolute inset-0 bg-brand-accent/5 translate-x-[-100%] group-hover:translate-x-0 transition-transform duration-500 pointer-events-none"></div>
<!-- Content -->
<div class="flex-grow min-w-0 z-10">
<span class="text-[10px] font-mono text-[var(--theme-text-muted)] uppercase tracking-widest mb-2 block group-hover:text-brand-accent transition-colors">
&lt; PREV_FILE
</span>
<h4 class="text-sm font-bold text-[var(--theme-text-primary)] uppercase tracking-tight truncate group-hover:text-brand-accent transition-colors">
{prevPost.title}
</h4>
</div>
<!-- Thumbnail -->
{prevPost.heroImage && (
<div class="hidden sm:block flex-shrink-0 w-12 h-12 overflow-hidden border border-[var(--theme-border-primary)] z-10 grayscale group-hover:grayscale-0 transition-all duration-500">
<Image
src={prevPost.heroImage}
alt=""
width={64}
height={64}
class="w-full h-full object-cover group-hover:scale-110 transition-transform duration-500"
/>
</div>
)}
</a>
) : (
<div class="border border-[var(--theme-border-secondary)] bg-[var(--theme-hover-bg)] p-6 flex items-center justify-center">
<span class="text-[10px] font-mono text-[var(--theme-text-subtle)] uppercase tracking-widest">/// START_OF_ARCHIVE</span>
</div>
)}
<!-- Next Post -->
{nextPost ? (
<a
href={nextPost.href}
class="group relative flex items-center gap-6 p-6 border border-[var(--theme-border-primary)] bg-[var(--theme-hover-bg)] hover:border-brand-accent/40 hover:bg-[var(--theme-hover-bg-strong)] transition-all duration-500 overflow-hidden"
>
<div class="absolute top-0 right-0 w-[2px] h-full bg-[var(--theme-text-subtle)] opacity-50 group-hover:bg-brand-accent group-hover:opacity-100 transition-all duration-500"></div>
<div class="absolute inset-0 bg-brand-accent/5 translate-x-[100%] group-hover:translate-x-0 transition-transform duration-500 pointer-events-none"></div>
<!-- Thumbnail -->
{nextPost.heroImage && (
<div class="hidden sm:block flex-shrink-0 w-12 h-12 overflow-hidden border border-[var(--theme-border-primary)] z-10 grayscale group-hover:grayscale-0 transition-all duration-500">
<Image
src={nextPost.heroImage}
alt=""
width={64}
height={64}
class="w-full h-full object-cover group-hover:scale-110 transition-transform duration-500"
/>
</div>
)}
<!-- Content -->
<div class="flex-grow min-w-0 text-right z-10">
<span class="text-[10px] font-mono text-[var(--theme-text-muted)] uppercase tracking-widest mb-2 block group-hover:text-brand-accent transition-colors">
NEXT_FILE &gt;
</span>
<h4 class="text-sm font-bold text-[var(--theme-text-primary)] uppercase tracking-tight truncate group-hover:text-brand-accent transition-colors">
{nextPost.title}
</h4>
</div>
</a>
) : (
<div class="border border-[var(--theme-border-secondary)] bg-[var(--theme-hover-bg)] p-6 flex items-center justify-center">
<span class="text-[10px] font-mono text-[var(--theme-text-subtle)] uppercase tracking-widest">/// END_OF_ARCHIVE</span>
</div>
)}
</div>
</nav>
)}

View File

@ -1,71 +0,0 @@
---
// Reading progress bar that tracks scroll position
---
<div id="reading-progress-container" class="fixed top-0 left-0 w-full h-[3px] z-[100] bg-[var(--theme-bg-primary)]/50">
<div id="reading-progress-bar" class="h-full bg-brand-accent w-0 transition-[width] duration-100 ease-out shadow-[0_0_10px_rgba(221,65,50,0.5)]"></div>
</div>
<div id="reading-status" class="fixed top-4 right-4 z-[90] hidden lg:flex items-center gap-3 px-3 py-1 bg-[var(--theme-overlay)] backdrop-blur-md border border-[var(--theme-border-primary)] opacity-0 transition-opacity duration-300 pointer-events-none">
<div class="w-1.5 h-1.5 bg-brand-accent rounded-full animate-pulse"></div>
<span class="text-[9px] font-mono text-[var(--theme-text-secondary)] uppercase tracking-widest">READING_BUFFER: <span id="progress-text" class="text-[var(--theme-text-primary)]">0%</span></span>
</div>
<script>
function initReadingProgress() {
const progressBar = document.getElementById('reading-progress-bar');
const statusContainer = document.getElementById('reading-status');
const statusText = document.getElementById('progress-text');
if (!progressBar) return;
function updateProgress() {
const article = document.querySelector('article');
if (!article) return;
const articleRect = article.getBoundingClientRect();
const articleTop = window.scrollY + articleRect.top;
const articleHeight = article.offsetHeight;
const windowHeight = window.innerHeight;
const scrollY = window.scrollY;
// Calculate progress based on article position
const start = articleTop;
const end = articleTop + articleHeight - windowHeight;
const current = scrollY;
let progress = 0;
if (current >= start && current <= end) {
progress = ((current - start) / (end - start)) * 100;
} else if (current > end) {
progress = 100;
}
const percentage = Math.round(Math.min(100, Math.max(0, progress)));
progressBar.style.width = `${percentage}%`;
if (statusText) {
statusText.textContent = `${percentage}%`;
}
// Show status only when reading (between 1% and 99%)
if (statusContainer) {
if (percentage > 2 && percentage < 98) {
statusContainer.classList.remove('opacity-0');
} else {
statusContainer.classList.add('opacity-0');
}
}
}
window.addEventListener('scroll', updateProgress, { passive: true });
window.addEventListener('resize', updateProgress, { passive: true });
updateProgress();
}
// Initialize on page load
initReadingProgress();
// Re-initialize on Astro page transitions
document.addEventListener('astro:page-load', initReadingProgress);
</script>

View File

@ -1,47 +0,0 @@
---
import BlogCard from './BlogCard.astro';
import type { ImageMetadata } from 'astro';
interface RelatedPost {
title: string;
description: string;
pubDate: Date;
heroImage?: ImageMetadata;
category?: string;
tags?: string[];
href: string;
}
interface Props {
posts: RelatedPost[];
class?: string;
}
const { posts, class: className = '' } = Astro.props;
---
{posts.length > 0 && (
<section class:list={['related-posts mt-20 pt-12 border-t border-[var(--theme-border-primary)]', className]}>
<div class="flex items-center gap-4 mb-8">
<span class="text-[10px] font-mono text-[var(--theme-text-muted)] uppercase tracking-widest font-bold">
/// RELATED_ARCHIVES
</span>
<span class="h-px flex-grow bg-[var(--theme-border-primary)]"></span>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{posts.slice(0, 3).map((post) => (
<BlogCard
title={post.title}
description={post.description}
pubDate={post.pubDate}
heroImage={post.heroImage}
category={post.category}
tags={post.tags}
href={post.href}
variant="compact"
/>
))}
</div>
</section>
)}

View File

@ -1,121 +0,0 @@
---
interface Props {
headings: Array<{
depth: number;
slug: string;
text: string;
}>;
class?: string;
}
const { headings, class: className = '' } = Astro.props;
// Filter to only H2 and H3 headings
const tocHeadings = headings.filter((h) => h.depth === 2 || h.depth === 3);
---
{tocHeadings.length > 0 && (
<nav class:list={['toc', className]} data-toc aria-label="Table of contents">
<div class="flex items-center gap-3 mb-6">
<span class="text-[10px] font-mono text-[var(--theme-text-muted)] uppercase tracking-widest font-bold">
/// CONTENTS
</span>
<span class="h-px flex-grow bg-[var(--theme-border-primary)]"></span>
</div>
<ul class="space-y-3">
{tocHeadings.map((heading) => (
<li>
<a
href={`#${heading.slug}`}
data-toc-link={heading.slug}
class:list={[
'toc-link block text-sm transition-all duration-300 hover:text-[var(--theme-text-primary)]',
heading.depth === 2
? 'text-[var(--theme-text-secondary)] font-medium'
: 'text-[var(--theme-text-muted)] pl-4 text-xs',
]}
>
<span class="flex items-center gap-2">
{heading.depth === 2 && (
<span class="w-1.5 h-1.5 bg-[var(--theme-text-subtle)] toc-indicator transition-colors duration-300"></span>
)}
{heading.text}
</span>
</a>
</li>
))}
</ul>
</nav>
)}
<script>
function initTableOfContents() {
const tocLinks = document.querySelectorAll('[data-toc-link]');
if (tocLinks.length === 0) return;
const headings = Array.from(tocLinks).map((link) => {
const slug = (link as HTMLElement).dataset.tocLink;
return document.getElementById(slug || '');
}).filter(Boolean) as HTMLElement[];
let currentActive: Element | null = null;
function updateActiveLink() {
const scrollY = window.scrollY;
const offset = 150; // Offset for when to activate
let activeHeading: HTMLElement | null = null;
for (const heading of headings) {
const rect = heading.getBoundingClientRect();
const top = rect.top + scrollY;
if (scrollY >= top - offset) {
activeHeading = heading;
}
}
if (activeHeading && currentActive !== activeHeading) {
// Remove active state from all links
tocLinks.forEach((link) => {
link.classList.remove('text-brand-accent', 'text-[var(--theme-text-primary)]');
link.classList.add('text-[var(--theme-text-secondary)]');
const indicator = link.querySelector('.toc-indicator');
if (indicator) {
indicator.classList.remove('bg-brand-accent');
indicator.classList.add('bg-[var(--theme-text-subtle)]');
}
});
// Add active state to current link
const activeLink = document.querySelector(`[data-toc-link="${activeHeading.id}"]`);
if (activeLink) {
activeLink.classList.remove('text-[var(--theme-text-secondary)]');
activeLink.classList.add('text-brand-accent');
const indicator = activeLink.querySelector('.toc-indicator');
if (indicator) {
indicator.classList.remove('bg-[var(--theme-text-subtle)]');
indicator.classList.add('bg-brand-accent');
}
}
currentActive = activeHeading;
}
}
window.addEventListener('scroll', updateActiveLink, { passive: true });
updateActiveLink();
}
// Initialize on page load
initTableOfContents();
// Re-initialize on Astro page transitions
document.addEventListener('astro:page-load', initTableOfContents);
</script>
<style>
.toc-link:hover .toc-indicator {
background-color: var(--color-brand-accent);
}
</style>

View File

@ -1,150 +0,0 @@
---
interface Props {
sectionTitle: string;
sectionSubtitle: string;
sectionLabel: string;
description: string;
entries: Array<{
systemId: string;
status: string;
dates: string;
company: string;
role: string;
tags?: string[];
description: string;
achievements?: Array<{
label: string;
text: string;
}>;
link?: {
url: string;
text: string;
};
}>;
}
const { sectionTitle, sectionSubtitle, sectionLabel, description, entries } = Astro.props;
---
<section id="experience" class="w-full py-32 border-t border-[var(--theme-border-primary)] bg-[var(--theme-bg-primary)] overflow-hidden">
<div class="container mx-auto px-6 lg:px-12">
<!-- Section Header -->
<div class="grid grid-cols-1 lg:grid-cols-12 gap-12 mb-24 lg:mb-32">
<div class="lg:col-span-8 group cursor-default">
<div class="flex items-center gap-3 mb-6 intro-element animate-on-scroll fade-in">
<div class="w-2 h-2 bg-brand-accent animate-pulse"></div>
<span class="font-mono text-[10px] uppercase tracking-[0.3em] text-brand-accent">SYS.RECORDS /// WORK_HISTORY</span>
</div>
<h2 class="text-5xl md:text-7xl lg:text-8xl font-bold uppercase tracking-tighter leading-[0.85] text-[var(--theme-text-primary)]">
<span class="block">{sectionTitle}</span>
<span class="block text-brand-accent">
{sectionSubtitle}
</span>
</h2>
</div>
<div class="lg:col-span-4 flex flex-col justify-end">
<div class="font-mono text-[10px] text-[var(--theme-text-subtle)] uppercase tracking-widest mb-4 animate-on-scroll fade-in stagger-2 flex items-center gap-2">
<span class="w-8 h-px bg-brand-accent/30"></span>
DATA_ARCHIVE_V7
</div>
<p class="text-[var(--theme-text-secondary)] text-lg leading-relaxed animate-on-scroll slide-up stagger-2 border-l border-brand-accent/30 pl-6">
{description}
</p>
</div>
</div>
<!-- Experience List -->
<div class="w-full border-t border-[var(--theme-border-primary)]">
{entries.map((entry, index) => (
<div class="group relative border-b border-[var(--theme-border-primary)] hover:bg-white/[0.01] transition-all duration-500 overflow-hidden">
<!-- Industrial Side Accent -->
<div class="absolute left-0 top-0 bottom-0 w-1 bg-brand-accent transform -translate-x-full group-hover:translate-x-0 transition-transform duration-500 ease-out"></div>
<a
href={entry.link?.url || '#'}
class="block py-12 lg:py-16 px-4 lg:px-8"
>
<div class="grid grid-cols-1 lg:grid-cols-12 gap-12 items-start">
<!-- Left: Header & Role -->
<div class="lg:col-span-4">
<div class="flex items-center gap-4 mb-4">
<span class="font-mono text-xs text-brand-accent opacity-50 group-hover:opacity-100 transition-opacity">[{entry.systemId || `EXP.0${index + 1}`}]</span>
<div class="flex items-center gap-2">
<div class={`w-1.5 h-1.5 rounded-full ${index === 0 ? 'bg-brand-accent animate-pulse' : 'bg-[var(--theme-text-subtle)]'}`}></div>
<span class="font-mono text-[9px] uppercase tracking-widest text-[var(--theme-text-muted)]">{index === 0 ? 'PRODUCTION_LIVE' : 'PRODUCTION_WRAP'}</span>
</div>
</div>
<h3 class="text-4xl md:text-5xl lg:text-6xl font-bold text-[var(--theme-text-primary)] uppercase tracking-tighter group-hover:text-brand-accent transition-colors duration-300 mb-4">
{entry.company}
</h3>
<p class="font-mono text-xs uppercase tracking-[0.2em] text-[var(--theme-text-secondary)]">
{entry.role}
</p>
</div>
<!-- Middle: Metadata -->
<div class="lg:col-span-3 space-y-8 pt-2">
<div>
<div class="text-[10px] font-mono uppercase tracking-[0.2em] text-[var(--theme-text-muted)] mb-3 flex items-center gap-2">
<span class="text-brand-accent">/</span> DUR.TIMELINE
</div>
<div class="font-mono text-sm text-[var(--theme-text-primary)]">{entry.dates}</div>
</div>
{entry.tags && entry.tags.length > 0 && (
<div>
<div class="text-[10px] font-mono uppercase tracking-[0.2em] text-[var(--theme-text-muted)] mb-3 flex items-center gap-2">
<span class="text-brand-accent">/</span> PIP.TOOLSET
</div>
<div class="flex flex-wrap gap-2">
{entry.tags.map(tag => (
<span class="text-[9px] font-mono uppercase tracking-wider px-2 py-1 border border-[var(--theme-border-secondary)] text-[var(--theme-text-muted)] group-hover:border-brand-accent/30 group-hover:text-[var(--theme-text-secondary)] transition-all">
{tag}
</span>
))}
</div>
</div>
)}
</div>
<!-- Right: Content -->
<div class="lg:col-span-5 pt-2">
<div class="text-[10px] font-mono uppercase tracking-[0.2em] text-[var(--theme-text-muted)] mb-4 flex items-center gap-2">
<span class="text-brand-accent">/</span> LOG.PRODUCTION_MANIFEST
</div>
<p class="text-[var(--theme-text-secondary)] leading-relaxed mb-8 group-hover:text-[var(--theme-text-primary)] transition-colors duration-300">
{entry.description}
</p>
{entry.achievements && entry.achievements.length > 0 && (
<div class="space-y-6">
{entry.achievements.map((achievement) => (
<div class="relative pl-6 py-1 group/item">
<!-- Minimalist bullet -->
<div class="absolute left-0 top-3 w-3 h-px bg-brand-accent/40 group-hover/item:w-5 transition-all"></div>
<div class="text-[9px] font-mono uppercase tracking-widest text-brand-accent/60 mb-1">
{achievement.label}
</div>
<div class="text-sm text-[var(--theme-text-secondary)] group-hover:text-[var(--theme-text-primary)] transition-colors">
{achievement.text}
</div>
</div>
))}
</div>
)}
{entry.link && (
<div class="mt-8 flex items-center gap-2 font-mono text-[10px] text-brand-accent uppercase tracking-widest opacity-0 group-hover:opacity-100 translate-x-[-10px] group-hover:translate-x-0 transition-all duration-500">
<span>View Project</span>
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="square" stroke-linejoin="miter">
<path d="M5 12h14M12 5l7 7-7 7"/>
</svg>
</div>
)}
</div>
</div>
</a>
</div>
))}
</div>
</div>
</section>

View File

@ -1,185 +0,0 @@
---
interface Props {
role: string;
client: string;
year: string;
region: string;
projectTitle: string;
projectSubtitle: string;
projectDescription: string;
stats: Array<{
label: string;
value: string;
}>;
videoUrl: string;
linkUrl: string;
}
const { role, client, year, region, projectTitle, projectSubtitle, projectDescription, stats, videoUrl, linkUrl } = Astro.props;
---
<section id="work" class="relative overflow-hidden group min-h-[100dvh] flex flex-col cursor-pointer">
<!-- Main Link Overlay -->
<a href={linkUrl} class="absolute inset-0 z-30" aria-label={`View ${projectTitle} ${projectSubtitle} Case Study`}></a>
<!-- Video Background -->
<div class="absolute inset-0 z-0">
<!-- Industrial Scanlines -->
<div class="project-scanlines absolute inset-0 z-1 pointer-events-none opacity-[0.05] bg-[linear-gradient(rgba(18,16,16,0)_50%,rgba(0,0,0,0.25)_50%),linear-gradient(90deg,rgba(255,0,0,0.06),rgba(0,255,0,0.02),rgba(0,0,112,0.06))] bg-[length:100%_2px,3px_100%]"></div>
<video
autoplay
loop
muted
playsinline
class="w-full h-full object-cover opacity-60 transition-opacity duration-700 group-hover:opacity-100"
>
<source src={videoUrl} type="video/mp4" />
</video>
<!-- Cinematic Letterboxing / Gradient Vignette -->
<div class="project-video-overlay absolute inset-0 bg-gradient-to-b from-[var(--theme-bg-primary)] via-transparent to-[var(--theme-bg-primary)] pointer-events-none transition-colors duration-500 opacity-80"></div>
<div class="project-video-overlay absolute inset-0 bg-gradient-to-r from-[var(--theme-bg-primary)] via-transparent to-[var(--theme-bg-primary)] pointer-events-none transition-colors duration-500 opacity-40"></div>
<!-- Subtle Grid Overlay -->
<div class="project-grid absolute inset-0 bg-[linear-gradient(var(--theme-grid-line)_1px,transparent_1px),linear-gradient(90deg,var(--theme-grid-line)_1px,transparent_1px)] bg-[size:100px_100px] pointer-events-none opacity-20"></div>
<!-- Technical Telemetry Overlay (Decorative) -->
<div class="absolute top-1/2 left-6 -translate-y-1/2 hidden lg:flex flex-col gap-12 font-mono text-[9px] text-brand-accent/30 uppercase tracking-[0.4em] [writing-mode:vertical-lr]">
<div class="flex items-center gap-4">
<div class="w-px h-12 bg-brand-accent/20"></div>
<span>SIGNAL_STRENGTH: OPTIMAL</span>
</div>
<div class="flex items-center gap-4">
<div class="w-px h-12 bg-brand-accent/20"></div>
<span>BUFFERING: COMPLETE</span>
</div>
</div>
</div>
<!-- Main Content Container - Spaced to frame the video -->
<div class="container mx-auto px-6 lg:px-12 relative z-10 flex-1 flex flex-col justify-between py-12 lg:py-20 pointer-events-none">
<!-- TOP HUD: Telemetry Data -->
<div class="grid grid-cols-2 md:grid-cols-4 gap-x-8 gap-y-12 border-t border-brand-accent/20 pt-8 animate-on-scroll slide-up">
<div class="relative">
<div class="flex items-center gap-2 mb-2">
<div class="w-1.5 h-1.5 bg-brand-accent animate-pulse"></div>
<span class="text-[9px] font-mono text-brand-accent uppercase tracking-[0.2em] block">SYS.ROLE</span>
</div>
<span class="text-xl md:text-2xl font-bold text-[var(--theme-text-primary)] uppercase tracking-tight leading-tight block">{role}</span>
</div>
<div>
<span class="text-[9px] font-mono text-brand-accent uppercase tracking-[0.2em] block mb-2 opacity-60">SYS.CLIENT</span>
<span class="text-xl md:text-2xl font-bold text-[var(--theme-text-primary)] uppercase tracking-tight leading-tight block">{client}</span>
</div>
<div>
<span class="text-[9px] font-mono text-brand-accent uppercase tracking-[0.2em] block mb-2 opacity-60">SYS.YEAR</span>
<span class="text-xl md:text-2xl font-bold text-[var(--theme-text-primary)] uppercase tracking-tight leading-tight block">{year}</span>
</div>
<div class="text-left md:text-left">
<span class="text-[9px] font-mono text-brand-accent uppercase tracking-[0.2em] block mb-2 opacity-60">SYS.REGION</span>
<span class="text-xl md:text-2xl font-bold text-[var(--theme-text-primary)] uppercase tracking-tight leading-tight block">{region}</span>
</div>
</div>
<!-- CENTER AREA: Vertical Label -->
<div class="flex-1 relative flex items-center min-h-[100px] lg:min-h-0">
<div class="hidden lg:block absolute -left-12 origin-left -rotate-90">
<h3 class="text-[10px] font-mono font-bold text-brand-accent uppercase tracking-[0.6em] opacity-40">
CASE_STUDY_REF. {projectTitle.replace(/\s+/g, '_').toUpperCase()}
</h3>
</div>
</div>
<!-- BOTTOM HUD: Project Details & Stats -->
<div class="border-b border-brand-accent/20 pb-8 animate-on-scroll slide-up stagger-1">
<div class="grid grid-cols-1 lg:grid-cols-12 gap-12 lg:items-end">
<!-- Title & Description -->
<div class="lg:col-span-8">
<h2 class="text-4xl md:text-7xl lg:text-8xl font-bold uppercase text-[var(--theme-text-primary)] mb-6 tracking-tighter leading-[0.85]">
{projectTitle} <span class="block text-brand-accent">{projectSubtitle}</span>
</h2>
<p class="text-[var(--theme-text-secondary)] font-light max-w-xl text-sm md:text-lg leading-relaxed border-l border-brand-accent/30 pl-6">
{projectDescription}
</p>
</div>
<!-- Technical Stats (Mini-Table) -->
<div class="lg:col-span-4 mt-8 lg:mt-0">
<div class="grid grid-cols-1 gap-6 font-mono">
{stats.map((stat, idx) => (
<div class="relative group/stat">
<div class="flex justify-between items-baseline mb-2 gap-4">
<span class="text-[var(--theme-text-muted)] text-[9px] uppercase tracking-[0.2em] truncate">0{idx + 1}. {stat.label}</span>
<span class="text-brand-accent font-bold text-base md:text-lg whitespace-nowrap">{stat.value}</span>
</div>
<div class="w-full h-px bg-[var(--theme-border-primary)] relative overflow-hidden">
<div class="absolute inset-0 bg-brand-accent w-1/3 transform -translate-x-full group-hover:translate-x-[300%] transition-transform duration-[2s] ease-in-out"></div>
</div>
</div>
))}
</div>
<div class="mt-10 flex justify-end">
<div class="flex items-center gap-3 group/btn">
<span class="font-mono text-[10px] uppercase tracking-[0.2em] text-brand-accent">UPLINK.PROJECT_DETAIL</span>
<div class="w-8 h-8 flex items-center justify-center border border-brand-accent/30 text-brand-accent group-hover/btn:bg-brand-accent group-hover/btn:text-brand-dark transition-all duration-300">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="square" stroke-linejoin="miter">
<path d="M5 12h14M12 5l7 7-7 7"/>
</svg>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</section>
<style>
/* Clean light mode: zero overlays, full contrast video, white high-contrast text */
:global([data-theme="light"]) .project-video-overlay,
:global([data-theme="light"]) .project-scanlines,
:global([data-theme="light"]) .project-grid {
display: none !important;
}
:global([data-theme="light"]) #work video {
opacity: 1 !important;
}
:global([data-theme="light"]) #work .text-\[var\(--theme-text-primary\)\],
:global([data-theme="light"]) #work .text-\[var\(--theme-text-secondary\)\],
:global([data-theme="light"]) #work .text-\[var\(--theme-text-muted\)\],
:global([data-theme="light"]) #work p,
:global([data-theme="light"]) #work span:not(.text-brand-accent) {
color: #ffffff !important;
opacity: 1 !important;
}
:global([data-theme="light"]) #work .text-\[var\(--theme-text-secondary\)\] {
font-weight: 400;
}
:global([data-theme="light"]) #work .text-\[var\(--theme-text-muted\)\] {
font-weight: 600;
}
:global([data-theme="light"]) #work .opacity-60,
:global([data-theme="light"]) #work .opacity-40,
:global([data-theme="light"]) #work .text-brand-accent\/30,
:global([data-theme="light"]) #work .text-brand-accent\/40,
:global([data-theme="light"]) #work .text-brand-accent\/60 {
opacity: 1 !important;
color: var(--color-brand-accent) !important;
}
:global([data-theme="light"]) #work .border-brand-accent\/20,
:global([data-theme="light"]) #work .border-t,
:global([data-theme="light"]) #work .border-b {
border-color: rgba(255, 255, 255, 0.3) !important;
}
</style>

View File

@ -1,301 +0,0 @@
---
import { Picture } from 'astro:assets';
import heroPortrait from '../../assets/nicholai-closeup-portrait.avif';
interface Props {
headlineLine1: string;
headlineLine2: string;
portfolioYear: string;
location: string;
locationLabel: string;
bio: string;
}
const { headlineLine1, headlineLine2, portfolioYear, location, locationLabel, bio } = Astro.props;
---
<section id="hero" class="relative w-full h-[100dvh] overflow-hidden bg-[var(--theme-bg-primary)]">
<!-- Industrial Scanlines -->
<div class="absolute inset-0 z-1 pointer-events-none opacity-[0.03] bg-[linear-gradient(rgba(18,16,16,0)_50%,rgba(0,0,0,0.25)_50%),linear-gradient(90deg,rgba(255,0,0,0.06),rgba(0,255,0,0.02),rgba(0,0,112,0.06))] bg-[length:100%_2px,3px_100%]"></div>
<!-- Background Image (Portrait) - Optimized with AVIF/WebP -->
<div class="absolute top-0 right-0 w-full md:w-1/2 h-full z-0">
<div class="relative w-full h-full">
<Picture
src={heroPortrait}
formats={['avif', 'webp']}
widths={[640, 1024, 1600]}
sizes="(max-width: 768px) 100vw, 50vw"
alt="Nicholai Vogel portrait"
class="w-full h-full object-cover object-center opacity-0 mix-blend-luminosity transition-opacity duration-[2500ms] ease-out delay-700 intro-element"
id="hero-portrait"
loading="eager"
decoding="sync"
/>
<div class="absolute inset-0 bg-gradient-to-l from-transparent via-[var(--theme-hero-gradient-side)] to-[var(--theme-bg-primary)] transition-colors duration-500"></div>
<div class="absolute inset-0 bg-gradient-to-t from-[var(--theme-bg-primary)] via-transparent to-transparent transition-colors duration-500"></div>
<!-- Technical Overlay Elements -->
<div class="absolute bottom-12 right-12 hidden lg:flex flex-col items-end gap-1 font-mono text-[9px] text-brand-accent/40 uppercase tracking-[0.3em] intro-element opacity-0 delay-1000">
<span>COORD: 38.8339° N, 104.8214° W</span>
<span>ELV: 1,839M</span>
<div class="flex gap-2 mt-2">
<div class="w-8 h-px bg-brand-accent/20"></div>
<div class="w-2 h-px bg-brand-accent/40"></div>
</div>
</div>
</div>
</div>
<!-- The 100 Squares Grid Overlay -->
<div id="grid-container" class="absolute inset-0 z-10 w-full h-full grid grid-cols-10 grid-rows-10 pointer-events-none">
{Array.from({ length: 100 }).map((_, i) => (
<div class="grid-cell w-full h-full border border-[var(--theme-border-secondary)] opacity-0 transition-all duration-500 ease-out" data-index={i}></div>
))}
</div>
<!-- The Content -->
<!-- Adjusted pt to clear fixed nav since BaseLayout padding is removed -->
<div class="absolute inset-0 z-20 flex flex-col justify-between p-6 md:p-12 lg:p-16 pt-32 lg:pt-40 pointer-events-auto">
<!-- Top Metadata -->
<div class="flex justify-between items-start w-full intro-element opacity-0 translate-y-4 transition-all duration-1000 ease-out delay-300">
<div class="flex items-center gap-3">
<div class="w-1.5 h-1.5 bg-brand-accent animate-pulse"></div>
<div class="font-mono text-[10px] uppercase tracking-[0.2em] text-[var(--theme-text-muted)]">
<span class="text-brand-accent mr-1">SYS.PRTF</span> / {portfolioYear}
</div>
</div>
<div class="font-mono text-[10px] text-[var(--theme-text-muted)] text-right tracking-[0.15em] uppercase">
<div class="mb-1 flex items-center justify-end gap-2">
<span class="text-[var(--theme-text-subtle)]">{locationLabel}</span>
<span class="text-brand-accent font-bold">///</span>
</div>
<div class="text-[var(--theme-text-secondary)]">{location}</div>
<div id="clock" class="text-brand-accent mt-0.5">00:00:00 MST</div>
</div>
</div>
<!-- Main Heading & Description -->
<div class="max-w-5xl">
<h1 class="text-6xl md:text-8xl lg:text-9xl tracking-tighter leading-[0.85] font-bold text-[var(--theme-text-primary)] mb-8 perspective-text">
<span class="block intro-element opacity-0 translate-y-10 transition-all duration-1000 ease-out delay-100">{headlineLine1}</span>
<span class="block text-brand-accent opacity-0 translate-y-10 transition-all duration-1000 ease-out delay-200 intro-element">{headlineLine2}</span>
</h1>
<p class="font-mono text-sm md:text-base max-w-lg text-[var(--theme-text-secondary)] font-light leading-relaxed intro-element opacity-0 translate-y-6 transition-all duration-1000 ease-out delay-500">
{bio}
</p>
</div>
<!-- Bottom Navigation -->
<div class="flex justify-between items-end w-full intro-element opacity-0 transition-all duration-1000 ease-out delay-700">
<a href="#experience" class="group flex items-center gap-6 py-2">
<div class="relative w-12 h-12 flex items-center justify-center border border-[var(--theme-border-primary)] text-brand-accent hover:border-brand-accent hover:bg-brand-accent/5 transition-all duration-300">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="square" stroke-linejoin="miter" class="group-hover:translate-y-1 transition-transform duration-300">
<path d="M7 13l5 5 5-5M12 6v12"/>
</svg>
<!-- Technical Corner Accent -->
<div class="absolute -top-px -left-px w-2 h-2 border-t border-l border-brand-accent opacity-0 group-hover:opacity-100 transition-opacity"></div>
<div class="absolute -bottom-px -right-px w-2 h-2 border-b border-r border-brand-accent opacity-0 group-hover:opacity-100 transition-opacity"></div>
</div>
<div class="flex flex-col font-mono text-[10px] uppercase tracking-[0.2em]">
<span class="text-[var(--theme-text-muted)] group-hover:text-brand-accent transition-colors">Scroll</span>
<span class="text-[var(--theme-text-subtle)] group-hover:text-[var(--theme-text-secondary)] transition-colors">To Explore</span>
</div>
</a>
<div class="hidden md:block text-right font-mono text-[10px] text-[var(--theme-text-muted)] tracking-[0.2em] uppercase">
<span class="text-[var(--theme-text-subtle)]">STATUS:</span> <span class="text-brand-accent">READY_FOR_INPUT</span><br>
<span class="text-[var(--theme-text-subtle)]">INDEX:</span> 00.01 / 05.00
</div>
</div>
</div>
</section>
<style>
.grid-cell.active {
background-color: var(--color-brand-accent);
opacity: 0.15;
transition: opacity 0s, background-color 0s; /* Instant on */
}
/* Fade out */
.grid-cell {
/* Snappier fade-out */
transition: opacity 0.6s ease-out, background-color 0.6s ease-out;
}
/* Initial Loaded State Classes */
.intro-visible {
opacity: 1 !important;
transform: translateY(0) !important;
}
/* Portrait Loaded State */
.portrait-visible {
opacity: 0.4 !important; /* Mobile default */
}
@media (min-width: 768px) {
.portrait-visible {
opacity: 0.6 !important; /* Desktop default */
}
}
</style>
<script>
const reduceMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches ?? false;
const finePointer = window.matchMedia?.('(pointer: fine) and (hover: hover)')?.matches ?? false;
// ===== CLOCK (pause on hidden tab, align to second boundaries) =====
let clockTimer = 0;
function updateClockOnce() {
const clock = document.getElementById('clock');
if (!clock) return;
const now = new Date();
const timeString = now.toLocaleTimeString('en-US', { hour12: false, timeZone: 'America/Denver' });
clock.textContent = `${timeString} MST`;
}
function startClock() {
if (clockTimer) window.clearTimeout(clockTimer);
const tick = () => {
if (document.hidden) {
clockTimer = window.setTimeout(tick, 1000);
return;
}
updateClockOnce();
// Align to the next second boundary to reduce drift.
const msToNextSecond = 1000 - (Date.now() % 1000);
clockTimer = window.setTimeout(tick, msToNextSecond);
};
tick();
}
startClock();
// Intro Animation Sequence
window.addEventListener('load', () => {
// Trigger Intro Elements
const introElements = document.querySelectorAll('.intro-element');
introElements.forEach(el => {
el.classList.add('intro-visible');
});
// Trigger Portrait
const portrait = document.getElementById('hero-portrait');
if (portrait) {
portrait.classList.add('portrait-visible');
}
// Trigger Grid Ripple (skip if reduced motion)
if (!reduceMotion) {
const cells = document.querySelectorAll('.grid-cell');
// Diagonal sweep effect
cells.forEach((cell, i) => {
const row = Math.floor(i / 10);
const col = i % 10;
const delay = (row + col) * 45; // slightly faster diagonal delay
window.setTimeout(() => {
cell.classList.add('active');
window.setTimeout(() => {
cell.classList.remove('active');
}, 180);
}, delay);
});
}
});
// Robust Grid Interaction
const section = document.getElementById('hero');
const cells = document.querySelectorAll('.grid-cell');
if (section) {
// Throttle mousemove work to one update per frame.
let latestX = 0;
let latestY = 0;
let pending = false;
let lastIndex = -1;
const timeouts: number[] = new Array(cells.length).fill(0);
const process = () => {
pending = false;
if (!finePointer || reduceMotion) return;
const rect = section.getBoundingClientRect();
const width = rect.width;
const height = rect.height;
if (width <= 0 || height <= 0) return;
const x = latestX - rect.left;
const y = latestY - rect.top;
const col = Math.floor((x / width) * 10);
const row = Math.floor((y / height) * 10);
if (col < 0 || col >= 10 || row < 0 || row >= 10) return;
const index = row * 10 + col;
if (index === lastIndex) return;
lastIndex = index;
const cell = cells[index] as HTMLElement | undefined;
if (!cell) return;
cell.classList.add('active');
const prev = timeouts[index];
if (prev) window.clearTimeout(prev);
// Shorter hold time for a quicker trail.
timeouts[index] = window.setTimeout(() => {
cell.classList.remove('active');
timeouts[index] = 0;
}, 35);
};
section.addEventListener('mousemove', (e) => {
latestX = e.clientX;
latestY = e.clientY;
if (pending) return;
pending = true;
window.requestAnimationFrame(process);
}, { passive: true });
}
// Random pulse for liveliness
let pulseInterval = 0;
function startPulse() {
if (pulseInterval) window.clearInterval(pulseInterval);
if (!finePointer || reduceMotion) return;
pulseInterval = window.setInterval(() => {
if (document.hidden) return;
const randomIndex = Math.floor(Math.random() * cells.length);
const cell = cells[randomIndex] as HTMLElement | undefined;
if (!cell) return;
cell.classList.add('active');
window.setTimeout(() => {
cell.classList.remove('active');
}, 160);
}, 1200);
}
startPulse();
document.addEventListener('visibilitychange', () => {
// Keep timers light in background.
if (!document.hidden) {
updateClockOnce();
}
});
</script>

View File

@ -1,106 +0,0 @@
---
import { Image } from 'astro:assets';
interface Props {
sectionTitle: string;
sectionSubtitle: string;
description: string;
skills: Array<{
id: string;
domain: string;
tools: string;
proficiency: string;
}>;
}
const { sectionTitle, sectionSubtitle, description, skills } = Astro.props;
// Image map for skill data attributes
const imageMap: Record<string, string> = {
"01": "compositing",
"02": "3d",
"03": "ai",
"04": "dev"
};
---
<section id="skills" class="bg-[var(--theme-bg-primary)] py-32 lg:py-48 overflow-hidden relative cursor-default">
<div class="container mx-auto px-6 lg:px-12 relative z-10">
<!-- Header Section -->
<div class="mb-24 lg:mb-32 grid grid-cols-1 lg:grid-cols-12 gap-12">
<div class="lg:col-span-8 group cursor-default">
<div class="flex items-center gap-3 mb-6 intro-element animate-on-scroll fade-in">
<div class="w-2 h-2 bg-brand-accent animate-pulse"></div>
<span class="font-mono text-[10px] uppercase tracking-[0.3em] text-brand-accent">SYS.TOOLSET /// PIPELINE_CAPABILITIES</span>
</div>
<h2 class="text-5xl md:text-7xl lg:text-8xl font-bold uppercase tracking-tighter leading-[0.85] text-[var(--theme-text-primary)]">
<span class="block">{sectionTitle}</span>
<span class="block text-brand-accent">{sectionSubtitle}</span>
</h2>
</div>
<div class="lg:col-span-4 flex flex-col justify-end">
<div class="font-mono text-[10px] text-[var(--theme-text-subtle)] uppercase tracking-widest mb-4 flex items-center gap-2">
<span class="w-8 h-px bg-brand-accent/30"></span>
TECH_STACK_MANIFEST
</div>
<p class="text-[var(--theme-text-secondary)] text-lg leading-relaxed border-l border-brand-accent/30 pl-6">
{description}
</p>
</div>
</div>
<!-- Skills Data Grid -->
<div class="w-full border-t border-[var(--theme-border-primary)]" id="skills-table">
<!-- Table Header -->
<div class="grid grid-cols-12 gap-4 py-8 border-b border-[var(--theme-border-primary)] text-[10px] font-mono uppercase tracking-[0.2em] text-[var(--theme-text-muted)] select-none">
<div class="col-span-2 md:col-span-1">/// ID.TAG</div>
<div class="col-span-10 md:col-span-4">DOMAIN.SPECIALIZATION</div>
<div class="col-span-12 md:col-span-5 hidden md:block">PIP.TOOLSET</div>
<div class="col-span-6 md:col-span-2 hidden md:block text-right">LVL.STATUS</div>
</div>
{skills.map((skill, index) => {
const proficiencyClass = skill.proficiency === "Expert" || skill.proficiency === "Specialist"
? "border-brand-accent/50 text-brand-accent bg-brand-accent/5"
: "border-[var(--theme-border-strong)] text-[var(--theme-text-secondary)]";
return (
<div class={`skill-row group relative grid grid-cols-12 gap-4 py-10 border-b border-[var(--theme-border-primary)] items-center transition-colors duration-300 hover:border-brand-accent/30 overflow-hidden`} data-image={imageMap[skill.id] || "default"}>
<!-- Hover Background Effect -->
<div class="absolute inset-0 bg-brand-accent/5 opacity-0 group-hover:opacity-100 transition-opacity duration-300 pointer-events-none"></div>
<!-- ID Column -->
<div class="col-span-2 md:col-span-1 text-brand-accent font-mono text-sm relative overflow-hidden z-10">
<span class="block group-hover:-translate-y-full transition-transform duration-500">{skill.id}</span>
<span class="absolute top-0 left-0 translate-y-full group-hover:translate-y-0 transition-transform duration-500">{skill.id}</span>
</div>
<!-- Main Content (Domain) -->
<div class="col-span-10 md:col-span-4 relative z-10">
<h3 class="text-3xl md:text-5xl font-bold text-[var(--theme-text-primary)] uppercase tracking-tighter group-hover:text-brand-accent transition-colors duration-300">{skill.domain}</h3>
<!-- Scan line effect for ALL items on hover -->
<div class="absolute bottom-0 left-0 h-[1px] w-full bg-brand-accent transform scale-x-0 group-hover:scale-x-100 transition-transform duration-700 ease-out origin-left opacity-0 group-hover:opacity-100"></div>
</div>
<!-- Tools Stack -->
<div class="col-span-12 md:col-span-5 text-[var(--theme-text-secondary)] font-mono text-xs md:text-sm tracking-wide group-hover:text-[var(--theme-text-primary)] transition-colors duration-300 z-10">
{skill.tools}
</div>
<!-- Proficiency Badge -->
<div class="col-span-6 md:col-span-2 text-right hidden md:block z-10">
<span class={`inline-block px-3 py-1 border text-[10px] font-bold uppercase tracking-widest ${proficiencyClass} group-hover:bg-brand-accent group-hover:text-brand-dark transition-all duration-300`}>{skill.proficiency}</span>
</div>
<!-- Decorative "Scan" Sweep Overlay -->
<div class="absolute inset-0 bg-gradient-to-r from-transparent via-brand-accent/10 to-transparent -translate-x-full group-hover:animate-scan-sweep pointer-events-none"></div>
</div>
);
})}
</div>
</div>
</section>

View File

@ -2,108 +2,18 @@ import { defineCollection, z } from 'astro:content';
import { glob } from 'astro/loaders';
const blog = defineCollection({
// Load Markdown and MDX files in the `src/content/blog/` directory.
loader: glob({ base: './src/content/blog', pattern: '**/*.{md,mdx}' }),
// Type-check frontmatter using a schema
schema: ({ image }) =>
z.object({
title: z.string(),
description: z.string(),
// Transform string to Date object
pubDate: z.coerce.date(),
updatedDate: z.coerce.date().optional(),
heroImage: image().optional(),
// Blog hub fields
featured: z.boolean().optional().default(false),
category: z.string().optional(),
tags: z.array(z.string()).optional(),
}),
});
const sections = defineCollection({
loader: glob({ base: './src/content/sections', pattern: '**/*.{md,mdx}' }),
schema: z.object({
// Hero section
headlineLine1: z.string().optional(),
headlineLine2: z.string().optional(),
portfolioYear: z.string().optional(),
location: z.string().optional(),
locationLabel: z.string().optional(),
bio: z.string().optional(),
// Experience section
sectionTitle: z.string().optional(),
sectionSubtitle: z.string().optional(),
sectionLabel: z.string().optional(),
description: z.string().optional(),
// Experience entries
entries: z.array(z.object({
systemId: z.string(),
status: z.string(),
dates: z.string(),
company: z.string(),
role: z.string(),
tags: z.array(z.string()).optional(),
description: z.string(),
achievements: z.array(z.object({
label: z.string(),
text: z.string(),
})).optional(),
link: z.object({
url: z.string(),
text: z.string(),
}).optional(),
})).optional(),
// Skills entries
skills: z.array(z.object({
id: z.string(),
domain: z.string(),
tools: z.string(),
proficiency: z.string(),
})).optional(),
// Featured project
role: z.string().optional(),
client: z.string().optional(),
year: z.string().optional(),
region: z.string().optional(),
projectTitle: z.string().optional(),
projectSubtitle: z.string().optional(),
projectDescription: z.string().optional(),
stats: z.array(z.object({
label: z.string(),
value: z.string(),
})).optional(),
videoUrl: z.string().optional(),
linkUrl: z.string().optional(),
}),
});
const pages = defineCollection({
loader: glob({ base: './src/content/pages', pattern: '**/*.{md,mdx}' }),
schema: z.object({
pageTitleLine1: z.string().optional(),
pageTitleLine2: z.string().optional(),
availabilityText: z.string().optional(),
email: z.string().optional(),
location: z.string().optional(),
locationCountry: z.string().optional(),
coordinates: z.string().optional(),
socialLinks: z.array(z.object({
name: z.string(),
url: z.string(),
})).optional(),
formLabels: z.object({
name: z.string().optional(),
email: z.string().optional(),
subject: z.string().optional(),
message: z.string().optional(),
submit: z.string().optional(),
transmissionUplink: z.string().optional(),
}).optional(),
subjectOptions: z.array(z.object({
value: z.string(),
label: z.string(),
})).optional(),
}),
});
export const collections = { blog, sections, pages };
export const collections = { blog };

View File

@ -1,104 +0,0 @@
---
title: 'Sample Project Case Study'
description: 'A template for documenting your projects with technical details, creative process, and results. Use this structure for your own case studies.'
pubDate: 'Dec 20 2024'
heroImage: '../../assets/g-star-image.avif'
featured: false
category: 'Case Study'
tags: ['Project', 'Portfolio', 'Template']
---
## Project Overview
This is a template for creating detailed case studies of your work. Case studies are an excellent way to showcase not just the final result, but your process, problem-solving approach, and the value you delivered.
### Quick Facts
- **Client**: Client Name
- **Role**: Your Role
- **Timeline**: Project Duration
- **Technologies**: List of tools/technologies used
- **Team Size**: Number of collaborators
- **Deliverables**: What was produced
## The Challenge
Start by describing the problem or opportunity. What was the client trying to achieve? What constraints or requirements existed?
> "This section should clearly articulate the why behind the project. Context is crucial for demonstrating your understanding of business and creative objectives."
### Key Requirements
- Requirement or constraint 1
- Requirement or constraint 2
- Requirement or constraint 3
## The Approach
Explain your methodology and creative/technical process. How did you tackle the challenge?
### Research & Planning
Describe any research, competitive analysis, or planning phase. What insights informed your approach?
### Design & Development
Walk through your design decisions and technical implementation. Include:
- Design explorations
- Technical architecture
- Tools and workflows
- Iterations and refinements
### Collaboration
If this was a team effort, describe how you collaborated and what your specific contributions were.
## Technical Implementation
```javascript
// Include relevant code snippets to demonstrate technical expertise
function exampleFunction() {
// This shows both your coding ability and willingness to share knowledge
return "Real code examples add credibility to case studies";
}
```
For technical projects, this section can include:
- Architecture diagrams
- Performance optimizations
- Interesting technical challenges solved
- Tools or frameworks utilized
## Results & Impact
Quantify the results whenever possible:
- **Metric 1**: Specific improvement or outcome
- **Metric 2**: Measurable result
- **Metric 3**: Business or creative impact
### Client Feedback
> "Include testimonials or direct quotes from clients/stakeholders if available. Social proof is powerful."
## Visual Documentation
<video controls width="100%">
<source src="/media/placeholder-video.mp4" type="video/mp4" />
Your browser does not support the video tag.
</video>
*Caption: Describe what this video demonstrates*
## Lessons Learned
Reflect on what you learned from this project:
- Technical skills developed
- Process improvements discovered
- Challenges overcome
- What you'd do differently next time
This shows growth mindset and continuous improvement.
## Conclusion
Summarize the project outcome and its significance. What made this project successful? What are you most proud of?
---
**Want to work together on something like this?** [Get in touch](/contact) to discuss your next project.

View File

@ -1,76 +1,54 @@
---
title: 'Welcome to Your New Portfolio'
description: 'This is a starter template for your Astro-based portfolio and blog. Learn about the features and capabilities of this template.'
title: 'Example Blog Post'
description: 'This is an example blog post showing the content schema and MDX capabilities.'
pubDate: 'Dec 27 2024'
heroImage: '../../assets/nicholai-medium-portrait.avif'
featured: true
category: 'Getting Started'
tags: ['Template', 'Introduction', 'Guide']
category: 'Example'
tags: ['Template', 'Example']
---
## Welcome!
## Example Post
This is your new portfolio and blog, built with **Astro**, **React**, and **Tailwind CSS**. This template provides everything you need to showcase your work, share your thoughts, and connect with your audience.
This is an example blog post demonstrating the MDX content structure.
## Key Features
### Frontmatter Schema
### Content Management
- **MDX Support**: Write blog posts using Markdown with embedded React components
- **Type-Safe Content Collections**: Schema validation ensures your content is always properly structured
- **Flexible Taxonomies**: Organize posts with categories and tags
Required fields:
- `title` - Post title
- `description` - Brief description
- `pubDate` - Publication date
### Blog Functionality
- **Featured Posts**: Highlight your best work on the blog index
- **Related Posts**: Automatic suggestions based on categories and tags
- **Reading Time**: Calculated automatically from word count
- **Table of Contents**: Auto-generated navigation for long-form content
- **Post Navigation**: Easy browsing between previous and next posts
Optional fields:
- `heroImage` - Header image
- `featured` - Boolean for featured posts
- `category` - Category for filtering
- `tags` - Array of tags
- `updatedDate` - Last update date
### Design System
This template includes a comprehensive industrial dark design system with:
- Carefully crafted color palettes and typography scales
- Consistent spacing and grid systems
- Professional component patterns
- Smooth animations and transitions
### Content Features
All design tokens are documented in `dev/design.json` for easy customization.
You can use standard Markdown:
### Performance & SEO
- **Cloudflare Pages Deployment**: Global CDN for lightning-fast load times
- **Optimized Images**: AVIF format support with conversion utilities
- **Structured Data**: SEO-optimized with JSON-LD schemas
- **RSS Feed**: Keep your audience updated automatically
- **Reading Progress**: Visual indicator for long-form content
- Lists
- **Bold text**
- *Italic text*
- `Code snippets`
## Customization
This template is designed to be easily customized:
1. **Content**: Update the files in `src/content/` to add your own bio, experience, skills, and projects
2. **Design**: Modify `dev/design.json` to adjust colors, typography, and spacing
3. **Configuration**: Edit `template.config.json` or use the setup script to update site-wide settings
4. **Components**: All UI components are in `src/components/` for easy modification
## Utilities Included
This template comes with helpful utility scripts:
```bash
pnpm convert:avif:all # Convert images to AVIF format
pnpm commit # AI-powered git commit messages
pnpm cf-typegen # Generate Cloudflare types
```javascript
// Code blocks with syntax highlighting
const example = "Hello World";
```
## Getting Started
And embed HTML/JSX:
Check out the other template posts to learn more:
- **Sample Case Study**: See how to structure project documentation
- **Writing Your First Post**: Learn about MDX features and content authoring
<div class="p-4 border border-white/10 my-4">
Custom styled content
</div>
Ready to make this portfolio your own? Start by customizing the content in `src/content/sections/` to tell your story.
### Images and Media
## What's Next?
Images from `src/assets/` are processed by Astro.
Static files from `public/media/` are served as-is.
Explore the codebase, make it your own, and start creating. The architecture is designed to be intuitive, with clear separation between content, components, and configuration.
Happy building!
Replace this example post with your own content.

View File

@ -1,195 +0,0 @@
---
title: 'Writing Your First Blog Post'
description: 'Learn how to create and publish blog posts using MDX, including examples of all the content types you can include: images, videos, code blocks, and more.'
pubDate: 'Dec 15 2024'
heroImage: '../../assets/PENCIL_1.3.1_wipe.avif'
featured: false
category: 'Tutorial'
tags: ['Guide', 'MDX', 'Writing', 'Tutorial']
---
## Getting Started with MDX
MDX combines the simplicity of Markdown with the power of React components. This means you can write naturally in Markdown while embedding interactive elements when needed.
## Frontmatter
Every blog post starts with frontmatter - the YAML metadata at the top of the file:
```yaml
---
title: 'Your Post Title'
description: 'A brief description for SEO and previews'
pubDate: 'Dec 27 2024'
heroImage: '../../assets/your-image.avif'
featured: false
category: 'Tutorial'
tags: ['Tag1', 'Tag2', 'Tag3']
---
```
### Required Fields
- `title`: The post title (shown in browser tab, social shares, etc.)
- `description`: SEO description and preview text
- `pubDate`: Publication date (used for sorting)
### Optional Fields
- `heroImage`: Header image (relative path from `src/assets/` or absolute from `public/`)
- `featured`: Set to `true` to feature this post on the blog index
- `category`: Primary category for organization
- `tags`: Array of tags for discovery and related posts
- `updatedDate`: If you revise the post significantly
## Text Formatting
Standard Markdown formatting works as expected:
**Bold text** using `**bold**`
*Italic text* using `*italic*`
~~Strikethrough~~ using `~~strikethrough~~`
### Headings
Use `##` for main sections (h2), `###` for subsections (h3), and so on. The table of contents is automatically generated from your headings.
## Links and References
Create links using `[link text](URL)` syntax:
- [Internal page links](/contact)
- [External links](https://astro.build)
- [Link to another post](/blog/sample-case-study)
## Lists
### Unordered Lists
- First item
- Second item
- Nested item
- Another nested item
- Third item
### Ordered Lists
1. Step one
2. Step two
3. Step three
### Checklists
- [x] Completed task
- [ ] Pending task
- [ ] Another pending task
## Code Blocks
Inline code uses single backticks: `const example = true`
Multi-line code blocks use triple backticks with language specification:
```javascript
// JavaScript example
function greet(name) {
console.log(`Hello, ${name}!`);
}
greet("World");
```
```css
/* CSS example */
.example {
color: var(--color-primary);
font-size: 1.125rem;
transition: all 0.3s ease;
}
```
```bash
# Shell commands
pnpm dev
pnpm build
pnpm deploy
```
## Blockquotes
Use `>` for blockquotes:
> "This is a blockquote. Great for highlighting important points, testimonials, or notable quotes."
> Multi-line blockquotes work too.
> Just prefix each line with `>`.
## Images
Reference images from `src/assets/` (processed by Astro) or `public/` (served as-is):
```markdown
![Alt text](../../assets/your-image.avif)
![Alt text](/media/your-image.jpg)
```
Images in `src/assets/` get automatically optimized. Use the AVIF conversion utility for best results:
```bash
pnpm convert:avif:all
```
## Videos
Embed videos using HTML video tags:
```html
<video controls width="100%">
<source src="/media/your-video.mp4" type="video/mp4" />
Your browser does not support the video tag.
</video>
```
<video controls width="100%">
<source src="/media/placeholder-video.mp4" type="video/mp4" />
Your browser does not support the video tag.
</video>
## Tables
Create tables using pipe syntax:
| Feature | Supported | Notes |
|---------|-----------|-------|
| MDX | ✓ | Full support |
| Images | ✓ | AVIF recommended |
| Videos | ✓ | MP4/WebM |
| Components | ✓ | React components |
## Horizontal Rules
Create section dividers with three dashes:
---
Like that!
## Tips for Great Posts
1. **Use descriptive headings** - They create the table of contents
2. **Add hero images** - Visual appeal matters
3. **Tag appropriately** - Helps with discovery and related posts
4. **Write good descriptions** - They're used for SEO and previews
5. **Use code examples** - Share knowledge and demonstrate expertise
6. **Break up long sections** - Images, quotes, and code blocks add visual variety
7. **Proofread** - Typos undermine credibility
## Publishing Your Post
1. Create your `.mdx` file in `src/content/blog/`
2. Write your content with proper frontmatter
3. Add any images to `src/assets/` or `public/media/`
4. Preview with `pnpm dev`
5. Build and deploy with `pnpm deploy`
The blog index automatically updates, related posts are generated, and your RSS feed includes the new content.
## What's Next?
Now that you know the basics, start writing! Share your expertise, document your projects, or write tutorials to help others. Your unique perspective is valuable.
Happy writing!

View File

@ -1,32 +0,0 @@
---
pageTitleLine1: "Get In"
pageTitleLine2: "Touch"
availabilityText: "Available for new projects and collaborations. Let's work together."
email: "your@email.com"
location: "Your City, State"
locationCountry: "Your Country"
coordinates: "00.0000° N, 00.0000° W"
socialLinks:
- name: "LinkedIn"
url: "https://linkedin.com/in/yourprofile"
- name: "GitHub"
url: "https://github.com/yourusername"
- name: "Twitter"
url: "https://twitter.com/yourhandle"
formLabels:
transmissionUplink: "Contact Form"
name: "/// Your Name"
email: "/// Your Email"
subject: "/// Subject"
message: "/// Message"
submit: "Send Message"
subjectOptions:
- value: "project"
label: "Project Inquiry"
- value: "collab"
label: "Collaboration"
- value: "job"
label: "Job Opportunity"
- value: "other"
label: "Other"
---

View File

@ -1,35 +0,0 @@
---
sectionTitle: "Experience"
sectionSubtitle: "History"
sectionLabel: "/// Your professional journey."
description: ""
entries:
- systemId: "SYS.01"
status: "ACTIVE"
dates: "2020 — PRESENT"
company: "Your Current Company"
role: "Your Role"
tags:
- "Tag 1"
- "Tag 2"
- "Tag 3"
description: "Brief description of your role and responsibilities at this position. What do you do day-to-day? What are you known for?"
achievements:
- label: "Projects"
text: "List notable projects, clients, or achievements here. This can be a comma-separated list."
- label: "Technologies"
text: "Key technologies or tools you use in this role."
link:
url: "https://example.com"
text: "Visit Company Website"
- systemId: "SYS.02"
status: "DAEMON"
dates: "2015 — 2020"
company: "Previous Company"
role: "Previous Role"
description: "Description of your previous role. You can add as many experience entries as you need. The 'status' field can be 'ACTIVE', 'DAEMON', or other custom values."
tags:
- "Skill A"
- "Skill B"
- "Skill C"
---

View File

@ -1,20 +0,0 @@
---
role: "Your Role"
client: "Client Name"
year: "2024"
region: "Global"
projectTitle: "Project"
projectSubtitle: "Name"
projectDescription: "Brief description of your featured project. What made it special? What was your contribution? This appears on your homepage to showcase your best work."
stats:
- label: "Stat Label 1"
value: "Stat Value 1"
- label: "Stat Label 2"
value: "Stat Value 2"
- label: "Technologies"
value: "Tool / Stack / Framework"
- label: "Stat Label 3"
value: "Stat Value 3"
videoUrl: "/media/placeholder-video.mp4"
linkUrl: "/blog/sample-case-study/"
---

View File

@ -1,8 +0,0 @@
---
headlineLine1: "YOUR NAME"
headlineLine2: "HERE"
portfolioYear: "Portfolio 2025"
location: "Your City, State"
locationLabel: "Location"
bio: "Your professional bio goes here. Describe your expertise, what you do, and what makes you unique. This appears on your homepage hero section."
---

View File

@ -1,22 +0,0 @@
---
sectionTitle: "Technical"
sectionSubtitle: "Arsenal"
description: "A look at your skills and specialties."
skills:
- id: "01"
domain: "Your Primary Skill"
tools: "Tool 1 • Tool 2 • Tool 3 • Tool 4"
proficiency: "Expert"
- id: "02"
domain: "Your Secondary Skill"
tools: "Tool A • Tool B • Tool C • Tool D"
proficiency: "Advanced"
- id: "03"
domain: "Another Specialty"
tools: "Software X • Software Y • Software Z"
proficiency: "Specialist"
- id: "04"
domain: "Additional Skill"
tools: "Framework 1 • Framework 2 • Platform 3"
proficiency: "Proficient"
---

View File

@ -1,30 +1,8 @@
---
import type { CollectionEntry } from 'astro:content';
import type { ImageMetadata } from 'astro';
import BaseLayout from './BaseLayout.astro';
import FormattedDate from '../components/FormattedDate.astro';
import ReadingProgress from '../components/ReadingProgress.astro';
import TableOfContents from '../components/TableOfContents.astro';
import PostNavigation from '../components/PostNavigation.astro';
import RelatedPosts from '../components/RelatedPosts.astro';
import { Image } from 'astro:assets';
import { SOCIAL_LINKS } from '../consts';
interface NavPost {
title: string;
href: string;
heroImage?: ImageMetadata;
}
interface RelatedPost {
title: string;
description: string;
pubDate: Date;
heroImage?: ImageMetadata;
category?: string;
tags?: string[];
href: string;
}
interface Props {
title: string;
@ -34,10 +12,6 @@ interface Props {
heroImage?: ImageMetadata;
category?: string;
tags?: string[];
headings?: Array<{ depth: number; slug: string; text: string }>;
prevPost?: NavPost;
nextPost?: NavPost;
relatedPosts?: RelatedPost[];
readTime?: string;
}
@ -49,36 +23,8 @@ const {
heroImage,
category,
tags,
headings = [],
prevPost,
nextPost,
relatedPosts = [],
readTime = '5 min read',
readTime,
} = Astro.props;
// Article structured data (JSON-LD)
// References the canonical Person @id from BaseLayout for knowledge graph linking
const articleSchema = {
"@context": "https://schema.org",
"@type": "Article",
"headline": title,
"description": description,
"datePublished": pubDate.toISOString(),
"dateModified": (updatedDate || pubDate).toISOString(),
"author": {
"@id": `${SOCIAL_LINKS.website}/#person`
},
"publisher": {
"@id": `${SOCIAL_LINKS.website}/#person`
},
"mainEntityOfPage": {
"@type": "WebPage",
"@id": Astro.url.href
},
...(heroImage && { "image": new URL(heroImage.src, Astro.url).toString() }),
...(category && { "articleSection": category }),
...(tags && tags.length > 0 && { "keywords": tags.join(", ") })
};
---
<BaseLayout
@ -89,184 +35,66 @@ const articleSchema = {
publishedTime={pubDate}
modifiedTime={updatedDate}
>
<!-- Article Structured Data -->
<script type="application/ld+json" set:html={JSON.stringify(articleSchema)} slot="head" />
<ReadingProgress />
<article class="relative pb-24">
<!-- All content in same grid structure for consistent width -->
<div class="container mx-auto px-6 lg:px-12">
<div class="grid grid-cols-1 lg:grid-cols-12 gap-8 lg:gap-12">
<!-- Main Column -->
<div class="lg:col-span-8 lg:col-start-3">
<!-- Back Navigation -->
<div class="mb-12">
<a href="/blog" class="inline-flex items-center gap-3 px-5 py-3 border border-[var(--theme-text-subtle)] bg-[var(--theme-overlay)] text-xs font-mono font-bold uppercase tracking-widest text-[var(--theme-text-secondary)] hover:border-brand-accent hover:text-[var(--theme-text-primary)] hover:bg-brand-accent/5 transition-all duration-300 group backdrop-blur-sm">
<span class="text-brand-accent group-hover:-translate-x-1 transition-transform duration-300">&lt;</span>
<span>RETURN_TO_ARCHIVE</span>
</a>
</div>
<!-- Hero Section: Side-by-Side Layout -->
<header class="mb-20 lg:mb-24 relative">
<div class="grid grid-cols-1 lg:grid-cols-2 gap-12 items-start">
<!-- Text Content -->
<div class="order-2 lg:order-1 relative z-10">
<!-- Metadata -->
<div class="flex flex-wrap items-center gap-4 text-[10px] font-mono text-[var(--theme-text-muted)] uppercase tracking-widest mb-8 border-b border-[var(--theme-border-primary)] pb-4">
<div class="flex items-center gap-2">
<div class="w-1.5 h-1.5 bg-brand-accent shadow-[0_0_10px_rgba(221,65,50,0.5)] rounded-full animate-pulse"></div>
<span class="text-brand-accent font-bold">SYS.LOG</span>
</div>
<span class="text-[var(--theme-text-subtle)]">/</span>
<FormattedDate date={pubDate} />
<span class="text-[var(--theme-text-subtle)]">/</span>
<span>{readTime}</span>
</div>
{category && (
<div class="mb-6">
<span class="inline-block px-3 py-1.5 text-[10px] font-mono font-bold uppercase tracking-[0.2em] bg-[var(--theme-hover-bg-strong)] border border-[var(--theme-border-primary)] text-brand-accent hover:bg-brand-accent/10 transition-colors cursor-default">
/// {category}
</span>
</div>
)}
<h1 class="text-4xl md:text-5xl lg:text-6xl font-bold text-[var(--theme-text-primary)] uppercase leading-[0.9] tracking-tighter mb-8 break-words text-balance">
{title}
</h1>
<p class="text-base md:text-lg text-[var(--theme-text-secondary)] leading-relaxed font-light mb-8 border-l-2 border-brand-accent pl-6">
{description}
</p>
<!-- Tags -->
{tags && tags.length > 0 && (
<div class="flex flex-wrap gap-2">
{tags.map((tag) => (
<span class="px-2 py-1 text-[9px] font-mono uppercase bg-[var(--theme-bg-primary)] border border-[var(--theme-border-primary)] text-[var(--theme-text-muted)] hover:border-brand-accent/50 hover:text-[var(--theme-text-primary)] transition-colors cursor-default">
#{tag}
</span>
))}
</div>
)}
</div>
<!-- Hero Image -->
{heroImage && (
<div class="order-1 lg:order-2">
<div class="relative aspect-[4/3] lg:aspect-square overflow-hidden border border-[var(--theme-border-primary)] bg-[var(--theme-hover-bg)] group">
<!-- Tech corners -->
<div class="absolute top-0 left-0 w-2 h-2 border-t border-l border-brand-accent z-20"></div>
<div class="absolute top-0 right-0 w-2 h-2 border-t border-r border-brand-accent z-20"></div>
<div class="absolute bottom-0 left-0 w-2 h-2 border-b border-l border-brand-accent z-20"></div>
<div class="absolute bottom-0 right-0 w-2 h-2 border-b border-r border-brand-accent z-20"></div>
<Image
src={heroImage}
alt=""
width={800}
height={800}
loading="eager"
class="w-full h-full object-cover opacity-80 group-hover:opacity-100 group-hover:scale-105 transition-all duration-700 ease-out grayscale hover:grayscale-0"
/>
<!-- Scanline overlay (subtle) -->
<div class="absolute inset-0 bg-[linear-gradient(rgba(18,16,16,0)_50%,rgba(0,0,0,0.25)_50%),linear-gradient(90deg,rgba(255,0,0,0.06),rgba(0,255,0,0.02),rgba(0,0,255,0.06))] z-10 bg-[length:100%_2px,3px_100%] pointer-events-none opacity-20"></div>
<div class="absolute inset-0 grid-overlay opacity-30 pointer-events-none"></div>
</div>
</div>
)}
</div>
</header>
<!-- Main Content -->
<div class="prose-custom">
<slot />
</div>
<!-- Author Footer -->
<footer class="mt-24 pt-10 border-t border-[var(--theme-border-primary)]">
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-6">
<div>
<p class="text-[10px] font-mono text-brand-accent uppercase tracking-widest mb-2 flex items-center gap-2">
<span class="w-1.5 h-1.5 bg-brand-accent rounded-full animate-pulse"></span>
/// END TRANSMISSION
</p>
<p class="text-[var(--theme-text-secondary)] text-sm font-mono">
LOG_DATE: <FormattedDate date={pubDate} />
{updatedDate && (
<span class="text-[var(--theme-text-muted)]"> // UPDATED: <FormattedDate date={updatedDate} /></span>
)}
</p>
</div>
<!-- Share Links -->
<div class="flex items-center gap-6">
<span class="text-[10px] font-mono text-[var(--theme-text-muted)] uppercase tracking-widest">DATA_UPLINK:</span>
<div class="flex items-center gap-2">
<a
href={`https://twitter.com/intent/tweet?text=${encodeURIComponent(title)}&url=${encodeURIComponent(Astro.url.href)}`}
target="_blank"
rel="noopener noreferrer"
class="w-10 h-10 flex items-center justify-center border border-[var(--theme-border-primary)] text-[var(--theme-text-secondary)] hover:border-brand-accent hover:text-brand-accent transition-all duration-300"
aria-label="Share on Twitter"
>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M22 4s-.7 2.1-2 3.4c1.6 10-9.4 17.3-18 11.6 2.2.1 4.4-.6 6-2C3 15.5.5 9.6 3 5c2.2 2.6 5.6 4.1 9 4-.9-4.2 4-6.6 7-3.8 1.1 0 3-1.2 3-1.2z"/>
</svg>
</a>
<a
href={`https://www.linkedin.com/shareArticle?mini=true&url=${encodeURIComponent(Astro.url.href)}&title=${encodeURIComponent(title)}`}
target="_blank"
rel="noopener noreferrer"
class="w-10 h-10 flex items-center justify-center border border-[var(--theme-border-primary)] text-[var(--theme-text-secondary)] hover:border-brand-accent hover:text-brand-accent transition-all duration-300"
aria-label="Share on LinkedIn"
>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M16 8a6 6 0 0 1 6 6v7h-4v-7a2 2 0 0 0-2-2 2 2 0 0 0-2 2v7h-4v-7a6 6 0 0 1 6-6z"/>
<rect width="4" height="12" x="2" y="9"/>
<circle cx="4" cy="4" r="2"/>
</svg>
</a>
<button
type="button"
onclick="navigator.clipboard.writeText(window.location.href)"
class="w-10 h-10 flex items-center justify-center border border-[var(--theme-border-primary)] text-[var(--theme-text-secondary)] hover:border-brand-accent hover:text-brand-accent transition-all duration-300"
aria-label="Copy link"
>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/>
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>
</svg>
</button>
</div>
</div>
</div>
</footer>
<!-- Post Navigation -->
<PostNavigation prevPost={prevPost} nextPost={nextPost} />
<!-- Related Posts -->
<RelatedPosts posts={relatedPosts} />
<!-- Back to Blog -->
<div class="mt-20 pt-10 border-t border-[var(--theme-border-primary)] flex justify-center lg:justify-start">
<a href="/blog" class="inline-flex items-center gap-4 px-8 py-4 border border-[var(--theme-border-strong)] text-xs font-bold uppercase tracking-widest text-[var(--theme-text-primary)] hover:border-brand-accent hover:bg-brand-accent hover:text-brand-dark transition-all duration-300 group">
<span class="font-mono transition-transform duration-300 group-hover:-translate-x-1">&lt;</span>
ACCESS_FULL_ARCHIVE
</a>
</div>
</div>
<!-- Table of Contents Sidebar (Desktop) -->
<aside class="hidden lg:block lg:col-span-2 lg:col-start-11">
<div class="sticky top-24 mt-32">
<TableOfContents headings={headings} />
</div>
</aside>
</div>
<article class="container mx-auto px-6 py-20 max-w-4xl">
<div class="mb-12">
<a href="/blog" class="text-sm text-brand-accent hover:underline">&larr; Back to blog</a>
</div>
<header class="mb-12">
<div class="flex items-center gap-4 text-sm text-[var(--theme-text-muted)] mb-4">
<FormattedDate date={pubDate} />
{readTime && (
<>
<span>•</span>
<span>{readTime}</span>
</>
)}
{category && (
<>
<span>•</span>
<span class="text-brand-accent">{category}</span>
</>
)}
</div>
<h1 class="text-5xl md:text-6xl font-bold mb-6 leading-tight">{title}</h1>
<p class="text-xl text-[var(--theme-text-muted)]">{description}</p>
{tags && tags.length > 0 && (
<div class="flex flex-wrap gap-2 mt-6">
{tags.map((tag) => (
<span class="px-3 py-1 text-xs border border-[var(--theme-border-primary)] text-[var(--theme-text-muted)]">
{tag}
</span>
))}
</div>
)}
</header>
{heroImage && (
<div class="mb-12">
<Image
src={heroImage}
alt=""
width={1200}
height={630}
class="w-full border border-[var(--theme-border-primary)]"
/>
</div>
)}
<div class="prose-custom">
<slot />
</div>
<footer class="mt-16 pt-8 border-t border-[var(--theme-border-primary)]">
<div class="text-sm text-[var(--theme-text-muted)]">
Published <FormattedDate date={pubDate} />
{updatedDate && (
<span> • Updated <FormattedDate date={updatedDate} /></span>
)}
</div>
</footer>
</article>
</BaseLayout>

View File

@ -1,223 +1,36 @@
---
import { Image } from 'astro:assets';
import { getCollection } from 'astro:content';
import BaseLayout from '../../layouts/BaseLayout.astro';
import FormattedDate from '../../components/FormattedDate.astro';
import BlogCard from '../../components/BlogCard.astro';
import BlogFilters from '../../components/BlogFilters.astro';
import { SITE_DESCRIPTION, SITE_TITLE } from '../../consts';
import { SITE_TITLE } from '../../consts';
import { calculateReadingTime } from '../../utils/reading-time';
// Fetch all posts sorted by date (newest first)
const allPosts = (await getCollection('blog')).sort(
(a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf(),
);
// Derive featured post (first post with featured: true, or fallback to latest)
const featuredPost = allPosts.find((post) => post.data.featured) || allPosts[0];
// Editor's picks: next 3 posts after featured (excluding the featured one)
//const editorPicks = allPosts
// .filter((post) => post.id !== featuredPost?.id)
// .slice(0, 3);
// Latest posts: all posts for the filterable grid
const latestPosts = allPosts;
// Extract unique categories for filters
const categories = [...new Set(allPosts.map((post) => post.data.category).filter(Boolean))] as string[];
---
<BaseLayout title={`Blog | ${SITE_TITLE}`} description={SITE_DESCRIPTION}>
<section class="container mx-auto px-6 lg:px-12">
<!-- Back Navigation -->
<div class="mb-12">
<a href="/" class="inline-flex items-center gap-3 px-5 py-3 border border-[var(--theme-border-primary)] bg-[var(--theme-overlay)] text-xs font-mono font-bold uppercase tracking-widest text-[var(--theme-text-secondary)] hover:border-brand-accent hover:text-[var(--theme-text-primary)] hover:bg-brand-accent/5 transition-all duration-300 group backdrop-blur-sm">
<span class="text-brand-accent group-hover:-translate-x-1 transition-transform duration-300">&lt;</span>
<span>RETURN_TO_HOME</span>
</a>
</div>
<!-- Page Header -->
<div class="grid grid-cols-1 lg:grid-cols-12 gap-8 lg:gap-12 mb-16 lg:mb-24">
<div class="lg:col-span-8">
<div class="flex items-center gap-3 mb-6">
<div class="w-2 h-2 bg-brand-accent animate-pulse"></div>
<span class="font-mono text-[10px] uppercase tracking-[0.3em] text-brand-accent">SYS.LOG /// PRODUCTION_ARCHIVE</span>
</div>
<h1 class="text-5xl md:text-7xl lg:text-8xl font-bold uppercase tracking-tighter leading-[0.85]">
<span class="block text-[var(--theme-text-primary)]">BLOG</span>
<span class="block text-brand-accent">ARCHIVE</span>
</h1>
</div>
<div class="lg:col-span-4 flex flex-col justify-end">
<div class="font-mono text-[10px] text-[var(--theme-text-subtle)] uppercase tracking-widest mb-4 flex items-center gap-2">
<span class="w-8 h-px bg-brand-accent/30"></span>
THOUGHTS & PROCESS
</div>
<p class="text-[var(--theme-text-secondary)] text-lg leading-relaxed border-l border-brand-accent/30 pl-6">
Deep dives into VFX production, technical pipelines, and creative process. Sharing lessons from the front lines of visual effects.
</p>
</div>
<BaseLayout title={`Blog | ${SITE_TITLE}`} description="Blog posts and articles">
<div class="container mx-auto px-6 py-20">
<div class="mb-12">
<a href="/" class="text-sm text-brand-accent hover:underline">&larr; Back to home</a>
</div>
<!-- Featured Hero Section -->
{featuredPost && (
<div class="mb-16 lg:mb-24 animate-on-scroll slide-up stagger-2">
<div class="flex items-center gap-4 mb-8">
<div class="w-2 h-2 bg-brand-accent rounded-full animate-pulse"></div>
<span class="text-[10px] font-mono text-brand-accent uppercase tracking-widest font-bold">
SYS.BLOG /// FEATURED
</span>
<span class="h-px flex-grow bg-[var(--theme-border-secondary)]"></span>
</div>
<h1 class="text-6xl font-bold mb-12">Blog</h1>
<article class="group relative border border-[var(--theme-border-primary)] bg-[var(--theme-hover-bg)] hover:border-brand-accent/40 transition-all duration-500 overflow-hidden">
<!-- Accent indicator strip -->
<div class="absolute top-0 left-0 w-1 h-full bg-brand-accent"></div>
<div class="absolute top-0 left-0 w-full h-1 bg-brand-accent opacity-0 group-hover:opacity-100 transition-opacity duration-500"></div>
<div class="grid grid-cols-1 lg:grid-cols-2">
<!-- Image section -->
<a href={`/blog/${featuredPost.id}/`} class="block relative aspect-[16/10] lg:aspect-auto overflow-hidden">
{featuredPost.data.heroImage && (
<Image
src={featuredPost.data.heroImage}
alt=""
width={900}
height={600}
class="w-full h-full object-cover transition-transform duration-[1.2s] ease-out group-hover:scale-105"
/>
)}
<div class="absolute inset-0 bg-[var(--theme-card-overlay)] group-hover:opacity-50 transition-opacity duration-500"></div>
<div class="absolute inset-0 bg-gradient-to-r from-transparent via-transparent to-[var(--theme-card-gradient)] hidden lg:block"></div>
<div class="absolute inset-0 bg-gradient-to-t from-[var(--theme-card-gradient)] to-transparent lg:hidden"></div>
<!-- Category badge -->
{featuredPost.data.category && (
<div class="absolute top-6 left-6">
<span class="px-4 py-2 text-[10px] font-mono font-bold uppercase tracking-widest bg-[var(--theme-overlay)] border border-brand-accent/50 text-brand-accent backdrop-blur-sm">
{featuredPost.data.category}
</span>
</div>
)}
<!-- Grid overlay effect -->
<div class="absolute inset-0 grid-overlay opacity-30 pointer-events-none"></div>
</a>
<!-- Content section -->
<div class="p-8 lg:p-12 flex flex-col justify-center">
<!-- Technical header -->
<div class="flex items-center gap-3 mb-6">
<span class="text-[10px] font-mono text-brand-accent uppercase tracking-widest">
<FormattedDate date={featuredPost.data.pubDate} />
</span>
<span class="h-px w-8 bg-[var(--theme-border-strong)]"></span>
<span class="text-[10px] font-mono text-[var(--theme-text-muted)] uppercase tracking-widest">
{calculateReadingTime(featuredPost.body)}
</span>
</div>
<!-- Title -->
<a href={`/blog/${featuredPost.id}/`}>
<h2 class="text-3xl lg:text-4xl xl:text-5xl font-bold text-[var(--theme-text-primary)] uppercase tracking-tight mb-6 group-hover:text-brand-accent transition-colors duration-300 leading-tight">
{featuredPost.data.title}
</h2>
</a>
<!-- Description -->
<p class="text-[var(--theme-text-secondary)] text-base lg:text-lg font-light leading-relaxed mb-8 line-clamp-3">
{featuredPost.data.description}
</p>
<!-- Tags -->
{featuredPost.data.tags && featuredPost.data.tags.length > 0 && (
<div class="flex flex-wrap gap-2 mb-8">
{featuredPost.data.tags.slice(0, 5).map((tag: string) => (
<span class="px-3 py-1.5 text-[10px] font-mono uppercase border border-[var(--theme-border-primary)] text-[var(--theme-text-muted)] group-hover:border-[var(--theme-border-strong)] transition-colors">
{tag}
</span>
))}
</div>
)}
<!-- Read link -->
<div class="pt-6 border-t border-[var(--theme-border-primary)]">
<a
href={`/blog/${featuredPost.id}/`}
class="inline-flex items-center gap-4 text-xs font-bold uppercase tracking-widest text-[var(--theme-text-primary)] hover:text-brand-accent transition-all duration-300 group/link"
>
Read Full Article
<span class="block w-8 h-[1px] bg-[var(--theme-border-strong)] group-hover/link:bg-brand-accent group-hover/link:w-12 transition-all duration-300"></span>
<svg
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="group-hover/link:translate-x-1 transition-transform duration-300"
>
<path d="M5 12h14" />
<path d="m12 5 7 7-7 7" />
</svg>
</a>
</div>
</div>
</div>
</article>
</div>
)}
<!-- Latest Section with Filters -->
<div class="mb-16 lg:mb-24">
<div class="flex items-center gap-4 mb-8">
<span class="text-[10px] font-mono text-[var(--theme-text-muted)] uppercase tracking-widest font-bold">
/// LATEST TRANSMISSIONS
</span>
<span class="h-px flex-grow bg-[var(--theme-border-secondary)]"></span>
</div>
<!-- Filters Component -->
<BlogFilters categories={categories} />
<!-- Posts Grid -->
<div data-posts-grid class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-x-6 gap-y-10">
{latestPosts.map((post, index) => (
<div
data-post
data-category={post.data.category || ''}
data-title={post.data.title}
data-description={post.data.description}
class={`animate-on-scroll slide-up stagger-${Math.min((index % 6) + 1, 6)}`}
>
<BlogCard
title={post.data.title}
description={post.data.description}
pubDate={post.data.pubDate}
heroImage={post.data.heroImage}
category={post.data.category}
tags={post.data.tags}
href={`/blog/${post.id}/`}
readTime={calculateReadingTime(post.body)}
/>
</div>
))}
</div>
<!-- Empty state (hidden by default, shown via JS when no results) -->
<div id="no-results" class="hidden text-center py-20">
<div class="text-[var(--theme-text-muted)] font-mono text-sm uppercase tracking-widest mb-4">
/// NO MATCHING ARTICLES FOUND
</div>
<p class="text-[var(--theme-text-secondary)] text-sm">
Try adjusting your search or filter criteria.
</p>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{allPosts.map((post) => (
<BlogCard
title={post.data.title}
description={post.data.description}
pubDate={post.data.pubDate}
heroImage={post.data.heroImage}
category={post.data.category}
tags={post.data.tags}
href={`/blog/${post.id}/`}
readTime={calculateReadingTime(post.body)}
/>
))}
</div>
</section>
</div>
</BaseLayout>

View File

@ -1,764 +1,61 @@
---
import BaseLayout from '../layouts/BaseLayout.astro';
import { SITE_TITLE } from '../consts';
import { getEntry } from 'astro:content';
const pageTitle = `Contact | ${SITE_TITLE}`;
// Fetch contact page content
const contactEntry = await getEntry('pages', 'contact');
const contactContent = contactEntry.data;
---
<BaseLayout title={pageTitle} description="Get in touch for collaboration or inquiries." usePadding={false}>
<BaseLayout title={pageTitle} description="Get in touch">
<div class="container mx-auto px-6 py-20 max-w-2xl">
<h1 class="text-6xl font-bold mb-6">Contact</h1>
<p class="text-xl text-[var(--theme-text-muted)] mb-12">Get in touch</p>
<!-- Background Grid (Optional, low opacity) -->
<div class="fixed inset-0 z-0 pointer-events-none">
<div class="w-full h-full grid grid-cols-12 gap-4 opacity-[0.03]">
{Array.from({ length: 12 }).map((_) => (
<div class="h-full border-r border-[var(--theme-text-primary)]"></div>
))}
</div>
</div>
<form class="space-y-6">
<div>
<label for="name" class="block text-sm font-mono text-[var(--theme-text-muted)] mb-2">Name</label>
<input
type="text"
id="name"
name="name"
class="w-full bg-transparent border border-[var(--theme-border-strong)] px-4 py-3 text-[var(--theme-text-primary)] focus:outline-none focus:border-brand-accent transition-colors"
required
/>
</div>
<section class="relative z-10 min-h-screen flex flex-col pt-32 lg:pt-48 pb-20 px-6 lg:px-12">
<div>
<label for="email" class="block text-sm font-mono text-[var(--theme-text-muted)] mb-2">Email</label>
<input
type="email"
id="email"
name="email"
class="w-full bg-transparent border border-[var(--theme-border-strong)] px-4 py-3 text-[var(--theme-text-primary)] focus:outline-none focus:border-brand-accent transition-colors"
required
/>
</div>
<!-- Page Header -->
<div class="grid grid-cols-1 lg:grid-cols-12 gap-12 mb-20 lg:mb-32 border-b border-[var(--theme-border-primary)] pb-12">
<div class="lg:col-span-8 group cursor-default">
<div class="flex items-center gap-3 mb-6 intro-element animate-on-scroll fade-in">
<div class="w-2 h-2 bg-brand-accent animate-pulse"></div>
<span class="font-mono text-[10px] uppercase tracking-[0.3em] text-brand-accent">SYS.UPLINK /// CONTACT_INTERFACE</span>
</div>
<h1 class="text-5xl md:text-7xl lg:text-8xl font-bold uppercase tracking-tighter leading-[0.85] text-[var(--theme-text-primary)]">
<span class="block">{contactContent.pageTitleLine1}</span>
<span class="block text-brand-accent">{contactContent.pageTitleLine2}</span>
</h1>
</div>
<div class="lg:col-span-4 flex flex-col justify-end">
<div class="font-mono text-[10px] text-[var(--theme-text-subtle)] uppercase tracking-widest mb-4 flex items-center gap-2">
<span class="w-8 h-px bg-brand-accent/30"></span>
COMM_AVAILABILITY
</div>
<p class="font-mono text-sm text-[var(--theme-text-secondary)] leading-relaxed border-l border-brand-accent/30 pl-6">
{contactContent.availabilityText}
</p>
</div>
</div>
<div>
<label for="message" class="block text-sm font-mono text-[var(--theme-text-muted)] mb-2">Message</label>
<textarea
id="message"
name="message"
rows="6"
class="w-full bg-transparent border border-[var(--theme-border-strong)] px-4 py-3 text-[var(--theme-text-primary)] focus:outline-none focus:border-brand-accent transition-colors resize-none"
required
></textarea>
</div>
<div class="grid grid-cols-1 lg:grid-cols-12 gap-12 lg:gap-24 flex-grow">
<button
type="submit"
class="border border-brand-accent px-6 py-3 text-sm font-bold uppercase tracking-wider text-brand-accent hover:bg-brand-accent hover:text-brand-dark transition-all"
>
Send Message
</button>
</form>
<!-- Left Column: Contact Form -->
<div class="lg:col-span-7">
<div class="mb-12 flex items-center gap-3">
<span class="w-1.5 h-1.5 bg-brand-accent rounded-full animate-pulse"></span>
<span class="font-mono text-[10px] text-brand-accent uppercase tracking-[0.2em] font-bold">/// TRANSMISSION_LOG</span>
</div>
<form id="contact-form" class="space-y-12">
<div class="group relative">
<input
type="text"
id="name"
name="name"
class="block w-full bg-transparent border-b border-[var(--theme-border-strong)] py-4 text-xl text-[var(--theme-text-primary)] focus:outline-none focus:border-brand-accent transition-colors duration-300 placeholder-transparent peer"
placeholder="Name"
required
/>
<label for="name" class="absolute left-0 top-4 text-[var(--theme-text-muted)] text-[10px] font-mono uppercase tracking-[0.2em] transition-all duration-300 peer-focus:-top-6 peer-focus:text-brand-accent peer-valid:-top-6 peer-valid:text-[var(--theme-text-secondary)] pointer-events-none">
{contactContent.formLabels?.name}
</label>
</div>
<div class="group relative">
<input
type="email"
id="email"
name="email"
class="block w-full bg-transparent border-b border-[var(--theme-border-strong)] py-4 text-xl text-[var(--theme-text-primary)] focus:outline-none focus:border-brand-accent transition-colors duration-300 placeholder-transparent peer"
placeholder="Email"
required
/>
<label for="email" class="absolute left-0 top-4 text-[var(--theme-text-muted)] text-[10px] font-mono uppercase tracking-[0.2em] transition-all duration-300 peer-focus:-top-6 peer-focus:text-brand-accent peer-valid:-top-6 peer-valid:text-[var(--theme-text-secondary)] pointer-events-none">
{contactContent.formLabels?.email}
</label>
</div>
<!-- Custom Dropdown -->
<div class="group relative" id="custom-select">
<input type="hidden" name="subject" id="subject-input" required>
<button type="button" id="select-trigger" class="block w-full text-left bg-transparent border-b border-[var(--theme-border-strong)] py-4 text-xl text-[var(--theme-text-primary)] focus:outline-none focus:border-brand-accent transition-colors duration-300 flex justify-between items-center group-hover:border-brand-accent/30">
<span id="select-value" class="text-transparent">Select</span> <!-- Hidden placeholder text to keep height -->
<div class="text-brand-accent transform transition-transform duration-300" id="select-arrow">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="square" stroke-linejoin="miter"><path d="m6 9 6 6 6-6"/></svg>
</div>
</button>
<label id="select-label" class="absolute left-0 top-4 text-[var(--theme-text-muted)] text-[10px] font-mono uppercase tracking-[0.2em] transition-all duration-300 pointer-events-none">
{contactContent.formLabels?.subject}
</label>
<!-- Dropdown Menu -->
<div id="select-options" class="absolute left-0 top-full w-full bg-[var(--theme-bg-primary)] border border-brand-accent/30 shadow-2xl z-50 hidden opacity-0 transform translate-y-2 transition-all duration-200 origin-top mt-2 backdrop-blur-xl">
<div class="p-1">
{contactContent.subjectOptions?.map((option) => (
<div class="option px-5 py-4 hover:bg-brand-accent/5 cursor-pointer text-[var(--theme-text-primary)] text-lg font-light transition-colors flex items-center gap-3 group/option" data-value={option.value}>
<span class="w-1.5 h-1.5 rounded-full bg-brand-accent opacity-0 group-hover/option:opacity-100 transition-opacity"></span>
{option.label}
</div>
))}
</div>
</div>
</div>
<div class="group relative">
<textarea
id="message"
name="message"
rows="4"
class="block w-full bg-transparent border-b border-[var(--theme-border-strong)] py-4 text-xl text-[var(--theme-text-primary)] focus:outline-none focus:border-brand-accent transition-colors duration-300 placeholder-transparent peer resize-none"
placeholder="Message"
required
></textarea>
<label for="message" class="absolute left-0 top-4 text-[var(--theme-text-muted)] text-[10px] font-mono uppercase tracking-[0.2em] transition-all duration-300 peer-focus:-top-6 peer-focus:text-brand-accent peer-valid:-top-6 peer-valid:text-[var(--theme-text-secondary)] pointer-events-none">
{contactContent.formLabels?.message}
</label>
</div>
<div class="pt-8">
<button type="submit" id="submit-btn" class="group relative inline-flex items-center justify-center gap-6 px-8 py-4 bg-brand-accent/5 border border-brand-accent/30 hover:bg-brand-accent hover:border-brand-accent transition-all duration-300 disabled:opacity-50 disabled:cursor-not-allowed overflow-hidden">
<span id="submit-text" data-default-text={contactContent.formLabels?.submit} class="relative z-10 font-mono text-xs font-bold uppercase tracking-[0.2em] text-brand-accent group-hover:text-brand-dark transition-colors">{contactContent.formLabels?.submit}</span>
<div class="relative z-10 w-8 h-8 flex items-center justify-center border border-brand-accent/20 group-hover:border-brand-dark/30 transition-colors">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="square" stroke-linejoin="miter" class="text-brand-accent group-hover:text-brand-dark group-hover:translate-x-1 transition-all">
<path d="M5 12h14M12 5l7 7-7 7"/>
</svg>
</div>
</button>
</div>
</form>
</div>
<!-- Right Column: System Status / Info -->
<div class="lg:col-span-5 space-y-16 lg:pt-24">
<!-- Data Block 1 -->
<div class="relative pl-6 border-l border-brand-accent/30 group">
<h3 class="font-mono text-[10px] text-[var(--theme-text-muted)] uppercase tracking-[0.2em] mb-4 flex items-center gap-2">
<span class="text-brand-accent">/</span> DIRECT_LINK
</h3>
<a href={`mailto:${contactContent.email}`} class="text-2xl md:text-3xl font-bold text-[var(--theme-text-primary)] hover:text-brand-accent transition-colors break-all uppercase tracking-tight">
{contactContent.email}
</a>
</div>
<!-- Data Block 2 -->
<div class="relative pl-6 border-l border-brand-accent/30 group">
<h3 class="font-mono text-[10px] text-[var(--theme-text-muted)] uppercase tracking-[0.2em] mb-4 flex items-center gap-2">
<span class="text-brand-accent">/</span> GEOSPATIAL_COORDS
</h3>
<p class="text-xl text-[var(--theme-text-primary)] font-bold uppercase tracking-tight mb-2">
{contactContent.location}<br>
<span class="text-[var(--theme-text-muted)] text-sm tracking-widest">{contactContent.locationCountry}</span>
</p>
<div class="font-mono text-xs text-brand-accent/60 group-hover:text-brand-accent transition-colors">
{contactContent.coordinates}
</div>
</div>
<!-- Data Block 3 -->
<div class="relative pl-6 border-l border-brand-accent/30">
<h3 class="font-mono text-[10px] text-[var(--theme-text-muted)] uppercase tracking-[0.2em] mb-4 flex items-center gap-2">
<span class="text-brand-accent">/</span> SOCIAL_MANIFEST
</h3>
<ul class="space-y-4">
{contactContent.socialLinks?.map((link, idx) => (
<li class="group/social">
<a href={link.url} class="flex items-center gap-4">
<span class="font-mono text-[9px] text-brand-accent opacity-0 group-hover/social:opacity-100 transition-opacity">[SIG.0{idx + 1}]</span>
<span class="text-[var(--theme-text-secondary)] group-hover/social:text-brand-accent transition-colors text-xl font-bold uppercase tracking-tight">{link.name}</span>
<div class="w-1.5 h-1.5 rounded-full bg-brand-accent/20 group-hover/social:bg-brand-accent transition-all"></div>
</a>
</li>
))}
</ul>
</div>
</div>
</div>
</section>
<!-- Toast Notification Container -->
<div id="toast-container" class="fixed top-6 right-6 z-50 pointer-events-none"></div>
<!-- Full-Screen Modal for Loading and Response -->
<div id="transmission-modal" class="fixed inset-0 z-[100] flex items-center justify-center bg-[var(--theme-bg-primary)]/95 backdrop-blur-2xl opacity-0 pointer-events-none transition-opacity duration-500">
<!-- Technical Grid Overlay for Modal -->
<div class="absolute inset-0 z-0 pointer-events-none opacity-[0.03] bg-[linear-gradient(rgba(18,16,16,0)_50%,rgba(0,0,0,0.25)_50%),linear-gradient(90deg,rgba(255,0,0,0.06),rgba(0,255,0,0.02),rgba(0,0,112,0.06))] bg-[length:100%_2px,3px_100%]"></div>
<!-- Loading State -->
<div id="loading-state" class="relative z-10 text-center">
<!-- Animated Transmission Graphic -->
<div class="relative w-40 h-40 mx-auto mb-16">
<!-- Outer rotating ring -->
<div class="absolute inset-0 border border-brand-accent/20 rounded-full animate-spin-slow"></div>
<!-- Middle pulsing ring -->
<div class="absolute inset-6 border border-brand-accent/40 rounded-full animate-pulse"></div>
<!-- Inner dot -->
<div class="absolute inset-0 flex items-center justify-center">
<div class="w-6 h-6 bg-brand-accent rounded-full animate-ping opacity-40"></div>
<div class="w-2 h-2 bg-brand-accent rounded-full absolute"></div>
</div>
<!-- Scanning bars -->
<div class="absolute inset-0 overflow-hidden rounded-full">
<div class="w-full h-full bg-gradient-to-b from-transparent via-brand-accent/10 to-transparent animate-scan-sweep-vertical"></div>
</div>
</div>
<!-- Loading Text -->
<div class="space-y-6">
<div class="flex items-center justify-center gap-3">
<div class="w-1.5 h-1.5 bg-brand-accent animate-pulse"></div>
<h2 class="text-4xl md:text-6xl font-bold text-[var(--theme-text-primary)] uppercase tracking-tighter">
<span id="loading-text">TRANSMITTING</span>
</h2>
<div class="w-1.5 h-1.5 bg-brand-accent animate-pulse"></div>
</div>
<div class="font-mono text-[10px] text-brand-accent uppercase tracking-[0.4em] flex items-center justify-center gap-4">
<span class="opacity-40">ENCRYPTING_PACKETS</span>
<span class="w-8 h-px bg-brand-accent/20"></span>
<span class="animate-pulse">STABLIZING_UPLINK</span>
</div>
</div>
</div>
<!-- Response State (hidden initially) -->
<div id="response-state" class="hidden w-full h-full absolute inset-0 z-20 flex flex-col items-center justify-center p-6 lg:p-12 opacity-0 transition-all duration-700">
<!-- Close button -->
<button id="close-modal" class="absolute top-12 right-12 z-50 group flex items-center gap-4">
<span class="font-mono text-[10px] text-[var(--theme-text-muted)] uppercase tracking-[0.2em] group-hover:text-brand-accent transition-colors">Terminate_Session [ESC]</span>
<div class="w-10 h-10 flex items-center justify-center border border-[var(--theme-border-strong)] group-hover:border-brand-accent group-hover:bg-brand-accent/5 transition-all">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="square" stroke-linejoin="miter" class="text-[var(--theme-text-primary)] group-hover:text-brand-accent transition-colors">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</div>
</button>
<!-- Content Container -->
<div class="w-full max-w-6xl mx-auto flex flex-col items-center relative">
<!-- Response content - The Focal Point -->
<div class="w-full relative bg-white/[0.01] border border-white/5 backdrop-blur-sm p-8 md:p-16 lg:p-24">
<!-- Technical Corner Accents -->
<div class="absolute -top-px -left-px w-12 h-12 border-t-2 border-l-2 border-brand-accent"></div>
<div class="absolute -top-px -right-px w-12 h-12 border-t-2 border-r-2 border-brand-accent"></div>
<div class="absolute -bottom-px -left-px w-12 h-12 border-b-2 border-l-2 border-brand-accent"></div>
<div class="absolute -bottom-px -right-px w-12 h-12 border-b-2 border-r-2 border-brand-accent"></div>
<!-- Header -->
<div class="flex items-center gap-4 mb-12">
<div class="w-2 h-2 bg-brand-accent animate-pulse"></div>
<span class="font-mono text-[10px] text-brand-accent uppercase tracking-[0.4em]">INCOMING_RESPONSE /// MSG.ID_ACK</span>
</div>
<!-- Content -->
<div id="response-content" class="prose-response max-h-[60vh] overflow-y-auto custom-scrollbar pr-4"></div>
</div>
</div>
</div>
</div>
<div class="mt-12 pt-12 border-t border-[var(--theme-border-primary)]">
<p class="text-sm text-[var(--theme-text-muted)]">
Or email directly: <a href="mailto:example@email.com" class="text-brand-accent hover:underline">example@email.com</a>
</p>
</div>
</div>
</BaseLayout>
<style>
/* Custom autofill styles to match theme */
input:-webkit-autofill,
input:-webkit-autofill:hover,
input:-webkit-autofill:focus,
textarea:-webkit-autofill,
textarea:-webkit-autofill:hover,
textarea:-webkit-autofill:focus {
-webkit-text-fill-color: var(--theme-text-primary);
-webkit-box-shadow: 0 0 0px 1000px var(--theme-bg-primary) inset;
transition: background-color 5000s ease-in-out 0s;
}
/* Label active state */
.label-active {
top: -1.5rem !important;
font-size: 0.75rem !important;
color: var(--theme-text-secondary) !important;
}
/* Dropdown open state */
.dropdown-open #select-arrow {
transform: rotate(180deg);
color: var(--theme-text-primary);
}
/* Custom Animations */
@keyframes spin-slow {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
@keyframes scan-sweep-vertical {
0% { transform: translateY(-100%); opacity: 0; }
50% { opacity: 1; }
100% { transform: translateY(100%); opacity: 0; }
}
@keyframes scale-in {
0% {
transform: scale(0.9);
opacity: 0;
}
100% {
transform: scale(1);
opacity: 1;
}
}
.animate-spin-slow {
animation: spin-slow 8s linear infinite;
}
.animate-scan-sweep-vertical {
animation: scan-sweep-vertical 2s ease-in-out infinite;
}
.animate-scale-in {
animation: scale-in 0.6s cubic-bezier(0.16, 1, 0.3, 1) forwards;
}
.delay-100 {
animation-delay: 100ms;
}
.delay-150 {
animation-delay: 150ms;
}
.delay-200 {
animation-delay: 200ms;
}
/* Custom Scrollbar */
.custom-scrollbar::-webkit-scrollbar {
width: 8px;
}
.custom-scrollbar::-webkit-scrollbar-track {
background: var(--theme-hover-bg-strong);
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background: var(--color-brand-accent);
border-radius: 4px;
opacity: 0.3;
}
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
opacity: 0.5;
}
/* Response Content Prose Styles - Enhanced Readability */
.prose-response {
color: var(--theme-text-primary);
text-align: left;
}
.prose-response h1,
.prose-response h2,
.prose-response h3 {
color: var(--theme-text-primary);
margin-top: 1.5em;
margin-bottom: 0.75em;
font-weight: 800;
line-height: 1.1;
letter-spacing: -0.02em;
text-transform: uppercase;
}
.prose-response h1 {
font-size: 2.5rem;
background: linear-gradient(to right, var(--theme-text-primary), var(--color-brand-accent));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
margin-bottom: 0.5em;
}
.prose-response h2 {
font-size: 1.75rem;
border-bottom: 1px solid var(--theme-border-primary);
padding-bottom: 0.5rem;
}
.prose-response h3 {
font-size: 1.25rem;
color: var(--color-brand-accent);
}
.prose-response p {
margin-bottom: 1.5em;
line-height: 1.7;
color: var(--theme-text-secondary);
font-size: 1.125rem;
font-weight: 400;
max-width: 75ch;
margin-right: auto;
}
.prose-response strong {
color: var(--theme-text-primary);
font-weight: 700;
}
.prose-response ul, .prose-response ol {
margin-bottom: 1.5em;
space-y: 0.5em;
}
.prose-response li {
position: relative;
padding-left: 1.5rem;
color: var(--theme-text-secondary);
}
.prose-response li::before {
content: "///";
position: absolute;
left: 0;
color: var(--color-brand-accent);
font-family: var(--font-mono);
font-size: 0.75rem;
font-weight: 700;
}
.prose-response em {
font-style: italic;
color: var(--theme-text-muted);
}
/* Blockquote for signature or special text */
.prose-response blockquote {
border-left: none;
margin: 3em 0 1em;
padding: 0;
color: var(--color-brand-accent);
font-family: 'Courier New', monospace;
font-size: 1rem;
text-transform: uppercase;
letter-spacing: 0.2em;
display: inline-block;
border-top: 1px solid rgba(221, 65, 50, 0.3);
padding-top: 2em;
}
.prose-response a {
color: var(--color-brand-accent);
text-decoration: underline;
text-underline-offset: 4px;
transition: all 0.3s;
}
.prose-response a:hover {
color: var(--theme-text-primary);
text-decoration-thickness: 2px;
}
/* Mobile adjustments */
@media (max-width: 768px) {
.prose-response h1 {
font-size: 2.5rem;
}
.prose-response p {
font-size: 1.125rem;
}
}
</style>
<script>
// ===== Custom Dropdown Logic =====
const selectContainer = document.getElementById('custom-select');
const selectTrigger = document.getElementById('select-trigger');
const selectOptions = document.getElementById('select-options');
const selectValue = document.getElementById('select-value');
const hiddenInput = document.getElementById('subject-input') as HTMLInputElement;
const selectLabel = document.getElementById('select-label');
const options = document.querySelectorAll('.option');
const arrow = document.getElementById('select-arrow');
if (selectTrigger && selectOptions && selectValue && hiddenInput && selectLabel) {
// Toggle Dropdown
selectTrigger.addEventListener('click', () => {
const isOpen = !selectOptions.classList.contains('hidden');
if (isOpen) {
closeDropdown();
} else {
openDropdown();
}
});
// Option Selection
options.forEach(option => {
option.addEventListener('click', (e) => {
const target = e.currentTarget as HTMLElement;
const value = target.dataset.value || '';
const text = target.innerText;
// Update UI
selectValue.textContent = text;
selectValue.classList.remove('text-transparent');
selectValue.classList.add('text-[var(--theme-text-primary)]');
// Update Data
hiddenInput.value = value;
// Update Label Style
selectLabel.classList.add('label-active');
selectLabel.classList.add('text-brand-accent');
closeDropdown();
});
});
// Close clicking outside
document.addEventListener('click', (e) => {
if (selectContainer && !selectContainer.contains(e.target as Node)) {
closeDropdown();
}
});
function openDropdown() {
selectOptions?.classList.remove('hidden');
// Small delay for opacity transition
requestAnimationFrame(() => {
selectOptions?.classList.remove('opacity-0', 'translate-y-2');
});
selectContainer?.classList.add('dropdown-open');
}
function closeDropdown() {
selectOptions?.classList.add('opacity-0', 'translate-y-2');
setTimeout(() => {
selectOptions?.classList.add('hidden');
}, 200);
selectContainer?.classList.remove('dropdown-open');
}
}
// ===== Toast Notification System =====
function showToast(message: string, type: 'success' | 'error' = 'error') {
const container = document.getElementById('toast-container');
if (!container) return;
const toast = document.createElement('div');
toast.className = `pointer-events-auto mb-4 p-4 border backdrop-blur-sm transform transition-all duration-300 translate-x-full opacity-0 ${
type === 'success'
? 'bg-green-500/10 border-green-500/30 text-green-400'
: 'bg-red-500/10 border-red-500/30 text-red-400'
}`;
toast.setAttribute('role', 'alert');
toast.innerHTML = `
<div class="flex items-start gap-3 max-w-sm">
<div class="flex-shrink-0">
${type === 'success'
? '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path></svg>'
: '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg>'
}
</div>
<p class="text-sm font-mono">${message}</p>
</div>
`;
container.appendChild(toast);
// Trigger animation
requestAnimationFrame(() => {
toast.classList.remove('translate-x-full', 'opacity-0');
});
// Auto-dismiss after 6 seconds
setTimeout(() => {
toast.classList.add('translate-x-full', 'opacity-0');
setTimeout(() => toast.remove(), 300);
}, 6000);
}
// ===== Modal Control Functions =====
const transmissionModal = document.getElementById('transmission-modal') as HTMLDivElement;
const loadingState = document.getElementById('loading-state') as HTMLDivElement;
const responseState = document.getElementById('response-state') as HTMLDivElement;
const closeModalBtn = document.getElementById('close-modal') as HTMLButtonElement;
function openModal() {
transmissionModal.classList.remove('pointer-events-none', 'opacity-0');
transmissionModal.classList.add('pointer-events-auto', 'opacity-100');
document.body.style.overflow = 'hidden';
}
function closeModal() {
transmissionModal.classList.add('pointer-events-none', 'opacity-0');
transmissionModal.classList.remove('pointer-events-auto', 'opacity-100');
document.body.style.overflow = '';
// Reset states after animation
setTimeout(() => {
loadingState.classList.remove('hidden');
responseState.classList.add('hidden');
responseState.classList.remove('opacity-100', 'scale-100');
responseState.classList.add('opacity-0', 'scale-95');
}, 500);
}
function showResponse() {
loadingState.classList.add('hidden');
responseState.classList.remove('hidden');
// Trigger animation after a brief delay
setTimeout(() => {
responseState.classList.remove('opacity-0', 'scale-95');
responseState.classList.add('opacity-100', 'scale-100');
}, 100);
}
// Modal close handler
if (closeModalBtn) {
closeModalBtn.addEventListener('click', closeModal);
}
// Close on click outside
if (transmissionModal) {
transmissionModal.addEventListener('click', (e) => {
const target = e.target as HTMLElement;
// Only close if response state is active and visible
// We check if the click target is the container itself (the background)
// response-state covers the whole screen when active
if (!responseState.classList.contains('hidden') &&
(target === responseState || target === transmissionModal)) {
closeModal();
}
});
}
// ===== Form Submission Handler =====
const contactForm = document.getElementById('contact-form') as HTMLFormElement;
const submitBtn = document.getElementById('submit-btn') as HTMLButtonElement;
const submitText = document.getElementById('submit-text') as HTMLSpanElement;
const responseContent = document.getElementById('response-content') as HTMLDivElement;
if (contactForm && submitBtn && submitText && responseContent && transmissionModal) {
contactForm.addEventListener('submit', async (e) => {
e.preventDefault();
// Get form data
const formData = new FormData(contactForm);
const payload = {
name: formData.get('name') as string,
email: formData.get('email') as string,
subject: formData.get('subject') as string,
message: formData.get('message') as string,
timestamp: new Date().toISOString(),
source: 'portfolio-website'
};
// Open modal with loading state
openModal();
// Disable submit button
submitBtn.disabled = true;
submitText.textContent = 'Transmitting...';
try {
// Create abort controller for timeout
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 10000); // 10 second timeout
// Make the request
const response = await fetch(import.meta.env.PUBLIC_N8N_WEBHOOK_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(payload),
signal: controller.signal
});
clearTimeout(timeoutId);
// Check for non-2xx response
if (!response.ok) {
throw new Error(`Server returned ${response.status}`);
}
// Parse response
const data = await response.json();
// Check if n8n returned an error
if (data.success === false) {
throw new Error(data.error || 'An error occurred processing your message');
}
// Success path - render markdown response
if (data.success && data.format === 'mdx' && data.message) {
try {
// Lazy-load markdown renderer only when needed (keeps initial JS lighter)
const { marked } = await import('marked');
const htmlContent = await marked.parse(data.message);
responseContent.innerHTML = htmlContent;
// Show response state with animation
showResponse();
// Reset button state
submitText.textContent = submitText.getAttribute('data-default-text') || 'Transmit Message';
submitBtn.disabled = false;
} catch (markdownError) {
console.error('Markdown parsing error:', markdownError);
// Fallback: show plain text instead of failing the whole interaction.
responseContent.textContent = String(data.message);
showResponse();
}
} else {
throw new Error('Invalid response format from server');
}
} catch (error) {
console.error('Form submission error:', error);
// Close modal
closeModal();
// Determine error message
let errorMessage = 'We couldn\'t reach the messaging system. Please try again or email me directly at nicholai@nicholai.work';
if (error instanceof Error) {
if (error.name === 'AbortError') {
errorMessage = 'Request timed out. Please try again or email me directly at nicholai@nicholai.work';
} else if (error.message && !error.message.includes('Server returned')) {
errorMessage = error.message;
}
}
// Show error toast
showToast(errorMessage, 'error');
// Update button to failure state
submitText.textContent = 'Transmission Failed';
const defaultText = submitText.getAttribute('data-default-text') || 'Transmit Message';
setTimeout(() => {
submitText.textContent = defaultText;
submitBtn.disabled = false;
}, 2000);
// Keep form data intact (don't reset)
}
});
}
</script>

View File

@ -1,71 +1,29 @@
---
import BaseLayout from '../layouts/BaseLayout.astro';
import Hero from '../components/sections/Hero.astro';
import Experience from '../components/sections/Experience.astro';
import FeaturedProject from '../components/sections/FeaturedProject.astro';
import Skills from '../components/sections/Skills.astro';
import { getEntry } from 'astro:content';
// Fetch all section content
const heroEntry = await getEntry('sections', 'hero');
const experienceEntry = await getEntry('sections', 'experience');
const skillsEntry = await getEntry('sections', 'skills');
const featuredProjectEntry = await getEntry('sections', 'featured-project');
// Extract content from entries
const heroContent = {
headlineLine1: heroEntry.data.headlineLine1 || '',
headlineLine2: heroEntry.data.headlineLine2 || '',
portfolioYear: heroEntry.data.portfolioYear || '',
location: heroEntry.data.location || '',
locationLabel: heroEntry.data.locationLabel || '',
bio: heroEntry.data.bio || '',
};
const experienceContent = {
sectionTitle: experienceEntry.data.sectionTitle || '',
sectionSubtitle: experienceEntry.data.sectionSubtitle || '',
sectionLabel: experienceEntry.data.sectionLabel || '',
description: experienceEntry.data.description || '',
entries: experienceEntry.data.entries || [],
};
const skillsContent = {
sectionTitle: skillsEntry.data.sectionTitle || '',
sectionSubtitle: skillsEntry.data.sectionSubtitle || '',
description: skillsEntry.data.description || '',
skills: skillsEntry.data.skills || [],
};
const featuredProjectContent = {
role: featuredProjectEntry.data.role || '',
client: featuredProjectEntry.data.client || '',
year: featuredProjectEntry.data.year || '',
region: featuredProjectEntry.data.region || '',
projectTitle: featuredProjectEntry.data.projectTitle || '',
projectSubtitle: featuredProjectEntry.data.projectSubtitle || '',
projectDescription: featuredProjectEntry.data.projectDescription || '',
stats: featuredProjectEntry.data.stats || [],
videoUrl: featuredProjectEntry.data.videoUrl || '',
linkUrl: featuredProjectEntry.data.linkUrl || '',
};
import { SITE_TITLE, SITE_DESCRIPTION } from '../consts';
---
<BaseLayout usePadding={false}>
<Hero {...heroContent} />
<BaseLayout>
<div class="container mx-auto px-6 py-20 max-w-4xl">
<h1 class="text-6xl font-bold mb-6">{SITE_TITLE}</h1>
<p class="text-xl text-[var(--theme-text-muted)] mb-12">{SITE_DESCRIPTION}</p>
<!-- Gradient Divider -->
<div class="w-full my-16 lg:my-24">
<div class="h-[1px] divider-gradient"></div>
<div class="grid gap-6">
<a
href="/blog"
class="border border-[var(--theme-border-strong)] px-6 py-4 hover:border-brand-accent hover:bg-brand-accent/5 transition-all"
>
<h2 class="text-2xl font-bold mb-2">Blog</h2>
<p class="text-[var(--theme-text-muted)]">Read posts and articles</p>
</a>
<a
href="/contact"
class="border border-[var(--theme-border-strong)] px-6 py-4 hover:border-brand-accent hover:bg-brand-accent/5 transition-all"
>
<h2 class="text-2xl font-bold mb-2">Contact</h2>
<p class="text-[var(--theme-text-muted)]">Get in touch</p>
</a>
</div>
</div>
<Experience {...experienceContent} />
<!-- Container Divider with accent hint -->
<div class="container mx-auto px-6 lg:px-12 my-8">
<div class="h-[1px] divider-gradient"></div>
</div>
<FeaturedProject {...featuredProjectContent} />
<Skills {...skillsContent} />
</BaseLayout>

View File

@ -1,65 +0,0 @@
{
"$schema": "https://json-schema.org/draft-07/schema#",
"description": "Template configuration for personalizing your Astro portfolio/blog",
"site": {
"title": "Your Name — Your Profession",
"description": "Your professional description here. Describe what you do, who you work with, and what makes you unique.",
"url": "https://yoursite.com",
"author": "Your Name"
},
"branding": {
"initials": "YN",
"year": "2025",
"backgroundText": "NAME",
"companyName": "Your Company"
},
"personal": {
"firstName": "Your",
"lastName": "Name",
"fullName": "Your Name",
"jobTitle": "Your Profession",
"bio": "Your professional bio goes here. Describe your expertise, what you do, and what makes you unique.",
"email": "your@email.com",
"location": "Your City, State",
"locationCountry": "Your Country",
"coordinates": "00.0000° N, 00.0000° W"
},
"social": {
"linkedin": "https://linkedin.com/in/yourprofile",
"github": "https://github.com/yourusername",
"twitter": "https://twitter.com/yourhandle",
"twitterHandle": "@yourhandle",
"website": "https://yoursite.com"
},
"seo": {
"keywords": ["Your Skill 1", "Your Skill 2", "Your Skill 3"],
"serviceTypes": ["Service 1", "Service 2", "Service 3"],
"companyUrl": "https://example.com"
},
"cloudflare": {
"projectName": "astro-portfolio-template"
},
"deployment": {
"platform": "cloudflare",
"notes": "This template is configured for Cloudflare Pages by default"
},
"_instructions": {
"usage": "Edit this file with your information, then run 'node init-template.js --config' to apply changes",
"fields": {
"site.url": "Your production URL (used for sitemaps, RSS, canonical URLs)",
"branding.initials": "2-3 character initials for navigation branding (e.g., 'JD' for John Doe)",
"branding.backgroundText": "Large decorative text in footer (usually surname or brand name)",
"personal.coordinates": "Optional. GPS coordinates for location (format: 'XX.XXXX° N, XX.XXXX° W')",
"social": "Add or remove social platforms as needed",
"cloudflare.projectName": "Cloudflare Pages project name (lowercase, hyphens allowed)"
}
}
}