├── app ├── page.module.css ├── providers.tsx ├── (home) │ ├── layout.tsx │ ├── page.tsx │ ├── projects │ │ └── page.tsx │ └── uses │ │ └── page.tsx ├── guestbook │ └── page.tsx ├── layout.tsx ├── posts │ └── [slug] │ │ └── page.tsx ├── blog │ ├── page.tsx │ └── weeklies │ │ └── page.tsx └── globals.css ├── README.md ├── lexicons.d.ts ├── .yarnrc.yml ├── public ├── favicon.ico ├── og-bg.png ├── tinies.webp ├── natb_cv_fv2.pdf ├── natb_cv_fv6.pdf ├── natb_cv_fv7.pdf ├── on p=np (3).pdf ├── on p=np-prev.pdf └── vercel.svg ├── postcss.config.js ├── siteconfig.json ├── types ├── glsl.d.ts └── mdx.d.ts ├── components ├── shader │ ├── gradient-importer.tsx │ ├── conditional.tsx │ ├── vert.glsl │ ├── noise.glsl │ ├── fragment.glsl │ ├── gradient.tsx │ ├── blend.glsl │ └── wave.js ├── timeago.tsx ├── ui │ ├── card.tsx │ └── iconButton.tsx ├── icons.tsx ├── footer.tsx ├── notPublicHover.tsx ├── ambilight.tsx ├── colortoggle.tsx ├── header.tsx ├── headerLink.tsx ├── time │ ├── currentTime.tsx │ └── ticker.tsx ├── comments │ ├── Comments.tsx │ ├── BlueskyEmbed.tsx │ └── BlueskyReply.tsx ├── mdx.tsx ├── profileCard.tsx ├── scrollingText.tsx ├── disclosure.tsx └── lastfm.tsx ├── mdx-components.tsx ├── .gitignore ├── next.config.js ├── tsconfig.json ├── helpers └── helpers.ts ├── tailwind.config.cjs ├── package.json ├── contentlayer.config.ts └── posts ├── weekly-2025-02-09.mdx ├── weekly-2025-03-25.mdx ├── blog.mdx ├── godot-vsc.mdx ├── deploying-nextjs.mdx ├── game-design.mdx └── weekly-2025-02-02.mdx /app/page.module.css: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## (dog with a) blog 2 | 3 | simple natalie blog -------------------------------------------------------------------------------- /lexicons.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /.yarnrc.yml: -------------------------------------------------------------------------------- 1 | enableImmutableInstalls: false 2 | 3 | nodeLinker: node-modules 4 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/espeon/blog/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /public/og-bg.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/espeon/blog/HEAD/public/og-bg.png -------------------------------------------------------------------------------- /public/tinies.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/espeon/blog/HEAD/public/tinies.webp -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: ["@tailwindcss/postcss"], 3 | }; 4 | -------------------------------------------------------------------------------- /public/natb_cv_fv2.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/espeon/blog/HEAD/public/natb_cv_fv2.pdf -------------------------------------------------------------------------------- /public/natb_cv_fv6.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/espeon/blog/HEAD/public/natb_cv_fv6.pdf -------------------------------------------------------------------------------- /public/natb_cv_fv7.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/espeon/blog/HEAD/public/natb_cv_fv7.pdf -------------------------------------------------------------------------------- /public/on p=np (3).pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/espeon/blog/HEAD/public/on p=np (3).pdf -------------------------------------------------------------------------------- /public/on p=np-prev.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/espeon/blog/HEAD/public/on p=np-prev.pdf -------------------------------------------------------------------------------- /siteconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "site": "blog.natalie.sh", 3 | "name": "Natalie Bridgers" 4 | } -------------------------------------------------------------------------------- /types/glsl.d.ts: -------------------------------------------------------------------------------- 1 | declare module "*.glsl" { 2 | const value: string; 3 | export default value; 4 | } 5 | -------------------------------------------------------------------------------- /types/mdx.d.ts: -------------------------------------------------------------------------------- 1 | import type { JSX } from "react"; 2 | // types/mdx.d.ts 3 | declare module '*.mdx' { 4 | let MDXComponent: (props) => JSX.Element 5 | export default MDXComponent 6 | } 7 | -------------------------------------------------------------------------------- /app/providers.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { ThemeProvider } from "next-themes"; 4 | 5 | export function Providers({ children }) { 6 | return {children}; 7 | } 8 | -------------------------------------------------------------------------------- /components/shader/gradient-importer.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import dynamic from "next/dynamic"; 3 | 4 | // do not ssr gradientreact 5 | const GradientReact = dynamic(() => import("./gradient"), { 6 | ssr: false, 7 | }); 8 | 9 | export default function Gradient() { 10 | return ; 11 | } 12 | -------------------------------------------------------------------------------- /app/(home)/layout.tsx: -------------------------------------------------------------------------------- 1 | import ProfileCard from "@/components/profileCard"; 2 | 3 | export default function Page({ children }) { 4 | return ( 5 |
6 | 7 | {children} 8 |
9 | ); 10 | } 11 | -------------------------------------------------------------------------------- /mdx-components.tsx: -------------------------------------------------------------------------------- 1 | import type { MDXComponents } from 'mdx/types' 2 | 3 | // This file is required to use MDX in `app` directory. 4 | export function useMDXComponents(components: MDXComponents): MDXComponents { 5 | return { 6 | // Allows customizing built-in components, e.g. to add styling. 7 | // h1: ({ children }) =>

{children}

