diff --git a/.github/workflows/pr-checks.yml b/.github/workflows/pr-checks.yml new file mode 100644 index 0000000..3436360 --- /dev/null +++ b/.github/workflows/pr-checks.yml @@ -0,0 +1,33 @@ +name: PR checks + +on: + pull_request: + +permissions: + contents: read + +concurrency: + group: pr-checks-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + checks: + name: Vite+ checks + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Check out the PR + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Set up Vite+ + uses: voidzero-dev/setup-vp@cab1ab9eaff52d4857875f4f2c016e36af8f2c76 # v1.22.0 + with: + node-version: "24" + cache: true + run-install: false + + - name: Install dependencies + run: vp install --frozen-lockfile + + - name: Check formatting, lint and types + run: vp check diff --git a/README.md b/README.md index c49184f..fb557e3 100644 --- a/README.md +++ b/README.md @@ -1,109 +1,33 @@ -# ashfid.dev - digital garden +# ashfid.dev / ashfid.com -Personal website built with TanStack Start, React, MDX, Tailwind CSS, and shadcn-style UI primitives. +I'm Ashfid, a software engineer and game developer. This is my digital garden, where I share what I'm learning, write about games and their mechanics, and keep useful bits of code so I can find them again. -Site metadata, navigation, categories, tools, skill groups, and icon mappings are maintained in -`site.config.json`. Long-form posts remain in `src/content/posts` as MDX. +I wanted it to feel cozy and a little playful, so it lives inside a small terminal window with a pixel version of me. Pick a theme, browse the posts, or try a command in the prompt. -```bash -vp install -vp run dev -``` - -## Add a blog post - -Create `src/content/posts/blog/my-post.mdx`. The filename becomes the URL, so this -example is published at `/blog/my-post`. - -```mdx ---- -title: My post title -description: A short summary used for search engines and link previews. -published_time: 2026-08-08 -category: web -tags: - - website - - development ---- +Visit the garden at [ashfid.dev](https://ashfid.dev) or [ashfid.com](https://ashfid.com). -Write the post here using Markdown. +Built with TanStack Start, React, MDX, and Tailwind CSS. Pages are generated ahead of time and hosted on Cloudflare. -## A section heading +## Run it locally -You can also import and use React components because post files use MDX. -``` - -`title`, `description`, `published_time`, `category`, and `tags` are required. -The category must match an ID in `site.config.json`. You can optionally add -`last_modified_time: YYYY-MM-DD`. Run `vp run check` and `vp run build` before -publishing to catch invalid frontmatter or MDX. - -Every page includes Open Graph and X/Twitter metadata. The build generates 1200×630 PNG share -cards with Satori into `public/images/og` before TanStack Start prerenders the site. Posts get an -individual card automatically. To feature an image already in the repository, add these optional -frontmatter fields (paths are relative to the repository root): - -```yaml -social_image: src/assets/images/posts/my-post/cover.jpg -social_image_alt: A concise description of the cover image -``` +Fork or clone the repo, then run these from the project folder with Vite+ installed: -Run `vp run generate:og` to preview regenerated cards without building the entire site. Generated -PNGs are ignored by Git because both development and production build commands recreate them. - -Image selection is predictable: - -- General site pages use `public/images/android-chrome-512x512.png`. -- Posts with `social_image` use that image. -- Posts without `social_image`, including the current snippets, use the same site avatar fallback. -- `hello-world` currently uses `src/assets/images/posts/desktop_view.jpeg`. -- `final-fantasy-7-remake-review` currently uses - `src/assets/images/posts/final-fantasy-7-remake/final-fantasy-7-remake-01.jpg`. - -Posts in the `game-review` category also require review details. These appear as a prominent score -and facts panel below the post title. - -```yaml -review: - rating: 4 - platform: PC - playtime_hours: 23.4 - status: Finished +```sh +vp install +vp run db:migrate:local +vp run dev ``` -Useful commands: - -- `vp run check`: type-check the project -- `pnpm run build`: reject legacy Pages builds, build every route, and verify the Worker bundle -- `pnpm run deploy`: deploy the verified bundle with Wrangler -- `vp preview`: preview the production build locally - -## Cloudflare Workers +Open [localhost:3000](http://localhost:3000). The database step sets up local storage for post likes; you only need it on the first run or when the database changes. -This is a TanStack Start Worker with prerendered static assets, D1, and a rate-limit binding. It must -be deployed as a Worker; a Pages static upload cannot run the server functions or rate limiter. +## Make it yours -Configure Workers Builds with: +Start with [site.config.json](site.config.json) for your name, links, tools, and skills. Posts and snippets live in [src/content/posts](src/content/posts). Copy an existing MDX file to get started; its filename becomes the URL. -- Production branch: `main` -- Build command: `pnpm run build` -- Deploy command: `pnpm run deploy` -- Root directory: `/` +Swap out my images and branding, and change whatever you like. If you're deploying your own copy, update [wrangler.jsonc](wrangler.jsonc) with your Cloudflare settings too. `vp run build` creates the production build. -The Cloudflare Vite plugin generates `dist/server/wrangler.json`, which points Wrangler at the -Worker entry in `dist/server` and the assets in `dist/client`. Do not upload either directory to -Pages manually. The build fails if it detects the Pages environment, a missing Worker bundle, a -stale root `dist/index.html`, an old `/_astro/` reference, or a missing generated asset. +## Contributing -For a manual release, run: - -```bash -pnpm run check -pnpm run build -pnpm run deploy -``` +Found a bug or have an idea? Open an issue or send a pull request. For bigger changes, start an issue so we can talk it through first. -Test the generated `workers.dev` URL before moving the production custom domains. Once the Worker is -verified, remove `ashfid.dev` and `www.ashfid.dev` from the legacy Pages project and attach them to -the Worker. The legacy Pages Git integration should then be disabled or deleted so a push cannot -publish a second deployment target. +You're welcome to fork this and build your own garden. It's available under the [MIT license](LICENSE). diff --git a/components.json b/components.json deleted file mode 100644 index e29a4d3..0000000 --- a/components.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "$schema": "https://ui.shadcn.com/schema.json", - "style": "new-york", - "rsc": false, - "tsx": true, - "tailwind": { "css": "src/styles/globals.css", "baseColor": "neutral", "cssVariables": true }, - "aliases": { - "components": "@/components", - "utils": "@/lib/utils", - "ui": "@/components/ui", - "lib": "@/lib", - "hooks": "@/hooks" - }, - "iconLibrary": "lucide" -} diff --git a/docs/research/2026-08-17-vite-plus-tanstack-start.md b/docs/research/2026-08-17-vite-plus-tanstack-start.md deleted file mode 100644 index cd0ae07..0000000 --- a/docs/research/2026-08-17-vite-plus-tanstack-start.md +++ /dev/null @@ -1,32 +0,0 @@ -# Vite+ and TanStack Start migration - -Research date: 17 August 2026 - -## Conclusion - -The site uses **TanStack Start**, not “TanStack Shard.” The official product styling for the toolchain is **Vite+**. - -A supportable explanation for the migration is that the two tools have distinct roles: - -- TanStack describes Start as a full-stack React framework powered by TanStack Router. It supplies the application layer, including routing, rendering, server features, and client/server builds. It officially supports Vite and Rsbuild as build tools. ([TanStack Start overview](https://tanstack.com/start/latest/docs/framework/react/overview)) -- Vite+ describes itself as a unified toolchain that provides one entry point for the runtime, dependency management, development server, checks, testing, and production builds. Its documented workflow includes `vp dev`, `vp check`, `vp test`, and `vp build`. ([Why Vite+?](https://viteplus.dev/guide/why), [Vite+ getting started](https://viteplus.dev/guide/)) - -The compatibility of this exact pairing is an inference from the official documentation: TanStack Start supports Vite, while Vite+ says it integrates with the existing Vite ecosystem and frameworks built on it. Neither official site currently documents a separate TanStack Start–Vite+ integration. ([TanStack Start overview](https://tanstack.com/start/latest/docs/framework/react/overview), [Why Vite+?](https://viteplus.dev/guide/why)) - -## Repository evidence - -- [`package.json`](../../package.json) depends on `@tanstack/react-start` and `vite-plus`; its development, build, preview, and formatting scripts use the `vp` command. -- [`vite.config.ts`](../../vite.config.ts) imports `tanstackStart` from `@tanstack/react-start/plugin/vite`, imports its configuration helpers from `vite-plus`, and enables prerendering with link crawling. -- [`pnpm-workspace.yaml`](../../pnpm-workspace.yaml) aliases `vite` to `@voidzero-dev/vite-plus-core`, matching the migration approach in the official [Vite+ migration guide](https://viteplus.dev/guide/migrate). - -This evidence supports saying the site now uses TanStack Start with Vite+, and that the change consolidates its development tooling while putting the application on TanStack Start's Router-based React framework. It does **not** establish personal motives or measurable improvements such as a faster feedback loop, reduced maintenance, or better site performance. - -## Recommended callout wording - -> **Updated 17 August 2026:** This post describes the site's original Astro and Svelte build. The site now uses **TanStack Start** with **Vite+**. TanStack Start provides the Router-based React application layer, while Vite+ brings development, builds, linting, formatting, and related tooling into one workflow. The original sections remain below as a record of how the site was first built. - -This wording explains the practical change without claiming undocumented personal motives or performance gains. “Related tooling” is deliberately narrower than claiming that this repository actively uses every capability Vite+ ships. - -## Status caveat - -As of the research date, TanStack labels Start as a Release Candidate, and Vite+ describes itself as beta (“stable, but not yet complete”). These statuses do not need to be in a short blog callout, but they should be considered before making stronger stability claims. ([TanStack Start overview](https://tanstack.com/start/latest/docs/framework/react/overview), [Vite+ troubleshooting](https://viteplus.dev/guide/troubleshooting)) diff --git a/package.json b/package.json index 5bce6b1..bc303de 100644 --- a/package.json +++ b/package.json @@ -30,14 +30,10 @@ "@tanstack/react-query": "^5.101.4", "@tanstack/react-router": "^1.170.23", "@tanstack/react-start": "^1.168.40", - "clsx": "^2.1.1", - "cmdk": "^1.1.1", + "@tanstack/redact": "^0.2.3", "drizzle-orm": "^0.45.2", - "fuse.js": "^7.5.0", - "lucide-react": "^0.539.0", "react": "^19.2.0", "react-dom": "^19.2.0", - "tailwind-merge": "^3.3.1", "use-lanyard": "^2.0.0", "zod": "^4.0.17" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 71f8184..545b6e1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -252,30 +252,18 @@ importers: '@tanstack/react-start': specifier: ^1.168.40 version: 1.168.40(@voidzero-dev/vite-plus-core@0.2.8(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.12)(typescript@5.9.3)(yaml@2.9.0))(esbuild@0.28.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(rollup@4.62.4)(supports-color@10.2.2) - clsx: - specifier: ^2.1.1 - version: 2.1.1 - cmdk: - specifier: ^1.1.1 - version: 1.1.1(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@tanstack/redact': + specifier: ^0.2.3 + version: 0.2.3(@cloudflare/vite-plugin@1.52.1(@voidzero-dev/vite-plus-core@0.2.8(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.12)(typescript@5.9.3)(yaml@2.9.0))(wrangler@4.123.0))(@voidzero-dev/vite-plus-core@0.2.8(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.12)(typescript@5.9.3)(yaml@2.9.0)) drizzle-orm: specifier: ^0.45.2 version: 0.45.2 - fuse.js: - specifier: ^7.5.0 - version: 7.5.0 - lucide-react: - specifier: ^0.539.0 - version: 0.539.0(react@19.2.8) react: specifier: ^19.2.0 version: 19.2.8 react-dom: specifier: ^19.2.0 version: 19.2.8(react@19.2.8) - tailwind-merge: - specifier: ^3.3.1 - version: 3.6.0 use-lanyard: specifier: ^2.0.0 version: 2.0.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8) @@ -2272,6 +2260,23 @@ packages: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + '@tanstack/redact@0.2.3': + resolution: {integrity: sha512-a+KRpHdWPDfM9tOci9k8Oxm3YlEC8thBac8nxN5k7tzLWM6hagz+hZr0A0ZLjRcpHMOEHVxASx33WyUVvfpffw==} + peerDependencies: + '@cloudflare/vite-plugin': 1.65.0 + next: ~15.5.0 || ~16.0.0 || ~16.1.0 || ~16.2.0 || ~16.3.0 || ~16.4.0 + vinext: ~1.3.0 + vite: '>=5' + peerDependenciesMeta: + '@cloudflare/vite-plugin': + optional: true + next: + optional: true + vinext: + optional: true + vite: + optional: true + '@tanstack/router-cli@1.167.25': resolution: {integrity: sha512-Nwg0ukiwwQBE755a6dQ2sYVNWuWCu09e9zAb0QrUMb7GjlZxmNv0CZFq1ZhJJG/1mjFLyW6pbxr7cImlip0MhA==} engines: {node: '>=20.19'} @@ -2805,16 +2810,6 @@ packages: resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} engines: {node: '>=12'} - clsx@2.1.1: - resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} - engines: {node: '>=6'} - - cmdk@1.1.1: - resolution: {integrity: sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg==} - peerDependencies: - react: ^18 || ^19 || ^19.0.0-rc - react-dom: ^18 || ^19 || ^19.0.0-rc - collapse-white-space@2.1.0: resolution: {integrity: sha512-loKTxY1zCOuG4j9f6EPnuyyYkf58RnhhWTvRoZEokgB+WbdXehfjFviyOVYkqzEWz1Q5kRiZdBYS5SwxbQYwzw==} @@ -3118,10 +3113,6 @@ packages: engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} os: [darwin] - fuse.js@7.5.0: - resolution: {integrity: sha512-sQtrEfA+ez/3G0cCZecF70oqpCRttCexYUG4mUrtWL49ULUzUyxokt5kyqwtKzj1270RaKih+hcP3qLcumccow==} - engines: {node: '>=10'} - gensync@1.0.0-beta.2: resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} engines: {node: '>=6.9.0'} @@ -3409,11 +3400,6 @@ packages: lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} - lucide-react@0.539.0: - resolution: {integrity: sha512-VVISr+VF2krO91FeuCrm1rSOLACQUYVy7NQkzrOty52Y8TlTPcXcMdQFj9bYzBgXbWCiywlwSZ3Z8u6a+6bMlg==} - peerDependencies: - react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 - lz-string@1.5.0: resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} hasBin: true @@ -3877,9 +3863,6 @@ packages: resolution: {integrity: sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==} engines: {node: '>=18'} - tailwind-merge@3.6.0: - resolution: {integrity: sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w==} - tailwindcss@4.3.3: resolution: {integrity: sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==} @@ -5624,6 +5607,13 @@ snapshots: react-dom: 19.2.8(react@19.2.8) use-sync-external-store: 1.6.0(react@19.2.8) + '@tanstack/redact@0.2.3(@cloudflare/vite-plugin@1.52.1(@voidzero-dev/vite-plus-core@0.2.8(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.12)(typescript@5.9.3)(yaml@2.9.0))(wrangler@4.123.0))(@voidzero-dev/vite-plus-core@0.2.8(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.12)(typescript@5.9.3)(yaml@2.9.0))': + dependencies: + magic-string: 0.30.21 + optionalDependencies: + '@cloudflare/vite-plugin': 1.52.1(@voidzero-dev/vite-plus-core@0.2.8(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.12)(typescript@5.9.3)(yaml@2.9.0))(wrangler@4.123.0) + vite: '@voidzero-dev/vite-plus-core@0.2.8(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.12)(typescript@5.9.3)(yaml@2.9.0)' + '@tanstack/router-cli@1.167.25(supports-color@10.2.2)': dependencies: '@tanstack/router-generator': 1.167.25(supports-color@10.2.2) @@ -6127,20 +6117,6 @@ snapshots: strip-ansi: 6.0.1 wrap-ansi: 7.0.0 - clsx@2.1.1: {} - - cmdk@1.1.1(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8): - dependencies: - '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.18)(react@19.2.8) - '@radix-ui/react-dialog': 1.1.23(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@radix-ui/react-id': 1.1.4(@types/react@19.2.18)(react@19.2.8) - '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - react: 19.2.8 - react-dom: 19.2.8(react@19.2.8) - transitivePeerDependencies: - - '@types/react' - - '@types/react-dom' - collapse-white-space@2.1.0: {} color-convert@2.0.1: @@ -6399,8 +6375,6 @@ snapshots: fsevents@2.3.3: optional: true - fuse.js@7.5.0: {} - gensync@1.0.0-beta.2: {} get-caller-file@2.0.5: {} @@ -6680,10 +6654,6 @@ snapshots: dependencies: yallist: 3.1.1 - lucide-react@0.539.0(react@19.2.8): - dependencies: - react: 19.2.8 - lz-string@1.5.0: {} magic-string@0.30.21: @@ -7499,8 +7469,6 @@ snapshots: supports-color@10.2.2: {} - tailwind-merge@3.6.0: {} - tailwindcss@4.3.3: {} tapable@2.3.3: {} diff --git a/scripts/generate-og-images.mjs b/scripts/generate-og-images.mjs index 0dbe499..e579573 100644 --- a/scripts/generate-og-images.mjs +++ b/scripts/generate-og-images.mjs @@ -208,7 +208,9 @@ function readFrontmatter(source, path) { return parse(match[1]); } -const defaultArtwork = await imageDataUrl(resolve(root, "public/images/android-chrome-512x512.png")); +const defaultArtwork = await imageDataUrl( + resolve(root, "public/images/android-chrome-512x512.png"), +); await renderCard( { title: config.site.title, @@ -223,7 +225,9 @@ await renderCard( const postFiles = (await walk(postsRoot)).filter((path) => path.endsWith(".mdx")); for (const path of postFiles) { const metadata = readFrontmatter(await readFile(path, "utf8"), path); - const postId = relative(postsRoot, path).replaceAll("\\", "/").replace(/\.mdx$/, ""); + const postId = relative(postsRoot, path) + .replaceAll("\\", "/") + .replace(/\.mdx$/, ""); const artwork = metadata.social_image ? await imageDataUrl(resolve(root, metadata.social_image)) : defaultArtwork; diff --git a/site.config.json b/site.config.json index 0628357..d0e4f89 100644 --- a/site.config.json +++ b/site.config.json @@ -7,24 +7,58 @@ "discordId": "394953409855488012", "supportUrl": "https://www.buymeacoffee.com/ashfid" }, - "navigation": [ - { "name": "Home", "url": "/", "icon": "ri:home-heart-line" }, - { "name": "Blog", "url": "/blog", "icon": "mdi:book-minus-outline" }, - { "name": "Snippets", "url": "/snippets", "icon": "mdi:treasure-chest-outline" }, - { "name": "Tools", "url": "/tools", "icon": "mdi:toolbox-outline" } - ], + "terminal": { + "user": "ashfid", + "host": "garden" + }, "socialLinks": [ - { "name": "GitHub", "url": "https://github.com/ashfidable", "icon": "mdi:github" }, - { "name": "RSS", "url": "/rss.xml", "icon": "mdi:rss" } + { + "name": "GitHub", + "url": "https://github.com/ashfidable", + "icon": "mdi:github" + }, + { + "name": "RSS", + "url": "/rss.xml", + "icon": "mdi:rss" + } ], "categories": [ - { "id": "development", "name": "Development", "icon": "mdi:code-not-equal-variant" }, - { "id": "entertainment", "name": "Entertainment", "icon": "mdi:movie-open-outline" }, - { "id": "game-review", "name": "Game Review", "icon": "mdi:gamepad-variant-outline" }, - { "id": "note-taking", "name": "Note-Taking", "icon": "mdi:note-edit-outline" }, - { "id": "productivity", "name": "Productivity", "icon": "mdi:progress-check" }, - { "id": "tool", "name": "Tool", "icon": "mdi:toolbox" }, - { "id": "web", "name": "Web", "icon": "mdi:web" } + { + "id": "development", + "name": "Development", + "icon": "mdi:code-not-equal-variant" + }, + { + "id": "entertainment", + "name": "Entertainment", + "icon": "mdi:movie-open-outline" + }, + { + "id": "game-review", + "name": "Game Review", + "icon": "mdi:gamepad-variant-outline" + }, + { + "id": "note-taking", + "name": "Note-Taking", + "icon": "mdi:note-edit-outline" + }, + { + "id": "productivity", + "name": "Productivity", + "icon": "mdi:progress-check" + }, + { + "id": "tool", + "name": "Tool", + "icon": "mdi:toolbox" + }, + { + "id": "web", + "name": "Web", + "icon": "mdi:web" + } ], "tools": [ { @@ -35,6 +69,14 @@ "categories": ["productivity", "note-taking"], "icon": "mdi:notebook-edit-outline" }, + { + "id": "craft", + "title": "Craft", + "description": "Beautiful documents and notes that stay in sync across devices.", + "url": "https://www.craft.do", + "categories": ["note-taking", "productivity"], + "icon": "custom:craft" + }, { "id": "discord", "title": "Discord", @@ -171,6 +213,14 @@ "categories": ["entertainment"], "icon": "simple-icons:threads" }, + { + "id": "todoist", + "title": "Todoist", + "description": "A to-do list and task manager for work and life.", + "url": "https://todoist.com", + "categories": ["productivity"], + "icon": "logos:todoist-icon" + }, { "id": "unity", "title": "Unity", @@ -225,38 +275,98 @@ "id": "programming-languages", "title": "Programming Languages", "entries": [ - { "name": "JavaScript", "icon": "logos:javascript" }, - { "name": "TypeScript", "icon": "logos:typescript-icon" }, - { "name": "C#", "icon": "logos:c-sharp" }, - { "name": "Python", "icon": "logos:python" }, - { "name": "C++", "icon": "logos:c-plusplus" }, - { "name": "C", "icon": "logos:c" }, - { "name": "Rust", "icon": "logos:rust" }, - { "name": "Golang", "icon": "logos:go" } + { + "name": "JavaScript", + "icon": "logos:javascript" + }, + { + "name": "TypeScript", + "icon": "logos:typescript-icon" + }, + { + "name": "C#", + "icon": "logos:c-sharp" + }, + { + "name": "Python", + "icon": "logos:python" + }, + { + "name": "C++", + "icon": "logos:c-plusplus" + }, + { + "name": "C", + "icon": "logos:c" + }, + { + "name": "Rust", + "icon": "logos:rust" + }, + { + "name": "Golang", + "icon": "logos:go" + } ] }, { "id": "frontend-and-backend", "title": "Frontend and Backend", "entries": [ - { "name": "Svelte/SvelteKit", "icon": "logos:svelte-icon" }, - { "name": "React", "icon": "logos:react" }, - { "name": "Astro", "icon": "logos:astro-icon" }, - { "name": "TanStack Start", "icon": "simple-icons:tanstack" }, - { "name": "Next.js", "icon": "logos:nextjs-icon" }, - { "name": "Nuxt", "icon": "logos:nuxt-icon" }, - { "name": "Vue", "icon": "logos:vue" } + { + "name": "Svelte/SvelteKit", + "icon": "logos:svelte-icon" + }, + { + "name": "React", + "icon": "logos:react" + }, + { + "name": "Astro", + "icon": "logos:astro-icon" + }, + { + "name": "TanStack Start", + "icon": "simple-icons:tanstack" + }, + { + "name": "Next.js", + "icon": "logos:nextjs-icon" + }, + { + "name": "Nuxt", + "icon": "logos:nuxt-icon" + }, + { + "name": "Vue", + "icon": "logos:vue" + } ] }, { "id": "game-engines", "title": "Game Engines", "entries": [ - { "name": "Unity", "icon": "simple-icons:unity" }, - { "name": "Godot", "icon": "logos:godot-icon" }, - { "name": "Unreal Engine", "icon": "simple-icons:unrealengine" }, - { "name": "MonoGame", "icon": "simple-icons:monogame" }, - { "name": "GameMaker", "icon": "simple-icons:gamemaker" } + { + "name": "Unity", + "icon": "simple-icons:unity" + }, + { + "name": "Godot", + "icon": "logos:godot-icon" + }, + { + "name": "Unreal Engine", + "icon": "simple-icons:unrealengine" + }, + { + "name": "MonoGame", + "icon": "simple-icons:monogame" + }, + { + "name": "GameMaker", + "icon": "simple-icons:gamemaker" + } ] } ] diff --git a/src/assets/icons/custom-icons.json b/src/assets/icons/custom-icons.json new file mode 100644 index 0000000..1351d8d --- /dev/null +++ b/src/assets/icons/custom-icons.json @@ -0,0 +1,10 @@ +{ + "prefix": "custom", + "width": 24, + "height": 24, + "icons": { + "craft": { + "body": "" + } + } +} diff --git a/src/client.tsx b/src/client.tsx new file mode 100644 index 0000000..ba506c7 --- /dev/null +++ b/src/client.tsx @@ -0,0 +1,23 @@ +import { StrictMode, startTransition } from "react"; +import { hydrateRoot } from "react-dom/client"; +import { Await, RouterProvider } from "@tanstack/react-router"; +import { hydrateStart } from "@tanstack/react-start/client"; +import { loadPost } from "$content/index"; + +const match = /^\/(blog|snippets)\/([^/]+)\/?$/.exec(window.location.pathname); +const article = + match && (match[1] === "blog" || match[1] === "snippets") + ? loadPost(match[1], decodeURIComponent(match[2])) + : Promise.resolve(); +// Start hydration normally so React can replay early interactions, but keep the +// server HTML until both the current route and article are ready to hydrate. +const ready = Promise.all([hydrateStart(), article]).then(([router]) => router); + +startTransition(() => { + hydrateRoot( + document, + + } /> + , + ); +}); diff --git a/src/components/back-to-top.tsx b/src/components/back-to-top.tsx deleted file mode 100644 index 940024c..0000000 --- a/src/components/back-to-top.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import { useEffect, useState } from "react"; -import { Icon } from "./icon"; - -export function BackToTop({ showThreshold = 500 }: { showThreshold?: number }) { - const [show, setShow] = useState(false); - - useEffect(() => { - const onScroll = () => setShow(document.documentElement.scrollTop > showThreshold); - onScroll(); - window.addEventListener("scroll", onScroll, { passive: true }); - return () => window.removeEventListener("scroll", onScroll); - }, [showThreshold]); - - if (!show) return null; - - return ( - - ); -} diff --git a/src/components/button.tsx b/src/components/button.tsx deleted file mode 100644 index 5871360..0000000 --- a/src/components/button.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import type { ButtonHTMLAttributes } from "react"; -import { cn } from "@/lib/utils"; - -export function Button({ className, ...props }: ButtonHTMLAttributes) { - return ( - - - - - No results found. - {Object.entries(groups).map(([group, items]) => ( - - {items?.map((command) => ( - { - setOpen(false); - window.setTimeout(command.run, 20); - }} - > - {command.emoji} - {command.title} - - ))} - - ))} - - - - ); -} diff --git a/src/components/icon.tsx b/src/components/icon.tsx index 0f6da0a..2903691 100644 --- a/src/components/icon.tsx +++ b/src/components/icon.tsx @@ -1,6 +1,6 @@ import icons from "virtual:site-icons"; -type IconData = { body: string; width: number; height: number; left?: number; top?: number }; +type IconData = { url: string; width: number; height: number; left?: number; top?: number }; export function Icon({ name, className }: { name?: string; className?: string }) { if (!name) return null; @@ -14,7 +14,8 @@ export function Icon({ name, className }: { name?: string; className?: string }) height="1em" className={className} aria-hidden="true" - dangerouslySetInnerHTML={{ __html: icon.body }} - /> + > + + ); } diff --git a/src/components/image-gallery.tsx b/src/components/image-gallery.tsx index 568d9ff..fce93cf 100644 --- a/src/components/image-gallery.tsx +++ b/src/components/image-gallery.tsx @@ -146,7 +146,6 @@ export function ImageGallery({ images, altPrefix, pageSize, spoiler = false }: I 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"} > @@ -167,7 +166,7 @@ export function ImageGallery({ images, altPrefix, pageSize, spoiler = false }: I type="button" className="group relative aspect-[16/10] overflow-hidden rounded-md bg-site-card outline-none focus-visible:ring-2 focus-visible:ring-site-ring focus-visible:ring-offset-2 focus-visible:ring-offset-site-page" onClick={(event) => openImage(index, event.currentTarget)} - aria-label={`Open ${alt}`} + title={`Open ${alt}`} > {activeIndex === null ? 0 : activeIndex + 1} / {images.length} -
+
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 (
{alt} @@ -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 ( - - ); -} 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 ( - - ); -} 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 ( - - ); -} - -export function PostLike({ state }: { state: ReturnType }) { - const { likes, sendLike, done, pending, saving, limited } = state; - const busy = pending || saving; - - return ( -
-
- -
-
- ); -} - -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 ( - <> - -
-
-
-

- {post.title} -

-
- {post.review && } -
- - - {formatDate(post.published_time)} - - {post.last_modified_time && ( - - - {formatDate(post.last_modified_time)} - - )} - - - {category?.name} - - - - {getReadingTime(post.body)} - - -
-
- -
- -
- -
- - ); -} - -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 ( - <> -
-
-
-

- {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 ( -
- - - - - - - - - {themesOpen && ( - - )} -
- ); -} 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 ( -
-

{title}

- {children} -
- ); -} - -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.song} - - - by {spotify.artist} - - -
-
-
- -
- )} - {!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) => ( +
+
+ + {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 + +

+
+

# 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) => ( + + ))} +
+
+ ); +} + +function ConfigPanel() { + const settings = useSettings(); + const click = (enabled = settings.sound) => playAudio("/audio/sound_toggle_on.mp3", enabled); + + return ( +