Compare commits
5 Commits
main
...
blog-integ
| Author | SHA1 | Date | |
|---|---|---|---|
| f982a558d3 | |||
| 03dd3571a3 | |||
| 5b07fae911 | |||
| ef24c27085 | |||
| 4b1b6ec6cb |
82
.clinerules/Nextjs-Developer.md
Normal file
82
.clinerules/Nextjs-Developer.md
Normal file
@ -0,0 +1,82 @@
|
||||
---
|
||||
name: Nextjs-Developer
|
||||
description: Specializes in high-quality frontend development. Complete code delivery generalist.
|
||||
color: Automatic Color
|
||||
---
|
||||
|
||||
# Next.js 15 AI Development Assistant
|
||||
|
||||
You are a Senior Front-End Developer and expert in ReactJS, Next.js 15, JavaScript, TypeScript, HTML, CSS, and modern UI/UX frameworks (TailwindCSS, shadcn/ui, Radix). You specialize in AI SDK v5 integration and provide thoughtful, nuanced answers with brilliant reasoning.
|
||||
|
||||
## Core Responsibilities
|
||||
* Follow user requirements precisely and to the letter
|
||||
* Think step-by-step: describe your plan in detailed pseudocode first
|
||||
* Confirm approach, then write complete, working code
|
||||
* Write correct, best practice, DRY, bug-free, fully functional code
|
||||
* Prioritize readable code over performance optimization
|
||||
* Implement all requested functionality completely
|
||||
* Leave NO todos, placeholders, or missing pieces
|
||||
* Include all required imports and proper component naming
|
||||
* Be concise and minimize unnecessary prose
|
||||
|
||||
## Core Process & Tool Usage
|
||||
You must follow this strict, non-negotiable workflow for every request:
|
||||
|
||||
1. **Fetch Latest Documentation (context7):** Before generating any code or technical plans, you MUST use the `context7` tool to retrieve the latest official documentation for the technologies involved. For any Next.js API questions, specifically use the `/vercel/next.js` library. This ensures your knowledge is always current and authoritative.
|
||||
|
||||
2. **Consult Component Registry (shadcn):** If the request involves creating or modifying UI components, you MUST use the `shadcn` tool to consult the `shadcn/ui` component registry.
|
||||
* **Prioritize Existing Components:** First, identify if an existing, approved component from the registry can be used or modified. Avoid creating new components from scratch.
|
||||
* **Reference Canonical Definitions:** NEVER generate component code without first referencing its canonical definition in the registry. Your implementation must be based on these approved patterns.
|
||||
|
||||
3. **Generate Response:** Only after completing the above steps, generate your response, plan, or code, ensuring it aligns perfectly with the retrieved documentation and component standards.
|
||||
|
||||
### Failure Modes (Strict Prohibitions)
|
||||
* **NEVER** assume outdated practices from your general training data. Rely **only** on the documentation retrieved via `context7`.
|
||||
* **NEVER** create UI components without first checking and referencing the `shadcn` registry.
|
||||
* **NEVER** provide advice or code that conflicts with the official documentation.
|
||||
|
||||
## Technology Stack Focus
|
||||
* **Next.js 15**: App Router, Server Components, Server Actions
|
||||
* **AI SDK v5**: Latest patterns and integrations
|
||||
* **shadcn/ui**: Component library implementation
|
||||
* **TypeScript**: Strict typing and best practices
|
||||
* **TailwindCSS**: Utility-first styling
|
||||
* **Radix UI**: Accessible component primitives
|
||||
|
||||
## Code Implementation Rules
|
||||
|
||||
### Code Quality
|
||||
* Use early returns for better readability
|
||||
* Use descriptive variable and function names
|
||||
* Prefix event handlers with "handle" (handleClick, handleKeyDown)
|
||||
* Use const over function declarations: `const toggle = () => {}`
|
||||
* Define types when possible
|
||||
* Implement proper accessibility features (tabindex, aria-label, keyboard events)
|
||||
|
||||
### Styling Guidelines
|
||||
* Always use Tailwind classes for styling
|
||||
* Avoid CSS files or inline styles
|
||||
* Use conditional classes efficiently
|
||||
* Follow shadcn/ui patterns for component styling
|
||||
|
||||
### Next.js 15 Specific
|
||||
* Leverage App Router architecture
|
||||
* Use Server Components by default, Client Components when needed
|
||||
* Implement proper data fetching patterns
|
||||
* Follow Next.js 15 caching and optimization strategies
|
||||
|
||||
### AI SDK v5 Integration
|
||||
* Use latest AI SDK v5 patterns and APIs
|
||||
* Implement proper error handling for AI operations
|
||||
* Follow streaming and real-time response patterns
|
||||
* Integrate with Next.js Server Actions when appropriate
|
||||
|
||||
## Response Protocol
|
||||
1. If uncertain about correctness, state so explicitly
|
||||
2. If you don't know something, admit it rather than guessing
|
||||
3. Search for latest information when dealing with rapidly evolving technologies
|
||||
4. Provide explanations without unnecessary examples unless requested
|
||||
5. Stay on-point and avoid verbose explanations
|
||||
|
||||
## Knowledge Updates
|
||||
When working with Next.js 15, AI SDK v5, or other rapidly evolving technologies, search for the latest documentation and best practices to ensure accuracy and current implementation patterns.
|
||||
152
.clinerules/Nextjs-Forms-Developer.md
Normal file
152
.clinerules/Nextjs-Forms-Developer.md
Normal file
@ -0,0 +1,152 @@
|
||||
---
|
||||
name: Nextjs-Forms-Developer
|
||||
description: Complete Server Actions integration - Progressive enhancement, FormData handling, validation, error management, and cache invalidation using Next.js 15 patterns
|
||||
color: Automatic Color
|
||||
---
|
||||
|
||||
# Next.js 15 Server Actions + Form Handling Master
|
||||
|
||||
You are a Senior Full-Stack Developer and expert in Next.js 15 App Router, Server Actions, and modern form handling patterns. You specialize in building production-ready forms with progressive enhancement, comprehensive validation (client & server), error handling, and seamless user experiences using React 19 and shadcn/ui integration.
|
||||
|
||||
## Core Responsibilities
|
||||
* Follow user requirements precisely and to the letter
|
||||
* Think step-by-step: describe your form architecture plan in detailed pseudocode first
|
||||
* Confirm approach, then write complete, working Server Action + form code
|
||||
* Write correct, best practice, type-safe, progressively enhanced form code
|
||||
* Prioritize security, accessibility, user experience, and performance
|
||||
* Implement all requested functionality completely
|
||||
* Leave NO todos, placeholders, or missing pieces
|
||||
* Include all required imports, proper error handling, and validation patterns
|
||||
* Be concise and minimize unnecessary prose
|
||||
|
||||
## Core Process & Tool Usage
|
||||
You must follow this strict, non-negotiable workflow for every request:
|
||||
|
||||
1. **Fetch Latest Documentation (context7):** Before generating any code or technical plans, you MUST use the `context7` tool to retrieve the latest official documentation for the technologies involved. For any Next.js API questions, specifically use the `/vercel/next.js` library. This ensures your knowledge is always current and authoritative.
|
||||
|
||||
2. **Consult Component Registry (shadcn):** If the request involves creating or modifying UI components, you MUST use the `shadcn` tool to consult the `shadcn/ui` component registry.
|
||||
* **Prioritize Existing Components:** First, identify if an existing, approved component from the registry can be used or modified. Avoid creating new components from scratch.
|
||||
* **Reference Canonical Definitions:** NEVER generate component code without first referencing its canonical definition in the registry. Your implementation must be based on these approved patterns.
|
||||
|
||||
3. **Generate Response:** Only after completing the above steps, generate your response, plan, or code, ensuring it aligns perfectly with the retrieved documentation and component standards.
|
||||
|
||||
### Failure Modes (Strict Prohibitions)
|
||||
* **NEVER** assume outdated practices from your general training data. Rely **only** on the documentation retrieved via `context7`.
|
||||
* **NEVER** create UI components without first checking and referencing the `shadcn` registry.
|
||||
* **NEVER** provide advice or code that conflicts with the official documentation.
|
||||
|
||||
## Technology Stack Focus
|
||||
* **Next.js 15**: App Router, Server Actions, Enhanced Forms (next/form)
|
||||
* **React 19**: useActionState, useOptimistic, useFormStatus (deprecated)
|
||||
* **Server Actions**: "use server" directive, progressive enhancement
|
||||
* **shadcn/ui**: Form components, validation integration
|
||||
* **Zod**: Schema validation (client & server)
|
||||
* **TypeScript**: Strict typing for form data and Server Action responses
|
||||
|
||||
## Code Implementation Rules
|
||||
|
||||
### Server Actions Architecture
|
||||
* Use "use server" directive for inline or module-level Server Actions
|
||||
* Implement proper FormData extraction and validation
|
||||
* Handle both success and error states with proper return objects
|
||||
* Use revalidatePath and revalidateTag for cache invalidation
|
||||
* Support redirect after successful form submission
|
||||
* Ensure Server Actions work with progressive enhancement
|
||||
|
||||
### Form Validation Patterns
|
||||
* Create shared Zod schemas for client and server validation
|
||||
* Implement server-side validation as primary security layer
|
||||
* Add client-side validation for improved user experience
|
||||
* Use useActionState for form state management and error display
|
||||
* Handle field-level and form-level error messages
|
||||
* Support both synchronous and asynchronous validation
|
||||
|
||||
### Progressive Enhancement
|
||||
* Ensure forms work without JavaScript enabled
|
||||
* Use next/form for enhanced form behavior (prefetching, client-side navigation)
|
||||
* Implement proper loading states with pending indicators
|
||||
* Support keyboard navigation and screen reader accessibility
|
||||
* Handle form submission with and without client-side hydration
|
||||
* Create fallback experiences for JavaScript failures
|
||||
|
||||
### useActionState Integration
|
||||
* Replace deprecated useFormStatus with useActionState
|
||||
* Manage form state, errors, and pending states effectively
|
||||
* Handle initial state and state updates from Server Actions
|
||||
* Display validation errors and success messages appropriately
|
||||
* Support optimistic updates where beneficial
|
||||
* Implement proper form reset after successful submission
|
||||
|
||||
### Error Handling & User Experience
|
||||
* Provide clear, actionable error messages for validation failures
|
||||
* Handle server errors gracefully with user-friendly messages
|
||||
* Implement proper try/catch blocks in Server Actions
|
||||
* Use error boundaries for unexpected failures
|
||||
* Support field-level error display with proper ARIA attributes
|
||||
* Create consistent error message patterns across forms
|
||||
|
||||
### shadcn/ui Form Integration
|
||||
* Use shadcn Form components with react-hook-form integration
|
||||
* Implement proper FormField, FormItem, FormLabel patterns
|
||||
* Support controlled and uncontrolled input components
|
||||
* Use FormMessage for validation error display
|
||||
* Create reusable form patterns and custom form components
|
||||
* Support dark mode and theme customization
|
||||
|
||||
### Advanced Form Patterns
|
||||
* Handle multi-step forms with state preservation
|
||||
* Implement file upload with progress tracking and validation
|
||||
* Support dynamic form fields and conditional rendering
|
||||
* Create nested object and array field handling
|
||||
* Implement form auto-save and draft functionality
|
||||
* Handle complex form relationships and dependencies
|
||||
|
||||
### Security Best Practices
|
||||
* Always validate data server-side regardless of client validation
|
||||
* Sanitize and escape form inputs appropriately
|
||||
* Implement CSRF protection (automatic with Server Actions)
|
||||
* Use proper input validation and type checking
|
||||
* Handle sensitive data with appropriate encryption
|
||||
* Implement rate limiting for form submissions
|
||||
|
||||
### Performance Optimization
|
||||
* Use useOptimistic for immediate UI feedback
|
||||
* Implement proper form field debouncing
|
||||
* Optimize revalidation strategies for different data types
|
||||
* Use Suspense boundaries for loading states
|
||||
* Minimize bundle size with code splitting
|
||||
* Cache validation schemas and reuse across components
|
||||
|
||||
### Accessibility Standards
|
||||
* Implement proper ARIA labels and descriptions
|
||||
* Support keyboard navigation throughout forms
|
||||
* Provide clear focus indicators and management
|
||||
* Use semantic HTML form elements
|
||||
* Support screen readers with proper announcements
|
||||
* Follow WCAG 2.1 AA guidelines for form accessibility
|
||||
|
||||
### Next.js 15 Specific Features
|
||||
* Leverage Enhanced Forms (next/form) for navigation forms
|
||||
* Use unstable_after for post-submission processing
|
||||
* Implement proper static/dynamic rendering strategies
|
||||
* Support both client and server components appropriately
|
||||
* Use proper route segment configuration
|
||||
* Handle streaming and Suspense boundaries effectively
|
||||
|
||||
### Testing & Development
|
||||
* Create testable Server Actions with proper error handling
|
||||
* Mock FormData objects for unit testing
|
||||
* Test progressive enhancement scenarios
|
||||
* Implement proper development error messages
|
||||
* Support hot reload during development
|
||||
* Create reusable testing utilities for forms
|
||||
|
||||
## Response Protocol
|
||||
1. If uncertain about progressive enhancement implications, state so explicitly
|
||||
2. If you don't know a specific Server Action API, admit it rather than guessing
|
||||
3. Search for latest Next.js 15 and React 19 documentation when needed
|
||||
4. Provide implementation examples only when requested
|
||||
5. Stay focused on Server Actions and form handling over general React patterns
|
||||
|
||||
## Knowledge Updates
|
||||
When working with Next.js 15 Server Actions, React 19 form features, or modern validation patterns, search for the latest documentation and best practices to ensure implementations follow current standards, security practices, and accessibility guidelines for production-ready applications.
|
||||
166
.clinerules/Nextjs-Realtime-Developer.md
Normal file
166
.clinerules/Nextjs-Realtime-Developer.md
Normal file
@ -0,0 +1,166 @@
|
||||
---
|
||||
name: Nextjs-Realtime-Developer
|
||||
description: Specializes in developing production-ready realtime solutions in Nextjs
|
||||
color: Automatic Color
|
||||
---
|
||||
|
||||
# Next.js 15 Real-time & WebSocket Patterns Master
|
||||
|
||||
You are a Senior Full-Stack Real-time Systems Developer and expert in Next.js 15, React 19, WebSocket implementations, Server-Sent Events (SSE), and modern real-time communication patterns. You specialize in building production-ready real-time applications with optimal user experiences using WebSockets, SSE, React 19 concurrent features, optimistic updates, and shadcn/ui integration.
|
||||
|
||||
## Core Responsibilities
|
||||
* Follow user requirements precisely and to the letter
|
||||
* Think step-by-step: describe your real-time architecture plan in detailed pseudocode first
|
||||
* Confirm approach, then write complete, working real-time communication code
|
||||
* Write correct, best practice, type-safe, performant real-time patterns
|
||||
* Prioritize scalability, connection management, error handling, and user experience
|
||||
* Implement all requested functionality completely with proper fallbacks
|
||||
* Leave NO todos, placeholders, or missing pieces
|
||||
* Include all required imports, proper error handling, and connection management
|
||||
* Be concise and minimize unnecessary prose
|
||||
|
||||
## Core Process & Tool Usage
|
||||
You must follow this strict, non-negotiable workflow for every request:
|
||||
|
||||
1. **Fetch Latest Documentation (context7):** Before generating any code or technical plans, you MUST use the `context7` tool to retrieve the latest official documentation for the technologies involved. For any Next.js API questions, specifically use the `/vercel/next.js` library. This ensures your knowledge is always current and authoritative.
|
||||
|
||||
2. **Consult Component Registry (shadcn):** If the request involves creating or modifying UI components, you MUST use the `shadcn` tool to consult the `shadcn/ui` component registry.
|
||||
* **Prioritize Existing Components:** First, identify if an existing, approved component from the registry can be used or modified. Avoid creating new components from scratch.
|
||||
* **Reference Canonical Definitions:** NEVER generate component code without first referencing its canonical definition in the registry. Your implementation must be based on these approved patterns.
|
||||
|
||||
3. **Generate Response:** Only after completing the above steps, generate your response, plan, or code, ensuring it aligns perfectly with the retrieved documentation and component standards.
|
||||
|
||||
### Failure Modes (Strict Prohibitions)
|
||||
* **NEVER** assume outdated practices from your general training data. Rely **only** on the documentation retrieved via `context7`.
|
||||
* **NEVER** create UI components without first checking and referencing the `shadcn` registry.
|
||||
* **NEVER** provide advice or code that conflicts with the official documentation.## Core Process & Tool Usage
|
||||
You must follow this strict, non-negotiable workflow for every request:
|
||||
|
||||
1. **Fetch Latest Documentation (context7):** Before generating any code or technical plans, you MUST use the `context7` tool to retrieve the latest official documentation for the technologies involved. For any Next.js API questions, specifically use the `/vercel/next.js` library. This ensures your knowledge is always current and authoritative.
|
||||
|
||||
2. **Consult Component Registry (shadcn):** If the request involves creating or modifying UI components, you MUST use the `shadcn` tool to consult the `shadcn/ui` component registry.
|
||||
* **Prioritize Existing Components:** First, identify if an existing, approved component from the registry can be used or modified. Avoid creating new components from scratch.
|
||||
* **Reference Canonical Definitions:** NEVER generate component code without first referencing its canonical definition in the registry. Your implementation must be based on these approved patterns.
|
||||
|
||||
3. **Generate Response:** Only after completing the above steps, generate your response, plan, or code, ensuring it aligns perfectly with the retrieved documentation and component standards.
|
||||
|
||||
### Failure Modes (Strict Prohibitions)
|
||||
* **NEVER** assume outdated practices from your general training data. Rely **only** on the documentation retrieved via `context7`.
|
||||
* **NEVER** create UI components without first checking and referencing the `shadcn` registry.
|
||||
* **NEVER** provide advice or code that conflicts with the official documentation.
|
||||
|
||||
## Technology Stack Focus
|
||||
* **Next.js 15**: App Router, Server Actions, Enhanced Forms, unstable_after API
|
||||
* **React 19**: useOptimistic, useActionState, useTransition, Suspense streaming
|
||||
* **WebSocket Patterns**: Socket.io, native WebSockets, connection pooling
|
||||
* **Server-Sent Events (SSE)**: Streaming responses, real-time data feeds
|
||||
* **shadcn/ui**: Real-time component patterns, chat interfaces, live dashboards
|
||||
* **TypeScript**: Strict typing for real-time data flows and connection states
|
||||
|
||||
## Code Implementation Rules
|
||||
|
||||
### WebSocket Architecture Patterns
|
||||
* Use Socket.io for production WebSocket implementations with fallback support
|
||||
* Implement proper connection lifecycle management (connect, disconnect, reconnect)
|
||||
* Create connection pooling and room-based communication patterns
|
||||
* Handle both client-to-server and server-to-client real-time messaging
|
||||
* Support authentication and authorization for WebSocket connections
|
||||
* Implement proper cleanup and memory leak prevention
|
||||
|
||||
### Server-Sent Events (SSE) Implementation
|
||||
* Use SSE for unidirectional real-time data streaming (server-to-client)
|
||||
* Implement proper SSE endpoints with correct headers and streaming responses
|
||||
* Create EventSource connections with automatic reconnection logic
|
||||
* Handle connection lifecycle, heartbeats, and graceful degradation
|
||||
* Support named events and structured data payloads
|
||||
* Implement proper cleanup and connection management
|
||||
|
||||
### Next.js 15 Integration Patterns
|
||||
* Leverage App Router for both WebSocket and SSE endpoint creation
|
||||
* Use unstable_after API for post-connection cleanup and logging
|
||||
* Implement proper Server Component integration with real-time features
|
||||
* Create hybrid patterns combining Server Actions with real-time updates
|
||||
* Support both client and server component real-time patterns
|
||||
* Handle streaming and Suspense boundaries for real-time data
|
||||
|
||||
### React 19 Concurrent Features
|
||||
* Use useOptimistic for immediate UI feedback during real-time operations
|
||||
* Implement useActionState for real-time form submissions and updates
|
||||
* Leverage useTransition for managing pending states in real-time operations
|
||||
* Create smooth user experiences with optimistic updates and rollback logic
|
||||
* Handle concurrent updates and conflict resolution
|
||||
* Support progressive enhancement for real-time features
|
||||
|
||||
### Real-time Data Patterns
|
||||
* Implement proper state synchronization between client and server
|
||||
* Create optimistic update patterns with rollback on failure
|
||||
* Handle data consistency and conflict resolution strategies
|
||||
* Support both push and pull real-time data patterns
|
||||
* Implement proper caching and data invalidation strategies
|
||||
* Create efficient delta updates and data diffing
|
||||
|
||||
### Connection Management & Reliability
|
||||
* Implement automatic reconnection with exponential backoff
|
||||
* Handle connection state management and user presence tracking
|
||||
* Create proper error boundaries for connection failures
|
||||
* Support graceful degradation when real-time features fail
|
||||
* Implement connection pooling and resource optimization
|
||||
* Handle network partitions and recovery scenarios
|
||||
|
||||
### Performance Optimization
|
||||
* Minimize data payloads and optimize message serialization
|
||||
* Implement proper debouncing and throttling for high-frequency updates
|
||||
* Use connection pooling and resource sharing strategies
|
||||
* Create efficient event handling and memory management
|
||||
* Implement lazy loading and code splitting for real-time features
|
||||
* Optimize bundle size for real-time communication libraries
|
||||
|
||||
### Security & Authentication
|
||||
* Implement proper WebSocket and SSE authentication flows
|
||||
* Create secure real-time data transmission with encryption
|
||||
* Handle authorization and role-based real-time access control
|
||||
* Implement rate limiting and abuse prevention for real-time endpoints
|
||||
* Support secure connection establishment and token validation
|
||||
* Create audit trails for real-time communication events
|
||||
|
||||
### shadcn/ui Real-time Components
|
||||
* Build chat interfaces with real-time message streaming
|
||||
* Create live dashboard components with real-time data updates
|
||||
* Implement notification systems with shadcn/ui components
|
||||
* Design collaborative interfaces with presence indicators
|
||||
* Build real-time form validation and submission feedback
|
||||
* Create live data visualization components with streaming updates
|
||||
|
||||
### Error Handling & User Experience
|
||||
* Provide clear connection state indicators to users
|
||||
* Handle offline/online state changes gracefully
|
||||
* Implement proper loading states for real-time operations
|
||||
* Create fallback experiences when real-time features are unavailable
|
||||
* Display meaningful error messages for connection issues
|
||||
* Support retry mechanisms with user-friendly feedback
|
||||
|
||||
### Advanced Real-time Patterns
|
||||
* Implement operational transformation for collaborative editing
|
||||
* Create conflict-free replicated data types (CRDTs) for distributed state
|
||||
* Build real-time multiplayer game mechanics
|
||||
* Implement live document collaboration with presence awareness
|
||||
* Create real-time data synchronization across multiple clients
|
||||
* Build streaming AI response interfaces with real-time updates
|
||||
|
||||
### WebSocket vs SSE Decision Framework
|
||||
* Use WebSockets when: Bidirectional communication, low latency required, complex interactions, gaming, collaborative editing
|
||||
* Use SSE when: Unidirectional updates, live feeds, notifications, streaming data, simpler implementation needs
|
||||
* Hybrid approach: Combine both for different aspects of the same application
|
||||
* Consider fallback strategies and progressive enhancement
|
||||
* Evaluate browser support and infrastructure requirements
|
||||
* Assess bandwidth and resource consumption patterns
|
||||
|
||||
## Response Protocol
|
||||
1. If uncertain about scalability implications, state so explicitly
|
||||
2. If you don't know a specific WebSocket or SSE API, admit it rather than guessing
|
||||
3. Search for latest Next.js 15 and React 19 real-time documentation when needed
|
||||
4. Provide implementation examples only when requested
|
||||
5. Stay focused on real-time patterns over general React/Next.js features
|
||||
|
||||
## Knowledge Updates
|
||||
When working with Next.js 15 real-time features, React 19 concurrent patterns, or modern WebSocket/SSE implementations, search for the latest documentation and best practices to ensure implementations follow current standards, performance optimizations, security practices, and scalability patterns for production-ready real-time applications.
|
||||
89
README.md
89
README.md
@ -86,6 +86,95 @@ The application supports the following environment variables:
|
||||
| `NODE_ENV` | Node.js environment | `development` |
|
||||
| `PORT` | Port to run the server on | `3000` |
|
||||
|
||||
### Blog Authoring (MDX)
|
||||
|
||||
You can write posts in MDX and have them show up on `/blog`.
|
||||
|
||||
- Local posts: add `.mdx` files to `app/blog/posts/` (these are always included).
|
||||
- Optional GitHub-backed posts: if you configure the `BLOG_*` environment variables, MDX files from your GitHub repo are fetched and merged with local posts. GitHub posts override local ones on duplicate slugs.
|
||||
|
||||
Setup:
|
||||
1) Copy `.env.local.example` to `.env.local` and fill in values.
|
||||
2) For GitHub-backed content, set:
|
||||
- `BLOG_REPO=owner/repo`
|
||||
- `BLOG_PATH=path/to/mdx/folder` (relative to repo root)
|
||||
- `BLOG_BRANCH=main` (or your branch)
|
||||
- `GITHUB_TOKEN=` (only required for private repos or higher rate limits)
|
||||
|
||||
Frontmatter template (put at the top of each `.mdx` file):
|
||||
```md
|
||||
---
|
||||
title: "Post Title"
|
||||
publishedAt: "2025-01-15" # YYYY-MM-DD
|
||||
summary: "One-liner summary"
|
||||
tags: ["tag1", "tag2"]
|
||||
image: "https://your.cdn/path-or-absolute-url.jpg"
|
||||
---
|
||||
```
|
||||
|
||||
Images:
|
||||
- Prefer absolute URLs (CDN or repo raw URLs) to avoid build-time asset issues.
|
||||
- The MDX renderer handles links and images for you (see `components/mdx.tsx`).
|
||||
|
||||
How updates appear on the site:
|
||||
- Incremental Static Regeneration (ISR): GitHub responses are cached with `next: { revalidate: BLOG_REVALIDATE_SECONDS }`. New/edited posts appear automatically after the configured window (default 300s).
|
||||
- On-demand revalidation: trigger an immediate refresh via the `/api/revalidate` endpoint (see below) or configure a GitHub webhook to call it on each push.
|
||||
|
||||
### Blog Environment Variables
|
||||
|
||||
| Variable | Description | Default |
|
||||
|----------|-------------|---------|
|
||||
| `BLOG_REPO` | GitHub `owner/repo` containing your MDX files. Leave empty to use only local posts. | — |
|
||||
| `BLOG_PATH` | Path in the repo where `.mdx` files live (relative to repo root). | `app/blog/posts` |
|
||||
| `BLOG_BRANCH` | Branch name to read from. | `main` |
|
||||
| `GITHUB_TOKEN` | GitHub token. Required for private repos or higher rate limits. | — |
|
||||
| `BLOG_REVALIDATE_SECONDS` | ISR interval (seconds) for GitHub fetch cache. | `300` |
|
||||
| `BLOG_CACHE_TAG` | Cache tag used for on-demand invalidation (`revalidateTag`). | `blog-content` |
|
||||
| `REVALIDATE_SECRET` | Shared secret for the revalidate API and GitHub webhook signature. | — |
|
||||
|
||||
### On-demand Revalidation
|
||||
|
||||
An API route at `/api/revalidate` invalidates the blog listing and post pages, and also busts the cache tag used for GitHub content.
|
||||
|
||||
- GET (simple manual trigger):
|
||||
- Revalidate listing:
|
||||
```bash
|
||||
curl -sS "https://your-domain/api/revalidate?secret=REVALIDATE_SECRET"
|
||||
```
|
||||
- Revalidate a specific post (by slug):
|
||||
```bash
|
||||
curl -sS "https://your-domain/api/revalidate?secret=REVALIDATE_SECRET&slug=my-post-slug"
|
||||
```
|
||||
- Optionally revalidate additional paths:
|
||||
```bash
|
||||
curl -sS "https://your-domain/api/revalidate?secret=REVALIDATE_SECRET&path=/blog/my-post-slug"
|
||||
```
|
||||
|
||||
- POST (advanced, supports multiple slugs/paths):
|
||||
```bash
|
||||
curl -sS -X POST "https://your-domain/api/revalidate" \
|
||||
-H "x-revalidate-secret: REVALIDATE_SECRET" \
|
||||
-H "content-type: application/json" \
|
||||
-d '{
|
||||
"slugs": ["my-post-slug"],
|
||||
"paths": ["/blog", "/blog/my-post-slug"]
|
||||
}'
|
||||
```
|
||||
|
||||
- GitHub Webhook (recommended):
|
||||
1) In your repo settings, add a Webhook:
|
||||
- Payload URL: `https://your-domain/api/revalidate`
|
||||
- Content type: `application/json`
|
||||
- Secret: set to the same value as `REVALIDATE_SECRET`
|
||||
- Event: “Just the push event”
|
||||
2) On push, the webhook sends changed file paths. The API will:
|
||||
- Revalidate the `BLOG_CACHE_TAG` to refresh GitHub fetches.
|
||||
- Revalidate `/blog` and any changed post slugs under `BLOG_PATH`.
|
||||
|
||||
Security notes:
|
||||
- Do not expose `REVALIDATE_SECRET` publicly. For manual GET usage, keep the URL private.
|
||||
- For CI/CD, use the POST form with the `x-revalidate-secret` header.
|
||||
|
||||
## License
|
||||
|
||||
This project is open source, take it. I don't give a fuck. I am not your dad.
|
||||
|
||||
160
app/api/revalidate/route.ts
Normal file
160
app/api/revalidate/route.ts
Normal file
@ -0,0 +1,160 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { revalidatePath, revalidateTag } from 'next/cache'
|
||||
import crypto from 'crypto'
|
||||
|
||||
export const runtime = 'nodejs'
|
||||
|
||||
const SECRET = process.env.REVALIDATE_SECRET || ''
|
||||
const BLOG_CACHE_TAG = process.env.BLOG_CACHE_TAG || 'blog-content'
|
||||
const BLOG_PATH = (process.env.BLOG_PATH || '').replace(/^\/+|\/+$/g, '')
|
||||
|
||||
/**
|
||||
* Compare strings in a timing-safe way
|
||||
*/
|
||||
function timingSafeEqual(a: string, b: string) {
|
||||
const aBuf = Buffer.from(a)
|
||||
const bBuf = Buffer.from(b)
|
||||
if (aBuf.length !== bBuf.length) return false
|
||||
return crypto.timingSafeEqual(aBuf, bBuf)
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate GitHub's HMAC signature header (X-Hub-Signature-256)
|
||||
*/
|
||||
function verifyGitHubSignature(rawBody: string, signatureHeader: string | null) {
|
||||
if (!SECRET || !signatureHeader) return false
|
||||
const expected = `sha256=${crypto.createHmac('sha256', SECRET).update(rawBody).digest('hex')}`
|
||||
return timingSafeEqual(expected, signatureHeader)
|
||||
}
|
||||
|
||||
/**
|
||||
* Authorize request via:
|
||||
* - query param ?secret=
|
||||
* - header x-revalidate-secret
|
||||
* - GitHub webhook signature X-Hub-Signature-256
|
||||
*/
|
||||
function isAuthorized(req: NextRequest, rawBody?: string) {
|
||||
if (!SECRET) return false
|
||||
|
||||
const urlSecret = req.nextUrl.searchParams.get('secret')
|
||||
if (urlSecret && timingSafeEqual(urlSecret, SECRET)) return true
|
||||
|
||||
const headerSecret = req.headers.get('x-revalidate-secret')
|
||||
if (headerSecret && timingSafeEqual(headerSecret, SECRET)) return true
|
||||
|
||||
const ghSig = req.headers.get('x-hub-signature-256')
|
||||
if (ghSig && rawBody && verifyGitHubSignature(rawBody, ghSig)) return true
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
function deriveSlugsFromChangedFiles(files: string[], blogPath: string) {
|
||||
const normalized = blogPath ? blogPath.replace(/^\/+|\/+$/g, '') : ''
|
||||
return files
|
||||
.filter((f) => f.endsWith('.mdx'))
|
||||
.filter((f) => {
|
||||
if (!normalized) return true
|
||||
// match if file is in configured BLOG_PATH
|
||||
const fNorm = f.replace(/^\/+|\/+$/g, '')
|
||||
return fNorm === normalized || fNorm.startsWith(`${normalized}/`)
|
||||
})
|
||||
.map((f) => {
|
||||
const name = f.split('/').pop() || f
|
||||
return name.replace(/\.mdx$/i, '')
|
||||
})
|
||||
}
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
if (!isAuthorized(req)) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const paths = new Set<string>(['/blog'])
|
||||
const tag = req.nextUrl.searchParams.get('tag') || BLOG_CACHE_TAG
|
||||
|
||||
const slug = req.nextUrl.searchParams.get('slug')
|
||||
if (slug) paths.add(`/blog/${slug}`)
|
||||
|
||||
const pathParam = req.nextUrl.searchParams.get('path')
|
||||
if (pathParam) paths.add(pathParam)
|
||||
|
||||
// Revalidate cache tag (refreshes GitHub fetches using next: { tags })
|
||||
revalidateTag(tag)
|
||||
// Revalidate listing and any provided paths
|
||||
for (const p of paths) revalidatePath(p)
|
||||
|
||||
return NextResponse.json({
|
||||
revalidated: true,
|
||||
tag,
|
||||
paths: Array.from(paths),
|
||||
mode: 'GET',
|
||||
})
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
}
|
||||
|
||||
function getChangedFilesFromGitHubPush(body: unknown): string[] {
|
||||
if (!isRecord(body) || !Array.isArray((body as Record<string, unknown>).commits)) return []
|
||||
const files: string[] = []
|
||||
const commits = (body as Record<string, unknown>).commits as unknown[]
|
||||
for (const c of commits) {
|
||||
if (!isRecord(c)) continue
|
||||
for (const key of ['added', 'modified', 'removed'] as const) {
|
||||
const arr = (c as Record<string, unknown>)[key]
|
||||
if (Array.isArray(arr)) {
|
||||
for (const f of arr) {
|
||||
if (typeof f === 'string') files.push(f)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return files
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const raw = await req.text()
|
||||
|
||||
if (!isAuthorized(req, raw)) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
let body: unknown = {}
|
||||
try {
|
||||
body = raw ? JSON.parse(raw) : {}
|
||||
} catch {
|
||||
// ignore parse errors for signature validation; continue with empty body
|
||||
}
|
||||
|
||||
const paths = new Set<string>(['/blog'])
|
||||
const tag = BLOG_CACHE_TAG
|
||||
|
||||
// Direct payload usage
|
||||
if (isRecord(body) && typeof body.path === 'string') paths.add(body.path)
|
||||
if (isRecord(body) && typeof body.slug === 'string') paths.add(`/blog/${body.slug}`)
|
||||
if (isRecord(body) && Array.isArray(body.paths)) {
|
||||
for (const p of body.paths as unknown[]) if (typeof p === 'string') paths.add(p)
|
||||
}
|
||||
if (isRecord(body) && Array.isArray(body.slugs)) {
|
||||
for (const s of body.slugs as unknown[]) if (typeof s === 'string') paths.add(`/blog/${s}`)
|
||||
}
|
||||
|
||||
// GitHub webhook (push) payload
|
||||
const ghEvent = req.headers.get('x-github-event')
|
||||
if (ghEvent === 'push') {
|
||||
const changed = getChangedFilesFromGitHubPush(body)
|
||||
const slugs = deriveSlugsFromChangedFiles(changed, BLOG_PATH)
|
||||
for (const s of slugs) paths.add(`/blog/${s}`)
|
||||
}
|
||||
|
||||
revalidateTag(tag)
|
||||
for (const p of paths) revalidatePath(p)
|
||||
|
||||
return NextResponse.json({
|
||||
revalidated: true,
|
||||
tag,
|
||||
paths: Array.from(paths),
|
||||
mode: 'POST',
|
||||
})
|
||||
}
|
||||
132
app/blog/[slug]/page.tsx
Normal file
132
app/blog/[slug]/page.tsx
Normal file
@ -0,0 +1,132 @@
|
||||
import Link from 'next/link'
|
||||
import { notFound } from 'next/navigation'
|
||||
import { CustomMDX } from '@/components/mdx'
|
||||
import PostHeader from '@/components/blog/PostHeader'
|
||||
import ProgressBar from '@/components/blog/ProgressBar'
|
||||
import { getAllPosts, getReadingTime, findAdjacentPosts } from '../utils'
|
||||
import { baseUrl } from '../../sitemap'
|
||||
|
||||
export async function generateStaticParams() {
|
||||
const posts = await getAllPosts()
|
||||
|
||||
return posts.map((post) => ({
|
||||
slug: post.slug,
|
||||
}))
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }) {
|
||||
const { slug } = await params
|
||||
const post = (await getAllPosts()).find((p) => p.slug === slug)
|
||||
if (!post) {
|
||||
return {}
|
||||
}
|
||||
|
||||
const {
|
||||
title,
|
||||
publishedAt: publishedTime,
|
||||
summary: description,
|
||||
image,
|
||||
} = post.metadata
|
||||
|
||||
const ogImage = image
|
||||
? image
|
||||
: `${baseUrl}/og?title=${encodeURIComponent(title)}`
|
||||
|
||||
return {
|
||||
title,
|
||||
description,
|
||||
openGraph: {
|
||||
title,
|
||||
description,
|
||||
type: 'article',
|
||||
publishedTime,
|
||||
url: `${baseUrl}/blog/${post.slug}`,
|
||||
images: [{ url: ogImage }],
|
||||
},
|
||||
twitter: {
|
||||
card: 'summary_large_image',
|
||||
title,
|
||||
description,
|
||||
images: [ogImage],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export default async function Blog({ params }: { params: Promise<{ slug: string }> }) {
|
||||
const { slug } = await params;
|
||||
const posts = await getAllPosts()
|
||||
const post = posts.find((p) => p.slug === slug)
|
||||
|
||||
if (!post) {
|
||||
notFound()
|
||||
}
|
||||
|
||||
const reading = getReadingTime(post.content)
|
||||
const { prev, next } = findAdjacentPosts(posts, slug)
|
||||
|
||||
return (
|
||||
<>
|
||||
<ProgressBar />
|
||||
<section className="mx-auto max-w-5xl px-4 md:px-6">
|
||||
<div className="mb-2">
|
||||
<Link
|
||||
href="/blog"
|
||||
className="inline-flex items-center gap-1 rounded-md px-2 py-1 text-sm text-neutral-700 transition-colors hover:bg-black/[0.04] hover:text-neutral-900 dark:text-neutral-300 dark:hover:bg-white/5 dark:hover:text-neutral-100"
|
||||
aria-label="Back to blog"
|
||||
>
|
||||
← <span className="underline-offset-4 hover:underline">Back to blog</span>
|
||||
</Link>
|
||||
</div>
|
||||
<script
|
||||
type="application/ld+json"
|
||||
suppressHydrationWarning
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: JSON.stringify({
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'BlogPosting',
|
||||
headline: post.metadata.title,
|
||||
datePublished: post.metadata.publishedAt,
|
||||
dateModified: post.metadata.publishedAt,
|
||||
description: post.metadata.summary,
|
||||
image: post.metadata.image
|
||||
? `${baseUrl}${post.metadata.image}`
|
||||
: `/og?title=${encodeURIComponent(post.metadata.title)}`,
|
||||
url: `${baseUrl}/blog/${post.slug}`,
|
||||
author: {
|
||||
'@type': 'Person',
|
||||
name: 'My Portfolio',
|
||||
},
|
||||
}),
|
||||
}}
|
||||
/>
|
||||
<PostHeader
|
||||
title={post.metadata.title}
|
||||
publishedAt={post.metadata.publishedAt}
|
||||
readingTimeText={reading.text}
|
||||
tags={post.metadata.tags}
|
||||
summary={post.metadata.summary}
|
||||
className="mb-6"
|
||||
/>
|
||||
<article className="prose mx-auto max-w-3xl">
|
||||
<CustomMDX source={post.content} />
|
||||
</article>
|
||||
<nav className="mx-auto mt-10 flex max-w-3xl justify-between text-sm">
|
||||
{prev ? (
|
||||
<Link href={`/blog/${prev.slug}`} className="underline-offset-4 hover:underline">
|
||||
← {prev.metadata.title}
|
||||
</Link>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
{next ? (
|
||||
<Link href={`/blog/${next.slug}`} className="underline-offset-4 hover:underline">
|
||||
{next.metadata.title} →
|
||||
</Link>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
</nav>
|
||||
</section>
|
||||
</>
|
||||
)
|
||||
}
|
||||
42
app/blog/page.tsx
Normal file
42
app/blog/page.tsx
Normal file
@ -0,0 +1,42 @@
|
||||
import Link from 'next/link'
|
||||
import PostCard from '@/components/blog/PostCard'
|
||||
import { getAllPosts } from '@/app/blog/utils'
|
||||
|
||||
export const metadata = {
|
||||
title: 'Blog',
|
||||
description: 'Read my blog.',
|
||||
}
|
||||
|
||||
export default async function Page() {
|
||||
const posts = await getAllPosts()
|
||||
const hasRepo = Boolean(process.env.BLOG_REPO)
|
||||
const repo = process.env.BLOG_REPO
|
||||
const repoPath = process.env.BLOG_PATH || ''
|
||||
const repoBranch = process.env.BLOG_BRANCH || 'main'
|
||||
const sourceLabel = hasRepo
|
||||
? `Local MDX + GitHub (${repo}${repoPath ? `/${repoPath}` : ''}@${repoBranch})`
|
||||
: 'Local MDX'
|
||||
|
||||
return (
|
||||
<section className="mx-auto max-w-5xl px-4 md:px-6">
|
||||
<div className="mb-2">
|
||||
<Link
|
||||
href="/"
|
||||
className="inline-flex items-center gap-1 rounded-md px-2 py-1 text-sm text-neutral-700 transition-colors hover:bg-black/[0.04] hover:text-neutral-900 dark:text-neutral-300 dark:hover:bg-white/5 dark:hover:text-neutral-100"
|
||||
aria-label="Back to home"
|
||||
>
|
||||
← <span className="underline-offset-4 hover:underline">Back home</span>
|
||||
</Link>
|
||||
</div>
|
||||
<h1 className="font-semibold text-2xl mb-6 tracking-tighter">My Blog</h1>
|
||||
<div className="mb-4 text-xs text-neutral-600 dark:text-neutral-400">
|
||||
Content source: {sourceLabel}
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-4 sm:gap-6 md:grid-cols-2">
|
||||
{posts.map((post) => (
|
||||
<PostCard key={post.slug} post={post} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
31
app/blog/posts/spaces-vs-tabs.mdx
Normal file
31
app/blog/posts/spaces-vs-tabs.mdx
Normal file
@ -0,0 +1,31 @@
|
||||
---
|
||||
title: 'Spaces vs. Tabs: The Indentation Debate Continues'
|
||||
publishedAt: '2024-04-08'
|
||||
summary: 'Explore the enduring debate between using spaces and tabs for code indentation, and why this choice matters more than you might think.'
|
||||
---
|
||||
|
||||
The debate between using spaces and tabs for indentation in coding may seem trivial to the uninitiated, but it is a topic that continues to inspire passionate discussions among developers. This seemingly minor choice can affect code readability, maintenance, and even team dynamics.
|
||||
|
||||
Let's delve into the arguments for both sides and consider why this debate remains relevant in the software development world.
|
||||
|
||||
## The Case for Spaces
|
||||
|
||||
Advocates for using spaces argue that it ensures consistent code appearance across different editors, tools, and platforms. Because a space is a universally recognized character with a consistent width, code indented with spaces will look the same no matter where it's viewed. This consistency is crucial for maintaining readability and avoiding formatting issues when code is shared between team members or published online.
|
||||
|
||||
Additionally, some programming languages and style guides explicitly recommend spaces for indentation, suggesting a certain number of spaces (often two or four) per indentation level. Adhering to these recommendations can be essential for projects that aim for best practices in code quality and readability.
|
||||
|
||||
## The Case for Tabs
|
||||
|
||||
On the other side of the debate, proponents of tabs highlight the flexibility that tabs offer. Because the width of a tab can be adjusted in most text editors, individual developers can choose how much indentation they prefer to see, making the code more accessible and comfortable to read on a personal level. This adaptability can be particularly beneficial in teams with diverse preferences regarding code layout.
|
||||
|
||||
Tabs also have the advantage of semantic meaning. A tab is explicitly meant to represent indentation, whereas a space is used for many purposes within code. This distinction can make automated parsing and manipulation of code simpler, as tools can more easily recognize and adjust indentation levels without confusing them with spaces used for alignment.
|
||||
|
||||
## Hybrid Approaches and Team Dynamics
|
||||
|
||||
The debate often extends into discussions about hybrid approaches, where teams might use tabs for indentation and spaces for alignment within lines, attempting to combine the best of both worlds. However, such strategies require clear team agreements and disciplined adherence to coding standards to prevent formatting chaos.
|
||||
|
||||
Ultimately, the choice between spaces and tabs often comes down to team consensus and project guidelines. In environments where collaboration and code sharing are common, agreeing on a standard that everyone follows is more important than the individual preferences of spaces versus tabs. Modern development tools and linters can help enforce these standards, making the choice less about technical limitations and more about team dynamics and coding philosophy.
|
||||
|
||||
## Conclusion
|
||||
|
||||
While the spaces vs. tabs debate might not have a one-size-fits-all answer, it underscores the importance of consistency, readability, and team collaboration in software development. Whether a team chooses spaces, tabs, or a hybrid approach, the key is to make a conscious choice that serves the project's needs and to adhere to it throughout the codebase. As with many aspects of coding, communication and agreement among team members are paramount to navigating this classic programming debate.
|
||||
52
app/blog/posts/static-typing.mdx
Normal file
52
app/blog/posts/static-typing.mdx
Normal file
@ -0,0 +1,52 @@
|
||||
---
|
||||
title: 'The Power of Static Typing in Programming'
|
||||
publishedAt: '2024-04-07'
|
||||
summary: 'In the ever-evolving landscape of software development, the debate between dynamic and static typing continues to be a hot topic.'
|
||||
---
|
||||
|
||||
In the ever-evolving landscape of software development, the debate between dynamic and static typing continues to be a hot topic. While dynamic typing offers flexibility and rapid development, static typing brings its own set of powerful advantages that can significantly improve the quality and maintainability of code. In this post, we'll explore why static typing is crucial for developers, accompanied by practical examples through markdown code snippets.
|
||||
|
||||
## Improved Code Quality and Safety
|
||||
|
||||
One of the most compelling reasons to use static typing is the improvement it brings to code quality and safety. By enforcing type checks at compile time, static typing catches errors early in the development process, reducing the chances of runtime errors.
|
||||
|
||||
```ts
|
||||
function greet(name: string): string {
|
||||
return `Hello, ${name}!`
|
||||
}
|
||||
|
||||
// This will throw an error at compile time, preventing potential runtime issues.
|
||||
let message: string = greet(123)
|
||||
```
|
||||
|
||||
## Enhanced Readability and Maintainability
|
||||
|
||||
Static typing makes code more readable and maintainable. By explicitly declaring types, developers provide a clear contract of what the code does, making it easier for others (or themselves in the future) to understand and modify the codebase.
|
||||
|
||||
## Facilitates Tooling and Refactoring
|
||||
|
||||
Modern IDEs leverage static typing to offer advanced features like code completion, refactoring, and static analysis. These tools can automatically detect issues, suggest fixes, and safely refactor code, enhancing developer productivity and reducing the likelihood of introducing bugs during refactoring.
|
||||
|
||||
```csharp
|
||||
// Refactoring example: Renaming a method in C#
|
||||
public class Calculator {
|
||||
public int Add(int a, int b) {
|
||||
return a + b;
|
||||
}
|
||||
}
|
||||
|
||||
// After refactoring `Add` to `Sum`, all references are automatically updated.
|
||||
public class Calculator {
|
||||
public int Sum(int a, int b) {
|
||||
return a + b;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Performance Optimizations
|
||||
|
||||
Static typing can lead to better performance. Since types are known at compile time, compilers can optimize the generated code more effectively. This can result in faster execution times and lower resource consumption.
|
||||
|
||||
## Conclusion
|
||||
|
||||
Static typing offers numerous benefits that contribute to the development of robust, efficient, and maintainable software. By catching errors early, enhancing readability, facilitating tooling, and enabling optimizations, static typing is an invaluable asset for developers. As the software industry continues to mature, the importance of static typing in ensuring code quality and performance cannot be overstated. Whether you're working on a large-scale enterprise application or a small project, embracing static typing can lead to better software development outcomes.
|
||||
39
app/blog/posts/vim.mdx
Normal file
39
app/blog/posts/vim.mdx
Normal file
@ -0,0 +1,39 @@
|
||||
---
|
||||
title: 'Embracing Vim: The Unsung Hero of Code Editors'
|
||||
publishedAt: '2024-04-09'
|
||||
summary: 'Discover why Vim, with its steep learning curve, remains a beloved tool among developers for editing code efficiently and effectively.'
|
||||
---
|
||||
|
||||
In the world of software development, where the latest and greatest tools frequently capture the spotlight, Vim stands out as a timeless classic. Despite its age and initial complexity, Vim has managed to retain a devoted following of developers who swear by its efficiency, versatility, and power.
|
||||
|
||||
This article delves into the reasons behind Vim's enduring appeal and why it continues to be a great tool for coding in the modern era.
|
||||
|
||||
## Efficiency and Speed
|
||||
|
||||
At the heart of Vim's philosophy is the idea of minimizing keystrokes to achieve maximum efficiency.
|
||||
|
||||
Unlike other text editors where the mouse is often relied upon for navigation and text manipulation, Vim's keyboard-centric design allows developers to perform virtually all coding tasks without leaving the home row. This not only speeds up coding but also reduces the risk of repetitive strain injuries.
|
||||
|
||||
## Highly Customizable
|
||||
|
||||
Vim can be extensively customized to suit any developer's preferences and workflow. With a vibrant ecosystem of plugins and a robust scripting language, users can tailor the editor to their specific needs, whether it's programming in Python, writing in Markdown, or managing projects.
|
||||
|
||||
This level of customization ensures that Vim remains relevant and highly functional for a wide range of programming tasks and languages.
|
||||
|
||||
## Ubiquity and Portability
|
||||
|
||||
Vim is virtually everywhere. It's available on all major platforms, and because it's lightweight and terminal-based, it can be used on remote servers through SSH, making it an indispensable tool for sysadmins and developers working in a cloud-based environment.
|
||||
|
||||
The ability to use the same editor across different systems without a graphical interface is a significant advantage for those who need to maintain a consistent workflow across multiple environments.
|
||||
|
||||
## Vibrant Community
|
||||
|
||||
Despite—or perhaps because of—its learning curve, Vim has cultivated a passionate and active community. Online forums, dedicated websites, and plugins abound, offering support, advice, and improvements.
|
||||
|
||||
This community not only helps newcomers climb the steep learning curve but also continually contributes to Vim's evolution, ensuring it remains adaptable and up-to-date with the latest programming trends and technologies.
|
||||
|
||||
## Conclusion
|
||||
|
||||
Vim is not just a text editor; it's a way of approaching coding with efficiency and thoughtfulness. Its steep learning curve is a small price to pay for the speed, flexibility, and control it offers.
|
||||
|
||||
For those willing to invest the time to master its commands, Vim proves to be an invaluable tool that enhances productivity and enjoyment in coding. In an age of ever-changing development tools, the continued popularity of Vim is a testament to its enduring value and utility.
|
||||
275
app/blog/utils.ts
Normal file
275
app/blog/utils.ts
Normal file
@ -0,0 +1,275 @@
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
|
||||
export type Metadata = {
|
||||
title: string
|
||||
publishedAt: string
|
||||
summary: string
|
||||
image?: string
|
||||
tags?: string[]
|
||||
}
|
||||
|
||||
export type Post = {
|
||||
metadata: Metadata
|
||||
slug: string
|
||||
content: string
|
||||
source: 'fs' | 'github'
|
||||
}
|
||||
|
||||
function parseFrontmatter(fileContent: string) {
|
||||
const frontmatterRegex = /---\s*([\s\S]*?)\s*---/
|
||||
const match = frontmatterRegex.exec(fileContent)
|
||||
if (!match) {
|
||||
// No frontmatter, treat entire content as body with minimal metadata
|
||||
return {
|
||||
metadata: {
|
||||
title: 'Untitled',
|
||||
publishedAt: new Date().toISOString(),
|
||||
summary: '',
|
||||
} as Metadata,
|
||||
content: fileContent.trim(),
|
||||
}
|
||||
}
|
||||
|
||||
const frontMatterBlock = match[1]
|
||||
const content = fileContent.replace(frontmatterRegex, '').trim()
|
||||
const frontMatterLines = frontMatterBlock.trim().split('\n')
|
||||
const metadata: Partial<Metadata> = {}
|
||||
|
||||
frontMatterLines.forEach((line) => {
|
||||
const [rawKey, ...valueArr] = line.split(': ')
|
||||
const key = rawKey?.trim()
|
||||
if (!key) return
|
||||
let value = valueArr.join(': ').trim()
|
||||
|
||||
// Remove surrounding quotes
|
||||
value = value.replace(/^['"](.*)['"]$/, '$1')
|
||||
|
||||
// Support simple array syntax for tags: [tag1, tag2]
|
||||
if (key === 'tags') {
|
||||
const arr =
|
||||
value.startsWith('[') && value.endsWith(']')
|
||||
? value
|
||||
.slice(1, -1)
|
||||
.split(',')
|
||||
.map((v) => v.trim().replace(/^['"](.*)['"]$/, '$1'))
|
||||
.filter(Boolean)
|
||||
: value
|
||||
.split(',')
|
||||
.map((v) => v.trim())
|
||||
.filter(Boolean)
|
||||
;(metadata as Record<string, unknown>)[key] = arr
|
||||
} else {
|
||||
;(metadata as Record<string, unknown>)[key] = value
|
||||
}
|
||||
})
|
||||
|
||||
return { metadata: metadata as Metadata, content }
|
||||
}
|
||||
|
||||
// ============ Local FS provider ============
|
||||
function getMDXFiles(dir: string) {
|
||||
return fs.existsSync(dir)
|
||||
? fs.readdirSync(dir).filter((file) => path.extname(file) === '.mdx')
|
||||
: []
|
||||
}
|
||||
|
||||
function readMDXFile(filePath: string) {
|
||||
const rawContent = fs.readFileSync(filePath, 'utf-8')
|
||||
return parseFrontmatter(rawContent)
|
||||
}
|
||||
|
||||
function getMDXData(dir: string): Post[] {
|
||||
const mdxFiles = getMDXFiles(dir)
|
||||
return mdxFiles.map((file) => {
|
||||
const { metadata, content } = readMDXFile(path.join(dir, file))
|
||||
const slug = path.basename(file, path.extname(file))
|
||||
return {
|
||||
metadata,
|
||||
slug,
|
||||
content,
|
||||
source: 'fs',
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function getFSPosts(): Post[] {
|
||||
return getMDXData(path.join(process.cwd(), 'app', 'blog', 'posts'))
|
||||
}
|
||||
|
||||
// ============ GitHub provider (optional) ============
|
||||
const BLOG_REPO = process.env.BLOG_REPO // 'owner/repo'
|
||||
const BLOG_PATH = process.env.BLOG_PATH || ''
|
||||
const BLOG_BRANCH = process.env.BLOG_BRANCH || 'main'
|
||||
const GITHUB_TOKEN = process.env.GITHUB_TOKEN
|
||||
const BLOG_REVALIDATE_SECONDS = Number(process.env.BLOG_REVALIDATE_SECONDS || 300)
|
||||
const BLOG_CACHE_TAG = process.env.BLOG_CACHE_TAG || 'blog-content'
|
||||
|
||||
type GithubContentItem = {
|
||||
name: string
|
||||
path: string
|
||||
type: 'file' | 'dir'
|
||||
}
|
||||
|
||||
type GithubFileResponse = {
|
||||
content?: string
|
||||
}
|
||||
|
||||
async function githubApi<T>(url: string): Promise<T> {
|
||||
const headers: Record<string, string> = {
|
||||
Accept: 'application/vnd.github+json',
|
||||
}
|
||||
if (GITHUB_TOKEN) headers.Authorization = `Bearer ${GITHUB_TOKEN}`
|
||||
|
||||
const res = await fetch(url, {
|
||||
headers,
|
||||
next: { revalidate: BLOG_REVALIDATE_SECONDS, tags: [BLOG_CACHE_TAG] },
|
||||
})
|
||||
if (!res.ok) {
|
||||
throw new Error(`GitHub API error ${res.status} on ${url}`)
|
||||
}
|
||||
return (await res.json()) as T
|
||||
}
|
||||
|
||||
async function getGithubPosts(): Promise<Post[]> {
|
||||
if (!BLOG_REPO) return []
|
||||
const [owner, repo] = BLOG_REPO.split('/')
|
||||
const base = `https://api.github.com/repos/${owner}/${repo}/contents`
|
||||
const dir = BLOG_PATH ? `/${BLOG_PATH}` : ''
|
||||
const listUrl = `${base}${dir}?ref=${encodeURIComponent(BLOG_BRANCH)}`
|
||||
|
||||
let items: GithubContentItem[]
|
||||
try {
|
||||
items = await githubApi<GithubContentItem[]>(listUrl)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
|
||||
const mdxItems = items.filter((it) => it.type === 'file' && it.name.endsWith('.mdx'))
|
||||
|
||||
const posts: Post[] = []
|
||||
for (const item of mdxItems) {
|
||||
// Fetch file content (base64) via contents API
|
||||
const fileUrl = `${base}/${encodeURIComponent(item.path)}?ref=${encodeURIComponent(BLOG_BRANCH)}`
|
||||
try {
|
||||
const fileJson = await githubApi<GithubFileResponse>(fileUrl)
|
||||
const contentBase64: string | undefined = fileJson.content
|
||||
if (!contentBase64) continue
|
||||
const raw = Buffer.from(contentBase64, 'base64').toString('utf8')
|
||||
const { metadata, content } = parseFrontmatter(raw)
|
||||
const slug = path.basename(item.name, '.mdx')
|
||||
posts.push({
|
||||
metadata,
|
||||
slug,
|
||||
content,
|
||||
source: 'github',
|
||||
})
|
||||
} catch {
|
||||
// skip on individual file failure
|
||||
continue
|
||||
}
|
||||
}
|
||||
return posts
|
||||
}
|
||||
|
||||
// ============ Public API ============
|
||||
|
||||
// Backward-compatible local-only function (used by older imports)
|
||||
export function getBlogPosts(): Post[] {
|
||||
return getFSPosts()
|
||||
}
|
||||
|
||||
// New unified provider that merges FS with optional GitHub content
|
||||
export async function getAllPosts(): Promise<Post[]> {
|
||||
const fsPosts = getFSPosts()
|
||||
const ghPosts = await getGithubPosts().catch(() => [])
|
||||
// Merge by slug, with GitHub taking precedence on duplicates
|
||||
const map = new Map<string, Post>()
|
||||
for (const p of fsPosts) map.set(p.slug, p)
|
||||
for (const p of ghPosts) map.set(p.slug, p)
|
||||
return Array.from(map.values())
|
||||
.filter((p) => !!p.metadata?.publishedAt && !!p.metadata?.title)
|
||||
.sort((a, b) => {
|
||||
const da = new Date(a.metadata.publishedAt).getTime()
|
||||
const db = new Date(b.metadata.publishedAt).getTime()
|
||||
return db - da
|
||||
})
|
||||
}
|
||||
|
||||
export function formatDate(date: string, includeRelative = false) {
|
||||
const currentDate = new Date()
|
||||
if (!date.includes('T')) {
|
||||
date = `${date}T00:00:00`
|
||||
}
|
||||
const targetDate = new Date(date)
|
||||
|
||||
const yearsAgo = currentDate.getFullYear() - targetDate.getFullYear()
|
||||
const monthsAgo =
|
||||
currentDate.getMonth() -
|
||||
targetDate.getMonth() +
|
||||
yearsAgo * 12
|
||||
const daysAgo = Math.floor(
|
||||
(currentDate.getTime() - targetDate.getTime()) / (1000 * 60 * 60 * 24)
|
||||
)
|
||||
|
||||
let formattedDate = ''
|
||||
|
||||
if (yearsAgo > 0) {
|
||||
formattedDate = `${yearsAgo}y ago`
|
||||
} else if (monthsAgo > 0) {
|
||||
formattedDate = `${monthsAgo}mo ago`
|
||||
} else if (daysAgo > 0) {
|
||||
formattedDate = `${daysAgo}d ago`
|
||||
} else {
|
||||
formattedDate = 'Today'
|
||||
}
|
||||
|
||||
const fullDate = targetDate.toLocaleString('en-us', {
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
})
|
||||
|
||||
if (!includeRelative) {
|
||||
return fullDate
|
||||
}
|
||||
|
||||
return `${fullDate} (${formattedDate})`
|
||||
}
|
||||
|
||||
export function getReadingTime(content: string) {
|
||||
const words = (content || '')
|
||||
.trim()
|
||||
.split(/\s+/)
|
||||
.filter(Boolean).length
|
||||
const minutes = Math.max(1, Math.ceil(words / 200))
|
||||
return { minutes, text: `${minutes} min read`, words }
|
||||
}
|
||||
|
||||
export function getExcerpt(summary?: string, content?: string, maxChars = 220) {
|
||||
if (summary && summary.trim().length > 0) return summary.trim()
|
||||
if (!content) return ''
|
||||
// Get first non-empty paragraph
|
||||
const firstPara =
|
||||
content
|
||||
.split(/\n{2,}/)
|
||||
.map((s) => s.trim())
|
||||
.find((p) => p.length > 0) || ''
|
||||
const clean = firstPara
|
||||
.replace(/```[\s\S]*?```/g, '') // remove code fences
|
||||
.replace(/`[^`]*`/g, '') // remove inline code
|
||||
.replace(/[#>*_~\[\]()\-]/g, '') // remove some md tokens
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
if (clean.length <= maxChars) return clean
|
||||
return clean.slice(0, maxChars).replace(/\s+\S*$/, '') + '…'
|
||||
}
|
||||
|
||||
export function findAdjacentPosts(posts: Post[], slug: string) {
|
||||
const idx = posts.findIndex((p) => p.slug === slug)
|
||||
if (idx === -1) return { prev: undefined, next: undefined }
|
||||
// posts expected sorted desc (newest to oldest)
|
||||
const prev = idx > 0 ? posts[idx - 1] : undefined // newer
|
||||
const next = idx < posts.length - 1 ? posts[idx + 1] : undefined // older
|
||||
return { prev, next }
|
||||
}
|
||||
@ -32,6 +32,8 @@ export function AvatarMotion({
|
||||
alt={alt}
|
||||
width={size}
|
||||
height={size}
|
||||
sizes={`${size}px`}
|
||||
style={{ width: size, height: size }}
|
||||
className={cn(
|
||||
baseSizeClasses,
|
||||
"rounded-full object-cover ring-2 ring-neutral-200 shadow-lg transition-shadow duration-300 group-hover:shadow-xl dark:shadow-none dark:ring-neutral-800",
|
||||
|
||||
174
app/components/contact-modal.tsx
Normal file
174
app/components/contact-modal.tsx
Normal file
@ -0,0 +1,174 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { motion, AnimatePresence } from "motion/react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function ContactModal() {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [submitStatus, setSubmitStatus] = useState<"idle" | "success" | "error">("idle");
|
||||
|
||||
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
setIsSubmitting(true);
|
||||
const formData = new FormData(event.currentTarget);
|
||||
formData.append("access_key", "861ad586-6ce2-4a29-a967-a64fed1a431f");
|
||||
|
||||
const object = Object.fromEntries(formData);
|
||||
const json = JSON.stringify(object);
|
||||
|
||||
try {
|
||||
const response = await fetch("https://api.web3forms.com/submit", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json"
|
||||
},
|
||||
body: json
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
setSubmitStatus("success");
|
||||
setTimeout(() => {
|
||||
setIsOpen(false);
|
||||
setSubmitStatus("idle");
|
||||
}, 2000);
|
||||
} else {
|
||||
setSubmitStatus("error");
|
||||
}
|
||||
} catch (error) {
|
||||
setSubmitStatus("error");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
onClick={() => setIsOpen(true)}
|
||||
className="text-xs text-[color:var(--accent)] hover:underline transition-colors duration-200"
|
||||
>
|
||||
Email me
|
||||
</button>
|
||||
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm"
|
||||
onClick={() => setIsOpen(false)}
|
||||
>
|
||||
<motion.div
|
||||
initial={{ scale: 0.9, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
exit={{ scale: 0.9, opacity: 0 }}
|
||||
transition={{ type: "spring", stiffness: 300, damping: 30 }}
|
||||
className="relative w-full max-w-md rounded-lg shadow-2xl glass-strong glass-refract"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-semibold text-neutral-100">Contact Me</h2>
|
||||
<button
|
||||
onClick={() => setIsOpen(false)}
|
||||
className="p-1 rounded-full hover:bg-white/5 transition-colors"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="name" className="block text-sm font-medium mb-1 text-neutral-300">
|
||||
Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="name"
|
||||
name="name"
|
||||
required
|
||||
className="w-full px-3 py-2 rounded-md border border-white/10 bg-black/20 text-neutral-200 placeholder-neutral-500 focus:outline-none focus:ring-2 focus:ring-[color:var(--accent)]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="email" className="block text-sm font-medium mb-1 text-neutral-300">
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
id="email"
|
||||
name="email"
|
||||
required
|
||||
className="w-full px-3 py-2 rounded-md border border-white/10 bg-black/20 text-neutral-200 placeholder-neutral-500 focus:outline-none focus:ring-2 focus:ring-[color:var(--accent)]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="message" className="block text-sm font-medium mb-1 text-neutral-300">
|
||||
Message
|
||||
</label>
|
||||
<textarea
|
||||
id="message"
|
||||
name="message"
|
||||
required
|
||||
rows={4}
|
||||
className="w-full px-3 py-2 rounded-md border border-white/10 bg-black/20 text-neutral-200 placeholder-neutral-500 focus:outline-none focus:ring-2 focus:ring-[color:var(--accent)] resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 pt-2">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className={cn(
|
||||
"flex-1 px-4 py-2 rounded-md glass text-neutral-200 font-medium transition-colors hover:opacity-95 focus:outline-none focus:ring-2 focus:ring-[color:var(--accent)]",
|
||||
"disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
)}
|
||||
>
|
||||
{isSubmitting ? "Sending..." : "Send Message"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsOpen(false)}
|
||||
className="px-4 py-2 rounded-md border border-white/10 text-neutral-300 hover:bg-white/5 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{submitStatus === "success" && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="mt-4 p-3 glass text-neutral-200 rounded-md text-sm"
|
||||
>
|
||||
Message sent successfully! I'll get back to you soon.
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{submitStatus === "error" && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="mt-4 p-3 glass text-neutral-200 rounded-md text-sm"
|
||||
>
|
||||
Something went wrong. Please try again or email me directly.
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</>
|
||||
);
|
||||
}
|
||||
180
app/components/doom/DoomOverlay.tsx
Normal file
180
app/components/doom/DoomOverlay.tsx
Normal file
@ -0,0 +1,180 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef } from "react";
|
||||
import { useDoomOverlay } from "@/app/providers/DoomOverlayProvider";
|
||||
import { Modal } from "@/app/components/overlay/Modal";
|
||||
import { JsDosPlayer } from "./JsDosPlayer";
|
||||
|
||||
export enum DoomEngine {
|
||||
JsDos = "js-dos",
|
||||
}
|
||||
|
||||
export type DoomConfig = {
|
||||
engine: DoomEngine;
|
||||
jsdos?: { zipUrl?: string };
|
||||
};
|
||||
|
||||
export function DoomOverlay() {
|
||||
const { isOpen, close } = useDoomOverlay();
|
||||
const [selectedZipUrl, setSelectedZipUrl] = useState<string | null>(null);
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const remoteDemoUrl = "https://v8.js-dos.com/v7/build/doom.jsdos";
|
||||
|
||||
const handleRemoteLoad = async () => {
|
||||
try {
|
||||
setLoadError(null);
|
||||
setSelectedZipUrl(remoteDemoUrl);
|
||||
} catch (error) {
|
||||
console.error("Failed to load remote demo:", error);
|
||||
setLoadError("Failed to load remote demo. Please try selecting a local file.");
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileSelect = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (file && file.name.endsWith(".jsdos")) {
|
||||
const url = URL.createObjectURL(file);
|
||||
setSelectedZipUrl(url);
|
||||
setLoadError(null);
|
||||
} else {
|
||||
setLoadError("Please select a valid .jsdos file");
|
||||
}
|
||||
};
|
||||
|
||||
const handleDrop = (event: React.DragEvent<HTMLDivElement>) => {
|
||||
event.preventDefault();
|
||||
const file = event.dataTransfer.files[0];
|
||||
if (file && file.name.endsWith(".jsdos")) {
|
||||
const url = URL.createObjectURL(file);
|
||||
setSelectedZipUrl(url);
|
||||
setLoadError(null);
|
||||
} else {
|
||||
setLoadError("Please drop a valid .jsdos file");
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragOver = (event: React.DragEvent<HTMLDivElement>) => {
|
||||
event.preventDefault();
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
setSelectedZipUrl(null);
|
||||
setLoadError(null);
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = "";
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal open={isOpen} onClose={close} title="Doom Emulator">
|
||||
<div className="p-6">
|
||||
{!selectedZipUrl ? (
|
||||
<div className="space-y-6">
|
||||
<div className="text-center">
|
||||
<h3 className="text-xl font-semibold text-neutral-100 mb-2">
|
||||
Running on a Potato
|
||||
</h3>
|
||||
<p className="text-neutral-400 mb-6">
|
||||
Choose how you'd like to run Doom:
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<button
|
||||
onClick={handleRemoteLoad}
|
||||
className="w-full px-4 py-3 rounded-lg glass text-neutral-200 font-medium transition-colors hover:opacity-95 focus:outline-none focus:ring-2 focus:ring-[color:var(--accent)]"
|
||||
>
|
||||
Load Demo from Internet
|
||||
</button>
|
||||
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<div className="w-full border-t border-white/20"></div>
|
||||
</div>
|
||||
<div className="relative flex justify-center text-sm">
|
||||
<span className="px-4 bg-transparent text-neutral-400">or</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
onDrop={handleDrop}
|
||||
onDragOver={handleDragOver}
|
||||
className="border-2 border-dashed border-white/20 rounded-lg p-8 text-center hover:border-white/30 transition-colors"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div className="text-neutral-400">
|
||||
<p className="mb-2">Drag and drop a .jsdos file here</p>
|
||||
<p className="text-sm">or</p>
|
||||
</div>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".jsdos"
|
||||
onChange={handleFileSelect}
|
||||
className="hidden"
|
||||
id="file-input"
|
||||
/>
|
||||
<label
|
||||
htmlFor="file-input"
|
||||
className="inline-block px-4 py-2 rounded-lg glass text-neutral-200 font-medium cursor-pointer transition-colors hover:opacity-95"
|
||||
>
|
||||
Browse Files
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loadError && (
|
||||
<div className="p-4 glass text-red-400 rounded-lg text-sm">
|
||||
{loadError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="text-xs text-neutral-500 space-y-1">
|
||||
<p>
|
||||
You can create .jsdos files using the{" "}
|
||||
<a
|
||||
href="https://js-dos.com/tools"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-[color:var(--accent)] hover:underline"
|
||||
>
|
||||
js-dos tools
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
<p>
|
||||
The demo requires internet connection and may take a moment to load.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="text-sm text-neutral-400">
|
||||
{selectedZipUrl === remoteDemoUrl
|
||||
? "Running demo from internet"
|
||||
: "Running local file"}
|
||||
</div>
|
||||
<button
|
||||
onClick={handleReset}
|
||||
className="px-3 py-1 text-sm rounded-md border border-white/10 text-neutral-300 hover:bg-white/5 transition-colors"
|
||||
>
|
||||
Change Source
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<JsDosPlayer zipUrl={selectedZipUrl} className="w-full" />
|
||||
|
||||
<div className="text-xs text-neutral-500">
|
||||
<p>Use keyboard controls to play. ESC to exit fullscreen mode.</p>
|
||||
<p>Close this modal to stop the emulator.</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
129
app/components/doom/JsDosPlayer.tsx
Normal file
129
app/components/doom/JsDosPlayer.tsx
Normal file
@ -0,0 +1,129 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
export type JsDosPlayerProps = {
|
||||
zipUrl?: string;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export type JsDosHandle = {
|
||||
stop: () => void;
|
||||
};
|
||||
|
||||
interface DosInstance {
|
||||
stop(): void;
|
||||
}
|
||||
|
||||
export function JsDosPlayer({ zipUrl, className = "" }: JsDosPlayerProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const dosInstanceRef = useRef<DosInstance | null>(null);
|
||||
|
||||
const ensureJsDosCss = () => {
|
||||
if (typeof document === "undefined") return;
|
||||
if (!document.querySelector('link[data-jsdos]')) {
|
||||
const link = document.createElement("link");
|
||||
link.rel = "stylesheet";
|
||||
link.href = "https://v8.js-dos.com/v7/js-dos.css";
|
||||
link.setAttribute("data-jsdos", "true");
|
||||
document.head.appendChild(link);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current || !zipUrl) return;
|
||||
|
||||
let isMounted = true;
|
||||
|
||||
const loadEmulator = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
// Dynamically import js-dos at runtime (CSS injected via CDN)
|
||||
ensureJsDosCss();
|
||||
const jsDosModule = await import("js-dos");
|
||||
const { Dos } = jsDosModule;
|
||||
|
||||
if (!isMounted || !containerRef.current) return;
|
||||
|
||||
// Create DOS instance
|
||||
const dos = Dos(containerRef.current);
|
||||
dosInstanceRef.current = dos;
|
||||
|
||||
// Run the provided archive
|
||||
await dos.run(zipUrl);
|
||||
|
||||
if (isMounted) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to load js-dos:", err);
|
||||
if (isMounted) {
|
||||
setError(err instanceof Error ? err.message : "Failed to load emulator");
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
loadEmulator();
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
// Cleanup DOS instance
|
||||
if (dosInstanceRef.current) {
|
||||
try {
|
||||
dosInstanceRef.current.stop();
|
||||
} catch (err) {
|
||||
console.error("Error stopping DOS instance:", err);
|
||||
}
|
||||
dosInstanceRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [zipUrl]);
|
||||
|
||||
const handleStop = () => {
|
||||
if (dosInstanceRef.current) {
|
||||
try {
|
||||
dosInstanceRef.current.stop();
|
||||
dosInstanceRef.current = null;
|
||||
} catch (err) {
|
||||
console.error("Error stopping DOS instance:", err);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (!zipUrl) {
|
||||
return (
|
||||
<div className={`flex items-center justify-center p-8 text-neutral-400 ${className}`}>
|
||||
<p>No archive URL provided</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className={`flex flex-col items-center justify-center p-8 text-neutral-400 ${className}`}>
|
||||
<p className="text-red-400 mb-4">Error: {error}</p>
|
||||
<p className="text-sm">Please check the archive URL or try a different file.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`relative ${className}`}>
|
||||
{isLoading && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black/50 z-10">
|
||||
<div className="text-neutral-200">Loading emulator...</div>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="w-full h-full min-h-[400px] bg-black rounded"
|
||||
style={{ minHeight: "400px" }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -12,6 +12,7 @@ export function DotBackground({ className }: { className?: string }) {
|
||||
"[background-size:28px_28px]",
|
||||
"[background-image:radial-gradient(var(--dot-color)_1px,transparent_1px)]",
|
||||
"dark:[background-image:radial-gradient(#404040_1px,transparent_1px)]",
|
||||
"opacity-40 dark:opacity-20",
|
||||
)}
|
||||
/>
|
||||
{/* Radial gradient for the container to give a faded look */}
|
||||
|
||||
113
app/components/error-boundary.tsx
Normal file
113
app/components/error-boundary.tsx
Normal file
@ -0,0 +1,113 @@
|
||||
"use client";
|
||||
|
||||
import React, { Component, ErrorInfo, ReactNode } from "react";
|
||||
import { motion } from "motion/react";
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
fallback?: ReactNode;
|
||||
}
|
||||
|
||||
interface State {
|
||||
hasError: boolean;
|
||||
error?: Error;
|
||||
}
|
||||
|
||||
export class ErrorBoundary extends Component<Props, State> {
|
||||
public state: State = {
|
||||
hasError: false,
|
||||
};
|
||||
|
||||
public static getDerivedStateFromError(error: Error): State {
|
||||
return { hasError: true, error };
|
||||
}
|
||||
|
||||
public componentDidCatch(error: Error, errorInfo: ErrorInfo) {
|
||||
console.error("Uncaught error:", error, errorInfo);
|
||||
}
|
||||
|
||||
private handleReset = () => {
|
||||
this.setState({ hasError: false, error: undefined });
|
||||
};
|
||||
|
||||
public render() {
|
||||
if (this.state.hasError) {
|
||||
if (this.props.fallback) {
|
||||
return this.props.fallback;
|
||||
}
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className="flex min-h-screen items-center justify-center p-4"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
>
|
||||
<div className="text-center space-y-4 max-w-md">
|
||||
<motion.div
|
||||
initial={{ scale: 0.8 }}
|
||||
animate={{ scale: 1 }}
|
||||
transition={{ delay: 0.2, type: "spring", stiffness: 200 }}
|
||||
>
|
||||
<svg
|
||||
className="w-16 h-16 mx-auto text-red-500"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
|
||||
/>
|
||||
</svg>
|
||||
</motion.div>
|
||||
|
||||
<motion.h2
|
||||
className="text-xl font-semibold text-neutral-900 dark:text-neutral-100"
|
||||
initial={{ y: 20, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
transition={{ delay: 0.3 }}
|
||||
>
|
||||
Something went wrong
|
||||
</motion.h2>
|
||||
|
||||
<motion.p
|
||||
className="text-neutral-600 dark:text-neutral-400"
|
||||
initial={{ y: 20, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
transition={{ delay: 0.4 }}
|
||||
>
|
||||
{this.state.error?.message || "An unexpected error occurred"}
|
||||
</motion.p>
|
||||
|
||||
<motion.div
|
||||
initial={{ y: 20, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
transition={{ delay: 0.5 }}
|
||||
className="space-y-2"
|
||||
>
|
||||
<button
|
||||
onClick={this.handleReset}
|
||||
className="px-4 py-2 bg-neutral-900 dark:bg-neutral-100 text-neutral-100 dark:text-neutral-900 rounded-md hover:bg-neutral-800 dark:hover:bg-neutral-200 transition-colors"
|
||||
>
|
||||
Try again
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
className="px-4 py-2 text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-neutral-100 transition-colors"
|
||||
>
|
||||
Reload page
|
||||
</button>
|
||||
</motion.div>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
52
app/components/footer.tsx
Normal file
52
app/components/footer.tsx
Normal file
@ -0,0 +1,52 @@
|
||||
function ArrowIcon() {
|
||||
return (
|
||||
<svg
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 12 12"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M2.07102 11.3494L0.963068 10.2415L9.2017 1.98864H2.83807L2.85227 0.454545H11.8438V9.46023H10.2955L10.3097 3.09659L2.07102 11.3494Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Footer() {
|
||||
return (
|
||||
<footer className="mt-16 border-t border-black/5 dark:border-white/10">
|
||||
<div className="mx-auto max-w-5xl px-4 md:px-6">
|
||||
<ul className="font-sm mt-6 flex flex-row flex-wrap gap-4 text-neutral-600 dark:text-neutral-300">
|
||||
<li>
|
||||
<a
|
||||
className="flex items-center transition-all hover:text-neutral-800 dark:hover:text-neutral-100"
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
href="/rss"
|
||||
>
|
||||
<ArrowIcon />
|
||||
<p className="ml-2 h-7">rss</p>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
className="flex items-center transition-all hover:text-neutral-800 dark:hover:text-neutral-100"
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
href="https://github.com/vercel/next.js"
|
||||
>
|
||||
<ArrowIcon />
|
||||
<p className="ml-2 h-7">github</p>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
<p className="mt-6 text-neutral-600 dark:text-neutral-300">
|
||||
© {new Date().getFullYear()} MIT Licensed
|
||||
</p>
|
||||
</div>
|
||||
</footer>
|
||||
)
|
||||
}
|
||||
46
app/components/loading-spinner.tsx
Normal file
46
app/components/loading-spinner.tsx
Normal file
@ -0,0 +1,46 @@
|
||||
"use client";
|
||||
|
||||
import { motion } from "motion/react";
|
||||
|
||||
interface LoadingSpinnerProps {
|
||||
size?: "sm" | "md" | "lg";
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function LoadingSpinner({ size = "md", className = "" }: LoadingSpinnerProps) {
|
||||
const sizeClasses = {
|
||||
sm: "w-4 h-4",
|
||||
md: "w-8 h-8",
|
||||
lg: "w-12 h-12",
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className={`inline-block ${sizeClasses[size]} ${className}`}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
>
|
||||
<svg
|
||||
className="animate-spin text-neutral-900 dark:text-neutral-100"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
/>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
/>
|
||||
</svg>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
109
app/components/mdx.tsx
Normal file
109
app/components/mdx.tsx
Normal file
@ -0,0 +1,109 @@
|
||||
import Link from 'next/link'
|
||||
import Image from 'next/image'
|
||||
import { MDXRemote } from 'next-mdx-remote/rsc'
|
||||
import { highlight } from 'sugar-high'
|
||||
import React from 'react'
|
||||
|
||||
function Table({ data }) {
|
||||
let headers = data.headers.map((header, index) => (
|
||||
<th key={index}>{header}</th>
|
||||
))
|
||||
let rows = data.rows.map((row, index) => (
|
||||
<tr key={index}>
|
||||
{row.map((cell, cellIndex) => (
|
||||
<td key={cellIndex}>{cell}</td>
|
||||
))}
|
||||
</tr>
|
||||
))
|
||||
|
||||
return (
|
||||
<table>
|
||||
<thead>
|
||||
<tr>{headers}</tr>
|
||||
</thead>
|
||||
<tbody>{rows}</tbody>
|
||||
</table>
|
||||
)
|
||||
}
|
||||
|
||||
function CustomLink(props) {
|
||||
let href = props.href
|
||||
|
||||
if (href.startsWith('/')) {
|
||||
return (
|
||||
<Link href={href} {...props}>
|
||||
{props.children}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
if (href.startsWith('#')) {
|
||||
return <a {...props} />
|
||||
}
|
||||
|
||||
return <a target="_blank" rel="noopener noreferrer" {...props} />
|
||||
}
|
||||
|
||||
function RoundedImage(props) {
|
||||
return <Image alt={props.alt} className="rounded-lg" {...props} />
|
||||
}
|
||||
|
||||
function Code({ children, ...props }) {
|
||||
let codeHTML = highlight(children)
|
||||
return <code dangerouslySetInnerHTML={{ __html: codeHTML }} {...props} />
|
||||
}
|
||||
|
||||
function slugify(str) {
|
||||
return str
|
||||
.toString()
|
||||
.toLowerCase()
|
||||
.trim() // Remove whitespace from both ends of a string
|
||||
.replace(/\s+/g, '-') // Replace spaces with -
|
||||
.replace(/&/g, '-and-') // Replace & with 'and'
|
||||
.replace(/[^\w\-]+/g, '') // Remove all non-word characters except for -
|
||||
.replace(/\-\-+/g, '-') // Replace multiple - with single -
|
||||
}
|
||||
|
||||
function createHeading(level) {
|
||||
const Heading = ({ children }) => {
|
||||
let slug = slugify(children)
|
||||
return React.createElement(
|
||||
`h${level}`,
|
||||
{ id: slug },
|
||||
[
|
||||
React.createElement('a', {
|
||||
href: `#${slug}`,
|
||||
key: `link-${slug}`,
|
||||
className: 'anchor',
|
||||
}),
|
||||
],
|
||||
children
|
||||
)
|
||||
}
|
||||
|
||||
Heading.displayName = `Heading${level}`
|
||||
|
||||
return Heading
|
||||
}
|
||||
|
||||
let components = {
|
||||
h1: createHeading(1),
|
||||
h2: createHeading(2),
|
||||
h3: createHeading(3),
|
||||
h4: createHeading(4),
|
||||
h5: createHeading(5),
|
||||
h6: createHeading(6),
|
||||
Image: RoundedImage,
|
||||
a: CustomLink,
|
||||
code: Code,
|
||||
Table,
|
||||
}
|
||||
|
||||
export function CustomMDX(props) {
|
||||
return (
|
||||
<MDXRemote
|
||||
{...props}
|
||||
components={{ ...components, ...(props.components || {}) }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
77
app/components/nav.tsx
Normal file
77
app/components/nav.tsx
Normal file
@ -0,0 +1,77 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { usePathname } from 'next/navigation'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
type NavItem = { href: string; label: string }
|
||||
|
||||
const mainNav: NavItem[] = [
|
||||
{ href: '/', label: 'Home' },
|
||||
{ href: '/blog', label: 'Blog' },
|
||||
{ href: '/#about', label: 'About' },
|
||||
{ href: '/#contact', label: 'Contact' },
|
||||
]
|
||||
|
||||
function NavLink({ href, label }: NavItem) {
|
||||
const pathname = usePathname()
|
||||
const isActive =
|
||||
href === '/'
|
||||
? pathname === '/'
|
||||
: pathname.startsWith(href.replace('/#', '/'))
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
className={cn(
|
||||
'px-2 py-1 text-sm rounded-md transition-colors text-neutral-800 dark:text-neutral-200',
|
||||
'hover:bg-black/[0.04] dark:hover:bg-white/5',
|
||||
isActive && 'text-foreground underline underline-offset-4'
|
||||
)}
|
||||
aria-current={isActive ? 'page' : undefined}
|
||||
>
|
||||
{label}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
export function Navbar() {
|
||||
return (
|
||||
<header className="sticky top-0 z-40 w-full border-b border-black/5 dark:border-white/10 bg-background/70 backdrop-blur">
|
||||
<div className="mx-auto max-w-5xl px-4 md:px-6 h-14 flex items-center justify-between">
|
||||
<div className="font-semibold tracking-tight">
|
||||
<Link
|
||||
href="/"
|
||||
className="px-2 py-1 rounded-md text-neutral-800 dark:text-neutral-200 hover:bg-black/[0.04] dark:hover:bg-white/5"
|
||||
aria-label="Home"
|
||||
>
|
||||
N
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<nav className="flex items-center gap-1" aria-label="Primary">
|
||||
{mainNav.map((item) => (
|
||||
<NavLink key={item.href} {...item} />
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="flex items-center gap-2" aria-label="Utilities">
|
||||
<Link
|
||||
href="/rss"
|
||||
className="px-2 py-1 text-sm rounded-md text-neutral-800 dark:text-neutral-200 hover:bg-black/[0.04] dark:hover:bg-white/5"
|
||||
>
|
||||
RSS
|
||||
</Link>
|
||||
<a
|
||||
href="https://github.com/vercel/next.js"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="px-2 py-1 text-sm rounded-md text-neutral-800 dark:text-neutral-200 hover:bg-black/[0.04] dark:hover:bg-white/5"
|
||||
>
|
||||
GitHub
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
118
app/components/overlay/Modal.tsx
Normal file
118
app/components/overlay/Modal.tsx
Normal file
@ -0,0 +1,118 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import { motion, AnimatePresence } from "motion/react";
|
||||
|
||||
interface ModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
children: React.ReactNode;
|
||||
title?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function Modal({ open, onClose, children, title, className = "" }: ModalProps) {
|
||||
const modalRef = useRef<HTMLDivElement>(null);
|
||||
const previousFocusRef = useRef<HTMLElement | null>(null);
|
||||
|
||||
// Lock body scroll when modal is open
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
document.body.style.overflow = "hidden";
|
||||
// Store current focus
|
||||
previousFocusRef.current = document.activeElement as HTMLElement;
|
||||
} else {
|
||||
document.body.style.overflow = "";
|
||||
// Restore focus when modal closes
|
||||
if (previousFocusRef.current) {
|
||||
previousFocusRef.current.focus();
|
||||
}
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.body.style.overflow = "";
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
// Focus trap and ESC key handling
|
||||
useEffect(() => {
|
||||
if (!open || !modalRef.current) return;
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") {
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === "Tab") {
|
||||
const modal = modalRef.current;
|
||||
if (!modal) return;
|
||||
|
||||
const focusableElements = modal.querySelectorAll(
|
||||
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
|
||||
);
|
||||
const firstElement = focusableElements[0] as HTMLElement;
|
||||
const lastElement = focusableElements[focusableElements.length - 1] as HTMLElement;
|
||||
|
||||
if (event.shiftKey) {
|
||||
if (document.activeElement === firstElement) {
|
||||
event.preventDefault();
|
||||
lastElement?.focus();
|
||||
}
|
||||
} else {
|
||||
if (document.activeElement === lastElement) {
|
||||
event.preventDefault();
|
||||
firstElement?.focus();
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||
}, [open, onClose]);
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 z-[9999] flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
>
|
||||
<motion.div
|
||||
ref={modalRef}
|
||||
initial={{ scale: 0.9, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
exit={{ scale: 0.9, opacity: 0 }}
|
||||
transition={{ type: "spring", stiffness: 300, damping: 30 }}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
className={`relative w-full max-w-4xl max-h-[90vh] rounded-lg shadow-2xl glass-strong glass-refract overflow-hidden ${className}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{title && (
|
||||
<div className="flex items-center justify-between p-4 border-b border-white/10">
|
||||
<h2 className="text-lg font-semibold text-neutral-100">{title}</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1 rounded-full hover:bg-white/5 transition-colors"
|
||||
aria-label="Close modal"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="overflow-auto max-h-[calc(90vh-4rem)]">
|
||||
{children}
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
36
app/components/posts.tsx
Normal file
36
app/components/posts.tsx
Normal file
@ -0,0 +1,36 @@
|
||||
import Link from 'next/link'
|
||||
import { formatDate, getBlogPosts } from 'app/blog/utils'
|
||||
|
||||
export function BlogPosts() {
|
||||
let allBlogs = getBlogPosts()
|
||||
|
||||
return (
|
||||
<div>
|
||||
{allBlogs
|
||||
.sort((a, b) => {
|
||||
if (
|
||||
new Date(a.metadata.publishedAt) > new Date(b.metadata.publishedAt)
|
||||
) {
|
||||
return -1
|
||||
}
|
||||
return 1
|
||||
})
|
||||
.map((post) => (
|
||||
<Link
|
||||
key={post.slug}
|
||||
className="flex flex-col space-y-1 mb-4"
|
||||
href={`/blog/${post.slug}`}
|
||||
>
|
||||
<div className="w-full flex flex-col md:flex-row space-x-0 md:space-x-2">
|
||||
<p className="text-neutral-600 dark:text-neutral-400 w-[100px] tabular-nums">
|
||||
{formatDate(post.metadata.publishedAt, false)}
|
||||
</p>
|
||||
<p className="text-neutral-900 dark:text-neutral-100 tracking-tight">
|
||||
{post.metadata.title}
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
115
app/components/sidebar-menu.tsx
Normal file
115
app/components/sidebar-menu.tsx
Normal file
@ -0,0 +1,115 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useScrollContext } from '@/app/providers/LenisProvider'
|
||||
import { useDoomOverlay } from '@/app/providers/DoomOverlayProvider'
|
||||
import { useWhiteboard } from '@/app/providers/WhiteboardProvider'
|
||||
|
||||
type Item = { href: string; label: string }
|
||||
|
||||
const items: Item[] = [
|
||||
{ href: '#about', label: 'About' },
|
||||
{ href: '#writing', label: 'Writing' },
|
||||
{ href: '#contact', label: 'Contact' },
|
||||
]
|
||||
|
||||
export default function SidebarMenu() {
|
||||
const [active, setActive] = useState<string>('')
|
||||
const { lenis } = useScrollContext()
|
||||
const { open: openDoom } = useDoomOverlay()
|
||||
const { open: openWhiteboard } = useWhiteboard()
|
||||
|
||||
useEffect(() => {
|
||||
const observers: IntersectionObserver[] = []
|
||||
|
||||
const onIntersect: IntersectionObserverCallback = (entries) => {
|
||||
entries.forEach((entry) => {
|
||||
if (entry.isIntersecting) {
|
||||
setActive(`#${entry.target.id}`)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const opts: IntersectionObserverInit = { rootMargin: '-30% 0px -60% 0px', threshold: 0.1 }
|
||||
const observer = new IntersectionObserver(onIntersect, opts)
|
||||
|
||||
items.forEach((it) => {
|
||||
const id = it.href.replace('#', '')
|
||||
const el = document.getElementById(id)
|
||||
if (el) observer.observe(el)
|
||||
})
|
||||
|
||||
observers.push(observer)
|
||||
|
||||
return () => {
|
||||
observers.forEach((o) => o.disconnect())
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<aside className="sticky top-24 h-fit">
|
||||
<p className="mb-2 text-xs font-medium uppercase tracking-wider text-neutral-800 dark:text-neutral-300">
|
||||
Menu
|
||||
</p>
|
||||
<nav aria-label="Section menu" className="flex flex-col gap-1">
|
||||
{items.map((it) => {
|
||||
const isActive = active === it.href
|
||||
return (
|
||||
<Link
|
||||
key={it.href}
|
||||
href={`/${it.href}`}
|
||||
onClick={(e) => {
|
||||
const id = it.href.replace('#', '')
|
||||
const el = document.getElementById(id)
|
||||
if (el && lenis) {
|
||||
e.preventDefault()
|
||||
lenis.scrollTo(el, { offset: -80 })
|
||||
try { history.replaceState(null, '', `/${it.href}`) } catch {}
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
'px-3 py-2 text-sm rounded-md transition-colors text-neutral-800 dark:text-neutral-200',
|
||||
'hover:bg-black/[0.04] dark:hover:bg-white/5',
|
||||
isActive && 'bg-black/[0.06] dark:bg-white/10'
|
||||
)}
|
||||
aria-current={isActive ? 'true' : undefined}
|
||||
>
|
||||
{it.label}
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
|
||||
<button
|
||||
onClick={(e) => {
|
||||
// Prevent the opening click from bubbling to the modal backdrop and instantly closing it
|
||||
e.stopPropagation();
|
||||
openDoom();
|
||||
}}
|
||||
className={cn(
|
||||
'px-3 py-2 text-sm rounded-md transition-colors text-left w-full',
|
||||
'hover:bg-black/[0.04] dark:hover:bg-white/5',
|
||||
'text-neutral-900 dark:text-neutral-100'
|
||||
)}
|
||||
>
|
||||
running on a potato
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
openWhiteboard();
|
||||
}}
|
||||
className={cn(
|
||||
'px-3 py-2 text-sm rounded-md transition-colors text-left w-full',
|
||||
'hover:bg-black/[0.04] dark:hover:bg-white/5',
|
||||
'text-neutral-900 dark:text-neutral-100'
|
||||
)}
|
||||
aria-label="Open whiteboard"
|
||||
>
|
||||
Whiteboard
|
||||
</button>
|
||||
</nav>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
190
app/components/whiteboard/WhiteboardCanvas.tsx
Normal file
190
app/components/whiteboard/WhiteboardCanvas.tsx
Normal file
@ -0,0 +1,190 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useRef, useState, useCallback } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type Point = { x: number; y: number };
|
||||
|
||||
const WhiteboardCanvas = () => {
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||
const ctxRef = useRef<CanvasRenderingContext2D | null>(null);
|
||||
const [isDrawing, setIsDrawing] = useState(false);
|
||||
const [lastPoint, setLastPoint] = useState<Point | null>(null);
|
||||
const [strokeColor, setStrokeColor] = useState<string>("#111111");
|
||||
const [strokeWidth, setStrokeWidth] = useState<number>(3);
|
||||
|
||||
// Setup canvas with proper DPR scaling
|
||||
const resizeCanvas = useCallback(() => {
|
||||
const canvas = canvasRef.current;
|
||||
const container = containerRef.current;
|
||||
if (!canvas || !container) return;
|
||||
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const rect = container.getBoundingClientRect();
|
||||
|
||||
canvas.width = Math.max(320, Math.floor(rect.width * dpr));
|
||||
canvas.height = Math.max(320, Math.floor((rect.height - 60) * dpr)); // minus controls height
|
||||
canvas.style.width = `${Math.floor(canvas.width / dpr)}px`;
|
||||
canvas.style.height = `${Math.floor(canvas.height / dpr)}px`;
|
||||
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
|
||||
ctxRef.current = ctx;
|
||||
|
||||
// When resizing, keep existing content by redrawing the bitmap:
|
||||
// Create a temp bitmap from existing content before resizing (if any).
|
||||
// Note: For simplicity, we won't preserve content across resizes in this minimal version.
|
||||
// Initialize canvas background as transparent. Users can export PNG.
|
||||
ctx.lineCap = "round";
|
||||
ctx.lineJoin = "round";
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
resizeCanvas();
|
||||
const onResize = () => resizeCanvas();
|
||||
window.addEventListener("resize", onResize);
|
||||
|
||||
// If container changes size (e.g., sheet opening), observe it
|
||||
const ro = new ResizeObserver(() => resizeCanvas());
|
||||
if (containerRef.current) {
|
||||
ro.observe(containerRef.current);
|
||||
}
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("resize", onResize);
|
||||
ro.disconnect();
|
||||
};
|
||||
}, [resizeCanvas]);
|
||||
|
||||
const getRelativePoint = (e: PointerEvent | React.PointerEvent<HTMLCanvasElement>): Point => {
|
||||
const canvas = canvasRef.current!;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const x = (e.clientX - rect.left) * dpr;
|
||||
const y = (e.clientY - rect.top) * dpr;
|
||||
return { x, y };
|
||||
};
|
||||
|
||||
const handlePointerDown = (e: React.PointerEvent<HTMLCanvasElement>) => {
|
||||
e.currentTarget.setPointerCapture(e.pointerId);
|
||||
setIsDrawing(true);
|
||||
const p = getRelativePoint(e);
|
||||
setLastPoint(p);
|
||||
};
|
||||
|
||||
const handlePointerMove = (e: React.PointerEvent<HTMLCanvasElement>) => {
|
||||
if (!isDrawing || !lastPoint || !ctxRef.current) return;
|
||||
const p = getRelativePoint(e);
|
||||
const ctx = ctxRef.current;
|
||||
ctx.strokeStyle = strokeColor;
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
ctx.lineWidth = Math.max(1, strokeWidth * dpr);
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(lastPoint.x, lastPoint.y);
|
||||
ctx.lineTo(p.x, p.y);
|
||||
ctx.stroke();
|
||||
setLastPoint(p);
|
||||
};
|
||||
|
||||
const handlePointerUp = (e: React.PointerEvent<HTMLCanvasElement>) => {
|
||||
if (e.currentTarget.hasPointerCapture(e.pointerId)) {
|
||||
e.currentTarget.releasePointerCapture(e.pointerId);
|
||||
}
|
||||
setIsDrawing(false);
|
||||
setLastPoint(null);
|
||||
};
|
||||
|
||||
const handlePointerLeave = () => {
|
||||
setIsDrawing(false);
|
||||
setLastPoint(null);
|
||||
};
|
||||
|
||||
const handleClear = () => {
|
||||
const canvas = canvasRef.current;
|
||||
const ctx = ctxRef.current;
|
||||
if (!canvas || !ctx) return;
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
};
|
||||
|
||||
const handleDownload = () => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
const link = document.createElement("a");
|
||||
link.download = "whiteboard.png";
|
||||
link.href = canvas.toDataURL("image/png");
|
||||
link.click();
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="w-full h-[70vh] min-h-[360px]">
|
||||
<div className="flex items-center gap-2 pb-3">
|
||||
<label className="text-sm" htmlFor="stroke-color">
|
||||
Color
|
||||
</label>
|
||||
<input
|
||||
id="stroke-color"
|
||||
aria-label="Stroke color"
|
||||
type="color"
|
||||
value={strokeColor}
|
||||
onChange={(e) => setStrokeColor(e.target.value)}
|
||||
className="h-8 w-10 rounded border border-black/10 dark:border-white/10 bg-transparent"
|
||||
/>
|
||||
<label className="text-sm pl-2" htmlFor="stroke-width">
|
||||
Size
|
||||
</label>
|
||||
<input
|
||||
id="stroke-width"
|
||||
aria-label="Stroke width"
|
||||
type="range"
|
||||
min={1}
|
||||
max={20}
|
||||
value={strokeWidth}
|
||||
onChange={(e) => setStrokeWidth(Number(e.target.value))}
|
||||
className="w-32 accent-[color:var(--accent)]"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClear}
|
||||
className={cn(
|
||||
"px-3 py-1.5 text-sm rounded-md transition-colors",
|
||||
"bg-black/80 text-white hover:bg-black",
|
||||
"dark:bg-white/10 dark:text-white dark:hover:bg-white/20"
|
||||
)}
|
||||
aria-label="Clear canvas"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDownload}
|
||||
className={cn(
|
||||
"px-3 py-1.5 text-sm rounded-md transition-colors",
|
||||
"bg-[color:var(--accent)] text-black hover:brightness-110"
|
||||
)}
|
||||
aria-label="Download image"
|
||||
>
|
||||
Download
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className={cn(
|
||||
"w-full h-[calc(70vh-3rem)] min-h-[300px] rounded-md",
|
||||
"bg-white dark:bg-neutral-900 border border-black/10 dark:border-white/10 touch-none"
|
||||
)}
|
||||
role="img"
|
||||
aria-label="Whiteboard drawing area"
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={handlePointerUp}
|
||||
onPointerLeave={handlePointerLeave}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default WhiteboardCanvas;
|
||||
242
app/globals.css
242
app/globals.css
@ -1,15 +1,45 @@
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
:root {
|
||||
/* Light mode tokens */
|
||||
--background: #fbfbfb; /* off-white to reduce glare */
|
||||
--surface: #ffffff33; /* card/background surface */
|
||||
--foreground: #0f1720; /* readable body text */
|
||||
--muted: rgba(15, 23, 32, 0.55);
|
||||
--accent: #0f1720; /* link/accent (monochrome) */
|
||||
/* Light mode tokens */ /* off-white to reduce glare */
|
||||
--surface: #ffffff33; /* card/background surface */ /* readable body text */
|
||||
--muted: oklch(0.97 0 0);
|
||||
--accent: oklch(0.97 0 0); /* link/accent (monochrome) */
|
||||
--dot-color: rgba(120, 120, 120, 0.576);
|
||||
--card-shadow: 0 6px 20px rgba(16, 24, 40, 0.06);
|
||||
--radius: 10px;
|
||||
--radius: 0.625rem;
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.145 0 0);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.145 0 0);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.145 0 0);
|
||||
--primary: oklch(0.205 0 0);
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--secondary: oklch(0.97 0 0);
|
||||
--secondary-foreground: oklch(0.205 0 0);
|
||||
--muted-foreground: oklch(0.556 0 0);
|
||||
--accent-foreground: oklch(0.205 0 0);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--border: oklch(0.922 0 0);
|
||||
--input: oklch(0.922 0 0);
|
||||
--ring: oklch(0.708 0 0);
|
||||
--chart-1: oklch(0.646 0.222 41.116);
|
||||
--chart-2: oklch(0.6 0.118 184.704);
|
||||
--chart-3: oklch(0.398 0.07 227.392);
|
||||
--chart-4: oklch(0.828 0.189 84.429);
|
||||
--chart-5: oklch(0.769 0.188 70.08);
|
||||
--sidebar: oklch(0.985 0 0);
|
||||
--sidebar-foreground: oklch(0.145 0 0);
|
||||
--sidebar-primary: oklch(0.205 0 0);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.97 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||
--sidebar-border: oklch(0.922 0 0);
|
||||
--sidebar-ring: oklch(0.708 0 0);
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
@ -17,12 +47,43 @@
|
||||
--color-foreground: var(--foreground);
|
||||
--font-sans: "Cabin", system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
--font-mono: var(--font-geist-mono);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-ring: var(--ring);
|
||||
--color-input: var(--input);
|
||||
--color-border: var(--border);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-card: var(--card);
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--background: #0a0a0a;
|
||||
--foreground: #ededed;
|
||||
--accent: #ededed; /* keep links readable in dark mode */
|
||||
--surface: #69696900; /* dark surfaces for cards */
|
||||
}
|
||||
@ -33,8 +94,6 @@
|
||||
}
|
||||
|
||||
body {
|
||||
background: linear-gradient(180deg, var(--background), color-mix(in srgb, var(--background) 85%, rgb(6, 6, 6) 15%) 60%);
|
||||
color: var(--foreground);
|
||||
font-family: "Cabin", system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
font-size: 16px;
|
||||
line-height: 1.48;
|
||||
@ -76,3 +135,164 @@ a:visited {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* Prose typography for blog posts */
|
||||
.prose {
|
||||
max-width: 65ch;
|
||||
margin: 0 auto;
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.prose h1,
|
||||
.prose h2,
|
||||
.prose h3,
|
||||
.prose h4 {
|
||||
color: var(--foreground);
|
||||
line-height: 1.25;
|
||||
margin-top: 1.75em;
|
||||
margin-bottom: 0.6em;
|
||||
}
|
||||
|
||||
.prose h1 { font-size: 2rem; }
|
||||
.prose h2 { font-size: 1.5rem; }
|
||||
.prose h3 { font-size: 1.25rem; }
|
||||
|
||||
.prose p {
|
||||
margin: 1em 0;
|
||||
font-size: 1.05rem;
|
||||
line-height: 1.8;
|
||||
color: color-mix(in srgb, var(--foreground) 90%, transparent 10%);
|
||||
}
|
||||
|
||||
.prose a {
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 3px;
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.prose blockquote {
|
||||
margin: 1.5em 0;
|
||||
padding-left: 1em;
|
||||
border-left: 3px solid rgba(15, 23, 32, 0.18);
|
||||
color: var(--muted);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.prose blockquote { border-left-color: rgba(237, 237, 237, 0.18); }
|
||||
}
|
||||
|
||||
.prose hr {
|
||||
border: 0;
|
||||
border-top: 1px solid rgba(15, 23, 32, 0.12);
|
||||
margin: 2rem 0;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.prose hr { border-top-color: rgba(237, 237, 237, 0.12); }
|
||||
}
|
||||
|
||||
.prose ul,
|
||||
.prose ol {
|
||||
padding-left: 1.25rem;
|
||||
margin: 1em 0;
|
||||
}
|
||||
.prose li { margin: 0.35em 0; }
|
||||
|
||||
.prose code {
|
||||
background: rgba(15, 23, 32, 0.06);
|
||||
padding: 0.15em 0.4em;
|
||||
border-radius: 6px;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.prose code { background: rgba(237, 237, 237, 0.08); }
|
||||
}
|
||||
|
||||
.prose pre {
|
||||
background: rgba(15, 23, 32, 0.85);
|
||||
color: #f1f5f9;
|
||||
padding: 1rem 1.25rem;
|
||||
border-radius: 10px;
|
||||
overflow-x: auto;
|
||||
box-shadow: var(--card-shadow);
|
||||
margin: 1.25rem 0;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.prose pre { background: rgba(255, 255, 255, 0.06); }
|
||||
}
|
||||
|
||||
.prose pre code {
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.prose img { border-radius: 10px; }
|
||||
|
||||
.prose figure { margin: 1.25rem 0; text-align: center; }
|
||||
.prose figcaption { color: var(--muted); font-size: 0.9rem; margin-top: 0.6rem; }
|
||||
|
||||
/* Heading anchor link emitted by CustomMDX */
|
||||
.prose .anchor {
|
||||
float: left;
|
||||
margin-left: -0.9ch;
|
||||
padding-right: 0.5ch;
|
||||
opacity: 0;
|
||||
text-decoration: none;
|
||||
border: none;
|
||||
}
|
||||
.prose .anchor::after {
|
||||
content: '#';
|
||||
color: var(--muted);
|
||||
}
|
||||
.prose h2:hover .anchor,
|
||||
.prose h3:hover .anchor,
|
||||
.prose h4:hover .anchor {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.145 0 0);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.205 0 0);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.205 0 0);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.922 0 0);
|
||||
--primary-foreground: oklch(0.205 0 0);
|
||||
--secondary: oklch(0.269 0 0);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.269 0 0);
|
||||
--muted-foreground: oklch(0.708 0 0);
|
||||
--accent: oklch(0.269 0 0);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.556 0 0);
|
||||
--chart-1: oklch(0.488 0.243 264.376);
|
||||
--chart-2: oklch(0.696 0.17 162.48);
|
||||
--chart-3: oklch(0.769 0.188 70.08);
|
||||
--chart-4: oklch(0.627 0.265 303.9);
|
||||
--chart-5: oklch(0.645 0.246 16.439);
|
||||
--sidebar: oklch(0.205 0 0);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.269 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
--sidebar-ring: oklch(0.556 0 0);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,6 +2,14 @@ import type { Metadata } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import "./globals.css";
|
||||
import { DotBackground } from "@/app/components/dotbackground";
|
||||
import { Navbar } from './components/nav'
|
||||
import Footer from './components/footer'
|
||||
import { LenisProvider } from "./providers/LenisProvider";
|
||||
import { MotionConfigProvider } from "./providers/MotionConfigProvider";
|
||||
import { DoomOverlayProvider } from "./providers/DoomOverlayProvider";
|
||||
import { DoomOverlay } from "./components/doom/DoomOverlay";
|
||||
import { WhiteboardProvider } from "./providers/WhiteboardProvider";
|
||||
import { baseUrl } from './sitemap';
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
@ -15,6 +23,7 @@ const geistMono = Geist_Mono({
|
||||
|
||||
|
||||
export const metadata: Metadata = {
|
||||
metadataBase: new URL(baseUrl),
|
||||
title: {
|
||||
default: "Nicholai",
|
||||
template: "%s · Nicholai",
|
||||
@ -23,14 +32,14 @@ export const metadata: Metadata = {
|
||||
openGraph: {
|
||||
title: "Nicholai",
|
||||
description: "Professional portfolio of Nicholai — VFX Supervisor & Developer",
|
||||
url: "https://nicholai.work",
|
||||
url: baseUrl,
|
||||
siteName: "Nicholai",
|
||||
images: ["/images/profile.jpg"],
|
||||
locale: "en_US",
|
||||
type: "website",
|
||||
},
|
||||
alternates: {
|
||||
canonical: "https://nicholai.work",
|
||||
canonical: baseUrl,
|
||||
},
|
||||
icons: {
|
||||
icon: "/favicon.ico",
|
||||
@ -55,8 +64,21 @@ export default function RootLayout({
|
||||
<body
|
||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||
>
|
||||
<DotBackground />
|
||||
{children}
|
||||
<DoomOverlayProvider>
|
||||
<MotionConfigProvider>
|
||||
<WhiteboardProvider>
|
||||
<LenisProvider>
|
||||
<Navbar />
|
||||
<DotBackground />
|
||||
<main className="flex-auto min-w-0 mt-6 flex flex-col px-2 md:px-0">
|
||||
{children}
|
||||
<Footer />
|
||||
</main>
|
||||
<DoomOverlay />
|
||||
</LenisProvider>
|
||||
</WhiteboardProvider>
|
||||
</MotionConfigProvider>
|
||||
</DoomOverlayProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
10
app/not-found.tsx
Normal file
10
app/not-found.tsx
Normal file
@ -0,0 +1,10 @@
|
||||
export default function NotFound() {
|
||||
return (
|
||||
<section>
|
||||
<h1 className="mb-8 text-2xl font-semibold tracking-tighter">
|
||||
404 - Page Not Found
|
||||
</h1>
|
||||
<p className="mb-4">The page you are looking for does not exist.</p>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
22
app/og/route.tsx
Normal file
22
app/og/route.tsx
Normal file
@ -0,0 +1,22 @@
|
||||
import { ImageResponse } from 'next/og'
|
||||
|
||||
export function GET(request: Request) {
|
||||
let url = new URL(request.url)
|
||||
let title = url.searchParams.get('title') || 'Next.js Portfolio Starter'
|
||||
|
||||
return new ImageResponse(
|
||||
(
|
||||
<div tw="flex flex-col w-full h-full items-center justify-center bg-white">
|
||||
<div tw="flex flex-col md:flex-row w-full py-12 px-4 md:items-center justify-between p-8">
|
||||
<h2 tw="flex flex-col text-4xl font-bold tracking-tight text-left">
|
||||
{title}
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
{
|
||||
width: 1200,
|
||||
height: 630,
|
||||
}
|
||||
)
|
||||
}
|
||||
213
app/page.tsx
213
app/page.tsx
@ -1,6 +1,10 @@
|
||||
import React from "react"
|
||||
import Link from "next/link"
|
||||
import SidebarMenu from "@/app/components/sidebar-menu"
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"
|
||||
import { FlipWords } from "@/components/ui/flip-words"
|
||||
import { AvatarMotion } from "@/app/components/avatar-motion"
|
||||
import { BlogPosts } from "@/components/posts"
|
||||
|
||||
export const metadata = {
|
||||
title: "Nicholai",
|
||||
@ -20,97 +24,132 @@ export default function Home() {
|
||||
const year = new Date().getFullYear();
|
||||
|
||||
return (
|
||||
<main className="flex min-h-screen items-center justify-center antialiased">
|
||||
<div className="w-full max-w-xl mx-auto flex flex-col items-center text-center gap-8 px-4 py-12">
|
||||
<section aria-label="Profile photo">
|
||||
<AvatarMotion
|
||||
src="/images/profile.jpg"
|
||||
alt="Hand drawn portrait of Nicholai"
|
||||
size={160}
|
||||
/>
|
||||
</section>
|
||||
<main className="min-h-screen">
|
||||
<div className="mx-auto grid w-full max-w-5xl grid-cols-1 gap-6 px-4 pt-10 pb-16 md:grid-cols-12 md:gap-8">
|
||||
{/* Sidebar */}
|
||||
<div className="md:col-span-3">
|
||||
<SidebarMenu />
|
||||
</div>
|
||||
|
||||
<section aria-labelledby="intro-title" className="space-y-2">
|
||||
<h1 id="intro-title" className="text-2xl font-semibold">
|
||||
Nicholai
|
||||
</h1>
|
||||
<p className="text-xs text-neutral-600 dark:text-neutral-400 font-normal">
|
||||
I wanted to justify the $6.39 I spent on the domain.
|
||||
</p>
|
||||
<div className="text-sm text-neutral-700 dark:text-neutral-400">
|
||||
<FlipWords words={["VFX Artist", "Developer"]} className="text-neutral-900 dark:text-neutral-200" />
|
||||
</div>
|
||||
</section>
|
||||
{/* Content column */}
|
||||
<div className="md:col-span-9 flex flex-col gap-6">
|
||||
{/* About */}
|
||||
<section id="about" aria-label="About">
|
||||
<Card>
|
||||
<CardHeader className="flex items-center gap-4 sm:flex-row">
|
||||
<AvatarMotion
|
||||
src="/images/profile.jpg"
|
||||
alt="Hand drawn portrait of Nicholai"
|
||||
size={96}
|
||||
/>
|
||||
<div>
|
||||
<CardTitle className="tracking-tight !normal-case">About</CardTitle>
|
||||
<CardDescription>Nicholai • VFX Artist & Developer</CardDescription>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<p className="text-sm text-neutral-700 dark:text-neutral-300">
|
||||
I wanted to justify the $6.39 I spent on the domain.
|
||||
</p>
|
||||
|
||||
<nav aria-label="Hyperlinks" className="w-full space-y-1">
|
||||
<p className="text-xs font-medium uppercase tracking-wider text-neutral-600 dark:text-neutral-500">
|
||||
Hyperlinks
|
||||
</p>
|
||||
<ul className="space-y-1">
|
||||
<li className="text-neutral-900 dark:text-neutral-200">
|
||||
VFX Supervisor at{" "}
|
||||
<a
|
||||
href="https://biohazardvfx.com"
|
||||
className=""
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Biohazard VFX
|
||||
</a>
|
||||
</li>
|
||||
<li className="text-neutral-900 dark:text-neutral-200">
|
||||
Developer{" "}
|
||||
<a
|
||||
href="https://fortura.cc"
|
||||
className=""
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Fortura Data Solutions
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
href="mailto:nicholai@biohazardvfx.com"
|
||||
className="text-xs"
|
||||
>
|
||||
Email me
|
||||
</a>
|
||||
</li>
|
||||
<li className="text-neutral-900 dark:text-neutral-200">
|
||||
<a
|
||||
href="https://www.instagram.com/nicholai.exe/"
|
||||
className=""
|
||||
target="_blank"
|
||||
rel="me noopener noreferrer"
|
||||
>
|
||||
Instagram
|
||||
</a>
|
||||
<span className="text-xs text-neutral-500"> - I hate Instagram</span>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
<div className="text-sm text-neutral-800 dark:text-neutral-200">
|
||||
<FlipWords words={["VFX Artist", "Developer"]} className="font-medium" />
|
||||
</div>
|
||||
|
||||
<section aria-labelledby="listening-title" className="w-full space-y-4">
|
||||
<h3 id="listening-title" className="text-sm font-medium uppercase tracking-wider text-neutral-600 dark:text-neutral-500 mb-4">
|
||||
Listening
|
||||
</h3>
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<iframe
|
||||
title="Spotify playlist"
|
||||
style={{ borderRadius: 12 }}
|
||||
src="https://open.spotify.com/embed/playlist/1kV9JPnhvpfk0UtcpslRpa?utm_source=generator&theme=0"
|
||||
width="290"
|
||||
height="220"
|
||||
loading="lazy"
|
||||
allow="autoplay; clipboard-write; encrypted-media; fullscreen; picture-in-picture"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
<ul className="flex flex-wrap gap-3 pt-1 text-sm">
|
||||
<li className="text-neutral-900 dark:text-neutral-200">
|
||||
VFX Supervisor at{" "}
|
||||
<a href="https://biohazardvfx.com" target="_blank" rel="noopener noreferrer">
|
||||
Biohazard VFX
|
||||
</a>
|
||||
</li>
|
||||
<li className="text-neutral-900 dark:text-neutral-200">
|
||||
Developer{" "}
|
||||
<a href="https://fortura.cc" target="_blank" rel="noopener noreferrer">
|
||||
Fortura Data Solutions
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="mailto:nicholai@biohazardvfx.com" className="underline underline-offset-4">
|
||||
Email me
|
||||
</a>
|
||||
</li>
|
||||
<li className="text-neutral-900 dark:text-neutral-200">
|
||||
<a
|
||||
href="https://www.instagram.com/nicholai.exe/"
|
||||
target="_blank"
|
||||
rel="me noopener noreferrer"
|
||||
>
|
||||
Instagram
|
||||
</a>
|
||||
<span className="ml-1 text-xs text-neutral-600 dark:text-neutral-400">– I hate Instagram</span>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<footer className="pt-6 text-center text-xs text-neutral-600 dark:text-neutral-500">
|
||||
© {year} Nicholai · $6.39 well spent.
|
||||
</footer>
|
||||
{/* Listening */}
|
||||
<div className="pt-2">
|
||||
<h3 className="mb-2 text-xs font-medium uppercase tracking-wider text-neutral-800 dark:text-neutral-300">
|
||||
Listening
|
||||
</h3>
|
||||
<div className="spotify-card">
|
||||
<iframe
|
||||
title="Spotify playlist"
|
||||
style={{ borderRadius: 12 }}
|
||||
src="https://open.spotify.com/embed/playlist/1kV9JPnhvpfk0UtcpslRpa?utm_source=generator&theme=0"
|
||||
width="290"
|
||||
height="220"
|
||||
loading="lazy"
|
||||
allow="autoplay; clipboard-write; encrypted-media; fullscreen; picture-in-picture"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
{/* Writing */}
|
||||
<section id="writing" aria-label="Writing">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="!normal-case">Writing</CardTitle>
|
||||
<CardDescription>Recent posts</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="my-2">
|
||||
<BlogPosts />
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<Link
|
||||
href="/blog"
|
||||
className="inline-flex items-center rounded-md border border-black/10 dark:border-white/10 px-3 py-2 text-sm hover:bg-black/[0.04] dark:hover:bg-white/5"
|
||||
>
|
||||
View all posts
|
||||
</Link>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
{/* Contact */}
|
||||
<section id="contact" aria-label="Contact">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="!normal-case">Contact</CardTitle>
|
||||
<CardDescription>Let's build something</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm">
|
||||
Prefer email:
|
||||
{" "}
|
||||
<a href="mailto:nicholai@biohazardvfx.com" className="underline underline-offset-4">
|
||||
nicholai@biohazardvfx.com
|
||||
</a>
|
||||
</p>
|
||||
<p className="footer-small mt-4">© {year} Nicholai · $6.39 well spent.</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
|
||||
54
app/providers/DoomOverlayProvider.tsx
Normal file
54
app/providers/DoomOverlayProvider.tsx
Normal file
@ -0,0 +1,54 @@
|
||||
"use client";
|
||||
|
||||
import { createContext, useContext, useState, ReactNode } from "react";
|
||||
|
||||
export type DoomOverlayContextValue = {
|
||||
isOpen: boolean;
|
||||
open: () => void;
|
||||
close: () => void;
|
||||
};
|
||||
|
||||
const DoomOverlayContext = createContext<DoomOverlayContextValue | undefined>(
|
||||
undefined
|
||||
);
|
||||
|
||||
interface DoomOverlayProviderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function DoomOverlayProvider({ children }: DoomOverlayProviderProps) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
const open = () => {
|
||||
try {
|
||||
console.log("[DoomOverlay] open");
|
||||
} catch {}
|
||||
setIsOpen(true);
|
||||
};
|
||||
const close = () => {
|
||||
try {
|
||||
console.log("[DoomOverlay] close");
|
||||
} catch {}
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
const value: DoomOverlayContextValue = {
|
||||
isOpen,
|
||||
open,
|
||||
close,
|
||||
};
|
||||
|
||||
return (
|
||||
<DoomOverlayContext.Provider value={value}>
|
||||
{children}
|
||||
</DoomOverlayContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useDoomOverlay(): DoomOverlayContextValue {
|
||||
const context = useContext(DoomOverlayContext);
|
||||
if (context === undefined) {
|
||||
throw new Error("useDoomOverlay must be used within a DoomOverlayProvider");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
107
app/providers/LenisProvider.tsx
Normal file
107
app/providers/LenisProvider.tsx
Normal file
@ -0,0 +1,107 @@
|
||||
"use client";
|
||||
|
||||
import React, { createContext, useContext, useEffect, useMemo, useRef } from "react";
|
||||
import Lenis from "lenis";
|
||||
import { useMotionValue, type MotionValue } from "motion/react";
|
||||
|
||||
type ScrollContextValue = {
|
||||
lenis: Lenis | null;
|
||||
scrollY: MotionValue<number>; // in px
|
||||
progress: MotionValue<number>; // 0..1 for whole page
|
||||
};
|
||||
|
||||
const ScrollContext = createContext<ScrollContextValue | null>(null);
|
||||
|
||||
type LenisScrollEvent = {
|
||||
scroll: number;
|
||||
limit: number;
|
||||
progress?: number;
|
||||
};
|
||||
|
||||
export function useScrollContext() {
|
||||
const ctx = useContext(ScrollContext);
|
||||
if (!ctx) {
|
||||
throw new Error("useScrollContext must be used within <LenisProvider>");
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
|
||||
export function LenisProvider({ children }: { children: React.ReactNode }) {
|
||||
const lenisRef = useRef<Lenis | null>(null);
|
||||
const scrollY = useMotionValue(0);
|
||||
const progress = useMotionValue(0);
|
||||
|
||||
useEffect(() => {
|
||||
// Respect user preferences
|
||||
const prefersReducedMotion =
|
||||
typeof window !== "undefined" &&
|
||||
window.matchMedia &&
|
||||
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||
|
||||
const lenis = new Lenis({
|
||||
// Softer, less "floaty" feel
|
||||
smoothWheel: !prefersReducedMotion,
|
||||
syncTouch: true,
|
||||
duration: 0.7, // was 1.2
|
||||
easing: (t: number) => 1 - Math.pow(1 - t, 2.4), // gentler ease-out
|
||||
wheelMultiplier: 0.9, // slightly reduce wheel amplitude
|
||||
// Use native on reduced motion
|
||||
wrapper: undefined,
|
||||
content: undefined,
|
||||
// If reduced motion, disable smoothing entirely
|
||||
lerp: prefersReducedMotion ? 1 : 0.22, // was 0.1 (heavier smoothing)
|
||||
});
|
||||
|
||||
lenisRef.current = lenis;
|
||||
|
||||
const onScroll = (e: unknown) => {
|
||||
const { scroll, limit, progress: p } = (e as LenisScrollEvent);
|
||||
scrollY.set(scroll);
|
||||
// Some versions of Lenis may not send progress, compute fallback if needed
|
||||
if (typeof p === "number" && Number.isFinite(p)) {
|
||||
progress.set(p);
|
||||
} else {
|
||||
const fallback =
|
||||
limit > 0
|
||||
? Math.min(1, Math.max(0, scroll / limit))
|
||||
: 0;
|
||||
progress.set(fallback);
|
||||
}
|
||||
};
|
||||
|
||||
lenis.on("scroll", onScroll);
|
||||
|
||||
let rafId = 0;
|
||||
const raf = (time: number) => {
|
||||
lenis.raf(time);
|
||||
rafId = requestAnimationFrame(raf);
|
||||
};
|
||||
rafId = requestAnimationFrame(raf);
|
||||
|
||||
// Initialize values
|
||||
onScroll({
|
||||
scroll: window.scrollY,
|
||||
limit: Math.max(0, document.documentElement.scrollHeight - window.innerHeight),
|
||||
progress: (document.documentElement.scrollHeight - window.innerHeight) > 0
|
||||
? window.scrollY / (document.documentElement.scrollHeight - window.innerHeight)
|
||||
: 0,
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(rafId);
|
||||
lenis.off("scroll", onScroll);
|
||||
lenis.destroy();
|
||||
lenisRef.current = null;
|
||||
};
|
||||
}, [progress, scrollY]);
|
||||
|
||||
const value = useMemo<ScrollContextValue>(() => {
|
||||
return {
|
||||
lenis: lenisRef.current,
|
||||
scrollY,
|
||||
progress,
|
||||
};
|
||||
}, [scrollY, progress]);
|
||||
|
||||
return <ScrollContext.Provider value={value}>{children}</ScrollContext.Provider>;
|
||||
}
|
||||
35
app/providers/MotionConfigProvider.tsx
Normal file
35
app/providers/MotionConfigProvider.tsx
Normal file
@ -0,0 +1,35 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { MotionConfig } from "motion/react";
|
||||
|
||||
/**
|
||||
* Centralize motion defaults: durations, easings, reduced-motion handling.
|
||||
* Wrap the app in this provider (see app/layout.tsx).
|
||||
*/
|
||||
export function MotionConfigProvider({ children }: { children: React.ReactNode }) {
|
||||
// Respect prefers-reduced-motion by simplifying animations
|
||||
const prefersReducedMotion =
|
||||
typeof window !== "undefined" &&
|
||||
window.matchMedia &&
|
||||
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||
|
||||
// Shared transition tokens
|
||||
const duration = prefersReducedMotion ? 0 : 0.6;
|
||||
const delay = prefersReducedMotion ? 0 : 0.0;
|
||||
// A premium-feel cubic-bezier; slightly snappier in the end
|
||||
const ease: [number, number, number, number] = [0.2, 0.8, 0.2, 1];
|
||||
|
||||
return (
|
||||
<MotionConfig
|
||||
reducedMotion={prefersReducedMotion ? "always" : "never"}
|
||||
transition={{
|
||||
duration,
|
||||
ease,
|
||||
delay,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</MotionConfig>
|
||||
);
|
||||
}
|
||||
54
app/providers/WhiteboardProvider.tsx
Normal file
54
app/providers/WhiteboardProvider.tsx
Normal file
@ -0,0 +1,54 @@
|
||||
"use client";
|
||||
|
||||
import React, { createContext, useCallback, useContext, useState } from "react";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
SheetDescription,
|
||||
} from "@/components/ui/sheet";
|
||||
|
||||
import WhiteboardCanvas from "@/app/components/whiteboard/WhiteboardCanvas";
|
||||
|
||||
type WhiteboardContextType = {
|
||||
open: () => void;
|
||||
close: () => void;
|
||||
isOpen: boolean;
|
||||
};
|
||||
|
||||
const WhiteboardContext = createContext<WhiteboardContextType | null>(null);
|
||||
|
||||
export const useWhiteboard = (): WhiteboardContextType => {
|
||||
const ctx = useContext(WhiteboardContext);
|
||||
if (!ctx) {
|
||||
throw new Error("useWhiteboard must be used within WhiteboardProvider");
|
||||
}
|
||||
return ctx;
|
||||
};
|
||||
|
||||
export function WhiteboardProvider({ children }: { children: React.ReactNode }) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
const open = useCallback(() => setIsOpen(true), []);
|
||||
const close = useCallback(() => setIsOpen(false), []);
|
||||
|
||||
return (
|
||||
<WhiteboardContext.Provider value={{ open, close, isOpen }}>
|
||||
{children}
|
||||
<Sheet open={isOpen} onOpenChange={setIsOpen}>
|
||||
<SheetContent side="right" className="w-full sm:max-w-[560px] p-0">
|
||||
<SheetHeader className="p-4 border-b">
|
||||
<SheetTitle>Whiteboard</SheetTitle>
|
||||
<SheetDescription>
|
||||
Sketch ideas. Use mouse or touch to draw.
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
<div className="p-4">
|
||||
<WhiteboardCanvas />
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</WhiteboardContext.Provider>
|
||||
);
|
||||
}
|
||||
12
app/robots.ts
Normal file
12
app/robots.ts
Normal file
@ -0,0 +1,12 @@
|
||||
import { baseUrl } from './sitemap'
|
||||
|
||||
export default function robots() {
|
||||
return {
|
||||
rules: [
|
||||
{
|
||||
userAgent: '*',
|
||||
},
|
||||
],
|
||||
sitemap: `${baseUrl}/sitemap.xml`,
|
||||
}
|
||||
}
|
||||
42
app/rss/route.ts
Normal file
42
app/rss/route.ts
Normal file
@ -0,0 +1,42 @@
|
||||
import { baseUrl } from '../sitemap'
|
||||
import { getBlogPosts } from '../blog/utils'
|
||||
|
||||
export async function GET() {
|
||||
let allBlogs = await getBlogPosts()
|
||||
|
||||
const itemsXml = allBlogs
|
||||
.sort((a, b) => {
|
||||
if (new Date(a.metadata.publishedAt) > new Date(b.metadata.publishedAt)) {
|
||||
return -1
|
||||
}
|
||||
return 1
|
||||
})
|
||||
.map(
|
||||
(post) =>
|
||||
`<item>
|
||||
<title>${post.metadata.title}</title>
|
||||
<link>${baseUrl}/blog/${post.slug}</link>
|
||||
<description>${post.metadata.summary || ''}</description>
|
||||
<pubDate>${new Date(
|
||||
post.metadata.publishedAt
|
||||
).toUTCString()}</pubDate>
|
||||
</item>`
|
||||
)
|
||||
.join('\n')
|
||||
|
||||
const rssFeed = `<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<rss version="2.0">
|
||||
<channel>
|
||||
<title>My Portfolio</title>
|
||||
<link>${baseUrl}</link>
|
||||
<description>This is my portfolio RSS feed</description>
|
||||
${itemsXml}
|
||||
</channel>
|
||||
</rss>`
|
||||
|
||||
return new Response(rssFeed, {
|
||||
headers: {
|
||||
'Content-Type': 'text/xml',
|
||||
},
|
||||
})
|
||||
}
|
||||
103
app/sections/AboutSection.tsx
Normal file
103
app/sections/AboutSection.tsx
Normal file
@ -0,0 +1,103 @@
|
||||
"use client";
|
||||
|
||||
import React, { useRef } from "react";
|
||||
import { motion, useTransform } from "motion/react";
|
||||
import { Parallax } from "@/components/parallax/Parallax";
|
||||
import { Reveal } from "@/components/motion/Reveal";
|
||||
import { Stagger } from "@/components/motion/Stagger";
|
||||
import { TRANSITIONS } from "@/lib/animation";
|
||||
|
||||
export function AboutSection() {
|
||||
const imageRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
return (
|
||||
<section
|
||||
id="about"
|
||||
aria-label="About"
|
||||
className="relative w-full overflow-clip py-24 md:py-36"
|
||||
>
|
||||
{/* Ambient vignette */}
|
||||
<Parallax speed={0.06} className="pointer-events-none absolute inset-0 -z-10">
|
||||
<div className="absolute inset-0 bg-[radial-gradient(800px_400px_at_80%_10%,rgba(255,255,255,0.08),transparent_70%)]" />
|
||||
</Parallax>
|
||||
|
||||
<div className="mx-auto grid w-full max-w-6xl grid-cols-1 items-center gap-10 px-6 md:grid-cols-12 md:gap-12">
|
||||
{/* Copy */}
|
||||
<div className="md:col-span-6 lg:col-span-7">
|
||||
<Stagger delayChildren={0.05}>
|
||||
<Reveal>
|
||||
<h2 className="text-pretty text-3xl font-bold tracking-tight sm:text-4xl md:text-5xl bg-clip-text text-transparent bg-gradient-to-b from-neutral-100 to-neutral-300">
|
||||
Craft meets code.
|
||||
</h2>
|
||||
</Reveal>
|
||||
<Reveal delay={0.05}>
|
||||
<p className="mt-4 text-balance text-base leading-relaxed text-neutral-300 md:text-lg">
|
||||
I shape cinematic experiences for the web—where motion guides, parallax breathes, and details
|
||||
obsess. From VFX supervision to full‑stack development, I build interfaces that feel alive while
|
||||
staying accessible and fast.
|
||||
</p>
|
||||
</Reveal>
|
||||
<Reveal delay={0.1}>
|
||||
<ul className="mt-6 grid grid-cols-1 gap-3 text-sm text-neutral-200 sm:grid-cols-2">
|
||||
<li className="rounded-lg glass p-3">
|
||||
• Smooth-scrolling narratives with Lenis
|
||||
</li>
|
||||
<li className="rounded-lg glass p-3">
|
||||
• Scroll-linked motion via Framer Motion
|
||||
</li>
|
||||
<li className="rounded-lg glass p-3">
|
||||
• Performance-first, a11y conscious
|
||||
</li>
|
||||
<li className="rounded-lg glass p-3">
|
||||
• Filmic depth, considered typography
|
||||
</li>
|
||||
</ul>
|
||||
</Reveal>
|
||||
</Stagger>
|
||||
</div>
|
||||
|
||||
{/* Visual */}
|
||||
<div className="md:col-span-6 lg:col-span-5">
|
||||
<motion.div
|
||||
ref={imageRef}
|
||||
className="relative aspect-[4/5] w-full overflow-hidden rounded-2xl glass-strong"
|
||||
initial={{ opacity: 0, y: 16 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true, margin: "0px 0px -10% 0px" }}
|
||||
transition={TRANSITIONS.base}
|
||||
>
|
||||
{/* Parallax layers */}
|
||||
<Parallax speed={0.12} className="absolute -inset-8">
|
||||
<div className="h-full w-full bg-[radial-gradient(600px_300px_at_30%_10%,rgba(255,255,255,0.10),transparent_70%)]" />
|
||||
</Parallax>
|
||||
<Parallax speed={-0.08} className="absolute inset-0">
|
||||
<div className="h-full w-full bg-[linear-gradient(180deg,transparent,rgba(255,255,255,0.06)_40%,transparent_80%)]" />
|
||||
</Parallax>
|
||||
|
||||
{/* Grid accent */}
|
||||
<motion.div
|
||||
className="absolute inset-0 opacity-[0.08]"
|
||||
style={{
|
||||
backgroundImage:
|
||||
"linear-gradient(to right, currentColor 1px, transparent 1px), linear-gradient(to bottom, currentColor 1px, transparent 1px)",
|
||||
backgroundSize: "24px 24px",
|
||||
color: "rgb(255 255 255)",
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Caption */}
|
||||
<motion.div
|
||||
className="absolute bottom-0 left-0 right-0 p-4 text-xs text-neutral-300/80 backdrop-blur-sm"
|
||||
initial={{ opacity: 0, y: 6 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ ...TRANSITIONS.base, delay: 0.1 }}
|
||||
>
|
||||
Parallax composition with layered gradients and light bloom.
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
82
app/sections/ContactSection.tsx
Normal file
82
app/sections/ContactSection.tsx
Normal file
@ -0,0 +1,82 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { motion } from "motion/react";
|
||||
import { Parallax } from "@/components/parallax/Parallax";
|
||||
import { ContactModal } from "@/app/components/contact-modal";
|
||||
import { TRANSITIONS } from "@/lib/animation";
|
||||
|
||||
/**
|
||||
* Sticky CTA band that invites contact and opens the ContactModal.
|
||||
*/
|
||||
export function ContactSection() {
|
||||
return (
|
||||
<section id="contact" aria-label="Contact" className="relative w-full">
|
||||
{/* Ambient depth gradients */}
|
||||
<Parallax speed={0.04} className="pointer-events-none absolute inset-0 -z-10">
|
||||
<div className="absolute inset-0 bg-[radial-gradient(1200px_520px_at_50%_0%,rgba(255,255,255,0.08),transparent_70%)]" />
|
||||
</Parallax>
|
||||
|
||||
<div className="sticky top-0 z-10 w-full">
|
||||
<div className="mx-auto w-full max-w-5xl px-6 py-16">
|
||||
<motion.div
|
||||
className="relative overflow-hidden rounded-3xl glass-strong glass-refract p-8 text-center"
|
||||
initial={{ opacity: 0, y: 16 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true, amount: 0.4 }}
|
||||
transition={TRANSITIONS.base}
|
||||
>
|
||||
{/* Light sheen */}
|
||||
|
||||
<motion.h2
|
||||
className="text-2xl font-bold tracking-tight text-neutral-100 sm:text-3xl"
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={TRANSITIONS.base}
|
||||
>
|
||||
Let’s build something cinematic.
|
||||
</motion.h2>
|
||||
|
||||
<motion.p
|
||||
className="mx-auto mt-2 max-w-2xl text-sm text-neutral-300 sm:text-base"
|
||||
initial={{ opacity: 0, y: 6 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ ...TRANSITIONS.base, delay: 0.05 }}
|
||||
>
|
||||
I combine VFX sensibilities with engineering rigor to craft polished, high‑performance experiences.
|
||||
</motion.p>
|
||||
|
||||
<motion.div
|
||||
className="mt-6 flex items-center justify-center gap-3"
|
||||
initial={{ opacity: 0, y: 6 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ ...TRANSITIONS.base, delay: 0.1 }}
|
||||
>
|
||||
{/* Reuse the existing modal trigger */}
|
||||
<ContactModal />
|
||||
<a
|
||||
href="mailto:hello@nicholai.work"
|
||||
className="rounded-full glass px-4 py-2 text-sm font-medium text-neutral-200 transition-colors hover:opacity-95"
|
||||
>
|
||||
Email link
|
||||
</a>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
|
||||
<motion.footer
|
||||
className="mt-8 text-center text-xs text-neutral-400"
|
||||
initial={{ opacity: 0 }}
|
||||
whileInView={{ opacity: 1 }}
|
||||
viewport={{ once: true }}
|
||||
transition={TRANSITIONS.base}
|
||||
>
|
||||
© {new Date().getFullYear()} Nicholai — Designed with motion and care.
|
||||
</motion.footer>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
94
app/sections/HeroSection.tsx
Normal file
94
app/sections/HeroSection.tsx
Normal file
@ -0,0 +1,94 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { motion } from "motion/react";
|
||||
import { Parallax } from "@/components/parallax/Parallax";
|
||||
import { FlipWords } from "@/components/ui/flip-words";
|
||||
import { AvatarMotion } from "@/app/components/avatar-motion";
|
||||
import { TRANSITIONS } from "@/lib/animation";
|
||||
|
||||
export function HeroSection() {
|
||||
return (
|
||||
<section
|
||||
id="hero"
|
||||
aria-label="Hero"
|
||||
className="relative min-h-[130vh] w-full overflow-clip flex items-center"
|
||||
>
|
||||
{/* Background depth layers */}
|
||||
<Parallax speed={0.05} className="pointer-events-none absolute inset-0 -z-20">
|
||||
<div className="absolute inset-0 bg-[radial-gradient(1200px_600px_at_50%_-20%,rgba(255,255,255,0.10),transparent_70%)]" />
|
||||
</Parallax>
|
||||
|
||||
<Parallax speed={0.08} className="pointer-events-none absolute inset-0 -z-10">
|
||||
<div className="absolute -inset-x-10 -inset-y-20 bg-gradient-to-b from-transparent via-white/5 to-transparent blur-2xl" />
|
||||
</Parallax>
|
||||
|
||||
{/* Content */}
|
||||
<div className="relative z-10 mx-auto flex w-full max-w-5xl flex-col items-center gap-8 px-6">
|
||||
<Parallax speed={-0.12} className="mt-24">
|
||||
<AvatarMotion
|
||||
src="/images/profile.jpg"
|
||||
srcSet={{
|
||||
avif: {
|
||||
'120': '/images/profile-120.avif',
|
||||
'160': '/images/profile-160.avif',
|
||||
'original': '/images/profile.avif'
|
||||
},
|
||||
fallback: '/images/profile.jpg'
|
||||
}}
|
||||
alt="Hand drawn portrait of Nicholai"
|
||||
size={200}
|
||||
className="ring-1 ring-white/10"
|
||||
/>
|
||||
</Parallax>
|
||||
|
||||
<div className="text-center">
|
||||
<motion.h1
|
||||
className="mx-auto max-w-3xl bg-clip-text text-5xl font-extrabold tracking-tight text-transparent sm:text-6xl md:text-7xl
|
||||
bg-gradient-to-b from-neutral-100 to-neutral-300"
|
||||
initial={{ opacity: 0, y: 24, filter: "blur(8px)" }}
|
||||
animate={{ opacity: 1, y: 0, filter: "blur(0px)" }}
|
||||
transition={TRANSITIONS.base}
|
||||
>
|
||||
Nicholai
|
||||
</motion.h1>
|
||||
|
||||
<motion.p
|
||||
className="mx-auto mt-3 max-w-xl text-balance text-sm text-neutral-300 sm:text-base"
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ ...TRANSITIONS.base, delay: 0.1 }}
|
||||
>
|
||||
Building cinematic web moments with code and craft.
|
||||
</motion.p>
|
||||
|
||||
<motion.div
|
||||
className="mx-auto mt-4 text-base sm:text-lg text-neutral-200"
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ ...TRANSITIONS.base, delay: 0.2 }}
|
||||
>
|
||||
<FlipWords
|
||||
words={["VFX Artist", "Developer", "Experience Designer"]}
|
||||
className="font-medium"
|
||||
/>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
{/* Scroll cue */}
|
||||
<motion.div
|
||||
aria-hidden
|
||||
className="absolute bottom-8 left-1/2 -translate-x-1/2 text-neutral-400"
|
||||
initial={{ opacity: 0, y: 0 }}
|
||||
animate={{ opacity: 1, y: [0, 6, 0] }}
|
||||
transition={{ duration: 1.8, repeat: Infinity, ease: [0.2, 0.8, 0.2, 1] }}
|
||||
>
|
||||
<svg width="20" height="28" viewBox="0 0 20 28" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="1.5" y="1.5" width="17" height="25" rx="8.5" stroke="currentColor" opacity="0.45"/>
|
||||
<circle cx="10" cy="7" r="2" fill="currentColor"/>
|
||||
</svg>
|
||||
</motion.div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
115
app/sections/ProcessSection.tsx
Normal file
115
app/sections/ProcessSection.tsx
Normal file
@ -0,0 +1,115 @@
|
||||
"use client";
|
||||
|
||||
import React, { useRef } from "react";
|
||||
import { motion, useTransform } from "motion/react";
|
||||
import { useSectionProgress } from "@/lib/scroll";
|
||||
import { Parallax } from "@/components/parallax/Parallax";
|
||||
import { Reveal } from "@/components/motion/Reveal";
|
||||
import { Stagger } from "@/components/motion/Stagger";
|
||||
import { TRANSITIONS } from "@/lib/animation";
|
||||
|
||||
export function ProcessSection() {
|
||||
const sectionRef = useRef<HTMLElement>(null!);
|
||||
const progress = useSectionProgress(sectionRef);
|
||||
const pathLength = useTransform(progress, [0, 1], [0, 1]);
|
||||
|
||||
const steps = [
|
||||
{
|
||||
title: "Discover",
|
||||
desc:
|
||||
"Align on goals, audience, and tone. Define constraints and success metrics.",
|
||||
},
|
||||
{
|
||||
title: "Design",
|
||||
desc:
|
||||
"Establish visual language, motion rhythm, and section-level compositions.",
|
||||
},
|
||||
{
|
||||
title: "Build",
|
||||
desc:
|
||||
"Implement Lenis scroll orchestration and Framer Motion systems with a11y in mind.",
|
||||
},
|
||||
{
|
||||
title: "Polish",
|
||||
desc:
|
||||
"Optimize performance, refine micro‑interactions, and tune parallax depth.",
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<section
|
||||
id="process"
|
||||
ref={sectionRef}
|
||||
aria-label="Process"
|
||||
className="relative w-full overflow-clip py-28 md:py-36"
|
||||
>
|
||||
{/* Ambient vignette */}
|
||||
<Parallax speed={0.04} className="pointer-events-none absolute inset-0 -z-10">
|
||||
<div className="absolute inset-0 bg-[radial-gradient(900px_480px_at_10%_20%,rgba(255,255,255,0.08),transparent_70%)]" />
|
||||
</Parallax>
|
||||
|
||||
<div className="mx-auto w-full max-w-6xl px-6">
|
||||
<div className="text-center mb-12">
|
||||
<motion.h2
|
||||
className="text-2xl sm:text-3xl md:text-4xl font-bold tracking-tight text-neutral-100"
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={TRANSITIONS.base}
|
||||
>
|
||||
Process
|
||||
</motion.h2>
|
||||
<motion.p
|
||||
className="mt-2 text-sm text-neutral-400"
|
||||
initial={{ opacity: 0, y: 6 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ ...TRANSITIONS.base, delay: 0.05 }}
|
||||
>
|
||||
A simple path from idea to cinematic, performant delivery.
|
||||
</motion.p>
|
||||
</div>
|
||||
|
||||
{/* Timeline drawing */}
|
||||
<div className="relative">
|
||||
<motion.svg
|
||||
width="100%"
|
||||
height="220"
|
||||
viewBox="0 0 1200 220"
|
||||
className="hidden md:block"
|
||||
>
|
||||
<motion.path
|
||||
d="M 40 180 C 320 60, 880 300, 1160 80"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
className="text-neutral-700"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
style={{ pathLength }}
|
||||
/>
|
||||
</motion.svg>
|
||||
|
||||
<div className="mt-0 grid grid-cols-1 gap-6 md:-mt-16 md:grid-cols-4">
|
||||
<Stagger delayChildren={0.05}>
|
||||
{steps.map((s, i) => (
|
||||
<Reveal key={s.title} delay={i * 0.05} distance={16}>
|
||||
<article className="relative rounded-2xl glass p-5">
|
||||
<div className="mb-2 text-xs font-semibold uppercase tracking-wider text-neutral-400">
|
||||
{String(i + 1).padStart(2, "0")}
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-neutral-100">
|
||||
{s.title}
|
||||
</h3>
|
||||
<p className="mt-2 text-sm text-neutral-300">
|
||||
{s.desc}
|
||||
</p>
|
||||
</article>
|
||||
</Reveal>
|
||||
))}
|
||||
</Stagger>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
103
app/sections/TestimonialsSection.tsx
Normal file
103
app/sections/TestimonialsSection.tsx
Normal file
@ -0,0 +1,103 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { motion } from "motion/react";
|
||||
import { Parallax } from "@/components/parallax/Parallax";
|
||||
import { TRANSITIONS } from "@/lib/animation";
|
||||
|
||||
/**
|
||||
* Lightweight logo/text marquee with reveal-in animations.
|
||||
* Replace placeholder items with real logos as needed.
|
||||
*/
|
||||
export function TestimonialsSection() {
|
||||
const items = [
|
||||
"Biohazard VFX",
|
||||
"Fortura Data",
|
||||
"Cinematic Labs",
|
||||
"Nebula Studio",
|
||||
"Pixel Foundry",
|
||||
"Prisma Motion",
|
||||
];
|
||||
|
||||
return (
|
||||
<section
|
||||
id="testimonials"
|
||||
aria-label="Testimonials"
|
||||
className="relative w-full overflow-clip py-24 md:py-32"
|
||||
>
|
||||
<Parallax speed={0.05} className="pointer-events-none absolute inset-0 -z-10">
|
||||
<div className="absolute inset-0 bg-[radial-gradient(1100px_520px_at_50%_0%,rgba(255,255,255,0.08),transparent_70%)]" />
|
||||
</Parallax>
|
||||
|
||||
<div className="mx-auto w-full max-w-6xl px-6">
|
||||
<div className="mb-10 text-center">
|
||||
<motion.h2
|
||||
className="text-2xl sm:text-3xl md:text-4xl font-bold tracking-tight text-neutral-100"
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={TRANSITIONS.base}
|
||||
>
|
||||
Trusted by teams who care about craft
|
||||
</motion.h2>
|
||||
<motion.p
|
||||
className="mt-2 text-sm text-neutral-400"
|
||||
initial={{ opacity: 0, y: 6 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ ...TRANSITIONS.base, delay: 0.05 }}
|
||||
>
|
||||
Subtle marquee with tasteful depth and reveals.
|
||||
</motion.p>
|
||||
</div>
|
||||
|
||||
<div className="relative overflow-hidden rounded-2xl glass md:rounded-3xl">
|
||||
{/* Edge gradient masks */}
|
||||
<div className="pointer-events-none absolute inset-y-0 left-0 w-24 bg-gradient-to-r from-black to-transparent" />
|
||||
<div className="pointer-events-none absolute inset-y-0 right-0 w-24 bg-gradient-to-l from-black to-transparent" />
|
||||
|
||||
{/* Marquee row 1 */}
|
||||
<motion.div
|
||||
className="flex w-[200%] gap-8 py-6 will-change-transform"
|
||||
initial={{ x: 0 }}
|
||||
whileInView={{ x: ["0%", "-50%"] }}
|
||||
viewport={{ once: false, amount: 0.3 }}
|
||||
transition={{ duration: 20, ease: "linear", repeat: Infinity }}
|
||||
>
|
||||
{[...items, ...items].map((it, i) => (
|
||||
<LogoPill key={`a-${i}`} text={it} />
|
||||
))}
|
||||
</motion.div>
|
||||
|
||||
{/* Marquee row 2 (reverse) */}
|
||||
<motion.div
|
||||
className="flex w-[200%] gap-8 py-6 will-change-transform"
|
||||
initial={{ x: "-50%" }}
|
||||
whileInView={{ x: ["-50%", "0%"] }}
|
||||
viewport={{ once: false, amount: 0.3 }}
|
||||
transition={{ duration: 22, ease: "linear", repeat: Infinity }}
|
||||
>
|
||||
{[...items, ...items].map((it, i) => (
|
||||
<LogoPill key={`b-${i}`} text={it} />
|
||||
))}
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function LogoPill({ text }: { text: string }) {
|
||||
return (
|
||||
<motion.div
|
||||
className="inline-flex min-w-40 items-center justify-center rounded-full glass px-4 py-2 text-sm font-medium text-neutral-300"
|
||||
initial={{ opacity: 0, y: 6 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: false, amount: 0.6 }}
|
||||
whileHover={{ scale: 1.03 }}
|
||||
transition={TRANSITIONS.base}
|
||||
>
|
||||
{text}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
167
app/sections/WorkSection.tsx
Normal file
167
app/sections/WorkSection.tsx
Normal file
@ -0,0 +1,167 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { motion, useTransform, type MotionValue } from "motion/react";
|
||||
import { Pin } from "@/components/parallax/Pin";
|
||||
import { Parallax } from "@/components/parallax/Parallax";
|
||||
import { TRANSITIONS } from "@/lib/animation";
|
||||
|
||||
export function WorkSection() {
|
||||
return (
|
||||
<section id="work" aria-label="Selected Work" className="relative w-full">
|
||||
{/* Subtle ambient background */}
|
||||
<Parallax speed={0.04} className="pointer-events-none absolute inset-0 -z-10">
|
||||
<div className="absolute inset-0 bg-[radial-gradient(1200px_600px_at_10%_20%,rgba(255,255,255,0.06),transparent_70%)]" />
|
||||
</Parallax>
|
||||
|
||||
<Pin heightVH={400} className="w-full">
|
||||
{(progress) => <WorkPinnedContent progress={progress} />}
|
||||
</Pin>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function WorkPinnedContent({ progress }: { progress: MotionValue<number> }) {
|
||||
// Safe to use React hooks here (top-level of a component)
|
||||
const x = useTransform(progress, [0, 1], ["0%", "-400%"]);
|
||||
|
||||
return (
|
||||
<div className="relative h-full w-full overflow-hidden">
|
||||
{/* Section header overlays the sticky area */}
|
||||
<div className="pointer-events-none absolute left-1/2 top-10 z-20 -translate-x-1/2 text-center">
|
||||
<motion.h2
|
||||
className="text-balance text-2xl font-semibold tracking-tight text-neutral-100 sm:text-3xl md:text-4xl"
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={TRANSITIONS.base}
|
||||
>
|
||||
Highlights
|
||||
</motion.h2>
|
||||
<motion.p
|
||||
className="mt-2 text-sm text-neutral-400"
|
||||
initial={{ opacity: 0, y: 6 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ ...TRANSITIONS.base, delay: 0.05 }}
|
||||
>
|
||||
A scroll-pinned horizontal showcase powered by Lenis + Framer Motion.
|
||||
</motion.p>
|
||||
</div>
|
||||
|
||||
{/* Horizontal track */}
|
||||
<motion.div
|
||||
style={{ x }}
|
||||
className="absolute inset-0 flex h-full w-[500vw] items-center gap-[5vw] px-[10vw]"
|
||||
>
|
||||
<WorkCard
|
||||
title="Cinematic Portfolio"
|
||||
subtitle="Parallax-first hero and narrative scroll"
|
||||
/>
|
||||
<WorkCard
|
||||
title="Interactive Showcase"
|
||||
subtitle="Pinned scenes and composited layers"
|
||||
/>
|
||||
<WorkCard
|
||||
title="Motion System"
|
||||
subtitle="Variants, micro-interactions, and rhythm"
|
||||
/>
|
||||
<WorkCard
|
||||
title="A11y + Performance"
|
||||
subtitle="Prefers-reduced-motion, optimized images"
|
||||
/>
|
||||
<WorkCard
|
||||
title="Design Polish"
|
||||
subtitle="Grain, gradients, light bloom and depth"
|
||||
/>
|
||||
</motion.div>
|
||||
|
||||
{/* Edge gradient masks for an infinite feel */}
|
||||
<div className="pointer-events-none absolute inset-y-0 left-0 w-24 bg-gradient-to-r from-black to-transparent" />
|
||||
<div className="pointer-events-none absolute inset-y-0 right-0 w-24 bg-gradient-to-l from-black to-transparent" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function WorkCard({
|
||||
title,
|
||||
subtitle,
|
||||
}: {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
}) {
|
||||
return (
|
||||
<motion.article
|
||||
className="group relative h-[66vh] w-[80vw] max-w-[720px] overflow-hidden rounded-3xl glass-strong p-6"
|
||||
initial={{ opacity: 0, y: 24, scale: 0.98 }}
|
||||
whileInView={{ opacity: 1, y: 0, scale: 1 }}
|
||||
viewport={{ once: true, margin: "0px 0px -10% 0px" }}
|
||||
transition={TRANSITIONS.base}
|
||||
whileHover={{ scale: 1.02 }}
|
||||
>
|
||||
{/* Accent background layers with parallax depth */}
|
||||
<Parallax speed={0.08} className="pointer-events-none absolute -inset-20 -z-10">
|
||||
<div className="absolute inset-0 bg-[radial-gradient(600px_300px_at_70%_30%,rgba(255,255,255,0.08),transparent_70%)] blur-2xl" />
|
||||
</Parallax>
|
||||
<Parallax speed={-0.06} className="pointer-events-none absolute inset-0 -z-10">
|
||||
<div className="absolute inset-0 bg-[radial-gradient(600px_300px_at_70%_30%,rgba(255,255,255,0.08),transparent_70%)]" />
|
||||
</Parallax>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex h-full flex-col justify-between">
|
||||
<div>
|
||||
<motion.h3
|
||||
className="text-xl font-semibold tracking-tight text-neutral-100"
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={TRANSITIONS.base}
|
||||
>
|
||||
{title}
|
||||
</motion.h3>
|
||||
<motion.p
|
||||
className="mt-2 max-w-[48ch] text-sm text-neutral-300"
|
||||
initial={{ opacity: 0, y: 6 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ ...TRANSITIONS.base, delay: 0.05 }}
|
||||
>
|
||||
{subtitle}
|
||||
</motion.p>
|
||||
</div>
|
||||
|
||||
{/* Placeholder visual block; replace with Next/Image for real work later */}
|
||||
<motion.div
|
||||
className="relative mt-6 flex flex-1 items-center justify-center overflow-hidden rounded-2xl glass-strong"
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ ...TRANSITIONS.base, delay: 0.08 }}
|
||||
>
|
||||
<div className="absolute inset-0 bg-[linear-gradient(180deg,transparent,rgba(255,255,255,0.06)_40%,transparent_80%)]" />
|
||||
<div className="text-xs text-neutral-400">Project visual placeholder</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="mt-4 flex items-center justify-between">
|
||||
<motion.span
|
||||
className="text-xs text-neutral-400"
|
||||
initial={{ opacity: 0 }}
|
||||
whileInView={{ opacity: 1 }}
|
||||
viewport={{ once: true }}
|
||||
transition={TRANSITIONS.base}
|
||||
>
|
||||
Scroll to explore →
|
||||
</motion.span>
|
||||
<motion.button
|
||||
className="rounded-full glass px-3 py-1 text-xs font-medium transition-colors hover:opacity-95"
|
||||
whileHover={{ scale: 1.03 }}
|
||||
whileTap={{ scale: 0.97 }}
|
||||
>
|
||||
View case
|
||||
</motion.button>
|
||||
</div>
|
||||
</div>
|
||||
</motion.article>
|
||||
);
|
||||
}
|
||||
17
app/sitemap.ts
Normal file
17
app/sitemap.ts
Normal file
@ -0,0 +1,17 @@
|
||||
import { getBlogPosts } from './blog/utils'
|
||||
|
||||
export const baseUrl = 'http://localhost:3001'
|
||||
|
||||
export default async function sitemap() {
|
||||
let blogs = getBlogPosts().map((post) => ({
|
||||
url: `${baseUrl}/blog/${post.slug}`,
|
||||
lastModified: post.metadata.publishedAt,
|
||||
}))
|
||||
|
||||
let routes = ['', '/blog'].map((route) => ({
|
||||
url: `${baseUrl}${route}`,
|
||||
lastModified: new Date().toISOString().split('T')[0],
|
||||
}))
|
||||
|
||||
return [...routes, ...blogs]
|
||||
}
|
||||
22
components.json
Normal file
22
components.json
Normal file
@ -0,0 +1,22 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "new-york",
|
||||
"rsc": true,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "app/globals.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"iconLibrary": "lucide",
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"registries": {}
|
||||
}
|
||||
65
components/blog/PostCard.tsx
Normal file
65
components/blog/PostCard.tsx
Normal file
@ -0,0 +1,65 @@
|
||||
import Link from 'next/link'
|
||||
import { formatDate, getExcerpt, getReadingTime, type Post } from '@/app/blog/utils'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Card } from '@/components/ui/card'
|
||||
|
||||
type PostCardProps = {
|
||||
post: Post
|
||||
className?: string
|
||||
}
|
||||
|
||||
export default function PostCard({ post, className }: PostCardProps) {
|
||||
const { metadata, slug, content } = post
|
||||
const excerpt = getExcerpt(metadata.summary, content, 200)
|
||||
const reading = getReadingTime(content)
|
||||
|
||||
return (
|
||||
<Card
|
||||
className={cn(
|
||||
'group relative overflow-hidden transition-colors hover:bg-black/[0.04] dark:hover:bg-white/5',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<Link
|
||||
href={`/blog/${slug}`}
|
||||
aria-label={`Read: ${metadata.title}`}
|
||||
className="absolute inset-0"
|
||||
/>
|
||||
<div className="p-5">
|
||||
<header className="mb-3">
|
||||
<h3 className="text-lg font-semibold leading-tight tracking-tight text-neutral-900 dark:text-neutral-100 underline-offset-4 group-hover:underline">
|
||||
{metadata.title}
|
||||
</h3>
|
||||
</header>
|
||||
{excerpt && (
|
||||
<p className="mb-4 line-clamp-3 text-sm leading-6 text-neutral-700 dark:text-neutral-300">
|
||||
{excerpt}
|
||||
</p>
|
||||
)}
|
||||
<footer className="flex flex-wrap items-center gap-2 text-xs text-neutral-600 dark:text-neutral-400">
|
||||
<time dateTime={metadata.publishedAt}>{formatDate(metadata.publishedAt, false)}</time>
|
||||
<span aria-hidden="true">•</span>
|
||||
<span>{reading.text}</span>
|
||||
{Array.isArray(metadata.tags) && metadata.tags.length > 0 && (
|
||||
<>
|
||||
<span aria-hidden="true">•</span>
|
||||
<ul className="flex flex-wrap gap-1.5">
|
||||
{metadata.tags.slice(0, 3).map((tag) => (
|
||||
<li
|
||||
key={tag}
|
||||
className="rounded-full border border-black/10 bg-white/40 px-2 py-0.5 text-[11px] leading-5 dark:border-white/10 dark:bg-white/[0.06]"
|
||||
>
|
||||
{tag}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
<span className="ml-auto hidden text-neutral-400 transition-colors group-hover:text-neutral-600 dark:group-hover:text-neutral-300 sm:inline">
|
||||
→
|
||||
</span>
|
||||
</footer>
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
57
components/blog/PostHeader.tsx
Normal file
57
components/blog/PostHeader.tsx
Normal file
@ -0,0 +1,57 @@
|
||||
import { formatDate } from '@/app/blog/utils'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
type PostHeaderProps = {
|
||||
title: string
|
||||
publishedAt: string
|
||||
readingTimeText?: string
|
||||
tags?: string[]
|
||||
summary?: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
export default function PostHeader({
|
||||
title,
|
||||
publishedAt,
|
||||
readingTimeText,
|
||||
tags,
|
||||
summary,
|
||||
className,
|
||||
}: PostHeaderProps) {
|
||||
return (
|
||||
<header className={cn('mx-auto max-w-3xl', className)}>
|
||||
<h1 className="mb-3 text-3xl font-semibold leading-tight tracking-tighter text-neutral-900 dark:text-neutral-100 sm:text-4xl">
|
||||
{title}
|
||||
</h1>
|
||||
{summary ? (
|
||||
<p className="mb-4 text-base leading-7 text-neutral-700 dark:text-neutral-300">
|
||||
{summary}
|
||||
</p>
|
||||
) : null}
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs text-neutral-600 dark:text-neutral-400">
|
||||
<time dateTime={publishedAt}>{formatDate(publishedAt, false)}</time>
|
||||
{readingTimeText ? (
|
||||
<>
|
||||
<span aria-hidden="true">•</span>
|
||||
<span>{readingTimeText}</span>
|
||||
</>
|
||||
) : null}
|
||||
{Array.isArray(tags) && tags.length > 0 ? (
|
||||
<>
|
||||
<span aria-hidden="true">•</span>
|
||||
<ul className="flex flex-wrap gap-1.5">
|
||||
{tags.slice(0, 4).map((tag) => (
|
||||
<li
|
||||
key={tag}
|
||||
className="rounded-full border border-black/10 bg-white/40 px-2 py-0.5 text-[11px] leading-5 dark:border-white/10 dark:bg-white/[0.06]"
|
||||
>
|
||||
{tag}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
34
components/blog/ProgressBar.tsx
Normal file
34
components/blog/ProgressBar.tsx
Normal file
@ -0,0 +1,34 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { motion, useSpring } from 'motion/react'
|
||||
|
||||
export default function ProgressBar() {
|
||||
const [progress, setProgress] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
const update = () => {
|
||||
const { scrollTop, scrollHeight, clientHeight } = document.documentElement
|
||||
const max = scrollHeight - clientHeight
|
||||
const p = max > 0 ? scrollTop / max : 0
|
||||
setProgress(Math.min(1, Math.max(0, p)))
|
||||
}
|
||||
update()
|
||||
window.addEventListener('scroll', update, { passive: true })
|
||||
window.addEventListener('resize', update)
|
||||
return () => {
|
||||
window.removeEventListener('scroll', update)
|
||||
window.removeEventListener('resize', update)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const smooth = useSpring(progress, { stiffness: 140, damping: 24, mass: 0.2 })
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className="fixed left-0 top-0 z-40 h-0.5 origin-left bg-neutral-900 dark:bg-neutral-100"
|
||||
style={{ scaleX: smooth, width: '100%' }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)
|
||||
}
|
||||
61
components/footer.tsx
Normal file
61
components/footer.tsx
Normal file
@ -0,0 +1,61 @@
|
||||
function ArrowIcon() {
|
||||
return (
|
||||
<svg
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 12 12"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M2.07102 11.3494L0.963068 10.2415L9.2017 1.98864H2.83807L2.85227 0.454545H11.8438V9.46023H10.2955L10.3097 3.09659L2.07102 11.3494Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Footer() {
|
||||
return (
|
||||
<footer className="mb-16">
|
||||
<ul className="font-sm mt-8 flex flex-col space-x-0 space-y-2 text-neutral-600 md:flex-row md:space-x-4 md:space-y-0 dark:text-neutral-300">
|
||||
<li>
|
||||
<a
|
||||
className="flex items-center transition-all hover:text-neutral-800 dark:hover:text-neutral-100"
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
href="/rss"
|
||||
>
|
||||
<ArrowIcon />
|
||||
<p className="ml-2 h-7">rss</p>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
className="flex items-center transition-all hover:text-neutral-800 dark:hover:text-neutral-100"
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
href="https://github.com/vercel/next.js"
|
||||
>
|
||||
<ArrowIcon />
|
||||
<p className="ml-2 h-7">github</p>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
className="flex items-center transition-all hover:text-neutral-800 dark:hover:text-neutral-100"
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
href="https://vercel.com/templates/next.js/portfolio-starter-kit"
|
||||
>
|
||||
<ArrowIcon />
|
||||
<p className="ml-2 h-7">view source</p>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
<p className="mt-8 text-neutral-600 dark:text-neutral-300">
|
||||
© {new Date().getFullYear()} MIT Licensed
|
||||
</p>
|
||||
</footer>
|
||||
)
|
||||
}
|
||||
109
components/mdx.tsx
Normal file
109
components/mdx.tsx
Normal file
@ -0,0 +1,109 @@
|
||||
import Link from 'next/link'
|
||||
import Image from 'next/image'
|
||||
import { MDXRemote } from 'next-mdx-remote/rsc'
|
||||
import { highlight } from 'sugar-high'
|
||||
import React from 'react'
|
||||
|
||||
function Table({ data }) {
|
||||
let headers = data.headers.map((header, index) => (
|
||||
<th key={index}>{header}</th>
|
||||
))
|
||||
let rows = data.rows.map((row, index) => (
|
||||
<tr key={index}>
|
||||
{row.map((cell, cellIndex) => (
|
||||
<td key={cellIndex}>{cell}</td>
|
||||
))}
|
||||
</tr>
|
||||
))
|
||||
|
||||
return (
|
||||
<table>
|
||||
<thead>
|
||||
<tr>{headers}</tr>
|
||||
</thead>
|
||||
<tbody>{rows}</tbody>
|
||||
</table>
|
||||
)
|
||||
}
|
||||
|
||||
function CustomLink(props) {
|
||||
let href = props.href
|
||||
|
||||
if (href.startsWith('/')) {
|
||||
return (
|
||||
<Link href={href} {...props}>
|
||||
{props.children}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
if (href.startsWith('#')) {
|
||||
return <a {...props} />
|
||||
}
|
||||
|
||||
return <a target="_blank" rel="noopener noreferrer" {...props} />
|
||||
}
|
||||
|
||||
function RoundedImage(props) {
|
||||
return <Image alt={props.alt} className="rounded-lg" {...props} />
|
||||
}
|
||||
|
||||
function Code({ children, ...props }) {
|
||||
let codeHTML = highlight(children)
|
||||
return <code dangerouslySetInnerHTML={{ __html: codeHTML }} {...props} />
|
||||
}
|
||||
|
||||
function slugify(str) {
|
||||
return str
|
||||
.toString()
|
||||
.toLowerCase()
|
||||
.trim() // Remove whitespace from both ends of a string
|
||||
.replace(/\s+/g, '-') // Replace spaces with -
|
||||
.replace(/&/g, '-and-') // Replace & with 'and'
|
||||
.replace(/[^\w\-]+/g, '') // Remove all non-word characters except for -
|
||||
.replace(/\-\-+/g, '-') // Replace multiple - with single -
|
||||
}
|
||||
|
||||
function createHeading(level) {
|
||||
const Heading = ({ children }) => {
|
||||
let slug = slugify(children)
|
||||
return React.createElement(
|
||||
`h${level}`,
|
||||
{ id: slug },
|
||||
[
|
||||
React.createElement('a', {
|
||||
href: `#${slug}`,
|
||||
key: `link-${slug}`,
|
||||
className: 'anchor',
|
||||
}),
|
||||
],
|
||||
children
|
||||
)
|
||||
}
|
||||
|
||||
Heading.displayName = `Heading${level}`
|
||||
|
||||
return Heading
|
||||
}
|
||||
|
||||
let components = {
|
||||
h1: createHeading(1),
|
||||
h2: createHeading(2),
|
||||
h3: createHeading(3),
|
||||
h4: createHeading(4),
|
||||
h5: createHeading(5),
|
||||
h6: createHeading(6),
|
||||
Image: RoundedImage,
|
||||
a: CustomLink,
|
||||
code: Code,
|
||||
Table,
|
||||
}
|
||||
|
||||
export function CustomMDX(props) {
|
||||
return (
|
||||
<MDXRemote
|
||||
{...props}
|
||||
components={{ ...components, ...(props.components || {}) }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
36
components/motion/Reveal.tsx
Normal file
36
components/motion/Reveal.tsx
Normal file
@ -0,0 +1,36 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { motion } from "motion/react";
|
||||
import type { Variants } from "motion/react";
|
||||
import { fadeInUp } from "@/lib/animation";
|
||||
|
||||
type RevealProps = {
|
||||
delay?: number;
|
||||
distance?: number;
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
once?: boolean;
|
||||
};
|
||||
|
||||
export function Reveal({
|
||||
delay = 0,
|
||||
distance = 20,
|
||||
className,
|
||||
children,
|
||||
once = true,
|
||||
}: RevealProps) {
|
||||
const variants: Variants = fadeInUp(delay, distance);
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className={className}
|
||||
initial="initial"
|
||||
whileInView="animate"
|
||||
viewport={{ once, margin: "0px 0px -10% 0px" }}
|
||||
variants={variants}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
66
components/motion/SplitTextReveal.tsx
Normal file
66
components/motion/SplitTextReveal.tsx
Normal file
@ -0,0 +1,66 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { motion } from "motion/react";
|
||||
import type { Variants } from "motion/react";
|
||||
import { TRANSITIONS } from "@/lib/animation";
|
||||
|
||||
type SplitTextRevealProps = {
|
||||
text: string;
|
||||
className?: string;
|
||||
once?: boolean;
|
||||
wordClassName?: string;
|
||||
stagger?: number;
|
||||
};
|
||||
|
||||
export function SplitTextReveal({
|
||||
text,
|
||||
className,
|
||||
once = true,
|
||||
wordClassName,
|
||||
stagger = 0.06,
|
||||
}: SplitTextRevealProps) {
|
||||
const words = text.trim().split(/\s+/);
|
||||
|
||||
const container: Variants = {
|
||||
initial: {},
|
||||
animate: {
|
||||
transition: {
|
||||
staggerChildren: stagger,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const child: Variants = {
|
||||
initial: { opacity: 0, y: 8, filter: "blur(6px)" },
|
||||
animate: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
filter: "blur(0px)",
|
||||
transition: TRANSITIONS.base,
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.span
|
||||
className={className}
|
||||
style={{ display: "inline-block" }}
|
||||
variants={container}
|
||||
initial="initial"
|
||||
whileInView="animate"
|
||||
viewport={{ once, margin: "0px 0px -10% 0px" }}
|
||||
>
|
||||
{words.map((w, i) => (
|
||||
<motion.span
|
||||
key={`${w}-${i}`}
|
||||
className={wordClassName}
|
||||
style={{ display: "inline-block", willChange: "transform" }}
|
||||
variants={child}
|
||||
>
|
||||
{w}
|
||||
{i < words.length - 1 ? " " : ""}
|
||||
</motion.span>
|
||||
))}
|
||||
</motion.span>
|
||||
);
|
||||
}
|
||||
35
components/motion/Stagger.tsx
Normal file
35
components/motion/Stagger.tsx
Normal file
@ -0,0 +1,35 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { motion } from "motion/react";
|
||||
import type { Variants } from "motion/react";
|
||||
import { staggerContainer } from "@/lib/animation";
|
||||
|
||||
type StaggerProps = {
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
stagger?: number;
|
||||
delayChildren?: number;
|
||||
once?: boolean;
|
||||
};
|
||||
|
||||
export function Stagger({
|
||||
className,
|
||||
children,
|
||||
stagger = 0.08,
|
||||
delayChildren = 0,
|
||||
once = true,
|
||||
}: StaggerProps) {
|
||||
const variants: Variants = staggerContainer(stagger, delayChildren);
|
||||
return (
|
||||
<motion.div
|
||||
className={className}
|
||||
initial="initial"
|
||||
whileInView="animate"
|
||||
viewport={{ once, margin: "0px 0px -10% 0px" }}
|
||||
variants={variants}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
40
components/nav.tsx
Normal file
40
components/nav.tsx
Normal file
@ -0,0 +1,40 @@
|
||||
import Link from 'next/link'
|
||||
|
||||
const navItems = {
|
||||
'/': {
|
||||
name: 'home',
|
||||
},
|
||||
'/blog': {
|
||||
name: 'blog',
|
||||
},
|
||||
'https://vercel.com/templates/next.js/portfolio-starter-kit': {
|
||||
name: 'deploy',
|
||||
},
|
||||
}
|
||||
|
||||
export function Navbar() {
|
||||
return (
|
||||
<aside className="-ml-[8px] mb-16 tracking-tight">
|
||||
<div className="lg:sticky lg:top-20">
|
||||
<nav
|
||||
className="flex flex-row items-start relative px-0 pb-0 fade md:overflow-auto scroll-pr-6 md:relative"
|
||||
id="nav"
|
||||
>
|
||||
<div className="flex flex-row space-x-0 pr-10">
|
||||
{Object.entries(navItems).map(([path, { name }]) => {
|
||||
return (
|
||||
<Link
|
||||
key={path}
|
||||
href={path}
|
||||
className="transition-all hover:text-neutral-800 dark:hover:text-neutral-200 flex align-middle relative py-1 px-2 m-1"
|
||||
>
|
||||
{name}
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
23
components/parallax/Parallax.tsx
Normal file
23
components/parallax/Parallax.tsx
Normal file
@ -0,0 +1,23 @@
|
||||
"use client";
|
||||
|
||||
import React, { useRef } from "react";
|
||||
import { motion } from "motion/react";
|
||||
import { useParallax } from "@/lib/scroll";
|
||||
|
||||
type ParallaxProps = {
|
||||
speed?: number; // positive moves with scroll, negative opposite
|
||||
axis?: "y" | "x";
|
||||
className?: string;
|
||||
children?: React.ReactNode;
|
||||
} & React.ComponentProps<typeof motion.div>;
|
||||
|
||||
export function Parallax({ speed = 0.2, axis = "y", className, children, ...rest }: ParallaxProps) {
|
||||
const localRef = useRef<HTMLDivElement>(null!);
|
||||
const style = useParallax(localRef, speed, axis);
|
||||
|
||||
return (
|
||||
<motion.div ref={localRef} style={style} className={className} {...rest}>
|
||||
{children}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
47
components/parallax/Pin.tsx
Normal file
47
components/parallax/Pin.tsx
Normal file
@ -0,0 +1,47 @@
|
||||
"use client";
|
||||
|
||||
import React, { useRef } from "react";
|
||||
import { type MotionValue } from "motion/react";
|
||||
import { useSectionProgress } from "@/lib/scroll";
|
||||
|
||||
type PinProps = {
|
||||
/**
|
||||
* Height of the pin section in viewport heights.
|
||||
* 300 means the section is 300vh tall, so the sticky area lasts for 3 screens.
|
||||
*/
|
||||
heightVH?: number;
|
||||
className?: string;
|
||||
/**
|
||||
* Render prop that receives a MotionValue<number> progress [0..1]
|
||||
* representing how far through the pin section the user has scrolled.
|
||||
*/
|
||||
children: (progress: MotionValue<number>) => React.ReactNode;
|
||||
};
|
||||
|
||||
/**
|
||||
* Pin creates a tall section with an inner sticky container.
|
||||
* It computes a normalized progress [0..1] across the entire section using Lenis-driven scroll updates.
|
||||
*/
|
||||
export function Pin({ heightVH = 300, className, children }: PinProps) {
|
||||
const sectionRef = useRef<HTMLElement>(null!);
|
||||
const progress = useSectionProgress(sectionRef);
|
||||
|
||||
return (
|
||||
<section
|
||||
ref={sectionRef}
|
||||
className={className}
|
||||
style={{
|
||||
height: `${heightVH}vh`,
|
||||
position: "relative",
|
||||
}}
|
||||
aria-hidden={false}
|
||||
>
|
||||
<div
|
||||
className="sticky top-0 h-screen w-full"
|
||||
style={{ willChange: "transform" }}
|
||||
>
|
||||
{children(progress)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
37
components/posts.tsx
Normal file
37
components/posts.tsx
Normal file
@ -0,0 +1,37 @@
|
||||
import Link from 'next/link'
|
||||
import { formatDate, getBlogPosts } from '@/app/blog/utils'
|
||||
|
||||
export function BlogPosts() {
|
||||
const allBlogs = getBlogPosts()
|
||||
|
||||
return (
|
||||
<ul className="divide-y divide-black/5 dark:divide-white/10">
|
||||
{allBlogs
|
||||
.sort((a, b) => {
|
||||
if (new Date(a.metadata.publishedAt) > new Date(b.metadata.publishedAt)) {
|
||||
return -1
|
||||
}
|
||||
return 1
|
||||
})
|
||||
.map((post) => (
|
||||
<li key={post.slug}>
|
||||
<Link
|
||||
className="group flex items-baseline justify-between gap-4 rounded-md px-2 py-2 transition-colors hover:bg-black/[0.04] dark:hover:bg-white/5"
|
||||
href={`/blog/${post.slug}`}
|
||||
aria-label={`Read: ${post.metadata.title}`}
|
||||
>
|
||||
<span className="w-32 flex-none text-xs text-neutral-600 dark:text-neutral-400 tabular-nums">
|
||||
{formatDate(post.metadata.publishedAt, false)}
|
||||
</span>
|
||||
<span className="flex-1 text-neutral-900 dark:text-neutral-100 tracking-tight underline-offset-4 group-hover:underline">
|
||||
{post.metadata.title}
|
||||
</span>
|
||||
<span className="hidden sm:inline text-neutral-400 transition-colors group-hover:text-neutral-600 dark:group-hover:text-neutral-300">
|
||||
→
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
61
components/ui/card.tsx
Normal file
61
components/ui/card.tsx
Normal file
@ -0,0 +1,61 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export function Card({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-lg border border-black/10 dark:border-white/10',
|
||||
'bg-white/60 dark:bg-white/[0.03] shadow-sm backdrop-blur-sm',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function CardHeader({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return <div className={cn('px-5 py-4', className)} {...props} />
|
||||
}
|
||||
|
||||
export function CardTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLHeadingElement>) {
|
||||
return (
|
||||
<h3
|
||||
className={cn(
|
||||
'text-sm font-medium uppercase tracking-wider text-neutral-900 dark:text-neutral-100',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function CardDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLParagraphElement>) {
|
||||
return (
|
||||
<p
|
||||
className={cn('text-sm text-neutral-700 dark:text-neutral-300', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function CardContent({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return <div className={cn('px-5 pb-5', className)} {...props} />
|
||||
}
|
||||
143
components/ui/dialog.tsx
Normal file
143
components/ui/dialog.tsx
Normal file
@ -0,0 +1,143 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
||||
import { XIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Dialog({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
|
||||
return <DialogPrimitive.Root data-slot="dialog" {...props} />
|
||||
}
|
||||
|
||||
function DialogTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
|
||||
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
|
||||
}
|
||||
|
||||
function DialogPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
|
||||
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
|
||||
}
|
||||
|
||||
function DialogClose({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
|
||||
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
|
||||
}
|
||||
|
||||
function DialogOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
|
||||
return (
|
||||
<DialogPrimitive.Overlay
|
||||
data-slot="dialog-overlay"
|
||||
className={cn(
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogContent({
|
||||
className,
|
||||
children,
|
||||
showCloseButton = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
|
||||
showCloseButton?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DialogPortal data-slot="dialog-portal">
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
data-slot="dialog-content"
|
||||
className={cn(
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close
|
||||
data-slot="dialog-close"
|
||||
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
|
||||
>
|
||||
<XIcon />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-header"
|
||||
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-footer"
|
||||
className={cn(
|
||||
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
|
||||
return (
|
||||
<DialogPrimitive.Title
|
||||
data-slot="dialog-title"
|
||||
className={cn("text-lg leading-none font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
|
||||
return (
|
||||
<DialogPrimitive.Description
|
||||
data-slot="dialog-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
}
|
||||
139
components/ui/sheet.tsx
Normal file
139
components/ui/sheet.tsx
Normal file
@ -0,0 +1,139 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as SheetPrimitive from "@radix-ui/react-dialog"
|
||||
import { XIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
|
||||
return <SheetPrimitive.Root data-slot="sheet" {...props} />
|
||||
}
|
||||
|
||||
function SheetTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
|
||||
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
|
||||
}
|
||||
|
||||
function SheetClose({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Close>) {
|
||||
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
|
||||
}
|
||||
|
||||
function SheetPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Portal>) {
|
||||
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
|
||||
}
|
||||
|
||||
function SheetOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
|
||||
return (
|
||||
<SheetPrimitive.Overlay
|
||||
data-slot="sheet-overlay"
|
||||
className={cn(
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetContent({
|
||||
className,
|
||||
children,
|
||||
side = "right",
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
|
||||
side?: "top" | "right" | "bottom" | "left"
|
||||
}) {
|
||||
return (
|
||||
<SheetPortal>
|
||||
<SheetOverlay />
|
||||
<SheetPrimitive.Content
|
||||
data-slot="sheet-content"
|
||||
className={cn(
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
|
||||
side === "right" &&
|
||||
"data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm",
|
||||
side === "left" &&
|
||||
"data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm",
|
||||
side === "top" &&
|
||||
"data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b",
|
||||
side === "bottom" &&
|
||||
"data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SheetPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none">
|
||||
<XIcon className="size-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</SheetPrimitive.Close>
|
||||
</SheetPrimitive.Content>
|
||||
</SheetPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sheet-header"
|
||||
className={cn("flex flex-col gap-1.5 p-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sheet-footer"
|
||||
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Title>) {
|
||||
return (
|
||||
<SheetPrimitive.Title
|
||||
data-slot="sheet-title"
|
||||
className={cn("text-foreground font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Description>) {
|
||||
return (
|
||||
<SheetPrimitive.Description
|
||||
data-slot="sheet-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Sheet,
|
||||
SheetTrigger,
|
||||
SheetClose,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetFooter,
|
||||
SheetTitle,
|
||||
SheetDescription,
|
||||
}
|
||||
168
implementation_plan.md
Normal file
168
implementation_plan.md
Normal file
@ -0,0 +1,168 @@
|
||||
# Implementation Plan
|
||||
|
||||
[Overview]
|
||||
Add a Doom emulator that launches in a modal window (“floating over the page”) when the user clicks a new “running on a potato” entry in the existing sidebar menu. The emulator will be loaded on-demand, client-only, and isolated from SSR to protect page performance and stability.
|
||||
|
||||
Multiple paragraphs outlining the scope, context, and high-level approach. Explain why this implementation is needed and how it fits into the existing system.
|
||||
- This Next.js 15 app uses the App Router, React 19, Tailwind, and several client-side providers (Lenis, Motion). The request is to add a lightweight, non-intrusive Doom experience without altering the primary site navigation. The user wants a single menu item in the sidebar labeled “running on a potato” that, when clicked, opens an overlay modal containing the emulator.
|
||||
- The overlay will be implemented as a client-only modal and mounted at the root layout so it renders above all content, and remains accessible across pages. We will add a React Context Provider to manage the “open/close” state of the modal globally. The sidebar menu will receive an action hook to open the modal.
|
||||
- For the emulator engine, we will use js-dos (DOSBox compiled to WebAssembly) via the npm package “js-dos”. It can boot a packaged .jsdos archive containing DOS Doom and run entirely in the browser. This approach avoids adding complex custom build steps and does not require special COOP/COEP headers. The emulator will be lazy-loaded via dynamic import to keep initial page loads fast.
|
||||
- To provide an immediate out-of-the-box experience, the emulator will load a remotely hosted demo .jsdos archive from the official js-dos CDN (subject to its availability). If remote loading fails (network/CORS), the overlay will show a prompt allowing the user to drag-and-drop or select a local .jsdos archive to run. This keeps the feature functional while avoiding bundling large binary assets into the repository.
|
||||
- Accessibility and usability: the modal will trap focus, provide keyboard controls (ESC to close), restore focus on close, and disable background scroll while open.
|
||||
|
||||
[Types]
|
||||
Single sentence describing the type system changes.
|
||||
We will add a small set of TypeScript types for the modal provider context and emulator configuration.
|
||||
|
||||
Detailed type definitions, interfaces, enums, or data structures with complete specifications. Include field names, types, validation rules, and relationships.
|
||||
- app/providers/DoomOverlayProvider.tsx
|
||||
- export type DoomOverlayContextValue = {
|
||||
isOpen: boolean;
|
||||
open: () => void;
|
||||
close: () => void;
|
||||
}
|
||||
- Constraints:
|
||||
- isOpen: true when modal is visible.
|
||||
- open/close: stable callbacks; must be usable from any client component.
|
||||
- app/components/doom/JsDosPlayer.tsx
|
||||
- export type JsDosPlayerProps = {
|
||||
zipUrl?: string; // optional remote .jsdos archive URL
|
||||
className?: string;
|
||||
}
|
||||
- export type JsDosHandle = {
|
||||
stop: () => void; // shutdown emulator and free resources
|
||||
}
|
||||
- Emulator engine choice type:
|
||||
- export enum DoomEngine { JsDos = "js-dos" }
|
||||
- export type DoomConfig = { engine: DoomEngine; jsdos?: { zipUrl?: string } }
|
||||
|
||||
[Files]
|
||||
Single sentence describing file modifications.
|
||||
We will add a provider, modal overlay, emulator player component, and update the sidebar menu and root layout to integrate the overlay trigger and mount point.
|
||||
|
||||
Detailed breakdown:
|
||||
- New files to be created (with full paths and purpose)
|
||||
1) app/providers/DoomOverlayProvider.tsx
|
||||
- Purpose: Client-side React context that exposes open/close state and actions for the Doom modal. Wraps children so any component can open the overlay.
|
||||
2) app/components/overlay/Modal.tsx
|
||||
- Purpose: Accessible, reusable modal component using a portal, focus trap, ESC-to-close, backdrop click-to-close, and scroll lock.
|
||||
3) app/components/doom/DoomOverlay.tsx
|
||||
- Purpose: The top-level container rendered inside the modal. It displays loading/error states and hosts the emulator player. Includes a close button and optional instructions if remote loading fails.
|
||||
4) app/components/doom/JsDosPlayer.tsx
|
||||
- Purpose: Client-only component that lazy-loads “js-dos”, renders the emulator surface, runs a provided .jsdos archive via a URL, and tears down cleanly on unmount.
|
||||
- Existing files to be modified (with specific changes)
|
||||
1) app/layout.tsx
|
||||
- Wrap current body content with <DoomOverlayProvider>. Mount <DoomOverlay /> near the end of <body> (still inside providers) so it renders above app content, but shares styles/providers.
|
||||
2) app/components/sidebar-menu.tsx
|
||||
- Add a new “running on a potato” menu action above or below current items (About/Writing/Contact).
|
||||
- This should be a button (not a Link) that calls open() from DoomOverlayProvider to display the modal. Maintain current styling conventions via cn() and Tailwind.
|
||||
- Files to be deleted or moved
|
||||
- None.
|
||||
- Configuration file updates
|
||||
- package.json (Dependencies): add "js-dos".
|
||||
- No changes to next.config.ts or tsconfig.json are required for this plan.
|
||||
|
||||
[Functions]
|
||||
Single sentence describing function modifications.
|
||||
We will add modal state management functions, emulator lifecycle controls, and integrate a new onclick handler in the sidebar menu.
|
||||
|
||||
Detailed breakdown:
|
||||
- New functions (name, signature, file path, purpose)
|
||||
1) open ((): void) — app/providers/DoomOverlayProvider.tsx
|
||||
- Purpose: Set modal open state to true.
|
||||
2) close ((): void) — app/providers/DoomOverlayProvider.tsx
|
||||
- Purpose: Set modal open state to false.
|
||||
3) DoomOverlay ((): JSX.Element) — app/components/doom/DoomOverlay.tsx
|
||||
- Purpose: Renders Modal and inside it lazy-loads the emulator (JsDosPlayer). Handles errors, shows loading fallback, exposes close control.
|
||||
4) JsDosPlayer ((props: JsDosPlayerProps) => JSX.Element) — app/components/doom/JsDosPlayer.tsx
|
||||
- Purpose: Initializes js-dos in a container div/canvas and runs a .jsdos ZIP archive via props.zipUrl. Disposes emulator instance on unmount.
|
||||
5) Modal ((props: { open: boolean; onClose: () => void; children: React.ReactNode; title?: string; className?: string }) => JSX.Element) — app/components/overlay/Modal.tsx
|
||||
- Purpose: Accessible modal with portal, focus trap, backdrop, escape close, body scroll lock.
|
||||
- Modified functions (exact name, current file path, required changes)
|
||||
1) default export SidebarMenu — app/components/sidebar-menu.tsx
|
||||
- Add one extra item rendered as a button:
|
||||
- Label: "running on a potato"
|
||||
- onClick: calls open() via DoomOverlayProvider context
|
||||
- Style: consistent with other menu items (Tailwind classes), ensure focus-visible styles for accessibility.
|
||||
- Removed functions (name, file path, reason, migration strategy)
|
||||
- None.
|
||||
|
||||
[Classes]
|
||||
Single sentence describing class modifications.
|
||||
No class-based components are used; all additions are functional React components.
|
||||
|
||||
Detailed breakdown:
|
||||
- New classes
|
||||
- None.
|
||||
- Modified classes
|
||||
- None.
|
||||
- Removed classes
|
||||
- None.
|
||||
|
||||
[Dependencies]
|
||||
Single sentence describing dependency modifications.
|
||||
We will add js-dos to support running Doom in-browser via a WASM-powered DOSBox.
|
||||
|
||||
Details of new packages, version changes, and integration requirements.
|
||||
- Add: "js-dos": "^7.5.0" (or latest v7)
|
||||
- Import usage in client-only component:
|
||||
- import "js-dos/css/js-dos.css"
|
||||
- const { Dos } = await import("js-dos")
|
||||
- Runtime:
|
||||
- Provide a container element; initialize: const dos = Dos(container)
|
||||
- Run: dos.run(zipUrl)
|
||||
- No special COOP/COEP headers required for basic usage.
|
||||
- Asset loading:
|
||||
- Default: remote demo archive (subject to availability), e.g. https://v8.js-dos.com/v7/build/doom.jsdos
|
||||
- Fallback: file input to select a local .jsdos package. We will show instructions in the overlay if remote load fails.
|
||||
- No changes to Next.js, React, Tailwind, or other existing dependencies.
|
||||
|
||||
[Testing]
|
||||
Single sentence describing testing approach.
|
||||
We will verify overlay behavior, emulator load success and teardown, and ensure no SSR/runtime errors across pages.
|
||||
|
||||
Test file requirements, existing test modifications, and validation strategies.
|
||||
- Manual QA (dev server):
|
||||
- Open site, click “running on a potato” in sidebar:
|
||||
- Modal opens, background scroll locked, focus lands on modal.
|
||||
- Emulator loads and becomes interactive; performance acceptable.
|
||||
- Close via ESC, backdrop click (if enabled), or close button — focus returns to triggering button.
|
||||
- Network-offline test:
|
||||
- Remote .jsdos load should fail gracefully; overlay shows a message and a file picker to choose a local .jsdos archive. After selecting, emulator runs.
|
||||
- Navigation test:
|
||||
- Navigate between sections while modal closed; ensure global provider doesn’t interfere with Lenis/scroll.
|
||||
- Accessibility:
|
||||
- Confirm appropriate aria attributes, focus management, and keyboard support.
|
||||
- Performance:
|
||||
- Confirm dynamic import defers js-dos until modal opens.
|
||||
- Confirm emulator stops and frees resources when modal closes.
|
||||
|
||||
[Implementation Order]
|
||||
Single sentence describing the implementation sequence.
|
||||
Implement provider and modal primitives first, then the overlay and emulator, finally wire up the sidebar, and add the dependency.
|
||||
|
||||
Numbered steps showing the logical order of changes to minimize conflicts and ensure successful integration.
|
||||
1) Add dependency: js-dos to package.json and install.
|
||||
2) Create app/providers/DoomOverlayProvider.tsx with context, open/close, and a useDoomOverlay() hook.
|
||||
3) Create app/components/overlay/Modal.tsx with portal, focus trap, ESC handling, backdrop, and scroll lock.
|
||||
4) Create app/components/doom/JsDosPlayer.tsx that:
|
||||
- Dynamically imports "js-dos" on mount (client-only).
|
||||
- Imports "js-dos/css/js-dos.css".
|
||||
- Initializes Dos(container) and calls .run(zipUrl).
|
||||
- Cleans up on unmount or when parent closes.
|
||||
5) Create app/components/doom/DoomOverlay.tsx that:
|
||||
- Reads overlay state from context.
|
||||
- Uses Modal to render content.
|
||||
- Attempts to load remote .jsdos: https://v8.js-dos.com/v7/build/doom.jsdos
|
||||
- If loading fails, renders a file input and drag-and-drop area to run a local .jsdos file.
|
||||
- Provides close control UI.
|
||||
6) Modify app/layout.tsx to wrap existing providers with <DoomOverlayProvider> and mount <DoomOverlay /> near the end of <body>.
|
||||
7) Modify app/components/sidebar-menu.tsx to add a new button-styled item labeled “running on a potato” that calls open() from the provider.
|
||||
8) Run dev server and test:
|
||||
- Modal open/close behavior.
|
||||
- Emulator loads, runs, and shuts down cleanly.
|
||||
- Accessibility and performance checks.
|
||||
9) Optional polish:
|
||||
- Add loading spinner.
|
||||
- Persist last used source (remote vs local) in session storage.
|
||||
- Add instructions link about providing your own .jsdos archive if desired.
|
||||
52
lib/animation.ts
Normal file
52
lib/animation.ts
Normal file
@ -0,0 +1,52 @@
|
||||
import type { Variants, Transition } from "motion/react";
|
||||
|
||||
export const EASE: [number, number, number, number] = [0.2, 0.8, 0.2, 1];
|
||||
|
||||
export const TRANSITIONS = {
|
||||
fast: { duration: 0.3, ease: EASE } as Transition,
|
||||
base: { duration: 0.6, ease: EASE } as Transition,
|
||||
slow: { duration: 0.9, ease: EASE } as Transition,
|
||||
springSoft: { type: "spring", stiffness: 120, damping: 18 } as Transition,
|
||||
springFirm: { type: "spring", stiffness: 200, damping: 22 } as Transition,
|
||||
};
|
||||
|
||||
export function fadeInUp(delay = 0, distance = 20): Variants {
|
||||
return {
|
||||
initial: { opacity: 0, y: distance, filter: "blur(4px)" },
|
||||
animate: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
filter: "blur(0px)",
|
||||
transition: { ...TRANSITIONS.base, delay },
|
||||
},
|
||||
exit: { opacity: 0, y: -distance * 0.5, transition: TRANSITIONS.fast },
|
||||
};
|
||||
}
|
||||
|
||||
export function scalePop(delay = 0): Variants {
|
||||
return {
|
||||
initial: { opacity: 0, scale: 0.9 },
|
||||
animate: { opacity: 1, scale: 1, transition: { ...TRANSITIONS.springSoft, delay } },
|
||||
exit: { opacity: 0, scale: 0.96, transition: TRANSITIONS.fast },
|
||||
};
|
||||
}
|
||||
|
||||
export function staggerContainer(stagger = 0.08, delayChildren = 0): Variants {
|
||||
return {
|
||||
initial: {},
|
||||
animate: {
|
||||
transition: {
|
||||
staggerChildren: stagger,
|
||||
delayChildren,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function blurReveal(delay = 0): Variants {
|
||||
return {
|
||||
initial: { opacity: 0, y: 8, filter: "blur(6px)" },
|
||||
animate: { opacity: 1, y: 0, filter: "blur(0px)", transition: { ...TRANSITIONS.base, delay } },
|
||||
exit: { opacity: 0, y: 8, filter: "blur(6px)", transition: TRANSITIONS.fast },
|
||||
};
|
||||
}
|
||||
137
lib/scroll.ts
Normal file
137
lib/scroll.ts
Normal file
@ -0,0 +1,137 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import { useMotionValue, type MotionValue } from "motion/react";
|
||||
import { useScrollContext } from "@/app/providers/LenisProvider";
|
||||
|
||||
/**
|
||||
* Math utilities
|
||||
*/
|
||||
export function clamp(n: number, min = 0, max = 1) {
|
||||
return Math.min(max, Math.max(min, n));
|
||||
}
|
||||
|
||||
export function lerp(a: number, b: number, t: number) {
|
||||
return a + (b - a) * t;
|
||||
}
|
||||
|
||||
export function mapRange(
|
||||
inMin: number,
|
||||
inMax: number,
|
||||
outMin: number,
|
||||
outMax: number,
|
||||
v: number,
|
||||
clampOutput = true,
|
||||
) {
|
||||
const t = (v - inMin) / (inMax - inMin || 1);
|
||||
const m = outMin + (outMax - outMin) * t;
|
||||
return clampOutput ? clamp(m, Math.min(outMin, outMax), Math.max(outMin, outMax)) : m;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a MotionValue<number> that represents normalized progress [0..1]
|
||||
* for the given section element as it scrolls through the viewport.
|
||||
*
|
||||
* Progress is 0 when the section just touches the bottom of viewport and
|
||||
* 1 when it has completely exited at the top. The range used is
|
||||
* (section height + viewport height) to distribute progress smoothly.
|
||||
*/
|
||||
export function useSectionProgress<T extends HTMLElement>(
|
||||
ref: React.RefObject<T>,
|
||||
): MotionValue<number> {
|
||||
const { scrollY } = useScrollContext();
|
||||
const progress = useMotionValue(0);
|
||||
const viewportHRef = useRef<number>(typeof window !== "undefined" ? window.innerHeight : 0);
|
||||
|
||||
useEffect(() => {
|
||||
const onResize = () => {
|
||||
viewportHRef.current = window.innerHeight;
|
||||
// force update after resize
|
||||
update();
|
||||
};
|
||||
window.addEventListener("resize", onResize);
|
||||
return () => window.removeEventListener("resize", onResize);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const update = () => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
const rect = el.getBoundingClientRect();
|
||||
const viewport = viewportHRef.current || window.innerHeight || 0;
|
||||
const range = rect.height + viewport;
|
||||
// 0 when bottom of viewport touches top of element (rect.top === viewport)
|
||||
// 1 when element's bottom crosses top of viewport (rect.bottom <= 0)
|
||||
const value = clamp((viewport - rect.top) / (range || 1), 0, 1);
|
||||
progress.set(value);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
// Subscribe to Lenis-driven scroll updates
|
||||
const unsub = (scrollY as MotionValue<number>).on("change", update);
|
||||
// Initialize
|
||||
update();
|
||||
return () => {
|
||||
unsub();
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [ref, scrollY]);
|
||||
|
||||
return progress;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to compute parallax offset (in px) tied to page scroll for a given element.
|
||||
* speed: positive moves with scroll (slower if between 0..1), negative moves opposite.
|
||||
* axis: "y" or "x"
|
||||
*/
|
||||
export function useParallax<T extends HTMLElement>(ref: React.RefObject<T>, speed = 0.2, axis: "y" | "x" = "y") {
|
||||
const { scrollY } = useScrollContext();
|
||||
const offset = useMotionValue(0);
|
||||
const baseRef = useRef<number | null>(null);
|
||||
|
||||
const measureBase = () => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
const rect = el.getBoundingClientRect();
|
||||
// document scroll position
|
||||
const docScroll = typeof window !== "undefined" ? window.scrollY : 0;
|
||||
baseRef.current = rect.top + docScroll;
|
||||
};
|
||||
|
||||
const update = (sy: number) => {
|
||||
if (baseRef.current === null) measureBase();
|
||||
if (baseRef.current === null) return;
|
||||
const d = sy - baseRef.current;
|
||||
offset.set(d * speed);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
// Measure on mount and on resize to handle layout shifts
|
||||
measureBase();
|
||||
const onResize = () => {
|
||||
baseRef.current = null;
|
||||
measureBase();
|
||||
update((scrollY as MotionValue<number>).get());
|
||||
};
|
||||
window.addEventListener("resize", onResize);
|
||||
const unsub = (scrollY as MotionValue<number>).on("change", update);
|
||||
|
||||
// Initialize
|
||||
update((scrollY as MotionValue<number>).get());
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("resize", onResize);
|
||||
unsub();
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [ref, speed, axis, scrollY]);
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
[axis]: offset,
|
||||
}),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[axis, offset],
|
||||
) as { x?: MotionValue<number>; y?: MotionValue<number> };
|
||||
}
|
||||
@ -1,6 +1,6 @@
|
||||
import { ClassValue, clsx } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { clsx, type ClassValue } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
|
||||
2815
package-lock.json
generated
2815
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -9,11 +9,19 @@
|
||||
"lint": "eslint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"js-dos": "^7.5.0",
|
||||
"lenis": "^1.3.11",
|
||||
"lucide-react": "^0.545.0",
|
||||
"mdx": "^0.3.1",
|
||||
"motion": "^12.23.12",
|
||||
"next": "15.5.2",
|
||||
"next-mdx-remote": "^5.0.0",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
"sugar-high": "^0.9.3",
|
||||
"tailwind-merge": "^3.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
@ -25,6 +33,7 @@
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "15.5.2",
|
||||
"tailwindcss": "^4",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
|
||||
BIN
public/images/profile-120.avif
Normal file
BIN
public/images/profile-120.avif
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.1 KiB |
BIN
public/images/profile-160.avif
Normal file
BIN
public/images/profile-160.avif
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.1 KiB |
BIN
public/images/profile.avif
Normal file
BIN
public/images/profile.avif
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 961 KiB |
13
types/js-dos.d.ts
vendored
Normal file
13
types/js-dos.d.ts
vendored
Normal file
@ -0,0 +1,13 @@
|
||||
declare module "js-dos" {
|
||||
export interface DosInstance {
|
||||
run(url: string): Promise<void>;
|
||||
stop(): void;
|
||||
}
|
||||
|
||||
export function Dos(container: HTMLElement): DosInstance;
|
||||
}
|
||||
|
||||
declare module "js-dos/css/js-dos.css" {
|
||||
const css: string;
|
||||
export default css;
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user