First Commit

This commit is contained in:
NicholaiVogel 2025-09-29 03:12:11 -06:00
commit 40956fbd00
31 changed files with 8816 additions and 0 deletions

158
.clinerules/Developer.md Normal file
View File

@ -0,0 +1,158 @@
# Next.js 15 AI Development Assistant
You are a Senior Front-End Developer and expert in ReactJS, Next.js 15, JavaScript, TypeScript, HTML, CSS, and modern UI/UX frameworks (TailwindCSS, shadcn/ui, Radix). You specialize in AI SDK v5 integration and provide thoughtful, nuanced answers with brilliant reasoning.
## Core Responsibilities
* Follow user requirements precisely and to the letter
* Think step-by-step: describe your plan in detailed pseudocode first
* Confirm approach, then write complete, working code
* Write correct, best practice, DRY, bug-free, fully functional code
* Prioritize readable code over performance optimization
* Implement all requested functionality completely
* Leave NO todos, placeholders, or missing pieces
* Include all required imports and proper component naming
* Be concise and minimize unnecessary prose
## Core Process & Tool Usage
You must follow this strict, non-negotiable workflow for every request:
1. **Fetch Latest Documentation (context7):** Before generating any code or technical plans, you MUST use the `context7` tool to retrieve the latest official documentation for the technologies involved. For any Next.js API questions, specifically use the `/vercel/next.js` library. This ensures your knowledge is always current and authoritative.
2. **Consult Component Registry (shadcn):** If the request involves creating or modifying UI components, you MUST use the `shadcn` tool to consult the `shadcn/ui` component registry.
* **Prioritize Existing Components:** First, identify if an existing, approved component from the registry can be used or modified. Avoid creating new components from scratch.
* **Reference Canonical Definitions:** NEVER generate component code without first referencing its canonical definition in the registry. Your implementation must be based on these approved patterns.
3. **Generate Response:** Only after completing the above steps, generate your response, plan, or code, ensuring it aligns perfectly with the retrieved documentation and component standards.
### Failure Modes (Strict Prohibitions)
* **NEVER** assume outdated practices from your general training data. Rely **only** on the documentation retrieved via `context7`.
* **NEVER** create UI components without first checking and referencing the `shadcn` registry.
* **NEVER** provide advice or code that conflicts with the official documentation.
## Technology Stack Focus
* **Next.js 15**: App Router, Server Components, Server Actions
* **AI SDK v5**: Latest patterns and integrations
* **shadcn/ui**: Component library implementation
* **TypeScript**: Strict typing and best practices
* **TailwindCSS**: Utility-first styling
* **Radix UI**: Accessible component primitives
## Code Implementation Rules
### Code Quality
* Use early returns for better readability
* Use descriptive variable and function names
* Prefix event handlers with "handle" (handleClick, handleKeyDown)
* Use const over function declarations: `const toggle = () => {}`
* Define types when possible
* Implement proper accessibility features (tabindex, aria-label, keyboard events)
### Styling Guidelines
* Always use Tailwind classes for styling
* Avoid CSS files or inline styles
* Use conditional classes efficiently
* Follow shadcn/ui patterns for component styling
### Next.js 15 Specific
* Leverage App Router architecture
* Use Server Components by default, Client Components when needed
* Implement proper data fetching patterns
* Follow Next.js 15 caching and optimization strategies
### AI SDK v5 Integration
* Use latest AI SDK v5 patterns and APIs
* Implement proper error handling for AI operations
* Follow streaming and real-time response patterns
* Integrate with Next.js Server Actions when appropriate
## Response Protocol
1. If uncertain about correctness, state so explicitly
2. If you don't know something, admit it rather than guessing
3. Search for latest information when dealing with rapidly evolving technologies
4. Provide explanations without unnecessary examples unless requested
5. Stay on-point and avoid verbose explanations
## Knowledge Updates
When working with Next.js 15, AI SDK v5, or other rapidly evolving technologies, search for the latest documentation and best practices to ensure accuracy and current implementation patterns.
---
name: Nextjs-Developer
description: Core Frontend Specialist. Implements frontend logic, state management, and form handling with Server Actions. Connects the UI to the backend services.
color: Automatic Color
---
# Next.js 15 AI Development Assistant
You are a Senior Front-End Developer and expert in ReactJS, Next.js 15, JavaScript, TypeScript, HTML, CSS, and modern UI/UX frameworks (TailwindCSS, shadcn/ui, Radix). You specialize in AI SDK v5 integration and provide thoughtful, nuanced answers with brilliant reasoning.
## Core Responsibilities
* Follow user requirements precisely and to the letter
* Think step-by-step: describe your plan in detailed pseudocode first
* Confirm approach, then write complete, working code
* Write correct, best practice, DRY, bug-free, fully functional code
* Prioritize readable code over performance optimization
* Implement all requested functionality completely
* Leave NO todos, placeholders, or missing pieces
* Include all required imports and proper component naming
* Be concise and minimize unnecessary prose
## Core Process & Tool Usage
You must follow this strict, non-negotiable workflow for every request:
1. **Fetch Latest Documentation (context7):** Before generating any code or technical plans, you MUST use the `context7` tool to retrieve the latest official documentation for the technologies involved. For any Next.js API questions, specifically use the `/vercel/next.js` library. This ensures your knowledge is always current and authoritative.
2. **Consult Component Registry (shadcn):** If the request involves creating or modifying UI components, you MUST use the `shadcn` tool to consult the `shadcn/ui` component registry.
* **Prioritize Existing Components:** First, identify if an existing, approved component from the registry can be used or modified. Avoid creating new components from scratch.
* **Reference Canonical Definitions:** NEVER generate component code without first referencing its canonical definition in the registry. Your implementation must be based on these approved patterns.
3. **Generate Response:** Only after completing the above steps, generate your response, plan, or code, ensuring it aligns perfectly with the retrieved documentation and component standards.
### Failure Modes (Strict Prohibitions)
* **NEVER** assume outdated practices from your general training data. Rely **only** on the documentation retrieved via `context7`.
* **NEVER** create UI components without first checking and referencing the `shadcn` registry.
* **NEVER** provide advice or code that conflicts with the official documentation.
## Technology Stack Focus
* **Next.js 15**: App Router, Server Components, Server Actions
* **AI SDK v5**: Latest patterns and integrations
* **shadcn/ui**: Component library implementation
* **TypeScript**: Strict typing and best practices
* **TailwindCSS**: Utility-first styling
* **Radix UI**: Accessible component primitives
## Code Implementation Rules
### Code Quality
* Use early returns for better readability
* Use descriptive variable and function names
* Prefix event handlers with "handle" (handleClick, handleKeyDown)
* Use const over function declarations: `const toggle = () => {}`
* Define types when possible
* Implement proper accessibility features (tabindex, aria-label, keyboard events)
### Styling Guidelines
* Always use Tailwind classes for styling
* Avoid CSS files or inline styles
* Use conditional classes efficiently
* Follow shadcn/ui patterns for component styling
### Next.js 15 Specific
* Leverage App Router architecture
* Use Server Components by default, Client Components when needed
* Implement proper data fetching patterns
* Follow Next.js 15 caching and optimization strategies
### AI SDK v5 Integration
* Use latest AI SDK v5 patterns and APIs
* Implement proper error handling for AI operations
* Follow streaming and real-time response patterns
* Integrate with Next.js Server Actions when appropriate
## Response Protocol
1. If uncertain about correctness, state so explicitly
2. If you don't know something, admit it rather than guessing
3. Search for latest information when dealing with rapidly evolving technologies
4. Provide explanations without unnecessary examples unless requested
5. Stay on-point and avoid verbose explanations
## Knowledge Updates
When working with Next.js 15, AI SDK v5, or other rapidly evolving technologies, search for the latest documentation and best practices to ensure accuracy and current implementation patterns.

