Compare changes

Choose any two refs to compare.

+5
.tangled/workflows/deploy.yml
··· 14 14 - coreutils 15 15 - curl 16 16 - nodejs 17 + - glibc 17 18 github:NixOS/nixpkgs/nixpkgs-unstable: 18 19 - bun 20 + 19 21 20 22 environment: 21 23 SITE_PATH: 'dist' # Copy entire repo ··· 27 29 command: | 28 30 export PATH="$HOME/.nix-profile/bin:$PATH" 29 31 32 + rm -rf bun.lock 33 + bun install @oven/bun-linux-x64 30 34 bun install 31 35 32 36 bun run build ··· 34 38 command: | 35 39 # Download Wisp CLI 36 40 curl https://sites.wisp.place/nekomimi.pet/wisp-cli-binaries/wisp-cli-x86_64-linux -o wisp-cli 41 + 37 42 chmod +x wisp-cli 38 43 39 44 # Deploy to Wisp
-184
CRUSH.md
··· 1 - # CRUSH.md 2 - 3 - ## Project Overview 4 - 5 - This is a personal portfolio website built with Bun, React, TypeScript, and Tailwind CSS. It uses the shadcn/ui component library and serves as both a portfolio and development environment for AT Protocol-related projects. The project demonstrates modern web development practices with a focus on decentralized technologies. 6 - 7 - ## Development Commands 8 - 9 - ### Core Commands 10 - - `bun install` - Install dependencies 11 - - `bun dev` - Start development server with hot reload and HMR 12 - - `bun start` - Run production server 13 - - `bun run build.ts` - Build for production (outputs to `dist/`) 14 - - `bun run build.ts --help` - Show all build options 15 - 16 - ### Build System 17 - The custom build script (`build.ts`) supports various options: 18 - - `--outdir <path>` - Output directory (default: "dist") 19 - - `--minify` - Enable minification 20 - - `--sourcemap <type>` - Sourcemap type (none|linked|inline|external) 21 - - `--external <list>` - External packages (comma separated) 22 - 23 - The build automatically: 24 - - Processes all HTML files in `src/` as entrypoints 25 - - Copies `public/` folder to dist 26 - - Uses Tailwind plugin for CSS processing 27 - - Includes linked sourcemaps by default 28 - 29 - ## Architecture 30 - 31 - ### Project Structure 32 - ``` 33 - src/ 34 - โ”œโ”€โ”€ components/ 35 - โ”‚ โ”œโ”€โ”€ ui/ # shadcn/ui components (Button, Card, Input, etc.) 36 - โ”‚ โ”œโ”€โ”€ sections/ # Main page sections (Header, Work, Connect) 37 - โ”‚ โ””โ”€โ”€ ... # Other React components 38 - โ”œโ”€โ”€ data/ 39 - โ”‚ โ””โ”€โ”€ portfolio.ts # Portfolio content and metadata 40 - โ”œโ”€โ”€ hooks/ # Custom React hooks 41 - โ”œโ”€โ”€ lib/ # Utility functions 42 - โ””โ”€โ”€ styles/ # Global CSS and Tailwind config 43 - ``` 44 - 45 - ### Server Architecture 46 - - Uses Bun's built-in server (`src/index.ts`) 47 - - Serves React SPA with API routes 48 - - API routes use pattern matching (`/api/hello/:name`) 49 - - CORS headers configured for cross-origin requests 50 - - Development mode includes HMR and browser console echoing 51 - 52 - ### Key Files 53 - - `src/index.ts` - Server entry point with API routes 54 - - `src/App.tsx` - Main React component with intersection observer animations 55 - - `src/data/portfolio.ts` - All portfolio content (personal info, work experience, skills) 56 - - `build.ts` - Custom build script with extensive CLI options 57 - - `styles/globals.css` - Tailwind imports, CSS variables, and custom animations 58 - 59 - ## Code Conventions 60 - 61 - ### TypeScript Configuration 62 - - Strict mode enabled with `noUncheckedIndexedAccess` 63 - - Path aliases: `@/*` maps to `./src/*` 64 - - JSX: `react-jsx` transform 65 - - Module resolution: `bundler` mode 66 - - Target: `ESNext` with DOM libraries 67 - 68 - ### Component Patterns 69 - - Uses shadcn/ui component library with `class-variance-authority` 70 - - Utility function `cn()` combines `clsx` and `tailwind-merge` 71 - - Components follow Radix UI patterns for accessibility 72 - - File exports: Named exports for components, default for main App 73 - 74 - ### Styling 75 - - Tailwind CSS v4 with custom CSS variables 76 - - Dark theme by default with light mode support 77 - - Glassmorphism effects with custom utilities 78 - - Custom animations: `fade-in-up`, `bounce-slow` 79 - - Fira Code monospace font throughout 80 - 81 - ### Import Aliases (from components.json) 82 - ```typescript 83 - "@/components" โ†’ "./src/components" 84 - "@/lib/utils" โ†’ "./src/lib/utils" 85 - "@/components/ui" โ†’ "./src/components/ui" 86 - "@/lib" โ†’ "./src/lib" 87 - "@/hooks" โ†’ "./src/hooks" 88 - ``` 89 - 90 - ## UI Components 91 - 92 - ### shadcn/ui Integration 93 - The project uses shadcn/ui with: 94 - - Style variant: "new-york" 95 - - Base color: "neutral" 96 - - Icon library: Lucide React 97 - - CSS variables enabled 98 - - Custom CSS location: `styles/globals.css` 99 - 100 - ### Available UI Components 101 - - Button (multiple variants: default, destructive, outline, secondary, ghost, link) 102 - - Card 103 - - Input 104 - - Label 105 - - Select 106 - - Textarea 107 - 108 - ### Custom Components 109 - - ThemeToggle (dark/light mode switching) 110 - - SectionNav (navigation between portfolio sections) 111 - - ProjectCard/WorkExperienceCard (portfolio item displays) 112 - - SocialLink (social media links with icons) 113 - 114 - ## Content Management 115 - 116 - Portfolio data is centralized in `src/data/portfolio.ts`: 117 - - `personalInfo` - Name, title, description, availability, contact 118 - - `currentRole` - Current employment status 119 - - `skills` - Array of technical skills 120 - - `workExperience` - Array of work history with projects 121 - - `socialLinks` - Social media profiles 122 - - `sections` - Page section identifiers 123 - 124 - The description format supports rich text with bold styling and URLs: 125 - ```typescript 126 - type DescriptionPart = { 127 - text: string 128 - bold?: boolean 129 - url?: string 130 - } 131 - ``` 132 - 133 - ## Deployment 134 - 135 - ### Netlify Configuration 136 - - Static site hosting 137 - - CORS headers configured in `public/netlify.toml` 138 - - AT Protocol DID file at `public/.well-known/atproto-did` 139 - 140 - ### Build Output 141 - - Production builds output to `dist/` 142 - - All HTML files in `src/` become entrypoints 143 - - Public assets copied automatically 144 - - Source maps linked for debugging 145 - 146 - ## Development Notes 147 - 148 - ### Hot Module Replacement 149 - - Development server includes HMR 150 - - Browser console logs echoed to server 151 - - Automatic reloading on file changes 152 - 153 - ### Performance Features 154 - - Intersection Observer for scroll-triggered animations 155 - - Code splitting support in build configuration 156 - - Minification enabled by default in production 157 - - Lazy loading with `react` imports 158 - 159 - ### AT Protocol Integration 160 - - Project showcases AT Protocol-related work 161 - - Uses `atproto-ui` component library 162 - - Bluesky and Tangled integration in portfolio 163 - 164 - ## Gotchas 165 - 166 - ### Build System 167 - - Custom build script requires Bun runtime (not Node.js) 168 - - HTML files in `src/` automatically become entrypoints 169 - - Must use `--external` flag for libraries that shouldn't be bundled 170 - 171 - ### Styling 172 - - Dark mode is default styling approach 173 - - CSS variables are used extensively for theming 174 - - Custom glassmorphism effects require SVG filters (defined in CSS) 175 - 176 - ### Server Routes 177 - - API routes use Bun's pattern matching syntax 178 - - All unmatched routes serve the main SPA (catch-all route) 179 - - CORS headers pre-configured for API access 180 - 181 - ### Content Structure 182 - - Portfolio content is TypeScript data, not markdown 183 - - Rich text descriptions use specific object structure 184 - - Projects support multiple links (live demo, GitHub, etc.)
+1 -21
README.md
··· 1 - # bun-react-tailwind-shadcn-template 2 - 3 - To install dependencies: 4 - 5 - ```bash 6 - bun install 7 - ``` 8 - 9 - To start a development server: 10 - 11 - ```bash 12 - bun dev 13 - ``` 14 - 15 - To run for production: 16 - 17 - ```bash 18 - bun start 19 - ``` 20 - 21 - This project was created using `bun init` in bun v1.3.1. [Bun](https://bun.com) is a fast all-in-one JavaScript runtime. 1 + https://nekomimi.pet
+2
bun-env.d.ts
··· 1 1 // Generated by `bun init` 2 2 3 + /// <reference path="src/guestbook.d.ts" /> 4 + 3 5 declare module "*.svg" { 4 6 /** 5 7 * A path to the SVG file
+20 -6
bun.lock
··· 1 1 { 2 2 "lockfileVersion": 1, 3 + "configVersion": 1, 3 4 "workspaces": { 4 5 "": { 5 6 "name": "bun-react-template", ··· 7 8 "@radix-ui/react-label": "^2.1.7", 8 9 "@radix-ui/react-select": "^2.2.6", 9 10 "@radix-ui/react-slot": "^1.2.3", 10 - "atproto-ui": "^0.7.2", 11 + "atproto-ui": "0.11.3", 11 12 "bun-plugin-tailwind": "^0.1.2", 12 13 "class-variance-authority": "^0.7.1", 13 14 "clsx": "^2.1.1", 15 + "cutebook": "0.1.1", 14 16 "lucide-react": "^0.545.0", 15 17 "react": "^19", 16 18 "react-dom": "^19", ··· 38 40 39 41 "@atcute/lexicons": ["@atcute/lexicons@1.2.2", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "esm-env": "^1.2.2" } }, "sha512-bgEhJq5Z70/0TbK5sx+tAkrR8FsCODNiL2gUEvS5PuJfPxmFmRYNWaMGehxSPaXWpU2+Oa9ckceHiYbrItDTkA=="], 40 42 43 + "@atcute/multibase": ["@atcute/multibase@1.1.6", "", { "dependencies": { "@atcute/uint8array": "^1.0.5" } }, "sha512-HBxuCgYLKPPxETV0Rot4VP9e24vKl8JdzGCZOVsDaOXJgbRZoRIF67Lp0H/OgnJeH/Xpva8Z5ReoTNJE5dn3kg=="], 44 + 45 + "@atcute/oauth-browser-client": ["@atcute/oauth-browser-client@2.0.1", "", { "dependencies": { "@atcute/client": "^4.0.5", "@atcute/identity": "^1.1.1", "@atcute/identity-resolver": "^1.1.4", "@atcute/lexicons": "^1.2.2", "@atcute/multibase": "^1.1.6", "@atcute/uint8array": "^1.0.5", "nanoid": "^5.1.5" } }, "sha512-lG021GkeORG06zfFf4bH85egObjBEKHNgAWHvbtY/E2dX4wxo88hf370pJDx8acdnuUJLJ2VKPikJtZwo4Heeg=="], 46 + 41 47 "@atcute/tangled": ["@atcute/tangled@1.0.10", "", { "dependencies": { "@atcute/atproto": "^3.1.8", "@atcute/lexicons": "^1.2.2" } }, "sha512-DGconZIN5TpLBah+aHGbWI1tMsL7XzyVEbr/fW4CbcLWYKICU6SAUZ0YnZ+5GvltjlORWHUy7hfftvoh4zodIA=="], 48 + 49 + "@atcute/uint8array": ["@atcute/uint8array@1.0.5", "", {}, "sha512-XLWWxoR2HNl2qU+FCr0rp1APwJXci7HnzbOQLxK55OaMNBXZ19+xNC5ii4QCsThsDxa4JS/JTzuiQLziITWf2Q=="], 42 50 43 51 "@atcute/util-fetch": ["@atcute/util-fetch@1.0.3", "", { "dependencies": { "@badrap/valita": "^0.4.6" } }, "sha512-f8zzTb/xlKIwv2OQ31DhShPUNCmIIleX6p7qIXwWwEUjX6x8skUtpdISSjnImq01LXpltGV5y8yhV4/Mlb7CRQ=="], 44 52 ··· 132 140 133 141 "@types/bun": ["@types/bun@1.3.2", "", { "dependencies": { "bun-types": "1.3.2" } }, "sha512-t15P7k5UIgHKkxwnMNkJbWlh/617rkDGEdSsDbu+qNHTaz9SKf7aC8fiIlUdD5RPpH6GEkP0cK7WlvmrEBRtWg=="], 134 142 135 - "@types/node": ["@types/node@24.10.0", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-qzQZRBqkFsYyaSWXuEHc2WR9c0a0CXwiE5FWUvn7ZM+vdy1uZLfCunD38UzhuB7YN/J11ndbDBcTmOdxJo9Q7A=="], 143 + "@types/node": ["@types/node@24.10.1", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ=="], 136 144 137 - "@types/react": ["@types/react@19.2.2", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA=="], 145 + "@types/react": ["@types/react@19.2.3", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-k5dJVszUiNr1DSe8Cs+knKR6IrqhqdhpUwzqhkS8ecQTSf3THNtbfIp/umqHMpX2bv+9dkx3fwDv/86LcSfvSg=="], 138 146 139 - "@types/react-dom": ["@types/react-dom@19.2.2", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-9KQPoO6mZCi7jcIStSnlOWn2nEF3mNmyr3rIAsGnAbQKYbRLyqmeSc39EVgtxXVia+LMT8j3knZLAZAh+xLmrw=="], 147 + "@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="], 148 + 149 + "actor-typeahead": ["actor-typeahead@0.1.2", "", {}, "sha512-I97YqqNl7Kar0J/bIJvgY/KmHpssHcDElhfwVTLP7wRFlkxso2ZLBqiS2zol5A8UVUJbQK2JXYaqNpZXz8Uk2A=="], 140 150 141 151 "aria-hidden": ["aria-hidden@1.2.6", "", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA=="], 142 152 143 - "atproto-ui": ["atproto-ui@0.7.2", "", { "dependencies": { "@atcute/atproto": "^3.1.7", "@atcute/bluesky": "^3.2.3", "@atcute/client": "^4.0.3", "@atcute/identity-resolver": "^1.1.3", "@atcute/tangled": "^1.0.6" }, "peerDependencies": { "react": "^18.2.0 || ^19.0.0", "react-dom": "^18.2.0 || ^19.0.0" }, "optionalPeers": ["react-dom"] }, "sha512-bVHjur5Wh5g+47p8Zaq7iZkd5zpqw5A8xg0z5rsDWkmRvqO8E3kZbL9Svco0qWQM/jg4akG/97Vn1XecATovzg=="], 153 + "atproto-ui": ["atproto-ui@0.11.3", "", { "dependencies": { "@atcute/atproto": "^3.1.7", "@atcute/bluesky": "^3.2.3", "@atcute/client": "^4.0.3", "@atcute/identity-resolver": "^1.1.3", "@atcute/tangled": "^1.0.10" }, "peerDependencies": { "react": "^18.2.0 || ^19.0.0", "react-dom": "^18.2.0 || ^19.0.0" }, "optionalPeers": ["react-dom"] }, "sha512-NIBsORuo9lpCpr1SNKcKhNvqOVpsEy9IoHqFe1CM9gNTArpQL1hUcoP1Cou9a1O5qzCul9kaiu5xBHnB81I/WQ=="], 144 154 145 155 "bun": ["bun@1.3.2", "", { "optionalDependencies": { "@oven/bun-darwin-aarch64": "1.3.2", "@oven/bun-darwin-x64": "1.3.2", "@oven/bun-darwin-x64-baseline": "1.3.2", "@oven/bun-linux-aarch64": "1.3.2", "@oven/bun-linux-aarch64-musl": "1.3.2", "@oven/bun-linux-x64": "1.3.2", "@oven/bun-linux-x64-baseline": "1.3.2", "@oven/bun-linux-x64-musl": "1.3.2", "@oven/bun-linux-x64-musl-baseline": "1.3.2", "@oven/bun-windows-x64": "1.3.2", "@oven/bun-windows-x64-baseline": "1.3.2" }, "os": [ "linux", "win32", "darwin", ], "cpu": [ "x64", "arm64", ], "bin": { "bun": "bin/bun.exe", "bunx": "bin/bunx.exe" } }, "sha512-x75mPJiEfhO1j4Tfc65+PtW6ZyrAB6yTZInydnjDZXF9u9PRAnr6OK3v0Q9dpDl0dxRHkXlYvJ8tteJxc8t4Sw=="], 146 156 ··· 154 164 155 165 "csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="], 156 166 167 + "cutebook": ["cutebook@0.1.1", "", { "dependencies": { "actor-typeahead": "^0.1.2" }, "peerDependencies": { "@atcute/client": "^4.0.0", "@atcute/identity-resolver": "^1.0.0", "@atcute/oauth-browser-client": "^2.0.0" } }, "sha512-Wh4fpQUFwVnmKnLA8MOnNRbPstYv2EeC8KG1d9P6MMzupjMP2GRaDnixzg1ADvH2wBuVcpGDbGm4zyhN+h3D8w=="], 168 + 157 169 "detect-node-es": ["detect-node-es@1.1.0", "", {}, "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ=="], 158 170 159 171 "esm-env": ["esm-env@1.2.2", "", {}, "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA=="], ··· 161 173 "get-nonce": ["get-nonce@1.0.1", "", {}, "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q=="], 162 174 163 175 "lucide-react": ["lucide-react@0.545.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-7r1/yUuflQDSt4f1bpn5ZAocyIxcTyVyBBChSVtBKn5M+392cPmI5YJMWOJKk/HUWGm5wg83chlAZtCcGbEZtw=="], 176 + 177 + "nanoid": ["nanoid@5.1.6", "", { "bin": { "nanoid": "bin/nanoid.js" } }, "sha512-c7+7RQ+dMB5dPwwCp4ee1/iV/q2P6aK1mTZcfr1BTuVlyW9hJYiMPybJCcnBlQtuSmTIWNeazm/zqNoZSSElBg=="], 164 178 165 179 "react": ["react@19.2.0", "", {}, "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ=="], 166 180 ··· 174 188 175 189 "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], 176 190 177 - "tailwind-merge": ["tailwind-merge@3.3.1", "", {}, "sha512-gBXpgUm/3rp1lMZZrM/w7D8GKqshif0zAymAhbCyIt8KMe+0v9DQ7cdYLR4FHH/cKpdTXb+A/tKKU3eolfsI+g=="], 191 + "tailwind-merge": ["tailwind-merge@3.4.0", "", {}, "sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g=="], 178 192 179 193 "tailwindcss": ["tailwindcss@4.1.17", "", {}, "sha512-j9Ee2YjuQqYT9bbRTfTZht9W/ytp5H+jJpZKiYdP/bpnXARAuELt9ofP0lPnmHjbga7SNQIxdTAXCmtKVYjN+Q=="], 180 194
+2 -1
package.json
··· 12 12 "@radix-ui/react-label": "^2.1.7", 13 13 "@radix-ui/react-select": "^2.2.6", 14 14 "@radix-ui/react-slot": "^1.2.3", 15 - "atproto-ui": "^0.7.2", 15 + "atproto-ui": "0.11.3", 16 16 "bun-plugin-tailwind": "^0.1.2", 17 17 "class-variance-authority": "^0.7.1", 18 18 "clsx": "^2.1.1", 19 + "cutebook": "0.1.1", 19 20 "lucide-react": "^0.545.0", 20 21 "react": "^19", 21 22 "react-dom": "^19",
+13
public/client-metadata.json
··· 1 + { 2 + "client_id": "https://nekomimi.pet/client-metadata.json", 3 + "client_name": "nekomimi.pet", 4 + "client_uri": "https://nekomimi.pet", 5 + "redirect_uris": ["https://nekomimi.pet/guestbook"], 6 + "scope": "atproto transition:generic", 7 + "grant_types": ["authorization_code", "refresh_token"], 8 + "response_types": ["code"], 9 + "token_endpoint_auth_method": "none", 10 + "application_type": "web", 11 + "dpop_bound_access_tokens": true 12 + } 13 +
+41 -3
src/App.tsx
··· 4 4 import { Header } from "./components/sections/Header" 5 5 import { Work } from "./components/sections/Work" 6 6 import { Connect } from "./components/sections/Connect" 7 + import { GuestbookPage } from "./components/sections/GuestbookPage" 7 8 import { sections } from "./data/portfolio" 8 9 9 10 export function App() { 10 11 const [activeSection, setActiveSection] = useState("") 12 + const [currentPath, setCurrentPath] = useState(window.location.pathname) 11 13 const sectionsRef = useRef<(HTMLElement | null)[]>([]) 12 14 15 + // Handle SPA navigation 13 16 useEffect(() => { 17 + const handlePopState = () => setCurrentPath(window.location.pathname) 18 + window.addEventListener('popstate', handlePopState) 19 + return () => window.removeEventListener('popstate', handlePopState) 20 + }, []) 21 + 22 + useEffect(() => { 23 + if (currentPath === '/guestbook') return // Skip observer on guestbook page 24 + 14 25 const observer = new IntersectionObserver( 15 26 (entries) => { 16 27 entries.forEach((entry) => { ··· 28 39 }) 29 40 30 41 return () => observer.disconnect() 31 - }, []) 42 + }, [currentPath]) 32 43 33 - 44 + // Guestbook page 45 + if (currentPath === '/guestbook') { 46 + return ( 47 + <div className="min-h-screen dark:bg-background text-foreground relative"> 48 + <div className="fixed top-6 left-6 z-50"> 49 + <button 50 + onClick={() => { 51 + window.history.pushState({}, '', '/') 52 + setCurrentPath('/') 53 + }} 54 + className="px-4 py-2 rounded-full text-sm font-medium bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-200 shadow-md hover:shadow-lg border border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700 transition-all flex items-center gap-2" 55 + > 56 + <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2}> 57 + <path strokeLinecap="round" strokeLinejoin="round" d="M10 19l-7-7m0 0l7-7m-7 7h18" /> 58 + </svg> 59 + Back 60 + </button> 61 + </div> 62 + <GuestbookPage /> 63 + </div> 64 + ) 65 + } 34 66 35 67 return ( 36 68 <div className="min-h-screen dark:bg-background text-foreground relative"> ··· 38 70 39 71 <main> 40 72 <div className="max-w-4xl mx-auto px-6 sm:px-8 lg:px-16"> 41 - <Header sectionRef={(el) => (sectionsRef.current[0] = el)} /> 73 + <Header 74 + sectionRef={(el) => (sectionsRef.current[0] = el)} 75 + onGuestbookClick={() => { 76 + window.history.pushState({}, '', '/guestbook') 77 + setCurrentPath('/guestbook') 78 + }} 79 + /> 42 80 </div> 43 81 <Work sectionRef={(el) => (sectionsRef.current[1] = el)} /> 44 82 <Connect sectionRef={(el) => (sectionsRef.current[2] = el)} />
+252
src/components/GuestbookEntries.tsx
··· 1 + import { useEffect, useState } from "react" 2 + 3 + interface GuestbookEntry { 4 + uri: string 5 + author: string 6 + authorHandle?: string 7 + message: string 8 + createdAt: string 9 + } 10 + 11 + interface ConstellationRecord { 12 + did: string 13 + collection: string 14 + rkey: string 15 + } 16 + 17 + const COLORS = [ 18 + '#dc2626', // red 19 + '#0d9488', // teal 20 + '#059669', // emerald 21 + '#84cc16', // lime 22 + '#ec4899', // pink 23 + '#3b82f6', // blue 24 + '#8b5cf6', // violet 25 + ] 26 + 27 + function getColorForIndex(index: number): string { 28 + return COLORS[index % COLORS.length]! 29 + } 30 + 31 + interface GuestbookEntriesProps { 32 + did: string 33 + limit?: number 34 + onRefresh?: (refresh: () => void) => void 35 + } 36 + 37 + export function GuestbookEntries({ did, limit = 50, onRefresh }: GuestbookEntriesProps) { 38 + const [entries, setEntries] = useState<GuestbookEntry[]>([]) 39 + const [loading, setLoading] = useState(true) 40 + const [error, setError] = useState<string | null>(null) 41 + 42 + const fetchEntries = async (signal: AbortSignal) => { 43 + setLoading(true) 44 + setError(null) 45 + 46 + try { 47 + const url = new URL('/xrpc/blue.microcosm.links.getBacklinks', 'https://constellation.microcosm.blue') 48 + url.searchParams.set('subject', did) 49 + url.searchParams.set('source', 'pet.nkp.guestbook.sign:subject') 50 + url.searchParams.set('limit', limit.toString()) 51 + 52 + const response = await fetch(url.toString(), { signal }) 53 + if (!response.ok) throw new Error('Failed to fetch signatures') 54 + 55 + const data = await response.json() 56 + 57 + if (!data.records || !Array.isArray(data.records)) { 58 + setEntries([]) 59 + setLoading(false) 60 + return 61 + } 62 + 63 + // Collect all entries first, then render once 64 + const entryPromises = (data.records as ConstellationRecord[]).map(async (record) => { 65 + try { 66 + const recordUrl = new URL('/xrpc/com.atproto.repo.getRecord', 'https://slingshot.wisp.place') 67 + recordUrl.searchParams.set('repo', record.did) 68 + recordUrl.searchParams.set('collection', record.collection) 69 + recordUrl.searchParams.set('rkey', record.rkey) 70 + 71 + const recordResponse = await fetch(recordUrl.toString(), { signal }) 72 + if (!recordResponse.ok) return null 73 + 74 + const recordData = await recordResponse.json() 75 + 76 + if ( 77 + recordData.value && 78 + recordData.value.$type === 'pet.nkp.guestbook.sign' && 79 + typeof recordData.value.message === 'string' 80 + ) { 81 + return { 82 + uri: recordData.uri, 83 + author: record.did, 84 + authorHandle: undefined, 85 + message: recordData.value.message, 86 + createdAt: recordData.value.createdAt, 87 + } as GuestbookEntry 88 + } 89 + } catch (err) { 90 + if (err instanceof Error && err.name === 'AbortError') throw err 91 + } 92 + return null 93 + }) 94 + 95 + const results = await Promise.all(entryPromises) 96 + const validEntries = results.filter((e): e is GuestbookEntry => e !== null) 97 + 98 + // Sort once and set all entries at once 99 + validEntries.sort((a, b) => 100 + new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime() 101 + ) 102 + 103 + setEntries(validEntries) 104 + setLoading(false) 105 + 106 + // Batch fetch profiles asynchronously 107 + if (validEntries.length > 0) { 108 + const uniqueDids = Array.from(new Set(validEntries.map(e => e.author))) 109 + 110 + // Batch fetch profiles up to 25 at a time (API limit) 111 + const profilePromises = [] 112 + for (let i = 0; i < uniqueDids.length; i += 25) { 113 + const batch = uniqueDids.slice(i, i + 25) 114 + 115 + const profileUrl = new URL('/xrpc/app.bsky.actor.getProfiles', 'https://public.api.bsky.app') 116 + batch.forEach(d => profileUrl.searchParams.append('actors', d)) 117 + 118 + profilePromises.push( 119 + fetch(profileUrl.toString(), { signal }) 120 + .then(profileResponse => profileResponse.ok ? profileResponse.json() : null) 121 + .then(profilesData => { 122 + if (profilesData?.profiles && Array.isArray(profilesData.profiles)) { 123 + const handles = new Map<string, string>() 124 + profilesData.profiles.forEach((profile: any) => { 125 + if (profile.handle) { 126 + handles.set(profile.did, profile.handle) 127 + } 128 + }) 129 + return handles 130 + } 131 + return new Map<string, string>() 132 + }) 133 + .catch((err) => { 134 + if (err instanceof Error && err.name === 'AbortError') throw err 135 + return new Map<string, string>() 136 + }) 137 + ) 138 + } 139 + 140 + // Wait for all profile batches, then update once 141 + const handleMaps = await Promise.all(profilePromises) 142 + const allHandles = new Map<string, string>() 143 + handleMaps.forEach(map => { 144 + map.forEach((handle, did) => allHandles.set(did, handle)) 145 + }) 146 + 147 + if (allHandles.size > 0) { 148 + setEntries(prev => prev.map(entry => { 149 + const handle = allHandles.get(entry.author) 150 + return handle ? { ...entry, authorHandle: handle } : entry 151 + })) 152 + } 153 + } 154 + } catch (err) { 155 + if (err instanceof Error && err.name === 'AbortError') return 156 + setError(err instanceof Error ? err.message : 'Failed to load entries') 157 + setLoading(false) 158 + } 159 + } 160 + 161 + useEffect(() => { 162 + const abortController = new AbortController() 163 + fetchEntries(abortController.signal) 164 + onRefresh?.(() => { 165 + abortController.abort() 166 + const newController = new AbortController() 167 + fetchEntries(newController.signal) 168 + }) 169 + 170 + return () => abortController.abort() 171 + }, [did, limit]) 172 + 173 + const formatDate = (isoString: string) => { 174 + const date = new Date(isoString) 175 + return date.toLocaleDateString('en-US', { month: 'long', day: 'numeric' }) 176 + } 177 + 178 + const shortenDid = (did: string) => { 179 + if (did.startsWith('did:')) { 180 + const afterPrefix = did.indexOf(':', 4) 181 + if (afterPrefix !== -1) { 182 + return `${did.slice(0, afterPrefix + 9)}...` 183 + } 184 + } 185 + return did 186 + } 187 + 188 + if (loading) { 189 + return ( 190 + <div className="text-center py-12 text-gray-500"> 191 + Loading entries... 192 + </div> 193 + ) 194 + } 195 + 196 + if (error) { 197 + return ( 198 + <div className="text-center py-12 text-red-500"> 199 + {error} 200 + </div> 201 + ) 202 + } 203 + 204 + if (entries.length === 0) { 205 + return ( 206 + <div className="text-center py-12 text-gray-500"> 207 + No entries yet. Be the first to sign! 208 + </div> 209 + ) 210 + } 211 + 212 + return ( 213 + <div className="space-y-4"> 214 + {entries.map((entry, index) => ( 215 + <div 216 + key={entry.uri} 217 + className="bg-gray-100 rounded-lg p-4 border-l-4 transition-colors" 218 + style={{ borderLeftColor: getColorForIndex(index) }} 219 + > 220 + <div className="flex justify-between items-start mb-1"> 221 + <a 222 + href={`https://bsky.app/profile/${entry.authorHandle || entry.author}`} 223 + target="_blank" 224 + rel="noopener noreferrer" 225 + className="font-semibold text-gray-900 hover:underline" 226 + > 227 + {entry.authorHandle || shortenDid(entry.author)} 228 + </a> 229 + <a 230 + href={`https://bsky.app/profile/${entry.authorHandle || entry.author}`} 231 + target="_blank" 232 + rel="noopener noreferrer" 233 + className="text-gray-400 hover:text-gray-600" 234 + style={{ color: getColorForIndex(index) }} 235 + > 236 + <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2}> 237 + <path strokeLinecap="round" strokeLinejoin="round" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" /> 238 + </svg> 239 + </a> 240 + </div> 241 + <p className="text-gray-800 mb-2"> 242 + {entry.message} 243 + </p> 244 + <span className="text-sm text-gray-500"> 245 + {formatDate(entry.createdAt)} 246 + </span> 247 + </div> 248 + ))} 249 + </div> 250 + ) 251 + } 252 +
+84
src/components/sections/GuestbookPage.tsx
··· 1 + /// <reference path="../../guestbook.d.ts" /> 2 + import { useEffect, useRef } from "react" 3 + import { configureGuestbook } from "cutebook/register" 4 + import { GuestbookEntries } from "../GuestbookEntries" 5 + 6 + // Configure guestbook once 7 + let configured = false 8 + if (!configured) { 9 + const isDev = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1' 10 + const port = window.location.port || '3000' 11 + 12 + // For dev, use loopback client format matching the demo 13 + // Client ID uses http://localhost, redirect_uri uses 127.0.0.1 14 + const scope = 'atproto transition:generic' 15 + const redirectUri = isDev ? `http://127.0.0.1:${port}/guestbook` : 'https://nekomimi.pet/guestbook' 16 + const clientId = isDev 17 + ? `http://localhost?redirect_uri=${encodeURIComponent(redirectUri)}&scope=${encodeURIComponent(scope)}` 18 + : 'https://nekomimi.pet/client-metadata.json' 19 + 20 + configureGuestbook({ 21 + oauth: { 22 + clientId, 23 + redirectUri, 24 + scope, 25 + }, 26 + }) 27 + configured = true 28 + } 29 + 30 + export function GuestbookPage() { 31 + const refreshRef = useRef<(() => void) | null>(null) 32 + 33 + const handleSignCreated = () => { 34 + refreshRef.current?.() 35 + } 36 + 37 + useEffect(() => { 38 + const signElement = document.querySelector('guestbook-sign') 39 + if (signElement) { 40 + signElement.addEventListener('sign-created', handleSignCreated) 41 + return () => signElement.removeEventListener('sign-created', handleSignCreated) 42 + } 43 + }, []) 44 + 45 + return ( 46 + <div className="min-h-screen bg-gradient-to-b from-gray-50 to-gray-100 py-12 px-6"> 47 + <div className="max-w-xl mx-auto"> 48 + {/* Header */} 49 + <header className="mb-12 text-center"> 50 + <div className="inline-block mb-4"> 51 + <span className="text-5xl">๐Ÿ“–</span> 52 + </div> 53 + <h1 className="text-3xl font-light tracking-tight text-gray-900 mb-3"> 54 + Ana's Guestbook 55 + </h1> 56 + <p className="text-gray-500 font-mono text-sm"> 57 + Leave a message, say hello 58 + </p> 59 + </header> 60 + 61 + {/* Sign Form */} 62 + <div className="mb-12 bg-white rounded-2xl shadow-sm border border-gray-200/50 p-6"> 63 + <guestbook-sign did="did:plc:ttdrpj45ibqunmfhdsb4zdwq"></guestbook-sign> 64 + </div> 65 + 66 + {/* Entries Header */} 67 + <div className="flex items-center gap-3 mb-6"> 68 + <div className="h-px flex-1 bg-gradient-to-r from-transparent via-gray-300 to-transparent"></div> 69 + <span className="text-xs font-mono text-gray-400 uppercase tracking-widest"> 70 + Messages 71 + </span> 72 + <div className="h-px flex-1 bg-gradient-to-r from-transparent via-gray-300 to-transparent"></div> 73 + </div> 74 + 75 + <GuestbookEntries 76 + did="did:plc:ttdrpj45ibqunmfhdsb4zdwq" 77 + limit={50} 78 + onRefresh={(refresh) => { refreshRef.current = refresh }} 79 + /> 80 + </div> 81 + </div> 82 + ) 83 + } 84 +
+35 -24
src/components/sections/Header.tsx
··· 1 1 import type { RefObject } from "react" 2 + import { CurrentlyPlaying, LastPlayed, type AtProtoStyles } from "atproto-ui" 2 3 import { personalInfo, currentRole, skills } from "../../data/portfolio" 3 4 4 5 interface HeaderProps { 5 6 sectionRef: (el: HTMLElement | null) => void 7 + onGuestbookClick?: () => void 6 8 } 7 9 8 - export function Header({ sectionRef }: HeaderProps) { 10 + export function Header({ sectionRef, onGuestbookClick }: HeaderProps) { 9 11 const scrollToWork = () => { 10 12 document.getElementById('work')?.scrollIntoView({ behavior: 'smooth' }) 11 13 } ··· 26 28 <div className="absolute inset-0 bg-background/70"></div> 27 29 </div> 28 30 29 - <div className="grid lg:grid-cols-5 gap-12 sm:gap-16 w-full relative z-10"> 31 + <div className="grid lg:grid-cols-5 gap-12 sm:gap-16 w-full relative z-10 items-center"> 30 32 <div className="lg:col-span-3 space-y-6 sm:space-y-8"> 31 33 <div className="space-y-3 sm:space-y-2"> 32 34 <div className="text-sm text-gray-300 font-mono tracking-wider">PORTFOLIO / 2025</div> ··· 37 39 </h1> 38 40 </div> 39 41 40 - <div className="space-y-6 max-w-md"> 42 + <div className="space-y-6 max-w-md "> 41 43 <p className="text-lg sm:text-xl text-stone-200 leading-relaxed"> 42 44 {personalInfo.description.map((part, i) => { 43 45 if (part.url) { ··· 66 68 })} 67 69 </p> 68 70 71 + 69 72 <div className="space-y-4"> 70 73 <div className="flex items-center gap-2 text-sm text-gray-300"> 71 74 <div className="w-2 h-2 bg-green-500 rounded-full animate-pulse"></div> ··· 113 116 Read my blog 114 117 </a> 115 118 </div> 119 + <button 120 + onClick={onGuestbookClick} 121 + className="glass glass-hover w-full px-6 py-3 rounded-lg transition-all duration-300 inline-flex items-center justify-center gap-2 text-sm text-gray-300 hover:text-white" 122 + > 123 + <svg 124 + className="w-4 h-4" 125 + fill="none" 126 + stroke="currentColor" 127 + viewBox="0 0 24 24" 128 + strokeWidth={2} 129 + > 130 + <path 131 + strokeLinecap="round" 132 + strokeLinejoin="round" 133 + d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" 134 + /> 135 + </svg> 136 + Sign my guestbook 137 + </button> 116 138 </div> 117 139 </div> 118 140 </div> 119 141 120 - <div className="lg:col-span-2 flex flex-col justify-end space-y-6 sm:space-y-8 mt-8 lg:mt-0"> 142 + <div className="hidden lg:flex lg:col-span-2 flex-col justify-end space-y-6 sm:space-y-8 mt-8 lg:mt-0"> 121 143 <div className="space-y-4"> 122 - <div className="text-sm text-gray-300 font-mono">CURRENTLY</div> 123 - <div className="space-y-2"> 124 - <div className="text-white">{currentRole.title}</div> 125 - <div className="text-sm text-gray-300">{personalInfo.availability.location}</div> 126 - <div className="text-gray-300">@ {currentRole.company}</div> 127 - <div className="text-xs text-gray-100">{currentRole.period}</div> 128 - </div> 129 - </div> 130 - 131 - <div className="space-y-4"> 132 - <div className="text-sm text-gray-300 font-mono">FOCUS</div> 133 - <div className="flex flex-wrap gap-2"> 134 - {skills.map((skill) => ( 135 - <span 136 - key={skill} 137 - className="glass glass-hover px-3 py-1 text-xs rounded-full transition-colors duration-300" 138 - > 139 - {skill} 140 - </span> 141 - ))} 144 + <p className="text-sm text-gray-300 font-mono">IM LISTENING TO:</p> 145 + <div className="glass rounded-2xl" style={{ 146 + '--atproto-color-bg': 'transparent', 147 + '--atproto-color-border': 'transparent', 148 + '--atproto-color-bg-elevated': 'rgba(255, 255, 255, 0.20)', 149 + '--atproto-color-text': 'white', 150 + '--atproto-color-text-secondary': 'rgba(255, 255, 255, 0.80)', 151 + } as AtProtoStyles }> 152 + <CurrentlyPlaying did="nekomimi.pet"/> 142 153 </div> 143 154 </div> 144 155 </div>
+1 -1
src/components/ui/card.tsx
··· 6 6 return ( 7 7 <div 8 8 data-slot="card" 9 - className={cn("glass glass-hover text-card-foreground flex flex-col gap-6 rounded-xl py-6", className)} 9 + className={cn("text-card-foreground flex flex-col gap-6 rounded-xl py-6", className)} 10 10 {...props} 11 11 /> 12 12 );
+27
src/guestbook.d.ts
··· 1 + import type { GuestbookSignElement, GuestbookDisplayElement } from "cutebook" 2 + import type { DetailedHTMLProps, HTMLAttributes } from "react" 3 + 4 + declare module "react" { 5 + namespace JSX { 6 + interface IntrinsicElements { 7 + 'guestbook-sign': DetailedHTMLProps<HTMLAttributes<HTMLElement> & { 8 + did?: string; 9 + }, HTMLElement>; 10 + 'guestbook-display': DetailedHTMLProps<HTMLAttributes<HTMLElement> & { 11 + did?: string; 12 + limit?: string; 13 + ref?: any; 14 + }, HTMLElement>; 15 + } 16 + } 17 + } 18 + 19 + declare global { 20 + interface HTMLElementTagNameMap { 21 + 'guestbook-sign': GuestbookSignElement; 22 + 'guestbook-display': GuestbookDisplayElement; 23 + } 24 + } 25 + 26 + export {} 27 +
+2 -2
src/index.html
··· 3 3 <head> 4 4 <meta charset="UTF-8" /> 5 5 <meta name="viewport" content="width=device-width, initial-scale=1.0" /> 6 - <title>Bun + React</title> 6 + <title>nekomimi.pet</title> 7 7 <link rel="preconnect" href="https://fonts.googleapis.com" /> 8 8 <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> 9 9 <link ··· 25 25 <filter id="frosted" x="0%" y="0%" width="100%" height="100%"> 26 26 <feTurbulence 27 27 type="fractalNoise" 28 - baseFrequency="0.008 0.008" 28 + baseFrequency="0.012 0.012" 29 29 numOctaves="2" 30 30 seed="92" 31 31 result="noise"
+12
src/index.ts
··· 11 11 }); 12 12 }, 13 13 14 + // Serve client-metadata.json for OAuth 15 + "/client-metadata.json": async () => { 16 + try { 17 + const file = Bun.file("public/client-metadata.json"); 18 + return new Response(file, { 19 + headers: { "Content-Type": "application/json" }, 20 + }); 21 + } catch { 22 + return new Response("File not found", { status: 404 }); 23 + } 24 + }, 25 + 14 26 // Serve static files from public directory 15 27 "/nekomata.png": async () => { 16 28 try {
+4 -3
styles/globals.css
··· 118 118 } 119 119 120 120 .animate-bounce-slow { 121 - animation: bounce-slow 2s ease-in-out infinite; 121 + animation: bounce-slow 1s ease-in-out infinite; 122 122 } 123 123 124 124 /* Glassmorphism utilities */ ··· 130 130 --shadow-color: rgba(255, 255, 255, 0.7); 131 131 132 132 /* Painted glass */ 133 - --tint-color: rgba(255, 255, 255, 0.08); 134 - --tint-opacity: 0.4; 133 + --tint-color: rgba(255, 255, 255, 0.28); 134 + --tint-opacity: 1; 135 135 136 136 /* Background frost */ 137 137 --frost-blur: 2px; ··· 162 162 box-shadow: 163 163 /*0 0 0 2px rgba(255, 255, 255, 0.7),*/ 164 164 0 20px 40px rgba(0, 0, 0, 0.16); 165 + transform: scale(1.05); /* Scales to 105% of original size */ 165 166 } 166 167 }