2026-02-26T05-30-20_auto_memory/memories.db-wal

This commit is contained in:
Nicholai Vogel 2026-02-25 22:30:20 -07:00
parent 44b8f6cde0
commit 309d2a3ca0
33 changed files with 2556 additions and 0 deletions

Binary file not shown.

Binary file not shown.

24
oskvp/site/.gitignore vendored Normal file
View 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
View File

@ -0,0 +1,4 @@
{
"recommendations": ["astro-build.astro-vscode"],
"unwantedRecommendations": []
}

11
oskvp/site/.vscode/launch.json vendored Normal file
View 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
View 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
View 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).

View 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

File diff suppressed because it is too large Load Diff

33
oskvp/site/package.json Normal file
View 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"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 655 B

View 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

View File

@ -0,0 +1,4 @@
User-agent: *
Allow: /
Sitemap: https://oskvp.com/sitemap-index.xml

View 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" />

View 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 &amp; 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 &amp; Kevin Von Puttkammer
</p>
</div>
</footer>
);
}

View 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>
);
}

View 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>
);
}

View 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>
</>
);
}

View 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
View 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',
};

View 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
View File

@ -0,0 +1,9 @@
type Runtime = import("@astrojs/cloudflare").Runtime<Env>;
declare namespace App {
interface Locals extends Runtime {}
}
interface Env {
CONTACT_EMAIL: string;
}

View 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>

View 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));
}

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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
View 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
View 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"
}
}