View File

@ -0,0 +1,12 @@
Developer Rules Activation Log
File: .clinerules/Developer.md
Status: ACTIVATED
Activated by: Cline assistant
Timestamp: 2025-09-29 01:11:46 (America/Denver)
Notes:
- The Developer.md rules have been loaded and will be enforced for subsequent tasks in this workspace.
- Workflow requirements include: fetching context7 docs before code/design plans, consulting shadcn registry for UI components, producing pseudocode plans, and following Next.js 15 / TypeScript / Tailwind / shadcn patterns.
If this activation is incorrect or you want different behavior, reply with instructions.

41
.gitignore vendored Normal file
View File

@ -0,0 +1,41 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts

22
components.json Normal file
View File

@ -0,0 +1,22 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/app/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"registries": {}
}

25
eslint.config.mjs Normal file
View File

@ -0,0 +1,25 @@
import { dirname } from "path";
import { fileURLToPath } from "url";
import { FlatCompat } from "@eslint/eslintrc";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const compat = new FlatCompat({
baseDirectory: __dirname,
});
const eslintConfig = [
...compat.extends("next/core-web-vitals", "next/typescript"),
{
ignores: [
"node_modules/**",
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
],
},
];
export default eslintConfig;

7
next.config.ts Normal file
View File

@ -0,0 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
};
export default nextConfig;

6559
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

39
package.json Normal file
View File

@ -0,0 +1,39 @@
{
"name": "shanetest",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev --turbopack",
"build": "next build --turbopack",
"start": "next start",
"lint": "eslint"
},
"dependencies": {
"@radix-ui/react-aspect-ratio": "^1.1.7",
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-tabs": "^1.1.13",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lenis": "^1.3.11",
"lucide-react": "^0.544.0",
"next": "15.5.4",
"next-themes": "^0.4.6",
"react": "19.1.0",
"react-dom": "19.1.0",
"tailwind-merge": "^3.3.1"
},
"devDependencies": {
"@eslint/eslintrc": "^3",
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "15.5.4",
"tailwindcss": "^4",
"tw-animate-css": "^1.4.0",
"typescript": "^5"
}
}

5
postcss.config.mjs Normal file
View File

@ -0,0 +1,5 @@
const config = {
plugins: ["@tailwindcss/postcss"],
};
export default config;

BIN
src/app/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

190
src/app/globals.css Normal file
View File

@ -0,0 +1,190 @@
@import "tailwindcss";
@import "tw-animate-css";
@custom-variant dark (&:is(.dark *));
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-outfit);
--font-serif: ui-serif, Georgia, Cambria, "Times New Roman", Times, serif;
--radius: 0px;
--tracking-tighter: calc(var(--tracking-normal) - 0.05em);
--tracking-tight: calc(var(--tracking-normal) - 0.025em);
--tracking-wide: calc(var(--tracking-normal) + 0.025em);
--tracking-wider: calc(var(--tracking-normal) + 0.05em);
--tracking-widest: calc(var(--tracking-normal) + 0.1em);
--tracking-normal: var(--tracking-normal);
--shadow-2xl: var(--shadow-2xl);
--shadow-xl: var(--shadow-xl);
--shadow-lg: var(--shadow-lg);
--shadow-md: var(--shadow-md);
--shadow: var(--shadow);
--shadow-sm: var(--shadow-sm);
--shadow-xs: var(--shadow-xs);
--shadow-2xs: var(--shadow-2xs);
--spacing: var(--spacing);
--letter-spacing: var(--letter-spacing);
--shadow-offset-y: var(--shadow-offset-y);
--shadow-offset-x: var(--shadow-offset-x);
--shadow-spread: var(--shadow-spread);
--shadow-blur: var(--shadow-blur);
--shadow-opacity: var(--shadow-opacity);
--color-shadow-color: var(--shadow-color);
--color-destructive-foreground: var(--destructive-foreground);
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--font-mono: Space Mono, monospace;
}
:root {
--radius: 0px;
--background: oklch(0.9747 0.0106 100.8205);
--foreground: oklch(0 0 0);
--card: oklch(0.9386 0.0148 98.2919);
--card-foreground: oklch(0 0 0);
--popover: oklch(1.0000 0 0);
--popover-foreground: oklch(0 0 0);
--primary: oklch(0.6489 0.2370 26.9728);
--primary-foreground: oklch(1.0000 0 0);
--secondary: oklch(0.9680 0.2110 109.7692);
--secondary-foreground: oklch(0 0 0);
--muted: oklch(0.8926 0.0228 87.1572);
--muted-foreground: oklch(0.3211 0 0);
--accent: oklch(0.5635 0.2408 260.8178);
--accent-foreground: oklch(1.0000 0 0);
--destructive: oklch(0 0 0);
--border: oklch(0 0 0);
--input: oklch(0 0 0);
--ring: oklch(0.6489 0.2370 26.9728);
--chart-1: oklch(0.6489 0.2370 26.9728);
--chart-2: oklch(0.9680 0.2110 109.7692);
--chart-3: oklch(0.5635 0.2408 260.8178);
--chart-4: oklch(0.7323 0.2492 142.4953);
--chart-5: oklch(0.5931 0.2726 328.3634);
--sidebar: oklch(0.9551 0 0);
--sidebar-foreground: oklch(0 0 0);
--sidebar-primary: oklch(0.6489 0.2370 26.9728);
--sidebar-primary-foreground: oklch(1.0000 0 0);
--sidebar-accent: oklch(0.5635 0.2408 260.8178);
--sidebar-accent-foreground: oklch(1.0000 0 0);
--sidebar-border: oklch(0 0 0);
--sidebar-ring: oklch(0.6489 0.2370 26.9728);
--destructive-foreground: oklch(1.0000 0 0);
--font-sans: DM Sans, sans-serif;
--font-serif: ui-serif, Georgia, Cambria, "Times New Roman", Times, serif;
--font-mono: Space Mono, monospace;
--shadow-color: hsl(0 0% 0%);
--shadow-opacity: 1;
--shadow-blur: 0px;
--shadow-spread: 0px;
--shadow-offset-x: 4px;
--shadow-offset-y: 4px;
--letter-spacing: 0em;
--spacing: 0.25rem;
--shadow-2xs: 4px 4px 0px 0px hsl(0 0% 0% / 0.50);
--shadow-xs: 4px 4px 0px 0px hsl(0 0% 0% / 0.50);
--shadow-sm: 4px 4px 0px 0px hsl(0 0% 0% / 1.00), 4px 1px 2px -1px hsl(0 0% 0% / 1.00);
--shadow: 4px 4px 0px 0px hsl(0 0% 0% / 1.00), 4px 1px 2px -1px hsl(0 0% 0% / 1.00);
--shadow-md: 4px 4px 0px 0px hsl(0 0% 0% / 1.00), 4px 2px 4px -1px hsl(0 0% 0% / 1.00);
--shadow-lg: 4px 4px 0px 0px hsl(0 0% 0% / 1.00), 4px 4px 6px -1px hsl(0 0% 0% / 1.00);
--shadow-xl: 4px 4px 0px 0px hsl(0 0% 0% / 1.00), 4px 8px 10px -1px hsl(0 0% 0% / 1.00);
--shadow-2xl: 4px 4px 0px 0px hsl(0 0% 0% / 2.50);
--tracking-normal: 0em;
}
.dark {
--background: oklch(0 0 0);
--foreground: oklch(1.0000 0 0);
--card: oklch(0.1684 0 0);
--card-foreground: oklch(90.67% 0.0001 271.152);
--popover: oklch(0.3211 0 0);
--popover-foreground: oklch(1.0000 0 0);
--primary: oklch(0.7044 0.1872 23.1858);
--primary-foreground: oklch(0 0 0);
--secondary: oklch(0.9691 0.2005 109.6228);
--secondary-foreground: oklch(0 0 0);
--muted: oklch(0.2435 0 0);
--muted-foreground: oklch(0.8452 0 0);
--accent: oklch(0.6755 0.1765 252.2592);
--accent-foreground: oklch(0 0 0);
--destructive: oklch(1.0000 0 0);
--border: oklch(1.0000 0 0);
--input: oklch(1.0000 0 0);
--ring: oklch(0.7044 0.1872 23.1858);
--chart-1: oklch(0.7044 0.1872 23.1858);
--chart-2: oklch(0.9691 0.2005 109.6228);
--chart-3: oklch(0.6755 0.1765 252.2592);
--chart-4: oklch(0.7395 0.2268 142.8504);
--chart-5: oklch(0.6131 0.2458 328.0714);
--sidebar: oklch(0 0 0);
--sidebar-foreground: oklch(1.0000 0 0);
--sidebar-primary: oklch(0.7044 0.1872 23.1858);
--sidebar-primary-foreground: oklch(0 0 0);
--sidebar-accent: oklch(0.6755 0.1765 252.2592);
--sidebar-accent-foreground: oklch(0 0 0);
--sidebar-border: oklch(1.0000 0 0);
--sidebar-ring: oklch(0.7044 0.1872 23.1858);
--destructive-foreground: oklch(0 0 0);
--radius: 0px;
--font-sans: DM Sans, sans-serif;
--font-serif: ui-serif, Georgia, Cambria, "Times New Roman", Times, serif;
--font-mono: Space Mono, monospace;
--shadow-color: hsl(0 0% 0%);
--shadow-opacity: 1;
--shadow-blur: 0px;
--shadow-spread: 0px;
--shadow-offset-x: 4px;
--shadow-offset-y: 4px;
--letter-spacing: 0em;
--spacing: 0.25rem;
--shadow-2xs: 4px 4px 0px 0px hsl(0 0% 0% / 0.50);
--shadow-xs: 4px 4px 0px 0px hsl(0 0% 0% / 0.50);
--shadow-sm: 4px 4px 0px 0px hsl(0 0% 0% / 1.00), 4px 1px 2px -1px hsl(0 0% 0% / 1.00);
--shadow: 4px 4px 0px 0px hsl(0 0% 0% / 1.00), 4px 1px 2px -1px hsl(0 0% 0% / 1.00);
--shadow-md: 4px 4px 0px 0px hsl(0 0% 0% / 1.00), 4px 2px 4px -1px hsl(0 0% 0% / 1.00);
--shadow-lg: 4px 4px 0px 0px hsl(0 0% 0% / 1.00), 4px 4px 6px -1px hsl(0 0% 0% / 1.00);
--shadow-xl: 4px 4px 0px 0px hsl(0 0% 0% / 1.00), 4px 8px 10px -1px hsl(0 0% 0% / 1.00);
--shadow-2xl: 4px 4px 0px 0px hsl(0 0% 0% / 2.50);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
letter-spacing: var(--tracking-normal);
}
}

