Compare commits

...

23 Commits

Author SHA1 Message Date
4d131335dc Remove outdated UI reference files and update .gitignore to exclude SpecStory auto-save and AI rules backup files 2025-11-21 07:14:08 -07:00
52b92ab44a Remove design-system-architect.md file 2025-11-21 06:51:58 -07:00
b1feda521c Update configuration and middleware for Cloudflare integration; add Space Grotesk font and improve media route handling
- Changed wrapper settings in open-next.config.ts to use "cloudflare-node" and "cloudflare-edge".
- Updated main entry point in wrangler.toml to point to ".open-next/worker.js".
- Modified middleware to allow access to the speakers project path.
- Added Space Grotesk font to layout.tsx for enhanced typography.
- Improved media route handling by resolving parameters correctly in route.ts.
- Adjusted ServiceCard component to use a more specific type for icon handling.
2025-11-18 13:37:20 -07:00
a76e20e91f Added Cursor Rules 2025-10-23 05:11:03 -06:00
a2c67c3fb9 removed reel.mp4 2025-10-23 04:26:26 -06:00
90e730c2fe coding agent adjustments 2025-10-23 03:06:16 -06:00
a06b2607c7 feat(video): add custom reel player with local mp4 support
Replaced Frame.io link with embedded local video player for the studio reel.

