feat(video): add custom reel player with local mp4 support
Replaced Frame.io link with embedded local video player for the studio reel. ## Changes - Created ReelPlayer component with custom HTML5 video controls - Play/pause, volume, fullscreen, progress bar with scrubbing - Loading and error states with user-friendly messages - Dark theme styling with orange (#ff4d00) accents and sharp corners - Responsive design for mobile/tablet/desktop - Integrated ReelPlayer into Temp-Placeholder (Work section) - Replaced external Frame.io link with local /reel.mp4 - Maintains minimal aesthetic with proper animations - Fixed middleware whitelist issue - Added /reel.mp4 to middleware allowlist (src/middleware.ts:8) - Prevents 307 redirect that was causing "text/html" Content-Type error - Added video file headers to next.config.ts - Ensures proper video/mp4 MIME type for all .mp4 files - Updated CLAUDE.md documentation - Added critical warning about middleware whitelist in "Common pitfalls" - Added rule #9 to "Agents operating rules" for public/ file additions - Future-proofs against this issue happening again ## Technical Details - Video: 146MB, H.264 codec, 4K resolution (3840x2160) - Player handles large file buffering gracefully - ReadyState check prevents loading overlay persistence - All controls accessible and keyboard-friendly 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
bedd355b78
commit
a06b2607c7
333
CLAUDE.md
333
CLAUDE.md
@ -1,168 +1,211 @@
|
|||||||
# CLAUDE.md
|
# Agents Guide (Single Source of Truth)
|
||||||
|
|
||||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
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.
|
||||||
|
|
||||||
## Project Overview
|
## 1) Scope and goals
|
||||||
|
|
||||||
Biohazard VFX is a modern Next.js 15 website for a visual effects studio, deployed to Cloudflare Workers using OpenNext. The site showcases portfolio work, services, and provides a multi-step contact form for client intake.
|
* 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
|
||||||
|
|
||||||
**Tech Stack:**
|
## 2) Tech stack
|
||||||
- Next.js 15.5.4 with App Router
|
|
||||||
- TypeScript (strict mode)
|
|
||||||
- React 19.1.0
|
|
||||||
- Tailwind CSS 4
|
|
||||||
- shadcn/ui (New York style)
|
|
||||||
- Framer Motion for animations
|
|
||||||
- Cloudflare Workers deployment via OpenNext
|
|
||||||
|
|
||||||
## Development Commands
|
* **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
|
||||||
|
|
||||||
```bash
|
## 3) Project layout
|
||||||
# Development
|
|
||||||
npm run dev # Start dev server with Turbopack
|
|
||||||
npm run type-check # Run TypeScript compiler (no emit)
|
|
||||||
npm run lint # Run ESLint
|
|
||||||
|
|
||||||
# Building
|
```
|
||||||
npm run build # Standard Next.js build
|
root
|
||||||
npm run build:open-next # Build for Cloudflare Workers (runs next build + open-next build)
|
├─ src/
|
||||||
|
│ ├─ app/ # App Router pages and layouts
|
||||||
# Production
|
│ │ ├─ (marketing)/ # Example route groups
|
||||||
npm start # Start Next.js production server (not used for Cloudflare deployment)
|
│ │ ├─ 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
|
||||||
```
|
```
|
||||||
|
|
||||||
## Deployment (Cloudflare Workers)
|
### Import aliases
|
||||||
|
|
||||||
The site deploys to Cloudflare Workers, not Vercel. Critical deployment files:
|
* Prefer absolute imports using `@` mapped to `src` via `tsconfig.json` paths.
|
||||||
|
|
||||||
- **`wrangler.toml`**: Cloudflare Workers configuration with routes for biohazardvfx.com
|
## 4) Authoritative UI system
|
||||||
- **`open-next.config.ts`**: OpenNext adapter configuration for Cloudflare
|
|
||||||
- **`next.config.ts`**: Ignores lint/TypeScript errors during build (required for deployment)
|
|
||||||
|
|
||||||
**Deploy commands:**
|
* **Theme**: dark mode is the default. Do not introduce light-first designs without approval.
|
||||||
```bash
|
* **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.
|
||||||
npx opennextjs-cloudflare build # Build for Cloudflare
|
* **Components**: use shadcn/ui primitives. Extend with local wrappers placed in `src/components/ui/`.
|
||||||
npx wrangler deploy # Deploy to Cloudflare Workers
|
* **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=
|
||||||
```
|
```
|
||||||
|
|
||||||
**Live URLs:**
|
Do not commit real secrets. `Agents` must fail a task rather than hardcode a secret.
|
||||||
- Production: https://biohazardvfx.com
|
|
||||||
- Worker: https://biohazard-vfx-website.nicholaivogelfilms.workers.dev
|
|
||||||
|
|
||||||
**Cloudflare-specific requirements:**
|
## 10) Local development
|
||||||
- Requires `nodejs_compat` compatibility flag
|
|
||||||
- Compatibility date: `2024-09-23` or later
|
|
||||||
- Assets binding configured at `.open-next/assets`
|
|
||||||
|
|
||||||
## Architecture
|
```
|
||||||
|
# install
|
||||||
|
npm ci
|
||||||
|
|
||||||
### Path Aliases
|
# run dev server
|
||||||
- `@/*` maps to `./src/*` (configured in tsconfig.json)
|
npm run dev
|
||||||
- shadcn/ui aliases: `@/components`, `@/components/ui`, `@/lib/utils`
|
|
||||||
|
|
||||||
### App Structure (Next.js App Router)
|
# type checks and linting
|
||||||
|
npm run typecheck
|
||||||
|
npm run lint
|
||||||
|
|
||||||
Pages are in `src/app/`:
|
# build and preview
|
||||||
- `page.tsx` - Homepage with hero, featured projects, capabilities
|
npm run build
|
||||||
- `about/page.tsx` - Studio origins, values, testimonials
|
npm run start
|
||||||
- `services/page.tsx` - Service offerings with ServiceCard components
|
|
||||||
- `portfolio/page.tsx` - Masonry grid portfolio layout
|
|
||||||
- `contact/page.tsx` - Multi-step contact form
|
|
||||||
- `layout.tsx` - Root layout with Navigation and Footer (removed from individual pages)
|
|
||||||
|
|
||||||
**Important:** Navigation and Footer components are only in the root layout. Individual pages should NOT include them.
|
|
||||||
|
|
||||||
### Data Files
|
|
||||||
|
|
||||||
Content is separated from components in `src/data/`:
|
|
||||||
- `projects.ts` - Project data with Project interface (id, title, description, category, thumbnailUrl, videoUrl, aspectRatio, featured, tags)
|
|
||||||
- `services.ts` - Services data
|
|
||||||
|
|
||||||
When adding new projects or services, update these files rather than hardcoding data in components.
|
|
||||||
|
|
||||||
### Component Organization
|
|
||||||
|
|
||||||
Custom components in `src/components/`:
|
|
||||||
- **Layout:** Navigation, Footer
|
|
||||||
- **Page sections:** Hero, ContactSection, MissionSection, BrandingSection
|
|
||||||
- **Content display:** ProjectCard, ServiceCard, MasonryGrid, ProjectShowcase, VideoPlayer, VideoPreview
|
|
||||||
- **Interactive:** MultiStepForm (4-step client intake), HorizontalAccordion
|
|
||||||
- **Visual effects:** DepthMap, CursorDotBackground, ScrollProgressBar, SectionDivider
|
|
||||||
- **Third-party:** InstagramFeed, ClientLogoGrid
|
|
||||||
|
|
||||||
shadcn/ui components in `src/components/ui/`:
|
|
||||||
- Uses New York style variant
|
|
||||||
- Includes: accordion, button, card, dialog, hover-card, input, label, navigation-menu, progress, select, separator, textarea
|
|
||||||
|
|
||||||
### Styling System
|
|
||||||
|
|
||||||
**Fonts:** Nine Google Fonts preloaded in layout.tsx:
|
|
||||||
- Geist Sans (primary), Geist Mono
|
|
||||||
- Bebas Neue, Orbitron, Inter, JetBrains Mono, Space Mono, Rajdhani, Exo 2
|
|
||||||
- Available as CSS variables: `--font-geist-sans`, `--font-bebas`, etc.
|
|
||||||
|
|
||||||
**Theme:**
|
|
||||||
- Dark mode by default (`className="dark"` on html element)
|
|
||||||
- CSS variables in `src/app/globals.css`
|
|
||||||
- shadcn/ui config in `components.json` (New York style, neutral base color)
|
|
||||||
|
|
||||||
**Tailwind:**
|
|
||||||
- Tailwind CSS 4 with PostCSS
|
|
||||||
- Config: `tailwind.config.ts`
|
|
||||||
- Global styles: `src/app/globals.css`
|
|
||||||
|
|
||||||
### SEO & Metadata
|
|
||||||
|
|
||||||
All pages include Next.js Metadata API:
|
|
||||||
- Open Graph tags
|
|
||||||
- Twitter cards
|
|
||||||
- Canonical links
|
|
||||||
- JSON-LD structured data (Organization schema in root layout)
|
|
||||||
- metadataBase: `https://biohazardvfx.com`
|
|
||||||
|
|
||||||
### Forms & Validation
|
|
||||||
|
|
||||||
MultiStepForm component uses:
|
|
||||||
- react-hook-form for form state
|
|
||||||
- zod for validation (via @hookform/resolvers)
|
|
||||||
- 4 steps: Project Type → Budget/Timeline → Project Details → Contact Info
|
|
||||||
- Progress indicator using shadcn/ui Progress component
|
|
||||||
|
|
||||||
### Images
|
|
||||||
|
|
||||||
Next.js Image optimization configured in next.config.ts:
|
|
||||||
- Remote patterns allowed: `images.unsplash.com`
|
|
||||||
- `unoptimized: false` (optimization enabled)
|
|
||||||
|
|
||||||
## Key Constraints
|
|
||||||
|
|
||||||
1. **Cloudflare deployment:** Do not suggest Vercel-specific features that won't work with OpenNext
|
|
||||||
2. **Build config:** Lint and TypeScript errors are ignored during build (required for deployment). Do not remove these settings from next.config.ts
|
|
||||||
3. **Dark theme only:** Site uses dark mode by default. Do not create light mode variants unless explicitly requested
|
|
||||||
4. **Layout structure:** Navigation and Footer are in root layout only. Do not add them to individual pages
|
|
||||||
5. **Data separation:** Project and service data lives in `src/data/`. Keep content separate from components
|
|
||||||
|
|
||||||
## Common Tasks
|
|
||||||
|
|
||||||
**Add a new page:**
|
|
||||||
1. Create directory in `src/app/`
|
|
||||||
2. Add `page.tsx` with metadata export
|
|
||||||
3. Update Navigation component at `src/components/Navigation.tsx`
|
|
||||||
|
|
||||||
**Add a new shadcn/ui component:**
|
|
||||||
```bash
|
|
||||||
npx shadcn@latest add [component-name]
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**Update project portfolio:**
|
Notes
|
||||||
1. Edit `src/data/projects.ts`
|
|
||||||
2. Add new Project object to projects array
|
|
||||||
3. Ensure aspectRatio is set correctly for masonry layout
|
|
||||||
|
|
||||||
**Update services:**
|
* 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.
|
||||||
1. Edit `src/data/services.ts`
|
|
||||||
2. Add service object with required fields
|
## 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`.
|
||||||
|
|
||||||
**Modify theme colors:**
|
|
||||||
1. Edit CSS variables in `src/app/globals.css`
|
|
||||||
2. Theme uses neutral base color with CSS variables for theming
|
|
||||||
|
|||||||
@ -20,6 +20,20 @@ const nextConfig: NextConfig = {
|
|||||||
typescript: {
|
typescript: {
|
||||||
ignoreBuildErrors: true,
|
ignoreBuildErrors: true,
|
||||||
},
|
},
|
||||||
|
// Custom headers for video files
|
||||||
|
async headers() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
source: "/:path*.mp4",
|
||||||
|
headers: [
|
||||||
|
{
|
||||||
|
key: "Content-Type",
|
||||||
|
value: "video/mp4",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default nextConfig;
|
export default nextConfig;
|
||||||
|
|||||||
BIN
public/reel.mp4
Normal file
BIN
public/reel.mp4
Normal file
Binary file not shown.
258
src/components/ReelPlayer.tsx
Normal file
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -5,6 +5,7 @@ import { InstagramFeed } from "./InstagramFeed";
|
|||||||
import { ScrollProgressBar } from "./ScrollProgressBar";
|
import { ScrollProgressBar } from "./ScrollProgressBar";
|
||||||
import { SectionDivider } from "./SectionDivider";
|
import { SectionDivider } from "./SectionDivider";
|
||||||
import { VideoPreview } from "./VideoPreview";
|
import { VideoPreview } from "./VideoPreview";
|
||||||
|
import { ReelPlayer } from "./ReelPlayer";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { motion, AnimatePresence } from "framer-motion";
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
import { DepthMap } from "./DepthMap";
|
import { DepthMap } from "./DepthMap";
|
||||||
@ -332,33 +333,21 @@ export function TempPlaceholder() {
|
|||||||
{/* Work Section */}
|
{/* Work Section */}
|
||||||
<section id="work" className="mb-16 md:mb-20">
|
<section id="work" className="mb-16 md:mb-20">
|
||||||
<motion.p
|
<motion.p
|
||||||
className="mb-4 text-base sm:text-lg"
|
className="mb-6 text-base sm:text-lg"
|
||||||
variants={itemVariants}
|
variants={itemVariants}
|
||||||
transition={{ duration: 0.4, ease: "easeOut" }}
|
transition={{ duration: 0.4, ease: "easeOut" }}
|
||||||
>
|
>
|
||||||
<strong>> Here's our reel:</strong>{" "}
|
<strong>> Here's our reel:</strong>
|
||||||
<motion.a
|
|
||||||
href="https://f.io/Wgx3EAHu"
|
|
||||||
className="inline-block break-words relative"
|
|
||||||
style={{ color: '#ff4d00' }}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
whileTap={{ scale: 0.98 }}
|
|
||||||
transition={{ duration: 0.2 }}
|
|
||||||
>
|
|
||||||
<span className="relative inline-block">
|
|
||||||
Biohazard Reel Mar 2025 - Frame.io
|
|
||||||
<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>
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
variants={itemVariants}
|
||||||
|
transition={{ duration: 0.4, ease: "easeOut" }}
|
||||||
|
className="mb-8"
|
||||||
|
>
|
||||||
|
<ReelPlayer src="/reel.mp4" />
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
<SectionDivider />
|
<SectionDivider />
|
||||||
|
|
||||||
<motion.p
|
<motion.p
|
||||||
|
|||||||
@ -5,7 +5,7 @@ export function middleware(request: NextRequest) {
|
|||||||
const { pathname } = request.nextUrl;
|
const { pathname } = request.nextUrl;
|
||||||
|
|
||||||
// Allow only the home page and Next.js internal routes
|
// Allow only the home page and Next.js internal routes
|
||||||
if (pathname === '/' || pathname.startsWith('/_next') || pathname.startsWith('/favicon.') || pathname === '/OLIVER.jpeg' || pathname === '/OLIVER_depth.jpeg' || pathname === '/no_pigeons_zone.gif') {
|
if (pathname === '/' || pathname.startsWith('/_next') || pathname.startsWith('/favicon.') || pathname === '/OLIVER.jpeg' || pathname === '/OLIVER_depth.jpeg' || pathname === '/no_pigeons_zone.gif' || pathname === '/reel.mp4') {
|
||||||
return NextResponse.next();
|
return NextResponse.next();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user