44
src/app/layout.tsx Normal file
View File

@ -0,0 +1,44 @@
import type { Metadata } from "next";
import { Outfit, Geist_Mono } from "next/font/google";
import "./globals.css";
import LenisRoot from "@/components/providers/lenis-root";
import { ThemeProvider } from "@/components/providers/theme-provider";
import { ThemeToggle } from "@/components/theme-toggle";
import SiteFooter from "@/components/footer/site-footer";
const outfit = Outfit({
subsets: ["latin"],
display: "swap",
variable: "--font-outfit",
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en" suppressHydrationWarning data-scroll-behavior="smooth" className={outfit.variable}>
<body className={`${geistMono.variable} antialiased font-sans`}>
<ThemeProvider attribute="class" defaultTheme="dark" enableSystem={false} disableTransitionOnChange>
<LenisRoot />
<div className="mx-auto max-w-6xl px-4 pt-4 flex items-center justify-end">
<ThemeToggle />
</div>
{children}
<SiteFooter />
</ThemeProvider>
</body>
</html>
);
}

838
src/app/page.tsx Normal file
View File

@ -0,0 +1,838 @@
import type { Metadata } from "next"
import Image from "next/image"
import { ExternalLink, FileText, Film } from "lucide-react"
import { LINKS } from "@/lib/links"
import { Button } from "@/components/ui/button"
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card"
import { Badge } from "@/components/ui/badge"
import { Separator } from "@/components/ui/separator"
import {
Breadcrumb,
BreadcrumbEllipsis,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
} from "@/components/ui/breadcrumb"
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area"
import { CollapsibleArea } from "@/components/collapsible-area"
import { StudiosStrip } from "@/components/studios-strip"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { AspectRatio } from "@/components/ui/aspect-ratio"
import LenisRoot from "@/components/providers/lenis-root"
export const metadata: Metadata = {
title: "Shane Simpson — VFX Artist (Houdini, FX & Lighting)",
description:
"Portfolio of Shane Simpson, a visual effects artist specializing in Houdini, FX, and lighting for film and animation.",
}
// Vimeo numeric IDs provided
const VIMEO = {
fxReelId: "896003602",
lightingReelId: "896017082",
reel2019Id: "427214395",
} as const
const VimeoEmbed = ({ id, title }: { id: string; title: string }) => {
const src = `https://player.vimeo.com/video/${id}`
return (
<div>
<AspectRatio ratio={16 / 9} className="overflow-hidden rounded-lg border bg-muted">
<iframe
title={title}
src={src}
loading="lazy"
allow="autoplay; fullscreen; picture-in-picture"
allowFullScreen
className="h-full w-full"
/>
</AspectRatio>
<div className="mt-2 flex items-center gap-2">
<Button asChild size="sm" className="gap-2">
<a href={src} target="_blank" rel="noopener noreferrer" aria-label={`Open ${title} on Vimeo`}>
<Film className="size-4" aria-hidden="true" />
Open on Vimeo
<ExternalLink className="size-4" aria-hidden="true" />
</a>
</Button>
</div>
</div>
)
}
export default function Page() {
return (
<main>
{/* Top bar with breadcrumb */}
<div className="mx-auto max-w-6xl px-4 pt-6 md:pt-8">
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbLink href="#top">Home</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbPage>Navigation</BreadcrumbPage>
</BreadcrumbItem>
<BreadcrumbEllipsis className="hidden md:flex" />
</BreadcrumbList>
</Breadcrumb>
</div>
<header id="top" className="mx-auto max-w-6xl px-4 pt-4 md:pt-6 pb-6 pt-6 md:pb-8 md:pt-8">
<h1 className="text-balance text-5xl font-bold leading-tight tracking-tight md:text-6xl">
<span className="text-primary">Shane Simpson</span>
</h1>
<p className="text-muted-foreground mt-3 max-w-prose text-lg md:text-xl">
Visual Effects Artist Houdini, FX & Lighting
</p>
<div className="mt-6 flex flex-wrap items-center gap-3">
<Button asChild size="lg" className="gap-2">
<a
href={LINKS.cv}
target="_blank"
rel="noopener noreferrer"
aria-label="Open CV in a new tab"
>
<FileText className="size-4" aria-hidden="true" />
View CV
<ExternalLink className="size-4" aria-hidden="true" />
</a>
</Button>
<Button asChild variant="outline" size="lg" className="gap-2">
<a
href={LINKS.fxReel}
target="_blank"
rel="noopener noreferrer"
aria-label="Open Houdini FX Reel in a new tab"
>
<Film className="size-4" aria-hidden="true" />
Houdini FX Reel
<ExternalLink className="size-4" aria-hidden="true" />
</a>
</Button>
<Button asChild variant="secondary" size="lg" className="gap-2">
<a
href={LINKS.reel2019}
target="_blank"
rel="noopener noreferrer"
aria-label="Open 2019 FX Highlight Reel in a new tab"
>
<Film className="size-4" aria-hidden="true" />
2019 FX Highlight
<ExternalLink className="size-4" aria-hidden="true" />
</a>
</Button>
</div>
</header>
<Separator className="mx-auto max-w-6xl my-6" />
<div className="mt-6 flex flex-wrap items-center gap-3">
</div>
{/* Top navigation (tabs) */}
<section className="mx-auto max-w-6xl px-4 pt-4 md:pt-6">
<Tabs defaultValue="home">
<TabsList className="grid w-full grid-cols-3 md:w-auto">
<TabsTrigger value="home" aria-label="Home">Home</TabsTrigger>
<TabsTrigger value="portfolio" aria-label="Portfolio">Portfolio</TabsTrigger>
<TabsTrigger value="about" aria-label="About and CV">About / CV</TabsTrigger>
</TabsList>
{/* HOME (condensed) */}
<TabsContent value="home">
{/* Hero */}
<div className="grid gap-8 md:grid-cols-12 md:gap-10">
{/* Sticky lede */}
<aside className="md:col-span-5">
<div className="sticky top-24 py-6 md:py-8">
<h2 className="text-pretty text-3xl font-semibold leading-tight md:text-4xl">
From curiosity to craft
</h2>
<p className="text-muted-foreground mt-3 text-base leading-relaxed md:text-lg">
Building images that feel inevitableshaped by physics, story,
and restraint.
</p>
{/* Skills strip (horizontal on small, wrapped on large) */}
<div className="mt-6 md:mt-8">
<ScrollArea className="max-w-full">
<div className="flex w-max items-center gap-2 pb-2 md:w-full md:flex-wrap">
<Badge variant="secondary">Houdini</Badge>
<Badge variant="secondary">FX</Badge>
<Badge variant="secondary">Lighting</Badge>
<Badge variant="outline">Maya</Badge>
</div>
<ScrollBar orientation="horizontal" />
</ScrollArea>
</div>
</div>
</aside>
{/* Condensed right column */}
<article className="space-y-8 md:col-span-7 md:space-y-10 py-6 md:py-8">
<Card>
<CardHeader>
<CardTitle>Highlights</CardTitle>
<CardDescription>
A quick look at recent work and affiliations.
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<VimeoEmbed id={VIMEO.fxReelId} title="Houdini FX Reel" />
<div>
<h3 className="text-sm font-semibold">Studios</h3>
<div className="mt-3">
<StudiosStrip />
</div>
</div>
</CardContent>
</Card>
</article>
</div>
</TabsContent>
{/* PORTFOLIO */}
<TabsContent value="portfolio">
<div className="py-6 md:py-8">
<h2 className="text-pretty text-3xl font-semibold leading-tight md:text-4xl">
Portfolio
</h2>
<p className="text-muted-foreground mt-2">
Selected reels and highlights.
</p>
</div>
<div className="grid gap-8 md:grid-cols-2">
<Card>
<CardHeader>
<CardTitle>Houdini FX Reel</CardTitle>
<CardDescription>Physically grounded FX with production-friendly iteration.</CardDescription>
</CardHeader>
<CardContent>
<VimeoEmbed id={VIMEO.fxReelId} title="Houdini FX Reel" />
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Lighting Reel</CardTitle>
<CardDescription>Selected lighting work across film and cinematics.</CardDescription>
</CardHeader>
<CardContent>
<VimeoEmbed id={VIMEO.lightingReelId} title="Lighting Reel" />
</CardContent>
</Card>
<Card className="md:col-span-2">
<CardHeader>
<CardTitle>2019 FX Highlight</CardTitle>
<CardDescription>Earlier highlights and studies.</CardDescription>
</CardHeader>
<CardContent>
<VimeoEmbed id={VIMEO.reel2019Id} title="2019 FX Highlight Reel" />
</CardContent>
</Card>
</div>
</TabsContent>
{/* ABOUT / CV (full current content) */}
<TabsContent value="about">
{/* Editorial section with sticky left rail */}
<section id="story" className="relative">
<div className="pt-12 pb-20 md:pt-16 md:pb-28">
<div className="grid gap-8 md:grid-cols-12 md:gap-10">
{/* Sticky heading/lede */}
<aside className="md:col-span-5">
<div className="sticky top-24">
<h2 className="text-pretty text-3xl font-semibold leading-tight md:text-4xl">
From curiosity to craft
</h2>
<p className="text-muted-foreground mt-3 text-base leading-relaxed md:text-lg">
Building images that feel inevitableshaped by physics, story,
and restraint.
</p>
{/* Skills strip (horizontal on small, wrapped on large) */}
<div className="mt-6 md:mt-8">
<ScrollArea className="max-w-full">
<div className="flex w-max items-center gap-2 pb-2 md:w-full md:flex-wrap">
<Badge variant="secondary">Houdini</Badge>
<Badge variant="secondary">FX</Badge>
<Badge variant="secondary">Lighting</Badge>
<Badge variant="outline">Maya</Badge>
</div>
<ScrollBar orientation="horizontal" />
</ScrollArea>
</div>
</div>
</aside>
{/* Flowing content right column */}
<article className="space-y-8 md:col-span-7 md:space-y-10">
<Card>
<CardHeader>
<CardTitle>Bio</CardTitle>
<CardDescription>
A career shaped by curiosity, collaboration, and craft.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4 leading-7">
<p>
Maya was the first software I learned in 2008. 2009 began my
time at the School of Visual Art in Manhattan for Computer
Animation and Visual Effects in pursuit of my Bachelor's
of Fine Art degree.
</p>
<p>
Sophomore year was when I started learning Houdini. The same
year I began working my first internship and involving myself
in as many senior thesis projects as possible.
</p>
<p>
By Junior year after two internships under my belt I had
landed my first job for both lighting and FX at an animation
studio called Panda Panther in NYC. From that point and after
graduation in 2013, I continued to freelance from NYC, to LA,
to Hungary and then Canada for companies such as;
</p>
<p className="text-balance">
Aardman Nathan Love, Framestore, MPC, Psyop, Digital Domain,
Digic Pictures, Pixomondo, FuseFX, Method Studios, Blur
Studios, DNeg
</p>
<p>My motivation is to create great work and continue learning.</p>
</CardContent>
</Card>
<Separator />
{/* Right-aligned media block inside card */}
<Card>
<CardHeader>
<CardTitle>Recent work and reels</CardTitle>
<CardDescription>
Selected highlights and studies in motion.
</CardDescription>
</CardHeader>
<CardContent>
<div className="grid items-start gap-6 md:grid-cols-5">
<div className="space-y-4 leading-7 md:col-span-3">
<p>
Focused on physically grounded effects, lighting, and the
interplay between simulation and composition. I favor
readable setups and production-friendly iteration.
</p>
<div className="flex flex-wrap gap-3">
<Button asChild size="sm" className="gap-2">
<a
href={LINKS.fxReel}
target="_blank"
rel="noopener noreferrer"
aria-label="Open Houdini FX Reel in a new tab"
>
<Film className="size-4" aria-hidden="true" />
FX Reel
<ExternalLink className="size-4" aria-hidden="true" />
</a>
</Button>
<Button asChild size="sm" variant="outline" className="gap-2">
<a
href={LINKS.reel2019}
target="_blank"
rel="noopener noreferrer"
aria-label="Open 2019 FX Highlight Reel in a new tab"
>
<Film className="size-4" aria-hidden="true" />
2019 Highlight
<ExternalLink className="size-4" aria-hidden="true" />
</a>
</Button>
<Button asChild size="sm" variant="secondary" className="gap-2">
<a
href={LINKS.cv}
target="_blank"
rel="noopener noreferrer"
aria-label="Open CV in a new tab"
>
<FileText className="size-4" aria-hidden="true" />
CV
<ExternalLink className="size-4" aria-hidden="true" />
</a>
</Button>
</div>
</div>
<div className="md:col-span-2">
<div className="relative aspect-[16/9] w-full overflow-hidden rounded-lg border bg-muted">
<Image
src="/window.svg"
alt=""
fill
className="object-contain p-6"
sizes="(min-width: 768px) 320px, 100vw"
priority
/>
</div>
<p className="text-muted-foreground mt-2 text-xs">
Representative still replace with artwork or film still.
</p>
</div>
</div>
</CardContent>
</Card>
</article>
</div>
</div>
</section>
{/* CV Section */}
<section id="cv" className="relative">
<div className="py-12 md:py-16">
<div className="grid gap-8 md:grid-cols-12 md:gap-10">
{/* Sticky heading/lede for CV */}
<aside className="md:col-span-5">
<div className="sticky top-24">
<h2 className="text-pretty text-3xl font-semibold leading-tight md:text-4xl">
Curriculum Vitae
</h2>
<p className="text-muted-foreground mt-3 text-base leading-relaxed md:text-lg">
Selected experience, skills, and education.
</p>
{/* Section anchors */}
<nav
aria-label="CV sections"
className="mt-6 hidden flex-col gap-2 md:flex"
>
<a
href="#cv-objective"
className="rounded px-1 py-0.5 text-sm text-foreground/80 hover:text-foreground hover:underline focus:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
Objective
</a>
<a
href="#cv-experience"
className="rounded px-1 py-0.5 text-sm text-foreground/80 hover:text-foreground hover:underline focus:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
Experience
</a>
<a
href="#cv-skills"
className="rounded px-1 py-0.5 text-sm text-foreground/80 hover:text-foreground hover:underline focus:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
Skills
</a>
<a
href="#cv-education"
className="rounded px-1 py-0.5 text-sm text-foreground/80 hover:text-foreground hover:underline focus:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
Education
</a>
<a
href="#cv-references"
className="rounded px-1 py-0.5 text-sm text-foreground/80 hover:text-foreground hover:underline focus:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
References
</a>
</nav>
</div>
</aside>
{/* CV content */}
<article className="space-y-8 md:col-span-7 md:space-y-10">
<Card id="cv-objective" className="scroll-mt-28">
<CardHeader>
<CardTitle>Objective</CardTitle>
<CardDescription>
Driven by continuous learning and production-ready workflows.
</CardDescription>
</CardHeader>
<CardContent className="leading-7">
<p>
I strive to provide realistic simulations by continuously learning the
latest tools, softwares, and workflows for working in a fast-paced
production pipeline.
</p>
</CardContent>
</Card>
<Separator />
<Card>
<CardHeader>
<CardTitle>Studios</CardTitle>
<CardDescription>Selected studios Shane has contributed to.</CardDescription>
</CardHeader>
<CardContent>
<StudiosStrip />
</CardContent>
</Card>
<Separator />
<Card id="cv-experience" className="scroll-mt-28">
<CardHeader>
<CardTitle>Experience</CardTitle>
<CardDescription>
Film, TV, and cinematics across leading studios.
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{/* Collapsible list to keep the section concise */}
<CollapsibleArea>
<div className="space-y-6">
{/* ScanlineVFX */}
<section>
<div className="flex flex-col gap-1">
<h3 className="text-base font-semibold">
FX TD and Generalist (Lighting TD) ScanlineVFX
</h3>
<p className="text-xs text-muted-foreground">
Vancouver, BC June 2021 December 2023
</p>
</div>
<ul className="mt-2 list-disc space-y-1 pl-5 text-sm leading-6">
<li>
Worked on films such as The Adam Project, The Grey Man, Black
Adam, and The Meg 2.
</li>
<li>
Created particle and volumetric FX including rigid body dynamics
in Houdini. Transitioned to Lighting, working in 3DS Max.
</li>
</ul>
</section>
<Separator />
{/* ILM */}
<section>
<div className="flex flex-col gap-1">
<h3 className="text-base font-semibold">
FX TD Industrial Light & Magic
</h3>
<p className="text-xs text-muted-foreground">
Vancouver, BC March 2021 May 2021
</p>
</div>
<ul className="mt-2 list-disc space-y-1 pl-5 text-sm leading-6">
<li>Worked on Jurassic World: Dominion.</li>
<li>
Created particle and fire FX, including rigid body dynamics
work.
</li>
</ul>
</section>
<Separator />
{/* DNEG */}
<section>
<div className="flex flex-col gap-1">
<h3 className="text-base font-semibold">FX TD DNEG</h3>
<p className="text-xs text-muted-foreground">
Vancouver, BC February 2020 April 2020
</p>
</div>
<ul className="mt-2 list-disc space-y-1 pl-5 text-sm leading-6">
<li>
Worked on Dune; created volumetric FX in Houdini and rendered
with Clarisse.
</li>
<li>Worked on Ghostbusters; volumetric dust and particle FX.</li>
</ul>
</section>
<Separator />
{/* Blur Studio */}
<section>
<div className="flex flex-col gap-1">
<h3 className="text-base font-semibold">FX Animator Blur Studio</h3>
<p className="text-xs text-muted-foreground">
Los Angeles, CA April 2019 August 2019
</p>
</div>
<ul className="mt-2 list-disc space-y-1 pl-5 text-sm leading-6">
<li>
Created particle and volumetric FX for The Elder Scrolls Online
cinematic.
</li>
<li>
Created particle, volumetric, and rigid body FX for League of
Legends cinematic; FX in Houdini, lit in V-Ray for 3DS Max.
</li>
</ul>
</section>
<Separator />
{/* FuseFX */}
<section>
<div className="flex flex-col gap-1">
<h3 className="text-base font-semibold">Lead FX Artist FuseFX</h3>
<p className="text-xs text-muted-foreground">
Los Angeles & Vancouver, BC Sep 2017 Jan 2018, Sep 2018 Feb 2019
</p>
</div>
<ul className="mt-2 list-disc space-y-1 pl-5 text-sm leading-6">
<li>
Created Houdini FX for The Tick, The Magicians, Lost in Space,
Deadly Class, The 100, and The Orville.
</li>
<li>
Bidded and delivered FX shots for The Magicians S3; reviewed work
and provided feedback.
</li>
<li>
Led insect swarm across 15 shots; smoke, FLIP, and environment FX;
rendered with Mantra.
</li>
<li>Created FX assets exported and tested in 3DS Max.</li>
</ul>
</section>
<Separator />
{/* Pixomondo */}
<section>
<div className="flex flex-col gap-1">
<h3 className="text-base font-semibold">Senior FX Artist Pixomondo</h3>
<p className="text-xs text-muted-foreground">
Vancouver, BC Jan 2017 Sep 2017, Jul 2018 Sep 2018
</p>
</div>
<ul className="mt-2 list-disc space-y-1 pl-5 text-sm leading-6">
<li>
Created large-scale simulations: bullet glass, FLIP, volumetrics
(smoke/dust), particles; lighting/rendering with Mantra and V-Ray.
</li>
<li>
Developed a cloud asset tool used to populate over 35 shots; dynamic
Balloon Spider CFX rig for Goosebumps 2.
</li>
</ul>
</section>
<Separator />
{/* Method Studios */}
<section>
<div className="flex flex-col gap-1">
<h3 className="text-base font-semibold">FX TD Method Studios</h3>
<p className="text-xs text-muted-foreground">
Vancouver, BC Feb 2018 May 2018
</p>
</div>
<ul className="mt-2 list-disc space-y-1 pl-5 text-sm leading-6">
<li>FX asset: bubble force field for The New Mutants.</li>
<li>Various FX assets for Ant-Man and the Wasp.</li>
</ul>
</section>
<Separator />
{/* Digic Pictures */}
<section>
<div className="flex flex-col gap-1">
<h3 className="text-base font-semibold">FX Artist Digic Pictures</h3>
<p className="text-xs text-muted-foreground">
Budapest, Hungary Feb 2016 Dec 2016
</p>
</div>
<ul className="mt-2 list-disc space-y-1 pl-5 text-sm leading-6">
<li>Created FX for Final Fantasy XV: Kingsglaive.</li>
<li>
Worked on several game cinematics including League of Legends; FX
in Houdini and Maya.
</li>
</ul>
</section>
<Separator />
{/* Digital Domain */}
<section>
<div className="flex flex-col gap-1">
<h3 className="text-base font-semibold">
Houdini FX & Lighting Artist Digital Domain
</h3>
<p className="text-xs text-muted-foreground">
Los Angeles Aug 2015 Oct 2015
</p>
</div>
<ul className="mt-2 list-disc space-y-1 pl-5 text-sm leading-6">
<li>Lighting in V-Ray for Maya on Starz Black Sails.</li>
<li>Cloud FX in Houdini rendered with Mantra.</li>
</ul>
</section>
<Separator />
{/* MPC */}
<section>
<div className="flex flex-col gap-1">
<h3 className="text-base font-semibold">
FX & Lighting/Texture Artist MPC NY/LA
</h3>
<p className="text-xs text-muted-foreground">
New York, NY 20132015 (various engagements)
</p>
</div>
<ul className="mt-2 list-disc space-y-1 pl-5 text-sm leading-6">
<li>Commercial FX: nCloth, nParticles, Maya Fluids.</li>
<li>
Assisted lighting using Mental Ray, Arnold; textured environment
assets in Mari.
</li>
</ul>
</section>
<Separator />
{/* Framestore */}
<section>
<div className="flex flex-col gap-1">
<h3 className="text-base font-semibold">
FX & Lighting Artist Framestore NY/LA
</h3>
<p className="text-xs text-muted-foreground">
NY JulAug 2013; LA May 2014 Feb 2015
</p>
</div>
<ul className="mt-2 list-disc space-y-1 pl-5 text-sm leading-6">
<li>
Commercials: 7up Light it up (2015), Julius Baer (2015), Target
Holiday (20142015).
</li>
<li>
Houdini shatter assets and simulations for Mercedes-Benz CLA
Barriers (2013).
</li>
</ul>
</section>
<Separator />
{/* Digital Tutors */}
<section>
<div className="flex flex-col gap-1">
<h3 className="text-base font-semibold">Digital Tutors Author</h3>
<p className="text-xs text-muted-foreground">March 2015 May 2015</p>
</div>
<ul className="mt-2 list-disc space-y-1 pl-5 text-sm leading-6">
<li>
Created series Workflow for Rendering Fur in V-Ray.
</li>
<li>
Covered stylization with texture-driven maps and render optimization
techniques.
</li>
</ul>
</section>
<Separator />
{/* Panda Panther */}
<section>
<div className="flex flex-col gap-1">
<h3 className="text-base font-semibold">
Lead FX Artist & Lighting Panda Panther
</h3>
<p className="text-xs text-muted-foreground">
New York, NY May July 2012
</p>
</div>
<ul className="mt-2 list-disc space-y-1 pl-5 text-sm leading-6">
<li>
Effects and lighting for Spyro: Skylanders using Maya Fluids and
nParticles; lighting in V-Ray.
</li>
</ul>
</section>
</div>
</CollapsibleArea>
</CardContent>
</Card>
<Separator />
<Card id="cv-skills" className="scroll-mt-28">
<CardHeader>
<CardTitle>Skills</CardTitle>
</CardHeader>
<CardContent className="grid gap-4 sm:grid-cols-2">
<div>
<h4 className="text-sm font-semibold">Effects</h4>
<p className="text-sm text-muted-foreground">Houdini, Maya, RealFlow</p>
</div>
<div>
<h4 className="text-sm font-semibold">Lighting / Rendering</h4>
<p className="text-sm text-muted-foreground">
V-Ray, 3DS Max, Mental Ray, Arnold, Krakatoa, Mantra, Redshift
</p>
</div>
<div>
<h4 className="text-sm font-semibold">Compositing</h4>
<p className="text-sm text-muted-foreground">Nuke, After Effects</p>
</div>
<div>
<h4 className="text-sm font-semibold">Texturing</h4>
<p className="text-sm text-muted-foreground">Mudbox, Mari, Photoshop, Illustrator</p>
</div>
<div className="sm:col-span-2">
<h4 className="text-sm font-semibold">Coding</h4>
<p className="text-sm text-muted-foreground">Python, Node.js, Java, TypeScript, C++</p>
</div>
</CardContent>
</Card>
<Separator />
<Card id="cv-education" className="scroll-mt-28">
<CardHeader>
<CardTitle>Education</CardTitle>
</CardHeader>
<CardContent className="leading-7">
<p>
School of Visual Arts BFA, Computer Animation and Visual Effects 20092013
</p>
</CardContent>
</Card>
<Separator />
<Card id="cv-references" className="scroll-mt-28">
<CardHeader>
<CardTitle>References</CardTitle>
</CardHeader>
<CardContent className="leading-7">
<p>Available upon request</p>
</CardContent>
</Card>
</article>
</div>
</div>
</section>
</TabsContent>
</Tabs>
</section>
</main>
)
}

