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:
parent
d384abea33
commit
c1f676c733
155
CLAUDE.md
155
CLAUDE.md
@ -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
301
README.md
@ -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
503
SETUP.md
@ -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.
|
||||
@ -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...
|
||||
|
||||
260
init-template.js
260
init-template.js
@ -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);
|
||||
});
|
||||
@ -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>
|
||||
@ -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">
|
||||
< 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 >
|
||||
</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>
|
||||
)}
|
||||
@ -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>
|
||||
@ -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>
|
||||
)}
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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 };
|
||||
|
||||
@ -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.
|
||||
@ -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.
|
||||
|
||||
@ -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
|
||||

|
||||

|
||||
```
|
||||
|
||||
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!
|
||||
@ -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"
|
||||
---
|
||||
@ -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"
|
||||
---
|
||||
@ -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/"
|
||||
---
|
||||
@ -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."
|
||||
---
|
||||
@ -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"
|
||||
---
|
||||
@ -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"><</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"><</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">← 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>
|
||||
|
||||
@ -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"><</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">← 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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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)"
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user