2026-02-26T05-30-20_auto_memory/memories.db-wal
This commit is contained in:
parent
44b8f6cde0
commit
309d2a3ca0
Binary file not shown.
Binary file not shown.
24
oskvp/site/.gitignore
vendored
Normal file
24
oskvp/site/.gitignore
vendored
Normal file
@ -0,0 +1,24 @@
|
||||
# build output
|
||||
dist/
|
||||
# generated types
|
||||
.astro/
|
||||
|
||||
# dependencies
|
||||
node_modules/
|
||||
|
||||
# logs
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
|
||||
|
||||
# environment variables
|
||||
.env
|
||||
.env.production
|
||||
|
||||
# macOS-specific files
|
||||
.DS_Store
|
||||
|
||||
# jetbrains setting folder
|
||||
.idea/
|
||||
4
oskvp/site/.vscode/extensions.json
vendored
Normal file
4
oskvp/site/.vscode/extensions.json
vendored
Normal file
@ -0,0 +1,4 @@
|
||||
{
|
||||
"recommendations": ["astro-build.astro-vscode"],
|
||||
"unwantedRecommendations": []
|
||||
}
|
||||
11
oskvp/site/.vscode/launch.json
vendored
Normal file
11
oskvp/site/.vscode/launch.json
vendored
Normal file
@ -0,0 +1,11 @@
|
||||
{
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"command": "./node_modules/.bin/astro dev",
|
||||
"name": "Development server",
|
||||
"request": "launch",
|
||||
"type": "node-terminal"
|
||||
}
|
||||
]
|
||||
}
|
||||
89
oskvp/site/CLAUDE.md
Normal file
89
oskvp/site/CLAUDE.md
Normal file
@ -0,0 +1,89 @@
|
||||
# OSKVP Site
|
||||
|
||||
Astro portfolio site for Oliver Shore and Kevin Von Puttkammer — film director duo based in Amsterdam.
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
bun dev # local dev server
|
||||
bun run build # production build (always use this, not bun build)
|
||||
bun preview # build + wrangler pages dev at :8788
|
||||
bun deploy # build + deploy to Cloudflare Pages
|
||||
bun cf-typegen # regenerate wrangler types
|
||||
```
|
||||
|
||||
## Stack
|
||||
|
||||
- Astro 5 + Cloudflare Pages adapter
|
||||
- React 19 (client components)
|
||||
- Tailwind CSS 4 (via Vite plugin)
|
||||
- GSAP 3 for animations
|
||||
- TypeScript, strict mode
|
||||
|
||||
## Architecture
|
||||
|
||||
- **Rendering**: Static (prerendered). All pages generate to HTML at build time.
|
||||
- **Hydration**: `client:load` for above-fold (Nav, Hero). `client:visible` for below-fold (Grid, Footer, Marquee).
|
||||
- **Fonts**: Playfair Display (display/headings) + Inter (body) via Google Fonts CDN. No self-hosted fonts.
|
||||
|
||||
## Pages
|
||||
|
||||
| Route | File | Description |
|
||||
|-------|------|-------------|
|
||||
| `/` | `src/pages/index.astro` | Homepage: Hero + all projects with filter |
|
||||
| `/music-videos` | `src/pages/music-videos.astro` | Music videos + album trailers |
|
||||
| `/commercials` | `src/pages/commercials.astro` | Commercial work |
|
||||
| `/about` | `src/pages/about.astro` | About page |
|
||||
| `/contact` | `src/pages/contact.astro` | Contact page |
|
||||
| `/404` | `src/pages/404.astro` | Not found |
|
||||
|
||||
## Key Files
|
||||
|
||||
- `src/data/projects.ts` — All 23 project entries (title, category, vimeoId, thumbnail)
|
||||
- `src/components/ProjectGrid.tsx` — Video grid with filter + Vimeo modal
|
||||
- `src/components/Hero.tsx` — Full-screen hero with background video loop
|
||||
- `src/components/Navigation.tsx` — Fixed header + mobile overlay menu
|
||||
- `src/styles/global.css` — Global styles + Tailwind theme tokens
|
||||
- `src/consts.ts` — Site constants (title, email, social links)
|
||||
- `wrangler.jsonc` — Cloudflare Pages config
|
||||
|
||||
## Design System
|
||||
|
||||
Colors:
|
||||
- Background: `#0A0A0A` (dark), `#050505` (darker/footer)
|
||||
- Text: white, with `/40`, `/50`, `/60` opacity variants for hierarchy
|
||||
- Accent: `#F0EDE8` (warm off-white)
|
||||
|
||||
Fonts:
|
||||
- Display/headings: `font-display` = Playfair Display, Georgia fallback
|
||||
- Body: `font-body` = Inter, system-ui fallback
|
||||
|
||||
## Adding Projects
|
||||
|
||||
Edit `src/data/projects.ts`. Each project needs:
|
||||
- `title`: string
|
||||
- `category`: `'Music Video' | 'Commercial' | 'Album Trailer' | 'Edit'`
|
||||
- `vimeoId`: Vimeo video ID
|
||||
- `vimeoUrl`: full Vimeo URL (handles hash URLs like `vimeo.com/id/hash`)
|
||||
- `thumbnail`: vimeocdn.com thumbnail URL
|
||||
- `featured`: optional boolean (used for future featured section)
|
||||
|
||||
## Vimeo Embed
|
||||
|
||||
Videos with a hash (e.g. `vimeo.com/765609109/dd374ab013`) are embedded as:
|
||||
`https://player.vimeo.com/video/{id}?h={hash}&autoplay=1`
|
||||
|
||||
Standard videos: `https://player.vimeo.com/video/{id}?autoplay=1`
|
||||
|
||||
## Deployment
|
||||
|
||||
```bash
|
||||
# Set account ID
|
||||
export CLOUDFLARE_ACCOUNT_ID=<id>
|
||||
|
||||
# Deploy
|
||||
bun deploy
|
||||
```
|
||||
|
||||
Configure custom domain in Cloudflare Pages dashboard.
|
||||
Domain to use: update `site` in `astro.config.mjs` from `https://oskvp.com` to actual domain.
|
||||
43
oskvp/site/README.md
Normal file
43
oskvp/site/README.md
Normal file
@ -0,0 +1,43 @@
|
||||
# Astro Starter Kit: Minimal
|
||||
|
||||
```sh
|
||||
bun create astro@latest -- --template minimal
|
||||
```
|
||||
|
||||
> 🧑🚀 **Seasoned astronaut?** Delete this file. Have fun!
|
||||
|
||||
## 🚀 Project Structure
|
||||
|
||||
Inside of your Astro project, you'll see the following folders and files:
|
||||
|
||||
```text
|
||||
/
|
||||
├── public/
|
||||
├── src/
|
||||
│ └── pages/
|
||||
│ └── index.astro
|
||||
└── package.json
|
||||
```
|
||||
|
||||
Astro looks for `.astro` or `.md` files in the `src/pages/` directory. Each page is exposed as a route based on its file name.
|
||||
|
||||
There's nothing special about `src/components/`, but that's where we like to put any Astro/React/Vue/Svelte/Preact components.
|
||||
|
||||
Any static assets, like images, can be placed in the `public/` directory.
|
||||
|
||||
## 🧞 Commands
|
||||
|
||||
All commands are run from the root of the project, from a terminal:
|
||||
|
||||
| Command | Action |
|
||||
| :------------------------ | :----------------------------------------------- |
|
||||
| `bun install` | Installs dependencies |
|
||||
| `bun dev` | Starts local dev server at `localhost:4321` |
|
||||
| `bun build` | Build your production site to `./dist/` |
|
||||
| `bun preview` | Preview your build locally, before deploying |
|
||||
| `bun astro ...` | Run CLI commands like `astro add`, `astro check` |
|
||||
| `bun astro -- --help` | Get help using the Astro CLI |
|
||||
|
||||
## 👀 Want to learn more?
|
||||
|
||||
Feel free to check [our documentation](https://docs.astro.build) or jump into our [Discord server](https://astro.build/chat).
|
||||
23
oskvp/site/astro.config.mjs
Normal file
23
oskvp/site/astro.config.mjs
Normal file
@ -0,0 +1,23 @@
|
||||
// @ts-check
|
||||
import sitemap from '@astrojs/sitemap';
|
||||
import { defineConfig } from 'astro/config';
|
||||
import cloudflare from '@astrojs/cloudflare';
|
||||
import tailwindcss from '@tailwindcss/vite';
|
||||
import react from '@astrojs/react';
|
||||
|
||||
export default defineConfig({
|
||||
site: 'https://oskvp.com',
|
||||
integrations: [
|
||||
sitemap({
|
||||
filter: (page) => !page.includes('/404'),
|
||||
}),
|
||||
react(),
|
||||
],
|
||||
adapter: cloudflare({
|
||||
platformProxy: { enabled: true },
|
||||
imageService: 'compile',
|
||||
}),
|
||||
vite: {
|
||||
plugins: [tailwindcss()],
|
||||
},
|
||||
});
|
||||
1186
oskvp/site/bun.lock
Normal file
1186
oskvp/site/bun.lock
Normal file
File diff suppressed because it is too large
Load Diff
33
oskvp/site/package.json
Normal file
33
oskvp/site/package.json
Normal file
@ -0,0 +1,33 @@
|
||||
{
|
||||
"name": "site",
|
||||
"type": "module",
|
||||
"version": "0.0.1",
|
||||
"scripts": {
|
||||
"dev": "astro dev",
|
||||
"build": "astro build",
|
||||
"preview": "astro build && wrangler pages dev",
|
||||
"astro": "astro",
|
||||
"deploy": "astro build && wrangler pages deploy",
|
||||
"cf-typegen": "wrangler types"
|
||||
},
|
||||
"dependencies": {
|
||||
"@astrojs/cloudflare": "^12.6.12",
|
||||
"@astrojs/react": "^4.4.2",
|
||||
"@astrojs/sitemap": "^3.7.0",
|
||||
"@tailwindcss/vite": "^4.2.1",
|
||||
"astro": "^5.17.1",
|
||||
"clsx": "^2.1.1",
|
||||
"gsap": "^3.14.2",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"react-icons": "^5.5.0",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tailwindcss": "^4.2.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^25.3.1",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"wrangler": "^4.68.1"
|
||||
}
|
||||
}
|
||||
BIN
oskvp/site/public/favicon.ico
Normal file
BIN
oskvp/site/public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 655 B |
7
oskvp/site/public/favicon.svg
Normal file
7
oskvp/site/public/favicon.svg
Normal file
@ -0,0 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
|
||||
<rect width="100" height="100" fill="#0A0A0A"/>
|
||||
<style>
|
||||
text { fill: #FFFFFF; }
|
||||
</style>
|
||||
<text x="50" y="72" font-family="Georgia, serif" font-size="52" font-weight="600" text-anchor="middle">O</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 283 B |
4
oskvp/site/public/robots.txt
Normal file
4
oskvp/site/public/robots.txt
Normal file
@ -0,0 +1,4 @@
|
||||
User-agent: *
|
||||
Allow: /
|
||||
|
||||
Sitemap: https://oskvp.com/sitemap-index.xml
|
||||
56
oskvp/site/src/components/BaseHead.astro
Normal file
56
oskvp/site/src/components/BaseHead.astro
Normal file
@ -0,0 +1,56 @@
|
||||
---
|
||||
import { SITE_TITLE, SITE_DESCRIPTION } from '@/consts';
|
||||
import '@/styles/global.css';
|
||||
|
||||
interface Props {
|
||||
title?: string;
|
||||
description?: string;
|
||||
image?: string;
|
||||
robots?: string;
|
||||
}
|
||||
|
||||
const {
|
||||
title,
|
||||
description = SITE_DESCRIPTION,
|
||||
image = '/og-default.jpg',
|
||||
robots = 'index, follow',
|
||||
} = Astro.props;
|
||||
|
||||
const pageTitle = title ? `${title} — OSKVP` : SITE_TITLE;
|
||||
const canonicalURL = new URL(Astro.url.pathname, Astro.site);
|
||||
---
|
||||
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="robots" content={robots} />
|
||||
<link rel="canonical" href={canonicalURL} />
|
||||
|
||||
<title>{pageTitle}</title>
|
||||
<meta name="description" content={description} />
|
||||
|
||||
<!-- Open Graph -->
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:url" content={canonicalURL} />
|
||||
<meta property="og:title" content={pageTitle} />
|
||||
<meta property="og:description" content={description} />
|
||||
<meta property="og:image" content={new URL(image, Astro.site)} />
|
||||
|
||||
<!-- Twitter -->
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:title" content={pageTitle} />
|
||||
<meta name="twitter:description" content={description} />
|
||||
<meta name="twitter:image" content={new URL(image, Astro.site)} />
|
||||
|
||||
<!-- Favicon -->
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
|
||||
<!-- Fonts -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Playfair+Display:ital,wght@0,400;0,500;0,600;0,700;1,400;1,500&family=Inter:wght@300;400;500&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
|
||||
<!-- Sitemap -->
|
||||
<link rel="sitemap" href="/sitemap-index.xml" />
|
||||
61
oskvp/site/src/components/Footer.tsx
Normal file
61
oskvp/site/src/components/Footer.tsx
Normal file
@ -0,0 +1,61 @@
|
||||
import { CONTACT_EMAIL } from '@/consts';
|
||||
|
||||
const YEAR = new Date().getFullYear();
|
||||
|
||||
export default function Footer() {
|
||||
return (
|
||||
<footer className="bg-[#050505] border-t border-white/5 px-6 md:px-10 py-12 mt-20">
|
||||
<div className="max-w-7xl mx-auto grid grid-cols-1 md:grid-cols-3 gap-8">
|
||||
<div>
|
||||
<p className="font-display text-2xl font-medium text-white mb-2">OSKV</p>
|
||||
<p className="text-white/40 text-sm font-light">
|
||||
Oliver Shore & Kevin Von Puttkammer
|
||||
</p>
|
||||
<p className="text-white/30 text-sm font-light mt-1">Amsterdam</p>
|
||||
</div>
|
||||
|
||||
<nav className="flex flex-col gap-3">
|
||||
<p className="text-white/20 text-xs tracking-widest uppercase mb-1">Work</p>
|
||||
{[
|
||||
{ href: '/', label: 'All Projects' },
|
||||
{ href: '/music-videos', label: 'Music Videos' },
|
||||
{ href: '/commercials', label: 'Commercials' },
|
||||
].map((link) => (
|
||||
<a
|
||||
key={link.href}
|
||||
href={link.href}
|
||||
className="text-white/50 text-sm font-light hover:text-white transition-colors"
|
||||
>
|
||||
{link.label}
|
||||
</a>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<p className="text-white/20 text-xs tracking-widest uppercase mb-1">Contact</p>
|
||||
<a
|
||||
href="/about"
|
||||
className="text-white/50 text-sm font-light hover:text-white transition-colors"
|
||||
>
|
||||
About
|
||||
</a>
|
||||
<a
|
||||
href={`mailto:${CONTACT_EMAIL}`}
|
||||
className="text-white/50 text-sm font-light hover:text-white transition-colors"
|
||||
>
|
||||
{CONTACT_EMAIL}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="max-w-7xl mx-auto mt-12 pt-6 border-t border-white/5 flex items-center justify-between">
|
||||
<p className="text-white/20 text-xs font-light">
|
||||
© {YEAR} OSKVP. All rights reserved.
|
||||
</p>
|
||||
<p className="text-white/10 text-xs font-light">
|
||||
Oliver Shore & Kevin Von Puttkammer
|
||||
</p>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
120
oskvp/site/src/components/Hero.tsx
Normal file
120
oskvp/site/src/components/Hero.tsx
Normal file
@ -0,0 +1,120 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import gsap from 'gsap';
|
||||
|
||||
// The featured background video from Webflow CDN
|
||||
const HERO_VIDEO_MP4 = 'https://cdn.prod.website-files.com/667f00b0126821a830d43ae0%2F698b8c26506f63ecf14573a4_OSKV_2026_305_mp4.mp4';
|
||||
const HERO_POSTER = 'https://cdn.prod.website-files.com/667f00b0126821a830d43ae0%2F698b8c26506f63ecf14573a4_OSKV_2026_305_poster.0000000.jpg';
|
||||
|
||||
export default function Hero() {
|
||||
const containerRef = useRef<HTMLElement>(null);
|
||||
const headingRef = useRef<HTMLHeadingElement>(null);
|
||||
const taglineRef = useRef<HTMLParagraphElement>(null);
|
||||
const ctaRef = useRef<HTMLDivElement>(null);
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
|
||||
|
||||
const alreadySeen = sessionStorage.getItem('oskvp-hero-seen');
|
||||
|
||||
const ctx = gsap.context(() => {
|
||||
if (!alreadySeen) {
|
||||
const tl = gsap.timeline({
|
||||
onComplete: () => sessionStorage.setItem('oskvp-hero-seen', '1'),
|
||||
});
|
||||
|
||||
tl.from(headingRef.current, {
|
||||
y: 60,
|
||||
opacity: 0,
|
||||
duration: 1.2,
|
||||
ease: 'power3.out',
|
||||
delay: 0.3,
|
||||
})
|
||||
.from(taglineRef.current, {
|
||||
y: 30,
|
||||
opacity: 0,
|
||||
duration: 0.8,
|
||||
ease: 'power2.out',
|
||||
}, '-=0.6')
|
||||
.from(ctaRef.current, {
|
||||
y: 20,
|
||||
opacity: 0,
|
||||
duration: 0.6,
|
||||
ease: 'power2.out',
|
||||
}, '-=0.4');
|
||||
}
|
||||
|
||||
// Parallax on video
|
||||
if (videoRef.current) {
|
||||
gsap.to(videoRef.current, {
|
||||
yPercent: 15,
|
||||
ease: 'none',
|
||||
scrollTrigger: {
|
||||
trigger: containerRef.current,
|
||||
start: 'top top',
|
||||
end: 'bottom top',
|
||||
scrub: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
}, containerRef);
|
||||
|
||||
return () => ctx.revert();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<section
|
||||
ref={containerRef}
|
||||
className="relative h-screen-safe w-full flex flex-col items-center justify-end pb-16 md:pb-20 overflow-hidden"
|
||||
>
|
||||
{/* Background video */}
|
||||
<div className="absolute inset-0">
|
||||
<video
|
||||
ref={videoRef}
|
||||
autoPlay
|
||||
muted
|
||||
loop
|
||||
playsInline
|
||||
poster={HERO_POSTER}
|
||||
className="absolute inset-0 w-full h-full object-cover"
|
||||
style={{ transform: 'scale(1.1)' }}
|
||||
>
|
||||
<source src={HERO_VIDEO_MP4} type="video/mp4" />
|
||||
</video>
|
||||
{/* Gradient overlays */}
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/90 via-black/30 to-black/20" />
|
||||
<div className="absolute inset-0 bg-black/20" />
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="relative z-10 text-center px-6 max-w-5xl mx-auto w-full">
|
||||
<h1
|
||||
ref={headingRef}
|
||||
className="font-display text-[10vw] md:text-[7vw] lg:text-[6rem] font-medium leading-none tracking-tight text-white mb-4"
|
||||
>
|
||||
OSKV
|
||||
</h1>
|
||||
<p
|
||||
ref={taglineRef}
|
||||
className="text-white/50 text-sm md:text-base font-light tracking-[0.3em] uppercase mb-8"
|
||||
>
|
||||
director duo
|
||||
</p>
|
||||
<div ref={ctaRef} className="flex gap-4 justify-center">
|
||||
<a
|
||||
href="#work"
|
||||
className="inline-block px-8 py-3 border border-white/30 text-white/80 text-sm tracking-widest uppercase font-light hover:bg-white hover:text-black transition-all duration-300"
|
||||
>
|
||||
View Work
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Scroll indicator */}
|
||||
<div className="absolute bottom-8 left-1/2 -translate-x-1/2 flex flex-col items-center gap-2 opacity-40">
|
||||
<span className="text-xs tracking-widest uppercase text-white font-light">Scroll</span>
|
||||
<div className="w-px h-8 bg-white/40 animate-pulse" />
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
30
oskvp/site/src/components/Marquee.tsx
Normal file
30
oskvp/site/src/components/Marquee.tsx
Normal file
@ -0,0 +1,30 @@
|
||||
const ITEMS = [
|
||||
'Music Videos',
|
||||
'Commercials',
|
||||
'Editorial',
|
||||
'Amsterdam',
|
||||
'Director Duo',
|
||||
'Film Direction',
|
||||
'Fashion',
|
||||
'Hip-Hop',
|
||||
];
|
||||
|
||||
export default function Marquee() {
|
||||
const doubled = [...ITEMS, ...ITEMS];
|
||||
|
||||
return (
|
||||
<div className="overflow-hidden border-t border-b border-white/10 py-4 my-16">
|
||||
<div className="flex marquee-track gap-12 whitespace-nowrap">
|
||||
{doubled.map((item, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className="text-xs tracking-[0.4em] uppercase text-white/20 font-light"
|
||||
>
|
||||
{item}
|
||||
<span className="ml-12 text-white/10">·</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
140
oskvp/site/src/components/Navigation.tsx
Normal file
140
oskvp/site/src/components/Navigation.tsx
Normal file
@ -0,0 +1,140 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import gsap from 'gsap';
|
||||
|
||||
const NAV_LINKS = [
|
||||
{ href: '/', label: 'All Work' },
|
||||
{ href: '/music-videos', label: 'Music Videos' },
|
||||
{ href: '/commercials', label: 'Commercials' },
|
||||
{ href: '/about', label: 'About' },
|
||||
{ href: '/contact', label: 'Contact' },
|
||||
];
|
||||
|
||||
interface Props {
|
||||
currentPath?: string;
|
||||
}
|
||||
|
||||
export default function Navigation({ currentPath = '/' }: Props) {
|
||||
const [isScrolled, setIsScrolled] = useState(false);
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const linksRef = useRef<HTMLAnchorElement[]>([]);
|
||||
const overlayRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const onScroll = () => setIsScrolled(window.scrollY > 40);
|
||||
window.addEventListener('scroll', onScroll, { passive: true });
|
||||
return () => window.removeEventListener('scroll', onScroll);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape' && menuOpen) closeMenu();
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [menuOpen]);
|
||||
|
||||
function openMenu() {
|
||||
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
|
||||
setMenuOpen(true);
|
||||
document.body.style.overflow = 'hidden';
|
||||
return;
|
||||
}
|
||||
|
||||
setMenuOpen(true);
|
||||
document.body.style.overflow = 'hidden';
|
||||
|
||||
const ctx = gsap.context(() => {
|
||||
gsap.fromTo(overlayRef.current,
|
||||
{ opacity: 0 },
|
||||
{ opacity: 1, duration: 0.3, ease: 'power2.out' }
|
||||
);
|
||||
gsap.fromTo(linksRef.current,
|
||||
{ y: 30, opacity: 0 },
|
||||
{ y: 0, opacity: 1, duration: 0.5, ease: 'power2.out', stagger: 0.07, delay: 0.1 }
|
||||
);
|
||||
});
|
||||
|
||||
return () => ctx.revert();
|
||||
}
|
||||
|
||||
function closeMenu() {
|
||||
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
|
||||
setMenuOpen(false);
|
||||
document.body.style.overflow = '';
|
||||
return;
|
||||
}
|
||||
|
||||
gsap.to(overlayRef.current, {
|
||||
opacity: 0,
|
||||
duration: 0.25,
|
||||
ease: 'power2.in',
|
||||
onComplete: () => {
|
||||
setMenuOpen(false);
|
||||
document.body.style.overflow = '';
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<header
|
||||
className={`fixed top-0 left-0 right-0 z-50 flex items-center justify-between px-6 md:px-10 transition-all duration-300 ${
|
||||
isScrolled ? 'bg-black/80 backdrop-blur-md py-4' : 'py-6'
|
||||
}`}
|
||||
style={{ height: 'var(--nav-height)' }}
|
||||
>
|
||||
<a href="/" className="text-white font-display text-xl font-semibold tracking-wide hover:opacity-70 transition-opacity">
|
||||
OSKV
|
||||
</a>
|
||||
|
||||
<nav className="hidden md:flex items-center gap-8">
|
||||
{NAV_LINKS.map((link) => (
|
||||
<a
|
||||
key={link.href}
|
||||
href={link.href}
|
||||
className={`text-sm font-light tracking-widest uppercase transition-opacity duration-200 ${
|
||||
currentPath === link.href
|
||||
? 'text-white opacity-100'
|
||||
: 'text-white/60 hover:text-white hover:opacity-100'
|
||||
}`}
|
||||
>
|
||||
{link.label}
|
||||
</a>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<button
|
||||
onClick={menuOpen ? closeMenu : openMenu}
|
||||
className="md:hidden flex flex-col gap-1.5 p-2 -mr-2 group"
|
||||
aria-label={menuOpen ? 'Close menu' : 'Open menu'}
|
||||
>
|
||||
<span className={`block w-6 h-px bg-white transition-all duration-300 ${menuOpen ? 'rotate-45 translate-y-[7px]' : ''}`} />
|
||||
<span className={`block w-6 h-px bg-white transition-all duration-300 ${menuOpen ? 'opacity-0' : ''}`} />
|
||||
<span className={`block w-6 h-px bg-white transition-all duration-300 ${menuOpen ? '-rotate-45 -translate-y-[7px]' : ''}`} />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
{/* Mobile fullscreen menu */}
|
||||
<div
|
||||
ref={overlayRef}
|
||||
className={`fixed inset-0 z-40 bg-black flex flex-col justify-center px-8 md:hidden ${menuOpen ? 'flex' : 'hidden'}`}
|
||||
>
|
||||
<nav className="flex flex-col gap-6">
|
||||
{NAV_LINKS.map((link, i) => (
|
||||
<a
|
||||
key={link.href}
|
||||
href={link.href}
|
||||
ref={(el) => { if (el) linksRef.current[i] = el; }}
|
||||
onClick={closeMenu}
|
||||
className={`font-display text-4xl font-medium tracking-tight transition-opacity ${
|
||||
currentPath === link.href ? 'text-white' : 'text-white/50 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
{link.label}
|
||||
</a>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
180
oskvp/site/src/components/ProjectGrid.tsx
Normal file
180
oskvp/site/src/components/ProjectGrid.tsx
Normal file
@ -0,0 +1,180 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import gsap from 'gsap';
|
||||
import { ScrollTrigger } from 'gsap/ScrollTrigger';
|
||||
import type { Project } from '@/data/projects';
|
||||
|
||||
gsap.registerPlugin(ScrollTrigger);
|
||||
|
||||
interface Props {
|
||||
projects: Project[];
|
||||
showFilter?: boolean;
|
||||
}
|
||||
|
||||
function getVimeoEmbedUrl(project: Project): string {
|
||||
// Handle URLs with hash (e.g. vimeo.com/id/hash)
|
||||
const url = project.vimeoUrl;
|
||||
const match = url.match(/vimeo\.com\/(\d+)\/([a-f0-9]+)/);
|
||||
if (match) {
|
||||
return `https://player.vimeo.com/video/${match[1]}?h=${match[2]}&autoplay=1&title=0&byline=0&portrait=0`;
|
||||
}
|
||||
return `https://player.vimeo.com/video/${project.vimeoId}?autoplay=1&title=0&byline=0&portrait=0`;
|
||||
}
|
||||
|
||||
const ALL_CATEGORIES = ['All', 'Music Video', 'Commercial', 'Album Trailer'] as const;
|
||||
|
||||
export default function ProjectGrid({ projects, showFilter = false }: Props) {
|
||||
const [activeCategory, setActiveCategory] = useState<string>('All');
|
||||
const [activeProject, setActiveProject] = useState<Project | null>(null);
|
||||
const gridRef = useRef<HTMLDivElement>(null);
|
||||
const cardsRef = useRef<HTMLDivElement[]>([]);
|
||||
const modalRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const filtered = activeCategory === 'All'
|
||||
? projects
|
||||
: projects.filter((p) => p.category === activeCategory);
|
||||
|
||||
useEffect(() => {
|
||||
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
|
||||
|
||||
const ctx = gsap.context(() => {
|
||||
cardsRef.current.forEach((card) => {
|
||||
if (!card) return;
|
||||
gsap.fromTo(card,
|
||||
{ y: 40, opacity: 0 },
|
||||
{
|
||||
y: 0,
|
||||
opacity: 1,
|
||||
duration: 0.6,
|
||||
ease: 'power2.out',
|
||||
scrollTrigger: {
|
||||
trigger: card,
|
||||
start: 'top 90%',
|
||||
toggleActions: 'play none none none',
|
||||
},
|
||||
}
|
||||
);
|
||||
});
|
||||
}, gridRef);
|
||||
|
||||
return () => ctx.revert();
|
||||
}, [filtered.length]);
|
||||
|
||||
const openModal = useCallback((project: Project) => {
|
||||
setActiveProject(project);
|
||||
document.body.style.overflow = 'hidden';
|
||||
}, []);
|
||||
|
||||
const closeModal = useCallback(() => {
|
||||
setActiveProject(null);
|
||||
document.body.style.overflow = '';
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') closeModal();
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [closeModal]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<section id="work" className="px-4 md:px-8 lg:px-12 py-12 md:py-20">
|
||||
{showFilter && (
|
||||
<div className="flex gap-6 mb-12 overflow-x-auto scrollbar-none pb-2">
|
||||
{ALL_CATEGORIES.map((cat) => (
|
||||
<button
|
||||
key={cat}
|
||||
onClick={() => setActiveCategory(cat)}
|
||||
className={`text-xs tracking-widest uppercase font-light whitespace-nowrap transition-all duration-200 pb-1 border-b ${
|
||||
activeCategory === cat
|
||||
? 'text-white border-white'
|
||||
: 'text-white/40 border-transparent hover:text-white/70'
|
||||
}`}
|
||||
>
|
||||
{cat}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
ref={gridRef}
|
||||
className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-1 md:gap-2"
|
||||
>
|
||||
{filtered.map((project, i) => (
|
||||
<div
|
||||
key={`${project.vimeoId}-${i}`}
|
||||
ref={(el) => { if (el) cardsRef.current[i] = el; }}
|
||||
className="group relative aspect-video bg-[#141414] overflow-hidden cursor-pointer"
|
||||
onClick={() => openModal(project)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => e.key === 'Enter' && openModal(project)}
|
||||
aria-label={`Play ${project.title}`}
|
||||
>
|
||||
<img
|
||||
src={project.thumbnail}
|
||||
alt={project.title}
|
||||
className="absolute inset-0 w-full h-full object-cover transition-transform duration-700 group-hover:scale-105"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
{/* Hover overlay */}
|
||||
<div className="project-card-overlay absolute inset-0 opacity-0 group-hover:opacity-100 transition-opacity duration-300" />
|
||||
{/* Project info */}
|
||||
<div className="absolute bottom-0 left-0 right-0 p-4 translate-y-2 opacity-0 group-hover:translate-y-0 group-hover:opacity-100 transition-all duration-300">
|
||||
<h3 className="font-display text-sm font-medium text-white leading-tight">
|
||||
{project.title}
|
||||
</h3>
|
||||
<p className="text-white/50 text-xs mt-1 tracking-wider uppercase font-light">
|
||||
{project.category}
|
||||
</p>
|
||||
</div>
|
||||
{/* Play icon */}
|
||||
<div className="absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity duration-300">
|
||||
<div className="w-14 h-14 rounded-full border border-white/60 flex items-center justify-center backdrop-blur-sm bg-black/20">
|
||||
<svg className="w-5 h-5 text-white ml-1" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M8 5v14l11-7z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Video Modal */}
|
||||
{activeProject && (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center p-4 modal-backdrop bg-black/90"
|
||||
onClick={(e) => e.target === e.currentTarget && closeModal()}
|
||||
ref={modalRef}
|
||||
>
|
||||
<div className="relative w-full max-w-5xl">
|
||||
<button
|
||||
onClick={closeModal}
|
||||
className="absolute -top-10 right-0 text-white/60 hover:text-white text-sm tracking-widest uppercase font-light transition-colors"
|
||||
aria-label="Close video"
|
||||
>
|
||||
Close ✕
|
||||
</button>
|
||||
<div className="relative aspect-video bg-black">
|
||||
<iframe
|
||||
src={getVimeoEmbedUrl(activeProject)}
|
||||
className="absolute inset-0 w-full h-full"
|
||||
allow="autoplay; fullscreen; picture-in-picture"
|
||||
allowFullScreen
|
||||
title={activeProject.title}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-4 flex items-baseline gap-3">
|
||||
<h2 className="font-display text-lg text-white">{activeProject.title}</h2>
|
||||
<span className="text-white/40 text-xs tracking-wider uppercase">{activeProject.category}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
8
oskvp/site/src/consts.ts
Normal file
8
oskvp/site/src/consts.ts
Normal file
@ -0,0 +1,8 @@
|
||||
export const SITE_TITLE = 'OSKVP — Director Duo';
|
||||
export const SITE_DESCRIPTION = 'Oliver Shore and Kevin Von Puttkammer. Experienced high-end film directors based in Amsterdam. Music videos, commercials, and editorial.';
|
||||
export const CONTACT_EMAIL = 'info@oskvp.com';
|
||||
|
||||
export const SOCIAL_LINKS = {
|
||||
email: 'info@oskvp.com',
|
||||
vimeo: 'https://vimeo.com/oskvp',
|
||||
};
|
||||
183
oskvp/site/src/data/projects.ts
Normal file
183
oskvp/site/src/data/projects.ts
Normal file
@ -0,0 +1,183 @@
|
||||
export type Category = 'Music Video' | 'Commercial' | 'Album Trailer' | 'Edit';
|
||||
|
||||
export interface Project {
|
||||
title: string;
|
||||
category: Category;
|
||||
vimeoId: string;
|
||||
vimeoUrl: string;
|
||||
thumbnail: string;
|
||||
featured?: boolean;
|
||||
}
|
||||
|
||||
export const PROJECTS: Project[] = [
|
||||
{
|
||||
title: "Scorpio | Jhayco",
|
||||
category: "Music Video",
|
||||
vimeoId: "1130333871",
|
||||
vimeoUrl: "https://vimeo.com/1130333871",
|
||||
thumbnail: "https://i.vimeocdn.com/video/2073983170-ccb471186d7638fd45df1030d87eb12b62948660a378fc38ae8c6cbed3635572-d_1280",
|
||||
featured: true,
|
||||
},
|
||||
{
|
||||
title: "305 | Jordan Adetunji Ft. Bryson Tiller",
|
||||
category: "Music Video",
|
||||
vimeoId: "1049887672",
|
||||
vimeoUrl: "https://vimeo.com/1049887672",
|
||||
thumbnail: "https://i.vimeocdn.com/video/1974334929-1e6873c89b312d2bee03dcba419c4bd3f84b3511ce7f6cb31ff02ee354ed77f6-d_1280",
|
||||
featured: true,
|
||||
},
|
||||
{
|
||||
title: "Bitter | Jordan Adetunji",
|
||||
category: "Music Video",
|
||||
vimeoId: "1158676579",
|
||||
vimeoUrl: "https://vimeo.com/1158676579",
|
||||
thumbnail: "https://i.vimeocdn.com/video/2112801969-5f16db1b155d73b491f14fd3f8e242f0626dacb23cacdb8677315427208742c8-d_1280",
|
||||
featured: true,
|
||||
},
|
||||
{
|
||||
title: "Lick Back | Coi Leray Ft. Skrilla",
|
||||
category: "Music Video",
|
||||
vimeoId: "1158695481",
|
||||
vimeoUrl: "https://vimeo.com/1158695481",
|
||||
thumbnail: "https://i.vimeocdn.com/video/2112823993-da5bdeb4117df90f8330505fe4e4fd4e96099a659912f6691dc1644303c83cf9-d_1280",
|
||||
},
|
||||
{
|
||||
title: "Dirty Diana | Jordan Adetunji",
|
||||
category: "Music Video",
|
||||
vimeoId: "1058365935",
|
||||
vimeoUrl: "https://vimeo.com/1058365935",
|
||||
thumbnail: "https://i.vimeocdn.com/video/1985125364-b89de57f87906328ed09e59129c3dcfdbb06567c136ffcdf76ecbadc4df9c954-d_1280",
|
||||
},
|
||||
{
|
||||
title: "STar | 2Hollis",
|
||||
category: "Album Trailer",
|
||||
vimeoId: "1082093114",
|
||||
vimeoUrl: "https://vimeo.com/1082093114",
|
||||
thumbnail: "https://i.vimeocdn.com/video/2013012741-777fabf067c9a8c4850a38f2ec69ff7c6f2b35dad3d25de19ba567a893e78c73-d_1280",
|
||||
},
|
||||
{
|
||||
title: "Fever | Wesghost",
|
||||
category: "Music Video",
|
||||
vimeoId: "1082090093",
|
||||
vimeoUrl: "https://vimeo.com/1082090093",
|
||||
thumbnail: "https://i.vimeocdn.com/video/2013009701-fd7fb6ef3a45830990b50e1541fc00a49f7ae2a3210fd839d8343ec632833759-d_1280",
|
||||
},
|
||||
{
|
||||
title: "President | Southside x Ken Carson x Destroy Lonely",
|
||||
category: "Music Video",
|
||||
vimeoId: "939688270",
|
||||
vimeoUrl: "https://vimeo.com/939688270",
|
||||
thumbnail: "https://i.vimeocdn.com/video/1841702367-f64e493ce57921cb68e3ec6eb52a919293f1bc76b6f819f79a9ed945db08e403-d_1280",
|
||||
},
|
||||
{
|
||||
title: "Molli | SKaiwater",
|
||||
category: "Music Video",
|
||||
vimeoId: "856985102",
|
||||
vimeoUrl: "https://vimeo.com/856985102",
|
||||
thumbnail: "https://i.vimeocdn.com/video/1713938050-c8d0c7f806592d6820174d2d8633721e7619b5d3e16ca9f832e2dc81e3da74d2-d_1280",
|
||||
},
|
||||
{
|
||||
title: "Secreto Victoria | Fuerza Regida",
|
||||
category: "Music Video",
|
||||
vimeoId: "999713731",
|
||||
vimeoUrl: "https://vimeo.com/999713731",
|
||||
thumbnail: "https://i.vimeocdn.com/video/1916215811-7928d24ddb960a279c50747a70dfb3acaeabcdd203206e1fa24222b272d3eb41-d_1280",
|
||||
},
|
||||
{
|
||||
title: "The End | Ken Carson",
|
||||
category: "Music Video",
|
||||
vimeoId: "915350617",
|
||||
vimeoUrl: "https://vimeo.com/915350617",
|
||||
thumbnail: "https://i.vimeocdn.com/video/1802379668-eccf4a09d75336d43ee402c5a423eb3f9d3de1c7b189020b3de4bdbbdc2b235e-d_1280",
|
||||
},
|
||||
{
|
||||
title: "MDMA | Ken Carson Ft. Destroy Lonely",
|
||||
category: "Music Video",
|
||||
vimeoId: "765609109",
|
||||
vimeoUrl: "https://vimeo.com/765609109/dd374ab013",
|
||||
thumbnail: "https://i.vimeocdn.com/video/1537459008-9ecf590de22e274f55b0a3820a640232ef412ab7ba69cf036156d64a11b74ac2-d_1280",
|
||||
},
|
||||
{
|
||||
title: "GETOVERHER | Zer0 Chance",
|
||||
category: "Music Video",
|
||||
vimeoId: "517920923",
|
||||
vimeoUrl: "https://vimeo.com/517920923",
|
||||
thumbnail: "https://i.vimeocdn.com/video/1072651509-0a3a0b84e72a777b10a846fc82903e79d3a9c4cfcc7c899a6b4bfdb5d7851248-d_1280",
|
||||
},
|
||||
{
|
||||
title: "go | Ken Carson",
|
||||
category: "Music Video",
|
||||
vimeoId: "729306830",
|
||||
vimeoUrl: "https://vimeo.com/729306830",
|
||||
thumbnail: "https://i.vimeocdn.com/video/1467937467-51b581a6f82de0f058ed1aaba1258032bacbd4529b0cc908d8549f00e5e68842-d_1280",
|
||||
},
|
||||
{
|
||||
title: "Too Late | Josh Nichols",
|
||||
category: "Music Video",
|
||||
vimeoId: "637291770",
|
||||
vimeoUrl: "https://vimeo.com/637291770",
|
||||
thumbnail: "https://i.vimeocdn.com/video/1280201798-e540b6c52552de37ae83ef802925779a683efd631881d474a_1280",
|
||||
},
|
||||
{
|
||||
title: "Ralph Lauren Fragrances",
|
||||
category: "Commercial",
|
||||
vimeoId: "1158696298",
|
||||
vimeoUrl: "https://vimeo.com/1158696298",
|
||||
thumbnail: "https://i.vimeocdn.com/video/2112823018-b15ef1e2911ef9d40c1840b3996205a574a9ecc8d9449c728cc0518db587291a-d_1280",
|
||||
featured: true,
|
||||
},
|
||||
{
|
||||
title: "Balmain Kids FW25",
|
||||
category: "Commercial",
|
||||
vimeoId: "1130339213",
|
||||
vimeoUrl: "https://vimeo.com/1130339213",
|
||||
thumbnail: "https://i.vimeocdn.com/video/2073989475-bb8d9605e87a5bca6d79519f7908e2a396e98c62f3c33fcab9b29c40a9aade59-d_1280",
|
||||
featured: true,
|
||||
},
|
||||
{
|
||||
title: "Alex Moss Soho Flagship Store",
|
||||
category: "Commercial",
|
||||
vimeoId: "1130337633",
|
||||
vimeoUrl: "https://vimeo.com/1130337633",
|
||||
thumbnail: "https://i.vimeocdn.com/video/2073987600-5a6534fdcfea7dea1f7346771ee23401cd66848f68dea107f2e9a3574da70a69-d_1280",
|
||||
},
|
||||
{
|
||||
title: "Mind Games Fragrance",
|
||||
category: "Commercial",
|
||||
vimeoId: "939688270",
|
||||
vimeoUrl: "https://vimeo.com/939688270",
|
||||
thumbnail: "https://i.vimeocdn.com/video/1841702367-f64e493ce57921cb68e3ec6eb52a919293f1bc76b6f819f79a9ed945db08e403-d_1280",
|
||||
},
|
||||
{
|
||||
title: "Timeless Tweed | Balmain RE23",
|
||||
category: "Commercial",
|
||||
vimeoId: "785052599",
|
||||
vimeoUrl: "https://vimeo.com/785052599",
|
||||
thumbnail: "https://i.vimeocdn.com/video/1577300832-55a40f92ab72eb0388118e54fb5cab8f48774108b1566f58e8e77a8cf39e1805-d_1280",
|
||||
},
|
||||
{
|
||||
title: "19 Degree Fragrance | Tumi",
|
||||
category: "Commercial",
|
||||
vimeoId: "868393589",
|
||||
vimeoUrl: "https://vimeo.com/868393589",
|
||||
thumbnail: "https://i.vimeocdn.com/video/1729511752-7b676cea1319a69647ee10230cada8a2cfdce8dd368b6439614917cd14231de2-d_1280",
|
||||
},
|
||||
{
|
||||
title: "The Unicorn | Balmain RE23",
|
||||
category: "Commercial",
|
||||
vimeoId: "783809805",
|
||||
vimeoUrl: "https://vimeo.com/783809805",
|
||||
thumbnail: "https://i.vimeocdn.com/video/1573662362-749b424c7bc0bac4f790cfc9d4dc0aa2d54f69b19ecde5aa216cd9f55ea2f711-d_1280",
|
||||
},
|
||||
{
|
||||
title: "The Balmain Force | Balmain RE23",
|
||||
category: "Commercial",
|
||||
vimeoId: "783811468",
|
||||
vimeoUrl: "https://vimeo.com/783811468",
|
||||
thumbnail: "https://i.vimeocdn.com/video/1573667153-74a46402be89ecca3c2593febaf914fd49c8dbb4fe3e7402d3d9a78779bb7e1b-d_1280",
|
||||
},
|
||||
];
|
||||
|
||||
export function getProjectsByCategory(category: Category): Project[] {
|
||||
return PROJECTS.filter((p) => p.category === category);
|
||||
}
|
||||
9
oskvp/site/src/env.d.ts
vendored
Normal file
9
oskvp/site/src/env.d.ts
vendored
Normal file
@ -0,0 +1,9 @@
|
||||
type Runtime = import("@astrojs/cloudflare").Runtime<Env>;
|
||||
|
||||
declare namespace App {
|
||||
interface Locals extends Runtime {}
|
||||
}
|
||||
|
||||
interface Env {
|
||||
CONTACT_EMAIL: string;
|
||||
}
|
||||
22
oskvp/site/src/layouts/BaseLayout.astro
Normal file
22
oskvp/site/src/layouts/BaseLayout.astro
Normal file
@ -0,0 +1,22 @@
|
||||
---
|
||||
import BaseHead from '@/components/BaseHead.astro';
|
||||
|
||||
interface Props {
|
||||
title?: string;
|
||||
description?: string;
|
||||
image?: string;
|
||||
robots?: string;
|
||||
}
|
||||
|
||||
const { title, description, image, robots } = Astro.props;
|
||||
---
|
||||
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<BaseHead title={title} description={description} image={image} robots={robots} />
|
||||
</head>
|
||||
<body>
|
||||
<slot />
|
||||
</body>
|
||||
</html>
|
||||
6
oskvp/site/src/lib/utils.ts
Normal file
6
oskvp/site/src/lib/utils.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
24
oskvp/site/src/pages/404.astro
Normal file
24
oskvp/site/src/pages/404.astro
Normal file
@ -0,0 +1,24 @@
|
||||
---
|
||||
import BaseLayout from '@/layouts/BaseLayout.astro';
|
||||
import Navigation from '@/components/Navigation';
|
||||
---
|
||||
|
||||
<BaseLayout title="404 — Not Found" robots="noindex, follow">
|
||||
<Navigation client:load />
|
||||
|
||||
<main class="min-h-screen flex flex-col items-center justify-center px-6 text-center">
|
||||
<p class="text-white/20 text-xs tracking-widest uppercase mb-4">404</p>
|
||||
<h1 class="font-display text-5xl md:text-7xl font-medium text-white tracking-tight mb-6">
|
||||
Not Found
|
||||
</h1>
|
||||
<p class="text-white/40 text-base font-light mb-12">
|
||||
This page doesn't exist.
|
||||
</p>
|
||||
<a
|
||||
href="/"
|
||||
class="inline-block px-8 py-3 border border-white/20 text-white/60 text-sm tracking-widest uppercase font-light hover:bg-white hover:text-black transition-all duration-300"
|
||||
>
|
||||
Back to Work
|
||||
</a>
|
||||
</main>
|
||||
</BaseLayout>
|
||||
57
oskvp/site/src/pages/about.astro
Normal file
57
oskvp/site/src/pages/about.astro
Normal file
@ -0,0 +1,57 @@
|
||||
---
|
||||
import BaseLayout from '@/layouts/BaseLayout.astro';
|
||||
import Navigation from '@/components/Navigation';
|
||||
import Footer from '@/components/Footer';
|
||||
import { CONTACT_EMAIL } from '@/consts';
|
||||
---
|
||||
|
||||
<BaseLayout title="About" description="Oliver Shore and Kevin Von Puttkammer — OSKVP. Experienced high-end film directors based in Amsterdam.">
|
||||
<Navigation client:load currentPath="/about" />
|
||||
|
||||
<main class="pt-32 pb-20 px-6 md:px-10 max-w-4xl mx-auto">
|
||||
<h1 class="font-display text-5xl md:text-7xl font-medium text-white tracking-tight leading-none mb-16">
|
||||
About
|
||||
</h1>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-16">
|
||||
<div>
|
||||
<p class="text-white/70 text-lg font-light leading-relaxed mb-6">
|
||||
Oliver Shore and Kevin Von Puttkammer are an Amsterdam-based director duo working at the intersection of fashion, music, and film.
|
||||
</p>
|
||||
<p class="text-white/50 text-base font-light leading-relaxed mb-6">
|
||||
Known for their distinctive visual language, OSKV brings a cinematic sensibility to every frame — from high-end music videos for artists like Ken Carson, Jordan Adetunji, and Jhayco, to brand campaigns for Balmain, Tumi, and Ralph Lauren.
|
||||
</p>
|
||||
<p class="text-white/50 text-base font-light leading-relaxed">
|
||||
Their work spans continents and genres, united by an uncompromising attention to craft and a fearless approach to visual storytelling.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-8">
|
||||
<div>
|
||||
<h2 class="font-display text-sm text-white/30 tracking-widest uppercase mb-4">Clients</h2>
|
||||
<ul class="flex flex-col gap-2">
|
||||
{[
|
||||
'Balmain', 'Tumi', 'Ralph Lauren', 'Alex Moss',
|
||||
'Jhayco', 'Jordan Adetunji', 'Ken Carson',
|
||||
'Coi Leray', 'Fuerza Regida', '2Hollis', 'Wesghost',
|
||||
].map((client) => (
|
||||
<li class="text-white/60 text-sm font-light">{client}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 class="font-display text-sm text-white/30 tracking-widest uppercase mb-4">Contact</h2>
|
||||
<a
|
||||
href={`mailto:${CONTACT_EMAIL}`}
|
||||
class="text-white/60 text-sm font-light hover:text-white transition-colors"
|
||||
>
|
||||
{CONTACT_EMAIL}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<Footer client:visible />
|
||||
</BaseLayout>
|
||||
23
oskvp/site/src/pages/commercials.astro
Normal file
23
oskvp/site/src/pages/commercials.astro
Normal file
@ -0,0 +1,23 @@
|
||||
---
|
||||
import BaseLayout from '@/layouts/BaseLayout.astro';
|
||||
import Navigation from '@/components/Navigation';
|
||||
import ProjectGrid from '@/components/ProjectGrid';
|
||||
import Footer from '@/components/Footer';
|
||||
import { PROJECTS } from '@/data/projects';
|
||||
|
||||
const commercials = PROJECTS.filter((p) => p.category === 'Commercial');
|
||||
---
|
||||
|
||||
<BaseLayout title="Commercials" description="Commercial direction by OSKVP — Oliver Shore and Kevin Von Puttkammer. Brands include Balmain, Tumi, Ralph Lauren, and more.">
|
||||
<Navigation client:load currentPath="/commercials" />
|
||||
|
||||
<section class="pt-32 pb-4 px-6 md:px-10">
|
||||
<h1 class="font-display text-4xl md:text-6xl font-medium text-white tracking-tight">Commercials</h1>
|
||||
<p class="text-white/40 text-sm mt-3 font-light">
|
||||
{commercials.length} projects
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<ProjectGrid client:visible projects={commercials} />
|
||||
<Footer client:visible />
|
||||
</BaseLayout>
|
||||
38
oskvp/site/src/pages/contact.astro
Normal file
38
oskvp/site/src/pages/contact.astro
Normal file
@ -0,0 +1,38 @@
|
||||
---
|
||||
import BaseLayout from '@/layouts/BaseLayout.astro';
|
||||
import Navigation from '@/components/Navigation';
|
||||
import Footer from '@/components/Footer';
|
||||
import { CONTACT_EMAIL } from '@/consts';
|
||||
---
|
||||
|
||||
<BaseLayout title="Contact" description="Get in touch with OSKVP — Oliver Shore and Kevin Von Puttkammer. Available for commissions worldwide.">
|
||||
<Navigation client:load currentPath="/contact" />
|
||||
|
||||
<main class="pt-32 pb-20 px-6 md:px-10 min-h-screen flex flex-col justify-center max-w-3xl mx-auto">
|
||||
<h1 class="font-display text-5xl md:text-7xl font-medium text-white tracking-tight leading-none mb-8">
|
||||
Let's Work
|
||||
</h1>
|
||||
<p class="text-white/50 text-lg font-light leading-relaxed mb-16 max-w-lg">
|
||||
Available for commissions worldwide — music videos, commercials, and editorial. Based in Amsterdam.
|
||||
</p>
|
||||
|
||||
<div class="flex flex-col gap-4">
|
||||
<p class="text-white/20 text-xs tracking-widest uppercase">Email</p>
|
||||
<a
|
||||
href={`mailto:${CONTACT_EMAIL}`}
|
||||
class="font-display text-2xl md:text-4xl text-white/80 hover:text-white transition-colors tracking-tight"
|
||||
>
|
||||
{CONTACT_EMAIL}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="mt-16 pt-16 border-t border-white/5">
|
||||
<p class="text-white/20 text-xs tracking-widest uppercase mb-6">Representation</p>
|
||||
<p class="text-white/40 text-sm font-light">
|
||||
For representation inquiries, please reach out via email.
|
||||
</p>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<Footer client:visible />
|
||||
</BaseLayout>
|
||||
17
oskvp/site/src/pages/index.astro
Normal file
17
oskvp/site/src/pages/index.astro
Normal file
@ -0,0 +1,17 @@
|
||||
---
|
||||
import BaseLayout from '@/layouts/BaseLayout.astro';
|
||||
import Navigation from '@/components/Navigation';
|
||||
import Hero from '@/components/Hero';
|
||||
import ProjectGrid from '@/components/ProjectGrid';
|
||||
import Marquee from '@/components/Marquee';
|
||||
import Footer from '@/components/Footer';
|
||||
import { PROJECTS } from '@/data/projects';
|
||||
---
|
||||
|
||||
<BaseLayout>
|
||||
<Navigation client:load currentPath="/" />
|
||||
<Hero client:load />
|
||||
<Marquee client:visible />
|
||||
<ProjectGrid client:visible projects={PROJECTS} showFilter={true} />
|
||||
<Footer client:visible />
|
||||
</BaseLayout>
|
||||
23
oskvp/site/src/pages/music-videos.astro
Normal file
23
oskvp/site/src/pages/music-videos.astro
Normal file
@ -0,0 +1,23 @@
|
||||
---
|
||||
import BaseLayout from '@/layouts/BaseLayout.astro';
|
||||
import Navigation from '@/components/Navigation';
|
||||
import ProjectGrid from '@/components/ProjectGrid';
|
||||
import Footer from '@/components/Footer';
|
||||
import { PROJECTS } from '@/data/projects';
|
||||
|
||||
const musicVideos = PROJECTS.filter((p) => p.category === 'Music Video' || p.category === 'Album Trailer');
|
||||
---
|
||||
|
||||
<BaseLayout title="Music Videos" description="Music video direction by OSKVP — Oliver Shore and Kevin Von Puttkammer. High-end music video production.">
|
||||
<Navigation client:load currentPath="/music-videos" />
|
||||
|
||||
<section class="pt-32 pb-4 px-6 md:px-10">
|
||||
<h1 class="font-display text-4xl md:text-6xl font-medium text-white tracking-tight">Music Videos</h1>
|
||||
<p class="text-white/40 text-sm mt-3 font-light">
|
||||
{musicVideos.length} projects
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<ProjectGrid client:visible projects={musicVideos} />
|
||||
<Footer client:visible />
|
||||
</BaseLayout>
|
||||
111
oskvp/site/src/styles/global.css
Normal file
111
oskvp/site/src/styles/global.css
Normal file
@ -0,0 +1,111 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
/* ── Design Tokens ── */
|
||||
@theme {
|
||||
--color-primary: #F0EDE8;
|
||||
--color-primary-light: #FFFFFF;
|
||||
--color-primary-dark: #C8C4BE;
|
||||
--color-accent: #C8C4BE;
|
||||
--color-dark: #0A0A0A;
|
||||
--color-darker: #050505;
|
||||
--color-gray: #141414;
|
||||
--color-gray-mid: #282828;
|
||||
--color-text-muted: #888888;
|
||||
|
||||
--font-display: "Playfair Display", "Georgia", serif;
|
||||
--font-body: "Inter", system-ui, -apple-system, sans-serif;
|
||||
--font-mono: "JetBrains Mono", "Courier New", monospace;
|
||||
}
|
||||
|
||||
/* ── Global Styles ── */
|
||||
:root {
|
||||
--nav-height: 72px;
|
||||
}
|
||||
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: var(--font-body);
|
||||
background-color: var(--color-dark);
|
||||
color: #ffffff;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow-x: hidden;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
font-family: var(--font-display);
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
/* ── Scrollbar ── */
|
||||
::-webkit-scrollbar { width: 6px; }
|
||||
::-webkit-scrollbar-track { background: var(--color-darker); }
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--color-gray-mid);
|
||||
border-radius: 3px;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover { background: var(--color-text-muted); }
|
||||
|
||||
/* ── Utility Classes ── */
|
||||
.font-display { font-family: var(--font-display); }
|
||||
|
||||
.scrollbar-none {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
.scrollbar-none::-webkit-scrollbar { display: none; }
|
||||
|
||||
/* ── Safe Viewport Height ── */
|
||||
.h-screen-safe { height: 100vh; height: 100dvh; }
|
||||
.min-h-screen-safe { min-height: 100vh; min-height: 100dvh; }
|
||||
|
||||
/* ── Marquee ── */
|
||||
@keyframes marquee-scroll {
|
||||
0% { transform: translateX(0); }
|
||||
100% { transform: translateX(-50%); }
|
||||
}
|
||||
.marquee-track { animation: marquee-scroll 30s linear infinite; }
|
||||
|
||||
/* ── Project Card ── */
|
||||
.project-card-overlay {
|
||||
background: linear-gradient(to top, rgba(0,0,0,0.85) 0%, rgba(0,0,0,0) 60%);
|
||||
transition: opacity 200ms ease;
|
||||
}
|
||||
|
||||
/* ── Modal ── */
|
||||
.modal-backdrop {
|
||||
backdrop-filter: blur(4px);
|
||||
-webkit-backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
/* ── Focus Visible ── */
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--color-primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* ── Reduced Motion ── */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.marquee-track { animation: none; }
|
||||
* {
|
||||
animation-duration: 0.01ms !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
13
oskvp/site/tsconfig.json
Normal file
13
oskvp/site/tsconfig.json
Normal file
@ -0,0 +1,13 @@
|
||||
{
|
||||
"extends": "astro/tsconfigs/strict",
|
||||
"include": [".astro/types.d.ts", "**/*"],
|
||||
"exclude": ["dist"],
|
||||
"compilerOptions": {
|
||||
"strictNullChecks": true,
|
||||
"types": ["node"],
|
||||
"baseUrl": ".",
|
||||
"paths": { "@/*": ["./src/*"] },
|
||||
"jsx": "react-jsx",
|
||||
"jsxImportSource": "react"
|
||||
}
|
||||
}
|
||||
11
oskvp/site/wrangler.jsonc
Normal file
11
oskvp/site/wrangler.jsonc
Normal file
@ -0,0 +1,11 @@
|
||||
{
|
||||
"$schema": "node_modules/wrangler/config-schema.json",
|
||||
"name": "oskvp",
|
||||
"compatibility_date": "2025-12-05",
|
||||
"compatibility_flags": ["nodejs_compat"],
|
||||
"pages_build_output_dir": "./dist",
|
||||
"observability": { "enabled": true },
|
||||
"vars": {
|
||||
"CONTACT_EMAIL": "info@oskvp.com"
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user