View File

@ -0,0 +1,44 @@
"use client"
import { useState } from "react"
import { Button } from "@/components/ui/button"
import clsx from "clsx"
type CollapsibleAreaProps = {
children: React.ReactNode
collapsedClass?: string
expandLabel?: string
collapseLabel?: string
}
export function CollapsibleArea({
children,
collapsedClass = "max-h-[35vh] md:max-h-[40vh] overflow-hidden",
expandLabel = "Show full CV",
collapseLabel = "Show less",
}: CollapsibleAreaProps) {
const [expanded, setExpanded] = useState(false)
return (
<div className="relative">
<div className={clsx(!expanded && collapsedClass)}>{children}</div>
{!expanded && (
<div className="pointer-events-none absolute inset-x-0 bottom-0 h-20 bg-gradient-to-t from-background to-background/0" />
)}
<div className="mt-4 flex items-center justify-center">
<Button
variant="default"
size="sm"
className="bg-primary text-primary-foreground hover:bg-primary/90"
onClick={() => setExpanded((v) => !v)}
aria-expanded={expanded}
aria-label={expanded ? collapseLabel : expandLabel}
>
{expanded ? collapseLabel : expandLabel}
</Button>
</div>
</div>
)
}

View File

@ -0,0 +1,140 @@
import Link from "next/link";
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
import { Mail, Github, Linkedin, Youtube } from "lucide-react";
import { LINKS } from "@/lib/links";
export default function SiteFooter() {
const year = new Date().getFullYear();
return (
<footer aria-label="Site footer" className="bg-background/0 border-t border-muted/20">
<div className="mx-auto max-w-6xl px-4 py-12">
<div className="grid gap-8 md:grid-cols-3">
<div>
<h3 className="text-lg font-semibold">Shane Simpson</h3>
<p className="mt-2 text-sm text-muted-foreground">
VFX Artist Houdini, FX & Lighting
</p>
<div className="mt-4 flex flex-wrap gap-3">
<Button asChild size="sm">
<a
href={LINKS.cv}
target="_blank"
rel="noopener noreferrer"
aria-label="View CV"
>
View CV
</a>
</Button>
<Button asChild variant="outline" size="sm">
<a
href={LINKS.fxReel}
target="_blank"
rel="noopener noreferrer"
aria-label="View FX reel"
>
FX Reel
</a>
</Button>
</div>
</div>
<div>
<h4 className="text-sm font-medium">Quick links</h4>
<ul className="mt-3 space-y-2 text-sm">
<li>
<Link href="/" className="hover:underline">
Home
</Link>
</li>
<li>
<a href="#portfolio" className="hover:underline">
Portfolio
</a>
</li>
<li>
<a href="#cv" className="hover:underline">
CV
</a>
</li>
<li>
<a href="#contact" className="hover:underline">
Contact
</a>
</li>
</ul>
</div>
<div>
<h4 className="text-sm font-medium">Connect</h4>
<p className="mt-2 text-sm text-muted-foreground">Follow or send a quick message</p>
<div className="mt-3 flex items-center gap-2">
<Button asChild variant="ghost" size="sm">
<a href="mailto:hello@shanesimpson.com" aria-label="Email">
<Mail className="h-4 w-4" />
</a>
</Button>
<Button asChild variant="ghost" size="sm">
<a
href="https://www.imdb.com/name/nm5104608/?ref_=ext_shr_lnk"
target="_blank"
rel="noopener noreferrer"
aria-label="IMDB"
>
<Github className="h-4 w-4" />
</a>
</Button>
<Button asChild variant="ghost" size="sm">
<a
href="https://www.linkedin.com/in/shane-simpson"
target="_blank"
rel="noopener noreferrer"
aria-label="LinkedIn"
>
<Linkedin className="h-4 w-4" />
</a>
</Button>
<Button asChild variant="ghost" size="sm">
<a
href={LINKS.reel2019}
target="_blank"
rel="noopener noreferrer"
aria-label="Vimeo reel"
>
<Youtube className="h-4 w-4" />
</a>
</Button>
</div>
</div>
</div>
<div className="mt-8">
<Separator />
</div>
<div className="mt-6 flex flex-col items-center justify-between gap-3 text-sm text-muted-foreground md:flex-row">
<p>© {year} Shane Simpson. All rights reserved.</p>
<p>
Built with{" "}
<a
href="https://nextjs.org"
className="underline"
target="_blank"
rel="noopener noreferrer"
>
Next.js
</a>{" "}
Designed with Tailwind + shadcn/ui
</p>
</div>
</div>
</footer>
);
}