, 8 | ...components, 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /components/timeago.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { timeAgo } from "helpers/helpers"; 4 | 5 | export default function TimeAgo(props: { 6 | date: string; 7 | parentheses?: boolean; 8 | }) { 9 | return ( 10 | 11 | {props.parentheses === true || props.parentheses === undefined ? "(" : ""} 12 | {timeAgo(props.date)} 13 | {props.parentheses === true || props.parentheses === undefined ? ")" : ""} 14 | 15 | ); 16 | } 17 | -------------------------------------------------------------------------------- /components/ui/card.tsx: -------------------------------------------------------------------------------- 1 | import clsx from "clsx"; 2 | import React from "react"; 3 | 4 | const Card = ({ children, className = "", ...props }) => { 5 | return ( 6 |
14 | {children} 15 |
16 | ); 17 | }; 18 | 19 | export default Card; 20 | -------------------------------------------------------------------------------- /.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.js 7 | .yarn/install-state.gz 8 | 9 | # testing 10 | /coverage 11 | 12 | # next.js 13 | /.next/ 14 | /out/ 15 | 16 | # production 17 | /build 18 | 19 | # misc 20 | .DS_Store 21 | *.pem 22 | 23 | # debug 24 | npm-debug.log* 25 | yarn-debug.log* 26 | yarn-error.log* 27 | 28 | # local env files 29 | .env*.local 30 | 31 | # vercel 32 | .vercel 33 | 34 | # typescript 35 | *.tsbuildinfo 36 | next-env.d.ts 37 | 38 | # contentlayer 39 | .contentlayer -------------------------------------------------------------------------------- /public/vercel.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /components/icons.tsx: -------------------------------------------------------------------------------- 1 | import { IoLogoTwitch } from "react-icons/io"; 2 | import { FaGithub, FaTwitch, FaTwitter, FaDiscord } from "react-icons/fa"; 3 | 4 | const IconRow = () => { 5 | let icons = [ 6 | { i: , url: "https://github.com/espeon" }, 7 | { i: , url: "https://twitter.com/ameiwi" }, 8 | { i: , url: "https://twitch.tv/ameiwi" }, 9 | { i: , url: "https://nat.vg/discord" }, 10 | ]; 11 | return ( 12 |
13 | {icons.map((e) => { 14 | return ( 15 |
16 | {e.i} 17 |
18 | ); 19 | })} 20 |
21 | ); 22 | }; 23 | 24 | export default IconRow; -------------------------------------------------------------------------------- /components/footer.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link"; 2 | import { ColorToggle } from "./colortoggle"; 3 | import { PiMusicNoteSimpleFill } from "react-icons/pi"; 4 | import LastFm from "./lastfm"; 5 | 6 | export default function Footer(props) { 7 | 8 | return ( 9 |
15 | 19 |
20 | 21 |
22 |
23 | 24 |
25 | 26 | 27 | 28 |
29 | ); 30 | } 31 | -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | import { withContentlayer } from "next-contentlayer2"; 2 | 3 | /** @type {import('next').NextConfig} */ 4 | const nextConfig = { 5 | output: "export", 6 | pageExtensions: ["js", "jsx", "ts", "tsx", "mdx"], 7 | swcMinify: true, 8 | webpack: (config, { buildId, dev, isServer, defaultLoaders, webpack }) => { 9 | config.module.rules.push({ 10 | test: /\.glsl$/, 11 | exclude: /node_modules/, 12 | use: ["raw-loader", "glslify-loader"], 13 | }); 14 | 15 | return { 16 | ...config, 17 | optimization: { 18 | // weird error with cssnanosimple 19 | minimize: false, 20 | }, 21 | }; 22 | }, 23 | experimental: { 24 | turbo: { 25 | rules: { 26 | "*.glsl": { 27 | loaders: ["raw-loader"], 28 | as: "*.js", 29 | }, 30 | }, 31 | }, 32 | }, 33 | }; 34 | 35 | export default withContentlayer(nextConfig); 36 | -------------------------------------------------------------------------------- /app/guestbook/page.tsx: -------------------------------------------------------------------------------- 1 | import Comments from "@/components/comments/Comments"; 2 | import Card from "@/components/ui/card"; 3 | 4 | export default function CommentsTest() { 5 | return ( 6 |
7 | 8 |

Guestbook

9 |
10 | Join in{" "} 11 | 15 | on Bluesky 16 | 17 | . 18 |
19 |
20 | 25 |
26 |
27 |
28 | ); 29 | } 30 | -------------------------------------------------------------------------------- /components/ui/iconButton.tsx: -------------------------------------------------------------------------------- 1 | import clsx from "clsx"; 2 | import { IconType } from "react-icons/lib"; 3 | interface IconButtonProps extends React.HTMLAttributes { 4 | className?: string; 5 | Icon: IconType; 6 | onClick?: () => void; 7 | ariaLabel?: string; 8 | disabled?: boolean; 9 | } 10 | 11 | export const IconButton = (props: IconButtonProps) => { 12 | return ( 13 | // tailwindcss button 14 | 26 | ); 27 | }; 28 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": [ 5 | "dom", 6 | "dom.iterable", 7 | "esnext" 8 | ], 9 | "allowJs": true, 10 | "skipLibCheck": true, 11 | //"strict": true, 12 | "forceConsistentCasingInFileNames": true, 13 | "noEmit": true, 14 | "esModuleInterop": true, 15 | "module": "esnext", 16 | "moduleResolution": "Bundler", 17 | "resolveJsonModule": true, 18 | "isolatedModules": true, 19 | "jsx": "preserve", 20 | "incremental": true, 21 | "baseUrl": ".", 22 | "paths": { 23 | "contentlayer/generated": [ 24 | "./.contentlayer/generated" 25 | ], 26 | "@/*": [ 27 | "./*" 28 | ] 29 | }, 30 | "plugins": [ 31 | { 32 | "name": "next" 33 | } 34 | ], 35 | "strict": false 36 | }, 37 | "include": [ 38 | "next-env.d.ts", 39 | "**/*.ts", 40 | "**/*.tsx", 41 | ".next/types/**/*.ts", 42 | ".contentlayer/generated" 43 | ], 44 | "exclude": [ 45 | "node_modules" 46 | ] 47 | } 48 | -------------------------------------------------------------------------------- /helpers/helpers.ts: -------------------------------------------------------------------------------- 1 | /// gives us a string like "2 days ago" or "a year ago" 2 | 3 | export const timeAgo = (date: string) => { 4 | const time = new Date(date).getTime(); 5 | const now = new Date().getTime(); 6 | const diff = now - time > 0 ? now - time : time - now; 7 | const seconds = diff / 1000; 8 | const minutes = seconds / 60; 9 | const hours = minutes / 60; 10 | let ret = ""; 11 | if (hours < 24) { 12 | ret = `${Math.floor(hours)} hours ago`; 13 | } else if (hours < 48) { 14 | ret = `a day ago`; 15 | } else if (hours < 24 * 30) { 16 | ret = `${Math.floor(hours / 24)} days ago`; 17 | } else if (hours < 24 * 60) { 18 | ret = `a month ago`; 19 | } else if (hours < 24 * 365) { 20 | ret = `${Math.floor(hours / 24 / 30)} months ago`; 21 | } else if (hours < 24 * 365 * 2) { 22 | ret = `a year ago`; 23 | } else { 24 | ret = `${Math.floor(hours / 24 / 365)} years ago`; 25 | } 26 | // handle if the date is in the future 27 | if (now - time < 0) { 28 | ret = "in " + ret.substring(ret.startsWith("-") ? 1 : 0, ret.length - 4); 29 | } 30 | return ret; 31 | }; 32 | -------------------------------------------------------------------------------- /components/shader/conditional.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { usePathname } from "next/navigation"; 4 | import { ReactNode } from "react"; 5 | 6 | interface ConditionalProps { 7 | paths: string[]; 8 | children: ReactNode; 9 | defaultComponent: ReactNode; 10 | } 11 | 12 | export function Conditional({ 13 | paths, 14 | children, 15 | defaultComponent, 16 | }: ConditionalProps) { 17 | const pathname = usePathname(); 18 | 19 | const isPathMatch = paths.some((path) => { 20 | // Convert both strings to lowercase for case-insensitive comparison 21 | const normalizedPath = path.toLowerCase(); 22 | const normalizedPathname = pathname.toLowerCase(); 23 | 24 | // Check if the pathname starts with the given path 25 | // This allows for matching nested routes as well 26 | const match = (normalizedPathname: string, normalizedPath: string) => { 27 | if (normalizedPath == "/") { 28 | return normalizedPathname === normalizedPath; 29 | } else { 30 | return normalizedPathname.startsWith(normalizedPath); 31 | } 32 | }; 33 | return match(normalizedPathname, normalizedPath); 34 | }); 35 | 36 | return isPathMatch ? <>{children} : <>{defaultComponent}; 37 | } 38 | -------------------------------------------------------------------------------- /components/notPublicHover.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import { useRef, useState } from "react"; 3 | import { PiEyeClosed, PiEye, PiWarning } from "react-icons/pi"; 4 | 5 | export const NotPublicHover = () => { 6 | const [isHovered, setIsHovered] = useState(false); 7 | const hoverRef = useRef(null); 8 | 9 | const handleMouseEnter = () => { 10 | setIsHovered(true); 11 | }; 12 | 13 | const handleMouseLeave = () => { 14 | // 2.5 seconds after mouse leave 15 | setTimeout(() => { 16 | setIsHovered(false); 17 | }, 1500); 18 | }; 19 | 20 | return ( 21 |
27 |
28 | 29 | This post is not public. Be careful who you share this with. 30 |
31 | {isHovered?: 32 | } 33 |
34 | ); 35 | }; 36 | -------------------------------------------------------------------------------- /components/ambilight.tsx: -------------------------------------------------------------------------------- 1 | export default function Ambilight() { 2 | return ( 3 | 4 | 12 | 13 | 19 | 28 | 34 | 39 | 40 | 45 | 46 | 47 | ); 48 | } 49 | -------------------------------------------------------------------------------- /tailwind.config.cjs: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | module.exports = { 3 | darkMode: "class", 4 | content: [ 5 | "./app/**/*.{js,ts,jsx,tsx,mdx}", 6 | "./pages/**/*.{js,ts,jsx,tsx,mdx}", 7 | "./components/**/*.{js,ts,jsx,tsx,mdx}", 8 | 9 | // Or if using `src` directory: 10 | "./src/**/*.{js,ts,jsx,tsx,mdx}", 11 | ], 12 | theme: { 13 | extend: { 14 | colors: { 15 | wisteria: { 16 | 50: "oklch(0.955 0.012 314.384)", 17 | 100: "oklch(0.910 0.55 314.384)", 18 | 200: "oklch(0.820 0.100 314.384)", 19 | 300: "oklch(0.730 0.105 314.384)", 20 | 400: "oklch(0.640 0.120 314.384)", 21 | 500: "oklch(0.550 0.100 314.384)", 22 | 600: "oklch(0.445 0.084 314.384)", 23 | 700: "oklch(0.334 0.081 314.384)", 24 | 800: "oklch(0.223 0.078 314.384)", 25 | 900: "oklch(0.111 0.076 314.384)", 26 | 950: "oklch(0.056 0.074 314.384)", 27 | }, 28 | }, 29 | fontFamily: { 30 | sans: ["var(--font-figtree)"], 31 | mono: ["var(--font-ibm-plex-mono)"], 32 | }, 33 | typography: { 34 | DEFAULT: { 35 | css: { 36 | "blockquote p:first-of-type::before": { content: "none" }, 37 | "blockquote p:first-of-type::after": { content: "none" }, 38 | }, 39 | }, 40 | }, 41 | }, 42 | }, 43 | plugins: [require("@tailwindcss/typography")], 44 | }; 45 | -------------------------------------------------------------------------------- /components/colortoggle.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import { useTheme } from "next-themes"; 3 | import { useEffect, useState } from "react"; 4 | import { IoMdMoon, IoMdRefreshCircle, IoMdSunny } from "react-icons/io"; 5 | import { LuMoon, LuSun, LuSunMoon } from "react-icons/lu"; 6 | import { IconButton } from "./ui/iconButton"; 7 | 8 | const other = (theme: string) => { 9 | if (theme === "dark") { 10 | return "light"; 11 | } else { 12 | if (theme === "system") { 13 | const systemTheme = window.matchMedia("(prefers-color-scheme: dark)"); 14 | return systemTheme.matches ? "light" : "dark"; 15 | } else { 16 | return "dark"; 17 | } 18 | } 19 | }; 20 | 21 | export const ColorToggle = () => { 22 | const { theme, setTheme } = useTheme(); 23 | const [mounted, setMounted] = useState(false); 24 | useEffect(() => { 25 | setMounted(true); 26 | }, []); 27 | let isDark: boolean; 28 | if (theme === "system") { 29 | const systemTheme = window.matchMedia("(prefers-color-scheme: dark)"); 30 | isDark = systemTheme.matches; 31 | } else { 32 | isDark = theme === "dark"; 33 | } 34 | const DarkLightIcon = mounted ? (isDark ? LuMoon : LuSun) : LuSunMoon; 35 | return ( 36 | // tailwindcss button 37 | { 42 | setTheme(other(theme)); 43 | }} 44 | /> 45 | ); 46 | }; 47 | -------------------------------------------------------------------------------- /components/header.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link"; 2 | import { ColorToggle } from "./colortoggle"; 3 | import clsx from "clsx"; 4 | import { HTMLAttributes } from "react"; 5 | import HeaderLink from "./headerLink"; 6 | import { IconButton } from "./ui/iconButton"; 7 | import { LuCommand } from "react-icons/lu"; 8 | 9 | const links = [ 10 | { href: "/", label: "Home" }, 11 | { href: "/blog", label: "Blog", alsoMatch: ["/posts"] }, 12 | { 13 | href: "/projects", 14 | label: ( 15 | <> 16 |
Projects
17 |
🏗
18 | 19 | ), 20 | }, 21 | { 22 | href: "/guestbook", 23 | label: ( 24 | <> 25 |
Guestbook
26 |
🪶
27 | 28 | ), 29 | }, 30 | { 31 | href: "/uses", 32 | label: ( 33 | <> 34 |
/uses
35 |
💻
36 | 37 | ), 38 | }, 39 | ]; 40 | 41 | export default function Header(props: HTMLAttributes) { 42 | return ( 43 |
50 |
51 |
52 | {links.map((link) => ( 53 | 54 | ))} 55 |
56 |
57 | 62 | 63 |
64 |
65 |
66 | ); 67 | } 68 | -------------------------------------------------------------------------------- /components/headerLink.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import clsx from "clsx"; 3 | import Link from "next/link"; 4 | import { usePathname } from "next/navigation"; 5 | import type { JSX } from "react"; 6 | export default function HeaderLink(props: { 7 | href: string; 8 | label: string | JSX.Element; 9 | alsoMatch?: string[]; 10 | }) { 11 | const pathname = usePathname(); 12 | const match = () => { 13 | if (props.href == "/") { 14 | return props.href === pathname; 15 | } 16 | if (props.alsoMatch) { 17 | let res = props.alsoMatch.some((alsoMatch) => 18 | pathname.startsWith(alsoMatch), 19 | ); 20 | if (res) return true; 21 | } 22 | return pathname.startsWith(props.href) || props.href === pathname; 23 | }; 24 | const isActive = match(); 25 | return ( 26 | 36 | 42 |
43 | {props.label} 44 |
45 |
46 | 47 | {props.label} 48 | 49 | ); 50 | } 51 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "type": "module", 4 | "scripts": { 5 | "dev": "NODE_OPTIONS='--inspect' next dev --turbo", 6 | "build": "next build", 7 | "start": "next start", 8 | "build-docs": "contentlayer2 build" 9 | }, 10 | "dependencies": { 11 | "@atcute/bluesky": "^1.0.12", 12 | "@atcute/client": "^2.0.7", 13 | "@atcute/lex-cli": "^1.0.4", 14 | "@catppuccin/vscode": "^3.16.0", 15 | "@headlessui/react": "^2.2.0", 16 | "@react-three/drei": "^9.121.4", 17 | "@react-three/fiber": "^8.17.14", 18 | "@tailwindcss/postcss": "^4.0.5", 19 | "clsx": "^2.1.1", 20 | "contentlayer2": "0.5.3", 21 | "date-fns": "^4.1.0", 22 | "glsl-dither": "^1.0.1", 23 | "glsl-luma": "^1.0.1", 24 | "glsl-noise": "^0.0.0", 25 | "glslify": "^7.1.1", 26 | "glslify-loader": "^2.0.0", 27 | "mdx-bundler": "^10.1.0", 28 | "next": "15.1.6", 29 | "next-contentlayer2": "0.5.3", 30 | "next-themes": "^0.4.4", 31 | "raw-loader": "^4.0.2", 32 | "react": "19.0.0", 33 | "react-crossfade-simple": "^1.0.7", 34 | "react-dom": "19.0.0", 35 | "react-icons": "^5.4.0", 36 | "reading-time": "^1.5.0", 37 | "rehype-autolink-headings": "^7.1.0", 38 | "rehype-pretty-code": "^0.14.0", 39 | "rehype-slug": "^6.0.0", 40 | "remark-gfm": "4.0.0", 41 | "shiki": "1.9.1", 42 | "swr": "^2.3.2", 43 | "three": "^0.173.0" 44 | }, 45 | "devDependencies": { 46 | "@tailwindcss/typography": "latest", 47 | "@types/mdx": "2.0.13", 48 | "@types/node": "22.13.1", 49 | "@types/react": "19.0.8", 50 | "@types/react-dom": "19.0.3", 51 | "autoprefixer": "^10.4.20", 52 | "postcss": "^8.5.1", 53 | "tailwindcss": "^4.0.5", 54 | "typescript": "5.7.3" 55 | }, 56 | "pnpm": { 57 | "overrides": { 58 | "@types/react": "19.0.8", 59 | "@types/react-dom": "19.0.3" 60 | }, 61 | "ignoredBuiltDependencies": [ 62 | "contentlayer2" 63 | ] 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /app/(home)/page.tsx: -------------------------------------------------------------------------------- 1 | import Header from "@/components/header"; 2 | 3 | // app/page.tsx 4 | import Link from "next/link"; 5 | import { PrettyImage } from "@/components/mdx"; 6 | import LastFm from "@/components/lastfm"; 7 | import Card from "@/components/ui/card"; 8 | import { FaDiscord, FaGithub, FaLastfm, FaLinkedin } from "react-icons/fa"; 9 | import CurrentTime from "@/components/time/currentTime"; 10 | import ProfileCard from "@/components/profileCard"; 11 | 12 | export default function Page() { 13 | return ( 14 |
15 | 16 |
17 | Who am I? 18 |
19 |

20 | I'm a recent college grad, with a major in Computer Science. In my 21 | free time, I like to make fast things to solve cool problems. 22 |

23 |

24 | Outside of coding, I spend my time creating and listening to music, 25 | going to concerts, reading, or experimenting in the kitchen. You’ll 26 | often find me working on my homelab, listening to music, or hanging 27 | out with friends. 28 |

29 |
30 | 31 | 32 | 33 | 34 |
35 | 40 | 41 | in Nashville, TN 42 |
43 |
44 |
45 | ); 46 | } 47 | -------------------------------------------------------------------------------- /components/time/currentTime.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import { useEffect, useState } from "react"; 3 | import { useRouter } from "next/navigation"; 4 | import clsx from "clsx"; 5 | import AnimatedCounter from "./ticker"; 6 | 7 | interface CurrentTimeProps { 8 | className?: string; 9 | displayMs?: boolean; 10 | msPrecision?: number; 11 | } 12 | 13 | const defaultProps: CurrentTimeProps = { 14 | className: "", 15 | displayMs: false, 16 | msPrecision: 0, 17 | }; 18 | 19 | function getDate(timeZone: string) { 20 | return new Date( 21 | new Date().toLocaleString("en-US", { 22 | timeZone, 23 | }), 24 | ); 25 | } 26 | 27 | export default function CurrentTime({ ...props }: CurrentTimeProps) { 28 | const [time, setTime] = useState(getDate("America/Chicago")); 29 | const router = useRouter(); 30 | 31 | useEffect(() => { 32 | const interval = setInterval(() => { 33 | // get date in CST 34 | setTime(getDate("America/Chicago")); 35 | }, 1000); 36 | 37 | return () => clearInterval(interval); 38 | }, []); 39 | 40 | return ( 41 |
47 | 54 | : 55 | 62 | : 63 | 70 | {props.displayMs ? ( 71 | <> 72 | . 73 | 80 | 81 | ) : null} 82 |
83 | ); 84 | } 85 | -------------------------------------------------------------------------------- /components/shader/vert.glsl: -------------------------------------------------------------------------------- 1 | varying vec3 v_color; 2 | 3 | void main() { 4 | float time = u_time * u_global.noiseSpeed; 5 | 6 | vec2 noiseCoord = resolution * uvNorm * u_global.noiseFreq; 7 | 8 | vec2 st = 1. - uvNorm.xy; 9 | 10 | // 11 | // Tilting the plane 12 | // 13 | 14 | // Front-to-back tilt 15 | float tilt = resolution.y / 2.0 * uvNorm.y; 16 | 17 | // Left-to-right angle 18 | float incline = resolution.x * uvNorm.x / 2.0 * u_vertDeform.incline; 19 | 20 | // Up-down shift to offset incline 21 | float offset = resolution.x / 2.0 * u_vertDeform.incline * mix(u_vertDeform.offsetBottom, u_vertDeform.offsetTop, uv.y); 22 | 23 | // 24 | // Vertex noise 25 | // 26 | 27 | float noise = snoise(vec3( 28 | noiseCoord.x * u_vertDeform.noiseFreq.x + time * u_vertDeform.noiseFlow, 29 | noiseCoord.y * u_vertDeform.noiseFreq.y, 30 | time * u_vertDeform.noiseSpeed + u_vertDeform.noiseSeed 31 | )) * u_vertDeform.noiseAmp; 32 | 33 | // Fade noise to zero at edges 34 | noise *= 1.0 - pow(abs(uvNorm.y), 2.0); 35 | 36 | // Clamp to 0 37 | noise = max(0.0, noise); 38 | 39 | vec3 pos = vec3( 40 | position.x, 41 | position.y + tilt + incline + noise - offset, 42 | position.z 43 | ); 44 | 45 | // 46 | // Vertex color, to be passed to fragment shader 47 | // 48 | 49 | if (u_active_colors[0] == 1.) { 50 | v_color = u_baseColor; 51 | } 52 | 53 | for (int i = 0; i < u_waveLayers_length; i++) { 54 | if (u_active_colors[i + 1] == 1.) { 55 | WaveLayers layer = u_waveLayers[i]; 56 | 57 | float noise = smoothstep( 58 | layer.noiseFloor, 59 | layer.noiseCeil, 60 | snoise(vec3( 61 | noiseCoord.x * layer.noiseFreq.x + time * layer.noiseFlow, 62 | noiseCoord.y * layer.noiseFreq.y, 63 | time * layer.noiseSpeed + layer.noiseSeed 64 | )) / 2.0 + 0.5 65 | ); 66 | 67 | v_color = blendNormal(v_color, layer.color, pow(noise, 4.)); 68 | } 69 | } 70 | 71 | // 72 | // Finish 73 | // 74 | 75 | gl_Position = projectionMatrix * modelViewMatrix * vec4(pos, 1.0); 76 | } 77 | -------------------------------------------------------------------------------- /app/layout.tsx: -------------------------------------------------------------------------------- 1 | import "./globals.css"; 2 | import { IBM_Plex_Mono, Figtree } from "next/font/google"; 3 | import { Providers } from "./providers"; 4 | import Footer from "@/components/footer"; 5 | import Header from "@/components/header"; 6 | import { Conditional } from "@/components/shader/conditional"; 7 | import { Suspense } from "react"; 8 | import Gradient from "@/components/shader/gradient-importer"; 9 | 10 | const mono = IBM_Plex_Mono({ 11 | weight: "400", 12 | style: ["normal", "italic"], 13 | subsets: ["latin"], 14 | variable: "--font-ibm-plex-mono", 15 | }); 16 | const poly = Figtree({ 17 | weight: "variable", 18 | subsets: ["latin"], 19 | variable: "--font-figtree", 20 | }); 21 | 22 | export default function RootLayout({ 23 | children, 24 | }: { 25 | children: React.ReactNode; 26 | }) { 27 | return ( 28 | 33 | 34 | 35 |
36 |
39 | } 42 | > 43 | 44 | 45 | 46 | 47 |
48 |
52 | } 53 | > 54 |
55 | 56 |
57 | {children} 58 |
59 |
60 | 61 | 62 | 63 | ); 64 | } 65 | 66 | export const metadata = { 67 | title: "natalie's website", 68 | // good description for seo 69 | description: " ", 70 | }; 71 | -------------------------------------------------------------------------------- /contentlayer.config.ts: -------------------------------------------------------------------------------- 1 | import { defineDocumentType, makeSource } from "contentlayer2/source-files"; 2 | 3 | import latte from "@catppuccin/vscode/themes/latte.json" with { type: "json" }; 4 | import mocha from "@catppuccin/vscode/themes/mocha.json" with { type: "json" }; 5 | 6 | import remarkGfm from "remark-gfm"; 7 | import rehypePrettyCode from "rehype-pretty-code"; 8 | import rehypeSlug from "rehype-slug"; 9 | import rehypeAutolinkHeadings from "rehype-autolink-headings"; 10 | import { readFileSync } from "fs"; 11 | 12 | export const Post = defineDocumentType(() => ({ 13 | name: "Post", 14 | filePathPattern: `**/*.mdx`, 15 | contentType: "mdx", 16 | fields: { 17 | title: { type: "string", required: true }, 18 | datePublished: { type: "date", required: true }, 19 | lastUpdated: { type: "date", required: false }, 20 | summary: { type: "string", required: true }, 21 | public: { type: "boolean", required: false, default: true }, 22 | coAuthors: { type: "list", of: { type: "string" }, required: false }, 23 | coAuthorPFPs: { type: "list", of: { type: "string" }, required: false }, 24 | isWeekly: { type: "boolean", required: false }, 25 | }, 26 | computedFields: { 27 | url: { 28 | type: "string", 29 | resolve: (post) => `/posts/${post._raw.flattenedPath}`, 30 | }, 31 | }, 32 | })); 33 | 34 | export default makeSource({ 35 | contentDirPath: "posts", 36 | documentTypes: [Post], 37 | mdx: { 38 | remarkPlugins: [remarkGfm], 39 | rehypePlugins: [ 40 | rehypeSlug, 41 | [ 42 | rehypePrettyCode, 43 | { 44 | theme: { 45 | dark: mocha, 46 | light: latte, 47 | }, 48 | onVisitLine(node) { 49 | // Prevent lines from collapsing in `display: grid` mode, and allow empty 50 | // lines to be copy/pasted 51 | if (node.children.length === 0) { 52 | node.children = [{ type: "text", value: " " }]; 53 | } 54 | }, 55 | onVisitHighlightedLine(node) { 56 | node.properties.className.push("line--highlighted"); 57 | }, 58 | onVisitHighlightedWord(node) { 59 | node.properties.className = ["word--highlighted"]; 60 | }, 61 | }, 62 | ], 63 | [ 64 | rehypeAutolinkHeadings, 65 | { 66 | properties: { 67 | className: ["anchor"], 68 | }, 69 | }, 70 | ], 71 | ], 72 | }, 73 | }); 74 | -------------------------------------------------------------------------------- /components/comments/Comments.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import { useEffect, useState } from "react"; 3 | import { 4 | AppBskyFeedGetPostThread, 5 | AppBskyFeedDefs, 6 | Brand, 7 | } from "@atcute/client/lexicons"; 8 | import BlueskyReply from "./BlueskyReply"; 9 | 10 | export interface CommentsProps { 11 | // The DID of the OP 12 | did: string; 13 | // The CID of the app.bsky.feed.post 14 | postCid: string; 15 | skipFirst: boolean; 16 | } 17 | 18 | type ThreadView = Brand.Union; 19 | 20 | function isThreadView(thread: unknown): thread is ThreadView { 21 | return (thread as ThreadView)?.$type === "app.bsky.feed.defs#threadViewPost"; 22 | } 23 | 24 | export default function Comments({ did, postCid, skipFirst }: CommentsProps) { 25 | const [comments, setComments] = 26 | useState(null); 27 | 28 | useEffect(() => { 29 | const params: AppBskyFeedGetPostThread.Params = { 30 | uri: `at://${did}/app.bsky.feed.post/${postCid}`, // Replace with actual AT-URI 31 | depth: 6, // Optional: how deep to fetch replies (default is 6) 32 | }; 33 | const searchParams = new URLSearchParams(); 34 | searchParams.append("uri", params.uri); 35 | if (params.depth !== undefined) { 36 | searchParams.append("depth", params.depth.toString()); 37 | } 38 | if (params.parentHeight !== undefined) { 39 | searchParams.append("parentHeight", params.parentHeight.toString()); 40 | } 41 | fetch( 42 | "https://public.api.bsky.app/xrpc/app.bsky.feed.getPostThread?" + 43 | searchParams, 44 | ) 45 | .then((res) => res.json()) 46 | .then((data) => { 47 | console.log(data); 48 | setComments(data); 49 | }); 50 | }, []); 51 | 52 | // if we are loading 53 | if (!comments) { 54 | return ( 55 |
56 | Loading... 57 | 60 |
61 | ); 62 | } 63 | 64 | // if we have error prop? 65 | if (!isThreadView(comments?.thread)) { 66 | return
Error: {(comments as any).error}
; 67 | } 68 | 69 | if (isThreadView(comments.thread)) { 70 | return ( 71 |
72 | {" "} 73 |
74 | ); 75 | } 76 | 77 | return
Comment count:
; 78 | } 79 | -------------------------------------------------------------------------------- /posts/weekly-2025-02-09.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Weekly #2: Week of 2025-02-09" 3 | datePublished: "2025-02-17" 4 | summary: "Slow week, but not a bad week." 5 | isWeekly: true 6 | --- 7 | 8 | ## Hello! 9 | 10 | Welcome to my second 'weekly' post! If you don't already know, this is my series of posts on what I did over the past week. 11 | Most of the 'work' I did was on my project [atp.tools](https://atp.tools), which is a collection of tools for reading and doing ATProto things. 12 | 13 | It was a slower week than usual, so I hope you forgive the shorter post. 14 | 15 | ## Code 16 | 17 | Most of my code was focused on adding new features (including a minigame!) to make [atp.tools](https://atp.tools) that much more fun and engaging. 18 | I don't want to talk about the minigame too much, but I'm really excited for people to find it, try it out, 19 | and hopefully post about it on Bluesky! I'm planning on creating an AppView, and leaderboards, and some social features, such as posting your scores directly to Bluesky. 20 | 21 | I've also started working on integrating the [Constellation](https://github.com/atcosm/links/tree/main/constellation) API into atp.tools. 22 | Right now it's pretty basic, but I have plenty of ideas on how to integrate it more fully into the site. 23 | 24 | A few days ago, there was a post on Bluesky going over CORS and why they don't particularly like it. I saw that, said "glad all of my services have cors enabled" 25 | and immediately checked [Geranium](https://github.com/espeon/geranium). oops. I'm not sure what I was thinking, but I'm glad I checked it out. 26 | Ended up setting up and permanently enabling CORS immediately after. 27 | 28 | ## Music 29 | 30 | Been listening to a lot of [Enter Shikari](https://tidal.com/browse/artist/3581716?u) recently. They're a great band but not known nearly so much as they should be in the US. 31 | Like a Robbie Williams. 32 | 33 | Also, [Atlas Genius](https://tidal.com/browse/artist/4603417?u)! I haven't listened to them since high school before this week or so, but it's nice to hear them again. 34 | 35 | Spiritbox also released a new single, "[No Loss, No Love](https://tidal.com/browse/album/415780330?u)". Sounds like a return to their roots, focusing less on melodic elements and more on raw intensity and Courtney's screams. 36 | She does a lot of screaming here, definitely more like [their self titled](https://tidal.com/browse/album/168954033?u) versus [The Fear of Fear](https://tidal.com/browse/album/323766599?u). 37 | 38 | Kyle Gordon also released a new track, "[We Will Never Die](https://tidal.com/browse/track/416327772?u)"! It's legitimately a good song (if you consider it's a parody) in the vein of early Vance Joy, Fun, The Lumineers and other millennial pop-folk stuff. 39 | 40 | ## Yeah. 41 | 42 | That should be it. Glad I released this post in time. I definitely enjoyed writing this, and I'm looking forward to the next one. See you all in a week! 43 | 44 | ## Comments 45 | 46 | Thoughts? Questions? Feedback? Let me know [on Bluesky](https://bsky.app/profile/natalie.sh/post/3liebm4lgoc2g)! 47 | 48 | -------------------------------------------------------------------------------- /components/mdx.tsx: -------------------------------------------------------------------------------- 1 | import { useMDXComponent } from "next-contentlayer2/hooks"; 2 | import Link from "next/link"; 3 | import Comments from "./comments/Comments"; 4 | 5 | /// pretty img wrapper with rounded edges and an optional caption 6 | export function PrettyImage({ 7 | src, 8 | alt, 9 | caption, 10 | round, 11 | height, 12 | width, 13 | className, 14 | noShadow, 15 | }: { 16 | src: string; 17 | alt: string; 18 | caption?: string; 19 | round?: boolean; 20 | noShadow?: boolean; 21 | height?: string; 22 | width?: string; 23 | className?: string; 24 | }) { 25 | return ( 26 |
30 | {alt} 42 | {caption && ( 43 |
44 |
45 | {caption} 46 |
47 |
48 | )} 49 |
50 | ); 51 | } 52 | 53 | // an embed card, similar to discord's oembed cards 54 | export function EmbedCard({ 55 | url, 56 | title, 57 | description, 58 | thumbnail, 59 | noShadow, 60 | }: { 61 | url: string; 62 | title: string; 63 | description: string; 64 | thumbnail?: string; 65 | noShadow?: boolean; 66 | }) { 67 | return ( 68 |
69 |
70 | {thumbnail != undefined ? ( 71 |
72 | 78 |
79 | ) : ( 80 | "" 81 | )} 82 | 83 |
84 | {title} 85 |
86 |
87 |
{description}
88 |
89 |
90 | ); 91 | } 92 | 93 | const components = { 94 | Image: PrettyImage, 95 | Card: EmbedCard, 96 | Comments, 97 | }; 98 | 99 | export default function MDX({ code }: { code: string }) { 100 | const MDXContent = useMDXComponent(code); 101 | return ( 102 |
103 | 104 |
105 | ); 106 | } 107 | -------------------------------------------------------------------------------- /app/posts/[slug]/page.tsx: -------------------------------------------------------------------------------- 1 | import { use } from "react"; 2 | // app/posts/[slug]/page.tsx 3 | import Header from "@/components/header"; 4 | import { allPosts } from "contentlayer/generated"; 5 | import { format, parseISO } from "date-fns"; 6 | import { useMDXComponent } from "next-contentlayer2/hooks"; 7 | import { notFound } from "next/navigation"; 8 | import MDX from "@/components/mdx"; 9 | import TimeAgo from "@/components/timeago"; 10 | import { PiEyeClosed, PiWarning } from "react-icons/pi"; 11 | import { NotPublicHover } from "@/components/notPublicHover"; 12 | 13 | import "../../page.module.css"; 14 | 15 | export async function generateStaticParams() { 16 | return allPosts.map((post) => ({ slug: post._raw.flattenedPath })); 17 | } 18 | 19 | export async function generateMetadata({ params }) { 20 | const p = await params; 21 | const post = allPosts.find((post) => { 22 | return post._raw.flattenedPath === p.slug; 23 | }); 24 | if (!post) { 25 | return; 26 | } 27 | 28 | const { title, datePublished, summary: description } = post; 29 | return { 30 | title, 31 | description, 32 | openGraph: { 33 | title, 34 | description, 35 | type: "article", 36 | publishedTime: datePublished, 37 | url: `blog.natalie.sh/posts/${post._raw.flattenedPath}`, 38 | images: [ 39 | { 40 | url: `https://ogimage-workers.kanbaru.workers.dev/?title=${encodeURIComponent(title)}&liner=${encodeURIComponent(description)}&date=${format(parseISO(post.datePublished), "MMM. dd, yyyy")}`, 41 | width: 1200, 42 | height: 630, 43 | }, 44 | ], 45 | }, 46 | }; 47 | } 48 | 49 | export default function Page(props: { params: Promise<{ slug: string }> }) { 50 | const params = use(props.params); 51 | // Find the post for the current page. 52 | const post = allPosts.find((post) => { 53 | console.log(post._raw.flattenedPath); 54 | return post._raw.flattenedPath === params.slug; 55 | }); 56 | 57 | // 404 if the post does not exist. 58 | if (!post) notFound(); 59 | 60 | // Parse the MDX file via the useMDXComponent hook. 61 | const MDXContent = useMDXComponent(post.body.code); 62 | return ( 63 |
64 |

