├── .env.example ├── .eslintrc.cjs ├── .gitignore ├── .vscode └── settings.json ├── README.md ├── app ├── components │ ├── Button.tsx │ ├── Card.tsx │ ├── Container.tsx │ ├── Footer.tsx │ ├── Header.tsx │ ├── Icons.tsx │ ├── PostHeader.tsx │ ├── Prose.tsx │ ├── Section.tsx │ └── SimpleLayout.tsx ├── contexts │ └── theme.tsx ├── data │ └── metadata.json ├── images │ ├── avatar.jpg │ ├── avatar.webp │ ├── logos │ │ ├── bukalapak.png │ │ ├── bukalapak.webp │ │ ├── kawalcovid19-dark.webp │ │ ├── kawalcovid19.webp │ │ ├── ninjavan.svg │ │ ├── pejuang-kode.svg │ │ ├── relay-commerce.jpeg │ │ ├── rumah-berbagi.svg │ │ ├── wbw.svg │ │ ├── xtremax.png │ │ └── xtremax.webp │ ├── photos │ │ ├── cityjs-conf-2023.jpg │ │ ├── cityjs-conf-2023.webp │ │ ├── ddc-2023.jpg │ │ ├── ddc-2023.webp │ │ ├── jsconf-asia-2019.jpg │ │ ├── jsconf-asia-2019.webp │ │ ├── piano-with-kids.jpg │ │ ├── piano-with-kids.webp │ │ ├── piano-with-wife.jpg │ │ ├── piano-with-wife.webp │ │ ├── web-unconf-2019.jpg │ │ └── web-unconf-2019.webp │ └── portrait.webp ├── models │ ├── metadata.ts │ ├── posts.ts │ ├── projects.ts │ ├── talks.ts │ └── tools.ts ├── root.tsx ├── routes │ ├── _index.tsx │ ├── _layout.blog_._index.tsx │ ├── _layout.edge.tsx │ ├── _layout.projects.tsx │ ├── _layout.talks.tsx │ ├── _layout.thank-you.tsx │ ├── _layout.tsx │ ├── _layout.uses.tsx │ ├── about.tsx │ ├── action.set-theme.tsx │ ├── assets │ │ ├── react-is-not-defined.png │ │ └── react-is-not-defined.webp │ ├── blog.react-dom-jsx.mdx │ ├── blog.tsx │ ├── talk.$slug.tsx │ └── talk._index.tsx ├── services │ └── mailgun.server.tsx ├── tailwind.css └── utils │ ├── format-date.ts │ └── theme.server.ts ├── package.json ├── pnpm-lock.yaml ├── public ├── 2024-01-25 haveaniesday.com.pdf ├── aminajadulu.com-recommendations-1.pdf ├── favicon.ico ├── zain-fathoni-cv.pdf └── zain-fathoni-portrait.jpg ├── remix.config.js ├── remix.env.d.ts ├── tailwind.config.js └── tsconfig.json /.env.example: -------------------------------------------------------------------------------- 1 | SESSION_SECRET=anything 2 | MAILGUN_API_KEY=key-somethingrandom 3 | MAILGUN_MAILING_LIST=mailing-list@mg.yourdomain.com 4 | -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | /** @type {import('eslint').Linter.Config} */ 2 | module.exports = { 3 | extends: ["@remix-run/eslint-config", "@remix-run/eslint-config/node"], 4 | }; 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | 3 | /.cache 4 | /build 5 | /public/build 6 | .env 7 | .vercel 8 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "workbench.colorCustomizations": { 3 | "activityBar.activeBackground": "#ef7853", 4 | "activityBar.background": "#ef7853", 5 | "activityBar.foreground": "#15202b", 6 | "activityBar.inactiveForeground": "#15202b99", 7 | "activityBarBadge.background": "#8af4a3", 8 | "activityBarBadge.foreground": "#15202b", 9 | "commandCenter.border": "#e7e7e799", 10 | "sash.hoverBorder": "#ef7853", 11 | "statusBar.background": "#eb5424", 12 | "statusBar.foreground": "#e7e7e7", 13 | "statusBarItem.hoverBackground": "#ef7853", 14 | "statusBarItem.remoteBackground": "#eb5424", 15 | "statusBarItem.remoteForeground": "#e7e7e7", 16 | "titleBar.activeBackground": "#eb5424", 17 | "titleBar.activeForeground": "#e7e7e7", 18 | "titleBar.inactiveBackground": "#eb542499", 19 | "titleBar.inactiveForeground": "#e7e7e799" 20 | }, 21 | "peacock.color": "#eb5424" 22 | } 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Remix 2 | 3 | This directory is a brief example of a [Remix](https://remix.run/docs) site that 4 | can be deployed to Vercel with zero configuration. 5 | 6 | ## Deploy Your Own 7 | 8 | [![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https://github.com/vercel/vercel/tree/main/examples/remix&template=remix) 9 | 10 | _Live Example: https://remix-run-template.vercel.app_ 11 | 12 | You can also deploy using the [Vercel CLI](https://vercel.com/cli): 13 | 14 | ```sh 15 | npm i -g vercel 16 | vercel 17 | ``` 18 | 19 | ## Development 20 | 21 | To run your Remix app locally, make sure your project's local dependencies are 22 | installed: 23 | 24 | ```sh 25 | npm install 26 | ``` 27 | 28 | Afterwards, start the Remix development server like so: 29 | 30 | ```sh 31 | npm run dev 32 | ``` 33 | 34 | Open up [http://localhost:3000](http://localhost:3000) and you should be ready 35 | to go! 36 | -------------------------------------------------------------------------------- /app/components/Button.tsx: -------------------------------------------------------------------------------- 1 | import clsx from "clsx"; 2 | 3 | export type ButtonProps = React.ButtonHTMLAttributes & { 4 | variant?: "primary" | "secondary"; 5 | className?: string; 6 | }; 7 | 8 | export type ButtonLinkProps = React.AnchorHTMLAttributes & { 9 | variant?: "primary" | "secondary"; 10 | className?: string; 11 | }; 12 | 13 | const variantStyles: Record = { 14 | primary: 15 | "bg-zinc-800 font-semibold text-zinc-100 hover:bg-zinc-700 active:bg-zinc-800 active:text-zinc-100/70 dark:bg-zinc-700 dark:hover:bg-zinc-600 dark:active:bg-zinc-700 dark:active:text-zinc-100/70", 16 | secondary: 17 | "bg-zinc-50 font-medium text-zinc-900 hover:bg-zinc-100 active:bg-zinc-100 active:text-zinc-900/60 dark:bg-zinc-800/50 dark:text-zinc-300 dark:hover:bg-zinc-800 dark:hover:text-zinc-50 dark:active:bg-zinc-800/50 dark:active:text-zinc-50/70", 18 | }; 19 | 20 | const baseClassName = 21 | "border(gray-100 2) hover:bg-gray-200 inline-flex items-center gap-2 justify-center rounded-md py-2 px-3 text-sm outline-offset-2 transition active:transition-none"; 22 | 23 | export function Button({ 24 | variant = "primary", 25 | className, 26 | ...props 27 | }: ButtonProps) { 28 | className = clsx(baseClassName, variantStyles[variant], className ?? ""); 29 | 30 | return 229 | ); 230 | } 231 | 232 | function clamp(number: number, a: number, b: number) { 233 | const min = Math.min(a, b); 234 | const max = Math.max(a, b); 235 | return Math.min(Math.max(number, min), max); 236 | } 237 | 238 | function AvatarContainer({ className, ...props }: ContainerProps) { 239 | return ( 240 |
247 | ); 248 | } 249 | 250 | function Avatar({ 251 | large = false, 252 | className, 253 | ...props 254 | }: { large?: boolean } & ContainerProps) { 255 | return ( 256 | 262 | 271 | 272 | ); 273 | } 274 | 275 | export function Header() { 276 | const matches = useMatches(); 277 | 278 | const isHomePage = matches[1].pathname === "/"; 279 | 280 | const headerRef = useRef(null); 281 | const avatarRef = useRef(null); 282 | const isInitial = useRef(true); 283 | 284 | useEffect(() => { 285 | const downDelay = avatarRef.current?.offsetTop ?? 0; 286 | const upDelay = 64; 287 | 288 | function setProperty(property: string, value: string | null) { 289 | document.documentElement.style.setProperty(property, value); 290 | } 291 | 292 | function removeProperty(property: string) { 293 | document.documentElement.style.removeProperty(property); 294 | } 295 | 296 | function updateHeaderStyles() { 297 | const { top, height } = headerRef.current?.getBoundingClientRect() ?? { 298 | top: 0, 299 | height: 0, 300 | }; 301 | const scrollY = clamp( 302 | self.scrollY, 303 | 0, 304 | document.body.scrollHeight - self.innerHeight, 305 | ); 306 | 307 | if (isInitial.current) { 308 | setProperty("--header-position", "sticky"); 309 | } 310 | 311 | setProperty("--content-offset", `${downDelay}px`); 312 | 313 | if (isInitial.current || scrollY < downDelay) { 314 | setProperty("--header-height", `${downDelay + height}px`); 315 | setProperty("--header-mb", `${-downDelay}px`); 316 | } else if (top + height < -upDelay) { 317 | const offset = Math.max(height, scrollY - upDelay); 318 | setProperty("--header-height", `${offset}px`); 319 | setProperty("--header-mb", `${height - offset}px`); 320 | } else if (top === 0) { 321 | setProperty("--header-height", `${scrollY + height}px`); 322 | setProperty("--header-mb", `${-scrollY}px`); 323 | } 324 | 325 | if (top === 0 && scrollY > 0 && scrollY >= downDelay) { 326 | setProperty("--header-inner-position", "fixed"); 327 | removeProperty("--header-top"); 328 | removeProperty("--avatar-top"); 329 | } else { 330 | removeProperty("--header-inner-position"); 331 | setProperty("--header-top", "0px"); 332 | setProperty("--avatar-top", "0px"); 333 | } 334 | } 335 | 336 | function updateAvatarStyles() { 337 | if (!isHomePage) { 338 | return; 339 | } 340 | 341 | const fromScale = 1; 342 | const toScale = 36 / 64; 343 | const fromX = 0; 344 | const toX = 2 / 16; 345 | 346 | const scrollY = downDelay - self.scrollY; 347 | 348 | let scale = (scrollY * (fromScale - toScale)) / downDelay + toScale; 349 | scale = clamp(scale, fromScale, toScale); 350 | 351 | let x = (scrollY * (fromX - toX)) / downDelay + toX; 352 | x = clamp(x, fromX, toX); 353 | 354 | setProperty( 355 | "--avatar-image-transform", 356 | `translate3d(${x}rem, 0, 0) scale(${scale})`, 357 | ); 358 | 359 | const borderScale = 1 / (toScale / scale); 360 | const borderX = (-toX + x) * borderScale; 361 | const borderTransform = `translate3d(${borderX}rem, 0, 0) scale(${borderScale})`; 362 | 363 | setProperty("--avatar-border-transform", borderTransform); 364 | setProperty("--avatar-border-opacity", scale === toScale ? "1" : "0"); 365 | } 366 | 367 | function updateStyles() { 368 | updateHeaderStyles(); 369 | updateAvatarStyles(); 370 | isInitial.current = false; 371 | } 372 | 373 | const opts: AddEventListenerOptions & EventListenerOptions = { 374 | passive: true, 375 | }; 376 | 377 | updateStyles(); 378 | self.addEventListener("scroll", updateStyles, opts); 379 | self.addEventListener("resize", updateStyles); 380 | 381 | return () => { 382 | self.removeEventListener("scroll", updateStyles, opts); 383 | self.removeEventListener("resize", updateStyles); 384 | }; 385 | }, [isHomePage]); 386 | 387 | return ( 388 | <> 389 |
396 | {isHomePage && ( 397 | <> 398 |
402 | 406 |
414 |
415 | 422 | 427 |
428 |
429 |
430 | 431 | )} 432 |
441 | 445 |
446 |
447 | {!isHomePage && ( 448 | 449 | 450 | 451 | )} 452 |
453 |
454 | 455 | 456 |
457 |
458 |
459 | 460 |
461 |
462 |
463 |
464 |
465 |
466 | {isHomePage &&
} 467 | 468 | ); 469 | } 470 | -------------------------------------------------------------------------------- /app/components/Icons.tsx: -------------------------------------------------------------------------------- 1 | export type Icon = ( 2 | props: JSX.IntrinsicAttributes & React.SVGProps, 3 | ) => JSX.Element; 4 | 5 | export function TwitterIcon( 6 | props: JSX.IntrinsicAttributes & React.SVGProps, 7 | ) { 8 | return ( 9 | 12 | ); 13 | } 14 | 15 | export function InstagramIcon( 16 | props: JSX.IntrinsicAttributes & React.SVGProps, 17 | ) { 18 | return ( 19 | 23 | ); 24 | } 25 | 26 | export function GitHubIcon( 27 | props: JSX.IntrinsicAttributes & React.SVGProps, 28 | ) { 29 | return ( 30 | 37 | ); 38 | } 39 | 40 | export function LinkedInIcon( 41 | props: JSX.IntrinsicAttributes & React.SVGProps, 42 | ) { 43 | return ( 44 | 45 | 46 | 47 | ); 48 | } 49 | 50 | export function BriefcaseIcon( 51 | props: JSX.IntrinsicAttributes & React.SVGProps, 52 | ) { 53 | return ( 54 | 72 | ); 73 | } 74 | 75 | export function ArrowDownIcon( 76 | props: JSX.IntrinsicAttributes & React.SVGProps, 77 | ) { 78 | return ( 79 | 87 | ); 88 | } 89 | 90 | export function ArrowLeftIcon( 91 | props: JSX.IntrinsicAttributes & React.SVGProps, 92 | ) { 93 | return ( 94 | 102 | ); 103 | } 104 | 105 | export function ChevronRightIcon( 106 | props: JSX.IntrinsicAttributes & React.SVGProps, 107 | ) { 108 | return ( 109 | 117 | ); 118 | } 119 | 120 | export function MailIcon( 121 | props: JSX.IntrinsicAttributes & React.SVGProps, 122 | ) { 123 | return ( 124 | 142 | ); 143 | } 144 | -------------------------------------------------------------------------------- /app/components/PostHeader.tsx: -------------------------------------------------------------------------------- 1 | import { Link } from "@remix-run/react"; 2 | import { extractPostAttributes, type MdxAttributes } from "~/models/posts"; 3 | import { formatDate } from "~/utils/format-date"; 4 | import { ArrowLeftIcon } from "./Icons"; 5 | 6 | export function PostHeader(props: MdxAttributes) { 7 | const post = extractPostAttributes(props); 8 | return ( 9 | <> 10 | 15 | 16 | 17 |
18 |

19 | {post.title} 20 |

21 | 28 |
29 | 30 | ); 31 | } 32 | -------------------------------------------------------------------------------- /app/components/Prose.tsx: -------------------------------------------------------------------------------- 1 | import clsx from "clsx"; 2 | 3 | export function Prose({ 4 | children, 5 | className, 6 | }: { 7 | children: React.ReactNode; 8 | className?: string; 9 | }) { 10 | return ( 11 |
12 | {children} 13 |
14 | ); 15 | } 16 | -------------------------------------------------------------------------------- /app/components/Section.tsx: -------------------------------------------------------------------------------- 1 | import { useId } from "react"; 2 | 3 | export type SectionProps = { 4 | title: string; 5 | children: React.ReactNode; 6 | }; 7 | 8 | export function Section({ title, children }: SectionProps) { 9 | let id = useId(); 10 | 11 | return ( 12 |
16 |
17 |

21 | {title} 22 |

23 |
{children}
24 |
25 |
26 | ); 27 | } 28 | -------------------------------------------------------------------------------- /app/components/SimpleLayout.tsx: -------------------------------------------------------------------------------- 1 | export function SimpleLayout({ 2 | title, 3 | intro, 4 | children, 5 | }: { 6 | title: string; 7 | intro: string; 8 | children?: React.ReactNode; 9 | }) { 10 | return ( 11 | <> 12 |
13 |

14 | {title} 15 |

16 |

17 | {intro} 18 |

19 |
20 |
{children}
21 | 22 | ); 23 | } 24 | -------------------------------------------------------------------------------- /app/contexts/theme.tsx: -------------------------------------------------------------------------------- 1 | import { useFetcher } from "@remix-run/react"; 2 | import type { Dispatch, ReactNode, SetStateAction } from "react"; 3 | import { createContext, useContext, useEffect, useRef, useState } from "react"; 4 | 5 | enum Theme { 6 | DARK = "dark", 7 | LIGHT = "light", 8 | } 9 | 10 | type ThemeContextType = [Theme | null, Dispatch>]; 11 | 12 | const ThemeContext = createContext(undefined); 13 | 14 | const prefersDarkMQ = "(prefers-color-scheme: dark)"; 15 | const getPreferredTheme = () => 16 | window.matchMedia(prefersDarkMQ).matches ? Theme.DARK : Theme.LIGHT; 17 | 18 | function ThemeProvider({ 19 | children, 20 | specifiedTheme, 21 | }: { 22 | children: ReactNode; 23 | specifiedTheme: Theme | null; 24 | }) { 25 | const [theme, setTheme] = useState(() => { 26 | if (specifiedTheme) { 27 | if (themes.includes(specifiedTheme)) { 28 | return specifiedTheme; 29 | } else { 30 | return null; 31 | } 32 | } 33 | 34 | // there's no way for us to know what the theme should be in this context 35 | // the client will have to figure it out before hydration. 36 | if (typeof window !== "object") { 37 | return null; 38 | } 39 | 40 | return getPreferredTheme(); 41 | }); 42 | 43 | const persistTheme = useFetcher(); 44 | 45 | // TODO: remove this when persistTheme is memoized properly 46 | const persistThemeRef = useRef(persistTheme); 47 | useEffect(() => { 48 | persistThemeRef.current = persistTheme; 49 | }, [persistTheme]); 50 | 51 | const mountRun = useRef(false); 52 | 53 | useEffect(() => { 54 | if (!mountRun.current) { 55 | mountRun.current = true; 56 | return; 57 | } 58 | if (!theme) { 59 | return; 60 | } 61 | 62 | persistThemeRef.current.submit( 63 | { theme }, 64 | { action: "action/set-theme", method: "post" }, 65 | ); 66 | }, [theme]); 67 | 68 | useEffect(() => { 69 | const mediaQuery = window.matchMedia(prefersDarkMQ); 70 | const handleChange = () => { 71 | setTheme(mediaQuery.matches ? Theme.DARK : Theme.LIGHT); 72 | }; 73 | mediaQuery.addEventListener("change", handleChange); 74 | return () => mediaQuery.removeEventListener("change", handleChange); 75 | }, []); 76 | 77 | return ( 78 | 79 | {children} 80 | 81 | ); 82 | } 83 | 84 | function useTheme() { 85 | const context = useContext(ThemeContext); 86 | if (context === undefined) { 87 | throw new Error("useTheme must be used within a ThemeProvider"); 88 | } 89 | return context; 90 | } 91 | 92 | const clientThemeCode = ` 93 | ;(() => { 94 | const theme = window.matchMedia(${JSON.stringify(prefersDarkMQ)}).matches 95 | ? 'dark' 96 | : 'light'; 97 | const cl = document.documentElement.classList; 98 | const themeAlreadyApplied = cl.contains('light') || cl.contains('dark'); 99 | if (themeAlreadyApplied) { 100 | // this script shouldn't exist if the theme is already applied! 101 | console.warn( 102 | "Hi there, could you let Zain know you're seeing this message? Thanks!", 103 | ); 104 | } else { 105 | cl.add(theme); 106 | } 107 | 108 | const meta = document.querySelector('meta[name=color-scheme]'); 109 | if (meta) { 110 | if (theme === 'dark') { 111 | meta.content = 'dark light'; 112 | } else if (theme === 'light') { 113 | meta.content = 'light dark'; 114 | } 115 | } else { 116 | console.warn( 117 | "Hey, could you let Zain know you're seeing this message? Thanks!", 118 | ); 119 | } 120 | })(); 121 | `; 122 | 123 | function NonFlashOfWrongTheme({ ssrTheme }: { ssrTheme: boolean }) { 124 | const [theme] = useTheme(); 125 | 126 | return ( 127 | <> 128 | 132 | {ssrTheme ? null : ( 133 |