View File

@ -0,0 +1,105 @@
"use client"
import 'lenis/dist/lenis.css'
import { ReactLenis } from "lenis/react"
import type { LenisRef } from "lenis/react"
import React, { useEffect, useMemo, useRef } from "react"
/**
* LenisRoot mounts a Lenis instance at the document root.
* - Runs only on the client ("use client")
* - Uses reasonable defaults for desktop wheel/touch
* - Respects prefers-reduced-motion by disabling smoothing
*
* Notes:
* - The ReactLenis component exposes a ref (LenisRef) when you need to call methods
* such as scrollTo or raf yourself. We attach a ref here so the instance can be
* inspected during development and is ready for any future integrations.
* - Importing 'lenis/dist/lenis.css' ensures the optional CSS required by Lenis is loaded.
*/
export function LenisRoot() {
const lenisRef = useRef<LenisRef | null>(null)
const prefersReducedMotion =
typeof window !== "undefined" &&
typeof window.matchMedia === "function" &&
window.matchMedia("(prefers-reduced-motion: reduce)").matches
const options = useMemo(
() => ({
// Let Lenis drive its own RAF loop (recommended for ReactLenis)
autoRaf: true,
// Enable smooth wheel scrolling when the user hasn't requested reduced motion
smoothWheel: !prefersReducedMotion,
// Enable a gentle smoothing for touch devices (set true to get smoothing on mobile)
// You can change this to `false` if you prefer native touch scrolling.
smoothTouch: !prefersReducedMotion,
// Enable anchor handling so links to #ids are handled by Lenis
anchors: true as const,
// Interpolation factor (0..1) - lower = more smoothing. When reduced motion is requested, disable smoothing.
lerp: prefersReducedMotion ? 1 : 0.08,
}),
[prefersReducedMotion]
)
useEffect(() => {
// Poll a few times until the ReactLenis ref is populated, then expose the instance
let checks = 0
const maxChecks = 20
const interval = setInterval(() => {
checks += 1
if (lenisRef.current?.lenis) {
// eslint-disable-next-line no-console
console.debug("Lenis initialized", lenisRef.current.lenis)
// Expose on window for quick debugging in devtools
try {
// @ts-expect-error - dev-only debugging helper
window.__lenis = lenisRef.current.lenis
} catch (e) {
/* ignore */
}
// Ensure the instance is running
try {
lenisRef.current.lenis.start?.()
} catch (e) {
/* ignore */
}
clearInterval(interval)
return
}
if (checks >= maxChecks) {
// eslint-disable-next-line no-console
console.warn("Lenis did not initialize within expected time")
clearInterval(interval)
}
}, 100)
return () => {
clearInterval(interval)
}
}, [])
React.useEffect(() => {
const instance = lenisRef.current?.lenis
if (!instance) return
const onScroll = (e: any) => {
// eslint-disable-next-line no-console
console.debug("lenis:scroll", e)
}
instance.on?.("scroll", onScroll)
return () => {
instance.off?.("scroll", onScroll)
}
}, [])
return <ReactLenis root options={options} ref={lenisRef} />
}
export default LenisRoot