65 | {post.public ? "" : } 66 | {post.title} 67 |

68 | {post.coAuthors && ( 69 |
70 | With {post.coAuthors.join(", ")} 71 |
72 | )} 73 |
74 | {format(parseISO(post.datePublished), "MMM. dd, yyyy")} 75 | 76 | {" "} 77 | - 78 | 79 |
80 | {post.lastUpdated && ( 81 | <> 82 | {" "} 83 | Last updated {format( 84 | parseISO(post.lastUpdated), 85 | "MMM. dd, yyyy", 86 | )}{" "} 87 | - . 88 | 89 | )} 90 |
91 |
92 | 93 |
94 | ); 95 | } 96 | -------------------------------------------------------------------------------- /components/profileCard.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link"; 2 | import { PrettyImage } from "./mdx"; 3 | import Card from "./ui/card"; 4 | import { FaGithub, FaLinkedin, FaLastfm, FaDiscord } from "react-icons/fa"; 5 | 6 | export default function ProfileCard() { 7 | return ( 8 | 9 |
10 |
11 | 18 |
19 |
20 | Hey, I'm Natalie. 21 |
22 |
23 | Software engineer. Designer. Amateur chef. 24 |
{" "} 25 | 26 | you can write anything here! 27 | 28 |
29 |
30 | inquiries? shoot me an email at nat @ natalie dot sh 31 |
32 |
33 | 34 | 35 | 36 | 40 | 41 | 42 | 43 | 49 | Bluesky butterfly logo 50 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 |
63 |
64 |
65 | ); 66 | } 67 | -------------------------------------------------------------------------------- /components/shader/noise.glsl: -------------------------------------------------------------------------------- 1 | // 2 | // Description : Array and textureless GLSL 2D/3D/4D simplex 3 | // noise functions. 4 | // Author : Ian McEwan, Ashima Arts. 5 | // Maintainer : stegu 6 | // Lastmod : 20110822 (ijm) 7 | // License : Copyright (C) 2011 Ashima Arts. All rights reserved. 8 | // Distributed under the MIT License. See LICENSE file. 9 | // https://github.com/ashima/webgl-noise 10 | // https://github.com/stegu/webgl-noise 11 | // 12 | vec3 mod289(vec3 x) { 13 | return x - floor(x * (1.0 / 289.0)) * 289.0; 14 | } 15 | 16 | vec4 mod289(vec4 x) { 17 | return x - floor(x * (1.0 / 289.0)) * 289.0; 18 | } 19 | 20 | vec4 permute(vec4 x) { 21 | return mod289(((x * 34.0) + 1.0) * x); 22 | } 23 | 24 | vec4 taylorInvSqrt(vec4 r) 25 | { 26 | return 1.79284291400159 - 0.85373472095314 * r; 27 | } 28 | 29 | float snoise(vec3 v) 30 | { 31 | const vec2 C = vec2(1.0 / 6.0, 1.0 / 3.0); 32 | const vec4 D = vec4(0.0, 0.5, 1.0, 2.0); 33 | 34 | // First corner 35 | vec3 i = floor(v + dot(v, C.yyy)); 36 | vec3 x0 = v - i + dot(i, C.xxx); 37 | 38 | // Other corners 39 | vec3 g = step(x0.yzx, x0.xyz); 40 | vec3 l = 1.0 - g; 41 | vec3 i1 = min(g.xyz, l.zxy); 42 | vec3 i2 = max(g.xyz, l.zxy); 43 | 44 | // x0 = x0 - 0.0 + 0.0 * C.xxx; 45 | // x1 = x0 - i1 + 1.0 * C.xxx; 46 | // x2 = x0 - i2 + 2.0 * C.xxx; 47 | // x3 = x0 - 1.0 + 3.0 * C.xxx; 48 | vec3 x1 = x0 - i1 + C.xxx; 49 | vec3 x2 = x0 - i2 + C.yyy; // 2.0*C.x = 1/3 = C.y 50 | vec3 x3 = x0 - D.yyy; // -1.0+3.0*C.x = -0.5 = -D.y 51 | 52 | // Permutations 53 | i = mod289(i); 54 | vec4 p = permute(permute(permute( 55 | i.z + vec4(0.0, i1.z, i2.z, 1.0)) 56 | + i.y + vec4(0.0, i1.y, i2.y, 1.0)) 57 | + i.x + vec4(0.0, i1.x, i2.x, 1.0)); 58 | 59 | // Gradients: 7x7 points over a square, mapped onto an octahedron. 60 | // The ring size 17*17 = 289 is close to a multiple of 49 (49*6 = 294) 61 | float n_ = 0.142857142857; // 1.0/7.0 62 | vec3 ns = n_ * D.wyz - D.xzx; 63 | 64 | vec4 j = p - 49.0 * floor(p * ns.z * ns.z); // mod(p,7*7) 65 | 66 | vec4 x_ = floor(j * ns.z); 67 | vec4 y_ = floor(j - 7.0 * x_); // mod(j,N) 68 | 69 | vec4 x = x_ * ns.x + ns.yyyy; 70 | vec4 y = y_ * ns.x + ns.yyyy; 71 | vec4 h = 1.0 - abs(x) - abs(y); 72 | 73 | vec4 b0 = vec4(x.xy, y.xy); 74 | vec4 b1 = vec4(x.zw, y.zw); 75 | 76 | //vec4 s0 = vec4(lessThan(b0,0.0))*2.0 - 1.0; 77 | //vec4 s1 = vec4(lessThan(b1,0.0))*2.0 - 1.0; 78 | vec4 s0 = floor(b0) * 2.0 + 1.0; 79 | vec4 s1 = floor(b1) * 2.0 + 1.0; 80 | vec4 sh = -step(h, vec4(0.0)); 81 | 82 | vec4 a0 = b0.xzyw + s0.xzyw * sh.xxyy; 83 | vec4 a1 = b1.xzyw + s1.xzyw * sh.zzww; 84 | 85 | vec3 p0 = vec3(a0.xy, h.x); 86 | vec3 p1 = vec3(a0.zw, h.y); 87 | vec3 p2 = vec3(a1.xy, h.z); 88 | vec3 p3 = vec3(a1.zw, h.w); 89 | 90 | //Normalise gradients 91 | vec4 norm = taylorInvSqrt(vec4(dot(p0, p0), dot(p1, p1), dot(p2, p2), dot(p3, p3))); 92 | p0 *= norm.x; 93 | p1 *= norm.y; 94 | p2 *= norm.z; 95 | p3 *= norm.w; 96 | 97 | // Mix final noise value 98 | vec4 m = max(0.6 - vec4(dot(x0, x0), dot(x1, x1), dot(x2, x2), dot(x3, x3)), 0.0); 99 | m = m * m; 100 | return 42.0 * dot(m * m, vec4(dot(p0, x0), dot(p1, x1), 101 | dot(p2, x2), dot(p3, x3))); 102 | } 103 | -------------------------------------------------------------------------------- /posts/weekly-2025-03-25.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Weekly #2: Week of 2025-03-25" 3 | datePublished: "2025-03-25" 4 | summary: "ATMosphere Conf and more!" 5 | isWeekly: true 6 | --- 7 | 8 | ## Hello! 9 | 10 | I haven't done one of these for a bit (oops). Lots of stuff happening in life and also in my head (bad). Have made some good progress on a lot of stuff though, and had some fun with friends and builders from around the US. 11 | 12 | ## ATMosphere Conf 13 | 14 | As you may know, I went to Seattle this last weekend for the ATmosphere Conf, put on by the unofficial [ATProtocol Community](https://atprotocol.dev/). I had a very fun time meeting online friends IRL, making new friends and learning about the latest developments in the ATProtocol ecosystem. 15 | 16 | One thing really stuck with me from [Rudy's](https://bsky.app/profile/rudyfraser.com), and that was the idea of building for your community and not the wider world. It's easy to get caught up in the hype, excitement, and pressure of making something big, but it's important to remember who you're building for. For example, I'm building teal.fm for the community of music lovers on Bluesky. I'd need to keep focused on features they like and UX that they understand, instead of just putting in random features that won't help or be used at all. 17 | 18 | Sort of related: I fall into the pit sometimes of overdesigning and overengineering things without prior experience or consulting with others, and, at least for me, that ends up being a waste of time and resources. You don't need to design for hyperscale if the only people who will use it are your close friends, and you can't make a slow, huge monolithic postgres-based app if you're doing hyperscale development. 19 | 20 | [Paul's](https://bsky.app/profile/pfrazee.com) talk was really interesting as well. It was about why and how Bluesky was made, and what they thought about when making it. He talked about enshittification and how Bluesky has taken steps to mitigate it, such as viewing themselves as a future adversary and building with that in mind. Plus, prioritizing UX and UI is crucial if you want to build something even people without development experience can use and enjoy. 21 | 22 | ## Teal.fm 23 | 24 | I've done a bit of work on teal.fm over the past few weeks. I've rewrote the jetstream listener in Rust for better performance, scalability, and usability, and released the library it's built on on crates.io. It's called [rocketman](https://crates.io/crates/rocketman) and I hope it will be useful to others. 25 | 26 | I've also been working on the profiles system. It's designed to be separate from Bluesky, including, in the future, the whole social graph. Right now, I have a basic profile page with a 'change user info' modal, API routes to get other profiles, and currently I'm working on profile search. 27 | 28 | Profile search, right now, is built on Trigram similarity in postgresql. Eventually I'll migrate it to a more scalable solution, such as a graph database or using software like meilisearch. 29 | 30 | ## Music 31 | 32 | Right now, I've fallen back into the sad indie folk trap! Have re-found [Medium Build](https://tidal.com/browse/artist/7190362?u) and [Tiny Habits](https://tidal.com/browse/artist/34295024?u). Super comfy music, and I'd recommend both to anyone looking for indie pop/folk music. 33 | 34 | ## Anyways, 35 | 36 | That's all for now! I'll hopefully be back next week with more things to share and talk about. 37 | 38 | ## Comments 39 | 40 | Thoughts? Questions? Feedback? Let me know [on Bluesky](https://bsky.app/profile/natalie.sh/post/3liebm4lgoc2g)! 41 | 42 | -------------------------------------------------------------------------------- /posts/blog.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Making my new Next.js MDX blog" 3 | datePublished: "2023-10-25" 4 | summary: "An overview of how I made my blog in Next.js and MDX" 5 | --- 6 | 7 | I recently decided to make a new blog, after having a dead link on my website for multiple years on end. 8 | 9 | I wanted it to be: 10 | 11 | 1. Fast 12 | 13 | 2. Simple and minimally designed 14 | 15 | 3. Easy to manage, via CMS or other. 16 | 17 | ## 1. Fast 18 | 19 | ### The stack 20 | - NextJS 21 | - MDX 22 | - TailwindCSS 23 | - Contentlayer 24 | 25 | I decided to use Next.js, partially because I'm already familiar with it (due to that, I know it's fast in most situations), and partially because I've heard about MDX in Next.js, which I was curious about. 26 | 27 | I quite like Next.js, as you can tell. 28 | 29 | I also decided to use MDX over a traditional CMS like WordPress or Ghost because 30 | 1. It's self contained, which is a pro versus something hosted elsewhere that can intermittently go down. 31 | 2. I'm fairly comfortable with both Markdown (I use it for all my notes) and JSX (which is used in React). 32 | 33 | ## 2. Simple 34 | 35 | TailwindCSS is the obvious choice here, due to it being a utility-first CSS framework, which means it's easy to make a simple design without having to write a lot of raw CSS. 36 | 37 | CSS is hard and I don't like dealing with it. 38 | 39 | Luckily, to get me writing even fewer styles, Tailwind also has a [typography plugin](https://tailwindcss.com/docs/typography-plugin) which has great looking defaults. All you need to do to use it is to 40 | 41 | ```bash 42 | npm install @tailwindcss/typography 43 | ``` 44 | 45 | and 46 | 47 | ```js title="tailwind.config.js" 48 | module.exports = { 49 | //... 50 | plugins: [ 51 | require('@tailwindcss/typography'), 52 | ], 53 | } 54 | ``` 55 | 56 | and \*tada*: I don't have to write styles for the main body of these blog posts. 57 | 58 | I kept everything else fairly simple and clean, with the other main point of interest being the font, which is [Figtree](https://github.com/erikdkennedy/figtree) by Erik Kennedy. I find it fairly clean and readable - as much as something like Helvetica or Inter. I think it's a good all rounder - works well as a header, title, and body font. 59 | 60 | ## 3. Easy 61 | 62 | I ended up picking MDX for this, as I mentioned earlier, due to its familiarity to me despite me never using it. File routings are also a plus, as I can just make and edit a new file in the 'posts' folder and it'll be a new post. 63 | 64 | Instead of using a posts.json file with all my post metadata in that, I decided to pivot to a frontmatter-based system. I already have it included for SEO reasons, so I might as well use it for other metadata. 65 | Here's the one for this page, actually: 66 | ```md 67 | --- 68 | title: "Making my new Next.js MDX blog" 69 | datePublished: "2024-10-25" 70 | summary: "An overview of how I made my blog in Next.js and MDX" 71 | --- 72 | ``` 73 | 74 | I then use Contentlayer to scan the directory, fetch the data, and return it as a json object. I then render the raw markdown using a helper tool in the Contentlayer library, and pass it to the MDX component to render 75 | like this: 76 | 77 | ```jsx 78 | export default function Page({ params }: { params: { slug: string } }) { 79 | // Find the post for the current page. 80 | const post = allPosts.find((post) => { 81 | console.log(post._raw.flattenedPath); 82 | return post._raw.flattenedPath === params.slug; 83 | }); 84 | // ... 85 | return ( 86 |
87 | {/*...*/} 88 | 89 |
90 | ); 91 | } 92 | ``` 93 | I've got a few components as well that I use when rendering to make things like photos look nice. 94 | 95 | ## Conclusion 96 | It was quite fun making this, and it only took me like two days! Most of the issues was working out MDX issues and certain packages (like `remark-gfm`) having compat issues with Contentlayer. 97 | -------------------------------------------------------------------------------- /app/blog/page.tsx: -------------------------------------------------------------------------------- 1 | // app/page.tsx 2 | import Link from "next/link"; 3 | import { compareDesc, format, parseISO } from "date-fns"; 4 | import { allPosts, Post } from "contentlayer/generated"; 5 | import TimeAgo from "@/components/timeago"; 6 | 7 | const REASONABLE_LENGTH = 160; 8 | 9 | function PostCard(post: Post) { 10 | // get first paragraph of post from MDX 11 | // we use raw, which is separated by \n 12 | let split = post.body.raw.split("\n"); 13 | 14 | // go through each line <= reasonable length and add it to firstParagraph 15 | let firstParagraph = ""; 16 | for (let i = 0; i < split.length; i++) { 17 | firstParagraph += " " + split[i]; 18 | if (split[i].length > REASONABLE_LENGTH) break; 19 | } 20 | // strip all html tags from first paragraph 21 | firstParagraph = firstParagraph.replace(/<[^>]*>?/gm, ""); 22 | 23 | // strip all markdown formatting from string including links 24 | firstParagraph = firstParagraph.replace(/[#/\[\]*>]/g, ""); 25 | firstParagraph = firstParagraph.replace(/\(.*?\)/g, ""); 26 | // if longer than reasonable length, then truncate and add ellipsis 27 | if (firstParagraph.length > REASONABLE_LENGTH) { 28 | firstParagraph = firstParagraph.substring(0, REASONABLE_LENGTH) + "..."; 29 | // if ends with a space or comma or other punctuation, remove it 30 | if ( 31 | firstParagraph.endsWith(" ...") || 32 | firstParagraph.endsWith(",...") || 33 | firstParagraph.endsWith("....") 34 | ) { 35 | firstParagraph = 36 | firstParagraph.substring(0, firstParagraph.length - 4) + "..."; 37 | } 38 | } 39 | 40 | return ( 41 |
42 |

43 | 47 | {post.title} 48 | 49 |

50 |
51 | {format(parseISO(post.datePublished), "MMM. dd, yyyy")}{" "} 52 | 53 | 54 | 55 | 56 | {post.lastUpdated && ( 57 | <> 58 | {" • "} 59 | Last updated {format( 60 | parseISO(post.lastUpdated), 61 | "MMM. dd, yyyy", 62 | )}{" "} 63 | - . 64 | 65 | )} 66 | 67 |
68 |
{post.summary}
69 |
70 | {firstParagraph} 71 |
72 |
73 | ); 74 | } 75 | 76 | export default function Home() { 77 | // filter out weeklies and non-public posts 78 | const posts = allPosts 79 | .filter((post) => post.public) 80 | .filter((p) => !p.isWeekly) 81 | .sort((a, b) => 82 | compareDesc(new Date(a.datePublished), new Date(b.datePublished)), 83 | ); 84 | 85 | return ( 86 |
87 |
88 | 92 | Main 93 | 94 | 98 | Weeklies 99 | 100 |
101 |
102 | {posts.map((post, idx) => ( 103 | 104 | ))} 105 |
106 |
107 | ); 108 | } 109 | -------------------------------------------------------------------------------- /components/shader/fragment.glsl: -------------------------------------------------------------------------------- 1 | varying vec3 v_color; 2 | 3 | vec3 permute(vec3 x) { return mod(((x*34.0)+1.0)*x, 289.0); } 4 | 5 | float snoise2(vec2 v){ 6 | const vec4 C = vec4(0.211324865405187, 0.366025403784439, 7 | -0.577350269189626, 0.024390243902439); 8 | vec2 i = floor(v + dot(v, C.yy) ); 9 | vec2 x0 = v - i + dot(i, C.xx); 10 | vec2 i1; 11 | i1 = (x0.x > x0.y) ? vec2(1.0, 0.0) : vec2(0.0, 1.0); 12 | vec4 x12 = x0.xyxy + C.xxzz; 13 | x12.xy -= i1; 14 | i = mod(i, 289.0); 15 | vec3 p = permute( permute( i.y + vec3(0.0, i1.y, 1.0 )) 16 | + i.x + vec3(0.0, i1.x, 1.0 )); 17 | vec3 m = max(0.5 - vec3(dot(x0,x0), dot(x12.xy,x12.xy), 18 | dot(x12.zw,x12.zw)), 0.0); 19 | m = m*m ; 20 | m = m*m ; 21 | vec3 x = 2.0 * fract(p * C.www) - 1.0; 22 | vec3 h = abs(x) - 0.5; 23 | vec3 ox = floor(x + 0.5); 24 | vec3 a0 = x - ox; 25 | m *= 1.79284291400159 - 0.85373472095314 * ( a0*a0 + h*h ); 26 | vec3 g; 27 | g.x = a0.x * x0.x + h.x * x0.y; 28 | g.yz = a0.yz * x12.xz + h.yz * x12.yw; 29 | return 130.0 * dot(m, g); 30 | } 31 | 32 | float luma(vec3 color) { 33 | return dot(color, vec3(0.299, 0.587, 0.114)); 34 | } 35 | 36 | float luma(vec4 color) { 37 | return dot(color.rgb, vec3(0.299, 0.587, 0.114)); 38 | } 39 | 40 | 41 | float dither2x2(vec2 position, float brightness) { 42 | int x = int(mod(position.x, 2.0)); 43 | int y = int(mod(position.y, 2.0)); 44 | int index = x + y * 2; 45 | float limit = 0.0; 46 | 47 | if (x < 8) { 48 | if (index == 0) limit = 0.25; 49 | if (index == 1) limit = 0.75; 50 | if (index == 2) limit = 1.00; 51 | if (index == 3) limit = 0.50; 52 | } 53 | 54 | return brightness < limit ? 0.0 : 1.0; 55 | } 56 | 57 | vec3 dither2x2(vec2 position, vec3 color) { 58 | return color * dither2x2(position, luma(color)); 59 | } 60 | 61 | vec4 dither2x2(vec2 position, vec4 color) { 62 | return vec4(color.rgb * dither2x2(position, luma(color)), 1.0); 63 | } 64 | 65 | float perlinNoise(vec2 st) { 66 | return fract(sin(dot(st.xy, vec2(12.9898, 78.233))) * 43758.5453123); 67 | } 68 | 69 | float fBm(vec2 st) { 70 | float value = 0.0; 71 | float amplitude = 0.5; 72 | for (int i = 0; i < 2; i++) { // Adjust the number of octaves as needed 73 | value += amplitude * perlinNoise(st); 74 | st *= 2.0; 75 | amplitude *= 0.5; 76 | } 77 | return value; 78 | } 79 | 80 | // clampOpp 81 | // Clamps a value if it is outside the specified range. 82 | // If the value is within the range, it returns a specified value. 83 | float clampOpp(float value, float min, float max, float val) { 84 | // If the float is in bounds, inBounds will be 0 85 | float inBounds = step(min, value) - step(value, max); 86 | return mix(val, value, inBounds); 87 | } 88 | 89 | void main() { 90 | vec3 color = v_color; 91 | vec2 st = gl_FragCoord.xy / resolution.xy; 92 | 93 | float lightness = color.r + color.g + color.b / 3.; 94 | 95 | // Darken effect 96 | if (u_darken_top == 1.0) { 97 | color.g -= pow(st.y + sin(-12.0) * st.x, u_shadow_power) * 0.4; 98 | } 99 | 100 | // Apply simplex noise overlay 101 | // Only changes the noise seed if lightness has changed 102 | 103 | float noise2 = fBm(st * 10.0 + clampOpp(lightness, 0.15, 1.7, 0.0)); 104 | color *= (max(0.9, 1.0 - lightness) + noise2 * max(0.1, 1.0 - lightness)); 105 | 106 | // // Apply simplex noise overlay to a separate color 107 | // float noise = fBm(st * 100.0 + max(0.9, 1. - lightness)); 108 | // vec3 nc = color * (1.0 + noise * 0.3); // Combine noise with color 109 | 110 | // // Apply dithering to the modified color 111 | // vec3 dc = (dither(gl_FragCoord.xy, nc)); 112 | 113 | // float threshold = .2; // Adjust this value to reduce darkness 114 | // dc = mix(dc, vec3(1.0), step(threshold, dc.r + dc.g + dc.b / 3.)); 115 | 116 | // // Blend the dithered color with the original color 117 | // float di = 0.075; // Adjust this value for how much darker the dithered areas should be 118 | // color = mix(color, dc, 0.1); 119 | 120 | gl_FragColor = vec4(color, 1.0); 121 | } 122 | -------------------------------------------------------------------------------- /components/scrollingText.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import clsx from "clsx"; 3 | import React, { CSSProperties, useEffect, useRef, useState } from "react"; 4 | 5 | interface ScrollingTextProps { 6 | /// Any other class names you need to put inside this component 7 | className?: string; 8 | /// The text (or component) to put inside. It should be inline. 9 | text?: React.ReactNode; 10 | /// Width on either side to fade in px, if the text wraps around 11 | fadeWidth?: number; 12 | } 13 | 14 | export function ScrollingText({ 15 | text, 16 | className = "", 17 | fadeWidth = 8, 18 | ...props 19 | }: ScrollingTextProps) { 20 | const containerRef = useRef(null); 21 | const textRef = useRef(null); 22 | const dupeTextRef = useRef(null); 23 | const dividerRef = useRef(null); 24 | const [isOverflowing, setIsOverflowing] = useState(false); 25 | const [animationDuration, setAnimationDuration] = useState(0); 26 | const [textWidth, setTextWidth] = useState(0); 27 | const [dividerWidth, setDividerWidth] = useState(0); 28 | const [containerWidth, setContainerWidth] = useState(0); 29 | 30 | const calculateOverflow = () => { 31 | const container = containerRef.current; 32 | const textElement = textRef.current; 33 | const dupe = dupeTextRef.current; 34 | 35 | if (container && textElement) { 36 | let isTextOverflowing: boolean | ((prevState: boolean) => boolean); 37 | let len: React.SetStateAction; 38 | 39 | if (dupe !== null) { 40 | isTextOverflowing = textElement.scrollWidth / 2 > container.offsetWidth; 41 | len = textElement.scrollWidth / 2; 42 | } else { 43 | isTextOverflowing = textElement.scrollWidth > container.offsetWidth; 44 | len = textElement.scrollWidth; 45 | } 46 | 47 | setIsOverflowing(isTextOverflowing); 48 | 49 | if (isTextOverflowing) { 50 | setContainerWidth(container.offsetWidth); 51 | setDividerWidth(dividerRef.current?.offsetWidth ?? 0); 52 | const duration = len / 60; // Adjust speed here 53 | setAnimationDuration(duration); 54 | setTextWidth(len); 55 | } 56 | } 57 | }; 58 | 59 | useEffect(() => { 60 | calculateOverflow(); 61 | }, [text]); 62 | 63 | useEffect(() => { 64 | window.addEventListener("resize", calculateOverflow); 65 | return () => window.removeEventListener("resize", calculateOverflow); 66 | }, []); 67 | 68 | return ( 69 |
85 |
101 | {text} 102 | {isOverflowing && ( 103 | <> 104 |
105 | ・ 106 |
107 | {text} 108 | 109 | )} 110 |
111 |
112 | ); 113 | } 114 | -------------------------------------------------------------------------------- /app/blog/weeklies/page.tsx: -------------------------------------------------------------------------------- 1 | // app/page.tsx 2 | import Link from "next/link"; 3 | import { compareDesc, format, parseISO } from "date-fns"; 4 | import { allPosts, Post } from "contentlayer/generated"; 5 | import TimeAgo from "@/components/timeago"; 6 | 7 | const REASONABLE_LENGTH = 160; 8 | 9 | function PostCard(post: Post) { 10 | // get first paragraph of post from MDX 11 | // we use raw, which is separated by \n 12 | let split = post.body.raw.split("\n"); 13 | 14 | // go through each line <= reasonable length and add it to firstParagraph 15 | let firstParagraph = ""; 16 | for (let i = 0; i < split.length; i++) { 17 | firstParagraph += " " + split[i]; 18 | if (split[i].length > REASONABLE_LENGTH) break; 19 | } 20 | // strip all html tags from first paragraph 21 | firstParagraph = firstParagraph.replace(/<[^>]*>?/gm, ""); 22 | 23 | // strip all markdown formatting from string including links 24 | firstParagraph = firstParagraph.replace(/[#/\[\]*>]/g, ""); 25 | firstParagraph = firstParagraph.replace(/\(.*?\)/g, ""); 26 | // if longer than reasonable length, then truncate and add ellipsis 27 | if (firstParagraph.length > REASONABLE_LENGTH) { 28 | firstParagraph = firstParagraph.substring(0, REASONABLE_LENGTH) + "..."; 29 | // if ends with a space or comma or other punctuation, remove it 30 | if ( 31 | firstParagraph.endsWith(" ...") || 32 | firstParagraph.endsWith(",...") || 33 | firstParagraph.endsWith("....") 34 | ) { 35 | firstParagraph = 36 | firstParagraph.substring(0, firstParagraph.length - 4) + "..."; 37 | } 38 | } 39 | 40 | return ( 41 |
42 |

43 | 47 | {post.title} 48 | 49 |

50 |
51 | {format(parseISO(post.datePublished), "MMM. dd, yyyy")}{" "} 52 | 53 | 54 | 55 | 56 | {post.lastUpdated && post.isWeekly && ( 57 | <> 58 | {" • "} 59 | Last updated {format( 60 | parseISO(post.lastUpdated), 61 | "MMM. dd, yyyy", 62 | )}{" "} 63 | - . 64 | 65 | )} 66 | 67 |
68 |
{post.summary}
69 |
70 | {firstParagraph} 71 |
72 |
73 | ); 74 | } 75 | 76 | export default function Home() { 77 | // filter out non-public posts and non-weeklies 78 | const posts = allPosts 79 | .filter((post) => post.public) 80 | .filter((p) => p.isWeekly) 81 | .sort((a, b) => 82 | compareDesc(new Date(a.datePublished), new Date(b.datePublished)), 83 | ); 84 | 85 | return ( 86 |
87 |
88 |
89 | 93 | Main 94 | 95 | 99 | Weeklies 100 | 101 |
102 |

Weeklies

103 |
104 | 105 | Weeknote 106 | 107 | -style posts, every week-ish. 108 |
109 | An effort to cultivate a habit of regular writing and reflection via 110 | journaling. 111 |
112 | Nothing too serious. 113 |
114 |
115 |
116 | {posts.map((post, idx) => ( 117 | 118 | ))} 119 |
120 |
121 | ); 122 | } 123 | -------------------------------------------------------------------------------- /posts/godot-vsc.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Godot and VSCode: How to integrate them together" 3 | datePublished: "2024-03-12" 4 | lastUpdated: "2024-08-13" 5 | summary: "Supercharge your game dev workflow by using VSCode instead of Godot's text editor." 6 | --- 7 | 8 | When I was beginning with Godot, it was fairly daunting. Especially the text editor. 9 | It didn't come with a lot of specific features I expect on modern text editors (pseudo IDEs at this point, let's be serious lol). 10 | After some time struggling with its internal editor, I finally decided to research ways to use VSCode instead. 11 | 12 | Mainly because I love Intellisense. It's the best. 13 | 14 | Anyways. 15 | 16 | This is the result of my research. 17 | 18 | ### What? 19 | There are a few steps. 20 | 1. Install Godot, and have it in your path 21 | 2. Install VSCode and the godot-tools plugin 22 | 3. Configure the plugin 23 | 24 | Easy, right? Let’s dive deeper into it, just in case. 25 | 26 | ### Installing Godot 27 | This step is really important. You need to install Godot to use it, and you want to have it **in your path** to be able to use godot-tools easily. 28 | 29 | On MacOS: 30 | 31 | ```sh 32 | brew install godot # or godot-mono 33 | ``` 34 | 35 | On Windows, if you have Scoop 36 | 37 | ```sh 38 | # if you don't have git installed 39 | scoop install git 40 | # add the extras bucket, where godot is 41 | scoop bucket add extras 42 | # finally, install godot 43 | scoop install godot # or godot-mono 44 | ``` 45 | 46 | Either way, if you can run `godot --help` afterwards, you're good to go. 47 | 48 | You’ll want to set these options in your Editor settings at 49 | Text Editor > External 50 | 51 | Exec Path: Your VSCode executable - on MacOS it’s probably `/Applications/Visual Studio Code.app/Contents/MacOS/Electron` 52 | 53 | Exec Flags: `{project} --goto {file}:{line}:{col}` 54 | 55 | 56 | ### Installing VSCode 57 | You probably have it, but if you don’t this should help: https://code.visualstudio.com 58 | 59 | The plugin you’ll need is godot-tools, found here: https://marketplace.visualstudio.com/items?itemName=geequlim.godot-tools 60 | 61 | It should auto-detect your Godot install given you have it in your path, and when you click on an item in Godot, it should automatically open your project and file in VSCode. 62 | 63 | ### Configuring godot-tools 64 | You probably don’t need to. Though, if you didn’t follow step 1, you will need to change your editor path - it will prompt you in the bottom right corner. 65 | 66 | ### Configuring VSCode 67 | This is optional, but I find having a launch.json in your game dir to be useful, in case you need to launch your game from VSCode. 68 | 69 | In your `.vscode/launch.json` (make it if it's not there) add: 70 | 71 | ```json title=".vscode/launch.json" 72 | { 73 | "version": "2.0.0", 74 | "configurations": [ 75 | { 76 | "name": "Launch", 77 | "type": "godot", 78 | "request": "launch", 79 | "project": "${workspaceRoot}", 80 | "scene": "main", 81 | }, 82 | { 83 | "name": "Launch Current Scene", 84 | "type": "godot", 85 | "request": "launch", 86 | "scene": "current" 87 | } 88 | ] 89 | } 90 | ``` 91 | 92 | Note that the Launch Current Scene will launch the current scene *based on what file you're currently in*. So, if you have a `text.gd` file or whatever and it's connected to a `text.tscn` scene, it will launch the scene. 93 | 94 | ## After 95 | You’ll have intellisense, syntax highlighting, and more in VSCode. You can optionally use AI based tools like GitHub Copilot, and other VSCode extensions with your code without weird workarounds. 96 | 97 | #### Sources 98 | I got a large portion of this from [this reddit post by u/Elohssa](https://www.reddit.com/r/godot/comments/16ve6y3/how_to_get_vscode_working_nicely_with_godot_and/) and [this blog post by Morris Morrison](https://morrismorrison.blog/enhancing-your-workflow-setting-up-visual-studio-code-with-godot-and-net). 99 | Also, the [godot-tools github repo](https://github.com/godotengine/godot-vscode-plugin#godot-tools) helped a lot. 100 | 101 | 102 | ## Errata 103 | 104 | - **2024-08-13**: Verified and edited Windows instructions to install the 'extras' bucket, and added a note about git. 105 | 106 | - If you have any questions or concerns, please feel free to [open an issue](https://github.com/espeon/blog/issues/new/). If you just want to say hi, you can find me on my [Discord server](https://nat.vg/discord). 107 | -------------------------------------------------------------------------------- /components/comments/BlueskyEmbed.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from "react"; 2 | import { 3 | AppBskyEmbedRecord, 4 | AppBskyFeedPost, 5 | AppBskyEmbedExternal, 6 | Brand, 7 | AppBskyEmbedImages, 8 | } from "@atcute/client/lexicons"; 9 | import { LuX } from "react-icons/lu"; 10 | 11 | type BlueskyPost = AppBskyFeedPost.Record; 12 | type BlueskyExternalEmbed = Brand.Union; 13 | 14 | // type that has a $type field 15 | type Records = { $type: string }; 16 | 17 | const BlueskyEmbed = ({ 18 | embed, 19 | did, 20 | }: { 21 | embed: BlueskyPost["embed"]; 22 | did: string; 23 | }) => { 24 | return ( 25 |
26 | {embed.$type === "app.bsky.embed.external" ? ( 27 |
28 | {embed.external.thumb && ( 29 | {embed.external.title} 38 | )} 39 |

{embed.external.title}

40 |

{embed.external.description}

41 |
42 | ) : embed.$type === "app.bsky.embed.images" ? ( 43 |
44 | 45 |
46 | ) : ( 47 |
48 | This embed type ({embed.$type}) is not yet implemented. 49 |
50 | )} 51 |
52 | ); 53 | }; 54 | 55 | export default BlueskyEmbed; 56 | 57 | const getBlueskyCdnLink = (did: string, cid: string, ext: string) => { 58 | return `https://cdn.bsky.app/img/feed_fullsize/plain/${did}/${cid}@${ext}`; 59 | }; 60 | 61 | const MultiImageLayout = ({ 62 | did, 63 | images, 64 | }: { 65 | did: string; 66 | images: AppBskyEmbedImages.Image[]; 67 | }) => { 68 | const [selectedImage, setSelectedImage] = useState(null); 69 | const imageCount = images.length; 70 | 71 | // Different grid layouts based on number of images 72 | const gridClassName = 73 | { 74 | 1: "grid-cols-1", 75 | 2: "grid-cols-2", 76 | 3: "grid-cols-2", 77 | 4: "grid-cols-2", 78 | }[Math.min(imageCount, 4)] || "grid-cols-2"; 79 | 80 | return ( 81 | <> 82 |
83 | {images.map((image, i) => ( 84 |
setSelectedImage(i)} 90 | > 91 | 100 |
101 | ))} 102 |
103 | 104 | {selectedImage !== null && ( 105 | <> 106 | {/* Image Preview */} 107 |
setSelectedImage(null)} 110 | > 111 | 119 | {images[selectedImage].alt && ( 120 |
121 | Alt text: {images[selectedImage].alt} 122 |
123 | )} 124 |
125 |
126 | 132 |
133 | 134 | )} 135 | 136 | ); 137 | }; 138 | -------------------------------------------------------------------------------- /components/shader/gradient.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import { useRef, useEffect, useState, useMemo, Suspense } from "react"; 3 | import { useTheme } from "next-themes"; // or whatever theme solution you're using 4 | import { Gradient } from "./wave"; 5 | 6 | const COLORS = { 7 | light: [0xf0abfc, 0xc084fc, 0xa5b4fc, 0x22d3ee], 8 | dark: [0x4a044e, 0x3b0764, 0x1e1b4b, 0x020617], 9 | }; 10 | 11 | const COLORS_HEX = { 12 | light: ["#f0abfc", "#c084fc", "#a5b4fc", "#22d3ee"], 13 | dark: ["#4a044e", "#3b0764", "#1e1b4b", "#020617"], 14 | }; 15 | 16 | type ColorScheme = { 17 | light: T; 18 | dark: T; 19 | }; 20 | 21 | export function triggerOnIdle(callback: any) { 22 | if (window && "requestIdleCallback" in window) { 23 | return requestIdleCallback(callback); 24 | } 25 | return setTimeout(() => callback(), 1); 26 | } 27 | 28 | const GradientReact = () => { 29 | const canvasRef = useRef(null); 30 | const overlayRef = useRef(null); 31 | const gradientRef = useRef(null); 32 | const { theme } = useTheme(); // Get the current theme 33 | 34 | const colors = useMemo(() => COLORS, []); 35 | 36 | const getColors = ( 37 | theme: "light" | "dark" | "system" | string = "system", 38 | col: ColorScheme = COLORS as ColorScheme, 39 | ): T => { 40 | let targetColors: T; 41 | 42 | if (theme === "system") { 43 | //console.log("Theme is system!"); 44 | const systemTheme = window.matchMedia("(prefers-color-scheme: dark)"); 45 | targetColors = systemTheme.matches ? col.dark : col.light; 46 | } else { 47 | targetColors = theme === "dark" ? col.dark : col.light; 48 | //console.log("Setting colors to", theme === "dark" ? "dark" : "light"); 49 | } 50 | 51 | return targetColors; 52 | }; 53 | 54 | const [isInitialized, setIsInitialized] = useState(false); 55 | const initColors = getColors(theme, COLORS_HEX); 56 | 57 | useEffect(() => { 58 | if (canvasRef.current && overlayRef.current && !isInitialized) { 59 | const initGradient = async () => { 60 | // initialise the gradient 61 | const gradient: any = new Gradient(); 62 | await gradient.initGradient("#gradient-canvas"); 63 | gradient.amp = 360; 64 | 65 | gradientRef.current = gradient; 66 | // we are done! 67 | setIsInitialized(true); 68 | 69 | // Fade out the black overlay 70 | if (overlayRef.current) { 71 | overlayRef.current.style.opacity = "0"; 72 | setTimeout(() => { 73 | if (overlayRef.current) { 74 | overlayRef.current.style.display = "none"; 75 | } 76 | }, 1000); 77 | } 78 | }; 79 | 80 | triggerOnIdle(initGradient); 81 | } 82 | }, [isInitialized]); 83 | 84 | useEffect(() => { 85 | if (isInitialized && gradientRef.current) { 86 | const gradient = gradientRef.current; 87 | // check if theme is system, if so, use system theme 88 | let targetColors: number[] = getColors(theme); 89 | // if we have colors then set them 90 | if (gradient?.sectionColors?.length > 0) { 91 | targetColors.forEach((targetColor, index) => { 92 | const [r, g, b] = [ 93 | ((targetColor >> 16) & 255) / 255, 94 | ((targetColor >> 8) & 255) / 255, 95 | (targetColor & 255) / 255, 96 | ]; 97 | gradient.sectionColors[index] = [r, g, b]; 98 | }); 99 | // set the base color 100 | gradient.uniforms.u_baseColor.value = gradient.sectionColors[0]; 101 | // set the wave colors (offset by 1 to skip the base color) 102 | gradient.uniforms.u_waveLayers.value.forEach((layer, index) => { 103 | layer.value.color.value = 104 | gradient.sectionColors[index + 1] || gradient.sectionColors[index]; 105 | }); 106 | } 107 | } 108 | }, [theme, isInitialized, colors]); 109 | 110 | return ( 111 | <> 112 |
116 | 129 | 130 | ); 131 | }; 132 | 133 | export default GradientReact; 134 | -------------------------------------------------------------------------------- /components/disclosure.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import { Disclosure } from "@headlessui/react"; 3 | import { FaChevronCircleUp, FaChevronUp } from "react-icons/fa"; 4 | import { PiTagChevron } from "react-icons/pi"; 5 | 6 | const MyDisc = () => { 7 | return ( 8 |
9 | 10 | {({ open }) => ( 11 | <> 12 | 17 |
22 | 29 |
30 | 31 | psst... you want more?{" "} 32 | 33 | click here 34 | 35 | 36 |
37 |
38 |
43 | sure. well,{" "} 44 |
45 |
52 | in my free time i like to{" "} 53 | draw,{" "} 54 | play video games, and{" "} 55 | listen to music. 56 |
57 |
64 | i'm quite the fan of pokemon, and i also like rhythm games like 65 | osu! and beat saber. i also like to play a lot of indie games. 66 | i've actually tried many times to make my own game, but i've 67 | never been able to get it off the ground. 68 |
69 |
76 | as for media other than music, i'm a big fan of anime, manga, 77 | and asian dramas. 78 |
79 |
86 | i've also been trying to learn chinese. i'm not very good at it, 87 | but i guess i'm in good company, along with a surprising amount of 88 | second generation chinese-americans. 89 |
90 |
97 | if you haven't seen the footer yet, i've also put my recently 98 | listened on last.fm there. you can also click it to go to my 99 | last.fm profile. 100 |
101 |
102 | 103 | )} 104 |
105 |
106 | ); 107 | }; 108 | 109 | export default MyDisc; 110 | -------------------------------------------------------------------------------- /components/comments/BlueskyReply.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | import { 4 | AppBskyFeedDefs, 5 | Brand, 6 | AppBskyFeedPost, 7 | AppBskyEmbedRecord, 8 | } from "@atcute/client/lexicons"; 9 | import Link from "next/link"; 10 | import { 11 | LuArrowLeft, 12 | LuArrowRight, 13 | LuHeart, 14 | LuRecycle, 15 | LuRedo, 16 | LuReply, 17 | } from "react-icons/lu"; 18 | 19 | import {} from "react-icons/fa6"; 20 | 21 | import BlueskyEmbed from "./BlueskyEmbed"; 22 | import { PiButterfly, PiButterflyFill } from "react-icons/pi"; 23 | 24 | type ThreadView = Brand.Union; 25 | type BlueskyPost = AppBskyFeedPost.Record; 26 | 27 | function isPost(post: any): post is BlueskyPost { 28 | return post.$type === "app.bsky.feed.post"; 29 | } 30 | 31 | export interface BlueskyReplyProps { 32 | thread: ThreadView; 33 | depth?: number; 34 | skipFirst?: boolean; 35 | } 36 | 37 | const BlueskyReply = ({ 38 | thread, 39 | depth = 0, 40 | skipFirst = false, 41 | }: BlueskyReplyProps) => { 42 | if (thread.$type !== "app.bsky.feed.defs#threadViewPost") { 43 | return null; 44 | } 45 | 46 | const { post, replies } = thread; 47 | const { author, embed, replyCount, repostCount, likeCount, record } = post; 48 | let bskyPost: BlueskyPost; 49 | if (isPost(record)) { 50 | bskyPost = record as BlueskyPost; 51 | } 52 | 53 | // Limit nesting depth to prevent too deep chains 54 | const MAX_DEPTH = 5; 55 | 56 | // Add visual connector line for nested replies 57 | const connectorClass = 58 | depth > 1 ? "border-l border-gray-400 dark:border-gray-800" : ""; 59 | 60 | return ( 61 |
62 | {!skipFirst && ( 63 |
64 | {/* Author Section */} 65 |
66 | {author.displayName} 71 |
72 | 76 | {author.displayName} 77 | 78 |
79 | 80 | @{author.handle} 81 | 82 |
83 |
84 |
85 | 86 | {/* Content Section */} 87 |
88 |
89 | {bskyPost?.text} 90 |
91 |
92 | 93 | {/* Embed Section */} 94 | {embed && } 95 | 96 | {/* Engagement Stats */} 97 |
98 |
99 | {likeCount} 100 |
101 |
102 | {replyCount} 103 |
104 |
105 | {repostCount} 106 |
107 | 111 | Go to post 112 | 113 | 114 |
115 |
116 | )} 117 | 118 | {/* Nested Replies */} 119 | {depth < MAX_DEPTH && replies && replies.length > 0 && ( 120 |
121 | {/* is a reply a ThreadView? */} 122 | {replies 123 | .filter((r) => r.$type === "app.bsky.feed.defs#threadViewPost") 124 | .map((nestedReply, index) => ( 125 | 130 | ))} 131 |
132 | )} 133 | 134 | {/* Show "View more replies" button if depth limit reached */} 135 | {depth === MAX_DEPTH && replies && replies.length > 0 && ( 136 | 139 | )} 140 |
141 | ); 142 | }; 143 | 144 | export default BlueskyReply; 145 | -------------------------------------------------------------------------------- /components/lastfm.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import useSWR from "swr"; 3 | import { ScrollingText } from "./scrollingText"; 4 | import { CrossFade } from "react-crossfade-simple"; 5 | import Link from "next/link"; 6 | import { useEffect } from "react"; 7 | import Ambilight from "./ambilight"; 8 | import { FaLastfm } from "react-icons/fa"; 9 | import { LuFileWarning } from "react-icons/lu"; 10 | 11 | interface Track { 12 | name: string; 13 | artist: string; 14 | imageUrl: string; 15 | isCurrent: boolean; 16 | } 17 | 18 | const FM_KEY = "6f5ff9d828991a85bd78449a85548586"; 19 | const MAIN = "kanb"; 20 | 21 | const fetcher = (url: string) => 22 | fetch(url) 23 | .then((res) => res.json()) 24 | .then((data) => { 25 | let recentTrack = data?.recenttracks.track[0]; 26 | console.log(recentTrack); 27 | return { 28 | name: recentTrack.name, 29 | artist: recentTrack.artist["#text"], 30 | imageUrl: recentTrack.image[recentTrack.image.length - 1]["#text"], 31 | isCurrent: recentTrack["@attr"]?.nowplaying == "true", 32 | }; 33 | }); 34 | 35 | const LastFm: React.FC = () => { 36 | const { data, error } = useSWR( 37 | `https://ws.audioscrobbler.com/2.0/?method=user.getrecenttracks&user=${MAIN}&api_key=${FM_KEY}&limit=1&format=json`, 38 | fetcher, 39 | { 40 | refreshInterval: 10000, // Refresh every 10 seconds 41 | revalidateOnFocus: false, // Disable revalidation on window focus 42 | }, 43 | ); 44 | 45 | // on data, log data 46 | useEffect(() => { 47 | if (data) { 48 | console.log(data); 49 | } 50 | }, [data]); 51 | 52 | return ( 53 | 54 | {data ? ( 55 |
56 | 57 |
58 | {data.imageUrl === 59 | "https://lastfm.freetls.fastly.net/i/u/300x300/2a96cbd8b46e442fc41c2b86b821562f.png" ? ( 60 |
61 | 62 |

No cover found!

63 |
64 | ) : ( 65 | cover 70 | )} 71 |
72 |
73 |
74 | {data.isCurrent ? "Now Playing" : "Last Played"} on{" "} 75 | 76 | 77 | 78 |
79 | 83 | 87 | 88 | 92 | 96 | 97 |
98 |
99 | ) : error ? ( 100 |
101 |
{error.message}
102 |
103 | ) : ( 104 |
105 |
Loading...
106 | 111 |
112 | )} 113 |
114 | ); 115 | }; 116 | export default LastFm; 117 | -------------------------------------------------------------------------------- /app/globals.css: -------------------------------------------------------------------------------- 1 | @import "tailwindcss"; 2 | 3 | @custom-variant dark (&:where(.dark, .dark *)); 4 | 5 | @plugin "@tailwindcss/typography"; 6 | @theme { 7 | --font-sans: "Figtree"; 8 | --font-mono: "IBM Plex Mono"; 9 | --color-wisteria-50: oklch(0.955 0.012 314.384); 10 | --color-wisteria-100: oklch(0.91 0.05 314.384); 11 | --color-wisteria-200: oklch(0.82 0.1 314.384); 12 | --color-wisteria-300: oklch(0.73 0.105 314.384); 13 | --color-wisteria-400: oklch(0.64 0.12 314.384); 14 | --color-wisteria-500: oklch(0.55 0.1 314.384); 15 | --color-wisteria-600: oklch(0.445 0.084 314.384); 16 | --color-wisteria-700: oklch(0.334 0.081 314.384); 17 | --color-wisteria-800: oklch(0.223 0.078 314.384); 18 | --color-wisteria-900: oklch(0.111 0.076 314.384); 19 | --color-wisteria-950: oklch(0.056 0.074 314.384); 20 | } 21 | 22 | html { 23 | @apply bg-pink-100 dark:bg-black dark:text-slate-300 text-black transition-colors duration-75 -z-50; 24 | } 25 | 26 | @keyframes scrollText { 27 | 0% { 28 | transform: translateX(0); 29 | } 30 | 100% { 31 | transform: translateX(calc(-1 * var(--text-width))); 32 | } 33 | } 34 | 35 | .animate-scroll-text { 36 | animation: scrollText; 37 | } 38 | 39 | .ambilight { 40 | filter: url(#ambilight); 41 | } 42 | 43 | #gradient-canvas { 44 | display: block; 45 | width: 100vw; 46 | height: 100vh; 47 | } 48 | 49 | /* prose */ 50 | 51 | .prose blockquote { 52 | @apply ml-2 border-l-[1px] font-light border-slate-500 not-italic; 53 | } 54 | 55 | .prose a { 56 | @apply dark:text-purple-300 text-blue-800 hover:text-blue-600 transition-all dark:decoration-slate-700 decoration-slate-300 decoration-1 underline-offset-2; 57 | } 58 | 59 | .a { 60 | @apply dark:text-purple-300 text-blue-800 hover:text-blue-600 transition-all dark:decoration-slate-700 decoration-slate-300 decoration-1 underline-offset-2; 61 | } 62 | 63 | .prose p, 64 | .prose li { 65 | @apply dark:text-gray-300 text-gray-800 mt-0; 66 | } 67 | 68 | .prose strong { 69 | @apply font-medium; 70 | } 71 | 72 | .prose blockquote strong { 73 | @apply font-semibold; 74 | } 75 | 76 | .prose h1, 77 | .prose h2, 78 | .prose h3, 79 | .prose h4, 80 | .prose h5, 81 | .prose h6 { 82 | @apply font-medium mb-4; 83 | } 84 | 85 | .prose pre { 86 | @apply border-0 p-0 m-0 rounded-lg bg-transparent; 87 | text-wrap: balance; 88 | } 89 | 90 | .prose figcaption + pre { 91 | @apply rounded-t-none; 92 | } 93 | 94 | .prose figure code { 95 | @apply px-4 py-3 border-2 border-neutral-100 dark:border-neutral-800; 96 | } 97 | 98 | .prose code { 99 | @apply border-2 border-neutral-100 dark:border-neutral-800 rounded-md px-0.5; 100 | } 101 | 102 | .prose pre code { 103 | border: initial; 104 | } 105 | 106 | pre { 107 | --tw-shadow-color: #0e1d1dc0; 108 | --tw-shadow: 0 2px 2px -0px rgb(0 0 0 / 0.1), 109 | 0 8px 10px -6px rgb(0 0 0 / 0.1); 110 | --tw-shadow-colored: 0 2px 2px -0px var(--tw-shadow-color), 111 | 0 8px 10px -6px var(--tw-shadow-color); 112 | box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), 113 | var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow); 114 | 115 | --tw-shadow: var(--tw-shadow-colored); 116 | @apply shadow-neutral-400/50 dark:shadow-black/50; 117 | } 118 | 119 | table { 120 | display: block; 121 | max-width: fit-content; 122 | overflow-x: auto; 123 | white-space: nowrap; 124 | } 125 | 126 | .prose > :first-child { 127 | /* Override removing top margin, causing layout shift */ 128 | margin-top: 1.25em !important; 129 | margin-bottom: 1.25em !important; 130 | } 131 | 132 | [data-rehype-pretty-code-fragment] .line { 133 | @apply py-2; 134 | } 135 | [data-rehype-pretty-code-fragment] [data-line-numbers] > .line::before { 136 | counter-increment: line; 137 | content: counter(line); 138 | display: inline-block; 139 | width: 1rem; 140 | margin-right: 1rem; 141 | text-align: right; 142 | color: gray; 143 | } 144 | 145 | [data-rehype-pretty-code-fragment] .line-highlighted span { 146 | @apply relative; 147 | } 148 | [data-rehype-pretty-code-fragment] .word--highlighted { 149 | @apply rounded-md bg-slate-500/10 p-1; 150 | } 151 | [data-rehype-pretty-code-title] { 152 | @apply px-4 py-3 font-mono text-xs font-medium border rounded-lg rounded-b-none dark:text-neutral-200 dark:border-[#333333] dark:bg-[#1c1c1c]; 153 | } 154 | 155 | /* Light mode styles (default) */ 156 | code[data-theme*=" "], 157 | code[data-theme*=" "] span { 158 | color: var(--shiki-light); 159 | background-color: var(--shiki-light-bg); 160 | } 161 | 162 | /* Dark mode styles */ 163 | :is(.dark code[data-theme*=" "], .dark code[data-theme*=" "] span) { 164 | color: var(--shiki-dark); 165 | background-color: var(--shiki-dark-bg); 166 | } 167 | /* Dark mode styles */ 168 | .dark code[data-theme*=" "], 169 | .dark code[data-theme*=" "] span { 170 | color: var(--shiki-dark); 171 | background-color: var(--shiki-dark-bg); 172 | } 173 | -------------------------------------------------------------------------------- /posts/deploying-nextjs.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Deploy Next.js Anywhere with Docker" 3 | datePublished: "2023-10-25" 4 | summary: "Containers can drastically simplify the process of deploying your Next.js app across various environments." 5 | public: false 6 | --- 7 | 8 | 9 | Here to learn how to deploy? Especially to multiple containers? Feel free to [skip to the good part :)](#deploying-nextjs-to-multiple-containers) 10 | 11 | Next\.js is a flexible React framework. It provides nice features like server/static rendering, server actions, incremental static rendering, middleware, and more. Vercel, the creators of Next.js, recommend that you deploy with them to get certain features, like ISR, out-of-the-box. While Vercel is a reputable platform that takes pride in its services, users who want to leave their ecosystem may find limited options in regards to certain missing features. 12 | 13 | One of the available options here is to deploy your application on your own infrastructure using containers. Containers are standardized units that package software for purposes such as development, testing, and shipping. They include all the necessary components to run the software, providing better consistency during deployment and lower overhead compared to virtual machines. This approach is favored by developers because it abstracts the software away from the host operating system, making it easier to deploy and manage applications. Thanks to the standardized nature of containers, one can be confident that if a solution is working in one’s own container, it should work consistently in any other containerized environment. 14 | 15 | ### Why host Next.js using containers? 16 | 17 | - Better security - Containers are isolated unless you don’t want them to be. This helps prevent conflicts with your host operating system, and ensures that you always have the right dependency versions. 18 | - Easier management - Containers can be easy to deploy. With Docker, it’s two commands - one to build and one to run the container. 19 | - Resource Management - Container managers provide the ability to restrict resource utilization. For instance, you can run Next.js with 256 or 512 MB of RAM and half a vCPU to simulate a constrained environment. 20 | - Works anywhere* - From AWS Fargate and Azure Container Apps, to a Raspberry Pi in your garage, you can run Next.js anywhere you can deploy a container, 21 | 22 | ### Any other options? 23 | Other than Vercel, if your website can be rendered statically (no serverless/edge functions), it’s better to deploy to a static hosting service, such as [Cloudflare Pages](https://developers.cloudflare.com/pages/framework-guides/nextjs/deploy-a-static-nextjs-site/) or [GitHub Pages](https://github.com/gregrickaby/nextjs-github-pages). 24 | 25 | You can also use [OpenNext](https://opennext.js.org) to deploy to AWS or Cloudflare. 26 | 27 | ## Prerequisites 28 | You will need [Docker Desktop](https://docs.docker.com/desktop/) (or an alternative) and [Node.js](https://nodejs.org/en) installed on your host system. If you intend to deploy, you will need [Docker Engine](https://docs.docker.com/engine) installed on the system you’re deploying to. 29 | 30 | ## Basic steps 31 | 1. Install prerequisites mentioned above 32 | 2. Run `npx create-next-app` 33 | 3. In your app’s folder, you’ll want to make a `Dockerfile`. [Next.js recommends one here](https://github.com/vercel/next.js/blob/canary/examples/with-docker/Dockerfile). 34 | 4. You will need to change your `output` to `"Standalone"` in your `next.config.js` (you may need to create `output` if it’s not there) 35 | 5. Run `docker build -t ghcr.io/espeon/your-nextjs/app .` to build the container 36 | 1. If you plan on using a registry that’s not Docker Hub, you’ll need to name your image based on the registry’s base url. See your registry’s docs for more info. 37 | 2. In this example, and further on, I’ll use `ghcr.io/espeon/your-nextjs/app` as an example tag. 38 | 6. Run `docker run -p 3000:3000 ghcr.io/espeon/your-nextjs/app` to run the container, passing through port 3000 to the host. 39 | 7. Upload your container to a container registry like Docker Hub, GitHub Container Registry, or your own self-hosted registry. 40 | 41 | Please note that if you use Bun, that currently Bun hasn’t implemented certain Node APIs that Next.js uses for App Router. If you are using App Router, you can build and install dependencies with Bun, but you have to deploy with Node.js. [See more here](https://bun.sh/guides/ecosystem/nextjs). 42 | 43 | 44 | ## Deploying Next.js 45 | There are several methods for deploying your Next.js container. You can deploy to managed services, such as AWS Fargate or Azure Container Apps, but I feel it is more advantageous to focus on deploying to bare metal here. 46 | 47 | ### Deploying with Docker 48 | Once you’ve SSHed into the server and installed docker, running your container is as easy as logging in (if necessary), pulling your container from the registry, and running it. 49 | 50 | ```sh 51 | docker login -u name # optional; the password is entered interactively 52 | docker start ghcr.io/espeon/your-nextjs/app 53 | ``` 54 | ## Deploying Next.js to multiple containers 55 | Sometimes your Next.js apps will be deployed between multiple containers (for example, with a round robin proxy or similar). In this case, the default Next.js cache is local-to-container and will not be shared between instances. This can cause issues such as diverging data and pages generating multiple times. 56 | 57 | To fix this by having a cache between containers, you can use [`@neshca/cache-handler`](https://github.com/caching-tools/next-shared-cache), which is a extensible, specialized, ISR and data cache made for Next. 58 | 59 | In this example, we’ll be using the [`redis-stack` handler](https://caching-tools.github.io/next-shared-cache/handlers/redis-stack). 60 | 61 | TODO: how to use it 62 | 63 | ### Deploying with Swarm 64 | TODO lol 65 | ### Deploying with k8s 66 | TODO this as well -------------------------------------------------------------------------------- /posts/game-design.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Crafting compelling video games" 3 | datePublished: "2024-04-21" 4 | summary: "How MiHoYo makes games captivating and fun" 5 | public: false 6 | --- 7 | 8 | ripped this off the mihoyo survey yeh. u dont have ta thank me 9 | 10 | hidden this for obvious reasons :3 11 | 12 | 1. Progression and Growth 13 | 14 | Growth is an essential element of a good video game. Implementing a progression system where players can improve their characters or abilities over time gives them a sense of achievement and makes the game more engaging. This could be through leveling up, acquiring new skills, or enhancing equipment. The key is to make the progression system rewarding and balanced, so players feel a sense of accomplishment without becoming overpowered too quickly. 15 | 16 | 2. Responsive Controls 17 | 18 | The importance of responsive and precise controls in a video game cannot be overstated. When controls are intuitive and responsive, players feel connected to the game and have a more enjoyable experience. This means that actions performed by the player should be immediately reflected in the game, and the control scheme should be easy to understand and use. 19 | 20 | 3. Challenging Gameplay 21 | 22 | A good video game should provide a challenge to players, requiring strategy, skill, and practice to complete levels or tasks. This keeps players engaged and motivated to improve their skills. However, it's important to strike a balance between difficulty and fairness, so players don't become frustrated or discouraged. 23 | 24 | 4. Exploration and Discovery 25 | 26 | Exploration is a key aspect of many video games, and including hidden areas, secrets, or random events in your game world can encourage players to explore and add replayability. This sense of discovery can make the game world feel more alive and immersive, and reward players for their curiosity. 27 | 28 | 5. Engaging Plot and Storyline 29 | 30 | A rich and detailed storyline with interesting characters and plot twists can help immerse players in the game world and give them a reason to care about what happens. A well-crafted plot can add depth and meaning to gameplay, and make the game more memorable. 31 | 32 | 6. Player Freedom and Choice 33 | 34 | Allowing players to make choices that affect the game world, their character, or the story can add a sense of freedom and agency to the game. This could be through branching storylines, open-world exploration, or customizable characters. Giving players the ability to shape their own experience can make the game more engaging and personal. 35 | 36 | 7. Strategic Gameplay 37 | 38 | Designing game mechanics that require planning and strategic thinking can add depth and complexity to gameplay. This could be through complex combat systems, resource management, or puzzle-solving. Strategic gameplay can challenge players to think critically and creatively, and make the game more rewarding. 39 | 40 | 8. Memorable Characters 41 | 42 | Developing characters with depth, unique personalities, and interesting backstories can make them more memorable and relatable. High-quality voice acting and character design can also enhance character appeal. Players are more likely to care about the game's story and world if they are invested in the characters. 43 | 44 | 9. Social Interaction and Multiplayer 45 | 46 | Including multiplayer features or in-game social systems can allow players to interact with each other, form communities, and make friends. This can add a new dimension to gameplay and make the game more enjoyable. Social features can also encourage players to keep coming back to the game. 47 | 48 | 10. Collectibles and Rewards 49 | 50 | Adding collectible items, such as unique weapons, outfits, or characters, can give players additional goals and add replayability to the game. Collectibles can also reward players for exploring the game world and add a sense of accomplishment. 51 | 52 | 11. Competition and Leaderboards 53 | 54 | Implementing features like leaderboards, PvP modes, or challenges can allow players to compete with each other and add excitement to gameplay. Competition can motivate players to improve their skills and engage more deeply with the game. 55 | 56 | 12. Immersive World-Building 57 | 58 | Creating a rich, detailed game world with consistent lore and aesthetics can help players feel immersed in the game. Using high-quality audio and visuals can also enhance the sense of immersion and make the game world feel more real. 59 | 60 | 13. High-Quality Graphics 61 | 62 | Using an attractive and unique art style that fits your game's theme and tone can make the game more visually appealing and memorable. High-quality graphics can also enhance gameplay by making it easier to see and interact with game elements. 63 | 64 | 14. Casual and Relaxed Gameplay 65 | 66 | Including options for casual play, such as easy difficulty settings or relaxed game modes, can make your game accessible to a wider audience. Casual gameplay can also provide a break from more intense gameplay and allow players to relax and enjoy the game at their own pace. 67 | 68 | 15. Emotional Engagement 69 | 70 | Writing stories and creating situations that evoke strong emotions can make the game more impactful and memorable. This could be through character relationships, dramatic events, or player choices. Emotional engagement can help players feel more connected to the game and its characters. 71 | 72 | 16. Puzzle-Solving 73 | 74 | Incorporating puzzles that are satisfying to solve can add variety to gameplay and challenge players to think creatively. Puzzles can be part of the main gameplay or optional side quests, and can provide a sense of accomplishment when solved. 75 | 76 | 17. Customization and Personalization 77 | 78 | Allowing players to customize their characters or game environments can let them express themselves and make their game experience unique. Customization can also add replayability and encourage players to engage more deeply with the game. -------------------------------------------------------------------------------- /components/shader/blend.glsl: -------------------------------------------------------------------------------- 1 | // 2 | // https://github.com/jamieowen/glsl-blend 3 | // 4 | 5 | // Normal 6 | 7 | vec3 blendNormal(vec3 base, vec3 blend) { 8 | return blend; 9 | } 10 | 11 | vec3 blendNormal(vec3 base, vec3 blend, float opacity) { 12 | return (blendNormal(base, blend) * opacity + base * (1.0 - opacity)); 13 | } 14 | 15 | // Screen 16 | 17 | float blendScreen(float base, float blend) { 18 | return 1.0 - ((1.0 - base) * (1.0 - blend)); 19 | } 20 | 21 | vec3 blendScreen(vec3 base, vec3 blend) { 22 | return vec3(blendScreen(base.r, blend.r), blendScreen(base.g, blend.g), blendScreen(base.b, blend.b)); 23 | } 24 | 25 | vec3 blendScreen(vec3 base, vec3 blend, float opacity) { 26 | return (blendScreen(base, blend) * opacity + base * (1.0 - opacity)); 27 | } 28 | 29 | // Multiply 30 | 31 | vec3 blendMultiply(vec3 base, vec3 blend) { 32 | return base * blend; 33 | } 34 | 35 | vec3 blendMultiply(vec3 base, vec3 blend, float opacity) { 36 | return (blendMultiply(base, blend) * opacity + base * (1.0 - opacity)); 37 | } 38 | 39 | // Overlay 40 | 41 | float blendOverlay(float base, float blend) { 42 | return base < 0.5 ? (2.0 * base * blend) : (1.0 - 2.0 * (1.0 - base) * (1.0 - blend)); 43 | } 44 | 45 | vec3 blendOverlay(vec3 base, vec3 blend) { 46 | return vec3(blendOverlay(base.r, blend.r), blendOverlay(base.g, blend.g), blendOverlay(base.b, blend.b)); 47 | } 48 | 49 | vec3 blendOverlay(vec3 base, vec3 blend, float opacity) { 50 | return (blendOverlay(base, blend) * opacity + base * (1.0 - opacity)); 51 | } 52 | 53 | // Hard light 54 | 55 | vec3 blendHardLight(vec3 base, vec3 blend) { 56 | return blendOverlay(blend, base); 57 | } 58 | 59 | vec3 blendHardLight(vec3 base, vec3 blend, float opacity) { 60 | return (blendHardLight(base, blend) * opacity + base * (1.0 - opacity)); 61 | } 62 | 63 | // Soft light 64 | 65 | float blendSoftLight(float base, float blend) { 66 | return (blend < 0.5) ? (2.0 * base * blend + base * base * (1.0 - 2.0 * blend)) : (sqrt(base) * (2.0 * blend - 1.0) + 2.0 * base * (1.0 - blend)); 67 | } 68 | 69 | vec3 blendSoftLight(vec3 base, vec3 blend) { 70 | return vec3(blendSoftLight(base.r, blend.r), blendSoftLight(base.g, blend.g), blendSoftLight(base.b, blend.b)); 71 | } 72 | 73 | vec3 blendSoftLight(vec3 base, vec3 blend, float opacity) { 74 | return (blendSoftLight(base, blend) * opacity + base * (1.0 - opacity)); 75 | } 76 | 77 | // Color dodge 78 | 79 | float blendColorDodge(float base, float blend) { 80 | return (blend == 1.0) ? blend : min(base / (1.0 - blend), 1.0); 81 | } 82 | 83 | vec3 blendColorDodge(vec3 base, vec3 blend) { 84 | return vec3(blendColorDodge(base.r, blend.r), blendColorDodge(base.g, blend.g), blendColorDodge(base.b, blend.b)); 85 | } 86 | 87 | vec3 blendColorDodge(vec3 base, vec3 blend, float opacity) { 88 | return (blendColorDodge(base, blend) * opacity + base * (1.0 - opacity)); 89 | } 90 | 91 | // Color burn 92 | 93 | float blendColorBurn(float base, float blend) { 94 | return (blend == 0.0) ? blend : max((1.0 - ((1.0 - base) / blend)), 0.0); 95 | } 96 | 97 | vec3 blendColorBurn(vec3 base, vec3 blend) { 98 | return vec3(blendColorBurn(base.r, blend.r), blendColorBurn(base.g, blend.g), blendColorBurn(base.b, blend.b)); 99 | } 100 | 101 | vec3 blendColorBurn(vec3 base, vec3 blend, float opacity) { 102 | return (blendColorBurn(base, blend) * opacity + base * (1.0 - opacity)); 103 | } 104 | 105 | // Vivid Light 106 | 107 | float blendVividLight(float base, float blend) { 108 | return (blend < 0.5) ? blendColorBurn(base, (2.0 * blend)) : blendColorDodge(base, (2.0 * (blend - 0.5))); 109 | } 110 | 111 | vec3 blendVividLight(vec3 base, vec3 blend) { 112 | return vec3(blendVividLight(base.r, blend.r), blendVividLight(base.g, blend.g), blendVividLight(base.b, blend.b)); 113 | } 114 | 115 | vec3 blendVividLight(vec3 base, vec3 blend, float opacity) { 116 | return (blendVividLight(base, blend) * opacity + base * (1.0 - opacity)); 117 | } 118 | 119 | // Lighten 120 | 121 | float blendLighten(float base, float blend) { 122 | return max(blend, base); 123 | } 124 | 125 | vec3 blendLighten(vec3 base, vec3 blend) { 126 | return vec3(blendLighten(base.r, blend.r), blendLighten(base.g, blend.g), blendLighten(base.b, blend.b)); 127 | } 128 | 129 | vec3 blendLighten(vec3 base, vec3 blend, float opacity) { 130 | return (blendLighten(base, blend) * opacity + base * (1.0 - opacity)); 131 | } 132 | 133 | // Linear burn 134 | 135 | float blendLinearBurn(float base, float blend) { 136 | // Note : Same implementation as BlendSubtractf 137 | return max(base + blend - 1.0, 0.0); 138 | } 139 | 140 | vec3 blendLinearBurn(vec3 base, vec3 blend) { 141 | // Note : Same implementation as BlendSubtract 142 | return max(base + blend - vec3(1.0), vec3(0.0)); 143 | } 144 | 145 | vec3 blendLinearBurn(vec3 base, vec3 blend, float opacity) { 146 | return (blendLinearBurn(base, blend) * opacity + base * (1.0 - opacity)); 147 | } 148 | 149 | // Linear dodge 150 | 151 | float blendLinearDodge(float base, float blend) { 152 | // Note : Same implementation as BlendAddf 153 | return min(base + blend, 1.0); 154 | } 155 | 156 | vec3 blendLinearDodge(vec3 base, vec3 blend) { 157 | // Note : Same implementation as BlendAdd 158 | return min(base + blend, vec3(1.0)); 159 | } 160 | 161 | vec3 blendLinearDodge(vec3 base, vec3 blend, float opacity) { 162 | return (blendLinearDodge(base, blend) * opacity + base * (1.0 - opacity)); 163 | } 164 | 165 | // Linear light 166 | 167 | float blendLinearLight(float base, float blend) { 168 | return blend < 0.5 ? blendLinearBurn(base, (2.0 * blend)) : blendLinearDodge(base, (2.0 * (blend - 0.5))); 169 | } 170 | 171 | vec3 blendLinearLight(vec3 base, vec3 blend) { 172 | return vec3(blendLinearLight(base.r, blend.r), blendLinearLight(base.g, blend.g), blendLinearLight(base.b, blend.b)); 173 | } 174 | 175 | vec3 blendLinearLight(vec3 base, vec3 blend, float opacity) { 176 | return (blendLinearLight(base, blend) * opacity + base * (1.0 - opacity)); 177 | } 178 | -------------------------------------------------------------------------------- /app/(home)/projects/page.tsx: -------------------------------------------------------------------------------- 1 | import Card from "@/components/ui/card"; 2 | import { FaGithub } from "react-icons/fa"; 3 | import { IconType } from "react-icons/lib"; 4 | import { LuLink } from "react-icons/lu"; 5 | 6 | export default function Projects() { 7 | return ( 8 |
9 | 21 | 37 | 48 | 60 | 72 | 84 | 96 | 112 |
113 | ); 114 | } 115 | 116 | interface ProjectCardLink { 117 | link: string; 118 | LinkIcon?: IconType; 119 | linkText?: string; 120 | } 121 | 122 | interface ProjectCardProps { 123 | imageUrl?: string; 124 | title: string; 125 | tech: string; 126 | description: string; 127 | links: ProjectCardLink[]; 128 | } 129 | 130 | function ProjectCard({ 131 | imageUrl, 132 | title, 133 | tech, 134 | description, 135 | links, 136 | }: ProjectCardProps) { 137 | return ( 138 | 139 |
140 | {imageUrl && ( 141 | {title} logo 146 | )} 147 |
148 |
{title}
149 |
150 | {tech} 151 |
152 |
153 |
154 |
155 | {description.split("\\n").map((line, i) => ( 156 |
157 | {line} 158 |
159 | ))} 160 |
161 |
162 |
163 | {links.map((link, i) => { 164 | const LinkIcon = link.LinkIcon ?? LuLink; 165 | return ( 166 | 172 | {" "} 173 | {link.linkText ? link.linkText : link.link} 174 | 175 | ); 176 | })} 177 |
178 |
179 |
180 | ); 181 | } 182 | -------------------------------------------------------------------------------- /posts/weekly-2025-02-02.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Weekly #1: Week of 2025-02-02" 3 | datePublished: "2025-02-09" 4 | summary: "First weekly. Mainly just updates." 5 | isWeekly: true 6 | --- 7 | 8 | ## Hello! 9 | 10 | I've wanted to do this for a while. I found out about weeknotes from [a friend](https://ovyerus.com) - who does her own every week(ish) on sundays. 11 | If you don't know, weeknotes are blog posts or weekly summaries published once a week that summarises the week's 12 | accomplishments/events/posts, or anything else that happened that week. 13 | 14 | I've been thinking about doing structured writing in hopes that it would help me focus, solidify weekly tasks, 15 | and be more disciplined in certain aspects. 16 | Plus, it's just something I can do to just get whatever I have in my brain down and run with it. No regrets and stuff. 17 | This will be my own work as well. No AI here, as stakes aren't nearly as high. 18 | 19 | I'm going to try to force myself to get one of these out during the weekend, most likely on sundays. These 'weeklies' will either 20 | feature something I've done every single day of the week (listicle style), or just a highlight of stuff I've done throughout the week. 21 | 22 | ## What did I do this week 23 | ...you may be asking. Well, this past week was pretty interesting to me. I was terribly sick with some kind of sore throat and bad fever/headache. 24 | Luckily, though, I'm on the cusp of recovering right now. 25 | 26 | Other than that, though, I did a lot this week! I made two rust services, gizo and geranium. I also finalised the teal.fm stamping flow and 27 | did a few small things to atptools towards my goal of implementing custom views for every important lexicon! I'll go in order: 28 | 29 | ### Gizo (as in 偽造カード/counterfeit card 🃏) 30 | 31 | Gizo is a link card generator service. I made it because I was worried about using cardyb (Bluesky's card generator service) in my app. 32 | So, I made my own. In Rust. It should be mostly API compatible with cardyb, plus it has lru + ttl based memory caching! 33 | 34 | Sort of related, did you know that oembeds are handled separately in the Bluesky app? They build them in manually so they can control 35 | the dimensions so there's no crazy layout shift! My plan for gizo is to have clients do the same, so they can handle oembeds how they wish 36 | instead of me dictating everything via gizo. 37 | 38 | ### Geranium 39 | 40 | Have you ever felt guilty that you were abusing others' resources? I have, and while there's no way around it sometimes, when there is I tend 41 | to take it. That's what initially got me thinking - I could build my own ATProto image proxy instead of using Bluesky's! 42 | When [Sam](https://mozzius.dev) mentioned making an image proxy as a first step to a high quality Instagram clone, I knew exactly what to do. 43 | I dove in and ended up making one. In doing so, it was my first time using the Hyper http server library, which has surprisingly good 44 | devex for such a low level library. Very pleased. 45 | 46 | I put up a demo server, and it looks like it's been operating perfectly fine so far. Here's an image served from said server: 47 | 53 | It's really grainy, but that's only because it's the demo server. I've set the jpeg compression to max in this case, but if you 54 | deploy it you can set it to whatever you want! 55 | Hopefully people only use it for testing, but if someone somehow wants to use such low quality images, please go for it but also please 56 | let me know first. 57 | 58 | ### teal.fm updates 59 | Teal.fm is an ATProto-based music scrobbling service. Well, that's what it's supposed to be. Most of the team has prior obligations and thus 60 | can't spend as much time on it, so development has all but stalled. But, that's where I come in! I don't really have prior obligations and 61 | hiring is pretty slow currently, so I can devote some time to it in between all my other stuff. 62 | 63 | #### State of teal.fm 64 | Currently, we have done: 65 | - manual scrobbling 66 | - jetstream ingest 67 | - all the xrpc routes we have lexicons for 68 | - docs (inc'l auto documenting of all the lexicons we use in teal.fm) 69 | 70 | What's to come (when one of us can work on it), ordered by priority: 71 | - track matching api 72 | - ingester rewrite (in rust) 73 | - move from sqlite to postgres/clickhouse 74 | - last.fm import 75 | - more xrpc routes 76 | - your teal.fm profile/separate social graph 77 | - and more! 78 | 79 | I've recently laid out and finalized the manual scrobbling feature, and hopefully we can get that out to a few users soon here. 80 | 81 | ### atp.tools updates 82 | I've added a few more views in pursuit of having all important lexicons be 'view'able. These ones were easy - the bluesky actor 83 | profile, and feed repost/like. I've also made a few layout changes, so there's no scrollbar on the right when there doesn't need to be. 84 | 85 | 86 | ## What did I listen to this week? 87 | I've been hooked on my [sad songs playlist](https://tidal.com/browse/playlist/808392ca-2b2e-4e1d-aa47-319470cc95ff) recently. 88 | Also lots of alt/rock. Think My Chemical Romance, Paramore, The 1975. Surprisingly nice to listen to while getting stuff done. 89 | 90 | ## What did I play this week? 91 | [Metaphor Refantazio](https://store.steampowered.com/app/2679460/Metaphor_ReFantazio/)! It feels like I'm almost done with it - 92 | I'm at the part right before the last two dungeons, when that lil twist happens! 93 | 94 | I've also been getting into [Satisfactory](https://store.steampowered.com/app/526870/Satisfactory/), I like it so far but I can't 95 | play more than like two hours of it before my brain turns to mush. 96 | 97 | ## Um, anyways 98 | That should be it! Hopefully you enjoyed reading my first Weekly as much as I did writing it. This format is really nice to get my ideas out 99 | when I have them, so expect something similar next week. 100 | 101 | p.s. I'm really glad I made the comments component for my guestbook, so I can just use it here! 102 | 103 | ## Comments 104 | Thoughts? Questions? Responses? Feel free to let me know [on Bluesky](https://bsky.app/profile/natalie.sh/post/3lhseg5goik2o)! 105 | 106 | -------------------------------------------------------------------------------- /components/time/ticker.tsx: -------------------------------------------------------------------------------- 1 | /* Heavily modified from https://github.com/bitttttten/mechanical-counter */ 2 | 3 | import React, { 4 | memo, 5 | useEffect, 6 | useCallback, 7 | useRef, 8 | useState, 9 | CSSProperties, 10 | } from "react"; 11 | 12 | export interface AnimatedCounterProps { 13 | value?: number; 14 | incrementColor?: string; 15 | decrementColor?: string; 16 | includeDecimals?: boolean; 17 | decimalPrecision?: number; 18 | padNumber?: number; 19 | showColorsWhenValueChanges?: boolean; 20 | includeCommas?: boolean; 21 | containerStyles?: CSSProperties; 22 | digitStyles?: CSSProperties; 23 | className?: string; 24 | } 25 | 26 | export interface NumberColumnProps { 27 | digit: string; 28 | delta: string | null; 29 | incrementColor: string; 30 | decrementColor: string; 31 | digitStyles: CSSProperties; 32 | showColorsWhenValueChanges?: boolean; 33 | } 34 | 35 | export interface DecimalColumnProps { 36 | isComma: boolean; 37 | digitStyles: CSSProperties; 38 | } 39 | 40 | // Decimal element component 41 | const DecimalColumn = ({ isComma, digitStyles }: DecimalColumnProps) => ( 42 | 43 | {isComma ? "," : "."} 44 | 45 | ); 46 | 47 | // Individual number element component 48 | const NumberColumn = memo( 49 | ({ 50 | digit, 51 | delta, 52 | incrementColor, 53 | decrementColor, 54 | digitStyles, 55 | showColorsWhenValueChanges, 56 | }: NumberColumnProps) => { 57 | const [position, setPosition] = useState(0); 58 | const [animationClass, setAnimationClass] = useState(null); 59 | const [movementType, setMovementType] = useState< 60 | "increment" | "decrement" | null 61 | >(null); 62 | const currentDigit = +digit; 63 | const previousDigit = usePrevious(+currentDigit); 64 | const columnContainer = useRef(null); 65 | 66 | const setColumnToNumber = useCallback( 67 | (number: string) => { 68 | if (columnContainer.current) { 69 | setPosition( 70 | columnContainer.current.clientHeight * parseInt(number, 10), 71 | ); 72 | } 73 | }, 74 | [columnContainer.current?.clientHeight], 75 | ); 76 | 77 | useEffect(() => { 78 | setAnimationClass(previousDigit !== currentDigit ? delta : ""); 79 | if (!showColorsWhenValueChanges) return; 80 | if (delta === "animate-moveUp") { 81 | setMovementType("increment"); 82 | } else if (delta === "animate-moveDown") { 83 | setMovementType("decrement"); 84 | } 85 | }, [digit, delta, previousDigit, currentDigit]); 86 | 87 | // reset movementType after 300ms 88 | useEffect(() => { 89 | if (movementType !== null) 90 | setTimeout(() => { 91 | setMovementType(null); 92 | }, 200); 93 | }, [movementType !== null, movementType]); 94 | 95 | useEffect(() => { 96 | setColumnToNumber(digit); 97 | }, [digit, setColumnToNumber]); 98 | 99 | if (digit === "-") { 100 | return {digit}; 101 | } 102 | 103 | return ( 104 |
105 |
118 | {[9, 8, 7, 6, 5, 4, 3, 2, 1, 0].reverse().map((num) => ( 119 |
120 | {num} 121 |
122 | ))} 123 |
124 | 0 125 |
126 | ); 127 | }, 128 | (prevProps, nextProps) => 129 | prevProps.digit === nextProps.digit && prevProps.delta === nextProps.delta, 130 | ); 131 | 132 | // Main component 133 | const AnimatedCounter = ({ 134 | value = 0, 135 | incrementColor = "#32cd32", 136 | decrementColor = "#fe6862", 137 | includeDecimals = true, 138 | decimalPrecision = 2, 139 | includeCommas = false, 140 | containerStyles = {}, 141 | digitStyles = {}, 142 | padNumber = 0, 143 | className = "", 144 | showColorsWhenValueChanges = true, 145 | }: AnimatedCounterProps) => { 146 | const numArray = formatForDisplay( 147 | Math.abs(value), 148 | includeDecimals, 149 | decimalPrecision, 150 | includeCommas, 151 | padNumber, 152 | ); 153 | const previousNumber = usePrevious(value); 154 | const isNegative = value < 0; 155 | 156 | let delta: string | null = null; 157 | 158 | if (previousNumber !== null) { 159 | if (value > previousNumber) { 160 | delta = "animate-moveUp"; // Tailwind class for increase 161 | } else if (value < previousNumber) { 162 | delta = "animate-moveDown"; // Tailwind class for decrease 163 | } 164 | } 165 | 166 | return ( 167 |
171 | {/* If number is negative, render '-' feedback */} 172 | {isNegative && ( 173 | 182 | )} 183 | {/* Format integer to NumberColumn components */} 184 | {numArray.map((number: string, index: number) => 185 | number === "." || number === "," ? ( 186 | 191 | ) : ( 192 | 201 | ), 202 | )} 203 |
204 | ); 205 | }; 206 | 207 | const formatForDisplay = ( 208 | number: number, 209 | includeDecimals: boolean, 210 | decimalPrecision: number, 211 | includeCommas: boolean, 212 | padTo: number = 0, 213 | ): string[] => { 214 | const decimalCount = includeDecimals ? decimalPrecision : 0; 215 | const parsedNumber = parseFloat(`${Math.max(number, 0)}`).toFixed( 216 | decimalCount, 217 | ); 218 | const numberToFormat = includeCommas 219 | ? parseFloat(parsedNumber).toLocaleString("en-US", { 220 | minimumFractionDigits: includeDecimals ? decimalPrecision : 0, 221 | }) 222 | : parsedNumber; 223 | return numberToFormat.padStart(padTo, "0").split(""); 224 | }; 225 | 226 | // Hook used to track previous value of primary number state in AnimatedCounter & individual digits in NumberColumn 227 | const usePrevious = (value: number | null) => { 228 | const ref = useRef(null); 229 | useEffect(() => { 230 | ref.current = value; 231 | }, [value]); 232 | return ref.current; 233 | }; 234 | 235 | export default AnimatedCounter; 236 | -------------------------------------------------------------------------------- /app/(home)/uses/page.tsx: -------------------------------------------------------------------------------- 1 | import TimeAgo from "@/components/timeago"; 2 | import Card from "@/components/ui/card"; 3 | import { FaCode, FaGithub } from "react-icons/fa"; 4 | import { IconType } from "react-icons/lib"; 5 | import { LuClock, LuLink } from "react-icons/lu"; 6 | 7 | export default function Projects() { 8 | return ( 9 |
10 | 11 |
12 |

