├── .env.example ├── .gitignore ├── README.md ├── app ├── api │ └── opengraph │ │ └── route.tsx ├── crafts │ └── [slug] │ │ └── page.tsx ├── favicon.ico ├── globals.css ├── layout.tsx ├── not-found.tsx └── page.tsx ├── bun.lockb ├── components ├── blur-overlay.tsx ├── clock.tsx ├── experience.tsx ├── experiment.tsx ├── experiments │ ├── carousel.tsx │ ├── component-wrapper.tsx │ ├── dynamic-label.tsx │ ├── filters.tsx │ ├── folder.tsx │ ├── order.tsx │ ├── reaction.tsx │ └── waitlist.tsx ├── project.tsx ├── section.tsx └── social.tsx ├── content-collections.ts ├── content └── crafts │ ├── carousel.mdx │ ├── dynamic-input-label.mdx │ ├── filters.mdx │ ├── folder.mdx │ ├── order.mdx │ ├── quick-reaction.mdx │ └── waitlist.mdx ├── lib ├── hooks │ └── use-media-query.ts ├── tinybird.ts └── utils.ts ├── next.config.ts ├── package.json ├── postcss.config.mjs ├── public ├── crafts │ ├── carousel.png │ ├── dynamic-input-label.png │ ├── filters.png │ ├── folder.png │ ├── order.png │ ├── quick-reaction.png │ └── waitlist.png └── fonts │ ├── Geist-Bold.ttf │ ├── Geist-Medium.ttf │ └── Geist-Regular.ttf └── tsconfig.json /.env.example: -------------------------------------------------------------------------------- 1 | # Tinybird - Used for tracking visits 2 | TINYBIRD_API_KEY="your-api-key" 3 | 4 | # Seline - Used for analytics 5 | SELINE_TOKEN="your-token" 6 | -------------------------------------------------------------------------------- /.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 | 37 | # vercel 38 | .vercel 39 | 40 | # typescript 41 | *.tsbuildinfo 42 | next-env.d.ts 43 | 44 | # content collections 45 | .content-collections -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). 2 | 3 | ## Getting Started 4 | 5 | First, run the development server: 6 | 7 | ```bash 8 | npm run dev 9 | # or 10 | yarn dev 11 | # or 12 | pnpm dev 13 | # or 14 | bun dev 15 | ``` 16 | 17 | Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. 18 | 19 | You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. 20 | 21 | This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. 22 | 23 | ## Learn More 24 | 25 | To learn more about Next.js, take a look at the following resources: 26 | 27 | - [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. 28 | - [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. 29 | 30 | You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! 31 | 32 | ## Deploy on Vercel 33 | 34 | The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. 35 | 36 | Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. 37 | -------------------------------------------------------------------------------- /app/api/opengraph/route.tsx: -------------------------------------------------------------------------------- 1 | import { ImageResponse } from "next/og"; 2 | import { NextRequest } from "next/server"; 3 | 4 | export const runtime = "edge"; 5 | 6 | export async function GET(req: NextRequest) { 7 | try { 8 | const { searchParams } = new URL(req.url); 9 | const title = searchParams.get("title") ?? "Christo Todorov"; 10 | 11 | // Load font 12 | const geistRegular = await fetch( 13 | new URL("../../../public/fonts/Geist-Regular.ttf", import.meta.url) 14 | ).then((res) => res.arrayBuffer()); 15 | 16 | const geistMedium = await fetch( 17 | new URL("../../../public/fonts/Geist-Medium.ttf", import.meta.url) 18 | ).then((res) => res.arrayBuffer()); 19 | 20 | return new ImageResponse( 21 | ( 22 |
34 | {title !== "Christo Todorov" ? ( 35 |
43 | Christo Todorov 44 |
45 | ) : ( 46 |
47 | )} 48 |
58 | {title} 59 |
60 |
61 | ), 62 | { 63 | width: 1200, 64 | height: 630, 65 | fonts: [ 66 | { 67 | name: "GeistRegular", 68 | data: geistRegular, 69 | style: "normal", 70 | }, 71 | { 72 | name: "GeistMedium", 73 | data: geistMedium, 74 | style: "normal", 75 | }, 76 | ], 77 | } 78 | ); 79 | } catch (error: any) { 80 | console.error(error.message); 81 | return new Response(`Failed to generate the image`, { 82 | status: 500, 83 | }); 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /app/crafts/[slug]/page.tsx: -------------------------------------------------------------------------------- 1 | import { allCrafts } from "content-collections"; 2 | import { MDXContent } from "@content-collections/mdx/react"; 3 | import DynamicInputLabel from "@/components/experiments/dynamic-label"; 4 | import Carousel from "@/components/experiments/carousel"; 5 | import Filters from "@/components/experiments/filters"; 6 | import Folder from "@/components/experiments/folder"; 7 | import Order from "@/components/experiments/order"; 8 | import Reaction from "@/components/experiments/reaction"; 9 | import Waitlist from "@/components/experiments/waitlist"; 10 | import { notFound } from "next/navigation"; 11 | import { Metadata } from "next/types"; 12 | 13 | type Props = { 14 | params: Promise<{ 15 | slug: string; 16 | }>; 17 | }; 18 | 19 | export async function generateMetadata({ params }: Props): Promise { 20 | const { slug } = await params; 21 | const craft = allCrafts.find((craft) => craft.slug === slug); 22 | 23 | if (!craft) notFound(); 24 | 25 | return { 26 | title: `Christo Todorov | ${craft.title}`, 27 | openGraph: { 28 | images: [ 29 | { 30 | url: `/api/opengraph?title=${encodeURIComponent(craft.title)}`, 31 | alt: craft.title, 32 | }, 33 | ], 34 | }, 35 | }; 36 | } 37 | 38 | const components = { 39 | Craft: { 40 | DynamicInputLabel: DynamicInputLabel, 41 | Carousel, 42 | Filters, 43 | Folder, 44 | Order, 45 | QuickReaction: Reaction, 46 | Waitlist, 47 | }, 48 | }; 49 | 50 | export default async function CraftPage({ params }: Props) { 51 | const { slug } = await params; 52 | const craft = allCrafts.find((craft) => craft.slug === slug); 53 | 54 | if (!craft?.mdx) { 55 | return notFound(); 56 | } 57 | 58 | return ( 59 |
60 |
61 |

{craft.title}

62 |

63 | {craft.date.toLocaleDateString()} 64 |

65 |
66 | 67 | 68 |
69 | ); 70 | } 71 | -------------------------------------------------------------------------------- /app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chroxify/website/c3a75c8f7dcf105d49959c03778557785547c00a/app/favicon.ico -------------------------------------------------------------------------------- /app/globals.css: -------------------------------------------------------------------------------- 1 | @import 'tailwindcss'; 2 | 3 | @plugin 'tailwindcss-animate'; 4 | 5 | @variant dark (&:is(.dark *)); 6 | 7 | @theme { 8 | --color-border: hsl(var(--border)); 9 | --color-input: hsl(var(--input)); 10 | --color-ring: hsl(var(--ring)); 11 | --color-background: hsl(var(--background)); 12 | --color-foreground: hsl(var(--foreground)); 13 | 14 | --color-primary: hsl(var(--primary)); 15 | --color-primary-foreground: hsl(var(--primary-foreground)); 16 | 17 | --color-secondary: hsl(var(--secondary)); 18 | --color-secondary-foreground: hsl(var(--secondary-foreground)); 19 | 20 | --color-destructive: hsl(var(--destructive)); 21 | --color-destructive-foreground: hsl(var(--destructive-foreground)); 22 | 23 | --color-muted: hsl(var(--muted)); 24 | --color-muted-foreground: hsl(var(--muted-foreground)); 25 | 26 | --color-accent: hsl(var(--accent)); 27 | --color-accent-foreground: hsl(var(--accent-foreground)); 28 | 29 | --color-popover: hsl(var(--popover)); 30 | --color-popover-foreground: hsl(var(--popover-foreground)); 31 | 32 | --color-card: hsl(var(--card)); 33 | --color-card-foreground: hsl(var(--card-foreground)); 34 | 35 | --radius-lg: var(--radius); 36 | --radius-md: calc(var(--radius) - 2px); 37 | --radius-sm: calc(var(--radius) - 4px); 38 | 39 | --animate-accordion-down: accordion-down 0.2s ease-out; 40 | --animate-accordion-up: accordion-up 0.2s ease-out; 41 | --animate-enter: enter 1s both; 42 | 43 | --font-weight-thin: 150; 44 | --font-weight-extralight: 200; 45 | --font-weight-light: 250; 46 | --font-weight-normal: 350; 47 | --font-weight-medium: 450; 48 | --font-weight-semibold: 550; 49 | --font-weight-bold: 650; 50 | --font-weight-extrabold: 750; 51 | --font-weight-black: 850; 52 | 53 | @keyframes accordion-down { 54 | from { 55 | height: 0; 56 | } 57 | to { 58 | height: var(--radix-accordion-content-height); 59 | } 60 | } 61 | @keyframes accordion-up { 62 | from { 63 | height: var(--radix-accordion-content-height); 64 | } 65 | to { 66 | height: 0; 67 | } 68 | } 69 | @keyframes enter { 70 | from { 71 | opacity: 0; 72 | transform: translateY(0.5rem); 73 | } 74 | to { 75 | opacity: 1; 76 | transform: translateY(0); 77 | } 78 | } 79 | } 80 | 81 | @utility container { 82 | margin-inline: auto; 83 | padding-inline: 2rem; 84 | @media (width >= theme(--breakpoint-sm)) { 85 | max-width: none; 86 | } 87 | @media (width >= 1400px) { 88 | max-width: 1400px; 89 | } 90 | } 91 | 92 | /* 93 | The default border color has changed to `currentColor` in Tailwind CSS v4, 94 | so we've added these compatibility styles to make sure everything still 95 | looks the same as it did with Tailwind CSS v3. 96 | 97 | If we ever want to remove these styles, we need to add an explicit border 98 | color utility to any element that depends on these defaults. 99 | */ 100 | @layer base { 101 | *, 102 | ::after, 103 | ::before, 104 | ::backdrop, 105 | ::file-selector-button { 106 | border-color: var(--color-gray-200, currentColor); 107 | } 108 | } 109 | 110 | @layer utilities { 111 | body { 112 | -webkit-font-smoothing: antialiased; 113 | -moz-osx-font-smoothing: grayscale; 114 | } 115 | } 116 | 117 | @layer base { 118 | :root { 119 | --background: 0 0% 100%; 120 | --foreground: 224 71.4% 4.1%; 121 | 122 | --muted: 0 0% 88%; 123 | --muted-foreground: 0 0% 58%; 124 | 125 | --popover: 0 0% 100%; 126 | --popover-foreground: 224 71.4% 4.1%; 127 | 128 | --card: 0 0% 100%; 129 | --card-foreground: 224 71.4% 4.1%; 130 | 131 | --border: 220 13% 91%; 132 | --input: 220 13% 91%; 133 | 134 | --primary: 0 0% 7%; 135 | --primary-foreground: 210 20% 98%; 136 | 137 | --secondary: 0 0% 96%; 138 | --secondary-foreground: 0 0% 30%; 139 | 140 | --accent: 220 14.3% 95.9%; 141 | --accent-foreground: 220.9 39.3% 11%; 142 | 143 | --destructive: 0 72.2% 50.6%; 144 | --destructive-foreground: 210 20% 98%; 145 | 146 | --ring: 220 13% 91%; 147 | 148 | --radius: 0.5rem; 149 | } 150 | 151 | .dark { 152 | --background: 0 0% 7%; 153 | --foreground: 0 0% 90%; 154 | 155 | --muted: 0 0% 30%; 156 | --muted-foreground: 0 0% 55%; 157 | 158 | --popover: 0 0% 7%; 159 | --popover-foreground: 210 20% 98%; 160 | 161 | --card: 0 0% 7%; 162 | --card-foreground: 210 20% 98%; 163 | 164 | --border: 0 0% 18%; 165 | --input: 0 0% 18%; 166 | 167 | --primary: 210 20% 98%; 168 | --primary-foreground: 220.9 39.3% 11%; 169 | 170 | --secondary: 0 0% 15%; 171 | --secondary-foreground: 0 0% 75%; 172 | 173 | --accent: 0 0% 18%; 174 | --accent-foreground: 210 20% 98%; 175 | 176 | --destructive: 355 100% 75%; 177 | --destructive-foreground: 210 20% 98%; 178 | 179 | --ring: 0 0% 40%; 180 | } 181 | } 182 | 183 | @layer base { 184 | * { 185 | @apply border-border font-normal; 186 | } 187 | body { 188 | @apply bg-background text-foreground; 189 | } 190 | } 191 | -------------------------------------------------------------------------------- /app/layout.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from "next"; 2 | import { Geist, Geist_Mono } from "next/font/google"; 3 | import { Analytics } from "@vercel/analytics/next"; 4 | import "./globals.css"; 5 | import Link from "next/link"; 6 | import { getLastVisitor, logVisit } from "@/lib/tinybird"; 7 | import { Clock } from "@/components/clock"; 8 | import { BlurOverlay } from "@/components/blur-overlay"; 9 | import Script from "next/script"; 10 | import { getOrdinalSuffix } from "@/lib/utils"; 11 | 12 | const geistSans = Geist({ 13 | variable: "--font-geist-sans", 14 | subsets: ["latin"], 15 | }); 16 | 17 | const geistMono = Geist_Mono({ 18 | variable: "--font-geist-mono", 19 | subsets: ["latin"], 20 | }); 21 | 22 | export const metadata: Metadata = { 23 | title: "Christo Todorov", 24 | description: "Design engineer based in Berlin, Germany.", 25 | openGraph: { 26 | images: [ 27 | { 28 | url: "/api/opengraph", 29 | alt: "Christo Todorov", 30 | }, 31 | ], 32 | }, 33 | }; 34 | 35 | export default async function RootLayout({ 36 | children, 37 | }: Readonly<{ 38 | children: React.ReactNode; 39 | }>) { 40 | logVisit(); 41 | const lastVisitor = await getLastVisitor(); 42 | 43 | return ( 44 | 45 | 46 | 50 |