├── README.md ├── public ├── og.jpg ├── favicon.ico ├── img │ ├── ar.webp │ ├── pool.webp │ ├── prev.webp │ ├── fancy.webp │ ├── gazelle.webp │ ├── lock-in.webp │ ├── skanzen.webp │ ├── halftone.webp │ ├── unbaited.webp │ ├── vodafone.webp │ ├── codrops2025.webp │ ├── streiflicht.webp │ ├── terrestrial.webp │ └── bolt-hackathon2025.webp ├── favicon-16x16.png ├── favicon-32x32.png ├── apple-touch-icon.png ├── android-chrome-192x192.png ├── android-chrome-512x512.png ├── fonts │ └── Satoshi-Variable.woff2 └── site.webmanifest ├── next.config.ts ├── postcss.config.mjs ├── eslint.config.mjs ├── src ├── lib │ └── utils.ts ├── components │ ├── loading.tsx │ ├── scramble-combined.tsx │ ├── scramble-combined-pair.tsx │ ├── newsletter.tsx │ ├── scramble-in.tsx │ └── scramble-hover.tsx ├── config │ └── site.ts ├── app │ ├── globals.css │ ├── api │ │ ├── unsubscribe │ │ │ └── route.ts │ │ └── subscribe │ │ │ └── route.ts │ ├── layout.tsx │ ├── unsubscribe │ │ └── page.tsx │ └── page.tsx ├── hooks │ ├── use-element-position.tsx │ └── use-mouse-position-ref.tsx └── data │ └── content.ts ├── .gitignore ├── tailwind.config.ts ├── tsconfig.json ├── package.json └── emails └── welcome-email.tsx /README.md: -------------------------------------------------------------------------------- 1 | Portfolio for danielpetho.com -------------------------------------------------------------------------------- /public/og.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danielpetho/portfolio-2025/HEAD/public/og.jpg -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danielpetho/portfolio-2025/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /public/img/ar.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danielpetho/portfolio-2025/HEAD/public/img/ar.webp -------------------------------------------------------------------------------- /public/img/pool.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danielpetho/portfolio-2025/HEAD/public/img/pool.webp -------------------------------------------------------------------------------- /public/img/prev.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danielpetho/portfolio-2025/HEAD/public/img/prev.webp -------------------------------------------------------------------------------- /public/img/fancy.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danielpetho/portfolio-2025/HEAD/public/img/fancy.webp -------------------------------------------------------------------------------- /public/img/gazelle.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danielpetho/portfolio-2025/HEAD/public/img/gazelle.webp -------------------------------------------------------------------------------- /public/img/lock-in.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danielpetho/portfolio-2025/HEAD/public/img/lock-in.webp -------------------------------------------------------------------------------- /public/img/skanzen.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danielpetho/portfolio-2025/HEAD/public/img/skanzen.webp -------------------------------------------------------------------------------- /public/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danielpetho/portfolio-2025/HEAD/public/favicon-16x16.png -------------------------------------------------------------------------------- /public/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danielpetho/portfolio-2025/HEAD/public/favicon-32x32.png -------------------------------------------------------------------------------- /public/img/halftone.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danielpetho/portfolio-2025/HEAD/public/img/halftone.webp -------------------------------------------------------------------------------- /public/img/unbaited.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danielpetho/portfolio-2025/HEAD/public/img/unbaited.webp -------------------------------------------------------------------------------- /public/img/vodafone.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danielpetho/portfolio-2025/HEAD/public/img/vodafone.webp -------------------------------------------------------------------------------- /public/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danielpetho/portfolio-2025/HEAD/public/apple-touch-icon.png -------------------------------------------------------------------------------- /public/img/codrops2025.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danielpetho/portfolio-2025/HEAD/public/img/codrops2025.webp -------------------------------------------------------------------------------- /public/img/streiflicht.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danielpetho/portfolio-2025/HEAD/public/img/streiflicht.webp -------------------------------------------------------------------------------- /public/img/terrestrial.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danielpetho/portfolio-2025/HEAD/public/img/terrestrial.webp -------------------------------------------------------------------------------- /public/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danielpetho/portfolio-2025/HEAD/public/android-chrome-192x192.png -------------------------------------------------------------------------------- /public/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danielpetho/portfolio-2025/HEAD/public/android-chrome-512x512.png -------------------------------------------------------------------------------- /public/fonts/Satoshi-Variable.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danielpetho/portfolio-2025/HEAD/public/fonts/Satoshi-Variable.woff2 -------------------------------------------------------------------------------- /public/img/bolt-hackathon2025.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danielpetho/portfolio-2025/HEAD/public/img/bolt-hackathon2025.webp -------------------------------------------------------------------------------- /next.config.ts: -------------------------------------------------------------------------------- 1 | import type { NextConfig } from "next"; 2 | 3 | const nextConfig: NextConfig = { 4 | /* config options here */ 5 | }; 6 | 7 | export default nextConfig; 8 | -------------------------------------------------------------------------------- /postcss.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('postcss-load-config').Config} */ 2 | const config = { 3 | plugins: { 4 | tailwindcss: {}, 5 | }, 6 | }; 7 | 8 | export default config; 9 | -------------------------------------------------------------------------------- /public/site.webmanifest: -------------------------------------------------------------------------------- 1 | {"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"} -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import { dirname } from "path"; 2 | import { fileURLToPath } from "url"; 3 | import { FlatCompat } from "@eslint/eslintrc"; 4 | 5 | const __filename = fileURLToPath(import.meta.url); 6 | const __dirname = dirname(__filename); 7 | 8 | const compat = new FlatCompat({ 9 | baseDirectory: __dirname, 10 | }); 11 | 12 | const eslintConfig = [ 13 | ...compat.extends("next/core-web-vitals", "next/typescript"), 14 | ]; 15 | 16 | export default eslintConfig; 17 | -------------------------------------------------------------------------------- /src/lib/utils.ts: -------------------------------------------------------------------------------- 1 | import { type ClassValue, clsx } from "clsx" 2 | import { twMerge } from "tailwind-merge" 3 | 4 | export function cn(...inputs: ClassValue[]) { 5 | return twMerge(clsx(inputs)) 6 | } 7 | 8 | export const ROW_DELAY = 30; 9 | export const SCRAMBLE_SPEED = 27; 10 | export const SCRAMBLED_LETTER_COUNT = 5; 11 | 12 | export const getAnimationDuration = (text: string) => { 13 | return Math.min((text.length - SCRAMBLED_LETTER_COUNT) * SCRAMBLE_SPEED, 100); 14 | }; -------------------------------------------------------------------------------- /src/components/loading.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useState, useEffect } from "react"; 4 | 5 | export default function LoadingSpinner() { 6 | const [loadingIcon, setLoadingIcon] = useState("◴"); 7 | 8 | useEffect(() => { 9 | const icons = ["◴", "◷", "◶", "◵"]; 10 | let i = 0; 11 | const interval = setInterval(() => { 12 | i = (i + 1) % icons.length; 13 | setLoadingIcon(icons[i]); 14 | }, 100); 15 | 16 | return () => clearInterval(interval); 17 | }, []); 18 | 19 | return {loadingIcon}; 20 | } -------------------------------------------------------------------------------- /src/config/site.ts: -------------------------------------------------------------------------------- 1 | export const siteConfig = { 2 | name: "daniel petho — design engineer", 3 | url: "https://danielpetho.com", 4 | ogImage: "https://danielpetho.com/og.jpg", 5 | // description: 6 | // "Daniel Petho", 7 | links: { 8 | twitter: "https://twitter.com/nonzeroexitcode", 9 | github: "https://github.com/danielpetho", 10 | linkedin: "https://www.linkedin.com/in/danielpetho", 11 | email: "hello@danielpetho.com", 12 | }, 13 | }; 14 | 15 | export type SiteConfig = typeof siteConfig; 16 | -------------------------------------------------------------------------------- /src/app/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | :root { 6 | --background: #ffffff; 7 | --foreground: #171717; 8 | } 9 | 10 | @media (prefers-color-scheme: dark) { 11 | :root { 12 | --background: #0a0a0a; 13 | --foreground: #ededed; 14 | } 15 | } 16 | 17 | body { 18 | color: var(--foreground); 19 | background: var(--background); 20 | } 21 | 22 | @font-face { 23 | font-family: "Satoshi"; 24 | src: url("/fonts/Satoshi-Variable.woff2") format("woff2-variations"); 25 | font-weight: 100 900; 26 | font-style: normal; 27 | font-display: swap; 28 | } 29 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.* 7 | .yarn/* 8 | !.yarn/patches 9 | !.yarn/plugins 10 | !.yarn/releases 11 | !.yarn/versions 12 | 13 | # testing 14 | /coverage 15 | 16 | # next.js 17 | /.next/ 18 | /out/ 19 | 20 | # production 21 | /build 22 | 23 | # misc 24 | .DS_Store 25 | *.pem 26 | 27 | # debug 28 | npm-debug.log* 29 | yarn-debug.log* 30 | yarn-error.log* 31 | .pnpm-debug.log* 32 | 33 | # env files (can opt-in for committing if needed) 34 | .env* 35 | 36 | # vercel 37 | .vercel 38 | 39 | # typescript 40 | *.tsbuildinfo 41 | next-env.d.ts 42 | -------------------------------------------------------------------------------- /tailwind.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from "tailwindcss"; 2 | 3 | export default { 4 | content: [ 5 | "./src/pages/**/*.{js,ts,jsx,tsx,mdx}", 6 | "./src/components/**/*.{js,ts,jsx,tsx,mdx}", 7 | "./src/app/**/*.{js,ts,jsx,tsx,mdx}", 8 | ], 9 | theme: { 10 | screens: { 11 | 'sm': '600px', 12 | 'md': '820px', 13 | 'lg': '1024px', 14 | 'xl': '1280px', 15 | '2xl': '1536px', 16 | }, 17 | extend: { 18 | colors: { 19 | background: "var(--background)", 20 | foreground: "var(--foreground)", 21 | }, 22 | fontFamily: { 23 | satoshi: ["Satoshi", "sans-serif"], 24 | }, 25 | }, 26 | }, 27 | plugins: [], 28 | } satisfies Config; 29 | -------------------------------------------------------------------------------- /src/hooks/use-element-position.tsx: -------------------------------------------------------------------------------- 1 | import { RefObject, useEffect, useState } from "react"; 2 | 3 | export const useElementPosition = (elementRef: RefObject) => { 4 | const [position, setPosition] = useState(null); 5 | 6 | useEffect(() => { 7 | const updatePosition = () => { 8 | if (elementRef.current) { 9 | setPosition(elementRef.current.getBoundingClientRect()); 10 | } 11 | }; 12 | 13 | // Initial position 14 | updatePosition(); 15 | 16 | // Update on scroll and resize 17 | window.addEventListener("scroll", updatePosition); 18 | window.addEventListener("resize", updatePosition); 19 | 20 | return () => { 21 | window.removeEventListener("scroll", updatePosition); 22 | window.removeEventListener("resize", updatePosition); 23 | }; 24 | }, [elementRef]); 25 | 26 | return position; 27 | }; 28 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2017", 4 | "lib": [ 5 | "dom", 6 | "dom.iterable", 7 | "esnext" 8 | ], 9 | "allowJs": true, 10 | "skipLibCheck": true, 11 | "strict": true, 12 | "noEmit": true, 13 | "esModuleInterop": true, 14 | "module": "esnext", 15 | "moduleResolution": "bundler", 16 | "resolveJsonModule": true, 17 | "isolatedModules": true, 18 | "jsx": "react-jsx", 19 | "incremental": true, 20 | "plugins": [ 21 | { 22 | "name": "next" 23 | } 24 | ], 25 | "paths": { 26 | "@/*": [ 27 | "./src/*" 28 | ] 29 | } 30 | }, 31 | "include": [ 32 | "next-env.d.ts", 33 | "**/*.ts", 34 | "**/*.tsx", 35 | ".next/types/**/*.ts", 36 | ".next/dev/types/**/*.ts" 37 | ], 38 | "exclude": [ 39 | "node_modules" 40 | ] 41 | } 42 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "portfolio", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev --turbopack", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint", 10 | "email": "email dev" 11 | }, 12 | "dependencies": { 13 | "@react-email/components": "^0.0.33", 14 | "@vercel/analytics": "^1.4.1", 15 | "clsx": "^2.1.1", 16 | "motion": "^11.15.0", 17 | "next": "^16.0.7", 18 | "react": "^19.2.1", 19 | "react-dom": "^19.2.1", 20 | "resend": "^4.1.2", 21 | "tailwind-merge": "^2.5.5" 22 | }, 23 | "devDependencies": { 24 | "@eslint/eslintrc": "^3", 25 | "@react-email/preview": "^0.0.12", 26 | "@types/node": "^20", 27 | "@types/react": "^19.2.7", 28 | "@types/react-dom": "^19.2.3", 29 | "eslint": "^9", 30 | "eslint-config-next": "^16.0.7", 31 | "postcss": "^8", 32 | "react-email": "3.0.7", 33 | "tailwindcss": "^3.4.1", 34 | "typescript": "^5" 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/app/api/unsubscribe/route.ts: -------------------------------------------------------------------------------- 1 | import { NextResponse } from 'next/server' 2 | import { Resend } from 'resend' 3 | 4 | const resend = new Resend(process.env.RESEND_API_KEY) 5 | const AUDIENCE_ID = process.env.RESEND_AUDIENCE_ID // Make sure to add this to your env variables 6 | 7 | export async function GET(request: Request) { 8 | const { searchParams } = new URL(request.url) 9 | const email = searchParams.get('email') 10 | 11 | if (!email) { 12 | return NextResponse.json( 13 | { error: 'Email parameter is required' }, 14 | { status: 400 } 15 | ) 16 | } 17 | 18 | return NextResponse.redirect( 19 | `http://danielpetho.com/unsubscribe?email=${email}` 20 | ) 21 | } 22 | 23 | export async function POST(request: Request) { 24 | try { 25 | const { email } = await request.json() 26 | 27 | if (!email) { 28 | return NextResponse.json( 29 | { error: 'Email is required' }, 30 | { status: 400 } 31 | ) 32 | } 33 | 34 | await resend.contacts.remove({ 35 | email, 36 | audienceId: AUDIENCE_ID!, 37 | }) 38 | 39 | return NextResponse.json( 40 | { message: 'Successfully unsubscribed' }, 41 | { status: 200 } 42 | ) 43 | } catch (error) { 44 | console.error('Unsubscribe error:', error) 45 | return NextResponse.json( 46 | { error: 'Failed to unsubscribe' }, 47 | { status: 500 } 48 | ) 49 | } 50 | } -------------------------------------------------------------------------------- /src/hooks/use-mouse-position-ref.tsx: -------------------------------------------------------------------------------- 1 | import { RefObject, useEffect, useRef } from "react" 2 | 3 | export const useMousePositionRef = ( 4 | containerRef?: RefObject 5 | ) => { 6 | const positionRef = useRef({ x: 0, y: 0 }) 7 | 8 | useEffect(() => { 9 | const updatePosition = (x: number, y: number) => { 10 | if (containerRef && containerRef.current) { 11 | const rect = containerRef.current.getBoundingClientRect() 12 | const relativeX = x - rect.left 13 | const relativeY = y - rect.top 14 | 15 | // Calculate relative position even when outside the container 16 | positionRef.current = { x: relativeX, y: relativeY } 17 | } else { 18 | positionRef.current = { x, y } 19 | } 20 | } 21 | 22 | const handleMouseMove = (ev: MouseEvent) => { 23 | updatePosition(ev.clientX, ev.clientY) 24 | } 25 | 26 | const handleTouchMove = (ev: TouchEvent) => { 27 | const touch = ev.touches[0] 28 | updatePosition(touch.clientX, touch.clientY) 29 | } 30 | 31 | // Listen for both mouse and touch events 32 | window.addEventListener("mousemove", handleMouseMove) 33 | window.addEventListener("touchmove", handleTouchMove) 34 | 35 | return () => { 36 | window.removeEventListener("mousemove", handleMouseMove) 37 | window.removeEventListener("touchmove", handleTouchMove) 38 | } 39 | }, [containerRef]) 40 | 41 | return positionRef 42 | } 43 | -------------------------------------------------------------------------------- /src/components/scramble-combined.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useState, forwardRef } from "react"; 4 | import ScrambleHover from "./scramble-hover"; 5 | import ScrambleIn, { ScrambleInHandle } from "./scramble-in"; 6 | 7 | interface ScrambleCombinedProps { 8 | children: React.ReactNode; 9 | delay?: number; 10 | scrambleSpeed?: number; 11 | scrambledLetterCount?: number; 12 | characters?: string; 13 | className?: string; 14 | } 15 | 16 | const ScrambleCombined = forwardRef( 17 | ( 18 | { 19 | children, 20 | delay = 0, 21 | scrambleSpeed = 50, 22 | scrambledLetterCount = 5, 23 | characters = "abcdefghijklmnopqrstuvwxyz!@#$%^&*()_+", 24 | className, 25 | }, 26 | ref 27 | ) => { 28 | const [isInComplete, setIsInComplete] = useState(false); 29 | 30 | return isInComplete ? ( 31 | 38 | {children} 39 | 40 | ) : ( 41 | setIsInComplete(true)} 49 | > 50 | {children} 51 | 52 | ); 53 | } 54 | ); 55 | 56 | ScrambleCombined.displayName = "ScrambleCombined"; 57 | export default ScrambleCombined; -------------------------------------------------------------------------------- /src/app/layout.tsx: -------------------------------------------------------------------------------- 1 | import { Analytics } from '@vercel/analytics/next'; 2 | import type { Metadata } from "next"; 3 | import { siteConfig } from "@/config/site"; 4 | import "./globals.css"; 5 | 6 | export const metadata: Metadata = { 7 | title: { 8 | default: siteConfig.name, 9 | template: `%s - ${siteConfig.name}`, 10 | }, 11 | metadataBase: new URL(siteConfig.url), 12 | //description: siteConfig.description, 13 | keywords: [ 14 | "Design", 15 | "Technology", 16 | "Design Engineer", 17 | ], 18 | authors: [ 19 | { 20 | name: "Daniel Petho", 21 | url: "https://danielpetho.com", 22 | }, 23 | ], 24 | creator: "danielpetho", 25 | openGraph: { 26 | type: "website", 27 | locale: "en_US", 28 | url: siteConfig.url, 29 | title: siteConfig.name, 30 | //description: siteConfig.description, 31 | siteName: siteConfig.name, 32 | images: [ 33 | { 34 | url: siteConfig.ogImage, 35 | width: 1200, 36 | height: 630, 37 | alt: siteConfig.name, 38 | }, 39 | ], 40 | }, 41 | twitter: { 42 | card: "summary_large_image", 43 | title: siteConfig.name, 44 | //description: siteConfig.description, 45 | images: [siteConfig.ogImage], 46 | creator: "@nonzeroexitcode", 47 | }, 48 | icons: { 49 | icon: "/favicon.ico", 50 | shortcut: "/favicon-16x16.png", 51 | apple: "/apple-touch-icon.png", 52 | }, 53 | manifest: `${siteConfig.url}/site.webmanifest`, 54 | }; 55 | 56 | export default function RootLayout({ 57 | children, 58 | }: Readonly<{ 59 | children: React.ReactNode; 60 | }>) { 61 | return ( 62 | 63 | 66 | {children} 67 | 68 | 69 | 70 | ); 71 | } 72 | -------------------------------------------------------------------------------- /src/app/api/subscribe/route.ts: -------------------------------------------------------------------------------- 1 | import { Resend } from "resend"; 2 | import { NextResponse } from "next/server"; 3 | import { WelcomeEmail } from "../../../../emails/welcome-email"; 4 | 5 | const resend = new Resend(process.env.RESEND_API_KEY); 6 | 7 | export async function POST(request: Request) { 8 | try { 9 | // For testing purposes 10 | const testEmail = "delivered@resend.dev"; 11 | 12 | const audienceId = process.env.RESEND_AUDIENCE_ID!; 13 | 14 | // Get email from request body for production use 15 | const { email } = await request.json(); 16 | 17 | if (!email) { 18 | return NextResponse.json( 19 | { error: "Email is required" }, 20 | { status: 400 } 21 | ); 22 | } 23 | 24 | // Use test email for testing, real email for production 25 | const emailToUse = process.env.NODE_ENV === "development" ? testEmail : email; 26 | const existingContacts = await resend.contacts.list({ audienceId }); 27 | const contactExists = existingContacts?.data?.data.some( 28 | (contact: { email: string }) => contact.email.toLowerCase() === emailToUse.toLowerCase() 29 | ); 30 | 31 | console.log("contactExists", contactExists); 32 | 33 | if (contactExists) { 34 | return NextResponse.json( 35 | { message: "Successfully subscribed" }, 36 | { status: 200 } 37 | ); 38 | } 39 | 40 | await resend.contacts.create({ 41 | email: emailToUse, 42 | audienceId: audienceId, 43 | unsubscribed: false 44 | }); 45 | 46 | // Send welcome email 47 | await resend.emails.send({ 48 | from: 'daniel ', 49 | to: emailToUse, 50 | subject: 'welcome', 51 | react: WelcomeEmail({email: emailToUse}), 52 | }); 53 | 54 | return NextResponse.json( 55 | { message: "Successfully subscribed" }, 56 | { status: 200 } 57 | ); 58 | 59 | } catch (error) { 60 | console.error('Unsubscribe error:', error) 61 | return NextResponse.json( 62 | { error: "Error subscribing to newsletter"}, 63 | { status: 500 } 64 | ); 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/app/unsubscribe/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { Suspense, useState } from "react"; 4 | import { useSearchParams } from "next/navigation"; 5 | import LoadingSpinner from "@/components/loading"; 6 | 7 | const UnsubscribeContent = () => { 8 | const searchParams = useSearchParams(); 9 | const email = searchParams.get("email"); 10 | const [status, setStatus] = useState< 11 | "idle" | "loading" | "success" | "error" 12 | >("idle"); 13 | 14 | const handleUnsubscribe = async () => { 15 | if (!email) return; 16 | 17 | setStatus("loading"); 18 | 19 | try { 20 | const response = await fetch("/api/unsubscribe", { 21 | method: "POST", 22 | headers: { 23 | "Content-Type": "application/json", 24 | }, 25 | body: JSON.stringify({ email }), 26 | }); 27 | 28 | if (!response.ok) { 29 | throw new Error("Failed to unsubscribe"); 30 | } 31 | 32 | setStatus("success"); 33 | } catch (err) { 34 | console.error("Error unsubscribing:", err); 35 | setStatus("error"); 36 | } 37 | }; 38 | 39 | if (!email) { 40 | return ( 41 |
42 |
43 |

no email address provided.

44 |

45 | if you have problems unsubscribing, please contact me at{" "} 46 | hi@danielpetho.com. 47 |

48 |
49 |
50 | ); 51 | } 52 | 53 | return ( 54 |
55 |
56 | {status === "idle" && ( 57 | <> 58 | click here to 59 | 65 | 66 | )} 67 | 68 | {status === "loading" && } 69 | 70 | {status === "success" && done ✓} 71 | 72 | {status === "error" && ( 73 |
74 |

an error occurred while unsubscribing ⚠

75 |

76 | please contact me at{" "} 77 | 81 | hi@danielpetho.com 82 | 83 | . 84 |

85 |
86 | )} 87 |
88 |
89 | ); 90 | }; 91 | 92 | export default function UnsubscribePage() { 93 | return ( 94 | 97 | 98 | 99 | } 100 | > 101 | 102 | 103 | ); 104 | } 105 | -------------------------------------------------------------------------------- /src/data/content.ts: -------------------------------------------------------------------------------- 1 | export const experiences = [ 2 | { 3 | title: "design engineer, studio nand", 4 | year: "‘24-‘25", 5 | links: "https://nand.io" 6 | }, 7 | { 8 | title: "ar developer, departd", 9 | year: "‘23-‘24", 10 | links: "https://departd.de" 11 | }, 12 | { 13 | title: "creative technologist, xorxor", 14 | year: "‘21-‘23", 15 | links: "https://xorxor.hu" 16 | }, 17 | { 18 | title: "tech & research intern, ericsson", 19 | year: "‘19-‘20", 20 | links: "https://www.ericsson.hu" 21 | } 22 | ]; 23 | 24 | export const projects = [ 25 | { 26 | title: "fancycomponents.dev", 27 | year: "now", 28 | links: "https://fancycomponents.dev", 29 | img: "./img/fancy.webp" 30 | }, 31 | { 32 | title: "cmyk halftone emulator", 33 | year: "2025", 34 | links: "https://cmyk.danielpetho.com", 35 | img: "./img/halftone.webp" 36 | }, 37 | { 38 | title: "mediapipe drawing * bolt hackathon", 39 | year: "2025", 40 | links: "https://devpost.com/software/mediapipe-drawing/", 41 | img: "./img/bolt-hackathon2025.webp" 42 | }, 43 | { 44 | title: "motion along path * codrops", 45 | year: "2025", 46 | links: "https://tympanus.net/codrops/2025/06/17/building-an-infinite-marquee-along-an-svg-path-with-react-motion/", 47 | img: "./img/codrops2025.webp" 48 | }, 49 | { 50 | title: "unbaited", 51 | year: "2025", 52 | links: "https://unbaited.danielpetho.com", 53 | img: "./img/unbaited.webp" 54 | }, 55 | { 56 | title: "lock in 2025", 57 | year: "2024", 58 | links: "https://lock-in.danielpetho.com", 59 | img: "./img/lock-in.webp" 60 | }, 61 | { 62 | title: "ar filters", 63 | year: "2024", 64 | links: "https://2024.danielpetho.com/lab/filters", 65 | img: "./img/ar.webp" 66 | }, 67 | { 68 | title: "prev. portfolio", 69 | year: "2024", 70 | links: "https://2024.danielpetho.com", 71 | img: "./img/prev.webp" 72 | }, 73 | { 74 | title: "pool group * xorxor", 75 | year: "2023", 76 | links: "https://medencecsoport.hu/", 77 | img: "./img/pool.webp" 78 | }, 79 | { 80 | title: "vodafone lobby * xorxor", 81 | year: "2023", 82 | links: "https://xorxor.hu/projects/vodafone-dataviz.html", 83 | img: "./img/vodafone.webp" 84 | }, 85 | { 86 | title: "terrestrial * generative hut", 87 | year: "2022", 88 | links: "https://github.com/danielpetho/terrestrial-fxhash-tutorial", 89 | img: "./img/terrestrial.webp" 90 | }, 91 | { 92 | title: "skanzen transylvania * xorxor", 93 | year: "2022", 94 | links: "https://xorxor.hu/projects/Skanzen.html", 95 | img: "./img/skanzen.webp" 96 | }, 97 | { 98 | title: "gazelle flip — video * extrawelt ", 99 | year: "2021", 100 | links: "https://www.youtube.com/watch?v=NY1mWQIqTBU", 101 | img: "./img/gazelle.webp" 102 | }, 103 | { 104 | title: "streiflicht — video * extrawelt", 105 | year: "2021", 106 | links: "https://www.youtube.com/watch?v=Cft0Ea__Ezs", 107 | img: "./img/streiflicht.webp" 108 | } 109 | ]; 110 | 111 | export const socials = [ 112 | { 113 | name: "x/twitter", 114 | links: "https://twitter.com/nonzeroexitcode" 115 | }, 116 | { 117 | name: "github", 118 | links: "https://github.com/danielpetho" 119 | }, 120 | { 121 | name: "instagram", 122 | links: "https://instagram.com/nonzeroexitcode" 123 | }, 124 | { 125 | name: "bluesky", 126 | links: "https://bsky.app/profile/danielpetho.com" 127 | }, 128 | { 129 | name: "threads", 130 | links: "https://threads.net/@nonzeroexitcode" 131 | }, 132 | { 133 | name: "linkedin", 134 | links: "https://linkedin.com/in/kpethodaniel" 135 | }, 136 | ]; -------------------------------------------------------------------------------- /src/components/scramble-combined-pair.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useState, forwardRef } from "react"; 4 | import ScrambleHover from "./scramble-hover"; 5 | import ScrambleIn, { ScrambleInHandle } from "./scramble-in"; 6 | 7 | interface ScrambleCombinedPairProps { 8 | leftText: React.ReactNode; 9 | leftTextString: string; 10 | rightText: React.ReactNode; 11 | img?: string; 12 | imgAlt?: string; 13 | delay?: number; 14 | scrambleSpeed?: number; 15 | scrambledLetterCount?: number; 16 | characters?: string; 17 | className?: string; 18 | showImage?: boolean; 19 | containerClassName?: string; 20 | } 21 | 22 | const ScrambleCombinedPair = forwardRef< 23 | ScrambleInHandle, 24 | ScrambleCombinedPairProps 25 | >( 26 | ( 27 | { 28 | leftText, 29 | leftTextString, 30 | rightText, 31 | img, 32 | imgAlt, 33 | delay = 0, 34 | scrambleSpeed = 50, 35 | scrambledLetterCount = 4, 36 | characters = "abcdefghijklmnopqrstuvwxyz!@#$%^&*()_+", 37 | className, 38 | showImage = false, 39 | containerClassName, 40 | }, 41 | ref 42 | ) => { 43 | const [leftComplete, setLeftComplete] = useState(false); 44 | const [rightComplete, setRightComplete] = useState(false); 45 | const [isHovering, setIsHovering] = useState(false); 46 | const bothComplete = leftComplete && rightComplete; 47 | 48 | return ( 49 |
setIsHovering(true)} 52 | onMouseLeave={() => setIsHovering(false)} 53 | > 54 | {bothComplete ? ( 55 |
56 | 64 | {leftText} 65 | 66 | 67 | {isHovering && showImage && ( 68 |
69 | {imgAlt!} 74 |
75 | )} 76 | 77 | 85 | {rightText} 86 | 87 |
88 | ) : ( 89 |
90 | setLeftComplete(true)} 98 | > 99 | {leftText} 100 | 101 | 102 | setRightComplete(true)} 109 | > 110 | {rightText} 111 | 112 |
113 | )} 114 |
115 | ); 116 | } 117 | ); 118 | 119 | // Helper to calculate animation duration (copied from page.tsx) 120 | const getAnimationDuration = (text: string) => { 121 | return Math.min((text.length - 5) * 30, 100); 122 | }; 123 | 124 | ScrambleCombinedPair.displayName = "ScrambleCombinedPair"; 125 | export default ScrambleCombinedPair; 126 | -------------------------------------------------------------------------------- /src/components/newsletter.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useState } from "react"; 4 | import ScrambleIn from "./scramble-in"; 5 | import ScrambleCombined from "./scramble-combined"; 6 | import { motion } from "framer-motion"; 7 | import { 8 | getAnimationDuration, 9 | SCRAMBLE_SPEED, 10 | SCRAMBLED_LETTER_COUNT, 11 | } from "@/lib/utils"; 12 | import LoadingSpinner from "./loading"; 13 | 14 | export default function Newsletter({ delay = 0 }: { delay?: number }) { 15 | const [email, setEmail] = useState(""); 16 | const [status, setStatus] = useState< 17 | "idle" | "loading" | "success" | "error" 18 | >("idle"); 19 | const [showScrambledPlaceholder, setShowScrambledPlaceholder] = 20 | useState(true); 21 | 22 | const handleSubmit = async (e: React.FormEvent) => { 23 | e.preventDefault(); 24 | setStatus("loading"); 25 | 26 | try { 27 | const response = await fetch("/api/subscribe", { 28 | method: "POST", 29 | headers: { 30 | "Content-Type": "application/json", 31 | }, 32 | body: JSON.stringify({ email }), 33 | }); 34 | 35 | if (response.status === 200) { 36 | setStatus("success"); 37 | setEmail(""); 38 | 39 | // Reset status after success 40 | setTimeout(() => { 41 | setStatus("idle"); 42 | }, 2000); 43 | } else { 44 | setStatus("error"); 45 | } 46 | } catch (error) { 47 | console.error("Error submitting newsletter form:", error); 48 | setStatus("error"); 49 | } 50 | }; 51 | 52 | return ( 53 |
57 |
58 |
59 | setEmail(e.target.value)} 65 | className="bg-transparent focus:outline-none pb-[1.5vw] sm:pb-1.5 md:pb-2 md:w-full" 66 | required 67 | /> 68 | {showScrambledPlaceholder && email.length === 0 && ( 69 |
70 | setShowScrambledPlaceholder(false)} 76 | > 77 | enter your email 78 | 79 |
80 | )} 81 | 107 |
108 | 109 | 120 |
121 |
122 | ); 123 | } 124 | -------------------------------------------------------------------------------- /emails/welcome-email.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Body, 3 | Container, 4 | Head, 5 | Hr, 6 | Html, 7 | Img, 8 | Link, 9 | Preview, 10 | Section, 11 | Text, 12 | } from "@react-email/components"; 13 | import * as React from "react"; 14 | 15 | const baseUrl = 16 | process.env.NODE_ENV === "development" 17 | ? "http://localhost:3000" 18 | : `https://danielpetho.com`; 19 | 20 | export const WelcomeEmail = ({ email }: { email: string }) => ( 21 | 22 | 23 | 24 | welcome 25 | 26 |
27 | Hi Friend, 28 | 29 | 30 | Thanks for subscribing to my newsletter! I'll be sending out emails 31 | at least once a month with newsletter-only content, updates on my 32 | work, updates on fancy components, and occasional thoughts about 33 | design and tech. 34 | 35 | 36 | 37 | A few things I'm currently working on and what you can expect in 38 | future updates: 39 | 40 | 41 | Fancy Components 42 | 43 | 47 | Fancy Components Preview 54 | 55 | 56 | Fancy Components is a playground where I [re]create experimental ui 57 | components and funky microinteractions. Each component comes with 58 | source code and detailed write-ups about the implementation process. 59 | 60 | 61 | Everything is open source and also available on{" "} 62 | 63 | GitHub 64 | 65 | . 66 | 67 | 68 | unbaited 69 | 70 | 71 | 72 | unbaited 73 | {" "} 74 | is a browser extension that helps clean up your X 75 | feed by filtering out engagement bait posts using AI. The project is 76 | fully open-source and available on{" "} 77 | 78 | GitHub 79 | 80 | . 81 | 82 | 83 | 84 | While currently focused on X, there are requests to extend support 85 | to other platforms as well. Contributions are very much welcomed! 86 | 87 | 88 | Socials 89 | 90 | 91 | You can also find me posting ui, motion & other tech experiments 92 | regularly on{" "} 93 | 94 | X 95 | 96 | ,{" "} 97 | 98 | Threads 99 | 100 | , and{" "} 101 | 104 | Bluesky 105 | 106 | . 107 | 108 | 109 |
110 | looking forward to sharing more 111 | — daniel 112 |
113 | 114 |
115 | 116 |
117 | 118 | if you have questions, or want to say hi, feel free to contact me 119 | at{" "} 120 | 121 | hi@danielpetho.com 122 | 123 | . 124 | 125 |
126 | 127 | 128 | You're seeing this email because you've opted in to receive updates 129 | from danielpetho.com. 130 | 131 | 132 | 140 | UNSUBSCRIBE 141 | 142 | 143 | Nuremberg, Germany 144 |
145 |
146 | 147 | 148 | ); 149 | 150 | export default WelcomeEmail; 151 | 152 | const main = { 153 | width: "100%", 154 | margin: 0, 155 | fontFamily: 156 | '-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Ubuntu,sans-serif', 157 | }; 158 | 159 | const container = { 160 | backgroundColor: "#ffffff", 161 | padding: "20px 0 48px", 162 | width: "100%", 163 | maxWidth: "800px", 164 | marginBottom: "64px", 165 | }; 166 | 167 | const box = { 168 | padding: "0 48px", 169 | margin: 0, 170 | }; 171 | 172 | const hr = { 173 | borderColor: "#eee", 174 | margin: "20px 0", 175 | }; 176 | 177 | const header = { 178 | fontSize: "20px", 179 | fontWeight: "600", 180 | lineHeight: "32px", 181 | marginBottom: "16px", 182 | textAlign: "left" as const, 183 | color: "#000000", 184 | }; 185 | 186 | const paragraph = { 187 | color: "#333", 188 | 189 | fontSize: "16px", 190 | lineHeight: "24px", 191 | textAlign: "left" as const, 192 | }; 193 | 194 | const signature = { 195 | marginTop: "32px", 196 | 197 | fontSize: "16px", 198 | lineHeight: "24px", 199 | textAlign: "left" as const, 200 | }; 201 | 202 | const reply = { 203 | marginTop: "32px", 204 | 205 | fontSize: "12px", 206 | lineHeight: "24px", 207 | textAlign: "left" as const, 208 | }; 209 | 210 | const footer = { 211 | textDecoration: "none", 212 | color: "#999", 213 | fontSize: "12px", 214 | lineHeight: "16px", 215 | }; 216 | -------------------------------------------------------------------------------- /src/components/scramble-in.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { 4 | useState, 5 | useEffect, 6 | forwardRef, 7 | useImperativeHandle, 8 | useCallback, 9 | Children, 10 | isValidElement, 11 | cloneElement, 12 | } from "react"; 13 | 14 | interface ScrambleInProps { 15 | children: React.ReactNode; 16 | scrambleSpeed?: number; 17 | scrambledLetterCount?: number; 18 | characters?: string; 19 | className?: string; 20 | scrambledClassName?: string; 21 | autoStart?: boolean; 22 | onStart?: () => void; 23 | onComplete?: () => void; 24 | delay?: number; 25 | } 26 | 27 | export interface ScrambleInHandle { 28 | start: () => void; 29 | reset: () => void; 30 | } 31 | 32 | const extractTextFromChildren = (children: React.ReactNode): string => { 33 | return Children.toArray(children) 34 | .map(child => { 35 | if (typeof child === 'string') return child; 36 | if (typeof child === 'number') return String(child); 37 | if (isValidElement(child)) { 38 | // @ts-expect-error - child.props.children may not exist on all React element types, 39 | return extractTextFromChildren(child.props.children); 40 | } 41 | return ''; 42 | }) 43 | .join(''); 44 | }; 45 | 46 | const ScrambleIn = forwardRef( 47 | ( 48 | { 49 | children, 50 | scrambleSpeed = 50, 51 | scrambledLetterCount = 10, 52 | characters = "abcdefghijklmnopqrstuvwxyz!@#$%^&*()_+", 53 | className = "", 54 | scrambledClassName = "", 55 | autoStart = true, 56 | onStart, 57 | onComplete, 58 | delay = 0, 59 | }, 60 | ref 61 | ) => { 62 | const text = extractTextFromChildren(children); 63 | 64 | const [displayText, setDisplayText] = useState(""); 65 | const [isAnimating, setIsAnimating] = useState(false); 66 | const [visibleLetterCount, setVisibleLetterCount] = useState(0); 67 | const [scrambleOffset, setScrambleOffset] = useState(0); 68 | 69 | const startAnimation = useCallback(() => { 70 | setIsAnimating(true); 71 | setVisibleLetterCount(0); 72 | setScrambleOffset(0); 73 | onStart?.(); 74 | }, [onStart]); 75 | 76 | const reset = useCallback(() => { 77 | setIsAnimating(false); 78 | setVisibleLetterCount(0); 79 | setScrambleOffset(0); 80 | setDisplayText(""); 81 | }, []); 82 | 83 | useImperativeHandle(ref, () => ({ 84 | start: startAnimation, 85 | reset, 86 | })); 87 | 88 | useEffect(() => { 89 | if (autoStart) { 90 | const timeout = setTimeout(() => { 91 | startAnimation(); 92 | }, delay); 93 | 94 | return () => clearTimeout(timeout); 95 | } 96 | }, [autoStart, startAnimation, delay]); 97 | 98 | useEffect(() => { 99 | let interval: NodeJS.Timeout; 100 | 101 | if (isAnimating) { 102 | interval = setInterval(() => { 103 | // Increase visible text length 104 | if (visibleLetterCount < text.length) { 105 | setVisibleLetterCount((prev) => prev + 1); 106 | } 107 | // Start sliding scrambled text out 108 | else if (scrambleOffset < scrambledLetterCount) { 109 | setScrambleOffset((prev) => prev + 1); 110 | } 111 | // Complete animation 112 | else { 113 | clearInterval(interval); 114 | setIsAnimating(false); 115 | onComplete?.(); 116 | } 117 | 118 | // Calculate how many scrambled letters we can show 119 | const remainingSpace = Math.max(0, text.length - visibleLetterCount); 120 | const currentScrambleCount = Math.min( 121 | remainingSpace, 122 | scrambledLetterCount 123 | ); 124 | 125 | // Generate scrambled text 126 | const scrambledPart = Array(currentScrambleCount) 127 | .fill(0) 128 | .map( 129 | () => characters[Math.floor(Math.random() * characters.length)] 130 | ) 131 | .join(""); 132 | 133 | setDisplayText(text.slice(0, visibleLetterCount) + scrambledPart); 134 | }, scrambleSpeed); 135 | } 136 | 137 | return () => { 138 | if (interval) clearInterval(interval); 139 | }; 140 | }, [ 141 | isAnimating, 142 | text, 143 | visibleLetterCount, 144 | scrambleOffset, 145 | scrambledLetterCount, 146 | characters, 147 | scrambleSpeed, 148 | onComplete, 149 | ]); 150 | 151 | const renderText = () => { 152 | let currentIndex = 0; 153 | const revealed = displayText.slice(0, visibleLetterCount); 154 | const scrambled = displayText.slice(visibleLetterCount); 155 | 156 | const processNode = (node: React.ReactNode): React.ReactNode => { 157 | if (typeof node === 'string' || typeof node === 'number') { 158 | const nodeText = String(node); 159 | const nodeLength = nodeText.length; 160 | const nodeRevealed = revealed.slice(currentIndex, currentIndex + nodeLength); 161 | const nodeScrambled = scrambled.slice(currentIndex, currentIndex + nodeLength); 162 | currentIndex += nodeLength; 163 | 164 | return ( 165 | <> 166 | {nodeRevealed} 167 | {nodeScrambled} 168 | 169 | ); 170 | } 171 | 172 | if (isValidElement(node)) { 173 | return cloneElement(node, { 174 | // @ts-expect-error - node.props.children may not exist on all React element types, 175 | ...node.props, 176 | // @ts-expect-error - node.props may not exist on all React element types, 177 | children: Children.map(node.props.children, child => processNode(child)) 178 | }); 179 | } 180 | 181 | return node; 182 | }; 183 | 184 | return Children.map(children, child => processNode(child)); 185 | }; 186 | 187 | return ( 188 | 189 | {text} 190 | 191 | 192 | ); 193 | } 194 | ); 195 | 196 | ScrambleIn.displayName = "ScrambleIn"; 197 | export default ScrambleIn; 198 | -------------------------------------------------------------------------------- /src/components/scramble-hover.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { 4 | useState, 5 | useEffect, 6 | Children, 7 | isValidElement, 8 | cloneElement, 9 | } from "react"; 10 | import { motion } from "motion/react"; 11 | import { cn } from "@/lib/utils"; 12 | 13 | interface ScrambleHoverProps { 14 | children: React.ReactNode; 15 | scrambleSpeed?: number; 16 | maxIterations?: number; 17 | sequential?: boolean; 18 | revealDirection?: "start" | "end" | "center"; 19 | useOriginalCharsOnly?: boolean; 20 | characters?: string; 21 | className?: string; 22 | scrambledClassName?: string; 23 | isHovering?: boolean; 24 | onHoverChange?: (isHovering: boolean) => void; 25 | useInternalHover?: boolean; 26 | } 27 | 28 | const extractTextFromChildren = (children: React.ReactNode): string => { 29 | return Children.toArray(children) 30 | .map(child => { 31 | if (typeof child === 'string') return child; 32 | if (typeof child === 'number') return String(child); 33 | if (isValidElement(child)) { 34 | // @ts-expect-error - child.props.children may not exist on all React element types, 35 | return extractTextFromChildren(child.props.children); 36 | } 37 | return ''; 38 | }) 39 | .join(''); 40 | }; 41 | 42 | const ScrambleHover: React.FC = ({ 43 | children, 44 | scrambleSpeed = 50, 45 | maxIterations = 10, 46 | useOriginalCharsOnly = false, 47 | characters = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz!@#$%^&*()_+", 48 | className, 49 | scrambledClassName, 50 | sequential = false, 51 | revealDirection = "start", 52 | isHovering: isHoveringProp, 53 | onHoverChange, 54 | useInternalHover = false, 55 | ...props 56 | }) => { 57 | // Replace existing text extraction 58 | const text = extractTextFromChildren(children); 59 | 60 | const [displayText, setDisplayText] = useState(text); 61 | const [isScrambling, setIsScrambling] = useState(false); 62 | const [internalHovering, setInternalHovering] = useState(false); 63 | const [revealedIndices] = useState(new Set()); 64 | 65 | const isHovering = useInternalHover ? internalHovering : isHoveringProp; 66 | 67 | const handleHoverStart = () => { 68 | if (useInternalHover) { 69 | setInternalHovering(true); 70 | } 71 | onHoverChange?.(true); 72 | }; 73 | 74 | const handleHoverEnd = () => { 75 | if (useInternalHover) { 76 | setInternalHovering(false); 77 | } 78 | onHoverChange?.(false); 79 | }; 80 | 81 | useEffect(() => { 82 | let interval: NodeJS.Timeout; 83 | let currentIteration = 0; 84 | 85 | const getNextIndex = () => { 86 | const textLength = text.length; 87 | switch (revealDirection) { 88 | case "start": 89 | return revealedIndices.size; 90 | case "end": 91 | return textLength - 1 - revealedIndices.size; 92 | case "center": 93 | const middle = Math.floor(textLength / 2); 94 | const offset = Math.floor(revealedIndices.size / 2); 95 | const nextIndex = 96 | revealedIndices.size % 2 === 0 97 | ? middle + offset 98 | : middle - offset - 1; 99 | 100 | if ( 101 | nextIndex >= 0 && 102 | nextIndex < textLength && 103 | !revealedIndices.has(nextIndex) 104 | ) { 105 | return nextIndex; 106 | } 107 | 108 | for (let i = 0; i < textLength; i++) { 109 | if (!revealedIndices.has(i)) return i; 110 | } 111 | return 0; 112 | default: 113 | return revealedIndices.size; 114 | } 115 | }; 116 | 117 | const shuffleText = (text: string) => { 118 | if (useOriginalCharsOnly) { 119 | const positions = text.split("").map((char, i) => ({ 120 | char, 121 | isSpace: char === " ", 122 | index: i, 123 | isRevealed: revealedIndices.has(i), 124 | })); 125 | 126 | const nonSpaceChars = positions 127 | .filter((p) => !p.isSpace && !p.isRevealed) 128 | .map((p) => p.char); 129 | 130 | // Shuffle remaining non-revealed, non-space characters 131 | for (let i = nonSpaceChars.length - 1; i > 0; i--) { 132 | const j = Math.floor(Math.random() * (i + 1)); 133 | [nonSpaceChars[i], nonSpaceChars[j]] = [ 134 | nonSpaceChars[j], 135 | nonSpaceChars[i], 136 | ]; 137 | } 138 | 139 | let charIndex = 0; 140 | return positions 141 | .map((p) => { 142 | if (p.isSpace) return " "; 143 | if (p.isRevealed) return text[p.index]; 144 | return nonSpaceChars[charIndex++]; 145 | }) 146 | .join(""); 147 | } else { 148 | return text 149 | .split("") 150 | .map((char, i) => { 151 | if (char === " ") return " "; 152 | if (revealedIndices.has(i)) return text[i]; 153 | return availableChars[ 154 | Math.floor(Math.random() * availableChars.length) 155 | ]; 156 | }) 157 | .join(""); 158 | } 159 | }; 160 | 161 | const availableChars = useOriginalCharsOnly 162 | ? Array.from(new Set(text.split(""))).filter((char) => char !== " ") 163 | : characters.split(""); 164 | 165 | if (isHovering) { 166 | setIsScrambling(true); 167 | interval = setInterval(() => { 168 | if (sequential) { 169 | if (revealedIndices.size < text.length) { 170 | const nextIndex = getNextIndex(); 171 | revealedIndices.add(nextIndex); 172 | setDisplayText(shuffleText(text)); 173 | } else { 174 | clearInterval(interval); 175 | setIsScrambling(false); 176 | } 177 | } else { 178 | setDisplayText(shuffleText(text)); 179 | currentIteration++; 180 | if (currentIteration >= maxIterations) { 181 | clearInterval(interval); 182 | setIsScrambling(false); 183 | setDisplayText(text); 184 | } 185 | } 186 | }, scrambleSpeed); 187 | } else { 188 | setDisplayText(text); 189 | revealedIndices.clear(); 190 | } 191 | 192 | return () => { 193 | if (interval) clearInterval(interval); 194 | }; 195 | }, [ 196 | isHovering, 197 | text, 198 | characters, 199 | scrambleSpeed, 200 | useOriginalCharsOnly, 201 | sequential, 202 | revealDirection, 203 | maxIterations, 204 | ]); 205 | 206 | const renderText = () => { 207 | let currentIndex = 0; 208 | 209 | const processNode = (node: React.ReactNode): React.ReactNode => { 210 | if (typeof node === 'string' || typeof node === 'number') { 211 | const nodeText = String(node); 212 | const nodeLength = nodeText.length; 213 | const chars = displayText 214 | .slice(currentIndex, currentIndex + nodeLength) 215 | .split(''); 216 | const result = chars.map((char, i) => ( 217 | 227 | {char} 228 | 229 | )); 230 | currentIndex += nodeLength; 231 | return result; 232 | } 233 | 234 | if (isValidElement(node)) { 235 | return cloneElement(node, { 236 | // @ts-expect-error - node.props.children may not exist on all React element types, 237 | ...node.props, 238 | // @ts-expect-error - node.props may not exist on all React element types, 239 | children: Children.map(node.props.children, child => processNode(child)) 240 | }); 241 | } 242 | 243 | return node; 244 | }; 245 | 246 | return Children.map(children, child => processNode(child)); 247 | }; 248 | 249 | return ( 250 | 256 | {text} 257 | 258 | 259 | ); 260 | }; 261 | 262 | export default ScrambleHover; 263 | -------------------------------------------------------------------------------- /src/app/page.tsx: -------------------------------------------------------------------------------- 1 | import ScrambleCombined from "@/components/scramble-combined"; 2 | import ScrambleIn from "@/components/scramble-in"; 3 | import ScrambleCombinedPair from "@/components/scramble-combined-pair"; 4 | import { experiences, projects, socials } from "@/data/content"; 5 | import Newsletter from "@/components/newsletter"; 6 | import { 7 | getAnimationDuration, 8 | ROW_DELAY, 9 | SCRAMBLE_SPEED, 10 | SCRAMBLED_LETTER_COUNT, 11 | } from "@/lib/utils"; 12 | 13 | export default function Home() { 14 | return ( 15 |
16 |
17 |
18 | {/* Header - Row 1 */} 19 |
20 |
21 |

22 | 28 | daniel petho 29 | 30 |

31 |
32 |
33 |

34 | 39 | design ✺︎ tech ∿︎ build ◳︎ 40 | 41 |

42 | 47 | 53 | 54 | design engineer @ krea{" "} 55 | 56 | ↗ 57 | 58 | 59 | 60 | 61 |
62 |
63 | 64 | {/* Newsletter */} 65 |
66 |

67 | 73 | newsletter 74 | 75 |

76 | 79 |
80 | 81 | {/* Previous Experience */} 82 | 126 | 127 | {/* Projects */} 128 | 176 | 177 | {/* Contact */} 178 |
179 |

180 | 186 | contact 187 | 188 |

189 | 226 |
227 |
228 |
229 |
230 | ); 231 | } 232 | --------------------------------------------------------------------------------