Initial ttvfx.com site: Next.js static export

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
twotalesanimation
2026-10-05 18:17:41 +02:00
commit 1f3af8f7f5
33 changed files with 8003 additions and 0 deletions
+43
View File
@@ -0,0 +1,43 @@
import Link from "next/link";
import Fox from "./Fox";
import { nav, site } from "@/content/site";
export default function Footer() {
return (
<footer className="border-t border-line">
<div className="mx-auto grid max-w-7xl gap-10 px-5 py-14 md:grid-cols-3 md:px-8">
<div className="flex items-start gap-4">
<Fox className="h-10 w-10 shrink-0" />
<div>
<p className="font-display text-lg font-light tracking-[0.3em] uppercase">Two Tales VFX</p>
<p className="mt-1 font-mono text-xs tracking-widest text-muted uppercase">Est. {site.founded}</p>
</div>
</div>
<nav className="flex flex-col gap-2" aria-label="Footer">
{nav.map((item) => (
<Link key={item.href} href={item.href} className="text-sm text-muted hover:text-fg">
{item.label}
</Link>
))}
</nav>
<div className="flex flex-col gap-2 text-sm md:items-end">
<a href={`mailto:${site.email}`} className="hover:text-accent">
{site.email}
</a>
<span className="text-muted">{site.location}</span>
{site.socials.map((s) => (
<a key={s.href} href={s.href} target="_blank" rel="noreferrer" className="text-muted hover:text-fg">
{s.label}
</a>
))}
</div>
</div>
<div className="mx-auto flex max-w-7xl justify-between border-t border-line px-5 py-6 font-mono text-[11px] tracking-widest text-muted uppercase md:px-8">
<span>
© {new Date().getFullYear()} {site.name}
</span>
<span>ttvfx.com</span>
</div>
</footer>
);
}
+11
View File
@@ -0,0 +1,11 @@
export default function Fox({ className = "" }: { className?: string }) {
return (
<svg viewBox="252 0 406 404" className={className} fill="currentColor" aria-hidden="true">
<path
fillRule="evenodd"
d="M252 1 252 275 517 403 517 176 528 152 495 174 517 121 466 176 332 176Z M369 248 453 247 478 302Z"
/>
<path d="M549 303 657 248 572 248Z" />
</svg>
);
}
+38
View File
@@ -0,0 +1,38 @@
import type { Project } from "@/content/site";
// A project still. Uses the project image when present, otherwise renders a
// stylised "viewer" frame (gradient plate + safe-area guide + slate overlay).
export default function Frame({ project, priority = false }: { project: Project; priority?: boolean }) {
const h = project.hue;
return (
<div className="relative aspect-[2.39/1] w-full overflow-hidden bg-ink-2">
{project.image ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={project.image}
alt={project.title}
loading={priority ? "eager" : "lazy"}
className="absolute inset-0 h-full w-full object-cover transition-transform duration-[1.2s] ease-out group-hover:scale-[1.04]"
/>
) : (
<div
className="absolute inset-0 transition-transform duration-[1.2s] ease-out group-hover:scale-[1.06]"
style={{
background: `radial-gradient(ellipse 60% 80% at 70% 60%, hsl(${h} 70% 45% / .55), transparent 70%),
radial-gradient(ellipse 50% 60% at 20% 30%, hsl(${(h + 40) % 360} 60% 30% / .5), transparent 70%),
linear-gradient(180deg, hsl(${h} 25% 9%), hsl(${h} 30% 4%))`,
}}
>
<div className="absolute inset-x-0 bottom-0 h-1/3 bg-gradient-to-t from-black/70 to-transparent" />
</div>
)}
<div className="pointer-events-none absolute inset-[6%] border border-white/10" />
<div className="pointer-events-none absolute top-3 left-4 font-mono text-[10px] tracking-widest text-white/50 uppercase">
{project.category} · {project.year}
</div>
<div className="pointer-events-none absolute top-3 right-4 font-mono text-[10px] tracking-widest text-white/50">
{project.shots ? `${project.shots} SHOTS` : "2.39:1"}
</div>
</div>
);
}
+81
View File
@@ -0,0 +1,81 @@
"use client";
import Link from "next/link";
import { useEffect, useState } from "react";
import Fox from "./Fox";
import { nav } from "@/content/site";
export default function Header() {
const [scrolled, setScrolled] = useState(false);
const [open, setOpen] = useState(false);
useEffect(() => {
const onScroll = () => setScrolled(window.scrollY > 24);
onScroll();
window.addEventListener("scroll", onScroll, { passive: true });
return () => window.removeEventListener("scroll", onScroll);
}, []);
useEffect(() => {
document.body.style.overflow = open ? "hidden" : "";
}, [open]);
return (
<header
className={`fixed inset-x-0 top-0 z-50 transition-colors duration-500 ${
scrolled || open ? "border-b border-line bg-ink/80 backdrop-blur-md" : "border-b border-transparent"
}`}
>
<div className="mx-auto flex h-16 max-w-7xl items-center justify-between px-5 md:h-20 md:px-8">
<Link href="/" className="flex items-center gap-3" onClick={() => setOpen(false)}>
<Fox className="h-7 w-7 text-fg md:h-8 md:w-8" />
<span className="font-display text-base font-light tracking-[0.3em] uppercase md:text-lg">
Two Tales <span className="text-accent">VFX</span>
</span>
</Link>
<nav className="hidden items-center gap-10 md:flex" aria-label="Main">
{nav.map((item) => (
<Link
key={item.href}
href={item.href}
className="font-mono text-xs tracking-[0.2em] text-muted uppercase transition-colors hover:text-fg"
>
{item.label}
</Link>
))}
</nav>
<button
type="button"
className="relative h-10 w-10 md:hidden"
aria-label={open ? "Close menu" : "Open menu"}
aria-expanded={open}
onClick={() => setOpen((v) => !v)}
>
<span
className={`absolute right-2 left-2 h-px bg-fg transition-transform duration-300 ${open ? "rotate-45" : "-translate-y-1.5"}`}
/>
<span
className={`absolute right-2 left-2 h-px bg-fg transition-transform duration-300 ${open ? "-rotate-45" : "translate-y-1.5"}`}
/>
</button>
</div>
{open && (
<nav className="flex h-[calc(100dvh-4rem)] flex-col gap-2 px-5 pt-10 md:hidden" aria-label="Mobile">
{nav.map((item) => (
<Link
key={item.href}
href={item.href}
onClick={() => setOpen(false)}
className="font-display text-4xl font-light tracking-wider uppercase"
>
{item.label}
</Link>
))}
</nav>
)}
</header>
);
}
+78
View File
@@ -0,0 +1,78 @@
import Link from "next/link";
import Fox from "./Fox";
import Timecode from "./Timecode";
import { site } from "@/content/site";
export default function Hero() {
return (
<section className="relative flex min-h-[100svh] items-end overflow-hidden pt-32 md:pt-44">
{/* Plate: showreel if configured, otherwise a moving light field */}
{site.reel ? (
<video
className="absolute inset-0 h-full w-full object-cover opacity-60"
src={site.reel}
poster={site.reelPoster ?? undefined}
autoPlay
muted
loop
playsInline
/>
) : (
<div className="absolute inset-0" aria-hidden="true">
<div className="animate-drift absolute top-[10%] left-[45%] h-[70vmax] w-[70vmax] rounded-full bg-[radial-gradient(circle,rgb(232_87_42/0.28),transparent_60%)] blur-2xl" />
<div
className="animate-drift absolute top-[-20%] left-[-20%] h-[60vmax] w-[60vmax] rounded-full bg-[radial-gradient(circle,rgb(70_110_160/0.22),transparent_60%)] blur-2xl"
style={{ animationDelay: "-9s" }}
/>
<Fox className="absolute top-1/2 right-[-8vw] h-[85vh] w-auto -translate-y-1/2 text-white/[0.035]" />
<div className="animate-scan absolute inset-x-0 top-0 h-px bg-gradient-to-r from-transparent via-white/20 to-transparent" />
</div>
)}
<div className="absolute inset-0 bg-gradient-to-t from-ink via-ink/40 to-ink/30" aria-hidden="true" />
{/* Viewer HUD */}
<div
className="pointer-events-none absolute inset-x-5 top-24 bottom-6 hidden font-mono text-[10px] tracking-[0.25em] text-white/40 uppercase md:inset-x-8 md:top-28 md:block"
aria-hidden="true"
>
<span className="absolute top-0 left-0 h-4 w-4 border-t border-l border-white/30" />
<span className="absolute top-0 right-0 h-4 w-4 border-t border-r border-white/30" />
<span className="absolute bottom-0 left-0 h-4 w-4 border-b border-l border-white/30" />
<span className="absolute right-0 bottom-0 h-4 w-4 border-r border-b border-white/30" />
<span className="absolute top-3 left-6">TTVFX_REEL_v{String(new Date().getFullYear()).slice(2)}1</span>
<span className="absolute top-3 right-6">ACEScg · 24 fps</span>
<Timecode className="absolute right-6 bottom-3" />
</div>
<div className="relative mx-auto w-full max-w-7xl px-5 pb-20 md:px-8 md:pb-28">
<p className="mb-6 font-mono text-xs tracking-[0.3em] text-accent uppercase">
Visual effects studio · Est. {site.founded}
</p>
<h1 className="font-display text-[9.5vw] leading-[0.95] font-thin tracking-tight uppercase md:text-[8.5vw] xl:text-[8rem]">
Invisible work.
<br />
<span className="font-light">Unforgettable</span> frames.
</h1>
<div className="mt-10 flex flex-col gap-8 md:flex-row md:items-end md:justify-between">
<p className="max-w-md text-base leading-relaxed text-muted md:text-lg">
Compositing, CG and finishing for series, film and commercials, built on a pipeline we wrote ourselves.
</p>
<div className="flex gap-3">
<Link
href="/work/"
className="bg-fg px-5 py-3.5 whitespace-nowrap md:px-6 font-mono text-xs tracking-[0.2em] text-ink uppercase transition-colors hover:bg-accent"
>
View work
</Link>
<Link
href="/#contact"
className="border border-white/25 px-5 py-3.5 font-mono text-xs tracking-[0.2em] whitespace-nowrap uppercase transition-colors hover:border-fg md:px-6"
>
Start a project
</Link>
</div>
</div>
</div>
</section>
);
}
+22
View File
@@ -0,0 +1,22 @@
import Link from "next/link";
import Frame from "./Frame";
import type { Project } from "@/content/site";
export default function ProjectCard({ project }: { project: Project }) {
return (
<Link href={`/work/${project.slug}/`} className="group block">
<Frame project={project} />
<div className="mt-5 flex items-start justify-between gap-6">
<div>
<h3 className="font-display text-2xl font-light tracking-wide uppercase transition-colors group-hover:text-accent md:text-3xl">
{project.title}
</h3>
<p className="mt-1 text-sm text-muted">{project.client}</p>
</div>
<p className="shrink-0 pt-2 text-right font-mono text-[11px] tracking-widest text-muted uppercase">
{project.credits.slice(0, 2).join(" / ")}
</p>
</div>
</Link>
);
}
+37
View File
@@ -0,0 +1,37 @@
"use client";
import { useEffect, useRef, type ReactNode } from "react";
export default function Reveal({
children,
delay = 0,
className = "",
}: {
children: ReactNode;
delay?: number;
className?: string;
}) {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
const el = ref.current;
if (!el) return;
const io = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
el.classList.add("is-visible");
io.disconnect();
}
},
{ rootMargin: "0px 0px -10% 0px" },
);
io.observe(el);
return () => io.disconnect();
}, []);
return (
<div ref={ref} className={`reveal ${className}`} style={{ transitionDelay: `${delay}ms` }}>
{children}
</div>
);
}
+17
View File
@@ -0,0 +1,17 @@
export default function SectionLabel({
index,
children,
className = "",
}: {
index: string;
children: React.ReactNode;
className?: string;
}) {
return (
<div className={`mb-10 flex items-center gap-4 font-mono text-xs tracking-[0.3em] text-muted uppercase md:mb-14 ${className}`}>
<span className="text-accent">{index}</span>
<span className="h-px w-10 bg-line" />
<span>{children}</span>
</div>
);
}
+30
View File
@@ -0,0 +1,30 @@
"use client";
import { useEffect, useRef } from "react";
const FPS = 24;
const pad = (n: number) => String(n).padStart(2, "0");
// A running SMPTE-style timecode, written straight to the DOM to avoid re-renders.
export default function Timecode({ className = "" }: { className?: string }) {
const ref = useRef<HTMLSpanElement>(null);
useEffect(() => {
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
const start = performance.now() - 1001 * (1000 / FPS);
const id = window.setInterval(() => {
const f = Math.floor(((performance.now() - start) / 1000) * FPS);
const s = Math.floor(f / FPS);
if (ref.current) {
ref.current.textContent = `01:${pad(Math.floor(s / 60) % 60)}:${pad(s % 60)}:${pad(f % FPS)}`;
}
}, 1000 / FPS);
return () => window.clearInterval(id);
}, []);
return (
<span ref={ref} className={`tabular-nums ${className}`}>
01:00:41:17
</span>
);
}