## Changes
- Created ReelPlayer component with custom HTML5 video controls
  - Play/pause, volume, fullscreen, progress bar with scrubbing
  - Loading and error states with user-friendly messages
  - Dark theme styling with orange (#ff4d00) accents and sharp corners
  - Responsive design for mobile/tablet/desktop

- Integrated ReelPlayer into Temp-Placeholder (Work section)
  - Replaced external Frame.io link with local /reel.mp4
  - Maintains minimal aesthetic with proper animations

- Fixed middleware whitelist issue
  - Added /reel.mp4 to middleware allowlist (src/middleware.ts:8)
  - Prevents 307 redirect that was causing "text/html" Content-Type error

- Added video file headers to next.config.ts
  - Ensures proper video/mp4 MIME type for all .mp4 files

- Updated CLAUDE.md documentation
  - Added critical warning about middleware whitelist in "Common pitfalls"
  - Added rule #9 to "Agents operating rules" for public/ file additions
  - Future-proofs against this issue happening again

## Technical Details
- Video: 146MB, H.264 codec, 4K resolution (3840x2160)
- Player handles large file buffering gracefully
- ReadyState check prevents loading overlay persistence
- All controls accessible and keyboard-friendly

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-23 03:05:27 -06:00
bedd355b78 refactor(ui): transform temp-placeholder into minimal card layout
- Removed gradient border wrapper and CursorDotBackground
- Created centered dark card (#0a0a0a) on black background with subtle border
- Added minimal navigation with smooth scroll to sections
- Organized content into sections (about, work, studio, contact)
- Added > prefixes to section headers for terminal aesthetic
- Removed all glow effects and rounded corners
- Kept only orange (#ff4d00), white, and black color palette
- Preserved all Easter eggs (depth map, pigeon zone, video previews)
- Added smooth scroll behavior to globals.css

Inspired by minimal editorial layouts while maintaining the site's humor and personality.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-23 02:09:48 -06:00
7b1acf5588 added a shitty card but claudes here to save the day lol 2025-10-23 01:47:08 -06:00
9845081330 feat(ui): enhance temp-placeholder with animations and visual polish
- Add interactive link animations with orange glow and draw-in underlines
- Implement scroll progress indicator (orange vertical bar)
- Add animated section dividers that expand on scroll into view
- Create loading skeleton for YouTube iframe previews
- Animate accordion chevron to orange when open
- Add hover tooltip to BIOHAZARD easter egg title
- Add scroll-triggered fade-in for pigeon easter egg
- Add subtle separation between contact links
- Extract reusable components: ScrollProgressBar, SectionDivider, VideoPreview
- Maintain dark VFX aesthetic with snappy framer-motion animations
2025-10-23 00:13:21 -06:00
3bafa982ee added loading animation 2025-10-13 01:40:37 -06:00
1589c35026 added hovercard embeds 2025-10-13 01:14:35 -06:00
150f16a3de added no pigeon zone 2025-10-13 01:06:17 -06:00
94b9eeea15 added depthmap to oliver 2025-10-13 00:58:29 -06:00
7af9d05f48 added oliver 2025-10-13 00:44:31 -06:00
aa3356e854 updated widget 2025-10-12 20:38:07 -06:00
431be04d28 added instagram embed 2025-10-12 20:14:51 -06:00
a2eafe3037 toned down the profanity 2025-10-12 19:36:10 -06:00
9733cc8ad6 fixed mobile scaling 2025-10-12 18:55:09 -06:00
f48786b20b removed scrollbar 2025-10-12 18:46:33 -06:00
06fe062114 added a little css 2025-10-12 18:41:59 -06:00
d6ae81d11a updated descriptions 2025-10-12 17:45:13 -06:00
9543cca413 Update global styles and layout components; introduce new font and restructure homepage. Adjust color variables for light and dark themes, add new font styles, and enhance footer and navigation for improved accessibility. Replace old project showcase with updated components reflecting recent projects and branding efforts. 2025-10-12 16:15:08 -06:00
77 changed files with 11966 additions and 368 deletions

View File

@ -0,0 +1,159 @@
---
globs: src/components/**/*.tsx
---
# Component Patterns
## Component Structure
### File Organization
- Place reusable components in `src/components/`
- Use PascalCase for component files: `ComponentName.tsx`
- Group related components in subdirectories when needed
### Component Template
```typescript
import { cn } from '@/lib/utils'
interface ComponentProps {
className?: string
children?: React.ReactNode
// Other props
}
export function ComponentName({
className,
children,
...props
}: ComponentProps) {
return (
<div className={cn("base-styles", className)} {...props}>
{children}
</div>
)
}
```
## shadcn/ui Integration
### Using shadcn/ui Components
```typescript
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
export function ProjectCard({ project }: { project: Project }) {
return (
<Card className="bg-black border-gray-800">
<CardHeader>
<CardTitle className="text-white">{project.title}</CardTitle>
</CardHeader>
<CardContent>
<Badge variant="outline">{project.category}</Badge>
</CardContent>
</Card>
)
}
```
### Extending shadcn/ui Components
```typescript
// Create wrapper components for common patterns
import { Button } from '@/components/ui/button'
import { cn } from '@/lib/utils'
interface PrimaryButtonProps extends React.ComponentProps<typeof Button> {
loading?: boolean
}
export function PrimaryButton({
loading,
className,
children,
...props
}: PrimaryButtonProps) {
return (
<Button
className={cn("bg-primary hover:bg-primary/90", className)}
disabled={loading}
{...props}
>
{loading ? "Loading..." : children}
</Button>
)
}
```
## Animation Patterns
### Framer Motion Usage
```typescript
import { motion } from 'framer-motion'
export function AnimatedCard({ children }: { children: React.ReactNode }) {
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }}
className="card-styles"
>
{children}
</motion.div>
)
}
```
## Component Composition
### Compound Components
```typescript
// Parent component
export function Accordion({ children }: { children: React.ReactNode }) {
return <div className="accordion-container">{children}</div>
}
// Child components
export function AccordionItem({ children }: { children: React.ReactNode }) {
return <div className="accordion-item">{children}</div>
}
export function AccordionTrigger({ children }: { children: React.ReactNode }) {
return <button className="accordion-trigger">{children}</button>
}
// Usage
<Accordion>
<AccordionItem>
<AccordionTrigger>Title</AccordionTrigger>
<AccordionContent>Content</AccordionContent>
</AccordionItem>
</Accordion>
```
## Props and TypeScript
### Interface Patterns
```typescript
// Use descriptive interface names
interface ProjectCardProps {
project: Project
variant?: 'default' | 'featured'
showDescription?: boolean
onSelect?: (project: Project) => void
}
// Use React.ComponentProps for extending HTML elements
interface CustomButtonProps extends React.ComponentProps<'button'> {
variant?: 'primary' | 'secondary'
size?: 'sm' | 'md' | 'lg'
}
```
## Styling Guidelines
1. **Tailwind First**: Use utility classes before custom CSS
2. **Conditional Classes**: Use `cn()` utility for conditional styling
3. **Responsive Design**: Mobile-first approach with responsive utilities
4. **Dark Theme**: Ensure all components work in dark mode
5. **Accessibility**: Include proper ARIA labels and semantic HTML

View File

@ -0,0 +1,97 @@
---
globs: src/data/**/*.ts,src/data/**/*.json
---
# Data and Content Management
## Data Structure
Non-secret content belongs in `src/data/` as TypeScript modules or JSON files. Keep data presentation-agnostic.
## Current Data Files
- [src/data/projects.ts](mdc:src/data/projects.ts) - Project portfolio data
- [src/data/services.ts](mdc:src/data/services.ts) - Service offerings data
## Data Patterns
### TypeScript Data Modules
```typescript
// src/data/projects.ts
export interface Project {
id: string
title: string
description: string
category: string
images: string[]
videoUrl?: string
}
export const projects: Project[] = [
{
id: 'project-1',
title: 'Project Title',
description: 'Project description',
category: 'commercial',
images: ['/image1.jpg', '/image2.jpg']
}
]
```
### JSON Data Files
```json
{
"services": [
{
"id": "vfx",
"name": "Visual Effects",
"description": "High-end VFX services"
}
]
}
```
## Data Usage Rules
1. **Server Components**: Prefer server components for data fetching
2. **File Imports**: Use direct imports instead of client-side fetching for static data
3. **Type Safety**: Define TypeScript interfaces for all data structures
4. **Separation**: Keep data separate from presentation logic
## Content Guidelines
- Use descriptive, SEO-friendly content
- Include proper alt text for images
- Maintain consistent naming conventions
- Keep content up-to-date and accurate
## Data Fetching Patterns
```typescript
// ✅ Good: Server component with direct import
import { projects } from '@/data/projects'
export default function PortfolioPage() {
return (
<div>
{projects.map(project => (
<ProjectCard key={project.id} project={project} />
))}
</div>
)
}
// ❌ Avoid: Client-side fetching of static data
'use client'
import { useEffect, useState } from 'react'
export function PortfolioPage() {
const [projects, setProjects] = useState([])
useEffect(() => {
fetch('/api/projects').then(res => res.json())
}, [])
// ...
}
```

View File

@ -0,0 +1,73 @@
---
alwaysApply: true
---
# Deployment and Build Process
## Cloudflare Workers with OpenNext
This project deploys to Cloudflare Workers using OpenNext for Next.js compatibility.
### Build Process
1. **Quality Gates** (run before build):
```bash
npm run type-check # TypeScript validation
npm run lint # ESLint validation
```
2. **Production Build**:
```bash
npm run build # Next.js build
```
3. **OpenNext Build**:
```bash
npx open-next@latest build # Generate Cloudflare-compatible build
```
4. **Deploy**:
```bash
npx wrangler deploy .open-next/worker
```
### Configuration Files
- [wrangler.toml](mdc:wrangler.toml) - Cloudflare Workers configuration
- [open-next.config.ts](mdc:open-next.config.ts) - OpenNext build configuration
- [next.config.ts](mdc:next.config.ts) - Next.js configuration
### Required wrangler.toml Settings
```toml
name = "site-worker"
main = ".open-next/worker/index.mjs"
compatibility_date = "2024-09-23"
compatibility_flags = ["nodejs_compat"]
assets = { directory = ".open-next/assets" }
```
## Environment Variables
Create `.env.sample` and keep it synchronized. Typical keys:
```
NEXT_PUBLIC_SITE_URL=
RESEND_API_KEY=
CF_PAGES_URL=
```
**Security**: Never commit real secrets. Use `.env` locally and environment variables in production.
## Build Configuration
- ESLint and TypeScript errors are ignored during build for deployment speed
- CI still gates on `lint` and `type-check` before merge
- Always fix errors instead of bypassing checks
## Deployment Checklist
- [ ] Run `npm run type-check` and `npm run lint`
- [ ] Ensure `assets.directory` matches OpenNext output
- [ ] Keep compatibility date at or after 2024-09-23
- [ ] Test build locally with `npm run build`
- [ ] Verify OpenNext build artifacts in `.open-next/`

View File

@ -0,0 +1,74 @@
---
alwaysApply: true
---
# Development Workflow
## Git Workflow
- **Default Branch**: `main` is protected
- **Workflow**: feature branches → PR → required checks → squash merge
- **Commit Format**: Conventional Commits
- `feat: add contact form schema`
- `fix: correct Image remote pattern`
- `chore: bump dependencies`
## Required Checks
Before any merge:
- `npm run lint` - ESLint validation
- `npm run type-check` - TypeScript validation
- `npm run build` - Production build (optional locally, required in CI)
## Development Commands
```bash
# Setup
npm ci # Install dependencies
# Development
npm run dev # Dev server with Turbopack
npm run type-check # TypeScript validation
npm run lint # ESLint validation
# Build & Deploy
npm run build # Production build
npm run start # Preview production build
npx open-next@latest build # OpenNext build
npx wrangler deploy .open-next/worker # Deploy to Cloudflare
```
## Code Quality
### TypeScript
- Use strict mode (enabled in [tsconfig.json](mdc:tsconfig.json))
- Prefer type inference over explicit types
- Use absolute imports with `@` alias
### ESLint
- Follow Next.js ESLint config
- Fix all linting errors before committing
- Don't bypass checks in production builds
## Testing Strategy
- **Unit Tests**: Place close to sources, name with `.test.ts` or `.test.tsx`
- **E2E Tests**: Optional Playwright setup
- **Manual Testing**: Test all user flows before deployment
## Pull Request Guidelines
- Keep PRs small and reviewable
- Include screenshots for UI changes
- Update [AGENTS.md](mdc:AGENTS.md) if conventions change
- Justify new dependencies in PR description
- Never commit secrets or sensitive data
## Common Pitfalls to Avoid
1. Adding remote image domains without updating [next.config.ts](mdc:next.config.ts)
2. Introducing client components unnecessarily
3. Duplicating navigation in nested layouts
4. Bypassing Tailwind utilities for custom CSS
5. Forgetting to update middleware whitelist for new static assets
6. Committing secrets instead of using environment variables

View File

@ -0,0 +1,87 @@
---
globs: **/*form*.tsx,**/*Form*.tsx
---
# Forms and Validation
## Form Library Stack
- **Forms**: react-hook-form for form state management
- **Validation**: Zod schemas for type-safe validation
- **Resolvers**: @hookform/resolvers for integration
## Form Structure
```typescript
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
// Define schema
const formSchema = z.object({
name: z.string().min(1, 'Name is required'),
email: z.string().email('Invalid email address'),
message: z.string().min(10, 'Message must be at least 10 characters')
})
type FormData = z.infer<typeof formSchema>
export function ContactForm() {
const form = useForm<FormData>({
resolver: zodResolver(formSchema),
defaultValues: {
name: '',
email: '',
message: ''
}
})
const onSubmit = (data: FormData) => {
// Handle form submission
}
return (
<form onSubmit={form.handleSubmit(onSubmit)}>
{/* Form fields with error handling */}
</form>
)
}
```
## Error Handling Rules
1. **Field-level errors**: Show validation errors under each field
2. **Generic submit error**: Display general submission errors
3. **Never swallow errors**: Always surface validation and submission errors
4. **Loading states**: Show loading indicators during submission
## Form Components
Use shadcn/ui form components from [src/components/ui/form.tsx](mdc:src/components/ui/form.tsx):
```typescript
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form'
import { Input } from '@/components/ui/input'
import { Button } from '@/components/ui/button'
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input placeholder="your@email.com" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
```
## Validation Patterns
- Use Zod for all form validation
- Provide clear, user-friendly error messages
- Validate on both client and server side
- Handle async validation (e.g., email uniqueness)

View File

@ -0,0 +1,75 @@
---
globs: **/*.tsx,**/*.ts
---
# Images and Assets
## Image Handling
### Next.js Image Component
Always use Next.js Image component for remote images:
```typescript
import Image from 'next/image'
<Image
src="https://images.unsplash.com/photo-123"
alt="Descriptive alt text"
width={800}
height={600}
className="rounded-lg"
/>
```
### Remote Image Domains
Current allowed domains in [next.config.ts](mdc:next.config.ts):
- `images.unsplash.com`
**When adding new external domains:**
1. Add to `remotePatterns` in [next.config.ts](mdc:next.config.ts)
2. Document the change in [AGENTS.md](mdc:AGENTS.md)
## Static Assets
### Public Directory
- Keep `public/` for truly static assets only
- Current assets: favicon files, images (OLIVER.jpeg, etc.), GIFs
### Middleware Whitelist
**CRITICAL**: When adding new static assets to `public/`, update the middleware allowlist in [src/middleware.ts](mdc:src/middleware.ts) line 8:
```typescript
// Add new asset paths here
if (pathname === '/' ||
pathname.startsWith('/_next') ||
pathname.startsWith('/favicon.') ||
pathname === '/OLIVER.jpeg' ||
pathname === '/new-asset.jpg' || // Add new assets here
pathname === '/reel.mp4') {
return NextResponse.next();
}
```
**Common symptom**: If assets return "text/html" Content-Type error, the path isn't whitelisted.
## Asset Optimization
- Use appropriate image formats (WebP when possible)
- Provide proper alt text for accessibility
- Use responsive images with `sizes` prop
- Optimize file sizes for web delivery
## Video Files
Custom headers are configured in [next.config.ts](mdc:next.config.ts) for `.mp4` files:
```typescript
{
source: "/:path*.mp4",
headers: [
{
key: "Content-Type",
value: "video/mp4",
},
],
}
```

View File

@ -0,0 +1,60 @@
---
alwaysApply: true
---
# Biohazard VFX Website - Project Structure
This is a Next.js 15.5.4 VFX studio website built with React 19, TypeScript, Tailwind CSS 4, and shadcn/ui components.
## Core Architecture
- **Framework**: Next.js 15.5.4 with App Router
- **Styling**: Tailwind CSS 4 + shadcn/ui components
- **Animation**: Framer Motion for subtle transitions
- **Forms**: react-hook-form + Zod validation
- **Deployment**: Cloudflare Workers via OpenNext
- **Package Manager**: npm
## Project Layout
```
src/
├─ app/ # App Router pages and layouts
│ ├─ (marketing)/ # Route groups
│ ├─ api/ # Route handlers
│ └─ layout.tsx # Root layout with global providers
├─ components/ # Reusable UI components
│ └─ ui/ # shadcn/ui primitives
├─ data/ # JSON/TS data objects
├─ lib/ # Utilities, hooks, server actions
├─ styles/ # globals.css, Tailwind utilities
└─ types/ # Shared TypeScript types
```
## Key Files
- [AGENTS.md](mdc:AGENTS.md) - Single source of truth for development guidelines
- [package.json](mdc:package.json) - Dependencies and scripts
- [next.config.ts](mdc:next.config.ts) - Next.js configuration
- [tsconfig.json](mdc:tsconfig.json) - TypeScript configuration with @ alias
- [src/middleware.ts](mdc:src/middleware.ts) - Route protection and redirects
- [src/app/layout.tsx](mdc:src/app/layout.tsx) - Root layout with fonts and metadata
## Import Aliases
Always use absolute imports with `@` mapped to `src/`:
```typescript
import { Component } from '@/components/Component'
import { data } from '@/data/projects'
```
## Development Commands
```bash
npm ci # Install dependencies
npm run dev # Development server with Turbopack
npm run type-check # TypeScript validation
npm run lint # ESLint validation
npm run build # Production build
npx open-next@latest build # OpenNext build for Cloudflare
```

View File

@ -0,0 +1,67 @@
---
globs: src/app/**/*.tsx
---
# Routing and Layout Rules
## App Router Structure
- **Pages**: Live in `src/app/` directory
- **Server Components**: Default to server components, promote to client only when needed
- **Layout Hierarchy**: Root layout owns global providers, navigation, and footer
- **Route Groups**: Use `(marketing)` for grouped routes without affecting URL structure
## Layout Rules
### Root Layout ([src/app/layout.tsx](mdc:src/app/layout.tsx))
- Owns global providers and theme class
- Contains `<Navigation />` and `<Footer />` components
- Sets up font variables and metadata
- **DO NOT** duplicate these in child layouts
### Page Layouts
- Keep server components as default
- Add `"use client"` only when necessary for interactivity
- Define unique metadata for each route
## Metadata Requirements
Every page must have:
```typescript
export const metadata: Metadata = {
title: "Unique Page Title",
description: "Unique description for SEO",
// Include Open Graph and Twitter cards
}
```
## Route Protection
The [src/middleware.ts](mdc:src/middleware.ts) currently redirects all routes to `/` except:
- Home page (`/`)
- Next.js internal routes (`/_next/*`)
- Favicon files
- Specific static assets (OLIVER.jpeg, OLIVER_depth.jpeg, etc.)
## Static Assets
When adding new files to `public/`, update the middleware allowlist in [src/middleware.ts](mdc:src/middleware.ts) line 8 to prevent 307 redirects.
## Common Patterns
```typescript
// Page component
export default function PageName() {
return (
<div className="container mx-auto px-4">
{/* Page content */}
</div>
)
}
// With metadata
export const metadata: Metadata = {
title: "Page Title",
description: "Page description"
}
```

View File

@ -0,0 +1,78 @@
---
globs: src/app/**/page.tsx,src/app/**/layout.tsx
---
# SEO and Metadata
## Metadata API Requirements
Every page must define unique metadata using the Next.js Metadata API:
```typescript
import type { Metadata } from "next"
export const metadata: Metadata = {
title: "Unique Page Title | Biohazard VFX",
description: "Unique, descriptive page description for SEO",
metadataBase: new URL("https://biohazardvfx.com"),
openGraph: {
title: "Page Title",
description: "Page description",
type: "website",
locale: "en_US",
siteName: "Biohazard VFX",
},
twitter: {
card: "summary_large_image",
title: "Page Title",
description: "Page description",
},
}
```
## Root Layout Metadata
The root layout in [src/app/layout.tsx](mdc:src/app/layout.tsx) includes:
- Global site metadata
- Open Graph configuration
- Twitter card setup
- JSON-LD structured data
- Canonical URLs
## SEO Best Practices
1. **Unique Titles**: Each page must have a unique, descriptive title
2. **Descriptions**: Write compelling meta descriptions (150-160 characters)
3. **Structured Data**: Use JSON-LD for rich snippets
4. **Canonical URLs**: Set canonical URLs to prevent duplicate content
5. **Open Graph**: Include OG tags for social media sharing
6. **Twitter Cards**: Configure Twitter card metadata
## Structured Data Example
```typescript
const jsonLd = {
"@context": "https://schema.org",
"@type": "Organization",
name: "Biohazard VFX",
description: "Visual effects studio",
url: "https://biohazardvfx.com",
logo: "https://biohazardvfx.com/logo.png",
sameAs: ["https://instagram.com/biohazardvfx"],
}
```
## Page-Specific Metadata
- **Home**: Focus on main services and value proposition
- **Portfolio**: Highlight featured projects and capabilities
- **Services**: Target specific service keywords
- **About**: Include company information and team details
- **Contact**: Include location and contact information
## Image SEO
- Use descriptive alt text for all images
- Optimize image file names
- Include image dimensions
- Use appropriate image formats (WebP when possible)

View File

@ -0,0 +1,60 @@
---
globs: *.tsx,*.ts,*.css
---
# UI System Guidelines
## Theme & Design System
- **Default Theme**: Dark mode only - do not introduce light-first designs
- **Typography**: Use CSS variables for fonts (Geist, Geist Mono, Bebas Neue, Orbitron, etc.)
- **Components**: Use shadcn/ui primitives from [src/components/ui/](mdc:src/components/ui/)
- **Spacing**: Follow Tailwind 4 defaults, prefer utility classes over custom CSS
- **Animation**: Keep Framer Motion subtle and meaningful only
## Component Structure
```typescript
// Use shadcn/ui primitives as base
import { Button } from '@/components/ui/button'
import { Card } from '@/components/ui/card'
// Extend with local wrappers when needed
export function CustomComponent() {
return (
<Card className="bg-black border-gray-800">
<Button variant="outline" className="text-white">
Action
</Button>
</Card>
)
}
```
## Styling Rules
1. **Dark Theme**: All components must work in dark mode
2. **Tailwind First**: Use utility classes before custom CSS
3. **Component Variants**: Use class-variance-authority for component variants
4. **Responsive**: Mobile-first responsive design
5. **Accessibility**: Include proper ARIA labels and semantic HTML
## Font Usage
Available font variables from [src/app/layout.tsx](mdc:src/app/layout.tsx):
- `--font-geist-sans` (default)
- `--font-geist-mono`
- `--font-bebas`
- `--font-orbitron`
- `--font-inter`
- `--font-jetbrains-mono`
- `--font-space-mono`
- `--font-rajdhani`
- `--font-exo-2`
## Animation Guidelines
- Use Framer Motion sparingly for meaningful transitions
- Prefer CSS transitions for simple hover effects
- Keep animations under 300ms for UI feedback
- Respect `prefers-reduced-motion` for accessibility

View File

@ -0,0 +1,99 @@
---
description: VFX studio specific patterns and requirements
---
# VFX Studio Specific Guidelines
## Media Handling
### Video Components
- Use [src/components/VideoPlayer.tsx](mdc:src/components/VideoPlayer.tsx) for video playback
- Use [src/components/ReelPlayer.tsx](mdc:src/components/ReelPlayer.tsx) for demo reels
- Support multiple video formats (MP4, WebM)
- Include proper video metadata and thumbnails
### Image Components
- Use [src/components/DepthMap.tsx](mdc:src/components/DepthMap.tsx) for depth map visualizations
- Implement lazy loading for portfolio images
- Use Next.js Image optimization for all media
## Portfolio Patterns
### Project Showcase
```typescript
// Use ProjectCard component for consistent project display
import { ProjectCard } from '@/components/ProjectCard'
import { ProjectShowcase } from '@/components/ProjectShowcase'
// Project data structure from src/data/projects.ts
interface Project {
id: string
title: string
description: string
category: 'commercial' | 'music-video' | 'film' | 'animation'
client?: string
year: number
images: string[]
videoUrl?: string
tags: string[]
}
```
### Service Categories
- Visual Effects (VFX)
- Motion Graphics
- 3D Animation
- Compositing
- Color Grading
- Post-Production
## Client Work Patterns
### Client Logo Grid
- Use [src/components/ClientLogoGrid.tsx](mdc:src/components/ClientLogoGrid.tsx)
- Display client logos with proper attribution
- Ensure logos are high-quality and properly sized
### Project Filtering
- Implement category-based filtering
- Support tag-based search
- Include year-based sorting
## Performance Considerations
### Media Optimization
- Compress images and videos for web delivery
- Use appropriate formats (WebP for images, MP4 for videos)
- Implement progressive loading for large media files
- Use CDN for media delivery
### Loading States
- Show skeleton loaders for media content
- Implement progressive image loading
- Use intersection observer for lazy loading
## VFX-Specific UI Elements
### Before/After Comparisons
- Implement split-screen comparisons
- Use slider controls for reveal effects
- Include toggle for before/after views
### Process Showcases
- Show breakdowns of VFX work
- Include wireframe and final render comparisons
- Display technical specifications
## Content Guidelines
### Project Descriptions
- Include technical details (software used, techniques)
- Mention client and project scope
- Highlight challenges and solutions
- Use industry-standard terminology
### Service Descriptions
- Be specific about capabilities
- Include typical project timelines
- Mention software and hardware capabilities
- Provide clear pricing structure (if applicable)

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 943 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

3
.cursorindexingignore Normal file
View File

@ -0,0 +1,3 @@
# Don't index SpecStory auto-save files, but allow explicit context inclusion via @ references
.specstory/**

6
.gitignore vendored
View File

@ -47,3 +47,9 @@ wrangler.toml.backup
# typescript
*.tsbuildinfo
next-env.d.ts
# No longer save SpecStory auto-save files to the repo
.specstory/**
# No longer save SpecStory AI rules backups to the repo
.specstory/ai_rules_backups/**

4
.specstory/.gitignore vendored Normal file
View File

@ -0,0 +1,4 @@
# SpecStory project identity file
/.project.json
# SpecStory explanation file
/.what-is-this.md

File diff suppressed because it is too large Load Diff

211
CLAUDE.md Normal file
View File

@ -0,0 +1,211 @@
# Agents Guide (Single Source of Truth)
This document is the canonical reference for humans and `Agents` working in this repository. It standardizes stack, structure, workflows, and guardrails so contributions stay consistent and safe.
## 1) Scope and goals
* Make onboarding fast with a single place to look
* Define conventions so changes are predictable
* Provide exact commands that `Agents` can run without guesswork
* Prevent accidental regressions in routing, theming, SEO, and deployment
## 2) Tech stack
* **Framework**: Next.js 15.5.4, React 19, TypeScript
* **Styling**: Tailwind CSS 4, shadcn/ui
* **Animation**: Framer Motion
* **Forms**: react-hook-form + Zod
* **Platform**: Cloudflare Workers via OpenNext
* **Package manager**: npm
* **Node**: LTS 20 or 22
## 3) Project layout
```
root
├─ src/
│ ├─ app/ # App Router pages and layouts
│ │ ├─ (marketing)/ # Example route groups
│ │ ├─ api/ # Route handlers
│ │ └─ layout.tsx # Root layout, see rules below
│ ├─ components/ # Reusable UI
│ ├─ data/ # JSON or TS data objects consumed by pages
│ ├─ lib/ # Utilities, hooks, server actions
│ ├─ styles/ # globals.css, tailwind utilities if applicable
│ └─ types/ # Shared types
├─ public/ # Static assets
├─ next.config.ts
├─ tailwind.config.ts
├─ wrangler.toml # Cloudflare deploy config
└─ package.json
```
### Import aliases
* Prefer absolute imports using `@` mapped to `src` via `tsconfig.json` paths.
## 4) Authoritative UI system
* **Theme**: dark mode is the default. Do not introduce light-first designs without approval.
* **Typography**: default to Geist and Geist Mono via CSS variables. If adding a new font, add it as a variable and document it here before use.
* **Components**: use shadcn/ui primitives. Extend with local wrappers placed in `src/components/ui/`.
* **Spacing and rhythm**: follow Tailwind 4 defaults. Prefer utility classes over custom CSS unless componentized.
* **Animation**: keep motion subtle. Framer Motion only for meaningful transitions.
## 5) Routing and layout rules
* The **root layout** owns global providers, theme class, `<Nav />`, and `<Footer />`. Do not duplicate these in child layouts.
* Pages live in `src/app`. Keep server components as the default. Promote to client component only when needed.
* Metadata must be defined per route with the Next.js Metadata API.
## 6) SEO and metadata
* Use the Metadata API for title, description, Open Graph, and Twitter cards.
* Add structured data with JSON-LD in the root layout or specific routes when required.
* All pages must render a unique `title` and `description` suitable for indexing.
## 7) Forms and validation
* Use `react-hook-form` with Zod schemas.
* Surface field-level errors and a generic submit error. Never swallow validation errors.
## 8) Images and assets
* Use Next Image component for remote images.
* If a new external domain is introduced, add it to `next.config.ts` remote patterns and document it here.
* Keep `public/` for truly static assets only.
## 9) Environment variables
Provide a `.env.sample` and keep it in sync. Typical keys:
```
NEXT_PUBLIC_SITE_URL=
RESEND_API_KEY=
CF_PAGES_URL=
```
Do not commit real secrets. `Agents` must fail a task rather than hardcode a secret.
## 10) Local development
```
# install
npm ci
# run dev server
npm run dev
# type checks and linting
npm run typecheck
npm run lint
# build and preview
npm run build
npm run start
```
Notes
* The Next build may be configured to ignore ESLint and TS errors for production bundling speed. CI still gates on `lint` and `typecheck` before merge.
## 11) Deployment on Cloudflare Workers with OpenNext
### Required wrangler.toml settings
```
name = "site-worker"
main = ".open-next/worker/index.mjs"
compatibility_date = "2024-09-23"
compatibility_flags = ["nodejs_compat"]
assets = { directory = ".open-next/assets" }
```
### Build and deploy
```
# produce OpenNext build artifacts
npx open-next@latest build
# deploy worker and assets
npx wrangler deploy .open-next/worker
```
Guidelines
* Always run `npm run typecheck` and `npm run lint` before build.
* Ensure `assets.directory` matches the OpenNext output.
* Keep the compatibility date at or after 2024-09-23.
## 12) Branching, commits, and CI
* **Default branch**: `main` is protected.
* **Workflow**: feature branches -> PR -> required checks -> squash merge.
* **Commit format**: Conventional Commits. Examples
* `feat: add contact form schema`
* `fix: correct Image remote pattern`
* `chore: bump dependencies`
* **Required checks**
* `npm run lint`
* `npm run typecheck`
* `npm run build` can be optional locally if CI runs it, but must succeed before deploy.
## 13) Testing
* **Unit**: place tests close to sources, name with `.test.ts` or `.test.tsx`.
* **E2E**: optional Playwright. If used, add a `playwright.config.ts` and a `npm run e2e` script.
## 14) Data and content
* Non-secret content belongs in `src/data` as TS modules or JSON. Keep it presentation-agnostic.
* Do not fetch static project data in client components. Prefer server components or file imports.
## 15) `Agents` operating rules
1. Read this guide before making changes.
2. Do not alter the root layout structure for global nav or footer. Extend only via component props or slots.
3. Before adding a dependency, justify it in the PR description and update this document if it affects conventions.
4. When creating pages, set Metadata and verify unique title and description.
5. If a build ignores ESLint or TS errors, CI still blocks merges on `lint` and `typecheck`. Fix the errors instead of bypassing checks.
6. Never commit secrets. Use `.env` and keep `.env.sample` current.
7. If you change image domains or fonts, document the change here.
8. Prefer small, reviewable PRs. Include screenshots for UI changes.
9. **When adding files to `public/`**, always update the middleware whitelist in `src/middleware.ts` (line 8) to allow access to the new files.
## 16) Common pitfalls
* Adding a remote image domain but forgetting to allow it in `next.config.ts`.
* Introducing a client component unnecessarily and breaking streaming or SSR.
* Duplicating navigation inside nested layouts.
* Styling drift by bypassing Tailwind utilities and shadcn primitives.
* **⚠️ CRITICAL - Middleware Whitelist**: `src/middleware.ts` redirects ALL routes to `/` except explicitly whitelisted paths. When adding new static assets to `public/` (images, videos, PDFs, etc.), you MUST add the path to the middleware allowlist (line 8) or the file will return a 307 redirect to `/` instead of serving. Common symptom: video/image returns "text/html" Content-Type error.
## 17) Quick command reference
```
# install deps
npm ci
# develop
npm run dev
# quality gates
npm run lint
npm run typecheck
# build and preview
npm run build
npm run start
# open-next build and deploy
npx open-next@latest build
npx wrangler deploy .open-next/worker
```
## 18) Change management
* Any modification to guardrails in sections 4 to 12 requires a PR that updates this document.
* Keep this file the single place that defines expectations for humans and `Agents`.

View File

@ -128,19 +128,46 @@ The site includes:
## Deployment
### Vercel (Recommended)
### Cloudflare Workers (Current)
1. Push your code to GitHub
2. Import the project to Vercel
3. Deploy automatically
This project is deployed to Cloudflare Workers using OpenNext:
### Other Platforms
**Prerequisites:**
- Cloudflare account with Workers enabled
- Domain configured in Cloudflare (if using custom domain)
Build the project and deploy the `.next` directory:
**Build & Deploy:**
```bash
# Build for Cloudflare Workers
npx opennextjs-cloudflare build
# Deploy to Cloudflare
npx wrangler deploy
```
**Configuration Files:**
- `wrangler.toml` - Cloudflare Workers configuration
- `open-next.config.ts` - OpenNext adapter settings
- `next.config.ts` - Next.js configuration
**Live URLs:**
- Production: https://biohazardvfx.com
- Worker: https://biohazard-vfx-website.nicholaivogelfilms.workers.dev
**Important Notes:**
- Linting and TypeScript errors are ignored during build (can be re-enabled in `next.config.ts`)
- Compatible with Next.js 15.5.4
- Uses `nodejs_compat` compatibility flag
- Requires compatibility date `2024-09-23` or later
### Alternative: Vercel
For Vercel deployment, revert `next.config.ts` to remove Cloudflare-specific settings:
```bash
npm run build
npm run start
# Then deploy via Vercel dashboard or CLI
```
## License

272
design.json Normal file
View File

@ -0,0 +1,272 @@
{
"version": "1.0.0",
"name": "BIOHAZARD VFX Website Design System",
"description": "Design system for the BIOHAZARD VFX website based on Temp-Placeholder component",
"colorPalette": {
"background": {
"primary": "#000000",
"secondary": "#0a0a0a",
"description": "Primary black backgrounds with very dark secondary"
},
"text": {
"primary": "#ffffff",
"secondary": "#e5e5e5",
"muted": "#a3a3a3",
"subtle": "#808080",
"verySubtle": "#606060",
"description": "White primary text with decreasing opacity gray variants"
},
"accent": {
"primary": "#ff4d00",
"description": "Orange accent color used for interactive elements, links, and highlights"
},
"borders": {
"subtle": "rgba(255, 255, 255, 0.05)",
"muted": "rgba(255, 255, 255, 0.1)",
"description": "Subtle white borders with low opacity for divisions"
},
"overlay": {
"dark": "rgba(0, 0, 0, 0.8)",
"description": "Dark overlay for modals and overlays"
}
},
"typography": {
"fontFamilies": {
"exo2": "font-exo-2",
"geist": "Geist, sans-serif",
"geistMono": "Geist Mono, monospace"
},
"scales": {
"xl": {
"sizes": ["9xl", "8xl", "7xl", "6xl", "5xl"],
"description": "Extra large heading sizes for hero/main title (BIOHAZARD)"
},
"lg": {
"sizes": ["4xl", "3xl"],
"description": "Large heading sizes for section titles"
},
"base": {
"sizes": ["lg", "base", "sm"],
"description": "Base text sizes for body content"
},
"xs": {
"sizes": ["xs"],
"description": "Extra small text for meta information"
}
},
"weights": {
"normal": 400,
"bold": 700,
"black": 900,
"description": "Font weights used throughout the design"
},
"lineHeight": {
"tight": "1.2",
"relaxed": "1.6",
"description": "Line heights for text readability"
}
},
"spacing": {
"container": {
"maxWidth": "900px",
"padding": {
"mobile": "px-4",
"sm": "sm:px-6",
"lg": "lg:px-8"
},
"description": "Main container width and responsive padding"
},
"sections": {
"vertical": {
"small": "mb-8",
"medium": "md:mb-16",
"large": "md:mb-20",
"description": "Vertical spacing between major sections"
},
"horizontal": {
"gap": "gap-6",
"description": "Horizontal gaps between elements"
}
},
"card": {
"padding": {
"base": "p-6",
"sm": "sm:p-8",
"md": "md:p-12"
},
"description": "Card container padding (main content area)"
},
"elements": {
"small": "mb-4",
"medium": "mb-6",
"large": "mb-8",
"description": "Element spacing within sections"
}
},
"breakpoints": {
"mobile": "< 640px",
"sm": "640px",
"md": "768px",
"lg": "1024px",
"xl": "1280px",
"description": "Tailwind CSS responsive breakpoints used"
},
"components": {
"navigation": {
"description": "Top navigation bar",
"layout": "flex justify-between items-center",
"padding": "py-6",
"border": "border-b border-white/10",
"typography": "text-lg font-mono tracking-tight",
"interactive": {
"links": "hover:text-[#ff4d00] transition-colors",
"gap": "gap-6",
"fontSize": "text-sm"
}
},
"card": {
"description": "Main content card container",
"background": "#0a0a0a",
"border": "border border-white/5",
"layout": "relative bg-[#0a0a0a] border border-white/5"
},
"heading": {
"main": {
"description": "Large BIOHAZARD heading with text shadow effect",
"fontSize": ["text-3xl", "sm:text-4xl", "md:text-5xl"],
"fontFamily": "font-exo-2",
"fontWeight": "font-black",
"color": "#000000",
"textShadow": "2px 2px 0px #ff4d00, 4px 4px 0px #ff4d00",
"interactive": "hover:opacity-80 cursor-pointer transition-opacity"
},
"heroTitle": {
"description": "Hero section title",
"fontSize": ["text-4xl", "sm:text-5xl", "md:text-7xl", "lg:text-8xl", "xl:text-9xl"],
"fontWeight": "font-black",
"fontFamily": "font-exo-2"
}
},
"link": {
"description": "Interactive link styling",
"color": "#ff4d00",
"hover": "hover:opacity-80",
"underline": {
"description": "Animated underline on hover",
"height": "h-[1px]",
"animation": "scaleX animation on hover"
}
},
"divider": {
"description": "Section divider component",
"type": "SectionDivider"
},
"accordion": {
"description": "Horizontal expandable accordion",
"type": "HorizontalAccordion"
},
"videoPlayer": {
"description": "Reel video player component",
"type": "ReelPlayer"
}
},
"layout": {
"sections": [
{
"name": "Navigation",
"id": "nav",
"content": "Brand name and navigation links"
},
{
"name": "About",
"id": "about",
"content": "Hero message with accordion and main title"
},
{
"name": "Work",
"id": "work",
"content": "Reel player and project list with video previews"
},
{
"name": "Studio",
"id": "studio",
"content": "Instagram feed component"
},
{
"name": "Contact",
"id": "contact",
"content": "Contact email and footer information"
}
]
},
"animations": {
"containerVariants": {
"hidden": "opacity: 0",
"visible": {
"opacity": 1,
"staggerChildren": 0.1,
"delayChildren": 0.1
},
"description": "Page load animation with stagger effect"
},
"itemVariants": {
"hidden": "opacity: 0, y: 20",
"visible": "opacity: 1, y: 0",
"transition": "duration: 0.4, ease: easeOut",
"description": "Individual item fade-in and slide-up animation"
},
"underlineAnimation": {
"initial": "scaleX: 0",
"hover": "scaleX: 1",
"transition": "duration: 0.3, ease: easeOut",
"description": "Animated underline on links"
},
"easterEgg": {
"initial": "opacity: 0, scale: 0.7",
"animate": "opacity: 1, scale: 1",
"transition": "duration: 0.4, ease: [0.16, 1, 0.3, 1]",
"description": "Modal popup animation for easter eggs"
}
},
"interactions": {
"links": {
"color": "#ff4d00",
"hoverEffect": "opacity 0.8, text-shadow glow",
"tapEffect": "scale 0.98",
"underlineAnimation": true,
"description": "Standard link interaction pattern"
},
"easterEgg": {
"trigger": "Click on main BIOHAZARD heading or footer text",
"action": "Display modal with depth map or easter egg image",
"closeAction": "Click outside or mouse leave",
"description": "Hidden interactive elements"
},
"hover": {
"cards": "opacity reduction on hover",
"text": "color change to accent color or text-shadow glow"
}
},
"responsiveness": {
"strategy": "Mobile-first with progressive enhancement",
"mobileOptimizations": {
"fontSize": "Capped scaling to prevent cramped text",
"maxScale": 0.8,
"description": "Mobile (< 640px) uses conservative font scaling"
},
"tabletOptimizations": {
"maxScale": 1.2,
"description": "Tablet (640-1024px) allows moderate scaling"
},
"desktopOptimizations": {
"maxScale": 1.8,
"description": "Desktop (> 1024px) allows expansive scaling"
}
},
"accessibility": {
"colorContrast": "High contrast white text on black backgrounds",
"interactiveElements": "Clear hover states and cursor pointers",
"semanticHTML": "Proper heading hierarchy and section landmarks",
"focus": "Default browser focus states on interactive elements"
}
}

View File

@ -1,12 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
output: "standalone",
// OpenNext configuration for Cloudflare deployment
experimental: {
// Enable any experimental features if needed
},
// Image optimization for Cloudflare
// Image optimization
images: {
unoptimized: false,
remotePatterns: [
@ -18,6 +13,27 @@ const nextConfig: NextConfig = {
},
],
},
// Ignore lint and TypeScript errors during build for deployment
eslint: {
ignoreDuringBuilds: true,
},
typescript: {
ignoreBuildErrors: true,
},
// Custom headers for video files
async headers() {
return [
{
source: "/:path*.mp4",
headers: [
{
key: "Content-Type",
value: "video/mp4",
},
],
},
];
},
};
export default nextConfig;

View File

@ -1,7 +1,24 @@
const config = {
default: {
override: {
wrapper: "cloudflare",
wrapper: "cloudflare-node",
converter: "edge",
proxyExternalRequest: "fetch",
incrementalCache: "dummy",
tagCache: "dummy",
queue: "dummy",
},
},
edgeExternals: ["node:crypto"],
middleware: {
external: true,
override: {
wrapper: "cloudflare-edge",
converter: "edge",
proxyExternalRequest: "fetch",
incrementalCache: "dummy",
tagCache: "dummy",
queue: "dummy",
},
},
};

6210
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -12,7 +12,10 @@
},
"dependencies": {
"@hookform/resolvers": "^5.2.2",
"@opennextjs/cloudflare": "^1.10.1",
"@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-hover-card": "^1.1.15",
"@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-navigation-menu": "^1.2.14",
"@radix-ui/react-progress": "^1.1.7",
@ -21,6 +24,7 @@
"@radix-ui/react-slot": "^1.2.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"framer-motion": "^12.23.24",
"lucide-react": "^0.545.0",
"next": "15.5.4",
"open-next": "^3.1.3",

BIN
public/HATER2.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

BIN
public/OLIVER.jpeg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 364 KiB

BIN
public/OLIVER_depth.jpeg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 361 KiB

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 246 KiB

BIN
public/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

84
public/favicon.svg Normal file
View File

@ -0,0 +1,84 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 999.95 999.44">
<defs>
<style>
.cls-1 {
fill: #2b3232;
}
.cls-2 {
fill: #3e4545;
}
</style>
</defs>
<path class="cls-2" d="M134.97,857.96l14.71,9.12c110.92,64.72,252.65,17.33,302.61-99.95,24.78-58.16,21.96-125.65-7.26-181.6l13.22-6.95c19.87,23.56,55.39,27.49,79.33,7.43,1.55-1.3,6.85-7.43,8.07-7.54l12.73,7.35c-11.88,23.76-20.32,49.61-23.04,76.21-15.99,156.6,137.55,276.06,285.61,220.88,17.42-6.49,33.5-15.68,49.03-25.72-1.47,1.94-3.62,3.86-5.5,5.53-79.85,71.07-197.4,90.69-297.15,52.8-23.39-8.88-45.1-21.17-65.86-34.98-39.19,27.07-84.5,45.55-132.02,51.59-79.21,10.07-173.92-15.36-232.24-71.16-.44-.42-2.78-2.5-2.24-3.02Z"/>
<g>
<path class="cls-2" d="M389.34,83.79c6.08-2.22,12.15-4.63,18.31-6.53l-.75,1.51c-.53.21-1.04.32-1.51.5-1.44.56-4.99,1.45-5.02,3.01-.14.06-.36-.06-.5,0-1.27.5-4.23,1.05-4.01,2.51-.15.05-.35-.05-.5,0-.98.34-2.32.12-2.01,1.51-.15.05-.35-.05-.5,0-1,.32-2.31.14-2.01,1.51-.15.05-.36-.06-.5,0-1.6.65-4.81,1.87-5.02,3.51-.15.07-.46-.08-.5,0h-1s0,1,0,1c-.16.06-.46-.07-.5,0h-1s0,1,0,1c-.17.04-.36-.05-.5,0-1.24.41-2.94,1-3.01,2.51-.12.08-.43-.08-.5,0-1-.04-1.54.48-1.51,1.51-.11.08-.42-.08-.5,0-1-.04-1.54.49-1.51,1.51-.12.07-.46-.08-.5,0h-1s0,1,0,1c-.28.13-.87.36-1,.5-1,0-1.55.5-1.51,1.51-.15.05-.36-.06-.5,0-1.09.43-1.92.4-2.01,2.01-.33.17-.67.33-1,.5-.94.49-1.99.33-2.01,2.01-.33.17-.68.32-1,.5-1.08.62-2.41.69-2.51,2.51-.69.48-1.32,1.02-2.01,1.51-1.25.87-3.31,1.48-3.51,3.51-3.05,2.54-6.52,5.97-9.03,9.03-1.97.11-2.28,1.96-3.01,3.01-.48.69-1.03,1.31-1.51,2.01-1.79.12-1.9,1.37-2.51,2.51-.18.33-.32.68-.5,1-1.01-.05-1.5.5-1.51,1.51-.2.22-.29.73-.5,1-1.67.02-1.52,1.06-2.01,2.01-.17.33-.33.67-.5,1-1.02-.03-1.55.5-1.51,1.51-.08.08.08.39,0,.5-1.02-.03-1.55.5-1.51,1.51-.08.08.08.39,0,.5-1.02-.03-1.55.5-1.51,1.51-.08.08.08.39,0,.5-1.02-.03-1.55.51-1.51,1.51-.08.07.06.34,0,.5h-1s0,1,0,1c-.07.04.06.35,0,.5h-1s0,1,0,1c-.08.05.07.36,0,.5-1.88.54-3.5,3.77-4.01,5.52-.04.15.04.34,0,.5h-1s0,1,0,1c-.08.04.06.35,0,.5-1.37-.31-1.18,1.01-1.51,2.01-.05.15.05.35,0,.5-1.37-.31-1.18,1.01-1.51,2.01-.05.15.05.35,0,.5-1.38-.31-1.17,1.02-1.51,2.01-.05.15.05.36,0,.5-1.21-.27-1.04.62-1.41,1.32-13.06,24.31-22.69,50.73-25.18,78.45-.93-.35-.54-2.94-.49-3.76.3-4.81,1.34-10.58,2.16-15.4,9.37-55.34,41.69-105.86,87.64-137.38.99,1.04,1.31.15,2.02-.23,2.76-1.49,4.38-1.82,7.01-2.78Z"/>
<path class="cls-2" d="M493.2,487.18c-.03,1.7.41,6.9-.49,7.79-1.01.99-7.89,2.19-10.04,3.01-18.93,7.2-33.2,24.45-36.64,44.39,0-.04-.48.04-.49-.74-.38-26.63,21.56-50.83,47.65-54.45Z"/>
<path class="cls-2" d="M557.42,542.87c-3-20.25-17.88-37.92-37-45.04-2.08-.77-8.58-1.82-9.44-2.6-1.25-1.12-.69-6.1-.72-8.06,26.42,3.79,47.99,28.05,47.65,54.95-.01.78-.48.7-.49.74Z"/>
<path class="cls-1" d="M389.34,83.79c-.3-1.33.69-.95,1.46-1.27,9.09-3.77,18.32-7.37,27.88-9.77l.25,1c-1.72.62-3.34,1.52-5,2.28-1.97.91-4.82,1.84-7.03,2.74l.75-1.51c-6.16,1.9-12.23,4.31-18.31,6.53ZM409.41,76.77h-1v.49h1v-.49Z"/>
<path class="cls-1" d="M327.13,148.51c-.81,1.64-2.18,4.92-4.01,5.52.51-1.75,2.13-4.97,4.01-5.52Z"/>
<path class="cls-1" d="M390.35,87.8c-.21,1.69-3.43,2.81-5.02,3.51.21-1.64,3.41-2.86,5.02-3.51Z"/>
<path class="cls-1" d="M405.4,79.27c-.04,1.6-3.58,2.41-5.02,3.01.03-1.56,3.58-2.45,5.02-3.01Z"/>
<path class="cls-1" d="M359.74,110.88c-.07,2.05-2.53,2.69-3.51,3.51.2-2.04,2.27-2.64,3.51-3.51Z"/>
<path class="cls-1" d="M347.2,123.43c-.67.82-1.04,3.01-3.01,3.01.73-1.05,1.04-2.9,3.01-3.01Z"/>
<path class="cls-1" d="M399.88,82.28c.22,1.42-2.75,2.05-4.01,2.51-.22-1.46,2.75-2.01,4.01-2.51Z"/>
<path class="cls-1" d="M381.82,93.32c.04,1.76-2.08,1.89-3.01,2.51.07-1.51,1.77-2.1,3.01-2.51Z"/>
<path class="cls-1" d="M342.68,128.44c-.61.9-.65,2.48-2.51,2.51.61-1.14.72-2.39,2.51-2.51Z"/>
<path class="cls-1" d="M364.26,106.87c-.02,1.87-1.63,1.9-2.51,2.51.1-1.82,1.43-1.89,2.51-2.51Z"/>
<path class="cls-1" d="M337.66,134.46c-.43.55-.27,2.13-2.01,2.01.49-.94.33-1.98,2.01-2.01Z"/>
<path class="cls-1" d="M367.27,104.36c-.02,1.68-1.07,1.52-2.01,2.01.02-1.67,1.07-1.52,2.01-2.01Z"/>
<path class="cls-1" d="M370.28,101.85c-.02,1.67-1.07,1.52-2.01,2.01.08-1.6.92-1.58,2.01-2.01Z"/>
<path class="cls-1" d="M447.54,567.46c1.11-.5,1.47,1.58,1.51,2.51l-.93-.06c-.15-.82-.44-1.63-.58-2.45Z"/>
<path class="cls-1" d="M395.36,84.79c.31,1.37-1.01,1.18-2.01,1.51-.31-1.38,1.02-1.17,2.01-1.51Z"/>
<path class="cls-1" d="M392.85,86.3c.31,1.39-1.02,1.17-2.01,1.51-.31-1.37,1.01-1.18,2.01-1.51Z"/>
<path class="cls-2" d="M290.5,247.35c0,.5,0,1,0,1.51-.2-1.14-.93-2.49-.5-4.01h.49c.01.83,0,1.67,0,2.51Z"/>
<path class="cls-1" d="M322.11,156.04c-.39.92-.06,2.33-1.51,2.01.32-1,.14-2.31,1.51-2.01Z"/>
<path class="cls-1" d="M320.6,158.55c-.32,1-.14,2.31-1.51,2.01.32-1,.14-2.31,1.51-2.01Z"/>
<path class="cls-1" d="M319.1,161.05c-.32,1-.14,2.31-1.51,2.01.33-.99.13-2.32,1.51-2.01Z"/>
<path class="cls-1" d="M330.64,143.49c-1,1.32-.82.85-1.51,1.51-.05-1,.49-1.54,1.51-1.51Z"/>
<path class="cls-1" d="M378.3,95.83c-.79.9-.19.52-1.51,1.51-.03-1.02.5-1.55,1.51-1.51Z"/>
<path class="cls-1" d="M376.3,97.34c-.58.62.22.44-1.51,1.51-.03-1.02.5-1.55,1.51-1.51Z"/>
<path class="cls-1" d="M372.28,100.35c-.42.46.52.79-1.51,1.51-.05-1.01.5-1.5,1.51-1.51Z"/>
<path class="cls-1" d="M332.14,141.49c-1,1.32-.78.82-1.51,1.51-.04-1,.49-1.54,1.51-1.51Z"/>
<path class="cls-1" d="M333.65,139.48c-1,1.33-.78.82-1.51,1.51-.04-1,.49-1.54,1.51-1.51Z"/>
<path class="cls-1" d="M335.15,137.47c-1.01,1.89-.96,1-1.51,1.51-.04-1,.49-1.54,1.51-1.51Z"/>
<path class="cls-1" d="M339.67,131.95c-1.02,1.87-1.14,1.12-1.51,1.51,0-1,.5-1.55,1.51-1.51Z"/>
<path class="cls-1" d="M666.3,382.82c1.09,1.72-.23.69-.5,1l.5-1Z"/>
<path class="cls-1" d="M328.13,147.01c-.1.25.12.88,0,1s-.81-.11-1,0v-1s1,0,1,0Z"/>
<path class="cls-1" d="M374.29,98.84c-.09.16.17.74-.05.96-.21.21-.78-.03-.95.05v-1s1,0,1,0Z"/>
<path class="cls-1" d="M329.13,145.5c-.09.23.08.69,0,1h-1s0-1,0-1h1Z"/>
<path class="cls-1" d="M384.83,91.31c-.11.19.13.88,0,1s-.75-.1-1,0v-1s1,0,1,0Z"/>
<path class="cls-1" d="M383.32,92.32c-.5.84.76.59-1,1v-1s1,0,1,0Z"/>
<path class="cls-1" d="M323.11,154.53c-.38,1.77-.19.52-1,1v-1s1,0,1,0Z"/>
<path class="cls-1" d="M209.22,372.78c-.35-.17-.59-.25-.52-.72.49-4.19-.39-8.54-.51-12.56-1.66-55.99,11.35-111.12,39.26-159.47,37.23-64.51,98.71-113.5,170.23-134.82.78.46-1.05,1.06-1.32,1.19-6.94,3.35-13.62,6.42-20.34,10.27-5.44,3.12-10.56,6.58-15.72,10.12-45.96,31.52-78.28,82.04-87.64,137.38-.82,4.82-1.86,10.58-2.16,15.4-.05.82-.43,3.41.49,3.76,2.49-27.72,12.13-54.14,25.18-78.45.38-.7.2-1.59,1.41-1.32-2.33,6.37-6.78,13.23-9.62,19.73-8.63,19.73-14.19,40.86-16.48,62.31-.1.95.61,2.03-.99,1.75,0-.83,0-1.67,0-2.51h-.49c-.43,1.52.3,2.87.5,4.01.56,3.19.03,3.52-.02,6.25-.37,19.38.79,36.11,5.28,54.97,21.72,91.22,103.42,158.77,197.44,162.05-.07,5.01.08,10.05,0,15.05-26.09,3.62-48.03,27.81-47.65,54.45.01.78.49.7.49.74.16,5.94.18,9.3,1.65,15.16.61,2.44,3.36,6.16-.14,6.92-.03-.05-.74.13-1.01-.34-13.17-23.13-22.69-40.8-40.88-60.87-47.78-52.74-112.12-75.94-180.85-88.57l-9.59-17c-1.52-8.1-2.69-16.63-4.97-24.55-.19-.65-.78-.2-1.02-.32Z"/>
<rect class="cls-2" x="408.41" y="76.77" width="1" height=".49"/>
<g>
<path class="cls-2" d="M621.14,86.3c.48,0,1.44-.6,1.5-.51l.06,1.42c.62.19,1.3.06,1.9.37,2.36,1.21,7.97,5.88,10.32,7.76,41.67,33.28,73.36,89.08,77.52,142.73.09,1.22.37,3.29-.49,4.26-7.32-63.66-46.72-122.59-101.19-155.44-7.85-4.74-16.18-8.53-24.24-12.89.32-.6,1.48-.14,2.03-.02,7.36,1.61,18.26,6.22,25.41,9.21,2.16.9,4.8,3.06,7.18,3.1Z"/>
<path class="cls-2" d="M794.24,325.12c-2.05-.82-.58-2.66-.5-3.51,1.37,1.15.51,2.75.5,3.51Z"/>
<path class="cls-2" d="M794.74,333.65c-.16-.11-.91.12-1,0-.58-.78.48-3.22.5-3.52.85.97.46,2.34.5,3.51Z"/>
<path class="cls-2" d="M793.74,319.1c-1.72-1.13-.63-2.11-.5-3.01,1.16,1,.55,2.16.5,3.01Z"/>
<path class="cls-2" d="M793.23,314.58c-1.29-.95-.53-1.85-.5-2.51,1.62,1.02.58,1.83.5,2.51Z"/>
<path class="cls-2" d="M792.23,304.05h-1c.04-.8-.32-2.1.5-2.51.08.64.42,1.32.5,2.51Z"/>
<path class="cls-2" d="M792.73,310.07c-1.48-.74-.49-1.85-.5-2.01,1.47.83.52,1.59.5,2.01Z"/>
<path class="cls-2" d="M792.23,306.05c0-.19,0-.92,0-1l.34.4-.34.6Z"/>
<path class="cls-1" d="M791.73,301.54c-.82.41-.46,1.71-.5,2.51h1c.02.33-.01.67,0,1,0,.08,0,.81,0,1,0,.66-.05,1.35,0,2.01.01.16-.98,1.26.5,2.01-.03.67.03,1.34,0,2.01s-.79,1.56.5,2.51c-.06.49.07,1.02,0,1.51-.12.9-1.22,1.89.5,3.01-.05.83.07,1.68,0,2.51s-1.55,2.69.5,3.51c-.02,1.66.1,3.37,0,5.02-.02.29-1.08,2.73-.5,3.52.09.12.84-.12,1,0,.5.36.44.98.5,1.51-1.52-.28-.97.77-1,1.73-.38,11.42.47,23.77-1.05,35.09-1.09,8.1-4.78,16.16-5.09,24.51l-9.64,17.96c-10.77,2.85-21.91,4.72-32.79,7.34-86.07,20.73-148.55,61.58-188.82,141.84-.23.46-.89.74-.92.81l-2.03-1.3c2.92-6.78,3.31-13.06,3.54-20.28,0-.04.48.04.49-.74.35-26.9-21.23-51.16-47.65-54.95-.07-5.01.06-10.04,0-15.05,65.56-2.22,127.35-36.49,164.68-89.95,27.95-40.03,42.65-90.84,37.02-139.84.86-.97.59-3.04.49-4.26-4.16-53.66-35.85-109.46-77.52-142.73-2.35-1.87-7.96-6.55-10.32-7.76-.6-.31-1.28-.18-1.9-.37l-.06-1.42c-.06-.09-1.02.51-1.5.51.1-.8-1.1-1.75-1.64-2.15-9.72-7.27-22.19-12.65-32.98-18.17,4.28.36,8.84,2.2,12.93,3.63,98.18,34.35,179.46,127.3,192.28,231.93Z"/>
</g>
<g>
<path class="cls-2" d="M795.24,335.15c.94,7.86-.2,18.35-.5,26.35-.16,4.4-.15,8.82-.42,13.21,51.24,23.12,95.35,61.75,125.36,109.2,36.8,58.18,52.36,128.59,43.07,197.12-1.25,9.2-3.35,18.28-4.95,27.41-.88,0-.55-2.2-.52-2.78,3.22-49.21-4.73-91.57-30.88-133.65-78.44-126.22-260.37-133.85-349.62-15.05-3.16,4.2-6.55,8.8-8.85,13.48-1.06.75-6.38-4.04-8.19-3.53l-2.9,5.49-2.92-1.23c1.15-1.45.99-3.29,1.49-4.73h1s-.5-1-.5-1c.1-.26-.1-.78,0-1,.03-.08.69-.35.92-.81,40.27-80.26,102.75-121.11,188.82-141.84,10.89-2.62,22.03-4.49,32.79-7.34l9.64-17.96c.32-8.34,4-16.41,5.09-24.51,1.53-11.32.67-23.67,1.05-35.09.03-.96-.51-2.01,1-1.73Z"/>
<path class="cls-2" d="M209.22,372.78c.24.11.83-.33,1.02.32,2.29,7.92,3.45,16.45,4.97,24.55l9.59,17c68.74,12.64,133.07,35.83,180.85,88.57,18.18,20.07,27.71,37.74,40.88,60.87.27.47.99.3,1.01.34.2.36-.12,2.26,0,3.01.13.82.42,1.63.58,2.45l.93.06c.03.65.07,1.29-.47,1.79-2.57,1.87-4.14-4.72-4.85-4.84-1.94-.07-6.29,4.25-7.74,3.32-54.91-87.22-167.44-122.14-262.74-81.14-83.19,35.79-131.75,117.26-128.13,207.57.12,2.98.87,6.5,1.04,9.5.03.55.32,1.9-.52,1.77-20.68-89.33,1.99-184.46,60.21-254.61,27.52-33.16,59.01-56.2,96.98-76.11,1.95-1.02,6.42-1.67,6.36-4.43Z"/>
</g>
</g>
<g>
<path class="cls-2" d="M338.59,499.8c-.55,6.45-2.46,12.84-3.36,19.31-11.58,83.63,40.08,162.87,121,185.41l.78.72c-1.7,22.22-8.24,44.39-17.82,64.45-77.77-20.51-140.92-83.05-163.49-160.09-11.94-40.75-12.74-84.85-2.01-125.94l.54-.43c22.18,2.16,44,7.62,64.35,16.58Z"/>
<path class="cls-2" d="M729.99,483.69c1.35,7.32,3.25,14.58,4.39,21.95,17.58,112.76-48.14,223.68-156.13,260.01-2.13.72-13.13,4.52-14.32,3.78-.83-.52-6.75-15.87-7.53-18.08-5.14-14.63-8.96-30.63-9.96-46.1l.78-.72c42.31-11.65,78.88-40.56,100.59-78.53,21.56-37.7,27.58-83.63,16.48-125.74,20.78-8.92,42.99-15.19,65.69-16.56Z"/>
</g>
<g>
<path class="cls-1" d="M339.17,380.31c-.06-.05.03-.94-.29-1.44-.77-1.22-2.66-3.07-1.99-4.56,11.6-10.97,23.98-21.19,37.44-29.83,84.29-54.11,193.48-48.98,272.34,12.55,6.97,5.44,13.34,11.59,20.03,17.35.69,1.19-1.56,4.49-2.42,5.43-8.06,8.87-15.94,18.72-24.82,27.11-2.32,2.19-17.99,15.82-19.67,15.97-4.28-3.5-8.17-7.47-12.47-10.96-58.46-47.44-141.99-49.75-203.3-6.14-7.37,5.24-13.9,11.37-20.87,17.1-10.92-8.13-21.53-17.46-30.71-27.52-2.52-2.77-10.22-13.71-11.95-14.93-.45-.32-1.26-.06-1.34-.13Z"/>
<path class="cls-2" d="M664.29,379.81c.14,1.34,1.32,1.93,2.01,3.01l-.5,1c-.75.87-2.55,4.75-3.81,6.47-11.49,15.66-26.46,29.79-42.18,41.12-4.28-3.5-8.18-7.45-12.47-10.96-43.55-35.55-102.76-46.6-156.54-29.62-25.28,7.98-48.29,22.29-67.29,40.61-11.4-8.15-22.11-17.67-31.55-28.05-2.42-2.66-15.66-18.38-15.34-20.78l2.55-2.31c.08.07.89-.19,1.34.13,1.72,1.22,9.42,12.16,11.95,14.93,9.18,10.06,19.78,19.39,30.71,27.52,6.96-5.73,13.5-11.86,20.87-17.1,61.31-43.6,144.84-41.3,203.3,6.14,4.3,3.49,8.18,7.46,12.47,10.96,1.67-.15,17.35-13.78,19.67-15.97,8.88-8.39,16.76-18.23,24.82-27.11Z"/>
</g>
<path class="cls-1" d="M555.92,565.45l.5,1h-1c.19-.55.45-.86.5-1Z"/>
<polygon class="cls-2" points="132.96 857.45 132.96 856.96 133.7 856.96 134.45 857.71 132.96 857.45"/>
<path class="cls-2" d="M871.26,855.96c.5.33-.23.66-.51.98l-.24-.24.75-.75Z"/>
<path class="cls-2" d="M585.77,73.25c.33.17.33.33,0,.5v-.5Z"/>
</svg>

After

Width:  |  Height:  |  Size: 12 KiB

View File

@ -1 +0,0 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

Before

Width:  |  Height:  |  Size: 391 B

View File

@ -1 +0,0 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

Before

Width:  |  Height:  |  Size: 1.0 KiB

View File

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

BIN
public/no_pigeons_zone.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

BIN
public/open 24 hours.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

View File

@ -1 +0,0 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

Before

Width:  |  Height:  |  Size: 128 B

BIN
public/welcome.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

View File

@ -1 +0,0 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

Before

Width:  |  Height:  |  Size: 385 B

View File

@ -0,0 +1,95 @@
import { NextRequest, NextResponse } from 'next/server';
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ path: string[] }> }
) {
try {
const resolvedParams = await params;
const path = resolvedParams.path.join('/');
// @ts-expect-error - MEDIA is bound via wrangler.toml and available in the Cloudflare context
const cloudflareContext = (globalThis as Record<string, unknown>)[Symbol.for('__cloudflare-context__')];
const MEDIA = cloudflareContext?.env?.MEDIA;
if (!MEDIA) {
return NextResponse.json(
{ error: 'Media bucket not configured' },
{ status: 500 }
);
}
// Get the object from R2
const object = await MEDIA.get(path);
if (!object) {
return NextResponse.json(
{ error: `File not found: ${path}` },
{ status: 404 }
);
}
// Get range header for video streaming support
const range = request.headers.get('range');
// Get the full object body to handle range requests properly
const body = await object.body.arrayBuffer();
const totalLength = body.byteLength;
let start = 0;
let end = totalLength - 1;
let status = 200;
if (range) {
// Extract start and end positions from range header
const match = range.match(/bytes=(\d+)-(\d+)?/);
if (match) {
start = parseInt(match[1], 10);
end = match[2] ? parseInt(match[2], 10) : end;
// Ensure end is within the bounds
if (end >= totalLength) {
end = totalLength - 1;
}
status = 206; // Partial content
}
}
// Calculate the length for the response
const contentLength = end - start + 1;
const slicedBody = body.slice(start, end + 1);
// Set headers for the response
const headers = new Headers();
object.writeHttpMetadata(headers);
headers.set('etag', object.httpEtag);
headers.set('cache-control', 'public, max-age=31536000, immutable');
// Add CORS headers to allow video requests from the same origin
headers.set('access-control-allow-origin', '*');
headers.set('access-control-allow-headers', 'range, content-type, accept');
headers.set('access-control-allow-methods', 'GET, HEAD, OPTIONS');
headers.set('access-control-expose-headers', 'content-range, accept-ranges, content-length, content-encoding');
// Add range response headers if needed
if (range) {
headers.set('content-range', `bytes ${start}-${end}/${totalLength}`);
headers.set('accept-ranges', 'bytes');
}
headers.set('content-length', contentLength.toString());
headers.set('content-type', path.endsWith('.mp4') ? 'video/mp4' : object.httpMetadata?.contentType || 'application/octet-stream');
return new NextResponse(slicedBody, {
status,
headers,
});
} catch (error) {
console.error('Error serving media:', error);
return NextResponse.json(
{ error: 'Failed to serve media file' },
{ status: 500 }
);
}
}

View File

@ -1,53 +1,53 @@
@import "tailwindcss";
:root {
--background: 0 0% 100%;
--foreground: 0 0% 3.9%;
--card: 0 0% 100%;
--card-foreground: 0 0% 3.9%;
--popover: 0 0% 100%;
--popover-foreground: 0 0% 3.9%;
--primary: 0 0% 9%;
--primary-foreground: 0 0% 98%;
--secondary: 0 0% 96.1%;
--secondary-foreground: 0 0% 9%;
--muted: 0 0% 96.1%;
--muted-foreground: 0 0% 45.1%;
--accent: 0 0% 96.1%;
--accent-foreground: 0 0% 9%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 0 0% 98%;
--border: 0 0% 89.8%;
--input: 0 0% 89.8%;
--ring: 0 0% 3.9%;
--chart-1: 12 76% 61%;
--chart-2: 173 58% 39%;
--chart-3: 197 37% 24%;
--chart-4: 43 74% 66%;
--chart-5: 27 87% 67%;
--radius: 0.5rem;
}
.dark {
--background: 0 0% 3.9%;
--background: 0 0% 0%;
--foreground: 0 0% 98%;
--card: 0 0% 3.9%;
--card-foreground: 0 0% 98%;
--popover: 0 0% 3.9%;
--popover-foreground: 0 0% 98%;
--primary: 0 0% 98%;
--primary: 188 100% 50%;
--primary-foreground: 0 0% 9%;
--secondary: 0 0% 14.9%;
--secondary-foreground: 0 0% 98%;
--muted: 0 0% 14.9%;
--muted-foreground: 0 0% 63.9%;
--accent: 0 0% 14.9%;
--accent-foreground: 0 0% 98%;
--accent: 188 100% 50%;
--accent-foreground: 0 0% 9%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 0 0% 98%;
--border: 0 0% 14.9%;
--input: 0 0% 14.9%;
--ring: 0 0% 83.1%;
--ring: 188 100% 50%;
--chart-1: 220 70% 50%;
--chart-2: 160 60% 45%;
--chart-3: 30 80% 55%;
--chart-4: 280 65% 60%;
--chart-5: 340 75% 55%;
--radius: 0.5rem;
}
.dark {
--background: 0 0% 0%;
--foreground: 0 0% 98%;
--card: 0 0% 3.9%;
--card-foreground: 0 0% 98%;
--popover: 0 0% 3.9%;
--popover-foreground: 0 0% 98%;
--primary: 188 100% 50%;
--primary-foreground: 0 0% 9%;
--secondary: 0 0% 14.9%;
--secondary-foreground: 0 0% 98%;
--muted: 0 0% 14.9%;
--muted-foreground: 0 0% 63.9%;
--accent: 188 100% 50%;
--accent-foreground: 0 0% 9%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 0 0% 98%;
--border: 0 0% 14.9%;
--input: 0 0% 14.9%;
--ring: 188 100% 50%;
--chart-1: 220 70% 50%;
--chart-2: 160 60% 45%;
--chart-3: 30 80% 55%;
@ -58,6 +58,13 @@
@theme inline {
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
--font-brand: var(--font-bebas);
--font-orbitron: var(--font-orbitron);
--font-inter: var(--font-inter);
--font-jetbrains-mono: var(--font-jetbrains-mono);
--font-space-mono: var(--font-space-mono);
--font-rajdhani: var(--font-rajdhani);
--font-exo-2: var(--font-exo-2);
--color-background: hsl(var(--background));
--color-foreground: hsl(var(--foreground));
--color-border: hsl(var(--border));
@ -66,5 +73,125 @@
body {
background: hsl(var(--background));
color: hsl(var(--foreground));
font-family: var(--font-sans), Arial, Helvetica, sans-serif;
font-family: var(--font-jetbrains-mono), Arial, Helvetica, sans-serif;
}
.font-brand {
font-family: var(--font-brand), var(--font-sans), Arial, Helvetica, sans-serif;
font-weight: 900;
letter-spacing: 0.02em;
text-transform: uppercase;
}
.font-orbitron {
font-family: var(--font-orbitron), var(--font-sans), Arial, Helvetica, sans-serif;
font-weight: 700;
letter-spacing: 0.05em;
}
.font-inter {
font-family: var(--font-inter), Arial, Helvetica, sans-serif;
}
.font-terminal {
font-family: var(--font-jetbrains-mono), monospace;
font-weight: 700;
letter-spacing: 0.02em;
text-transform: uppercase;
}
.font-space-mono {
font-family: var(--font-space-mono), monospace;
font-weight: 700;
letter-spacing: 0.05em;
text-transform: uppercase;
}
.font-rajdhani {
font-family: var(--font-rajdhani), sans-serif;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.font-exo-2 {
font-family: var(--font-exo-2), sans-serif;
font-weight: 900;
letter-spacing: 0.05em;
text-transform: uppercase;
}
/* Dot effect for black text - simplified approach */
.text-dots {
position: relative;
color: #000000;
background-image:
radial-gradient(circle, #000000 1px, transparent 1px);
background-size: 3px 3px;
background-position: 0 0;
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
}
/* Custom dotted border with solid corner dots */
.dotted-border-corners {
position: relative;
}
.dotted-border-corners::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
border: 2px dotted #f5f5f5;
pointer-events: none;
}
.dotted-border-corners::after {
content: '';
position: absolute;
top: -3px;
left: -3px;
right: -3px;
bottom: -3px;
background:
/* Corner dots */
radial-gradient(circle, #f5f5f5 2px, transparent 2px) 0 0 / 6px 6px,
radial-gradient(circle, #f5f5f5 2px, transparent 2px) 100% 0 / 6px 6px,
radial-gradient(circle, #f5f5f5 2px, transparent 2px) 0 100% / 6px 6px,
radial-gradient(circle, #f5f5f5 2px, transparent 2px) 100% 100% / 6px 6px;
background-repeat: no-repeat;
pointer-events: none;
}
/* Hide scrollbar for all elements */
* {
scrollbar-width: none; /* Firefox */
-ms-overflow-style: none; /* IE and Edge */
}
*::-webkit-scrollbar {
display: none; /* Chrome, Safari, Opera */
}
html {
scrollbar-width: none; /* Firefox */
-ms-overflow-style: none; /* IE and Edge */
scroll-behavior: smooth;
}
html::-webkit-scrollbar {
display: none; /* Chrome, Safari, Opera */
}
body {
scrollbar-width: none; /* Firefox */
-ms-overflow-style: none; /* IE and Edge */
}
body::-webkit-scrollbar {
display: none; /* Chrome, Safari, Opera */
}

View File

@ -1,5 +1,5 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import { Geist, Geist_Mono, Bebas_Neue, Orbitron, Inter, JetBrains_Mono, Space_Mono, Space_Grotesk, Rajdhani, Exo_2 } from "next/font/google";
import "./globals.css";
import { Navigation } from "@/components/Navigation";
import { Footer } from "@/components/Footer";
@ -18,24 +18,91 @@ const geistMono = Geist_Mono({
preload: true,
});
const bebasNeue = Bebas_Neue({
variable: "--font-bebas",
subsets: ["latin"],
weight: "400",
display: "swap",
preload: true,
});
const orbitron = Orbitron({
variable: "--font-orbitron",
subsets: ["latin"],
display: "swap",
preload: true,
});
const inter = Inter({
variable: "--font-inter",
subsets: ["latin"],
display: "swap",
preload: true,
});
const jetbrainsMono = JetBrains_Mono({
variable: "--font-jetbrains-mono",
subsets: ["latin"],
display: "swap",
preload: true,
});
const spaceMono = Space_Mono({
variable: "--font-space-mono",
subsets: ["latin"],
weight: ["400", "700"],
display: "swap",
preload: true,
});
const rajdhani = Rajdhani({
variable: "--font-rajdhani",
subsets: ["latin"],
weight: ["300", "400", "500", "600", "700"],
display: "swap",
preload: true,
});
const spaceGrotesk = Space_Grotesk({
variable: "--font-space-grotesk",
subsets: ["latin"],
weight: ["400", "500", "600", "700"],
display: "swap",
preload: true,
});
const exo2 = Exo_2({
variable: "--font-exo-2",
subsets: ["latin"],
weight: ["400", "700", "800", "900"],
display: "swap",
preload: true,
});
export const metadata: Metadata = {
title: "Biohazard VFX - Professional Visual Effects Studio",
description: "Creating stunning visual effects for film, television, and digital media. Expert VFX, motion graphics, compositing, and 3D animation services.",
title: "Biohazard VFX - Visual Effects Studio",
description: "Creating stunning visual effects for commercials, music videos, and digital media. Expert VFX, motion graphics, compositing, and 3D animation services.",
metadataBase: new URL("https://biohazardvfx.com"),
icons: {
icon: [
{ url: '/favicon.ico', sizes: 'any' },
{ url: '/favicon.svg', type: 'image/svg+xml' },
],
},
alternates: {
canonical: "/",
},
openGraph: {
title: "Biohazard VFX - Professional Visual Effects Studio",
description: "Creating stunning visual effects for film, television, and digital media.",
title: "Biohazard VFX - Visual Effects Studio",
description: "Creating stunning visual effects for commercials, music videos, and digital media.",
type: "website",
locale: "en_US",
siteName: "Biohazard VFX",
},
twitter: {
card: "summary_large_image",
title: "Biohazard VFX - Professional Visual Effects Studio",
description: "Creating stunning visual effects for film, television, and digital media.",
title: "Biohazard VFX - Visual Effects Studio",
description: "Creating stunning visual effects for commercials, music videos, and digital media.",
},
};
@ -48,14 +115,14 @@ export default function RootLayout({
"@context": "https://schema.org",
"@type": "Organization",
name: "Biohazard VFX",
description: "Professional visual effects studio specializing in film, television, and digital media",
description: "Visual effects studio specializing in commercials, music videos, and digital media",
url: "https://biohazardvfx.com",
logo: "https://biohazardvfx.com/logo.png",
sameAs: [],
};
return (
<html lang="en">
<html lang="en" className="dark">
<head>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />
@ -65,13 +132,11 @@ export default function RootLayout({
/>
</head>
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
className={`${geistSans.variable} ${geistMono.variable} ${bebasNeue.variable} ${orbitron.variable} ${inter.variable} ${jetbrainsMono.variable} ${spaceMono.variable} ${rajdhani.variable} ${spaceGrotesk.variable} ${exo2.variable} antialiased bg-black text-white`}
>
<Navigation />
<main className="min-h-screen">
{children}
</main>
<Footer />
</body>
</html>
);

View File

@ -1,131 +1,18 @@
import Link from "next/link";
import { Hero } from "@/components/Hero";
import { VideoPlayer } from "@/components/VideoPlayer";
import { ProjectCard } from "@/components/ProjectCard";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Separator } from "@/components/ui/separator";
import { projects, studioReel } from "@/data/projects";
import { Sparkles, Zap, Award } from "lucide-react";
import { ClientLogoGrid } from "@/components/ClientLogoGrid";
import { ProjectShowcase } from "@/components/ProjectShowcase";
import { MissionSection } from "@/components/MissionSection";
import { ContactSection } from "@/components/ContactSection";
import { BrandingSection } from "@/components/BrandingSection";
import { projects } from "@/data/projects";
import { TempPlaceholder } from "@/components/Temp-Placeholder";
export default function Home() {
const featuredProjects = projects.filter((p) => p.featured);
return (
<>
<Hero
title="Crafting Extraordinary Visual Experiences"
subtitle="Award-winning visual effects studio delivering stunning VFX, motion graphics, and 3D animation for film, television, and digital media."
/>
{/* Studio Reel Section */}
<section className="container py-16">
<div className="space-y-4 text-center">
<h2 className="text-3xl font-bold tracking-tighter sm:text-4xl md:text-5xl">
Our Studio Reel
</h2>
<p className="mx-auto max-w-2xl text-muted-foreground">
A showcase of our best work from the past year
</p>
</div>
<div className="mt-8">
<VideoPlayer
videoUrl={studioReel.videoUrl}
thumbnailUrl={studioReel.thumbnailUrl}
title={studioReel.title}
className="aspect-video w-full"
/>
</div>
</section>
<Separator className="container" />
{/* Featured Projects Section */}
<section className="container py-16">
<div className="space-y-4 text-center">
<h2 className="text-3xl font-bold tracking-tighter sm:text-4xl md:text-5xl">
Featured Projects
</h2>
<p className="mx-auto max-w-2xl text-muted-foreground">
A selection of our recent work across film, commercial, and digital media
</p>
</div>
<div className="mt-12 grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
{featuredProjects.map((project) => (
<ProjectCard key={project.id} project={project} />
))}
</div>
<div className="mt-12 text-center">
<Button asChild size="lg">
<Link href="/portfolio">View Full Portfolio</Link>
</Button>
</div>
</section>
<Separator className="container" />
{/* Capabilities Section */}
<section className="container py-16">
<div className="space-y-4 text-center">
<h2 className="text-3xl font-bold tracking-tighter sm:text-4xl md:text-5xl">
Our Capabilities
</h2>
<p className="mx-auto max-w-2xl text-muted-foreground">
Comprehensive visual effects services backed by years of industry experience
</p>
</div>
<div className="mt-12 grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
<Card>
<CardHeader>
<Sparkles className="h-8 w-8 text-primary" />
<CardTitle>Industry-Leading Technology</CardTitle>
<CardDescription>
Utilizing the latest tools and techniques to deliver photorealistic VFX that seamlessly integrate with your footage.
</CardDescription>
</CardHeader>
</Card>
<Card>
<CardHeader>
<Zap className="h-8 w-8 text-primary" />
<CardTitle>Fast Turnaround</CardTitle>
<CardDescription>
Efficient pipeline and experienced team ensure your project stays on schedule without compromising quality.
</CardDescription>
</CardHeader>
</Card>
<Card>
<CardHeader>
<Award className="h-8 w-8 text-primary" />
<CardTitle>Award-Winning Team</CardTitle>
<CardDescription>
Our artists have contributed to numerous award-winning productions across film and television.
</CardDescription>
</CardHeader>
</Card>
</div>
</section>
{/* CTA Section */}
<section className="container py-16">
<Card className="border-2">
<CardContent className="flex flex-col items-center gap-6 p-12 text-center">
<h2 className="text-3xl font-bold tracking-tighter sm:text-4xl">
Ready to Bring Your Vision to Life?
</h2>
<p className="max-w-2xl text-muted-foreground">
Let&apos;s discuss your project and how we can help you create something extraordinary.
</p>
<div className="flex flex-col gap-4 sm:flex-row">
<Button asChild size="lg">
<Link href="/contact">Start a Project</Link>
</Button>
<Button asChild size="lg" variant="outline">
<Link href="/services">Explore Services</Link>
</Button>
</div>
</CardContent>
</Card>
</section>
<TempPlaceholder />
</>
);
}

View File

@ -0,0 +1,19 @@
import { Metadata } from 'next';
import { SpeakersPageClient } from './speakers-client';
export const metadata: Metadata = {
title: 'SPEAKERS | Biohazard VFX',
description: '3D visualization gallery',
alternates: {
canonical: '/projects/speakers',
},
openGraph: {
title: 'SPEAKERS | Biohazard VFX',
description: '3D visualization gallery.',
type: 'website',
},
};
export default function SpeakersPage() {
return <SpeakersPageClient />;
}

View File

@ -0,0 +1,164 @@
'use client';
import { motion } from 'framer-motion';
import { PolycamEmbed } from '@/components/PolycamEmbed';
import { speakers } from '@/data/speakers';
const containerVariants = {
hidden: { opacity: 0 },
visible: {
opacity: 1,
transition: {
staggerChildren: 0.1,
delayChildren: 0.1,
},
},
};
const itemVariants = {
hidden: {
opacity: 0,
y: 20,
},
visible: {
opacity: 1,
y: 0,
},
};
export function SpeakersPageClient() {
return (
<section className="bg-[#0f0f0f] text-white min-h-screen flex flex-col" style={{ fontFamily: 'var(--font-space-grotesk)' }}>
{/* Header Card */}
<header className="py-6 px-4 sm:px-6 lg:px-8">
<div className="container mx-auto max-w-[1200px]">
<motion.div
className="relative bg-[#1a1a1a] border border-white/10 rounded-xl px-6 py-4 md:px-8 md:py-5 shadow-2xl"
style={{
boxShadow: '0 20px 40px rgba(0, 0, 0, 0.6), inset 0 1px 0 rgba(255, 255, 255, 0.05)',
}}
variants={itemVariants}
transition={{ duration: 0.4, ease: 'easeOut' }}
initial="hidden"
animate="visible"
role="banner"
>
<div className="flex flex-col lg:flex-row lg:items-start gap-6 lg:gap-8">
{/* Title */}
<motion.h1
className="text-4xl md:text-5xl font-black font-exo-2 leading-none"
style={{
color: '#ff4d00',
}}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, ease: 'easeOut', delay: 0.1 }}
>
SPEAKERS
</motion.h1>
{/* Info Grid - Left Aligned */}
<div className="grid grid-cols-1 sm:grid-cols-3 gap-6 text-left flex-1">
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, ease: 'easeOut', delay: 0.15 }}
>
<p className="text-xs text-gray-500 uppercase tracking-wide">Code</p>
<p className="text-xs md:text-sm font-mono text-white">SPKR</p>
</motion.div>
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, ease: 'easeOut', delay: 0.2 }}
className="hidden sm:block"
>
<p className="text-xs text-gray-500 uppercase tracking-wide">Client</p>
<p className="text-xs md:text-sm text-white whitespace-nowrap">Carly Gibert</p>
</motion.div>
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, ease: 'easeOut', delay: 0.25 }}
className="hidden sm:block"
>
<p className="text-xs text-gray-500 uppercase tracking-wide">Studio</p>
<p className="text-xs md:text-sm text-white whitespace-nowrap">Biohazard VFX</p>
</motion.div>
</div>
</div>
</motion.div>
</div>
</header>
{/* Divider */}
<div className="px-4 sm:px-6 lg:px-8">
<div className="container mx-auto max-w-[1200px]">
<div className="border-t border-white/10" />
</div>
</div>
{/* Embeds Section */}
<main className="flex-1 px-4 sm:px-6 lg:px-8 py-12 md:py-16">
<div className="container mx-auto max-w-[1200px]">
{/* Visually hidden heading for screen readers */}
<h2 className="sr-only">3D Scan Gallery</h2>
<motion.div
className="relative space-y-8"
variants={containerVariants}
initial="hidden"
animate="visible"
transition={{
staggerChildren: 0.1,
delayChildren: 0.1,
}}
role="region"
aria-label="3D scan gallery"
>
{speakers.map((scan, index) => (
<PolycamEmbed
key={scan.id}
captureId={scan.captureId}
title={scan.title}
index={index}
/>
))}
</motion.div>
</div>
</main>
{/* Divider */}
<div className="px-4 sm:px-6 lg:px-8">
<div className="container mx-auto max-w-[1200px]">
<div className="border-t border-white/10" />
</div>
</div>
{/* Footer */}
<footer className="px-4 sm:px-6 lg:px-8 py-12 md:py-16 border-t border-white/10" role="contentinfo">
<div className="container mx-auto max-w-[1200px]">
<div className="flex flex-col md:flex-row md:items-end md:justify-between gap-8">
<div>
<p className="text-sm text-gray-400 mb-4 font-semibold">Biohazard VFX</p>
<p className="text-sm text-gray-300 max-w-sm">
Artists and technical people specializing in VFX and 3D visualization.
</p>
</div>
<motion.a
href="mailto:contact@biohazardvfx.com"
className="text-sm font-mono"
style={{ color: '#ff4d00' }}
whileHover={{ opacity: 0.8 }}
aria-label="Email contact"
>
contact@biohazardvfx.com
</motion.a>
</div>
</div>
</footer>
</section>
);
}

View File

@ -0,0 +1,13 @@
export function BrandingSection() {
return (
<section className="py-24 bg-black">
<div className="container">
<div className="text-center">
<h1 className="font-brand text-6xl sm:text-7xl md:text-8xl lg:text-9xl text-white leading-none">
BIOHAZARD.
</h1>
</div>
</div>
</section>
);
}

View File

@ -0,0 +1,29 @@
export function ClientLogoGrid() {
const clientLogos = [
"Vercel", "NEXT", "scale", "APOLLO", "Calcom", "Linear",
"knock", "FLOX", "trunk", "Replay", "Graphite", "spiral",
"haastes", "CURSOR", "KREA", "Harvey", "ElevenLabs", "Black Forest Labs",
"Superplastic", "Triplicate", "SOLANA", "Basement", "MY BEAST", "EDGELORD",
"VIRTUAL REALITY", "VIVID", "SHADCN", "KARMA", "G"
];
return (
<section className="py-8 bg-black">
<div className="mx-auto max-w-[980px] px-4">
<p className="mb-6 text-[10px] uppercase tracking-[0.2em] text-gray-400/80 text-center">
Trusted by basement.
</p>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-5 gap-x-10 gap-y-5">
{clientLogos.map((logo, index) => (
<div
key={index}
className="w-full text-left text-slate-300/70 text-[11px] sm:text-xs md:text-sm hover:text-slate-200 transition-colors border border-gray-600/30 p-3 rounded-sm"
>
{logo}
</div>
))}
</div>
</div>
</section>
);
}

View File

@ -0,0 +1,20 @@
export function ContactSection() {
return (
<section className="py-10 bg-black">
<div className="mx-auto max-w-5xl px-4">
{/* Hatched bar */}
<div className="h-8 w-full bg-[repeating-linear-gradient(45deg,rgba(255,255,255,0.08)_0,rgba(255,255,255,0.08)_8px,transparent_8px,transparent_16px)] mb-6" />
<div className="text-left space-y-3">
<div className="text-xs text-gray-400">contact</div>
<h2 className="text-white text-2xl font-bold">Let's make an impact together.</h2>
<a
href="mailto:hello@basement.studio"
className="text-white text-lg underline hover:text-gray-300"
>
hello@basement.studio
</a>
</div>
</div>
</section>
);
}

View File

@ -0,0 +1,120 @@
"use client";
import { useEffect, useState, useRef } from "react";
interface CursorDotBackgroundProps {
dotSize?: number;
dotSpacing?: number;
fadeDistance?: number;
opacity?: number;
className?: string;
}
export function CursorDotBackground({
dotSize = 1,
dotSpacing = 20,
fadeDistance = 100,
opacity = 0.3,
className = "",
}: CursorDotBackgroundProps) {
const [mousePosition, setMousePosition] = useState({ x: 0, y: 0 });
const [isHovering, setIsHovering] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const handleMouseMove = (e: MouseEvent) => {
if (containerRef.current) {
const rect = containerRef.current.getBoundingClientRect();
setMousePosition({
x: e.clientX - rect.left,
y: e.clientY - rect.top,
});
}
};
const handleMouseEnter = () => setIsHovering(true);
const handleMouseLeave = () => setIsHovering(false);
const container = containerRef.current;
if (container) {
container.addEventListener("mousemove", handleMouseMove);
container.addEventListener("mouseenter", handleMouseEnter);
container.addEventListener("mouseleave", handleMouseLeave);
}
return () => {
if (container) {
container.removeEventListener("mousemove", handleMouseMove);
container.removeEventListener("mouseenter", handleMouseEnter);
container.removeEventListener("mouseleave", handleMouseLeave);
}
};
}, []);
// Generate dots based on container size
const generateDots = () => {
if (!containerRef.current) return [];
const rect = containerRef.current.getBoundingClientRect();
const width = rect.width;
const height = rect.height;
const dots = [];
const cols = Math.ceil(width / dotSpacing);
const rows = Math.ceil(height / dotSpacing);
for (let row = 0; row < rows; row++) {
for (let col = 0; col < cols; col++) {
const x = col * dotSpacing;
const y = row * dotSpacing;
// Calculate distance from mouse position
const distance = Math.sqrt(
Math.pow(x - mousePosition.x, 2) + Math.pow(y - mousePosition.y, 2)
);
// Calculate opacity based on distance and hover state
let dotOpacity = 0;
if (isHovering && distance <= fadeDistance) {
dotOpacity = opacity * (1 - distance / fadeDistance);
}
if (dotOpacity > 0) {
dots.push({
x,
y,
opacity: dotOpacity,
});
}
}
}
return dots;
};
return (
<div
ref={containerRef}
className={`absolute inset-0 pointer-events-none ${className}`}
style={{
backgroundImage: `radial-gradient(circle, rgba(255,255,255,0.1) 1px, transparent 1px)`,
backgroundSize: `${dotSpacing}px ${dotSpacing}px`,
}}
>
{generateDots().map((dot, index) => (
<div
key={index}
className="absolute rounded-full bg-white transition-opacity duration-150 ease-out"
style={{
left: dot.x,
top: dot.y,
width: dotSize,
height: dotSize,
opacity: dot.opacity,
transform: "translate(-50%, -50%)",
}}
/>
))}
</div>
);
}

