Initial ttvfx.com site: Next.js static export
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user