Compare commits
23 Commits
main
...
temp-place
| Author | SHA1 | Date | |
|---|---|---|---|
| 4d131335dc | |||
| 52b92ab44a | |||
| b1feda521c | |||
| a76e20e91f | |||
| a2c67c3fb9 | |||
| 90e730c2fe | |||
| a06b2607c7 | |||
| bedd355b78 | |||
| 7b1acf5588 | |||
| 9845081330 | |||
| 3bafa982ee | |||
| 1589c35026 | |||
| 150f16a3de | |||
| 94b9eeea15 | |||
| 7af9d05f48 | |||
| aa3356e854 | |||
| 431be04d28 | |||
| a2eafe3037 | |||
| 9733cc8ad6 | |||
| f48786b20b | |||
| 06fe062114 | |||
| d6ae81d11a | |||
| 9543cca413 |
0
.claude/agents/design-system-architect.md
Normal file
159
.cursor/rules/component-patterns.mdc
Normal 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
|
||||
97
.cursor/rules/data-content.mdc
Normal 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())
|
||||
}, [])
|
||||
|
||||
// ...
|
||||
}
|
||||
```
|
||||
73
.cursor/rules/deployment.mdc
Normal 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/`
|
||||
74
.cursor/rules/development-workflow.mdc
Normal 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
|
||||
87
.cursor/rules/forms-validation.mdc
Normal 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)
|
||||
75
.cursor/rules/images-assets.mdc
Normal 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",
|
||||
},
|
||||
],
|
||||
}
|
||||
```
|
||||
60
.cursor/rules/project-structure.mdc
Normal 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
|
||||
```
|
||||
67
.cursor/rules/routing-layout.mdc
Normal 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"
|
||||
}
|
||||
```
|
||||
78
.cursor/rules/seo-metadata.mdc
Normal 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)
|
||||
60
.cursor/rules/ui-system.mdc
Normal 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
|
||||
99
.cursor/rules/vfx-specific.mdc
Normal 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)
|
||||
BIN
.cursor/screenshots/animated-easter-egg-before.png
Normal file
|
After Width: | Height: | Size: 80 KiB |
BIN
.cursor/screenshots/before-easter-egg.png
Normal file
|
After Width: | Height: | Size: 80 KiB |
BIN
.cursor/screenshots/modal-redesign-test.png
Normal file
|
After Width: | Height: | Size: 80 KiB |
BIN
.cursor/screenshots/oliver-image-test.png
Normal file
|
After Width: | Height: | Size: 943 KiB |
BIN
.cursor/screenshots/page-2025-10-13T05-58-57-572Z.png
Normal file
|
After Width: | Height: | Size: 51 KiB |
BIN
.cursor/screenshots/page-2025-10-13T06-22-07-898Z.png
Normal file
|
After Width: | Height: | Size: 83 KiB |
BIN
.cursor/screenshots/page-2025-10-13T06-23-59-180Z.png
Normal file
|
After Width: | Height: | Size: 80 KiB |
3
.cursorindexingignore
Normal file
@ -0,0 +1,3 @@
|
||||
|
||||
# Don't index SpecStory auto-save files, but allow explicit context inclusion via @ references
|
||||
.specstory/**
|
||||
6
.gitignore
vendored
@ -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
@ -0,0 +1,4 @@
|
||||
# SpecStory project identity file
|
||||
/.project.json
|
||||
# SpecStory explanation file
|
||||
/.what-is-this.md
|
||||
1526
.specstory/history/2025-10-23_11-06Z-generate-cursor-rules.md
Normal file
211
CLAUDE.md
Normal 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`.
|
||||
|
||||
41
README.md
@ -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
@ -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"
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
|
||||
@ -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
@ -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
|
After Width: | Height: | Size: 2.8 KiB |
BIN
public/OLIVER.jpeg
Normal file
|
After Width: | Height: | Size: 364 KiB |
BIN
public/OLIVER_depth.jpeg
Normal file
|
After Width: | Height: | Size: 361 KiB |
BIN
public/favicon.ico
Normal file
|
After Width: | Height: | Size: 246 KiB |
BIN
public/favicon.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
84
public/favicon.svg
Normal 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 |
@ -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 |
@ -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 |
@ -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
|
After Width: | Height: | Size: 34 KiB |
BIN
public/open 24 hours.gif
Normal file
|
After Width: | Height: | Size: 3.8 KiB |
@ -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
|
After Width: | Height: | Size: 46 KiB |
@ -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 |
95
src/app/api/media/[...path]/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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 */
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
129
src/app/page.tsx
@ -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'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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
19
src/app/projects/speakers/page.tsx
Normal 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 />;
|
||||
}
|
||||
164
src/app/projects/speakers/speakers-client.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
13
src/components/BrandingSection.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
29
src/components/ClientLogoGrid.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
20
src/components/ContactSection.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
120
src/components/CursorDotBackground.tsx
Normal 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
@ -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' }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
86
src/components/HorizontalAccordion.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
44
src/components/InstagramFeed.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
43
src/components/MissionSection.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
|
||||
172
src/components/PolycamEmbed.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
41
src/components/ProjectShowcase.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
258
src/components/ReelPlayer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
22
src/components/ScrollProgressBar.tsx
Normal 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',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
20
src/components/SectionDivider.tsx
Normal 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' }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -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">
|
||||
|
||||
703
src/components/Temp-Placeholder.tsx
Normal 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>> 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>> 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 & 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>> 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>> 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
44
src/components/VideoPreview.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
57
src/components/ui/accordion.tsx
Normal 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 }
|
||||
59
src/components/ui/alert.tsx
Normal 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 }
|
||||
29
src/components/ui/hover-card.tsx
Normal 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 }
|
||||
@ -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
@ -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
@ -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
@ -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).*)',
|
||||
],
|
||||
};
|
||||
|
||||
@ -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"
|
||||
|
||||
|
||||