142
src/components/DepthMap.tsx Normal file
View File

@ -0,0 +1,142 @@
"use client";
import { useEffect, useRef, useState } from "react";
interface DepthMapProps {
originalImg: string;
depthImg: string;
verticalThreshold?: number;
horizontalThreshold?: number;
}
export function DepthMap({
originalImg,
depthImg,
verticalThreshold = 15,
horizontalThreshold = 15,
}: DepthMapProps) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const [mousePos, setMousePos] = useState({ x: 0.5, y: 0.5 });
const [loaded, setLoaded] = useState(false);
const originalImgRef = useRef<HTMLImageElement | null>(null);
const depthImgRef = useRef<HTMLImageElement | null>(null);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
// Load images
const original = new Image();
const depth = new Image();
original.crossOrigin = "anonymous";
depth.crossOrigin = "anonymous";
let loadedCount = 0;
const onLoad = () => {
loadedCount++;
if (loadedCount === 2) {
originalImgRef.current = original;
depthImgRef.current = depth;
setLoaded(true);
}
};
original.onload = onLoad;
depth.onload = onLoad;
original.src = originalImg;
depth.src = depthImg;
}, [originalImg, depthImg]);
useEffect(() => {
if (!loaded || !canvasRef.current) return;
const canvas = canvasRef.current;
const ctx = canvas.getContext("2d");
if (!ctx || !originalImgRef.current || !depthImgRef.current) return;
const original = originalImgRef.current;
const depth = depthImgRef.current;
// Set canvas size to match image
canvas.width = original.width;
canvas.height = original.height;
// Calculate displacement based on mouse position
const offsetX = (mousePos.x - 0.5) * horizontalThreshold;
const offsetY = (mousePos.y - 0.5) * verticalThreshold;
// Clear canvas
ctx.clearRect(0, 0, canvas.width, canvas.height);
// Draw the original image
ctx.drawImage(original, 0, 0);
// Get image data for manipulation
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
const pixels = imageData.data;
// Draw depth map to get depth values
ctx.drawImage(depth, 0, 0);
const depthData = ctx.getImageData(0, 0, canvas.width, canvas.height);
const depthPixels = depthData.data;
// Create displaced image
const displaced = ctx.createImageData(canvas.width, canvas.height);
for (let y = 0; y < canvas.height; y++) {
for (let x = 0; x < canvas.width; x++) {
const idx = (y * canvas.width + x) * 4;
// Get depth value (using red channel) - inverted so darker = more movement
const depthValue = 1 - (depthPixels[idx] / 255);
// Calculate displacement
const displaceX = Math.round(offsetX * depthValue);
const displaceY = Math.round(offsetY * depthValue);
// Source pixel position
const srcX = Math.max(0, Math.min(canvas.width - 1, x - displaceX));
const srcY = Math.max(0, Math.min(canvas.height - 1, y - displaceY));
const srcIdx = (srcY * canvas.width + srcX) * 4;
// Copy pixel
displaced.data[idx] = pixels[srcIdx];
displaced.data[idx + 1] = pixels[srcIdx + 1];
displaced.data[idx + 2] = pixels[srcIdx + 2];
displaced.data[idx + 3] = pixels[srcIdx + 3];
}
}
ctx.putImageData(displaced, 0, 0);
}, [loaded, mousePos, horizontalThreshold, verticalThreshold]);
const handleMouseMove = (e: React.MouseEvent<HTMLCanvasElement>) => {
const canvas = canvasRef.current;
if (!canvas) return;
const rect = canvas.getBoundingClientRect();
const x = (e.clientX - rect.left) / rect.width;
const y = (e.clientY - rect.top) / rect.height;
setMousePos({ x, y });
};
const handleMouseLeave = () => {
setMousePos({ x: 0.5, y: 0.5 });
};
return (
<canvas
ref={canvasRef}
onMouseMove={handleMouseMove}
onMouseLeave={handleMouseLeave}
className="w-full h-full object-cover"
style={{ display: loaded ? 'block' : 'none' }}
/>
);
}