View File

@ -0,0 +1,8 @@
"use client"
import type { ThemeProviderProps } from "next-themes"
import { ThemeProvider as NextThemesProvider } from "next-themes"
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
return <NextThemesProvider {...props}>{children}</NextThemesProvider>
}

View File

@ -0,0 +1,43 @@
import { Badge } from "@/components/ui/badge"
type Studio = { name: string }
const STUDIOS: Studio[] = [
{ name: "Scanline VFX" },
{ name: "Industrial Light & Magic" },
{ name: "DNEG" },
{ name: "Blur Studio" },
{ name: "FuseFX" },
{ name: "Pixomondo" },
{ name: "Method Studios" },
{ name: "Digic Pictures" },
{ name: "Digital Domain" },
{ name: "MPC" },
{ name: "Framestore" },
{ name: "Digital Tutors" },
{ name: "Panda Panther" },
]
/**
* StudiosStrip
* Present studios as a wrapping chip grid using shadcn/ui Badge.
* This removes any horizontal scrollbar and looks consistent in light/dark.
*/
export function StudiosStrip() {
return (
<ul className="flex flex-wrap items-center gap-3">
{STUDIOS.map((s) => (
<li key={s.name}>
<Badge
variant="secondary"
className="h-8 rounded-md px-3 text-xs font-medium"
aria-label={s.name}
title={s.name}
>
{s.name}
</Badge>
</li>
))}
</ul>
)
}