/uses

13 |
14 | Inspired by{" "} 15 | 20 | Wes Bos' /uses 21 | 22 | . Find more /uses here. 23 |
24 |
25 | 26 | 27 | Last updated 28 | 29 |
30 |
31 |

32 | I use a variety of tools and services to make my life easier. I'm 33 | always looking for new ways to improve my workflow, so if you have 34 | any suggestions or recommendations, please let me know! 35 |

36 |

37 | Anyways, this should be a pretty comprehensive list of the tools I 38 | use often. 39 |

40 |
41 |

Editor + Terminal

42 |
43 |
    44 |
  • 45 | I commonly use{" "} 46 | 51 | VSCode 52 | {" "} 53 | , but I'm leaning on{" "} 54 | 59 | Zed 60 | {" "} 61 | more, because I find it significantly faster to use. 62 |
  • 63 |
  • 64 | I use{" "} 65 | 70 | Catpuccin Mocha 71 | {" "} 72 | everywhere I can, including VSCode, Zed, and Spotify! 73 |
  • 74 |
  • 75 | My font of choice is{" "} 76 | iA Writer Mono{" "} 77 | (or the Nerd Font variant, iMWriting Mono). 78 |
  • 79 |
  • 80 | For my terminal, I mainly use iTerm2 on macOS, but occasionally 81 | I may use the Windows Terminal or Alacritty. 82 |
  • 83 |
  • 84 | On my terminal, I generally use zsh with oh-my-zsh + my Paramour 85 | theme. 86 |
  • 87 |
