+
{Math.round(zoom * 100)}%
diff --git a/src/components/mdx.tsx b/src/components/mdx.tsx
index 57950b6..70f8fc7 100644
--- a/src/components/mdx.tsx
+++ b/src/components/mdx.tsx
@@ -1,10 +1,4 @@
-import {
- useEffect,
- useRef,
- useState,
- type ComponentPropsWithoutRef,
- type ReactNode,
-} from "react";
+import { type ReactNode } from "react";
import type { MDXComponents } from "mdx/types";
import { Icon } from "./icon";
@@ -12,14 +6,24 @@ function headingAnchor(id: string | undefined, children: ReactNode) {
return
{children} ;
}
-export function PostImage({ src = "", alt = "" }: { src?: string; alt?: string }) {
+export function PostImage({
+ src = "",
+ alt = "",
+ width = 640,
+ height = 360,
+}: {
+ src?: string;
+ alt?: string;
+ width?: number;
+ height?: number;
+}) {
return (
@@ -126,108 +130,3 @@ export function extractHeadings(source: string): Heading[] {
return [{ depth: match[1].length, slug: slugify(text), text }];
});
}
-
-export function TableOfContents({ headings }: { headings: Heading[] }) {
- const [activeSlug, setActiveSlug] = useState(headings[0]?.slug ?? "");
- const selectedHeading = useRef(null);
-
- useEffect(() => {
- let frame = 0;
-
- const updateActiveHeading = () => {
- window.cancelAnimationFrame(frame);
- frame = window.requestAnimationFrame(() => {
- if (selectedHeading.current) {
- setActiveSlug(selectedHeading.current);
- return;
- }
-
- const readingLine = window.scrollY + Math.min(window.innerHeight * 0.28, 220);
- let current = headings[0]?.slug ?? "";
-
- for (const heading of headings) {
- const element = document.getElementById(heading.slug);
- if (!element) continue;
- const top = element.getBoundingClientRect().top + window.scrollY;
- if (top > readingLine) break;
- current = heading.slug;
- }
-
- setActiveSlug(current);
- });
- };
-
- const clearSelectedHeading = () => {
- selectedHeading.current = null;
- };
- const clearSelectedHeadingWithKey = (event: KeyboardEvent) => {
- if (["ArrowDown", "ArrowUp", "End", "Home", "PageDown", "PageUp", " "].includes(event.key)) {
- clearSelectedHeading();
- }
- };
-
- updateActiveHeading();
- window.addEventListener("scroll", updateActiveHeading, { passive: true });
- window.addEventListener("resize", updateActiveHeading);
- window.addEventListener("wheel", clearSelectedHeading, { passive: true });
- window.addEventListener("touchmove", clearSelectedHeading, { passive: true });
- window.addEventListener("keydown", clearSelectedHeadingWithKey);
- return () => {
- window.cancelAnimationFrame(frame);
- window.removeEventListener("scroll", updateActiveHeading);
- window.removeEventListener("resize", updateActiveHeading);
- window.removeEventListener("wheel", clearSelectedHeading);
- window.removeEventListener("touchmove", clearSelectedHeading);
- window.removeEventListener("keydown", clearSelectedHeadingWithKey);
- };
- }, [headings]);
-
- if (headings.length === 0) return null;
-
- return (
-
-
-
-
- On this page
-
- {headings.length} {headings.length === 1 ? "section" : "sections"}
-
-
-
-
- {headings.map((heading) => {
- const active = heading.slug === activeSlug;
- return (
-
- {
- event.preventDefault();
- selectedHeading.current = heading.slug;
- setActiveSlug(heading.slug);
- event.currentTarget.closest("details")?.removeAttribute("open");
- document.getElementById(heading.slug)?.scrollIntoView({ behavior: "smooth" });
- }}
- >
-
- {heading.text}
-
-
- );
- })}
-
-
-
- );
-}
diff --git a/src/components/navigation-links.ts b/src/components/navigation-links.ts
deleted file mode 100644
index c293822..0000000
--- a/src/components/navigation-links.ts
+++ /dev/null
@@ -1,5 +0,0 @@
-import { navigation } from "$content/index";
-
-export const links = navigation;
-
-export const mobileLinks = links.slice(0, 3);
diff --git a/src/components/navigation.tsx b/src/components/navigation.tsx
deleted file mode 100644
index aa3c154..0000000
--- a/src/components/navigation.tsx
+++ /dev/null
@@ -1,161 +0,0 @@
-import { Link, useLocation, useRouterState } from "@tanstack/react-router";
-import { useState } from "react";
-import { Icon } from "./icon";
-import { Logo } from "./site";
-import { Settings } from "./settings";
-import { StatusCard } from "./status-card";
-import { links, mobileLinks } from "./navigation-links";
-import { site, socialLinks } from "$content/index";
-
-function isCurrent(pathname: string, url: string) {
- if (url === "/") return pathname === "/";
- return pathname === url || pathname.startsWith(`${url}/`);
-}
-
-function Footer() {
- return (
-
- );
-}
-
-export function DesktopNavigation() {
- const pathname = useLocation({ select: (location) => location.pathname });
- const pending = useRouterState({ select: (state) => state.status === "pending" });
- return (
-
- );
-}
-
-export function MobileNavigation() {
- const pathname = useLocation({ select: (location) => location.pathname });
- const [open, setOpen] = useState(false);
- return (
-
-
- {mobileLinks.map((link) => (
-
-
-
- {link.name}
-
-
- ))}
-
- setOpen(true)}
- >
-
- Menu
-
-
-
- {open && (
-
-
setOpen(false)}
- />
-
-
-
-
-
- {links.map((link) => (
-
- setOpen(false)}
- className="group flex flex-col items-center justify-center gap-2 rounded-md bg-site-button p-4 text-base outline-none transition-[background-color,box-shadow] duration-150 hover:bg-site-button-hover focus-visible:ring-2 focus-visible:ring-site-ring aria-[current=page]:border-2 aria-[current=page]:bg-site-button-active aria-[current=page]:font-bold"
- aria-current={isCurrent(pathname, link.url) ? "page" : undefined}
- >
-
- {link.name}
-
-
- ))}
-
-
-
-
-
- )}
-
- );
-}
diff --git a/src/components/post-like.tsx b/src/components/post-like.tsx
deleted file mode 100644
index 63f29e6..0000000
--- a/src/components/post-like.tsx
+++ /dev/null
@@ -1,240 +0,0 @@
-import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
-import { useId, useRef, useState } from "react";
-import { addPostLikes, getPostLikes } from "@/features/post-likes/post-likes";
-import { Icon } from "./icon";
-
-const maxLikes = 5;
-const batchDelay = 500;
-const emptyLikes = { taps: 0, total: 0 };
-const limitMessage = "Try again in 1 minute.";
-const heartPath =
- "M12 21.35 10.55 20.03C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.08A5.96 5.96 0 0 1 16.5 3C19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35Z";
-
-export function PostLikeSummary({ state }: { state: ReturnType }) {
- const { likes, sendLike, done, pending, saving, limited } = state;
- const busy = pending || saving;
-
- return (
-
- 0 ? "like-heart-hit" : ""}`}
- />
-
- {pending ? "…" : likes.total}
-
- {limited && (
- Try again in 1 minute
- )}
-
- );
-}
-
-export function PostLike({ state }: { state: ReturnType }) {
- const { likes, sendLike, done, pending, saving, limited } = state;
- const busy = pending || saving;
-
- return (
-
-
-
- {done && }
- {likes.taps > 0 && (
-
- +{likes.taps}
-
- )}
- {limited && (
-
- Try again in 1 minute
-
- )}
-
-
- {pending ? "…" : likes.total}
-
-
- {pending
- ? "Loading likes"
- : limited
- ? limitMessage
- : `${likes.taps} of ${maxLikes} likes sent. ${likes.total} total likes`}
-
-
-
-
- );
-}
-
-export function usePostLikes(postKey: string) {
- const queryClient = useQueryClient();
- const queryKey = ["post-likes", postKey] as const;
- const [taps, setTaps] = useState(0);
- const [limited, setLimited] = useState(false);
- const queued = useRef(0);
- const timer = useRef>(undefined);
- const limitTimer = useRef>(undefined);
- const { data: total, isPending: pending } = useQuery({
- queryKey,
- queryFn: () => getPostLikes({ data: { postKey } }),
- staleTime: 60_000,
- });
- function rollBack(amount: number) {
- setTaps((current) => Math.max(0, current - amount));
- queryClient.setQueryData(queryKey, (current = amount) => Math.max(0, current - amount));
- }
- const { mutate, isPending: saving } = useMutation({
- mutationFn: (amount: number) => addPostLikes({ data: { postKey, amount } }),
- onSuccess: async (total, amount) => {
- if (total === null) {
- rollBack(amount);
- setLimited(true);
- if (limitTimer.current) clearTimeout(limitTimer.current);
- limitTimer.current = setTimeout(() => setLimited(false), 60_000);
- await queryClient.invalidateQueries({ queryKey });
- return;
- }
-
- queryClient.setQueryData(queryKey, total);
- await queryClient.invalidateQueries({ queryKey });
- },
- onError: (_error, amount) => rollBack(amount),
- });
- function flush() {
- if (queued.current === 0) return;
- const amount = queued.current;
- queued.current = 0;
- if (timer.current) clearTimeout(timer.current);
- mutate(amount);
- }
- function sendLike() {
- if (pending || saving || limited || taps === maxLikes) return;
-
- const nextTaps = taps + 1;
- setTaps(nextTaps);
- queryClient.setQueryData(queryKey, (current = 0) => current + 1);
- queued.current += 1;
-
- if (timer.current) clearTimeout(timer.current);
- if (nextTaps === maxLikes) flush();
- else timer.current = setTimeout(flush, batchDelay);
- }
- const likes = total === undefined ? emptyLikes : { taps, total };
- const done = taps === maxLikes;
-
- return { likes, sendLike, done, pending, saving, limited };
-}
-
-function HeartFill({ likes }: { likes: number }) {
- const clipId = useId();
- const baseGradientId = useId();
- const fillGradientId = useId();
- const fill = likes / maxLikes;
-
- return (
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- );
-}
-
-function Shine() {
- return (
-
-
-
-
- );
-}
diff --git a/src/components/post-page.tsx b/src/components/post-page.tsx
deleted file mode 100644
index 0fc11da..0000000
--- a/src/components/post-page.tsx
+++ /dev/null
@@ -1,110 +0,0 @@
-import type { Post } from "$content/index";
-import { getCategory } from "$content/index";
-import { formatDate } from "$utils/date-formatter";
-import { getReadingTime } from "$utils/reading-time";
-import { BackToTop } from "./back-to-top";
-import { Icon } from "./icon";
-import { extractHeadings, mdxComponents, TableOfContents } from "./mdx";
-import { PostLike, PostLikeSummary, usePostLikes } from "./post-like";
-
-function transitionName(value: string) {
- return value.toLowerCase().replace(/[^a-z0-9]+/g, "-");
-}
-
-export function PostPage({ post }: { post: Post }) {
- const category = getCategory(post.category);
- const Content = post.Content;
- const postKey = `${post.category}:${post.slug}`;
- const likes = usePostLikes(postKey);
- return (
- <>
-
-
-
-
-
-
-
-
-
- >
- );
-}
-
-function ReviewDetails({ review }: { review: NonNullable }) {
- return (
-
-
-
-
Review score
-
- {Array.from({ length: 5 }, (_, index) => {
- const value = index + 1;
- const icon =
- review.rating >= value
- ? "mdi:star"
- : review.rating >= value - 0.5
- ? "mdi:star-half-full"
- : "mdi:star-outline";
- return ;
- })}
-
-
-
- {review.rating}
- / 5
-
-
-
-
-
Status
- {review.status}
-
-
-
Platform
- {review.platform}
-
-
-
Playtime
-
- {review.playtime_hours}h
-
-
-
-
- );
-}
diff --git a/src/components/post-search-list.tsx b/src/components/post-search-list.tsx
deleted file mode 100644
index 6be237d..0000000
--- a/src/components/post-search-list.tsx
+++ /dev/null
@@ -1,151 +0,0 @@
-import Fuse from "fuse.js";
-import { Search, X } from "lucide-react";
-import { useMemo, useRef, useState, type ReactNode } from "react";
-import { flushSync } from "react-dom";
-import type { Post } from "$content/index";
-
-type PostSearchListProps = {
- posts: Post[];
- noun: string;
- renderPost: (post: Post) => ReactNode;
-};
-
-export function PostSearchList({ posts, noun, renderPost }: PostSearchListProps) {
- const [query, setQuery] = useState("");
- const transitionId = useRef(0);
- const searchQuery = query.trim();
- const plural = `${noun}s`;
- const label = `Search ${plural}…`;
- const fuse = useMemo(
- () =>
- new Fuse(posts, {
- keys: [
- { name: "title", weight: 0.45 },
- { name: "description", weight: 0.25 },
- { name: "tags", weight: 0.15 },
- { name: "category", weight: 0.1 },
- { name: "body", weight: 0.05 },
- ],
- threshold: 0.45,
- ignoreLocation: true,
- }),
- [posts],
- );
- const results = useMemo(
- () => (searchQuery ? fuse.search(searchQuery).map(({ item }) => item) : posts),
- [fuse, posts, searchQuery],
- );
- const groups = useMemo(
- () =>
- [...posts]
- .sort((a, b) => a.published_time.getTime() - b.published_time.getTime())
- .reduce>((result, post) => {
- const year = String(post.published_time.getFullYear());
- (result[year] ??= []).push(post);
- return result;
- }, {}),
- [posts],
- );
- const hasQuery = searchQuery.length > 0;
- const resultNoun = results.length === 1 ? noun : plural;
-
- function updateQuery(nextQuery: string) {
- if (!document.startViewTransition) {
- setQuery(nextQuery);
- return;
- }
-
- const id = ++transitionId.current;
- document.documentElement.dataset.searchTransition = "";
- try {
- const transition = document.startViewTransition(() => {
- flushSync(() => setQuery(nextQuery));
- });
- const clearTransition = () => {
- if (id === transitionId.current) {
- delete document.documentElement.dataset.searchTransition;
- }
- };
- void transition.finished.then(clearTransition, clearTransition);
- } catch {
- delete document.documentElement.dataset.searchTransition;
- setQuery(nextQuery);
- }
- }
-
- return (
- <>
-
-
-
-
- {label}
-
- updateQuery(event.currentTarget.value)}
- onKeyDown={(event) => {
- if (event.key === "Escape") updateQuery("");
- }}
- placeholder={label}
- autoComplete="off"
- className="h-11 w-full rounded-md border border-site-border bg-site-card pl-10 pr-10 text-sm text-site-foreground outline-none transition-[border-color,box-shadow] placeholder:text-site-muted focus:border-site-border-hover focus:ring-2 focus:ring-site-ring [&::-webkit-search-cancel-button]:appearance-none"
- />
- {query && (
- updateQuery("")}
- aria-label="Clear search"
- className="absolute right-2 top-1/2 grid h-7 w-7 -translate-y-1/2 place-items-center rounded-md text-site-muted outline-none transition-colors hover:bg-site-button-hover hover:text-site-foreground focus-visible:ring-2 focus-visible:ring-site-ring"
- >
-
-
- )}
-
-
- {hasQuery
- ? `${results.length} ${resultNoun} found for “${searchQuery}”`
- : "Search by title, description, tag, category, or content."}
-
-
-
- {hasQuery ? (
- results.length > 0 ? (
-
- Search results
-
- {results.map((post) => (
- {renderPost(post)}
- ))}
-
-
- ) : (
-
-
No matches found
-
- Try a shorter term or check the spelling.
-
-
- )
- ) : (
- Object.keys(groups)
- .reverse()
- .map((year) => (
-
- {year}
-
- {groups[year]?.map((post) => (
- {renderPost(post)}
- ))}
-
-
- ))
- )}
- >
- );
-}
diff --git a/src/components/settings.tsx b/src/components/settings.tsx
deleted file mode 100644
index 2b49f15..0000000
--- a/src/components/settings.tsx
+++ /dev/null
@@ -1,173 +0,0 @@
-import { useEffect, useId, useRef, useState } from "react";
-import { Eye, EyeOff, Palette, Radius, Square, Volume2, VolumeX } from "lucide-react";
-import { themeNames, useSettings, type ThemeName } from "@/providers/settings-provider";
-import { playAudio } from "@/utils/play-audio";
-import { Icon } from "./icon";
-
-const themeLabels: Record = {
- dark: "Circuit Dark",
- light: "Paper Light",
- dusk: "Violet Dusk",
- forest: "Deep Forest",
- ember: "Warm Ember",
- ocean: "Clear Ocean",
- rose: "Soft Rose",
- mint: "Alpine Mint",
- lavender: "Lavender Sky",
- peach: "Peach Horizon",
- meadow: "Sunny Meadow",
- sand: "Golden Sand",
-};
-
-const controlClass =
- "grid h-10 w-10 place-items-center rounded-md bg-site-button text-site-icon outline-none transition-[background-color,color,transform,box-shadow] duration-150 ease-springy hover:-translate-y-0.5 hover:bg-site-button-hover hover:text-site-icon-hover active:translate-y-0 focus-visible:ring-2 focus-visible:ring-site-ring focus-visible:ring-offset-2 focus-visible:ring-offset-site-page md:h-8 md:w-8 md:bg-site-card";
-
-export function Settings() {
- const settings = useSettings();
- const [themesOpen, setThemesOpen] = useState(false);
- const themePickerRef = useRef(null);
- const themePickerId = useId();
-
- useEffect(() => {
- if (!themesOpen) return;
-
- const closeOnOutsideClick = (event: PointerEvent) => {
- if (!themePickerRef.current?.contains(event.target as Node)) setThemesOpen(false);
- };
- const closeOnEscape = (event: KeyboardEvent) => {
- if (event.key === "Escape") setThemesOpen(false);
- };
-
- window.addEventListener("pointerdown", closeOnOutsideClick);
- window.addEventListener("keydown", closeOnEscape);
- return () => {
- window.removeEventListener("pointerdown", closeOnOutsideClick);
- window.removeEventListener("keydown", closeOnEscape);
- };
- }, [themesOpen]);
-
- return (
-
-
{
- settings.toggleSound();
- playAudio("/audio/sound_toggle_on.mp3");
- }}
- aria-label={settings.sound ? "Mute interface sounds" : "Enable interface sounds"}
- aria-pressed={settings.sound}
- title={settings.sound ? "Mute sounds" : "Enable sounds"}
- >
- {settings.sound ? (
-
- ) : (
-
- )}
-
-
-
{
- settings.toggleRounded();
- playAudio("/audio/sound_toggle_on.mp3", settings.sound);
- }}
- aria-label={settings.rounded ? "Use square corners" : "Use rounded corners"}
- aria-pressed={settings.rounded}
- title={settings.rounded ? "Use square corners" : "Use rounded corners"}
- >
- {settings.rounded ? (
-
- ) : (
-
- )}
-
-
-
{
- settings.toggleSpoilers();
- playAudio("/audio/sound_toggle_on.mp3", settings.sound);
- }}
- aria-label="Spoiler protection"
- aria-pressed={settings.hideSpoilers}
- title={settings.hideSpoilers ? "Show spoilers" : "Hide spoilers"}
- >
- {settings.hideSpoilers ? (
-
- ) : (
-
- )}
-
-
-
{
- setThemesOpen((open) => !open);
- playAudio("/audio/fly_in_out.mp3", settings.sound);
- }}
- aria-label="Choose color theme"
- aria-expanded={themesOpen}
- aria-controls={themePickerId}
- title="Choose color theme"
- >
-
-
-
- {themesOpen && (
-
-
-
- Choose a theme
-
-
- Your preference is saved on this device.
-
-
-
- {themeNames.map((theme) => {
- const selected = settings.theme === theme;
- return (
- {
- settings.changeTheme(theme);
- setThemesOpen(false);
- playAudio("/audio/sound_toggle_on.mp3", settings.sound);
- }}
- >
-
- {themeLabels[theme]}
- {selected && (
-
- )}
-
- );
- })}
-
-
- )}
-
- );
-}
diff --git a/src/components/site.tsx b/src/components/site.tsx
index cd0b855..14c3ce6 100644
--- a/src/components/site.tsx
+++ b/src/components/site.tsx
@@ -1,10 +1,12 @@
-import { Link } from "@tanstack/react-router";
-import type { ReactNode } from "react";
import spriteSheet from "$assets/images/ashfid_pixel_sheet.png";
-import { site } from "$content/index";
-import { highlightedText } from "$utils/string-formatter";
-export function Sprite({ pixelScale = 3.5 }: { pixelScale?: number | string }) {
+export function Sprite({
+ pixelScale = 3.5,
+ playing = false,
+}: {
+ pixelScale?: number | string;
+ playing?: boolean;
+}) {
return (
);
}
-
-export function Logo() {
- return (
-
-
-
-
-
{site.name}
-
- );
-}
-
-export function HeroSection({
- title = "Title Here",
- children,
-}: {
- title?: string;
- children: ReactNode;
-}) {
- return (
-
- );
-}
-
-export function HomeHeroSection() {
- return (
-
-
- Welcome to my digital garden! 🌱
-
- I’m a Software Engineer and Programmer, and I’ve created this cozy corner to share my
- thoughts or tutorials, break down mechanics, and much more.{" "}
-
- Let's ditch the over-engineering and have some fun!
-
-
-
-
-
- );
-}
-
-export function SectionHeading({ title }: { title: string }) {
- const { firstWord, restWords } = highlightedText(title);
- return (
-
- {firstWord}
- {restWords}
-
- );
-}
diff --git a/src/components/status-card.tsx b/src/components/status-card.tsx
deleted file mode 100644
index be7ffd5..0000000
--- a/src/components/status-card.tsx
+++ /dev/null
@@ -1,78 +0,0 @@
-import { useEffect, useState } from "react";
-import { useLanyard } from "use-lanyard";
-import { Icon } from "./icon";
-import { site } from "$content/index";
-
-export function StatusCard() {
- const data = useLanyard(site.discordId as `${bigint}`);
- const [, tick] = useState(0);
- useEffect(() => {
- const timer = window.setInterval(() => tick((value) => value + 1), 1000);
- return () => window.clearInterval(timer);
- }, []);
-
- const activity = data?.activities?.[0];
- const spotify = data?.spotify;
- const elapsed = spotify ? Math.max(0, Date.now() - spotify.timestamps.start) : 0;
- const duration = spotify ? spotify.timestamps.end - spotify.timestamps.start : 1;
- const progress = Math.min(100, (elapsed / duration) * 100);
- const minutes = Math.floor(elapsed / 60000);
- const seconds = String(Math.floor((elapsed / 1000) % 60)).padStart(2, "0");
- const statusColor =
- data?.discord_status === "online"
- ? "bg-green-500"
- : data?.discord_status === "idle"
- ? "bg-yellow-500"
- : "bg-red-500";
- const title = spotify ? "Listening to Spotify" : activity ? "Playing" : "Thinking...";
-
- return (
-
-
- {spotify && }
- {!spotify && activity && (
-
- )}
- {!activity && !spotify && (
-
- )}
- {title}
-
- {spotify && (
-
- )}
- {!spotify && activity && (
-
- {activity.name}
- {activity.state && (
-
- Currently:
- {activity.state}
-
- )}
-
- )}
-
- );
-}
diff --git a/src/components/terminal/data.ts b/src/components/terminal/data.ts
new file mode 100644
index 0000000..48ae1a2
--- /dev/null
+++ b/src/components/terminal/data.ts
@@ -0,0 +1,22 @@
+import { blogPosts, skills, snippets, terminal, type Post } from "$content/index";
+
+const byNewest = (a: Post, b: Post) => b.published_time.getTime() - a.published_time.getTime();
+
+export const latestPosts = [...blogPosts].sort(byNewest);
+export const latestSnippets = [...snippets].sort(byNewest);
+export const sortedSkills = [...skills].sort((a, b) => b.entries.length - a.entries.length);
+
+export const focusRing =
+ "outline-none focus-visible:ring-2 focus-visible:ring-site-ring focus-visible:ring-offset-2 focus-visible:ring-offset-site-page";
+
+export function isoDate(date: Date) {
+ return date.toISOString().slice(0, 10);
+}
+
+/** `user@host`, from the "terminal" block in site.config.json. */
+export const shellUser = `${terminal.user}@${terminal.host}`;
+
+/** zsh's default prompt (`%n@%m %1~ %# `) shows only the current folder: `~`, `blog`, `config`. */
+export function shellDir(path: string) {
+ return path === "~" ? "~" : (path.split("/").pop() ?? path);
+}
diff --git a/src/components/terminal/layout.ts b/src/components/terminal/layout.ts
new file mode 100644
index 0000000..3cc38e7
--- /dev/null
+++ b/src/components/terminal/layout.ts
@@ -0,0 +1,13 @@
+export const tabs = [
+ { id: "home", label: "home", path: "~", href: "/" },
+ { id: "blog", label: "blog", path: "~/blog", href: "/blog" },
+ { id: "snippets", label: "snippets", path: "~/snippets", href: "/snippets" },
+ { id: "tools", label: "tools", path: "~/tools", href: "/tools" },
+ { id: "config", label: "config", path: "~/config", href: "/config" },
+] as const;
+export type TabId = (typeof tabs)[number]["id"];
+export type PanelTab = Exclude
;
+
+export function tabPath(id: TabId) {
+ return tabs.find((tab) => tab.id === id)!.href;
+}
diff --git a/src/components/terminal/panels.tsx b/src/components/terminal/panels.tsx
new file mode 100644
index 0000000..52fdac8
--- /dev/null
+++ b/src/components/terminal/panels.tsx
@@ -0,0 +1,323 @@
+import { Link, useNavigate } from "@tanstack/react-router";
+import { type CSSProperties, type ReactNode } from "react";
+import { categories, tools } from "$content/index";
+import { themeNames, useSettings, type ThemeName } from "@/providers/settings-provider";
+import { playAudio } from "@/utils/play-audio";
+import { Icon } from "../icon";
+import { themeLabels } from "@/providers/settings-provider";
+import { Sprite } from "../site";
+import { focusRing, latestPosts, latestSnippets, sortedSkills } from "./data";
+import { tabPath, type PanelTab, type TabId } from "./layout";
+import {
+ CHAR_MS,
+ MoreLink,
+ PostRows,
+ Prompt,
+ Script,
+ ToolRows,
+ stackLabels,
+ type Step,
+} from "./ui";
+
+function homeSteps(openTab: (tab: PanelTab) => void): Step[] {
+ return [
+ {
+ command: "whoami",
+ content: (
+
+
+
+
+
+
+ Ashfid
+
+ — software engineer · game dev
+
+
+
+ Welcome to my digital garden 🌱 I write tutorials, game reviews, and break down how
+ game mechanics work.
+ No over-engineering, just fun.
+
+
+
+ ),
+ },
+ {
+ command: "ls -lt ~/blog | head -3",
+ label: "Latest posts",
+ content: (
+ <>
+
+ {latestPosts.length > 3 && (
+ openTab("blog")}>→ all {latestPosts.length} posts
+ )}
+ >
+ ),
+ },
+ {
+ command: "ls -lt ~/snippets | head -3",
+ label: "Latest snippets",
+ content: (
+ <>
+
+ {latestSnippets.length > 3 && (
+ openTab("snippets")}>
+ → all {latestSnippets.length} snippets
+
+ )}
+ >
+ ),
+ },
+ {
+ command: "cat stack.txt",
+ label: "Stack",
+ content: (
+
+ {sortedSkills.map((skill) => (
+
+
+ {stackLabels[skill.id] ?? skill.id}
+ {skill.title}
+
+
+ {skill.entries.map((entry) => (
+
+
+ {entry.name}
+
+ ))}
+
+
+ ))}
+
+ ),
+ },
+ ];
+}
+
+export function ToolsPanel({ category }: { category?: string }) {
+ const selected = category ?? "all";
+ const used = categories.filter((item) => tools.some((tool) => tool.categories.includes(item.id)));
+ const shown = category ? tools.filter((tool) => tool.categories.includes(category)) : tools;
+ const command = category ? `ls ${category}/` : "ls";
+ return (
+
+
+ Tools I use
+
+
+
+ {command}
+
+
+
+
+
# apps and services I use, or have used, every day
+
+ {["all", ...used.map((category) => category.id)].map((id) => (
+
+ {id}
+
+ ))}
+
+
+
+ {shown.length} tools
+
+
+
+ );
+}
+
+function Choice({
+ name,
+ value,
+ options,
+ onChange,
+ render = (option) => option,
+}: {
+ name: string;
+ value: T;
+ options: readonly T[];
+ onChange: (value: T) => void;
+ render?: (option: T) => ReactNode;
+}) {
+ return (
+
+
+ {name} =
+
+
+ {options.map((option) => (
+
+ onChange(option)}
+ className="peer sr-only"
+ />
+
+ {render(option)}
+
+
+ ))}
+
+
+ );
+}
+
+function ConfigPanel() {
+ const settings = useSettings();
+ const click = (enabled = settings.sound) => playAudio("/audio/sound_toggle_on.mp3", enabled);
+
+ return (
+