View File

@ -0,0 +1,28 @@
"use client"
import { Moon, Sun } from "lucide-react"
import { useTheme } from "next-themes"
import { Button } from "@/components/ui/button"
export function ThemeToggle() {
const { theme, setTheme } = useTheme()
const isDark = theme === "dark"
const handleToggle = () => {
setTheme(isDark ? "light" : "dark")
}
return (
<Button
variant="ghost"
size="icon"
aria-label="Toggle theme"
onClick={handleToggle}
>
<Sun className="size-5 rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
<Moon className="absolute size-5 rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
<span className="sr-only">Toggle theme</span>
</Button>
)
}

View File

@ -0,0 +1,11 @@
"use client"
import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio"
function AspectRatio({
...props
}: React.ComponentProps<typeof AspectRatioPrimitive.Root>) {
return <AspectRatioPrimitive.Root data-slot="aspect-ratio" {...props} />
}
export { AspectRatio }

View File

@ -0,0 +1,46 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
secondary:
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
destructive:
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Badge({
className,
variant,
asChild = false,
...props
}: React.ComponentProps<"span"> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "span"
return (
<Comp
data-slot="badge"
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
)
}
export { Badge, badgeVariants }

View File

@ -0,0 +1,109 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { ChevronRight, MoreHorizontal } from "lucide-react"
import { cn } from "@/lib/utils"
function Breadcrumb({ ...props }: React.ComponentProps<"nav">) {
return <nav aria-label="breadcrumb" data-slot="breadcrumb" {...props} />
}
function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
return (
<ol
data-slot="breadcrumb-list"
className={cn(
"text-muted-foreground flex flex-wrap items-center gap-1.5 text-sm break-words sm:gap-2.5",
className
)}
{...props}
/>
)
}
function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) {
return (
<li
data-slot="breadcrumb-item"
className={cn("inline-flex items-center gap-1.5", className)}
{...props}
/>
)
}
function BreadcrumbLink({
asChild,
className,
...props
}: React.ComponentProps<"a"> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot : "a"
return (
<Comp
data-slot="breadcrumb-link"
className={cn("hover:text-foreground transition-colors", className)}
{...props}
/>
)
}
function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
data-slot="breadcrumb-page"
role="link"
aria-disabled="true"
aria-current="page"
className={cn("text-foreground font-normal", className)}
{...props}
/>
)
}
function BreadcrumbSeparator({
children,
className,
...props
}: React.ComponentProps<"li">) {
return (
<li
data-slot="breadcrumb-separator"
role="presentation"
aria-hidden="true"
className={cn("[&>svg]:size-3.5", className)}
{...props}
>
{children ?? <ChevronRight />}
</li>
)
}
function BreadcrumbEllipsis({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="breadcrumb-ellipsis"
role="presentation"
aria-hidden="true"
className={cn("flex size-9 items-center justify-center", className)}
{...props}
>
<MoreHorizontal className="size-4" />
<span className="sr-only">More</span>
</span>
)
}
export {
Breadcrumb,
BreadcrumbList,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbPage,
BreadcrumbSeparator,
BreadcrumbEllipsis,
}