88 |

Desktop Apps

89 | 140 |

Desk Setup

141 |
    142 |
  • 143 | For my monitors, I use a LG UltraGear 34GP63A-B (1440p, 144 | ultrawide, VA panel :p) and a UltraGear 27GL83A-B (1440p, IPS 145 | :D). 146 |
  • 147 |
  • 148 | I have a little KVM switcher setup for my MacBook Pro and 149 | Desktop computers, so I can switch my whole setup between them 150 | instantly. 151 |
  • 152 |
  • 153 | I use{" "} 154 | 159 | MX Master 3 160 | {" "} 161 | for my work mouse, and a{" "} 162 | 167 | Logitech G Pro X Superlight 168 | {" "} 169 | for my gaming mouse. 170 |
  • 171 |
  • 172 | I use a{" "} 173 | 178 | ZSA Moonlander 179 | {" "} 180 | for my work keyboard, but I also have a{" "} 181 | 186 | Massdrop ALT 187 | 188 | . 189 |
  • 190 |
  • 191 | For my desk, I have an{" "} 192 | 197 | IKEA Bekant 198 | {" "} 199 | standing desk base, with the{" "} 200 | 205 | Uppspel 206 | {" "} 207 | top (yes, the Republic of Gamers one) on backwards (I swear it's 208 | better). I wouldn't recommend either - they're expensive and 209 | made with poorer quality materials, but I like them enough to 210 | not throw them out. 211 |
  • 212 |
213 |

Computer

214 | 278 |

Linux

279 |
    280 |
  • 281 | I use{" "} 282 | 287 | CachyOS 288 | {" "} 289 | on my desktop, which is a lightweight Linux distribution that is 290 | focused on performance and security. 291 |
  • 292 |
  • 293 | {" "} 294 | My daily driver WM currently is Hyprland (looking for other 295 | options if you have them!) with dotfiles based off{" "} 296 | 297 | JaKooLit's Arch-Hyprland 298 | {" "} 299 | dotfiles. 300 |
  • 301 |
302 |

Audio

303 | 363 |
364 |
365 |
366 |
367 | ); 368 | } 369 | -------------------------------------------------------------------------------- /components/shader/wave.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Stripe WebGl Gradient Animation 3 | * All Credits to Stripe.com 4 | * ScrollObserver functionality to disable animation when not scrolled into view has been disabled and 5 | * commented out for now. 6 | */ 7 | 8 | /* 9 | * HTML 10 | * 11 | */ 12 | 13 | /* 14 | * CSS 15 | * #gradient-canvas { 16 | * width:100%; 17 | * height:100%; 18 | * --gradient-color-1: #6a00ff; 19 | * --gradient-color-2: #5618ad; 20 | * --gradient-color-3: #ff9382; 21 | * --gradient-color-4: #f6575b; 22 | * } 23 | */ 24 | 25 | /* 26 | * JS 27 | * import { Gradient } from './Gradient.js' 28 | * 29 | * // Create your instance 30 | * const gradient = new Gradient() 31 | * 32 | * // Call `initGradient` with the selector to your canvas 33 | * gradient.initGradient('#gradient-canvas') 34 | */ 35 | 36 | import blendGlsl from "./blend.glsl"; 37 | import fragmentGlsl from "./fragment.glsl"; 38 | import noiseGlsl from "./noise.glsl"; 39 | import vertexGlsl from "./vert.glsl"; 40 | 41 | //Converting colors to proper format 42 | function normalizeColor(hexCode) { 43 | return [ 44 | ((hexCode >> 16) & 255) / 255, 45 | ((hexCode >> 8) & 255) / 255, 46 | (255 & hexCode) / 255, 47 | ]; 48 | } 49 | ["SCREEN", "LINEAR_LIGHT"].reduce( 50 | (hexCode, t, n) => 51 | Object.assign(hexCode, { 52 | [t]: n, 53 | }), 54 | {}, 55 | ); 56 | 57 | //Essential functionality of WebGl 58 | //t = width 59 | //n = height 60 | class MiniGl { 61 | constructor(canvas, width, height, debug = false) { 62 | const _miniGl = this, 63 | debug_output = 64 | -1 !== document.location.search.toLowerCase().indexOf("debug=webgl"); 65 | (_miniGl.canvas = canvas), 66 | (_miniGl.gl = _miniGl.canvas.getContext("webgl", { 67 | antialias: true, 68 | })), 69 | (_miniGl.meshes = []); 70 | const context = _miniGl.gl; 71 | width && height && this.setSize(width, height), 72 | _miniGl.lastDebugMsg, 73 | (_miniGl.debug = 74 | debug && debug_output 75 | ? function (e) { 76 | const t = new Date(); 77 | t - _miniGl.lastDebugMsg > 1e3 && console.log("---"), 78 | console.log( 79 | t.toLocaleTimeString() + 80 | Array(Math.max(0, 32 - e.length)).join(" ") + 81 | e + 82 | ": ", 83 | ...Array.from(arguments).slice(1), 84 | ), 85 | (_miniGl.lastDebugMsg = t); 86 | } 87 | : () => {}), 88 | Object.defineProperties(_miniGl, { 89 | Material: { 90 | enumerable: false, 91 | value: class { 92 | constructor(vertexShaders, fragments, uniforms = {}) { 93 | const material = this; 94 | function getShaderByType(type, source) { 95 | const shader = context.createShader(type); 96 | return ( 97 | context.shaderSource(shader, source), 98 | context.compileShader(shader), 99 | context.getShaderParameter(shader, context.COMPILE_STATUS) || 100 | console.error(context.getShaderInfoLog(shader)), 101 | _miniGl.debug("Material.compileShaderSource", { 102 | source: source, 103 | }), 104 | shader 105 | ); 106 | } 107 | function getUniformVariableDeclarations(uniforms, type) { 108 | return Object.entries(uniforms) 109 | .map(([uniform, value]) => 110 | value.getDeclaration(uniform, type), 111 | ) 112 | .join("\n"); 113 | } 114 | (material.uniforms = uniforms), (material.uniformInstances = []); 115 | 116 | const prefix = 117 | "\n precision highp float;\n "; 118 | (material.vertexSource = `\n ${prefix}\n attribute vec4 position;\n attribute vec2 uv;\n attribute vec2 uvNorm;\n ${getUniformVariableDeclarations( 119 | _miniGl.commonUniforms, 120 | "vertex", 121 | )}\n ${getUniformVariableDeclarations( 122 | uniforms, 123 | "vertex", 124 | )}\n ${vertexShaders}\n `), 125 | (material.Source = `\n ${prefix}\n ${getUniformVariableDeclarations( 126 | _miniGl.commonUniforms, 127 | "fragment", 128 | )}\n ${getUniformVariableDeclarations( 129 | uniforms, 130 | "fragment", 131 | )}\n ${fragments}\n `), 132 | (material.vertexShader = getShaderByType( 133 | context.VERTEX_SHADER, 134 | material.vertexSource, 135 | )), 136 | (material.fragmentShader = getShaderByType( 137 | context.FRAGMENT_SHADER, 138 | material.Source, 139 | )), 140 | (material.program = context.createProgram()), 141 | context.attachShader(material.program, material.vertexShader), 142 | context.attachShader(material.program, material.fragmentShader), 143 | context.linkProgram(material.program), 144 | context.getProgramParameter( 145 | material.program, 146 | context.LINK_STATUS, 147 | ) || console.error(context.getProgramInfoLog(material.program)), 148 | context.useProgram(material.program), 149 | material.attachUniforms(void 0, _miniGl.commonUniforms), 150 | material.attachUniforms(void 0, material.uniforms); 151 | } 152 | //t = uniform 153 | attachUniforms(name, uniforms) { 154 | //n = material 155 | const material = this; 156 | void 0 === name 157 | ? Object.entries(uniforms).forEach(([name, uniform]) => { 158 | material.attachUniforms(name, uniform); 159 | }) 160 | : "array" == uniforms.type 161 | ? uniforms.value.forEach((uniform, i) => 162 | material.attachUniforms(`${name}[${i}]`, uniform), 163 | ) 164 | : "struct" == uniforms.type 165 | ? Object.entries(uniforms.value).forEach(([uniform, i]) => 166 | material.attachUniforms(`${name}.${uniform}`, i), 167 | ) 168 | : (_miniGl.debug("Material.attachUniforms", { 169 | name: name, 170 | uniform: uniforms, 171 | }), 172 | material.uniformInstances.push({ 173 | uniform: uniforms, 174 | location: context.getUniformLocation( 175 | material.program, 176 | name, 177 | ), 178 | })); 179 | } 180 | }, 181 | }, 182 | Uniform: { 183 | enumerable: !1, 184 | value: class { 185 | constructor(e) { 186 | (this.type = "float"), Object.assign(this, e); 187 | (this.typeFn = 188 | { 189 | float: "1f", 190 | int: "1i", 191 | vec2: "2fv", 192 | vec3: "3fv", 193 | vec4: "4fv", 194 | mat4: "Matrix4fv", 195 | }[this.type] || "1f"), 196 | this.update(); 197 | } 198 | update(value) { 199 | void 0 !== this.value && 200 | context[`uniform${this.typeFn}`]( 201 | value, 202 | 0 === this.typeFn.indexOf("Matrix") 203 | ? this.transpose 204 | : this.value, 205 | 0 === this.typeFn.indexOf("Matrix") ? this.value : null, 206 | ); 207 | } 208 | //e - name 209 | //t - type 210 | //n - length 211 | getDeclaration(name, type, length) { 212 | const uniform = this; 213 | if (uniform.excludeFrom !== type) { 214 | if ("array" === uniform.type) 215 | return ( 216 | uniform.value[0].getDeclaration( 217 | name, 218 | type, 219 | uniform.value.length, 220 | ) + `\nconst int ${name}_length = ${uniform.value.length};` 221 | ); 222 | if ("struct" === uniform.type) { 223 | let name_no_prefix = name.replace("u_", ""); 224 | return ( 225 | (name_no_prefix = 226 | name_no_prefix.charAt(0).toUpperCase() + 227 | name_no_prefix.slice(1)), 228 | `uniform struct ${name_no_prefix} 229 | {\n` + 230 | Object.entries(uniform.value) 231 | .map(([name, uniform]) => 232 | uniform 233 | .getDeclaration(name, type) 234 | .replace(/^uniform/, ""), 235 | ) 236 | .join("") + 237 | `\n} ${name}${length > 0 ? `[${length}]` : ""};` 238 | ); 239 | } 240 | return `uniform ${uniform.type} ${name}${ 241 | length > 0 ? `[${length}]` : "" 242 | };`; 243 | } 244 | } 245 | }, 246 | }, 247 | PlaneGeometry: { 248 | enumerable: !1, 249 | value: class { 250 | constructor(width, height, n, i, orientation) { 251 | context.createBuffer(), 252 | (this.attributes = { 253 | position: new _miniGl.Attribute({ 254 | target: context.ARRAY_BUFFER, 255 | size: 3, 256 | }), 257 | uv: new _miniGl.Attribute({ 258 | target: context.ARRAY_BUFFER, 259 | size: 2, 260 | }), 261 | uvNorm: new _miniGl.Attribute({ 262 | target: context.ARRAY_BUFFER, 263 | size: 2, 264 | }), 265 | index: new _miniGl.Attribute({ 266 | target: context.ELEMENT_ARRAY_BUFFER, 267 | size: 3, 268 | type: context.UNSIGNED_SHORT, 269 | }), 270 | }), 271 | this.setTopology(n, i), 272 | this.setSize(width, height, orientation); 273 | } 274 | setTopology(e = 1, t = 1) { 275 | const n = this; 276 | (n.xSegCount = e), 277 | (n.ySegCount = t), 278 | (n.vertexCount = (n.xSegCount + 1) * (n.ySegCount + 1)), 279 | (n.quadCount = n.xSegCount * n.ySegCount * 2), 280 | (n.attributes.uv.values = new Float32Array(2 * n.vertexCount)), 281 | (n.attributes.uvNorm.values = new Float32Array( 282 | 2 * n.vertexCount, 283 | )), 284 | (n.attributes.index.values = new Uint16Array(3 * n.quadCount)); 285 | for (let e = 0; e <= n.ySegCount; e++) 286 | for (let t = 0; t <= n.xSegCount; t++) { 287 | const i = e * (n.xSegCount + 1) + t; 288 | if ( 289 | ((n.attributes.uv.values[2 * i] = t / n.xSegCount), 290 | (n.attributes.uv.values[2 * i + 1] = 1 - e / n.ySegCount), 291 | (n.attributes.uvNorm.values[2 * i] = 292 | (t / n.xSegCount) * 2 - 1), 293 | (n.attributes.uvNorm.values[2 * i + 1] = 294 | 1 - (e / n.ySegCount) * 2), 295 | t < n.xSegCount && e < n.ySegCount) 296 | ) { 297 | const s = e * n.xSegCount + t; 298 | (n.attributes.index.values[6 * s] = i), 299 | (n.attributes.index.values[6 * s + 1] = 300 | i + 1 + n.xSegCount), 301 | (n.attributes.index.values[6 * s + 2] = i + 1), 302 | (n.attributes.index.values[6 * s + 3] = i + 1), 303 | (n.attributes.index.values[6 * s + 4] = 304 | i + 1 + n.xSegCount), 305 | (n.attributes.index.values[6 * s + 5] = 306 | i + 2 + n.xSegCount); 307 | } 308 | } 309 | n.attributes.uv.update(), 310 | n.attributes.uvNorm.update(), 311 | n.attributes.index.update(), 312 | _miniGl.debug("Geometry.setTopology", { 313 | uv: n.attributes.uv, 314 | uvNorm: n.attributes.uvNorm, 315 | index: n.attributes.index, 316 | }); 317 | } 318 | setSize(width = 1, height = 1, orientation = "xz") { 319 | const geometry = this; 320 | (geometry.width = width), 321 | (geometry.height = height), 322 | (geometry.orientation = orientation), 323 | (geometry.attributes.position.values && 324 | geometry.attributes.position.values.length === 325 | 3 * geometry.vertexCount) || 326 | (geometry.attributes.position.values = new Float32Array( 327 | 3 * geometry.vertexCount, 328 | )); 329 | const o = width / -2, 330 | r = height / -2, 331 | segment_width = width / geometry.xSegCount, 332 | segment_height = height / geometry.ySegCount; 333 | for (let yIndex = 0; yIndex <= geometry.ySegCount; yIndex++) { 334 | const t = r + yIndex * segment_height; 335 | for (let xIndex = 0; xIndex <= geometry.xSegCount; xIndex++) { 336 | const r = o + xIndex * segment_width, 337 | l = yIndex * (geometry.xSegCount + 1) + xIndex; 338 | (geometry.attributes.position.values[ 339 | 3 * l + "xyz".indexOf(orientation[0]) 340 | ] = r), 341 | (geometry.attributes.position.values[ 342 | 3 * l + "xyz".indexOf(orientation[1]) 343 | ] = -t); 344 | } 345 | } 346 | geometry.attributes.position.update(), 347 | _miniGl.debug("Geometry.setSize", { 348 | position: geometry.attributes.position, 349 | }); 350 | } 351 | }, 352 | }, 353 | Mesh: { 354 | enumerable: !1, 355 | value: class { 356 | constructor(geometry, material) { 357 | const mesh = this; 358 | (mesh.geometry = geometry), 359 | (mesh.material = material), 360 | (mesh.wireframe = !1), 361 | (mesh.attributeInstances = []), 362 | Object.entries(mesh.geometry.attributes).forEach( 363 | ([e, attribute]) => { 364 | mesh.attributeInstances.push({ 365 | attribute: attribute, 366 | location: attribute.attach(e, mesh.material.program), 367 | }); 368 | }, 369 | ), 370 | _miniGl.meshes.push(mesh), 371 | _miniGl.debug("Mesh.constructor", { 372 | mesh: mesh, 373 | }); 374 | } 375 | draw() { 376 | context.useProgram(this.material.program), 377 | this.material.uniformInstances.forEach( 378 | ({ uniform: e, location: t }) => e.update(t), 379 | ), 380 | this.attributeInstances.forEach( 381 | ({ attribute: e, location: t }) => e.use(t), 382 | ), 383 | context.drawElements( 384 | this.wireframe ? context.LINES : context.TRIANGLES, 385 | this.geometry.attributes.index.values.length, 386 | context.UNSIGNED_SHORT, 387 | 0, 388 | ); 389 | } 390 | remove() { 391 | _miniGl.meshes = _miniGl.meshes.filter((e) => e != this); 392 | } 393 | }, 394 | }, 395 | Attribute: { 396 | enumerable: !1, 397 | value: class { 398 | constructor(e) { 399 | (this.type = context.FLOAT), 400 | (this.normalized = !1), 401 | (this.buffer = context.createBuffer()), 402 | Object.assign(this, e), 403 | this.update(); 404 | } 405 | update() { 406 | void 0 !== this.values && 407 | (context.bindBuffer(this.target, this.buffer), 408 | context.bufferData( 409 | this.target, 410 | this.values, 411 | context.STATIC_DRAW, 412 | )); 413 | } 414 | attach(e, t) { 415 | const n = context.getAttribLocation(t, e); 416 | return ( 417 | this.target === context.ARRAY_BUFFER && 418 | (context.enableVertexAttribArray(n), 419 | context.vertexAttribPointer( 420 | n, 421 | this.size, 422 | this.type, 423 | this.normalized, 424 | 0, 425 | 0, 426 | )), 427 | n 428 | ); 429 | } 430 | use(e) { 431 | context.bindBuffer(this.target, this.buffer), 432 | this.target === context.ARRAY_BUFFER && 433 | (context.enableVertexAttribArray(e), 434 | context.vertexAttribPointer( 435 | e, 436 | this.size, 437 | this.type, 438 | this.normalized, 439 | 0, 440 | 0, 441 | )); 442 | } 443 | }, 444 | }, 445 | }); 446 | const a = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]; 447 | _miniGl.commonUniforms = { 448 | projectionMatrix: new _miniGl.Uniform({ 449 | type: "mat4", 450 | value: a, 451 | }), 452 | modelViewMatrix: new _miniGl.Uniform({ 453 | type: "mat4", 454 | value: a, 455 | }), 456 | resolution: new _miniGl.Uniform({ 457 | type: "vec2", 458 | value: [1, 1], 459 | }), 460 | aspectRatio: new _miniGl.Uniform({ 461 | type: "float", 462 | value: 1, 463 | }), 464 | }; 465 | } 466 | setSize(e = 1080, t = 1920) { 467 | (this.width = e), 468 | (this.height = t), 469 | (this.canvas.width = e), 470 | (this.canvas.height = t), 471 | this.gl.viewport(0, 0, e, t), 472 | (this.commonUniforms.resolution.value = [e, t]), 473 | (this.commonUniforms.aspectRatio.value = e / t), 474 | this.debug("MiniGL.setSize", { 475 | width: e, 476 | height: t, 477 | }); 478 | } 479 | //left, right, top, bottom, near, far 480 | setOrthographicCamera(e = 0, t = 0, n = 0, i = -2e3, s = 2e3) { 481 | (this.commonUniforms.projectionMatrix.value = [ 482 | 2 / this.width, 483 | 0, 484 | 0, 485 | 0, 486 | 0, 487 | 2 / this.height, 488 | 0, 489 | 0, 490 | 0, 491 | 0, 492 | 2 / (i - s), 493 | 0, 494 | e, 495 | t, 496 | n, 497 | 1, 498 | ]), 499 | this.debug( 500 | "setOrthographicCamera", 501 | this.commonUniforms.projectionMatrix.value, 502 | ); 503 | } 504 | render() { 505 | this.gl.clearColor(0, 0, 0, 0), 506 | this.gl.clearDepth(1), 507 | this.meshes.forEach((e) => e.draw()); 508 | } 509 | } 510 | 511 | //Sets initial properties 512 | function e(object, propertyName, val) { 513 | return ( 514 | propertyName in object 515 | ? Object.defineProperty(object, propertyName, { 516 | value: val, 517 | enumerable: !0, 518 | configurable: !0, 519 | writable: !0, 520 | }) 521 | : (object[propertyName] = val), 522 | object 523 | ); 524 | } 525 | 526 | //Gradient object 527 | class Gradient { 528 | constructor(...t) { 529 | e(this, "el", void 0), 530 | e(this, "cssVarRetries", 0), 531 | e(this, "maxCssVarRetries", 200), 532 | e(this, "angle", 0), 533 | e(this, "isLoadedClass", !1), 534 | e(this, "isScrolling", !1), 535 | /*e(this, "isStatic", o.disableAmbientAnimations()),*/ e( 536 | this, 537 | "scrollingTimeout", 538 | void 0, 539 | ), 540 | e(this, "scrollingRefreshDelay", 200), 541 | e(this, "isIntersecting", !1), 542 | e(this, "shaderFiles", void 0), 543 | e(this, "vertexShader", void 0), 544 | e(this, "sectionColors", void 0), 545 | e(this, "computedCanvasStyle", void 0), 546 | e(this, "conf", void 0), 547 | e(this, "uniforms", void 0), 548 | e(this, "t", 1253106), 549 | e(this, "last", 0), 550 | e(this, "width", void 0), 551 | e(this, "minWidth", 1111), 552 | e(this, "height", 600), 553 | e(this, "xSegCount", void 0), 554 | e(this, "ySegCount", void 0), 555 | e(this, "mesh", void 0), 556 | e(this, "material", void 0), 557 | e(this, "geometry", void 0), 558 | e(this, "minigl", void 0), 559 | e(this, "scrollObserver", void 0), 560 | e(this, "amp", 320), 561 | e(this, "seed", 5), 562 | e(this, "freqX", 14e-5), 563 | e(this, "freqY", 29e-5), 564 | e(this, "freqDelta", 1e-5), 565 | e(this, "activeColors", [1, 1, 1, 1]), 566 | e(this, "isMetaKey", !1), 567 | e(this, "isGradientLegendVisible", !1), 568 | e(this, "isMouseDown", !1), 569 | e(this, "handleScroll", () => { 570 | clearTimeout(this.scrollingTimeout), 571 | (this.scrollingTimeout = setTimeout( 572 | this.handleScrollEnd, 573 | this.scrollingRefreshDelay, 574 | )), 575 | this.isGradientLegendVisible && this.hideGradientLegend(), 576 | this.conf.playing && ((this.isScrolling = !0), this.pause()); 577 | }), 578 | e(this, "handleScrollEnd", () => { 579 | (this.isScrolling = !1), this.isIntersecting && this.play(); 580 | }), 581 | e(this, "resize", () => { 582 | (this.width = window.innerWidth), 583 | this.minigl.setSize(this.width, this.height), 584 | this.minigl.setOrthographicCamera(), 585 | (this.xSegCount = Math.ceil(this.width * this.conf.density[0])), 586 | (this.ySegCount = Math.ceil(this.height * this.conf.density[1])), 587 | this.mesh.geometry.setTopology(this.xSegCount, this.ySegCount), 588 | this.mesh.geometry.setSize(this.width, this.height), 589 | (this.mesh.material.uniforms.u_shadow_power.value = 590 | this.width < 600 ? 5 : 6); 591 | }), 592 | e(this, "handleMouseDown", (e) => { 593 | this.isGradientLegendVisible && 594 | ((this.isMetaKey = e.metaKey), 595 | (this.isMouseDown = !0), 596 | !1 === this.conf.playing && requestAnimationFrame(this.animate)); 597 | }), 598 | e(this, "handleMouseUp", () => { 599 | this.isMouseDown = !1; 600 | }), 601 | e(this, "animate", (e) => { 602 | if (!this.shouldSkipFrame(e) || this.isMouseDown) { 603 | if ( 604 | ((this.t += Math.min(e - this.last, 1e3 / 15)), 605 | (this.last = e), 606 | this.isMouseDown) 607 | ) { 608 | let e = 160; 609 | this.isMetaKey && (e = -160), (this.t += e); 610 | } 611 | (this.mesh.material.uniforms.u_time.value = this.t), 612 | this.minigl.render(); 613 | } 614 | if (0 !== this.last && this.isStatic) 615 | return this.minigl.render(), void this.disconnect(); 616 | /*this.isIntersecting && */ (this.conf.playing || this.isMouseDown) && 617 | requestAnimationFrame(this.animate); 618 | }), 619 | e(this, "addIsLoadedClass", () => { 620 | /*this.isIntersecting && */ !this.isLoadedClass && 621 | ((this.isLoadedClass = !0), 622 | this.el.classList.add("isLoaded"), 623 | setTimeout(() => { 624 | this.el.parentElement && 625 | this.el.parentElement.classList.add("isLoaded"); 626 | }, 3e3)); 627 | }), 628 | e(this, "pause", () => { 629 | this.conf.playing = false; 630 | }), 631 | e(this, "play", () => { 632 | requestAnimationFrame(this.animate), (this.conf.playing = true); 633 | }), 634 | e(this, "initGradient", (selector) => { 635 | this.el = document.querySelector(selector); 636 | this.connect(); 637 | return this; 638 | }); 639 | } 640 | async connect() { 641 | (this.shaderFiles = { 642 | vertex: vertexGlsl, 643 | fragment: fragmentGlsl, 644 | noise: noiseGlsl, 645 | blend: blendGlsl, 646 | }), 647 | (this.conf = { 648 | presetName: "", 649 | wireframe: false, 650 | density: [0.06, 0.16], 651 | zoom: 1, 652 | rotation: 0, 653 | playing: true, 654 | }), 655 | document.querySelectorAll("canvas").length < 1 656 | ? console.log("DID NOT LOAD HERO STRIPE CANVAS") 657 | : ((this.minigl = new MiniGl(this.el, null, null, !0)), 658 | requestAnimationFrame(() => { 659 | this.el && 660 | ((this.computedCanvasStyle = getComputedStyle(this.el)), 661 | this.waitForCssVars()); 662 | })); 663 | /* 664 | this.scrollObserver = await s.create(.1, !1), 665 | this.scrollObserver.observe(this.el), 666 | this.scrollObserver.onSeparate(() => { 667 | window.removeEventListener("scroll", this.handleScroll), window.removeEventListener("mousedown", this.handleMouseDown), window.removeEventListener("mouseup", this.handleMouseUp), window.removeEventListener("keydown", this.handleKeyDown), this.isIntersecting = !1, this.conf.playing && this.pause() 668 | }), 669 | this.scrollObserver.onIntersect(() => { 670 | window.addEventListener("scroll", this.handleScroll), window.addEventListener("mousedown", this.handleMouseDown), window.addEventListener("mouseup", this.handleMouseUp), window.addEventListener("keydown", this.handleKeyDown), this.isIntersecting = !0, this.addIsLoadedClass(), this.play() 671 | })*/ 672 | } 673 | disconnect() { 674 | this.scrollObserver && 675 | (window.removeEventListener("scroll", this.handleScroll), 676 | window.removeEventListener("mousedown", this.handleMouseDown), 677 | window.removeEventListener("mouseup", this.handleMouseUp), 678 | window.removeEventListener("keydown", this.handleKeyDown), 679 | this.scrollObserver.disconnect()), 680 | window.removeEventListener("resize", this.resize); 681 | } 682 | initMaterial() { 683 | this.uniforms = { 684 | u_time: new this.minigl.Uniform({ 685 | value: 0, 686 | }), 687 | u_shadow_power: new this.minigl.Uniform({ 688 | value: 5, 689 | }), 690 | u_darken_top: new this.minigl.Uniform({ 691 | value: "" === this.el.dataset.jsDarkenTop ? 1 : 0, 692 | }), 693 | u_active_colors: new this.minigl.Uniform({ 694 | value: this.activeColors, 695 | type: "vec4", 696 | }), 697 | u_global: new this.minigl.Uniform({ 698 | value: { 699 | noiseFreq: new this.minigl.Uniform({ 700 | value: [this.freqX, this.freqY], 701 | type: "vec2", 702 | }), 703 | noiseSpeed: new this.minigl.Uniform({ 704 | value: 5e-6, 705 | }), 706 | }, 707 | type: "struct", 708 | }), 709 | u_vertDeform: new this.minigl.Uniform({ 710 | value: { 711 | incline: new this.minigl.Uniform({ 712 | value: Math.sin(this.angle) / Math.cos(this.angle), 713 | }), 714 | offsetTop: new this.minigl.Uniform({ 715 | value: -0.5, 716 | }), 717 | offsetBottom: new this.minigl.Uniform({ 718 | value: -0.5, 719 | }), 720 | noiseFreq: new this.minigl.Uniform({ 721 | value: [1, 3], 722 | type: "vec2", 723 | }), 724 | noiseAmp: new this.minigl.Uniform({ 725 | value: this.amp, 726 | }), 727 | noiseSpeed: new this.minigl.Uniform({ 728 | value: 10, 729 | }), 730 | noiseFlow: new this.minigl.Uniform({ 731 | value: 3, 732 | }), 733 | noiseSeed: new this.minigl.Uniform({ 734 | value: this.seed, 735 | }), 736 | }, 737 | type: "struct", 738 | excludeFrom: "fragment", 739 | }), 740 | u_baseColor: new this.minigl.Uniform({ 741 | value: this.sectionColors[0], 742 | type: "vec3", 743 | excludeFrom: "fragment", 744 | }), 745 | u_waveLayers: new this.minigl.Uniform({ 746 | value: [], 747 | excludeFrom: "fragment", 748 | type: "array", 749 | }), 750 | }; 751 | for (let e = 1; e < this.sectionColors.length; e += 1) 752 | this.uniforms.u_waveLayers.value.push( 753 | new this.minigl.Uniform({ 754 | value: { 755 | color: new this.minigl.Uniform({ 756 | value: this.sectionColors[e], 757 | type: "vec3", 758 | }), 759 | noiseFreq: new this.minigl.Uniform({ 760 | value: [ 761 | 1 + e / this.sectionColors.length, 762 | 3 + e / this.sectionColors.length, 763 | ], 764 | type: "vec2", 765 | }), 766 | noiseSpeed: new this.minigl.Uniform({ 767 | value: 11 + 0.3 * e, 768 | }), 769 | noiseFlow: new this.minigl.Uniform({ 770 | value: 6.5 + 0.3 * e, 771 | }), 772 | noiseSeed: new this.minigl.Uniform({ 773 | value: this.seed + 10 * e, 774 | }), 775 | noiseFloor: new this.minigl.Uniform({ 776 | value: 0.1, 777 | }), 778 | noiseCeil: new this.minigl.Uniform({ 779 | value: 0.63 + 0.07 * e, 780 | }), 781 | }, 782 | type: "struct", 783 | }), 784 | ); 785 | return ( 786 | (this.vertexShader = [ 787 | this.shaderFiles.noise, 788 | this.shaderFiles.blend, 789 | this.shaderFiles.vertex, 790 | ].join("\n\n")), 791 | new this.minigl.Material( 792 | this.vertexShader, 793 | this.shaderFiles.fragment, 794 | this.uniforms, 795 | ) 796 | ); 797 | } 798 | initMesh() { 799 | (this.material = this.initMaterial()), 800 | (this.geometry = new this.minigl.PlaneGeometry()), 801 | (this.mesh = new this.minigl.Mesh(this.geometry, this.material)); 802 | } 803 | shouldSkipFrame(e) { 804 | return ( 805 | !!window.document.hidden || 806 | !this.conf.playing || 807 | parseInt(e, 10) % 2 == 0 || 808 | void 0 809 | ); 810 | } 811 | updateFrequency(e) { 812 | (this.freqX += e), (this.freqY += e); 813 | } 814 | toggleColor(index) { 815 | this.activeColors[index] = 0 === this.activeColors[index] ? 1 : 0; 816 | } 817 | showGradientLegend() { 818 | this.width > this.minWidth && 819 | ((this.isGradientLegendVisible = !0), 820 | document.body.classList.add("isGradientLegendVisible")); 821 | } 822 | hideGradientLegend() { 823 | (this.isGradientLegendVisible = !1), 824 | document.body.classList.remove("isGradientLegendVisible"); 825 | } 826 | init() { 827 | this.initGradientColors(), 828 | this.initMesh(), 829 | this.resize(), 830 | requestAnimationFrame(this.animate), 831 | window.addEventListener("resize", this.resize); 832 | } 833 | /* 834 | * Waiting for the css variables to become available, usually on page load before we can continue. 835 | * Using default colors assigned below if no variables have been found after maxCssVarRetries 836 | */ 837 | waitForCssVars() { 838 | if ( 839 | this.computedCanvasStyle && 840 | -1 !== 841 | this.computedCanvasStyle 842 | .getPropertyValue("--gradient-color-1") 843 | .indexOf("#") 844 | ) 845 | this.init(), this.addIsLoadedClass(); 846 | else { 847 | if ( 848 | ((this.cssVarRetries += 1), this.cssVarRetries > this.maxCssVarRetries) 849 | ) { 850 | return ( 851 | (this.sectionColors = [16711680, 16711680, 16711935, 65280, 255]), 852 | void this.init() 853 | ); 854 | } 855 | requestAnimationFrame(() => this.waitForCssVars()); 856 | } 857 | } 858 | /* 859 | * Initializes the four section colors by retrieving them from css variables. 860 | */ 861 | initGradientColors() { 862 | this.sectionColors = [ 863 | "--gradient-color-1", 864 | "--gradient-color-2", 865 | "--gradient-color-3", 866 | "--gradient-color-4", 867 | ] 868 | .map((cssPropertyName) => { 869 | let hex = this.computedCanvasStyle 870 | .getPropertyValue(cssPropertyName) 871 | .trim(); 872 | //Check if shorthand hex value was used and double the length so the conversion in normalizeColor will work. 873 | if (4 === hex.length) { 874 | const hexTemp = hex 875 | .substr(1) 876 | .split("") 877 | .map((hexTemp) => hexTemp + hexTemp) 878 | .join(""); 879 | hex = `#${hexTemp}`; 880 | } 881 | return hex && `0x${hex.substr(1)}`; 882 | }) 883 | .filter(Boolean) 884 | .map(normalizeColor); 885 | } 886 | } 887 | 888 | export { Gradient }; 889 | 890 | /* 891 | *Finally initializing the Gradient class, assigning a canvas to it and calling Gradient.connect() which initializes everything, 892 | * Use Gradient.pause() and Gradient.play() for controls. 893 | * 894 | * Here are some default property values you can change anytime: 895 | * Amplitude: Gradient.amp = 0 896 | * Colors: Gradient.sectionColors (if you change colors, use normalizeColor(#hexValue)) before you assign it. 897 | * 898 | * 899 | * Useful functions 900 | * Gradient.toggleColor(index) 901 | * Gradient.updateFrequency(freq) 902 | */ 903 | --------------------------------------------------------------------------------