View File

@ -4,81 +4,31 @@ import { Separator } from "@/components/ui/separator";
export function Footer() {
const currentYear = new Date().getFullYear();
const footerLinks = [
{ href: "/", label: "Home" },
{ href: "/services", label: "Services" },
{ href: "/portfolio", label: "Showcase" },
{ href: "/about", label: "People" },
{ href: "#", label: "Blog" },
{ href: "#", label: "Lab" },
{ href: "/contact", label: "Contact Us" },
];
return (
<footer className="border-t bg-background">
<div className="container py-12">
<div className="grid grid-cols-1 gap-8 md:grid-cols-4">
<div className="space-y-4">
<h3 className="text-lg font-semibold">Biohazard VFX</h3>
<p className="text-sm text-muted-foreground">
Creating stunning visual effects for film, television, and digital media.
</p>
</div>
<div className="space-y-4">
<h4 className="text-sm font-semibold">Navigation</h4>
<ul className="space-y-2 text-sm">
<li>
<Link href="/" className="text-muted-foreground hover:text-foreground transition-colors">
Home
</Link>
</li>
<li>
<Link href="/about" className="text-muted-foreground hover:text-foreground transition-colors">
About
</Link>
</li>
<li>
<Link href="/services" className="text-muted-foreground hover:text-foreground transition-colors">
Services
</Link>
</li>
<li>
<Link href="/portfolio" className="text-muted-foreground hover:text-foreground transition-colors">
Portfolio
</Link>
</li>
</ul>
</div>
<div className="space-y-4">
<h4 className="text-sm font-semibold">Services</h4>
<ul className="space-y-2 text-sm">
<li className="text-muted-foreground">Visual Effects</li>
<li className="text-muted-foreground">Motion Graphics</li>
<li className="text-muted-foreground">Compositing</li>
<li className="text-muted-foreground">3D Animation</li>
</ul>
</div>
<div className="space-y-4">
<h4 className="text-sm font-semibold">Contact</h4>
<ul className="space-y-2 text-sm">
<li>
<Link href="/contact" className="text-muted-foreground hover:text-foreground transition-colors">
Get in Touch
</Link>
</li>
<li className="text-muted-foreground">info@biohazardvfx.com</li>
</ul>
</div>
</div>
<Separator className="my-8" />
<div className="flex flex-col items-center justify-between gap-4 md:flex-row">
<p className="text-sm text-muted-foreground">
© {currentYear} Biohazard VFX. All rights reserved.
</p>
<div className="flex gap-4 text-sm text-muted-foreground">
<Link href="#" className="hover:text-foreground transition-colors">
Privacy Policy
<footer className="border-t border-gray-800 bg-black">
<div className="mx-auto max-w-5xl px-4 py-12">
<div className="grid grid-cols-1 gap-2 text-center">
{footerLinks.map((link) => (
<Link
key={link.href}
href={link.href}
className="text-xs text-gray-400 hover:text-white transition-colors"
>
{link.label}
</Link>
<Link href="#" className="hover:text-foreground transition-colors">
Terms of Service
</Link>
</div>
))}
</div>
<p className="mt-6 text-center text-[10px] text-gray-500">© {currentYear} Basement</p>
</div>
</footer>
);

View File

@ -1,40 +1,39 @@
import Link from "next/link";
import { Button } from "@/components/ui/button";
interface HeroProps {
title: string;
subtitle: string;
ctaText?: string;
ctaLink?: string;
secondaryCtaText?: string;
secondaryCtaLink?: string;
videoSrc?: string;
}
export function Hero({
title,
subtitle,
ctaText = "View Portfolio",
ctaLink = "/portfolio",
secondaryCtaText = "Get in Touch",
secondaryCtaLink = "/contact",
}: HeroProps) {
export function Hero({ videoSrc = "/hero-video.mp4" }: HeroProps) {
return (
<section className="container flex flex-col items-center justify-center space-y-8 py-24 text-center md:py-32">
<div className="space-y-4 max-w-4xl">
<h1 className="text-4xl font-bold tracking-tighter sm:text-5xl md:text-6xl lg:text-7xl">
{title}
</h1>
<p className="mx-auto max-w-2xl text-lg text-muted-foreground sm:text-xl">
{subtitle}
</p>
</div>
<div className="flex flex-col gap-4 sm:flex-row">
<Button asChild size="lg">
<Link href={ctaLink}>{ctaText}</Link>
</Button>
<Button asChild size="lg" variant="outline">
<Link href={secondaryCtaLink}>{secondaryCtaText}</Link>
</Button>
<section className="relative h-[85vh] w-full overflow-hidden">
{/* Video Background */}
<video
className="absolute inset-0 h-full w-full object-cover"
autoPlay
muted
loop
playsInline
>
<source src={videoSrc} type="video/mp4" />
Your browser does not support the video tag.
</video>
{/* Gradient + dark overlay to match mockup */}
<div className="absolute inset-0 bg-gradient-to-t from-black via-black/40 to-transparent" />
{/* Content bottom-aligned */}
<div className="relative z-10 flex h-full items-end">
<div className="container pb-10 text-white">
<div className="max-w-5xl space-y-3">
<h1 className="text-[28px] sm:text-[36px] md:text-[44px] lg:text-[48px] leading-tight font-bold tracking-tight">
A digital studio & branding powerhouse
<br />
making cool shit that performs.
</h1>
<p className="max-w-xl text-[10px] sm:text-xs text-gray-300">
We partner with the world's most ambitious brands to create powerful brands to unlock their full potential. We go beyond design to create compelling, strategic brand image.
</p>
</div>
</div>
</div>
</section>
);

View File

@ -0,0 +1,86 @@
"use client";
import { useState } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { ChevronRight } from "lucide-react";
interface HorizontalAccordionProps {
trigger: string;
children: React.ReactNode;
className?: string;
}
export function HorizontalAccordion({
trigger,
children,
className = ""
}: HorizontalAccordionProps) {
const [isOpen, setIsOpen] = useState(false);
return (
<div className={`flex flex-col gap-4 ${className}`}>
{/* Trigger Button */}
<motion.button
onClick={() => setIsOpen(!isOpen)}
className="flex items-center gap-2 text-gray-300 hover:text-white transition-colors whitespace-nowrap text-lg font-medium"
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
>
{trigger}
<motion.div
animate={{
rotate: isOpen ? 90 : 0,
color: isOpen ? '#ff4d00' : '#d1d5db'
}}
transition={{ duration: 0.2 }}
>
<ChevronRight className="h-4 w-4" />
</motion.div>
</motion.button>
{/* Animated Content */}
<AnimatePresence mode="wait">
{isOpen && (
<motion.div
initial={{
height: 0,
opacity: 0,
y: -10
}}
animate={{
height: "auto",
opacity: 1,
y: 0
}}
exit={{
height: 0,
opacity: 0,
y: -10
}}
transition={{
duration: 0.4,
ease: [0.4, 0, 0.2, 1],
opacity: { duration: 0.3 }
}}
className="overflow-hidden w-full"
style={{ willChange: "height, opacity, transform" }}
>
<motion.div
initial={{ x: -20, opacity: 0 }}
animate={{ x: 0, opacity: 1 }}
exit={{ x: -20, opacity: 0 }}
transition={{
duration: 0.3,
delay: 0.15,
ease: "easeOut"
}}
className="w-full max-w-2xl"
>
{children}
</motion.div>
</motion.div>
)}
</AnimatePresence>
</div>
);
}

View File

@ -0,0 +1,44 @@
"use client";
import { useEffect } from "react";
export function InstagramFeed() {
useEffect(() => {
// Load LightWidget script for Instagram feed
const script = document.createElement('script');
script.src = 'https://cdn.lightwidget.com/widgets/lightwidget.js';
script.async = true;
document.body.appendChild(script);
return () => {
// Cleanup script on unmount
if (document.body.contains(script)) {
document.body.removeChild(script);
}
};
}, []);
return (
<div className="mb-8">
<p className="mb-4 text-base sm:text-lg">
<strong>Latest from our studio:</strong>
</p>
{/* Instagram Feed Grid - Posts Only */}
<div className="mb-4">
<iframe
src="https://cdn.lightwidget.com/widgets/dfd875efe9b05e47b5ff190cc0a71990.html"
scrolling="no"
className="lightwidget-widget"
style={{
width: '100%',
height: '200px',
border: 'none',
overflow: 'hidden',
backgroundColor: 'transparent'
}}
/>
</div>
</div>
);
}

View File

@ -0,0 +1,43 @@
export function MissionSection() {
return (
<section className="py-24 bg-black">
<div className="container">
<div className="max-w-4xl mx-auto text-center mb-16">
<h2 className="text-3xl font-bold tracking-tighter sm:text-4xl md:text-5xl text-white leading-tight">
We're here to create the extraordinary. No shortcuts, just bold, precision-engineered work that elevates the game & leaves a mark.
</h2>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-8">
<div className="space-y-4">
<h3 className="text-xl font-bold text-white">Website & Features</h3>
<p className="text-gray-300 leading-relaxed">
From sleek landing pages to complex web applications, we create responsive, user-friendly websites that look great and function flawlessly on any device.
</p>
</div>
<div className="space-y-4">
<h3 className="text-xl font-bold text-white">Visual Branding</h3>
<p className="text-gray-300 leading-relaxed">
From logo identities to comprehensive brand guidelines, we build brands that tell a story, resonate with audiences, and stand out in the market.
</p>
</div>
<div className="space-y-4">
<h3 className="text-xl font-bold text-white">UI/UX Design</h3>
<p className="text-gray-300 leading-relaxed">
We craft intuitive and engaging user interfaces that prioritize usability and accessibility, ensuring a seamless and enjoyable experience for every user.
</p>
</div>
<div className="space-y-4">
<h3 className="text-xl font-bold text-white">Marketing & Growth</h3>
<p className="text-gray-300 leading-relaxed">
From social media campaigns to SEO optimization, we develop strategies that drive traffic, generate leads, and boost conversions for your business.
</p>
</div>
</div>
</div>
</section>
);
}

View File

@ -22,10 +22,10 @@ export function Navigation() {
];
return (
<header className="sticky top-0 z-50 w-full border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<header className="sticky top-0 z-50 w-full border-b border-gray-800 bg-black/95 backdrop-blur supports-[backdrop-filter]:bg-black/60">
<div className="container flex h-16 items-center justify-between">
<Link href="/" className="flex items-center space-x-2">
<span className="text-2xl font-bold">Biohazard VFX</span>
<span className="text-2xl font-bold text-white">basement.</span>
</Link>
<NavigationMenu>
@ -33,7 +33,7 @@ export function Navigation() {
{navItems.map((item) => (
<NavigationMenuItem key={item.href}>
<NavigationMenuLink asChild active={pathname === item.href}>
<Link href={item.href} className={navigationMenuTriggerStyle()}>
<Link href={item.href} className={`${navigationMenuTriggerStyle()} text-white hover:text-gray-300`}>
{item.label}
</Link>
</NavigationMenuLink>

View File

@ -0,0 +1,172 @@
'use client';
import { useState, useEffect } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
interface PolycamEmbedProps {
captureId: string;
title: string;
index?: number;
}
export function PolycamEmbed({ captureId, title, index = 0 }: PolycamEmbedProps) {
const [isFullscreen, setIsFullscreen] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const [hasError, setHasError] = useState(false);
const [isDesktop, setIsDesktop] = useState(true);
useEffect(() => {
const checkDesktop = () => {
setIsDesktop(window.innerWidth >= 1024);
};
checkDesktop();
window.addEventListener('resize', checkDesktop);
return () => window.removeEventListener('resize', checkDesktop);
}, []);
const itemVariants = {
hidden: {
opacity: 0,
y: 20,
},
visible: {
opacity: 1,
y: 0,
},
};
return (
<>
<motion.div
variants={itemVariants}
transition={{ duration: 0.4, ease: 'easeOut', delay: index * 0.1 }}
whileHover={{
boxShadow: '0 20px 40px rgba(0, 0, 0, 0.6)',
transition: { duration: 0.2, ease: 'easeOut' },
}}
className="relative bg-[#1a1a1a] rounded-lg overflow-hidden p-4"
>
{/* Regular Embed */}
<div
className="relative overflow-hidden rounded-lg bg-black/40"
style={{ aspectRatio: isDesktop ? '16 / 10' : '5 / 4' }}
>
{/* Loading Skeleton */}
{isLoading && !hasError && (
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-white/5 to-transparent animate-pulse" />
)}
{/* Error State */}
{hasError && (
<div className="absolute inset-0 flex items-center justify-center bg-black/60 text-center">
<div>
<p className="text-gray-400 text-sm mb-2">Failed to load 3D scan</p>
<p className="text-gray-600 text-xs">Please try again later</p>
</div>
</div>
)}
<iframe
src={`https://poly.cam/capture/${captureId}/embed`}
title={title}
frameBorder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
className="w-full h-full"
onLoad={() => setIsLoading(false)}
onError={() => {
setIsLoading(false);
setHasError(true);
}}
aria-label={`3D scan viewer: ${title}`}
/>
{/* Fullscreen Button */}
<motion.button
onClick={() => setIsFullscreen(true)}
className="absolute top-4 left-4 p-2 rounded-lg bg-black/70 hover:bg-black/85 transition-colors z-10"
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.95 }}
aria-label="Open fullscreen view"
>
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="#ff4d00"
strokeWidth="2"
>
<path d="M8 3H5a2 2 0 0 0-2 2v3m18 0V5a2 2 0 0 0-2-2h-3m0 18h3a2 2 0 0 0 2-2v-3M3 16v3a2 2 0 0 0 2 2h3" />
</svg>
</motion.button>
</div>
{/* Title */}
{title && (
<div className="mt-4">
<h2 className="text-lg md:text-xl font-semibold text-white">{title}</h2>
</div>
)}
</motion.div>
{/* Fullscreen Modal */}
<AnimatePresence>
{isFullscreen && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
className="fixed inset-0 z-50 bg-black/80 flex items-center justify-center p-4 sm:p-8 md:p-16 lg:p-20"
onClick={() => setIsFullscreen(false)}
role="dialog"
aria-modal="true"
aria-label="Fullscreen 3D scan viewer"
>
<motion.div
initial={{ scale: 0.9, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0.9, opacity: 0 }}
transition={{ duration: 0.2 }}
className="relative w-full h-full"
style={{ aspectRatio: isDesktop ? '16 / 10' : '5 / 4' }}
onClick={(e) => e.stopPropagation()}
>
<iframe
src={`https://poly.cam/capture/${captureId}/embed`}
title={title}
frameBorder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
className="w-full h-full rounded-lg"
/>
{/* Close Button */}
<motion.button
onClick={() => setIsFullscreen(false)}
className="absolute top-4 left-4 p-2 rounded-lg bg-black/70 hover:bg-black/85 transition-colors"
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.95 }}
aria-label="Close fullscreen view"
>
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="#ff4d00"
strokeWidth="2"
>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</motion.button>
</motion.div>
</motion.div>
)}
</AnimatePresence>
</>
);
}

View File

@ -0,0 +1,41 @@
import { Project } from "@/data/projects";
interface ProjectShowcaseProps {
projects: Project[];
}
export function ProjectShowcase({ projects }: ProjectShowcaseProps) {
return (
<section className="py-16 bg-black">
<div className="mx-auto max-w-5xl px-4">
<h2 className="text-white text-xl font-bold mb-8">Featured Projects</h2>
<div className="space-y-16">
{projects.map((project) => (
<div key={project.id} className="grid grid-cols-12 gap-6 items-start">
{/* Image left */}
<div className="col-span-12 md:col-span-8">
<div className="relative overflow-hidden">
<img
src={project.thumbnailUrl}
alt={project.title}
className="w-full h-auto object-cover"
/>
</div>
</div>
{/* Right-side label and description */}
<div className="col-span-12 md:col-span-4 text-gray-300 text-xs space-y-2">
<div className="text-right">
<div className="text-white">{project.title}</div>
</div>
<p className="leading-relaxed">
{project.description}
</p>
</div>
</div>
))}
</div>
</div>
</section>
);
}

View File

@ -0,0 +1,258 @@
"use client";
import { useRef, useState, useEffect } from "react";
import { Play, Pause, Volume2, VolumeX, Maximize, AlertCircle } from "lucide-react";
interface ReelPlayerProps {
src: string;
className?: string;
}
export function ReelPlayer({ src, className = "" }: ReelPlayerProps) {
const videoRef = useRef<HTMLVideoElement>(null);
const progressBarRef = useRef<HTMLDivElement>(null);
const [isPlaying, setIsPlaying] = useState(false);
const [isMuted, setIsMuted] = useState(false);
const [currentTime, setCurrentTime] = useState(0);
const [duration, setDuration] = useState(0);
const [volume, setVolume] = useState(1);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const video = videoRef.current;
if (!video) return;
const handleLoadedMetadata = () => {
setDuration(video.duration);
};
const handleTimeUpdate = () => {
setCurrentTime(video.currentTime);
};
const handleEnded = () => {
setIsPlaying(false);
};
const handleError = (e: Event) => {
setIsLoading(false);
const videoEl = e.target as HTMLVideoElement;
const errorCode = videoEl.error?.code;
const errorMessage = videoEl.error?.message;
let userMessage = "Failed to load video. ";
switch (errorCode) {
case 1:
userMessage += "Video loading was aborted.";
break;
case 2:
userMessage += "Network error occurred.";
break;
case 3:
userMessage += "Video format not supported by your browser.";
break;
case 4:
userMessage += "Video source not found.";
break;
default:
userMessage += errorMessage || "Unknown error.";
}
setError(userMessage);
console.error("Video error:", errorCode, errorMessage);
};
const handleCanPlay = () => {
console.log("Video canplay event fired");
setIsLoading(false);
setError(null);
};
const handleLoadedData = () => {
console.log("Video loadeddata event fired");
setIsLoading(false);
};
video.addEventListener("loadedmetadata", handleLoadedMetadata);
video.addEventListener("timeupdate", handleTimeUpdate);
video.addEventListener("ended", handleEnded);
video.addEventListener("error", handleError);
video.addEventListener("canplay", handleCanPlay);
video.addEventListener("loadeddata", handleLoadedData);
// Check if video is already loaded (in case events fired before listeners attached)
if (video.readyState >= 3) {
// HAVE_FUTURE_DATA or HAVE_ENOUGH_DATA
console.log("Video already loaded, readyState:", video.readyState);
setIsLoading(false);
if (video.duration) {
setDuration(video.duration);
}
}
return () => {
video.removeEventListener("loadedmetadata", handleLoadedMetadata);
video.removeEventListener("timeupdate", handleTimeUpdate);
video.removeEventListener("ended", handleEnded);
video.removeEventListener("error", handleError);
video.removeEventListener("canplay", handleCanPlay);
video.removeEventListener("loadeddata", handleLoadedData);
};
}, []);
const togglePlay = async () => {
const video = videoRef.current;
if (!video || error) return;
try {
if (isPlaying) {
video.pause();
setIsPlaying(false);
} else {
await video.play();
setIsPlaying(true);
}
} catch (err) {
console.error("Play error:", err);
setError("Unable to play video. " + (err as Error).message);
setIsPlaying(false);
}
};
const toggleMute = () => {
const video = videoRef.current;
if (!video) return;
video.muted = !isMuted;
setIsMuted(!isMuted);
};
const handleProgressClick = (e: React.MouseEvent<HTMLDivElement>) => {
const video = videoRef.current;
const progressBar = progressBarRef.current;
if (!video || !progressBar) return;
const rect = progressBar.getBoundingClientRect();
const clickX = e.clientX - rect.left;
const percentage = clickX / rect.width;
video.currentTime = percentage * video.duration;
};
const toggleFullscreen = () => {
const video = videoRef.current;
if (!video) return;
if (!document.fullscreenElement) {
video.requestFullscreen();
} else {
document.exitFullscreen();
}
};
const formatTime = (seconds: number) => {
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${mins}:${secs.toString().padStart(2, "0")}`;
};
const progress = duration > 0 ? (currentTime / duration) * 100 : 0;
return (
<div className={`relative bg-black border border-white/10 ${className}`}>
{/* Video Element */}
<video
ref={videoRef}
className="w-full aspect-video bg-black"
onClick={togglePlay}
preload="auto"
playsInline
>
<source src={src} type="video/mp4" />
Your browser does not support the video tag.
</video>
{/* Loading State */}
{isLoading && !error && (
<div className="absolute inset-0 flex items-center justify-center bg-black/50">
<div className="text-white text-sm">Loading video...</div>
</div>
)}
{/* Error State */}
{error && (
<div className="absolute inset-0 flex flex-col items-center justify-center bg-black/80 p-4">
<AlertCircle className="w-12 h-12 text-[#ff4d00] mb-3" />
<div className="text-white text-sm text-center max-w-md">
{error}
</div>
<div className="text-gray-400 text-xs mt-2">
Try refreshing the page or using a different browser.
</div>
</div>
)}
{/* Custom Controls */}
{!error && (
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/90 via-black/70 to-transparent p-4">
{/* Progress Bar */}
<div
ref={progressBarRef}
className="w-full h-1 bg-white/20 cursor-pointer mb-3 relative"
onClick={handleProgressClick}
>
<div
className="h-full bg-[#ff4d00] transition-all duration-100"
style={{ width: `${progress}%` }}
/>
</div>
{/* Controls Row */}
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-3">
{/* Play/Pause Button */}
<button
onClick={togglePlay}
className="text-white hover:text-[#ff4d00] transition-colors"
aria-label={isPlaying ? "Pause" : "Play"}
>
{isPlaying ? (
<Pause className="w-5 h-5" />
) : (
<Play className="w-5 h-5" />
)}
</button>
{/* Volume Button */}
<button
onClick={toggleMute}
className="text-white hover:text-[#ff4d00] transition-colors"
aria-label={isMuted ? "Unmute" : "Mute"}
>
{isMuted ? (
<VolumeX className="w-5 h-5" />
) : (
<Volume2 className="w-5 h-5" />
)}
</button>
{/* Time Display */}
<span className="text-white text-sm font-mono">
{formatTime(currentTime)} / {formatTime(duration)}
</span>
</div>
{/* Fullscreen Button */}
<button
onClick={toggleFullscreen}
className="text-white hover:text-[#ff4d00] transition-colors"
aria-label="Fullscreen"
>
<Maximize className="w-5 h-5" />
</button>
</div>
</div>
)}
</div>
);
}

View File

@ -0,0 +1,22 @@
"use client";
import { motion, useScroll, useSpring } from "framer-motion";
export function ScrollProgressBar() {
const { scrollYProgress } = useScroll();
const scaleY = useSpring(scrollYProgress, {
stiffness: 100,
damping: 30,
restDelta: 0.001
});
return (
<motion.div
className="fixed right-0 top-0 bottom-0 w-[3px] origin-top z-50 pointer-events-none"
style={{
scaleY,
backgroundColor: '#ff4d00',
}}
/>
);
}

View File

@ -0,0 +1,20 @@
"use client";
import { motion } from "framer-motion";
interface SectionDividerProps {
className?: string;
}
export function SectionDivider({ className = "" }: SectionDividerProps) {
return (
<motion.div
className={`h-[1px] bg-gray-800 my-8 md:my-12 ${className}`}
initial={{ scaleX: 0 }}
whileInView={{ scaleX: 1 }}
viewport={{ once: true, margin: "-50px" }}
transition={{ duration: 0.6, ease: "easeOut" }}
style={{ transformOrigin: 'left' }}
/>
);
}

View File

@ -9,7 +9,7 @@ interface ServiceCardProps {
export function ServiceCard({ service }: ServiceCardProps) {
// Dynamically get the icon component
const IconComponent = (LucideIcons as any)[service.icon] || LucideIcons.Box;
const IconComponent = (LucideIcons as Record<string, unknown>)[service.icon] || LucideIcons.Box;
return (
<Card className="h-full transition-shadow hover:shadow-lg">

View File

@ -0,0 +1,703 @@
"use client";
import { HorizontalAccordion } from "./HorizontalAccordion";
import { InstagramFeed } from "./InstagramFeed";
import { ScrollProgressBar } from "./ScrollProgressBar";
import { SectionDivider } from "./SectionDivider";
import { VideoPreview } from "./VideoPreview";
import { ReelPlayer } from "./ReelPlayer";
import { useEffect, useRef, useState } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { DepthMap } from "./DepthMap";
import Image from "next/image";
import { HoverCard, HoverCardContent, HoverCardTrigger } from "@/components/ui/hover-card";
// Animation variants for page load
const containerVariants = {
hidden: { opacity: 0 },
visible: {
opacity: 1,
transition: {
staggerChildren: 0.1,
delayChildren: 0.1,
},
},
};
const itemVariants = {
hidden: {
opacity: 0,
y: 20
},
visible: {
opacity: 1,
y: 0,
},
};
export function TempPlaceholder() {
const titleRef = useRef<HTMLHeadingElement | null>(null);
const titleInnerRef = useRef<HTMLSpanElement | null>(null);
const bioTextRef = useRef<HTMLSpanElement | null>(null);
const [titleWidth, setTitleWidth] = useState<number | null>(null);
const [bioFontSizePx, setBioFontSizePx] = useState<number | null>(null);
const baseBioFontSizeRef = useRef<number | null>(null);
const [isEasterEggOpen, setIsEasterEggOpen] = useState(false);
const [mousePosition, setMousePosition] = useState({ x: 0, y: 0 });
const [isPigeonEggOpen, setIsPigeonEggOpen] = useState(false);
const [pigeonMousePosition, setPigeonMousePosition] = useState({ x: 0, y: 0 });
useEffect(() => {
const measure = () => {
const measuredTitleWidth = titleInnerRef.current?.offsetWidth ?? null;
setTitleWidth(measuredTitleWidth);
if (measuredTitleWidth && bioTextRef.current) {
const element = bioTextRef.current;
if (baseBioFontSizeRef.current === null) {
const initialFontSize = parseFloat(getComputedStyle(element).fontSize);
baseBioFontSizeRef.current = isNaN(initialFontSize) ? 16 : initialFontSize;
}
// Temporarily ensure we measure at base font size
const baseFontSize = baseBioFontSizeRef.current ?? 16;
const previousInlineFontSize = element.style.fontSize;
element.style.fontSize = `${baseFontSize}px`;
const bioNaturalWidth = element.offsetWidth;
// Restore previous inline style before we set state (will update after render)
element.style.fontSize = previousInlineFontSize;
if (bioNaturalWidth > 0) {
// On mobile, use a more conservative scaling to prevent cramped text
const isMobile = window.innerWidth < 640; // sm breakpoint
const isTablet = window.innerWidth < 1024; // lg breakpoint
let maxScale;
if (isMobile) {
maxScale = 0.8; // Limit scaling on mobile
} else if (isTablet) {
maxScale = 1.2; // Allow more scaling on tablet
} else {
maxScale = 1.8; // Allow much more scaling on desktop
}
const scale = Math.min(measuredTitleWidth / bioNaturalWidth, maxScale);
setBioFontSizePx(baseFontSize * scale);
}
}
};
measure();
window.addEventListener("resize", measure);
return () => window.removeEventListener("resize", measure);
}, []);
return (
<>
<ScrollProgressBar />
<section className="py-8 md:py-16 bg-black text-white min-h-screen">
<div className="container mx-auto px-4 sm:px-6 lg:px-8 max-w-[900px]">
{/* Navigation */}
<nav className="mb-12 md:mb-16">
<div className="flex justify-between items-center py-6 border-b border-white/10">
<div className="text-lg font-mono tracking-tight">BIOHAZARD</div>
<div className="flex gap-6 text-sm">
<a href="#about" className="hover:text-[#ff4d00] transition-colors">About</a>
<a href="#work" className="hover:text-[#ff4d00] transition-colors">Work</a>
<a href="#studio" className="hover:text-[#ff4d00] transition-colors">Studio</a>
<a href="#contact" className="hover:text-[#ff4d00] transition-colors">Contact</a>
</div>
</div>
</nav>
{/* Main Card Container */}
<div className="relative bg-[#0a0a0a] border border-white/5 p-6 sm:p-8 md:p-12">
<motion.div
className="relative"
variants={containerVariants}
initial="hidden"
animate="visible"
transition={{
staggerChildren: 0.1,
delayChildren: 0.1,
}}
>
{/* About Section */}
<section id="about" className="mb-16 md:mb-20">
<motion.p
className="text-sm text-gray-500 mb-6"
variants={itemVariants}
transition={{ duration: 0.4, ease: "easeOut" }}
>
Last updated: 10-12-2025
</motion.p>
<motion.h1
ref={titleRef}
className="text-3xl sm:text-4xl md:text-5xl font-bold mb-4 leading-tight"
variants={itemVariants}
transition={{ duration: 0.4, ease: "easeOut" }}
>
<span ref={titleInnerRef} className="inline-block">
You've gotta be <em className="text-gray-400">shittin'</em> me.
</span>
</motion.h1>
<motion.p
className="text-base sm:text-lg mb-2 text-gray-300"
variants={itemVariants}
transition={{ duration: 0.4, ease: "easeOut" }}
>
This is the 20th time this has happened.
</motion.p>
<motion.p
className="text-base sm:text-lg mb-6 md:mb-8 text-gray-400"
variants={itemVariants}
transition={{ duration: 0.4, ease: "easeOut" }}
>
<em>Nicholai broke the website, again.</em>
</motion.p>
<motion.div
className="mb-8"
variants={itemVariants}
transition={{ duration: 0.4, ease: "easeOut" }}
>
<HorizontalAccordion trigger="How did we get here?">
<div className="w-full">
<p className="mb-4 text-gray-400 text-sm">
<em>(TLDR: perfectionism is the mind killer)</em>
</p>
<ol className="list-decimal list-inside space-y-3 text-gray-300 leading-relaxed break-words">
<li>We needed a website (circa January 2023)</li>
<li>We tried to build one on squarespace (that shit sucks)</li>
<li>
Nicholai figured "I know some html and javascript, why not just{" "}
<em>make</em> one."
</li>
<li>
But of course, <strong>the html site sucked</strong> and was
difficult to host.
</li>
<li>
And naturally, the website for some reason <em>needed</em> to look
good.
</li>
<li>
So then began a longwinded journey of Nicholai learning <em>react</em>
</li>
<li>Nicholai should've stuck to python.</li>
</ol>
</div>
</HorizontalAccordion>
</motion.div>
<HoverCard openDelay={300}>
<HoverCardTrigger asChild>
<motion.h1
onClick={(e) => {
setMousePosition({ x: e.clientX, y: e.clientY });
setIsEasterEggOpen(true);
}}
className="text-4xl sm:text-5xl md:text-7xl lg:text-8xl xl:text-9xl font-black mb-4 md:mb-6 font-exo-2 text-center mx-auto leading-none cursor-pointer transition-opacity hover:opacity-80"
style={{
color: '#000000',
textShadow: '2px 2px 0px #ff4d00, 4px 4px 0px #ff4d00',
width: titleWidth ? `${titleWidth}px` : undefined
}}
variants={itemVariants}
transition={{ duration: 0.4, ease: "easeOut" }}
>
<span ref={bioTextRef} className="inline-block" style={{ fontSize: bioFontSizePx ? `${bioFontSizePx}px` : undefined }}>
BIOHAZARD
</span>
</motion.h1>
</HoverCardTrigger>
<HoverCardContent className="w-auto px-3 py-2 bg-black border-gray-800 text-gray-300 text-sm">
Click to reveal
</HoverCardContent>
</HoverCard>
<AnimatePresence>
{isEasterEggOpen && (
<>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.3 }}
className="fixed inset-0 z-50 bg-black/80"
onClick={() => setIsEasterEggOpen(false)}
/>
<motion.div
initial={{ opacity: 0, scale: 0.7, x: '-50%', y: '-50%' }}
animate={{ opacity: 1, scale: 1, x: '-50%', y: '-50%' }}
exit={{ opacity: 0, scale: 0.7, x: '-50%', y: '-50%' }}
transition={{
duration: 0.4,
ease: [0.16, 1, 0.3, 1]
}}
style={{
position: 'fixed',
left: mousePosition.x,
top: mousePosition.y,
}}
className="z-50 w-[90vw] max-w-[350px]"
onMouseLeave={() => setIsEasterEggOpen(false)}
>
<div className="relative bg-black overflow-hidden shadow-2xl">
<motion.div
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
transition={{
duration: 0.3,
delay: 0.1,
ease: [0.16, 1, 0.3, 1]
}}
className="relative w-full aspect-square"
>
<DepthMap
originalImg="/OLIVER.jpeg"
depthImg="/OLIVER_depth.jpeg"
verticalThreshold={40}
horizontalThreshold={70}
/>
</motion.div>
</div>
</motion.div>
</>
)}
</AnimatePresence>
<AnimatePresence>
{isPigeonEggOpen && (
<>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.3 }}
className="fixed inset-0 z-50 bg-black/80"
onClick={() => setIsPigeonEggOpen(false)}
/>
<motion.div
initial={{ opacity: 0, scale: 0.7, x: '-50%', y: '-50%' }}
animate={{ opacity: 1, scale: 1, x: '-50%', y: '-50%' }}
exit={{ opacity: 0, scale: 0.7, x: '-50%', y: '-50%' }}
transition={{
duration: 0.4,
ease: [0.16, 1, 0.3, 1]
}}
style={{
position: 'fixed',
left: pigeonMousePosition.x,
top: pigeonMousePosition.y,
}}
className="z-50 w-[90vw] max-w-[400px]"
onMouseLeave={() => setIsPigeonEggOpen(false)}
>
<div className="relative bg-black overflow-hidden shadow-2xl">
<motion.div
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
transition={{
duration: 0.3,
delay: 0.1,
ease: [0.16, 1, 0.3, 1]
}}
className="relative w-full"
>
<Image
src="/no_pigeons_zone.gif"
alt="No pigeons zone"
width={400}
height={400}
className="w-full h-auto"
unoptimized
/>
</motion.div>
</div>
</motion.div>
</>
)}
</AnimatePresence>
<motion.p
className="mb-6 md:mb-8 text-base sm:text-lg text-gray-300"
variants={itemVariants}
transition={{ duration: 0.4, ease: "easeOut" }}
>
<strong>Who we are:</strong> artists and technical people, we're
better at VFX than we are at web design, I promise.
</motion.p>
</section>
<SectionDivider />
{/* Work Section */}
<section id="work" className="mb-16 md:mb-20">
<motion.p
className="mb-6 text-base sm:text-lg"
variants={itemVariants}
transition={{ duration: 0.4, ease: "easeOut" }}
>
<strong>&gt; Here's our reel:</strong>
</motion.p>
<motion.div
variants={itemVariants}
transition={{ duration: 0.4, ease: "easeOut" }}
className="mb-8"
>
<ReelPlayer src="/reel.mp4" />
</motion.div>
<SectionDivider />
<motion.p
className="mb-4 md:mb-6 text-base sm:text-lg"
variants={itemVariants}
transition={{ duration: 0.4, ease: "easeOut" }}
>
<strong>&gt; Some projects we've worked on:</strong>
</motion.p>
<motion.ul
className="mb-6 md:mb-8 space-y-3 md:space-y-4 text-sm sm:text-base"
variants={itemVariants}
transition={{ duration: 0.4, ease: "easeOut" }}
>
<motion.li
className="flex items-start"
variants={itemVariants}
transition={{ duration: 0.4, ease: "easeOut" }}
>
<span className="text-gray-400 mr-3 mt-1"></span>
<motion.a
href="https://www.instagram.com/biohazardvfx/"
className="block leading-relaxed relative"
style={{ color: '#ff4d00' }}
target="_blank"
rel="noopener noreferrer"
whileHover={{ opacity: 0.8 }}
whileTap={{ scale: 0.98 }}
transition={{ duration: 0.2 }}
>
<span className="relative inline-block">
Gstar Raw - Pommelhorse
<motion.span
className="absolute bottom-0 left-0 h-[1px] bg-current"
initial={{ scaleX: 0 }}
whileHover={{ scaleX: 1 }}
transition={{ duration: 0.3, ease: "easeOut" }}
style={{ transformOrigin: 'left', width: '100%' }}
/>
</span>
</motion.a>
</motion.li>
<motion.li
className="flex items-start"
variants={itemVariants}
transition={{ duration: 0.4, ease: "easeOut" }}
>
<span className="text-gray-400 mr-3 mt-1"></span>
<HoverCard>
<HoverCardTrigger asChild>
<motion.a
href="https://www.youtube.com/watch?v=4QIZE708gJ4"
className="block leading-relaxed relative"
style={{ color: '#ff4d00' }}
target="_blank"
rel="noopener noreferrer"
whileHover={{
textShadow: '0 0 8px rgba(255, 77, 0, 0.6)',
}}
whileTap={{ scale: 0.98 }}
transition={{ duration: 0.2 }}
>
<span className="relative inline-block">
Post Malone - I Had Some Help<br className="sm:hidden" />
<span className="sm:hidden">(feat. Morgan Wallen)</span>
<span className="hidden sm:inline"> (feat. Morgan Wallen)</span>
<motion.span
className="absolute bottom-0 left-0 h-[1px] bg-current"
initial={{ scaleX: 0 }}
whileHover={{ scaleX: 1 }}
transition={{ duration: 0.3, ease: "easeOut" }}
style={{ transformOrigin: 'left', width: '100%' }}
/>
</span>
</motion.a>
</HoverCardTrigger>
<HoverCardContent className="w-80 p-0 bg-black border-gray-800 z-50">
<VideoPreview
videoId="4QIZE708gJ4"
title="Post Malone - I Had Some Help (feat. Morgan Wallen)"
/>
</HoverCardContent>
</HoverCard>
</motion.li>
<motion.li
className="flex items-start"
variants={itemVariants}
transition={{ duration: 0.4, ease: "easeOut" }}
>
<span className="text-gray-400 mr-3 mt-1"></span>
<HoverCard>
<HoverCardTrigger asChild>
<motion.a
href="https://www.youtube.com/watch?v=z2tUpLHdd4M"
className="block leading-relaxed relative"
style={{ color: '#ff4d00' }}
target="_blank"
rel="noopener noreferrer"
whileHover={{
textShadow: '0 0 8px rgba(255, 77, 0, 0.6)',
}}
whileTap={{ scale: 0.98 }}
transition={{ duration: 0.2 }}
>
<span className="relative inline-block">
The Wait Is Over | OFFICIAL TRAILER
<motion.span
className="absolute bottom-0 left-0 h-[1px] bg-current"
initial={{ scaleX: 0 }}
whileHover={{ scaleX: 1 }}
transition={{ duration: 0.3, ease: "easeOut" }}
style={{ transformOrigin: 'left', width: '100%' }}
/>
</span>
</motion.a>
</HoverCardTrigger>
<HoverCardContent className="w-80 p-0 bg-black border-gray-800 z-50">
<VideoPreview
videoId="z2tUpLHdd4M"
title="The Wait Is Over | OFFICIAL TRAILER"
/>
</HoverCardContent>
</HoverCard>
</motion.li>
<motion.li
className="flex items-start"
variants={itemVariants}
transition={{ duration: 0.4, ease: "easeOut" }}
>
<span className="text-gray-400 mr-3 mt-1"></span>
<HoverCard>
<HoverCardTrigger asChild>
<motion.a
href="https://www.youtube.com/watch?v=RCZ9wl1Up40"
className="block leading-relaxed relative"
style={{ color: '#ff4d00' }}
target="_blank"
rel="noopener noreferrer"
whileHover={{
textShadow: '0 0 8px rgba(255, 77, 0, 0.6)',
}}
whileTap={{ scale: 0.98 }}
transition={{ duration: 0.2 }}
>
<span className="relative inline-block">
2hollis Star Album Trailer
<motion.span
className="absolute bottom-0 left-0 h-[1px] bg-current"
initial={{ scaleX: 0 }}
whileHover={{ scaleX: 1 }}
transition={{ duration: 0.3, ease: "easeOut" }}
style={{ transformOrigin: 'left', width: '100%' }}
/>
</span>
</motion.a>
</HoverCardTrigger>
<HoverCardContent className="w-80 p-0 bg-black border-gray-800 z-50">
<VideoPreview
videoId="RCZ9wl1Up40"
title="2hollis Star Album Trailer"
/>
</HoverCardContent>
</HoverCard>
</motion.li>
<motion.li
className="flex items-start"
variants={itemVariants}
transition={{ duration: 0.4, ease: "easeOut" }}
>
<span className="text-gray-400 mr-3 mt-1"></span>
<HoverCard>
<HoverCardTrigger asChild>
<motion.a
href="https://www.youtube.com/watch?v=yLxoVrFpLmQ"
className="block leading-relaxed relative"
style={{ color: '#ff4d00' }}
target="_blank"
rel="noopener noreferrer"
whileHover={{
textShadow: '0 0 8px rgba(255, 77, 0, 0.6)',
}}
whileTap={{ scale: 0.98 }}
transition={{ duration: 0.2 }}
>
<span className="relative inline-block">
Thanksgiving With Kai, Kevin &amp; Druski
<motion.span
className="absolute bottom-0 left-0 h-[1px] bg-current"
initial={{ scaleX: 0 }}
whileHover={{ scaleX: 1 }}
transition={{ duration: 0.3, ease: "easeOut" }}
style={{ transformOrigin: 'left', width: '100%' }}
/>
</span>
</motion.a>
</HoverCardTrigger>
<HoverCardContent className="w-80 p-0 bg-black border-gray-800 z-50">
<VideoPreview
videoId="yLxoVrFpLmQ"
title="Thanksgiving With Kai, Kevin & Druski"
/>
</HoverCardContent>
</HoverCard>
</motion.li>
<motion.li
className="flex items-start"
variants={itemVariants}
transition={{ duration: 0.4, ease: "easeOut" }}
>
<span className="text-gray-400 mr-3 mt-1"></span>
<HoverCard>
<HoverCardTrigger asChild>
<motion.a
href="https://www.youtube.com/watch?v=a2Zqdo9RbNs"
className="block leading-relaxed relative"
style={{ color: '#ff4d00' }}
target="_blank"
rel="noopener noreferrer"
whileHover={{
textShadow: '0 0 8px rgba(255, 77, 0, 0.6)',
}}
whileTap={{ scale: 0.98 }}
transition={{ duration: 0.2 }}
>
<span className="relative inline-block">
ENHYPEN () Bad Desire<br className="sm:hidden" />
<span className="sm:hidden">(With or Without You) Official MV</span>
<span className="hidden sm:inline"> (With or Without You) Official MV</span>
<motion.span
className="absolute bottom-0 left-0 h-[1px] bg-current"
initial={{ scaleX: 0 }}
whileHover={{ scaleX: 1 }}
transition={{ duration: 0.3, ease: "easeOut" }}
style={{ transformOrigin: 'left', width: '100%' }}
/>
</span>
</motion.a>
</HoverCardTrigger>
<HoverCardContent className="w-80 p-0 bg-black border-gray-800 z-50">
<VideoPreview
videoId="a2Zqdo9RbNs"
title="ENHYPEN (엔하이픈) Bad Desire (With or Without You) Official MV"
/>
</HoverCardContent>
</HoverCard>
</motion.li>
</motion.ul>
</section>
<SectionDivider />
{/* Studio Section */}
<section id="studio" className="mb-16 md:mb-20">
<motion.div
variants={itemVariants}
transition={{ duration: 0.4, ease: "easeOut" }}
>
<InstagramFeed />
</motion.div>
</section>
<SectionDivider />
{/* Contact Section */}
<section id="contact">
<motion.div
variants={itemVariants}
transition={{ duration: 0.4, ease: "easeOut" }}
>
<motion.p
className="mb-4 text-sm sm:text-base text-gray-300"
variants={itemVariants}
transition={{ duration: 0.4, ease: "easeOut" }}
>
<strong>&gt; Contact us:</strong>{" "}
<motion.a
href="mailto:contact@biohazardvfx.com"
className="break-words inline-block relative"
style={{ color: '#ff4d00' }}
target="_blank"
rel="noopener noreferrer"
whileHover={{
textShadow: '0 0 8px rgba(255, 77, 0, 0.6)',
}}
whileTap={{ scale: 0.98 }}
transition={{ duration: 0.2 }}
>
<span className="relative inline-block">
contact@biohazardvfx.com
<motion.span
className="absolute bottom-0 left-0 h-[1px] bg-current"
initial={{ scaleX: 0 }}
whileHover={{ scaleX: 1 }}
transition={{ duration: 0.3, ease: "easeOut" }}
style={{ transformOrigin: 'left', width: '100%' }}
/>
</span>
</motion.a>
</motion.p>
<motion.p
className="text-sm sm:text-base text-gray-300 pb-6 border-b border-white/10"
variants={itemVariants}
transition={{ duration: 0.4, ease: "easeOut" }}
>
<strong>&gt; File a complaint:</strong>{" "}
<motion.a
href="https://www.youtube.com/watch?v=dQw4w9WgXcQ"
className="break-words inline-block relative"
style={{ color: '#ff4d00' }}
target="_blank"
rel="noopener noreferrer"
whileHover={{
textShadow: '0 0 8px rgba(255, 77, 0, 0.6)',
}}
whileTap={{ scale: 0.98 }}
transition={{ duration: 0.2 }}
>
<span className="relative inline-block">
help@biohazardvfx.com
<motion.span
className="absolute bottom-0 left-0 h-[1px] bg-current"
initial={{ scaleX: 0 }}
whileHover={{ scaleX: 1 }}
transition={{ duration: 0.3, ease: "easeOut" }}
style={{ transformOrigin: 'left', width: '100%' }}
/>
</span>
</motion.a>
</motion.p>
<motion.p
onClick={(e) => {
setPigeonMousePosition({ x: e.clientX, y: e.clientY });
setIsPigeonEggOpen(true);
}}
className="text-xs text-gray-600 mt-4 cursor-pointer hover:text-gray-500 transition-colors"
initial={{ opacity: 0, y: 10 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-50px" }}
transition={{ duration: 0.6, ease: "easeOut", delay: 0.2 }}
>
No pigeons allowed in this zone
</motion.p>
</motion.div>
</section>
</motion.div>
</div>
</div>
</section>
</>
);
}

View File

@ -0,0 +1,44 @@
"use client";
import { useState } from "react";
import { motion, AnimatePresence } from "framer-motion";
interface VideoPreviewProps {
videoId: string;
title: string;
}
export function VideoPreview({ videoId, title }: VideoPreviewProps) {
const [isLoading, setIsLoading] = useState(true);
return (
<div className="relative w-full" style={{ width: 320, height: 180 }}>
<AnimatePresence>
{isLoading && (
<motion.div
initial={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.3 }}
className="absolute inset-0 bg-black rounded-md flex items-center justify-center"
>
<motion.div
animate={{ opacity: [0.5, 1, 0.5] }}
transition={{ duration: 1.5, repeat: Infinity, ease: "easeInOut" }}
className="w-16 h-16 border-2 border-gray-800 rounded-md"
/>
</motion.div>
)}
</AnimatePresence>
<iframe
width="320"
height="180"
src={`https://www.youtube.com/embed/${videoId}`}
title={title}
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
className="rounded-md border-0"
onLoad={() => setIsLoading(false)}
/>
</div>
);
}

View File

@ -0,0 +1,57 @@
"use client"
import * as React from "react"
import * as AccordionPrimitive from "@radix-ui/react-accordion"
import { ChevronDown } from "lucide-react"
import { cn } from "@/lib/utils"
const Accordion = AccordionPrimitive.Root
const AccordionItem = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Item>
>(({ className, ...props }, ref) => (
<AccordionPrimitive.Item
ref={ref}
className={cn("border-b", className)}
{...props}
/>
))
AccordionItem.displayName = "AccordionItem"
const AccordionTrigger = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<AccordionPrimitive.Header className="flex">
<AccordionPrimitive.Trigger
ref={ref}
className={cn(
"flex flex-1 items-center justify-between py-4 text-sm font-medium transition-all hover:underline text-left [&[data-state=open]>svg]:rotate-180",
className
)}
{...props}
>
{children}
<ChevronDown className="h-4 w-4 shrink-0 text-muted-foreground transition-transform duration-200" />
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
))
AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName
const AccordionContent = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<AccordionPrimitive.Content
ref={ref}
className="overflow-hidden text-sm data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down"
{...props}
>
<div className={cn("pb-4 pt-0", className)}>{children}</div>
</AccordionPrimitive.Content>
))
AccordionContent.displayName = AccordionPrimitive.Content.displayName
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }

View File

@ -0,0 +1,59 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const alertVariants = cva(
"relative w-full rounded-lg border px-4 py-3 text-sm [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground [&>svg~*]:pl-7",
{
variants: {
variant: {
default: "bg-background text-foreground",
destructive:
"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
},
},
defaultVariants: {
variant: "default",
},
}
)
const Alert = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
>(({ className, variant, ...props }, ref) => (
<div
ref={ref}
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
))
Alert.displayName = "Alert"
const AlertTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h5
ref={ref}
className={cn("mb-1 font-medium leading-none tracking-tight", className)}
{...props}
/>
))
AlertTitle.displayName = "AlertTitle"
const AlertDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("text-sm [&_p]:leading-relaxed", className)}
{...props}
/>
))
AlertDescription.displayName = "AlertDescription"
export { Alert, AlertTitle, AlertDescription }

View File

@ -0,0 +1,29 @@
"use client"
import * as React from "react"
import * as HoverCardPrimitive from "@radix-ui/react-hover-card"
import { cn } from "@/lib/utils"
const HoverCard = HoverCardPrimitive.Root
const HoverCardTrigger = HoverCardPrimitive.Trigger
const HoverCardContent = React.forwardRef<
React.ElementRef<typeof HoverCardPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof HoverCardPrimitive.Content>
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
<HoverCardPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 w-64 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none 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 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-hover-card-content-transform-origin]",
className
)}
{...props}
/>
))
HoverCardContent.displayName = HoverCardPrimitive.Content.displayName
export { HoverCard, HoverCardTrigger, HoverCardContent }