View File

@ -0,0 +1,58 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant,
size,
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot : "button"
return (
<Comp
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }

View File

@ -0,0 +1,92 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Card({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card"
className={cn(
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
className
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn("leading-none font-semibold", className)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-6", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}

View File

@ -0,0 +1,58 @@
"use client"
import * as React from "react"
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
import { cn } from "@/lib/utils"
function ScrollArea({
className,
children,
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
return (
<ScrollAreaPrimitive.Root
data-slot="scroll-area"
className={cn("relative", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport
data-slot="scroll-area-viewport"
className="focus-visible:ring-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1"
>
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
)
}
function ScrollBar({
className,
orientation = "vertical",
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
return (
<ScrollAreaPrimitive.ScrollAreaScrollbar
data-slot="scroll-area-scrollbar"
orientation={orientation}
className={cn(
"flex touch-none p-px transition-colors select-none",
orientation === "vertical" &&
"h-full w-2.5 border-l border-l-transparent",
orientation === "horizontal" &&
"h-2.5 flex-col border-t border-t-transparent",
className
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb
data-slot="scroll-area-thumb"
className="bg-border relative flex-1 rounded-full"
/>
</ScrollAreaPrimitive.ScrollAreaScrollbar>
)
}
export { ScrollArea, ScrollBar }

View File

@ -0,0 +1,28 @@
"use client"
import * as React from "react"
import * as SeparatorPrimitive from "@radix-ui/react-separator"
import { cn } from "@/lib/utils"
function Separator({
className,
orientation = "horizontal",
decorative = true,
...props
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
return (
<SeparatorPrimitive.Root
data-slot="separator"
decorative={decorative}
orientation={orientation}
className={cn(
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
className
)}
{...props}
/>
)
}
export { Separator }

View File

@ -0,0 +1,66 @@
"use client"
import * as React from "react"
import * as TabsPrimitive from "@radix-ui/react-tabs"
import { cn } from "@/lib/utils"
function Tabs({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
return (
<TabsPrimitive.Root
data-slot="tabs"
className={cn("flex flex-col gap-2", className)}
{...props}
/>
)
}
function TabsList({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.List>) {
return (
<TabsPrimitive.List
data-slot="tabs-list"
className={cn(
"bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px]",
className
)}
{...props}
/>
)
}
function TabsTrigger({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
return (
<TabsPrimitive.Trigger
data-slot="tabs-trigger"
className={cn(
"data-[state=active]:bg-background dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function TabsContent({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
return (
<TabsPrimitive.Content
data-slot="tabs-content"
className={cn("flex-1 outline-none", className)}
{...props}
/>
)
}
export { Tabs, TabsList, TabsTrigger, TabsContent }

5
src/lib/links.ts Normal file
View File

@ -0,0 +1,5 @@
export const LINKS = {
cv: "https://docs.google.com/document/u/1/d/e/2PACX-1vTj3VH5MtXGwEVhFKKAfYCqtGrY1Lu_EU53DLL7gjb7gfqo6g3wZLNVjanOiCn3BCKbsAoE987l9rug/pub",
reel2019: "https://vimeo.com/427214395?fl=pl&fe=vl",
fxReel: "https://vimeo.com/shanesimpson/fx-reel?share=copy",
} as const

6
src/lib/utils.ts Normal file
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))
}

2
src/types/css.d.ts vendored Normal file
View File

@ -0,0 +1,2 @@
// Allow importing global CSS (e.g., './globals.css') in TypeScript with bundler moduleResolution
declare module "*.css" {}

27
tsconfig.json Normal file
View File

@ -0,0 +1,27 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", "**/*.d.ts", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}