View File

@ -13,47 +13,47 @@ export interface Project {
export const projects: Project[] = [
{
id: "1",
title: "Cinematic VFX Breakdown",
description: "High-end visual effects for feature film production",
category: "Film",
title: "Wound Shop",
description: "Partnering with Wound Shop means building the fastest, most performant websites. We craft beautiful, responsive designs that make for an enjoyable user experience.",
category: "Web Development",
thumbnailUrl: "https://images.unsplash.com/photo-1536440136628-849c177e76a1?w=800&q=80",
videoUrl: "https://www.youtube.com/embed/dQw4w9WgXcQ",
aspectRatio: 16 / 9,
featured: true,
tags: ["VFX", "Film", "CGI"],
tags: ["Web", "Development", "Design"],
},
{
id: "2",
title: "Commercial Product Spot",
description: "Stunning product visualization and motion graphics",
category: "Commercial",
title: "Daylight",
description: "A bold vision needs a strong launch. We crafted a dynamic brand identity for Daylight, ensuring it captured their innovative spirit and stood out in a crowded market, establishing a fresh, modern, and trustworthy presence.",
category: "Branding",
thumbnailUrl: "https://images.unsplash.com/photo-1492691527719-9d1e07e534b4?w=800&q=80",
videoUrl: "https://www.youtube.com/embed/dQw4w9WgXcQ",
aspectRatio: 1,
featured: true,
tags: ["Commercial", "Motion Graphics"],
tags: ["Branding", "Identity", "Launch"],
},
{
id: "3",
title: "Music Video Effects",
description: "Creative visual effects for music production",
category: "Music Video",
title: "KidSuper",
description: "Working with KidSuper, we developed a vibrant and playful brand identity that reflects their unique approach to fashion. Our designs brought their creative vision to life, ensuring a cohesive and memorable brand experience.",
category: "Fashion",
thumbnailUrl: "https://images.unsplash.com/photo-1511379938547-c1f69419868d?w=800&q=80",
videoUrl: "https://www.youtube.com/embed/dQw4w9WgXcQ",
aspectRatio: 9 / 16,
featured: true,
tags: ["Music Video", "Creative"],
tags: ["Fashion", "Creative", "Brand"],
},
{
id: "4",
title: "Sci-Fi Environment Design",
description: "Futuristic world building and environment creation",
category: "Film",
title: "Shop MrBeast",
description: "The world's biggest YouTuber needed a storefront that could keep up. We built a robust e-commerce platform for MrBeast, handling massive traffic and ensuring a seamless shopping experience for his millions of fans.",
category: "E-commerce",
thumbnailUrl: "https://images.unsplash.com/photo-1451187580459-43490279c0fa?w=800&q=80",
videoUrl: "https://www.youtube.com/embed/dQw4w9WgXcQ",
aspectRatio: 21 / 9,
featured: false,
tags: ["VFX", "Environment", "3D"],
featured: true,
tags: ["E-commerce", "Platform", "Scale"],
},
{
id: "5",

15
src/data/speakers.ts Normal file
View File

@ -0,0 +1,15 @@
export interface PolycamScan {
id: string;
title: string;
captureId: string;
tags?: string[];
}
export const speakers: PolycamScan[] = [
{
id: "scan-001",
title: "Low quality 3d Gaussian Splatting test",
captureId: "0306c02b-5cd2-4da2-92df-b8820eb9df67",
tags: ["3d-scan", "polycam"],
},
];

118
src/lib/instagram-api.ts Normal file
View File

@ -0,0 +1,118 @@
// Instagram API utilities for fetching posts and media
// Based on Instagram Graph API documentation from Context7
export interface InstagramPost {
id: string;
caption: string;
media_url: string;
permalink: string;
timestamp: string;
media_type: string;
thumbnail_url?: string;
}
// Instagram Graph API oEmbed endpoint for getting embeddable HTML
export async function getInstagramEmbedHtml(postUrl: string): Promise<string | null> {
try {
const response = await fetch(
`https://graph.facebook.com/v23.0/instagram_oembed?url=${encodeURIComponent(postUrl)}`
);
if (!response.ok) {
throw new Error('Failed to fetch Instagram embed');
}
const data = await response.json();
return data.html || null;
} catch (error) {
console.error('Error fetching Instagram embed:', error);
return null;
}
}
// Fetch Instagram page media using Graph API (requires access token)
export async function getInstagramPageMedia(
pageId: string,
accessToken: string,
limit: number = 6
): Promise<InstagramPost[]> {
try {
const response = await fetch(
`https://graph.facebook.com/v23.0/${pageId}/media?fields=id,caption,media_url,permalink,timestamp,media_type,thumbnail_url&limit=${limit}&access_token=${accessToken}`
);
if (!response.ok) {
throw new Error('Failed to fetch Instagram media');
}
const data = await response.json();
return data.data || [];
} catch (error) {
console.error('Error fetching Instagram media:', error);
return [];
}
}
// Alternative: Use a third-party service like EmbedSocial or SnapWidget
export function getEmbedSocialWidget(username: string): string {
// This would return the embed code for services like EmbedSocial
return `
<div class="embedsocial-hashtag" data-ref="instagram" data-hashtag="${username}">
<a class="feed-powered-by-es" href="https://embedsocial.com/instagram/" target="_blank" title="Widget by EmbedSocial">Widget by EmbedSocial</a>
</div>
`;
}
// Sample data for development/demo purposes
export const sampleInstagramPosts: InstagramPost[] = [
{
id: "1",
caption: "Working on some sick VFX for an upcoming project... 🔥",
media_url: "https://picsum.photos/400/400?random=1",
permalink: "https://www.instagram.com/p/sample1/",
timestamp: "2025-01-10T10:00:00Z",
media_type: "IMAGE"
},
{
id: "2",
caption: "Behind the scenes of our latest work 🎬",
media_url: "https://picsum.photos/400/400?random=2",
permalink: "https://www.instagram.com/p/sample2/",
timestamp: "2025-01-08T15:30:00Z",
media_type: "VIDEO",
thumbnail_url: "https://picsum.photos/400/400?random=2"
},
{
id: "3",
caption: "Studio life - coffee, code, and VFX magic ✨",
media_url: "https://picsum.photos/400/400?random=3",
permalink: "https://www.instagram.com/p/sample3/",
timestamp: "2025-01-05T12:00:00Z",
media_type: "IMAGE"
},
{
id: "4",
caption: "Client project reveal - can't wait to share this one! 🚀",
media_url: "https://picsum.photos/400/400?random=4",
permalink: "https://www.instagram.com/p/sample4/",
timestamp: "2025-01-03T14:20:00Z",
media_type: "IMAGE"
},
{
id: "5",
caption: "Late night render session - the magic happens when everyone else is sleeping 🌙",
media_url: "https://picsum.photos/400/400?random=5",
permalink: "https://www.instagram.com/p/sample5/",
timestamp: "2025-01-01T22:30:00Z",
media_type: "VIDEO",
thumbnail_url: "https://picsum.photos/400/400?random=5"
},
{
id: "6",
caption: "New year, new projects, same dedication to quality VFX 💪",
media_url: "https://picsum.photos/400/400?random=6",
permalink: "https://www.instagram.com/p/sample6/",
timestamp: "2024-12-30T09:00:00Z",
media_type: "IMAGE"
}
];

28
src/middleware.ts Normal file
View File

@ -0,0 +1,28 @@
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
// Allow only the home page, speakers project, and Next.js internal routes
if (pathname === '/' || pathname.startsWith('/projects/speakers') || pathname.startsWith('/_next') || pathname.startsWith('/favicon.') || pathname === '/OLIVER.jpeg' || pathname === '/OLIVER_depth.jpeg' || pathname === '/no_pigeons_zone.gif' || pathname === '/reel.mp4') {
return NextResponse.next();
}
// Redirect all other routes to home
return NextResponse.redirect(new URL('/', request.url));
}
export const config = {
matcher: [
/*
* Match all request paths except for the ones starting with:
* - api (API routes)
* - _next/static (static files)
* - _next/image (image optimization files)
* - favicon.ico (favicon file)
*/
'/((?!api|_next/static|_next/image|favicon.ico).*)',
],
};

View File

@ -1,27 +1,18 @@
# Cloudflare Workers configuration
# Update this file with your Cloudflare account details and deployment settings
# Cloudflare Workers configuration for Next.js
name = "biohazard-vfx-website"
compatibility_date = "2024-01-01"
main = ".open-next/worker.mjs"
account_id = "a19f770b9be1b20e78b8d25bdcfd3bbd"
compatibility_date = "2024-09-23"
compatibility_flags = ["nodejs_compat"]
main = ".open-next/worker.js"
# Account ID and other deployment details should be configured through environment variables
# or added here after initial setup
# Custom domains
routes = [
{ pattern = "biohazardvfx.com/*", zone_name = "biohazardvfx.com" },
{ pattern = "www.biohazardvfx.com/*", zone_name = "biohazardvfx.com" }
]
[site]
bucket = ".open-next/assets"
# Environment variables
[vars]
# Add your environment variables here
# EXAMPLE_VAR = "value"
# Uncomment and configure for production
# [env.production]
# name = "biohazard-vfx-website-production"
# route = "yourdomain.com/*"
# Uncomment and configure for preview/staging
# [env.preview]
# name = "biohazard-vfx-website-preview"
# Assets binding for OpenNext
[assets]
directory = ".open-next/assets"
binding = "ASSETS"