feat: Initial Linkat Directory overhaul

This commit represents a complete overhaul of the project, transforming it from a generic website template into the dedicated Linkat Directory. Key changes include:

- **Project Restructuring:** Significant reorganisation of files and directories to align with the Linkat Directory's purpose.
- **Core Functionality:** Implementation of features specific to displaying and managing Linkat (Bluesky) user profiles and their associated links.
- **Component Refactoring:** Introduction of new components like `UserDirectory.svelte`, `DirectoryHeader.svelte`, `DynamicHead.svelte`, and `MultiUserLinks.svelte`.
- **Dependency Updates:** Modifications to `package.json` and `package-lock.json` to reflect new and updated dependencies.
- **Configuration:** Addition of `.env.example` and `src/lib/config/linkat-users.ts` for user configuration.
- **Styling:** Updates to `src/lib/css/app.css` and `src/lib/css/variables.css` for the new design.
- **Route Definitions:** Creation of new routes for user profiles (`src/routes/user/[did]/+layout.ts`, `src/routes/user/[did]/+page.svelte`).
- **Asset Management:** Inclusion of `static/logo.ico` and `static/logo.png`.

This change establishes the foundational structure and core features for the Linkat Directory application.

ewancroft.uk 4e0575e3 f2f78e5b

verified
+13
.env.example
··· 1 + # Linkat Directory Configuration 2 + # Copy this file to .env and update with your values 3 + 4 + # Primary user DID (required if no users configured) 5 + DIRECTORY_OWNER=did:plc:your-did-here 6 + 7 + # Multiple users (comma-separated, optional) 8 + # These users will be displayed alongside DIRECTORY_OWNER 9 + PUBLIC_LINKAT_USERS=did:plc:user1,did:web:user2,did:plc:user3 10 + 11 + # Example DIDs: 12 + # DIRECTORY_OWNER=did:plc:abc123def456ghi789jkl012mno345pqr678stu 13 + # PUBLIC_LINKAT_USERS=did:plc:user1,did:plc:user2,did:plc:user3,did:plc:user4
+51 -62
README.md
··· 1 - # Website Template 1 + # Linkat Directory 2 2 3 3 [![No Maintenance Intended](http://unmaintained.tech/badge.svg)](http://unmaintained.tech/) 4 4 5 - This repository provides a versatile frontend template, primarily designed for [WhiteWind](https://whtwnd.com/), a Markdown blog service utilising [ATProto](https://atproto.com/). It is built upon a customised version of [WhiteBreeze](https://github.com/hugeblank/whitebreeze), specifically derived from commit [ff402f3](https://github.com/ewanc26/website/commit/ff402f3460d86c40ead13294ae1ff5d8605f741c) of [my website](https://github.com/ewanc26/website). 5 + <img src="./static/logo.png" alt="Linkat Directory" width="100"/> 6 6 7 - This template offers a pre-configured starting point with a robust structure, ready for customisation across various frontend projects, though its core focus remains WhiteWind compatibility. 7 + ## Project Purpose 8 + 9 + Linkat Directory is a SvelteKit application designed to serve as a curated directory of links, primarily focusing on Bluesky profiles and content. It allows for the display of user profiles, including their Decentralised Identifiers (DIDs), handles, display names, avatars, and descriptions. The application is built with a focus on responsiveness and ease of use, providing a clean interface for discovering links. 8 10 9 11 ## Installation 10 12 11 - To commence using this template, ensure Node.js and npm are installed on your system. 13 + To set up the Linkat Directory locally, follow these steps: 12 14 13 - ### Prerequisites 15 + 1. **Clone the repository:** 16 + ```bash 17 + git clone git@github.com:ewanc26/linkat-directory.git 18 + cd linkat-directory 19 + ``` 14 20 15 - - Node.js (LTS version recommended) 16 - - npm (comes with Node.js) 17 - - Docker and Docker Compose (for Dockerised deployment) 21 + 2. **Install dependencies:** 22 + ```bash 23 + npm install 24 + ``` 18 25 19 - ### Environment Variables 20 - 21 - Prior to running the application, configure the following environment variables within a `.env` file located in the project root: 22 - 23 - ```ini 24 - PUBLIC_ATPROTOCOL_USER="myhandle.bsky.social" # Your handle, or DID 25 - ``` 26 - #### Note 27 - 28 - You should also add your DID to the `.static/.well-known/atproto-did` file if you want to use your domain as your AT Protocol handle. 29 - 30 - #### Optional Environment Variables 31 - 32 - - `PUBLIC_LASTFM_USERNAME`: Required for the Now Playing (Last.fm) feature in `src/lib/components/profile/Status.svelte`. 33 - - `PUBLIC_ACTIVITYPUB_USER=@user@server.tld`: Enables ActivityPub compatibility for improved content sharing and discoverability. 26 + 3. **Configure environment variables:** 27 + Create a `.env` file in the project root based on `.env.example`. At a minimum, you should define `DIRECTORY_OWNER` or `PUBLIC_LINKAT_USERS`. 28 + 29 + Example for a single directory owner: 30 + ``` 31 + DIRECTORY_OWNER=did:plc:your-did-here 32 + ``` 33 + 34 + Example for multiple users: 35 + ``` 36 + PUBLIC_LINKAT_USERS=did:plc:user1,did:web:user2 37 + ``` 34 38 35 - #### Embed Images 36 - 37 - While the `./static/embed/` directory is currently empty, it is intended for social media embed images. By default, the system will look for `blog.png` or `main.png` within this directory. For optimal display, these images should have dimensions of 630x1200 pixels. 39 + 4. **Run the development server:** 40 + ```bash 41 + npm run dev 42 + ``` 43 + The application will be accessible at `http://localhost:5173`. 38 44 39 45 ## Usage 40 46 41 - ### Development 42 - 43 - To run the project in development mode: 44 - 45 - ```sh 46 - npm install 47 - npm run dev 48 - ``` 49 - 50 - ### Production 51 - 52 - For optimal production deployment, the following record types are required in your [AT Protocol repository](https://atproto.com/specs/repository): 53 - 54 - #### Required Records 47 + Once the application is running, you can: 55 48 56 - - `app.bsky.actor.profile`: Your profile. 57 - - `com.whtwnd.blog.entry`: Your blog posts. 58 - - `blue.linkat.board`: Your links. 59 - 60 - ### Deployment 49 + - Browse the main directory page to see configured users. 50 + - View individual user profiles by navigating to `/user/[did]`, where `[did]` is the user's Decentralised Identifier. 51 + - The application dynamically generates Open Graph and Twitter metadata for improved social sharing. 61 52 62 - #### Standalone 53 + ## Project Structure 63 54 64 - To build and run the project as a standalone application: 55 + Key directories and files: 65 56 66 - ```sh 67 - npm install 68 - npm run build 69 - node index.js 70 - ``` 57 + - `src/routes/`: Contains SvelteKit routes, including the main page (`+page.svelte`) and user profile pages (`user/[did]/+page.svelte`). 58 + - `src/lib/components/`: Reusable Svelte components, such as `DynamicHead.svelte` for managing dynamic `<head>` content, and profile-related components. 59 + - `src/lib/css/`: Global CSS styles, including `app.css` (for general styling) and `variables.css` (for CSS variables). 60 + - `src/lib/utils/`: Utility functions, such as caching mechanisms. 61 + - `src/lib/profile/profile.ts`: Logic for fetching and processing user profile data from Bluesky. 71 62 72 - Environment variables can be set before the last command, and the port can be configured with the `PORT` variable. 63 + ## Contributing 73 64 74 - #### Dockerised 65 + Contributions are welcome! Please ensure your code adheres to the project's coding standards, including British English for comments and documentation, and responsive design principles. 75 66 76 - To deploy using Docker: 67 + ## Credits 77 68 78 - 1. Modify `compose.yaml` to change the host port if necessary. 79 - 2. Run the following command: 69 + This project utilises data and concepts from: 80 70 81 - ```sh 82 - docker compose up -d 83 - ``` 71 + - [linkat.blue](https://linkat.blue) by [mkizka.dev](https://bsky.app/profile/did:plc:4gow62pk3vqpuwiwaslcwisa) 72 + - [atproto.com](https://atproto.com) by [Bluesky](https://bsky.social) 84 73 85 - ## Licensing 74 + ## License 86 75 87 - This project is a template based on WhiteBreeze. For comprehensive licensing details, please consult the `LICENSE` file within this repository. 76 + This project is licensed under the [GNU Affero General Public License Version 3](LICENSE).
+896 -36
package-lock.json
··· 22 22 "unified": "^11.0.5" 23 23 }, 24 24 "devDependencies": { 25 - "@sveltejs/adapter-vercel": "^5.7.0", 25 + "@sveltejs/adapter-vercel": "^5.8.1", 26 26 "@sveltejs/kit": "^2.24.0", 27 27 "@sveltejs/vite-plugin-svelte": "^4.0.4", 28 28 "@tailwindcss/forms": "^0.5.9", ··· 65 65 "node": ">=6.0.0" 66 66 } 67 67 }, 68 + "node_modules/@esbuild/aix-ppc64": { 69 + "version": "0.25.8", 70 + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.8.tgz", 71 + "integrity": "sha512-urAvrUedIqEiFR3FYSLTWQgLu5tb+m0qZw0NBEasUeo6wuqatkMDaRT+1uABiGXEu5vqgPd7FGE1BhsAIy9QVA==", 72 + "cpu": [ 73 + "ppc64" 74 + ], 75 + "dev": true, 76 + "license": "MIT", 77 + "optional": true, 78 + "os": [ 79 + "aix" 80 + ], 81 + "engines": { 82 + "node": ">=18" 83 + } 84 + }, 85 + "node_modules/@esbuild/android-arm": { 86 + "version": "0.25.8", 87 + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.8.tgz", 88 + "integrity": "sha512-RONsAvGCz5oWyePVnLdZY/HHwA++nxYWIX1atInlaW6SEkwq6XkP3+cb825EUcRs5Vss/lGh/2YxAb5xqc07Uw==", 89 + "cpu": [ 90 + "arm" 91 + ], 92 + "dev": true, 93 + "license": "MIT", 94 + "optional": true, 95 + "os": [ 96 + "android" 97 + ], 98 + "engines": { 99 + "node": ">=18" 100 + } 101 + }, 102 + "node_modules/@esbuild/android-arm64": { 103 + "version": "0.25.8", 104 + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.8.tgz", 105 + "integrity": "sha512-OD3p7LYzWpLhZEyATcTSJ67qB5D+20vbtr6vHlHWSQYhKtzUYrETuWThmzFpZtFsBIxRvhO07+UgVA9m0i/O1w==", 106 + "cpu": [ 107 + "arm64" 108 + ], 109 + "dev": true, 110 + "license": "MIT", 111 + "optional": true, 112 + "os": [ 113 + "android" 114 + ], 115 + "engines": { 116 + "node": ">=18" 117 + } 118 + }, 119 + "node_modules/@esbuild/android-x64": { 120 + "version": "0.25.8", 121 + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.8.tgz", 122 + "integrity": "sha512-yJAVPklM5+4+9dTeKwHOaA+LQkmrKFX96BM0A/2zQrbS6ENCmxc4OVoBs5dPkCCak2roAD+jKCdnmOqKszPkjA==", 123 + "cpu": [ 124 + "x64" 125 + ], 126 + "dev": true, 127 + "license": "MIT", 128 + "optional": true, 129 + "os": [ 130 + "android" 131 + ], 132 + "engines": { 133 + "node": ">=18" 134 + } 135 + }, 68 136 "node_modules/@esbuild/darwin-arm64": { 69 137 "version": "0.21.5", 70 138 "cpu": [ ··· 80 148 "node": ">=12" 81 149 } 82 150 }, 151 + "node_modules/@esbuild/darwin-x64": { 152 + "version": "0.25.8", 153 + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.8.tgz", 154 + "integrity": "sha512-Vh2gLxxHnuoQ+GjPNvDSDRpoBCUzY4Pu0kBqMBDlK4fuWbKgGtmDIeEC081xi26PPjn+1tct+Bh8FjyLlw1Zlg==", 155 + "cpu": [ 156 + "x64" 157 + ], 158 + "dev": true, 159 + "license": "MIT", 160 + "optional": true, 161 + "os": [ 162 + "darwin" 163 + ], 164 + "engines": { 165 + "node": ">=18" 166 + } 167 + }, 168 + "node_modules/@esbuild/freebsd-arm64": { 169 + "version": "0.25.8", 170 + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.8.tgz", 171 + "integrity": "sha512-YPJ7hDQ9DnNe5vxOm6jaie9QsTwcKedPvizTVlqWG9GBSq+BuyWEDazlGaDTC5NGU4QJd666V0yqCBL2oWKPfA==", 172 + "cpu": [ 173 + "arm64" 174 + ], 175 + "dev": true, 176 + "license": "MIT", 177 + "optional": true, 178 + "os": [ 179 + "freebsd" 180 + ], 181 + "engines": { 182 + "node": ">=18" 183 + } 184 + }, 185 + "node_modules/@esbuild/freebsd-x64": { 186 + "version": "0.25.8", 187 + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.8.tgz", 188 + "integrity": "sha512-MmaEXxQRdXNFsRN/KcIimLnSJrk2r5H8v+WVafRWz5xdSVmWLoITZQXcgehI2ZE6gioE6HirAEToM/RvFBeuhw==", 189 + "cpu": [ 190 + "x64" 191 + ], 192 + "dev": true, 193 + "license": "MIT", 194 + "optional": true, 195 + "os": [ 196 + "freebsd" 197 + ], 198 + "engines": { 199 + "node": ">=18" 200 + } 201 + }, 202 + "node_modules/@esbuild/linux-arm": { 203 + "version": "0.25.8", 204 + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.8.tgz", 205 + "integrity": "sha512-FuzEP9BixzZohl1kLf76KEVOsxtIBFwCaLupVuk4eFVnOZfU+Wsn+x5Ryam7nILV2pkq2TqQM9EZPsOBuMC+kg==", 206 + "cpu": [ 207 + "arm" 208 + ], 209 + "dev": true, 210 + "license": "MIT", 211 + "optional": true, 212 + "os": [ 213 + "linux" 214 + ], 215 + "engines": { 216 + "node": ">=18" 217 + } 218 + }, 219 + "node_modules/@esbuild/linux-arm64": { 220 + "version": "0.25.8", 221 + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.8.tgz", 222 + "integrity": "sha512-WIgg00ARWv/uYLU7lsuDK00d/hHSfES5BzdWAdAig1ioV5kaFNrtK8EqGcUBJhYqotlUByUKz5Qo6u8tt7iD/w==", 223 + "cpu": [ 224 + "arm64" 225 + ], 226 + "dev": true, 227 + "license": "MIT", 228 + "optional": true, 229 + "os": [ 230 + "linux" 231 + ], 232 + "engines": { 233 + "node": ">=18" 234 + } 235 + }, 236 + "node_modules/@esbuild/linux-ia32": { 237 + "version": "0.25.8", 238 + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.8.tgz", 239 + "integrity": "sha512-A1D9YzRX1i+1AJZuFFUMP1E9fMaYY+GnSQil9Tlw05utlE86EKTUA7RjwHDkEitmLYiFsRd9HwKBPEftNdBfjg==", 240 + "cpu": [ 241 + "ia32" 242 + ], 243 + "dev": true, 244 + "license": "MIT", 245 + "optional": true, 246 + "os": [ 247 + "linux" 248 + ], 249 + "engines": { 250 + "node": ">=18" 251 + } 252 + }, 253 + "node_modules/@esbuild/linux-loong64": { 254 + "version": "0.25.8", 255 + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.8.tgz", 256 + "integrity": "sha512-O7k1J/dwHkY1RMVvglFHl1HzutGEFFZ3kNiDMSOyUrB7WcoHGf96Sh+64nTRT26l3GMbCW01Ekh/ThKM5iI7hQ==", 257 + "cpu": [ 258 + "loong64" 259 + ], 260 + "dev": true, 261 + "license": "MIT", 262 + "optional": true, 263 + "os": [ 264 + "linux" 265 + ], 266 + "engines": { 267 + "node": ">=18" 268 + } 269 + }, 270 + "node_modules/@esbuild/linux-mips64el": { 271 + "version": "0.25.8", 272 + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.8.tgz", 273 + "integrity": "sha512-uv+dqfRazte3BzfMp8PAQXmdGHQt2oC/y2ovwpTteqrMx2lwaksiFZ/bdkXJC19ttTvNXBuWH53zy/aTj1FgGw==", 274 + "cpu": [ 275 + "mips64el" 276 + ], 277 + "dev": true, 278 + "license": "MIT", 279 + "optional": true, 280 + "os": [ 281 + "linux" 282 + ], 283 + "engines": { 284 + "node": ">=18" 285 + } 286 + }, 287 + "node_modules/@esbuild/linux-ppc64": { 288 + "version": "0.25.8", 289 + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.8.tgz", 290 + "integrity": "sha512-GyG0KcMi1GBavP5JgAkkstMGyMholMDybAf8wF5A70CALlDM2p/f7YFE7H92eDeH/VBtFJA5MT4nRPDGg4JuzQ==", 291 + "cpu": [ 292 + "ppc64" 293 + ], 294 + "dev": true, 295 + "license": "MIT", 296 + "optional": true, 297 + "os": [ 298 + "linux" 299 + ], 300 + "engines": { 301 + "node": ">=18" 302 + } 303 + }, 304 + "node_modules/@esbuild/linux-riscv64": { 305 + "version": "0.25.8", 306 + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.8.tgz", 307 + "integrity": "sha512-rAqDYFv3yzMrq7GIcen3XP7TUEG/4LK86LUPMIz6RT8A6pRIDn0sDcvjudVZBiiTcZCY9y2SgYX2lgK3AF+1eg==", 308 + "cpu": [ 309 + "riscv64" 310 + ], 311 + "dev": true, 312 + "license": "MIT", 313 + "optional": true, 314 + "os": [ 315 + "linux" 316 + ], 317 + "engines": { 318 + "node": ">=18" 319 + } 320 + }, 321 + "node_modules/@esbuild/linux-s390x": { 322 + "version": "0.25.8", 323 + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.8.tgz", 324 + "integrity": "sha512-Xutvh6VjlbcHpsIIbwY8GVRbwoviWT19tFhgdA7DlenLGC/mbc3lBoVb7jxj9Z+eyGqvcnSyIltYUrkKzWqSvg==", 325 + "cpu": [ 326 + "s390x" 327 + ], 328 + "dev": true, 329 + "license": "MIT", 330 + "optional": true, 331 + "os": [ 332 + "linux" 333 + ], 334 + "engines": { 335 + "node": ">=18" 336 + } 337 + }, 338 + "node_modules/@esbuild/linux-x64": { 339 + "version": "0.25.8", 340 + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.8.tgz", 341 + "integrity": "sha512-ASFQhgY4ElXh3nDcOMTkQero4b1lgubskNlhIfJrsH5OKZXDpUAKBlNS0Kx81jwOBp+HCeZqmoJuihTv57/jvQ==", 342 + "cpu": [ 343 + "x64" 344 + ], 345 + "dev": true, 346 + "license": "MIT", 347 + "optional": true, 348 + "os": [ 349 + "linux" 350 + ], 351 + "engines": { 352 + "node": ">=18" 353 + } 354 + }, 355 + "node_modules/@esbuild/netbsd-arm64": { 356 + "version": "0.25.8", 357 + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.8.tgz", 358 + "integrity": "sha512-d1KfruIeohqAi6SA+gENMuObDbEjn22olAR7egqnkCD9DGBG0wsEARotkLgXDu6c4ncgWTZJtN5vcgxzWRMzcw==", 359 + "cpu": [ 360 + "arm64" 361 + ], 362 + "dev": true, 363 + "license": "MIT", 364 + "optional": true, 365 + "os": [ 366 + "netbsd" 367 + ], 368 + "engines": { 369 + "node": ">=18" 370 + } 371 + }, 372 + "node_modules/@esbuild/netbsd-x64": { 373 + "version": "0.25.8", 374 + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.8.tgz", 375 + "integrity": "sha512-nVDCkrvx2ua+XQNyfrujIG38+YGyuy2Ru9kKVNyh5jAys6n+l44tTtToqHjino2My8VAY6Lw9H7RI73XFi66Cg==", 376 + "cpu": [ 377 + "x64" 378 + ], 379 + "dev": true, 380 + "license": "MIT", 381 + "optional": true, 382 + "os": [ 383 + "netbsd" 384 + ], 385 + "engines": { 386 + "node": ">=18" 387 + } 388 + }, 389 + "node_modules/@esbuild/openbsd-arm64": { 390 + "version": "0.25.8", 391 + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.8.tgz", 392 + "integrity": "sha512-j8HgrDuSJFAujkivSMSfPQSAa5Fxbvk4rgNAS5i3K+r8s1X0p1uOO2Hl2xNsGFppOeHOLAVgYwDVlmxhq5h+SQ==", 393 + "cpu": [ 394 + "arm64" 395 + ], 396 + "dev": true, 397 + "license": "MIT", 398 + "optional": true, 399 + "os": [ 400 + "openbsd" 401 + ], 402 + "engines": { 403 + "node": ">=18" 404 + } 405 + }, 406 + "node_modules/@esbuild/openbsd-x64": { 407 + "version": "0.25.8", 408 + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.8.tgz", 409 + "integrity": "sha512-1h8MUAwa0VhNCDp6Af0HToI2TJFAn1uqT9Al6DJVzdIBAd21m/G0Yfc77KDM3uF3T/YaOgQq3qTJHPbTOInaIQ==", 410 + "cpu": [ 411 + "x64" 412 + ], 413 + "dev": true, 414 + "license": "MIT", 415 + "optional": true, 416 + "os": [ 417 + "openbsd" 418 + ], 419 + "engines": { 420 + "node": ">=18" 421 + } 422 + }, 423 + "node_modules/@esbuild/openharmony-arm64": { 424 + "version": "0.25.8", 425 + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.8.tgz", 426 + "integrity": "sha512-r2nVa5SIK9tSWd0kJd9HCffnDHKchTGikb//9c7HX+r+wHYCpQrSgxhlY6KWV1nFo1l4KFbsMlHk+L6fekLsUg==", 427 + "cpu": [ 428 + "arm64" 429 + ], 430 + "dev": true, 431 + "license": "MIT", 432 + "optional": true, 433 + "os": [ 434 + "openharmony" 435 + ], 436 + "engines": { 437 + "node": ">=18" 438 + } 439 + }, 440 + "node_modules/@esbuild/sunos-x64": { 441 + "version": "0.25.8", 442 + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.8.tgz", 443 + "integrity": "sha512-zUlaP2S12YhQ2UzUfcCuMDHQFJyKABkAjvO5YSndMiIkMimPmxA+BYSBikWgsRpvyxuRnow4nS5NPnf9fpv41w==", 444 + "cpu": [ 445 + "x64" 446 + ], 447 + "dev": true, 448 + "license": "MIT", 449 + "optional": true, 450 + "os": [ 451 + "sunos" 452 + ], 453 + "engines": { 454 + "node": ">=18" 455 + } 456 + }, 457 + "node_modules/@esbuild/win32-arm64": { 458 + "version": "0.25.8", 459 + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.8.tgz", 460 + "integrity": "sha512-YEGFFWESlPva8hGL+zvj2z/SaK+pH0SwOM0Nc/d+rVnW7GSTFlLBGzZkuSU9kFIGIo8q9X3ucpZhu8PDN5A2sQ==", 461 + "cpu": [ 462 + "arm64" 463 + ], 464 + "dev": true, 465 + "license": "MIT", 466 + "optional": true, 467 + "os": [ 468 + "win32" 469 + ], 470 + "engines": { 471 + "node": ">=18" 472 + } 473 + }, 474 + "node_modules/@esbuild/win32-ia32": { 475 + "version": "0.25.8", 476 + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.8.tgz", 477 + "integrity": "sha512-hiGgGC6KZ5LZz58OL/+qVVoZiuZlUYlYHNAmczOm7bs2oE1XriPFi5ZHHrS8ACpV5EjySrnoCKmcbQMN+ojnHg==", 478 + "cpu": [ 479 + "ia32" 480 + ], 481 + "dev": true, 482 + "license": "MIT", 483 + "optional": true, 484 + "os": [ 485 + "win32" 486 + ], 487 + "engines": { 488 + "node": ">=18" 489 + } 490 + }, 491 + "node_modules/@esbuild/win32-x64": { 492 + "version": "0.25.8", 493 + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.8.tgz", 494 + "integrity": "sha512-cn3Yr7+OaaZq1c+2pe+8yxC8E144SReCQjN6/2ynubzYjvyqZjTXfQJpAcQpsdJq3My7XADANiYGHoFC69pLQw==", 495 + "cpu": [ 496 + "x64" 497 + ], 498 + "dev": true, 499 + "license": "MIT", 500 + "optional": true, 501 + "os": [ 502 + "win32" 503 + ], 504 + "engines": { 505 + "node": ">=18" 506 + } 507 + }, 83 508 "node_modules/@eslint-community/eslint-utils": { 84 509 "version": "4.4.1", 85 510 "dev": true, ··· 271 696 }, 272 697 "node_modules/@isaacs/fs-minipass": { 273 698 "version": "4.0.1", 699 + "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", 700 + "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", 274 701 "dev": true, 275 702 "license": "ISC", 276 703 "dependencies": { ··· 325 752 }, 326 753 "node_modules/@mapbox/node-pre-gyp": { 327 754 "version": "2.0.0", 755 + "resolved": "https://registry.npmjs.org/@mapbox/node-pre-gyp/-/node-pre-gyp-2.0.0.tgz", 756 + "integrity": "sha512-llMXd39jtP0HpQLVI37Bf1m2ADlEb35GYSh1SDSLsBhR+5iCxiNGlT31yqbNtVHygHAtMy6dWFERpU2JgufhPg==", 328 757 "dev": true, 329 758 "license": "BSD-3-Clause", 330 759 "dependencies": { ··· 425 854 } 426 855 }, 427 856 "node_modules/@rollup/pluginutils": { 428 - "version": "5.1.3", 857 + "version": "5.2.0", 858 + "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.2.0.tgz", 859 + "integrity": "sha512-qWJ2ZTbmumwiLFomfzTyt5Kng4hwPi9rwCYN4SHb6eaRU1KNO4ccxINHr/VhH4GgPlt1XfSTLX2LBTme8ne4Zw==", 429 860 "dev": true, 430 861 "license": "MIT", 431 862 "dependencies": { ··· 480 911 } 481 912 }, 482 913 "node_modules/@sveltejs/adapter-vercel": { 483 - "version": "5.7.0", 914 + "version": "5.8.1", 915 + "resolved": "https://registry.npmjs.org/@sveltejs/adapter-vercel/-/adapter-vercel-5.8.1.tgz", 916 + "integrity": "sha512-5G4+KEWHXU2FC2sRWtauJPgDTC1vRuhnK6wi6+cKX96G7nBXM/mDG1cO7joJS1PbDEUQ9kXIgZ2XgsimOLn+qQ==", 484 917 "dev": true, 485 918 "license": "MIT", 486 919 "dependencies": { 487 - "@vercel/nft": "^0.29.2", 488 - "esbuild": "^0.24.0" 920 + "@vercel/nft": "^0.30.0", 921 + "esbuild": "^0.25.4" 489 922 }, 490 923 "peerDependencies": { 491 924 "@sveltejs/kit": "^2.4.0" 492 925 } 493 926 }, 494 927 "node_modules/@sveltejs/adapter-vercel/node_modules/@esbuild/darwin-arm64": { 495 - "version": "0.24.2", 928 + "version": "0.25.8", 929 + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.8.tgz", 930 + "integrity": "sha512-Jw0mxgIaYX6R8ODrdkLLPwBqHTtYHJSmzzd+QeytSugzQ0Vg4c5rDky5VgkoowbZQahCbsv1rT1KW72MPIkevw==", 496 931 "cpu": [ 497 932 "arm64" 498 933 ], ··· 507 942 } 508 943 }, 509 944 "node_modules/@sveltejs/adapter-vercel/node_modules/esbuild": { 510 - "version": "0.24.2", 945 + "version": "0.25.8", 946 + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.8.tgz", 947 + "integrity": "sha512-vVC0USHGtMi8+R4Kz8rt6JhEWLxsv9Rnu/lGYbPR8u47B+DCBksq9JarW0zOO7bs37hyOK1l2/oqtbciutL5+Q==", 511 948 "dev": true, 512 949 "hasInstallScript": true, 513 950 "license": "MIT", ··· 518 955 "node": ">=18" 519 956 }, 520 957 "optionalDependencies": { 521 - "@esbuild/aix-ppc64": "0.24.2", 522 - "@esbuild/android-arm": "0.24.2", 523 - "@esbuild/android-arm64": "0.24.2", 524 - "@esbuild/android-x64": "0.24.2", 525 - "@esbuild/darwin-arm64": "0.24.2", 526 - "@esbuild/darwin-x64": "0.24.2", 527 - "@esbuild/freebsd-arm64": "0.24.2", 528 - "@esbuild/freebsd-x64": "0.24.2", 529 - "@esbuild/linux-arm": "0.24.2", 530 - "@esbuild/linux-arm64": "0.24.2", 531 - "@esbuild/linux-ia32": "0.24.2", 532 - "@esbuild/linux-loong64": "0.24.2", 533 - "@esbuild/linux-mips64el": "0.24.2", 534 - "@esbuild/linux-ppc64": "0.24.2", 535 - "@esbuild/linux-riscv64": "0.24.2", 536 - "@esbuild/linux-s390x": "0.24.2", 537 - "@esbuild/linux-x64": "0.24.2", 538 - "@esbuild/netbsd-arm64": "0.24.2", 539 - "@esbuild/netbsd-x64": "0.24.2", 540 - "@esbuild/openbsd-arm64": "0.24.2", 541 - "@esbuild/openbsd-x64": "0.24.2", 542 - "@esbuild/sunos-x64": "0.24.2", 543 - "@esbuild/win32-arm64": "0.24.2", 544 - "@esbuild/win32-ia32": "0.24.2", 545 - "@esbuild/win32-x64": "0.24.2" 958 + "@esbuild/aix-ppc64": "0.25.8", 959 + "@esbuild/android-arm": "0.25.8", 960 + "@esbuild/android-arm64": "0.25.8", 961 + "@esbuild/android-x64": "0.25.8", 962 + "@esbuild/darwin-arm64": "0.25.8", 963 + "@esbuild/darwin-x64": "0.25.8", 964 + "@esbuild/freebsd-arm64": "0.25.8", 965 + "@esbuild/freebsd-x64": "0.25.8", 966 + "@esbuild/linux-arm": "0.25.8", 967 + "@esbuild/linux-arm64": "0.25.8", 968 + "@esbuild/linux-ia32": "0.25.8", 969 + "@esbuild/linux-loong64": "0.25.8", 970 + "@esbuild/linux-mips64el": "0.25.8", 971 + "@esbuild/linux-ppc64": "0.25.8", 972 + "@esbuild/linux-riscv64": "0.25.8", 973 + "@esbuild/linux-s390x": "0.25.8", 974 + "@esbuild/linux-x64": "0.25.8", 975 + "@esbuild/netbsd-arm64": "0.25.8", 976 + "@esbuild/netbsd-x64": "0.25.8", 977 + "@esbuild/openbsd-arm64": "0.25.8", 978 + "@esbuild/openbsd-x64": "0.25.8", 979 + "@esbuild/openharmony-arm64": "0.25.8", 980 + "@esbuild/sunos-x64": "0.25.8", 981 + "@esbuild/win32-arm64": "0.25.8", 982 + "@esbuild/win32-ia32": "0.25.8", 983 + "@esbuild/win32-x64": "0.25.8" 546 984 } 547 985 }, 548 986 "node_modules/@sveltejs/kit": { ··· 944 1382 "license": "ISC" 945 1383 }, 946 1384 "node_modules/@vercel/nft": { 947 - "version": "0.29.2", 1385 + "version": "0.30.0", 1386 + "resolved": "https://registry.npmjs.org/@vercel/nft/-/nft-0.30.0.tgz", 1387 + "integrity": "sha512-xVye7Z0riD9czsMuEJYpFqm2FR33r3euYaFzuEPCoUtYuDwmus3rJfKtcFU7Df+pgj8p4zs78x5lOWYoLNr+7Q==", 948 1388 "dev": true, 949 1389 "license": "MIT", 950 1390 "dependencies": { ··· 970 1410 }, 971 1411 "node_modules/@vercel/nft/node_modules/resolve-from": { 972 1412 "version": "5.0.0", 1413 + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", 1414 + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", 973 1415 "dev": true, 974 1416 "license": "MIT", 975 1417 "engines": { ··· 977 1419 } 978 1420 }, 979 1421 "node_modules/abbrev": { 980 - "version": "3.0.0", 1422 + "version": "3.0.1", 1423 + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-3.0.1.tgz", 1424 + "integrity": "sha512-AO2ac6pjRB3SJmGJo+v5/aK6Omggp6fsLrs6wN9bd35ulu4cCwaAU9+7ZhXjeqHVkaHThLuzH0nZr0YpCDhygg==", 981 1425 "dev": true, 982 1426 "license": "ISC", 983 1427 "engines": { ··· 997 1441 }, 998 1442 "node_modules/acorn-import-attributes": { 999 1443 "version": "1.9.5", 1444 + "resolved": "https://registry.npmjs.org/acorn-import-attributes/-/acorn-import-attributes-1.9.5.tgz", 1445 + "integrity": "sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ==", 1000 1446 "dev": true, 1001 1447 "license": "MIT", 1002 1448 "peerDependencies": { ··· 1012 1458 } 1013 1459 }, 1014 1460 "node_modules/agent-base": { 1015 - "version": "7.1.3", 1461 + "version": "7.1.4", 1462 + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", 1463 + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", 1016 1464 "dev": true, 1017 1465 "license": "MIT", 1018 1466 "engines": { ··· 1107 1555 }, 1108 1556 "node_modules/async-sema": { 1109 1557 "version": "3.1.1", 1558 + "resolved": "https://registry.npmjs.org/async-sema/-/async-sema-3.1.1.tgz", 1559 + "integrity": "sha512-tLRNUXati5MFePdAk8dw7Qt7DpxPB60ofAgn8WRhW6a2rcimZnYBP9oxHiv0OHy+Wz7kPMG+t4LGdt31+4EmGg==", 1110 1560 "dev": true, 1111 1561 "license": "MIT" 1112 1562 }, ··· 1187 1637 }, 1188 1638 "node_modules/bindings": { 1189 1639 "version": "1.5.0", 1640 + "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", 1641 + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", 1190 1642 "dev": true, 1191 1643 "license": "MIT", 1192 1644 "dependencies": { ··· 1349 1801 }, 1350 1802 "node_modules/chownr": { 1351 1803 "version": "3.0.0", 1804 + "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", 1805 + "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", 1352 1806 "dev": true, 1353 1807 "license": "BlueOak-1.0.0", 1354 1808 "engines": { ··· 1401 1855 }, 1402 1856 "node_modules/consola": { 1403 1857 "version": "3.4.2", 1858 + "resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz", 1859 + "integrity": "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==", 1404 1860 "dev": true, 1405 1861 "license": "MIT", 1406 1862 "engines": { ··· 1515 1971 } 1516 1972 }, 1517 1973 "node_modules/detect-libc": { 1518 - "version": "2.0.3", 1974 + "version": "2.0.4", 1975 + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.4.tgz", 1976 + "integrity": "sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA==", 1519 1977 "dev": true, 1520 1978 "license": "Apache-2.0", 1521 1979 "engines": { ··· 1657 2115 "@esbuild/win32-x64": "0.21.5" 1658 2116 } 1659 2117 }, 2118 + "node_modules/esbuild/node_modules/@esbuild/aix-ppc64": { 2119 + "version": "0.21.5", 2120 + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", 2121 + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", 2122 + "cpu": [ 2123 + "ppc64" 2124 + ], 2125 + "dev": true, 2126 + "license": "MIT", 2127 + "optional": true, 2128 + "os": [ 2129 + "aix" 2130 + ], 2131 + "engines": { 2132 + "node": ">=12" 2133 + } 2134 + }, 2135 + "node_modules/esbuild/node_modules/@esbuild/android-arm": { 2136 + "version": "0.21.5", 2137 + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", 2138 + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", 2139 + "cpu": [ 2140 + "arm" 2141 + ], 2142 + "dev": true, 2143 + "license": "MIT", 2144 + "optional": true, 2145 + "os": [ 2146 + "android" 2147 + ], 2148 + "engines": { 2149 + "node": ">=12" 2150 + } 2151 + }, 2152 + "node_modules/esbuild/node_modules/@esbuild/android-arm64": { 2153 + "version": "0.21.5", 2154 + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", 2155 + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", 2156 + "cpu": [ 2157 + "arm64" 2158 + ], 2159 + "dev": true, 2160 + "license": "MIT", 2161 + "optional": true, 2162 + "os": [ 2163 + "android" 2164 + ], 2165 + "engines": { 2166 + "node": ">=12" 2167 + } 2168 + }, 2169 + "node_modules/esbuild/node_modules/@esbuild/android-x64": { 2170 + "version": "0.21.5", 2171 + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", 2172 + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", 2173 + "cpu": [ 2174 + "x64" 2175 + ], 2176 + "dev": true, 2177 + "license": "MIT", 2178 + "optional": true, 2179 + "os": [ 2180 + "android" 2181 + ], 2182 + "engines": { 2183 + "node": ">=12" 2184 + } 2185 + }, 2186 + "node_modules/esbuild/node_modules/@esbuild/darwin-x64": { 2187 + "version": "0.21.5", 2188 + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", 2189 + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", 2190 + "cpu": [ 2191 + "x64" 2192 + ], 2193 + "dev": true, 2194 + "license": "MIT", 2195 + "optional": true, 2196 + "os": [ 2197 + "darwin" 2198 + ], 2199 + "engines": { 2200 + "node": ">=12" 2201 + } 2202 + }, 2203 + "node_modules/esbuild/node_modules/@esbuild/freebsd-arm64": { 2204 + "version": "0.21.5", 2205 + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", 2206 + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", 2207 + "cpu": [ 2208 + "arm64" 2209 + ], 2210 + "dev": true, 2211 + "license": "MIT", 2212 + "optional": true, 2213 + "os": [ 2214 + "freebsd" 2215 + ], 2216 + "engines": { 2217 + "node": ">=12" 2218 + } 2219 + }, 2220 + "node_modules/esbuild/node_modules/@esbuild/freebsd-x64": { 2221 + "version": "0.21.5", 2222 + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", 2223 + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", 2224 + "cpu": [ 2225 + "x64" 2226 + ], 2227 + "dev": true, 2228 + "license": "MIT", 2229 + "optional": true, 2230 + "os": [ 2231 + "freebsd" 2232 + ], 2233 + "engines": { 2234 + "node": ">=12" 2235 + } 2236 + }, 2237 + "node_modules/esbuild/node_modules/@esbuild/linux-arm": { 2238 + "version": "0.21.5", 2239 + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", 2240 + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", 2241 + "cpu": [ 2242 + "arm" 2243 + ], 2244 + "dev": true, 2245 + "license": "MIT", 2246 + "optional": true, 2247 + "os": [ 2248 + "linux" 2249 + ], 2250 + "engines": { 2251 + "node": ">=12" 2252 + } 2253 + }, 2254 + "node_modules/esbuild/node_modules/@esbuild/linux-arm64": { 2255 + "version": "0.21.5", 2256 + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", 2257 + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", 2258 + "cpu": [ 2259 + "arm64" 2260 + ], 2261 + "dev": true, 2262 + "license": "MIT", 2263 + "optional": true, 2264 + "os": [ 2265 + "linux" 2266 + ], 2267 + "engines": { 2268 + "node": ">=12" 2269 + } 2270 + }, 2271 + "node_modules/esbuild/node_modules/@esbuild/linux-ia32": { 2272 + "version": "0.21.5", 2273 + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", 2274 + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", 2275 + "cpu": [ 2276 + "ia32" 2277 + ], 2278 + "dev": true, 2279 + "license": "MIT", 2280 + "optional": true, 2281 + "os": [ 2282 + "linux" 2283 + ], 2284 + "engines": { 2285 + "node": ">=12" 2286 + } 2287 + }, 2288 + "node_modules/esbuild/node_modules/@esbuild/linux-loong64": { 2289 + "version": "0.21.5", 2290 + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", 2291 + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", 2292 + "cpu": [ 2293 + "loong64" 2294 + ], 2295 + "dev": true, 2296 + "license": "MIT", 2297 + "optional": true, 2298 + "os": [ 2299 + "linux" 2300 + ], 2301 + "engines": { 2302 + "node": ">=12" 2303 + } 2304 + }, 2305 + "node_modules/esbuild/node_modules/@esbuild/linux-mips64el": { 2306 + "version": "0.21.5", 2307 + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", 2308 + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", 2309 + "cpu": [ 2310 + "mips64el" 2311 + ], 2312 + "dev": true, 2313 + "license": "MIT", 2314 + "optional": true, 2315 + "os": [ 2316 + "linux" 2317 + ], 2318 + "engines": { 2319 + "node": ">=12" 2320 + } 2321 + }, 2322 + "node_modules/esbuild/node_modules/@esbuild/linux-ppc64": { 2323 + "version": "0.21.5", 2324 + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", 2325 + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", 2326 + "cpu": [ 2327 + "ppc64" 2328 + ], 2329 + "dev": true, 2330 + "license": "MIT", 2331 + "optional": true, 2332 + "os": [ 2333 + "linux" 2334 + ], 2335 + "engines": { 2336 + "node": ">=12" 2337 + } 2338 + }, 2339 + "node_modules/esbuild/node_modules/@esbuild/linux-riscv64": { 2340 + "version": "0.21.5", 2341 + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", 2342 + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", 2343 + "cpu": [ 2344 + "riscv64" 2345 + ], 2346 + "dev": true, 2347 + "license": "MIT", 2348 + "optional": true, 2349 + "os": [ 2350 + "linux" 2351 + ], 2352 + "engines": { 2353 + "node": ">=12" 2354 + } 2355 + }, 2356 + "node_modules/esbuild/node_modules/@esbuild/linux-s390x": { 2357 + "version": "0.21.5", 2358 + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", 2359 + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", 2360 + "cpu": [ 2361 + "s390x" 2362 + ], 2363 + "dev": true, 2364 + "license": "MIT", 2365 + "optional": true, 2366 + "os": [ 2367 + "linux" 2368 + ], 2369 + "engines": { 2370 + "node": ">=12" 2371 + } 2372 + }, 2373 + "node_modules/esbuild/node_modules/@esbuild/linux-x64": { 2374 + "version": "0.21.5", 2375 + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", 2376 + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", 2377 + "cpu": [ 2378 + "x64" 2379 + ], 2380 + "dev": true, 2381 + "license": "MIT", 2382 + "optional": true, 2383 + "os": [ 2384 + "linux" 2385 + ], 2386 + "engines": { 2387 + "node": ">=12" 2388 + } 2389 + }, 2390 + "node_modules/esbuild/node_modules/@esbuild/netbsd-x64": { 2391 + "version": "0.21.5", 2392 + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", 2393 + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", 2394 + "cpu": [ 2395 + "x64" 2396 + ], 2397 + "dev": true, 2398 + "license": "MIT", 2399 + "optional": true, 2400 + "os": [ 2401 + "netbsd" 2402 + ], 2403 + "engines": { 2404 + "node": ">=12" 2405 + } 2406 + }, 2407 + "node_modules/esbuild/node_modules/@esbuild/openbsd-x64": { 2408 + "version": "0.21.5", 2409 + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", 2410 + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", 2411 + "cpu": [ 2412 + "x64" 2413 + ], 2414 + "dev": true, 2415 + "license": "MIT", 2416 + "optional": true, 2417 + "os": [ 2418 + "openbsd" 2419 + ], 2420 + "engines": { 2421 + "node": ">=12" 2422 + } 2423 + }, 2424 + "node_modules/esbuild/node_modules/@esbuild/sunos-x64": { 2425 + "version": "0.21.5", 2426 + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", 2427 + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", 2428 + "cpu": [ 2429 + "x64" 2430 + ], 2431 + "dev": true, 2432 + "license": "MIT", 2433 + "optional": true, 2434 + "os": [ 2435 + "sunos" 2436 + ], 2437 + "engines": { 2438 + "node": ">=12" 2439 + } 2440 + }, 2441 + "node_modules/esbuild/node_modules/@esbuild/win32-arm64": { 2442 + "version": "0.21.5", 2443 + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", 2444 + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", 2445 + "cpu": [ 2446 + "arm64" 2447 + ], 2448 + "dev": true, 2449 + "license": "MIT", 2450 + "optional": true, 2451 + "os": [ 2452 + "win32" 2453 + ], 2454 + "engines": { 2455 + "node": ">=12" 2456 + } 2457 + }, 2458 + "node_modules/esbuild/node_modules/@esbuild/win32-ia32": { 2459 + "version": "0.21.5", 2460 + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", 2461 + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", 2462 + "cpu": [ 2463 + "ia32" 2464 + ], 2465 + "dev": true, 2466 + "license": "MIT", 2467 + "optional": true, 2468 + "os": [ 2469 + "win32" 2470 + ], 2471 + "engines": { 2472 + "node": ">=12" 2473 + } 2474 + }, 2475 + "node_modules/esbuild/node_modules/@esbuild/win32-x64": { 2476 + "version": "0.21.5", 2477 + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", 2478 + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", 2479 + "cpu": [ 2480 + "x64" 2481 + ], 2482 + "dev": true, 2483 + "license": "MIT", 2484 + "optional": true, 2485 + "os": [ 2486 + "win32" 2487 + ], 2488 + "engines": { 2489 + "node": ">=12" 2490 + } 2491 + }, 1660 2492 "node_modules/escalade": { 1661 2493 "version": "3.2.0", 1662 2494 "dev": true, ··· 1871 2703 }, 1872 2704 "node_modules/estree-walker": { 1873 2705 "version": "2.0.2", 2706 + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", 2707 + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", 1874 2708 "dev": true, 1875 2709 "license": "MIT" 1876 2710 }, ··· 1965 2799 }, 1966 2800 "node_modules/file-uri-to-path": { 1967 2801 "version": "1.0.0", 2802 + "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", 2803 + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", 1968 2804 "dev": true, 1969 2805 "license": "MIT" 1970 2806 }, ··· 2122 2958 }, 2123 2959 "node_modules/graceful-fs": { 2124 2960 "version": "4.2.11", 2961 + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", 2962 + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", 2125 2963 "dev": true, 2126 2964 "license": "ISC" 2127 2965 }, ··· 2314 3152 }, 2315 3153 "node_modules/https-proxy-agent": { 2316 3154 "version": "7.0.6", 3155 + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", 3156 + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", 2317 3157 "dev": true, 2318 3158 "license": "MIT", 2319 3159 "dependencies": { ··· 3372 4212 }, 3373 4213 "node_modules/minizlib": { 3374 4214 "version": "3.0.2", 4215 + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.0.2.tgz", 4216 + "integrity": "sha512-oG62iEk+CYt5Xj2YqI5Xi9xWUeZhDI8jjQmC5oThVH5JGCTgIjr7ciJDzC7MBzYd//WvR1OTmP5Q38Q8ShQtVA==", 3375 4217 "dev": true, 3376 4218 "license": "MIT", 3377 4219 "dependencies": { ··· 3383 4225 }, 3384 4226 "node_modules/mkdirp": { 3385 4227 "version": "3.0.1", 4228 + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-3.0.1.tgz", 4229 + "integrity": "sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg==", 3386 4230 "dev": true, 3387 4231 "license": "MIT", 3388 4232 "bin": { ··· 3448 4292 }, 3449 4293 "node_modules/node-fetch": { 3450 4294 "version": "2.7.0", 4295 + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", 4296 + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", 3451 4297 "dev": true, 3452 4298 "license": "MIT", 3453 4299 "dependencies": { ··· 3467 4313 }, 3468 4314 "node_modules/node-gyp-build": { 3469 4315 "version": "4.8.4", 4316 + "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", 4317 + "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==", 3470 4318 "dev": true, 3471 4319 "license": "MIT", 3472 4320 "bin": { ··· 3482 4330 }, 3483 4331 "node_modules/nopt": { 3484 4332 "version": "8.1.0", 4333 + "resolved": "https://registry.npmjs.org/nopt/-/nopt-8.1.0.tgz", 4334 + "integrity": "sha512-ieGu42u/Qsa4TFktmaKEwM6MQH0pOWnaB3htzh0JRtx84+Mebc0cbZYN5bC+6WTZ4+77xrL9Pn5m7CV6VIkV7A==", 3485 4335 "dev": true, 3486 4336 "license": "ISC", 3487 4337 "dependencies": { ··· 4663 5513 }, 4664 5514 "node_modules/tar": { 4665 5515 "version": "7.4.3", 5516 + "resolved": "https://registry.npmjs.org/tar/-/tar-7.4.3.tgz", 5517 + "integrity": "sha512-5S7Va8hKfV7W5U6g3aYxXmlPoZVAwUMy9AOKyF2fVuZa2UD3qZjg578OrLRt8PcNN1PleVaL/5/yYATNL0ICUw==", 4666 5518 "dev": true, 4667 5519 "license": "ISC", 4668 5520 "dependencies": { ··· 4721 5573 }, 4722 5574 "node_modules/tr46": { 4723 5575 "version": "0.0.3", 5576 + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", 5577 + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", 4724 5578 "dev": true, 4725 5579 "license": "MIT" 4726 5580 }, ··· 5054 5908 }, 5055 5909 "node_modules/webidl-conversions": { 5056 5910 "version": "3.0.1", 5911 + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", 5912 + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", 5057 5913 "dev": true, 5058 5914 "license": "BSD-2-Clause" 5059 5915 }, 5060 5916 "node_modules/whatwg-url": { 5061 5917 "version": "5.0.0", 5918 + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", 5919 + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", 5062 5920 "dev": true, 5063 5921 "license": "MIT", 5064 5922 "dependencies": { ··· 5171 6029 }, 5172 6030 "node_modules/yallist": { 5173 6031 "version": "5.0.0", 6032 + "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", 6033 + "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", 5174 6034 "dev": true, 5175 6035 "license": "BlueOak-1.0.0", 5176 6036 "engines": {
+1 -2
package.json
··· 11 11 "lint": "eslint ." 12 12 }, 13 13 "devDependencies": { 14 - 15 - "@sveltejs/adapter-vercel": "^5.7.0", 14 + "@sveltejs/adapter-vercel": "^5.8.1", 16 15 "@sveltejs/kit": "^2.24.0", 17 16 "@sveltejs/vite-plugin-svelte": "^4.0.4", 18 17 "@tailwindcss/forms": "^0.5.9",
+1 -7
src/app.html
··· 10 10 <meta name="mobile-web-app-capable" content="yes" /> 11 11 <meta name="apple-mobile-web-app-capable" content="yes" /> 12 12 <meta name="msapplication-TileColor" content="#000000" /> 13 - <meta 14 - name="msapplication-TileImage" 15 - content="%sveltekit.assets%/favicon/ms-icon-144x144.png" 16 - /> 17 - 18 - <!-- THEME LOADER - MUST BE FIRST SCRIPT --> 19 - <script src="%sveltekit.assets%/scripts/themeLoader.js"></script> 13 + <link rel="icon" href="/logo.ico" /> 20 14 %sveltekit.head% 21 15 </head> 22 16 <body data-sveltekit-preload-data="hover">
+27 -468
src/lib/components/archive/ArchiveCard.svelte
··· 1 1 <script lang="ts"> 2 - import { fly, fade } from "svelte/transition"; 3 - import { quintOut } from "svelte/easing"; 4 - import { formatDate, formatNumber } from "$utils/formatters"; 5 - import { getMilestone } from "$utils/milestones"; 6 - import DocumentIcon from "$components/icons/utility/DocumentIcon.svelte"; 7 - import LinkExternalIcon from "$components/icons/utility/LinkExternalIcon.svelte"; 8 - import CoffeeIcon from "$components/icons/utility/CoffeeIcon.svelte"; 9 - import ClockIcon from "$components/icons/utility/ClockIcon.svelte"; 10 - import BookIcon from "$components/icons/utility/BookIcon.svelte"; 11 - import BooksIcon from "$components/icons/utility/BooksIcon.svelte"; 12 - 13 - export let type: 'post' | 'link'; 14 - export let post: any = {}; // For post type 15 - export let title: string = ""; // For link type or post title 16 - export let url: string = ""; // For link type 17 - export let value: string = ""; // For link type 18 - export let monthIndex: number = 0; 19 - export let postIndex: number = 0; 20 - export let localeLoaded: boolean = false; 21 - export let postNumber: number | null = null; // New prop for milestone calculation 22 - 23 - // Reactive variable to store the display date string for posts 24 - let displayDate: string; 25 - 26 - // Update displayDate whenever post.createdAt or localeLoaded changes for posts 27 - $: { 28 - if (type === 'post' && localeLoaded && post?.createdAt) { 29 - const postDate = new Date(post.createdAt); 30 - displayDate = formatDate(postDate); 31 - } else if (type === 'post') { 32 - displayDate = "Loading..."; 33 - } 34 - } 35 - 36 - // Calculate milestone information 37 - $: milestone = type === 'post' && postNumber ? getMilestone(postNumber) : null; 38 - 39 - // Determine the title to display based on type 40 - $: displayTitle = type === 'post' ? post?.title : title; 41 - $: href = type === 'post' ? `/blog/${post.rkey}` : url; 42 - 43 - // Calculate reading time category for visual styling 44 - $: readingTime = type === 'post' ? Math.ceil(post.wordCount / 200) : 0; 45 - $: isLongRead = readingTime > 10; 46 - $: isMediumRead = readingTime > 5 && readingTime <= 10; 47 - $: isQuickRead = readingTime <= 2; 48 - 49 - // Get appropriate icon based on reading time 50 - $: getReadingTimeIcon = (time: number) => { 51 - if (time <= 2) return 'quick'; // Coffee cup for quick reads 52 - if (time <= 5) return 'short'; // Clock for short reads 53 - if (time <= 10) return 'medium'; // Book for medium reads 54 - return 'long'; // Stack of books for long reads 55 - }; 2 + export let type: 'link' | 'user'; 3 + export let url: string; 4 + export let title: string; 5 + export let value: string | undefined = undefined; 56 6 </script> 57 7 58 - <div 59 - class="archive-card group" 60 - class:long-read={type === 'post' && isLongRead} 61 - class:medium-read={type === 'post' && isMediumRead} 62 - class:has-milestone={milestone} 63 - in:fly={{ 64 - y: 15, 65 - x: 0, 66 - delay: 150 + monthIndex * 30 + postIndex * 50, 67 - duration: 300, 68 - easing: quintOut, 69 - }} 70 - > 71 - <a {href} class="card-link"> 72 - <article class="card-content"> 73 - <!-- Milestone banner (appears at top if present) --> 74 - {#if milestone} 75 - <div 76 - class="milestone-banner" 77 - class:special={milestone.type === 'special'} 78 - class:major={milestone.type === 'major'} 79 - class:minor={milestone.type === 'minor'} 80 - in:fade={{ delay: 300, duration: 400 }} 81 - > 82 - <span class="milestone-emoji" role="img" aria-label="milestone">{milestone.emoji}</span> 83 - <span class="milestone-text">{milestone.text}</span> 84 - </div> 85 - {/if} 86 - 87 - <!-- Header section with title and type indicator --> 88 - <header class="card-header"> 89 - {#if type === 'post'} 90 - <div class="type-indicator post-indicator"> 91 - <DocumentIcon size="14" /> 92 - <span class="sr-only">Blog post</span> 93 - </div> 94 - {:else} 95 - <div class="type-indicator link-indicator"> 96 - <LinkExternalIcon size="14" /> 97 - <span class="sr-only">External link</span> 98 - </div> 99 - {/if} 100 - 101 - <h3 class="card-title" title={displayTitle}> 102 - {displayTitle} 103 - </h3> 104 - </header> 105 - 106 - <!-- Main content area --> 107 - <div class="card-body"> 108 - {#if type === 'post'} 109 - <!-- Reading stats with visual emphasis --> 110 - <div class="reading-stats"> 111 - <div class="stat-item words"> 112 - <span class="stat-number">{formatNumber(post.wordCount) || '0'}</span> 113 - <span class="stat-label">words</span> 114 - </div> 115 - <div class="stat-divider">•</div> 116 - <div class="stat-item time" class:highlight={isLongRead}> 117 - <div class="reading-time-icon" class:quick={isQuickRead} class:medium={isMediumRead} class:long={isLongRead}> 118 - {#if getReadingTimeIcon(readingTime) === 'quick'} 119 - <CoffeeIcon size="14" /> 120 - {:else if getReadingTimeIcon(readingTime) === 'short'} 121 - <ClockIcon size="14" /> 122 - {:else if getReadingTimeIcon(readingTime) === 'medium'} 123 - <BookIcon size="14" /> 124 - {:else} 125 - <BooksIcon size="14" /> 126 - {/if} 127 - </div> 128 - <span class="stat-number">{Math.ceil(post.wordCount / 200)}</span> 129 - <span class="stat-label">min read</span> 130 - </div> 131 - </div> 132 - {:else if type === 'link'} 133 - <p class="link-value">{value}</p> 134 - {/if} 135 - </div> 136 - 137 - <!-- Footer with metadata --> 138 - <footer class="card-footer"> 139 - {#if type === 'post'} 140 - <div class="date-section"> 141 - <span class="date-label">Last Updated</span> 142 - <div class="date-value"> 143 - {#if localeLoaded && displayDate !== "Loading..."} 144 - <span transition:fade>{displayDate}</span> 145 - {:else} 146 - <span class="loading">Loading...</span> 147 - {/if} 148 - </div> 149 - </div> 150 - {:else if type === 'link'} 151 - <div class="link-domain"> 152 - {url?.replace(/^https?:\/\//, "").split("/")[0]} 153 - </div> 154 - {/if} 155 - </footer> 156 - </article> 8 + {#if type === 'link'} 9 + <a 10 + href={url} 11 + target="_blank" 12 + rel="noopener noreferrer" 13 + class="block p-4 rounded-lg border hover:shadow-lg transition-shadow" 14 + style="background: var(--card-bg); border-color: var(--border-color);" 15 + > 16 + {#if value} 17 + <div class="text-2xl mb-2">{value}</div> 18 + {/if} 19 + <h3 class="font-semibold text-lg mb-1">{title}</h3> 20 + <p class="text-sm text-link opacity-75 truncate">{url}</p> 157 21 </a> 158 - </div> 159 - 160 - <style> 161 - .archive-card { 162 - backface-visibility: hidden; 163 - transition: all 0.3s cubic-bezier(0.4, 0.0, 0.2, 1); 164 - } 165 - 166 - .card-link { 167 - display: block; 168 - text-decoration: none; 169 - height: 100%; 170 - } 171 - 172 - .card-content { 173 - display: flex; 174 - flex-direction: column; 175 - height: 100%; 176 - min-height: 140px; 177 - padding: 20px; 178 - border: 1px solid transparent; 179 - border-radius: 0px; 180 - background: var(--header-footer-bg); 181 - transition: all 0.3s cubic-bezier(0.4, 0.0, 0.2, 1); 182 - position: relative; 183 - overflow: hidden; 184 - } 185 - 186 - .card-content::before { 187 - content: ''; 188 - position: absolute; 189 - top: 0; 190 - left: 0; 191 - right: 0; 192 - height: 3px; 193 - background: var(--link-color); 194 - transform: scaleX(0); 195 - transition: transform 0.3s ease; 196 - transform-origin: left; 197 - } 198 - 199 - .group:hover .card-content { 200 - border-color: var(--button-bg); 201 - transform: translateY(-2px); 202 - box-shadow: 0 8px 25px rgba(0, 0, 0, 0.1); 203 - } 204 - 205 - .group:hover .card-content::before { 206 - transform: scaleX(1); 207 - } 208 - 209 - /* Disable line animation for milestone cards */ 210 - .has-milestone .card-content::before { 211 - display: none; 212 - } 213 - 214 - /* Milestone Banner Styles */ 215 - .milestone-banner { 216 - display: flex; 217 - align-items: center; 218 - justify-content: center; 219 - gap: 8px; 220 - padding: 8px 12px; 221 - margin: -20px -20px 16px -20px; 222 - font-size: 0.85rem; 223 - font-weight: 600; 224 - text-align: center; 225 - position: relative; 226 - overflow: hidden; 227 - } 228 - 229 - .milestone-banner::before { 230 - content: ''; 231 - position: absolute; 232 - top: 0; 233 - left: -100%; 234 - width: 100%; 235 - height: 100%; 236 - background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.2), transparent); 237 - animation: shimmer 2s infinite; 238 - } 239 - 240 - @keyframes shimmer { 241 - 0% { left: -100%; } 242 - 100% { left: 100%; } 243 - } 244 - 245 - .milestone-banner.special { 246 - background: linear-gradient(135deg, var(--button-bg), var(--button-hover-bg)); 247 - color: white; 248 - text-shadow: 0 1px 2px rgba(0, 0, 0, 0.2); 249 - } 250 - 251 - .milestone-banner.major { 252 - background: linear-gradient(135deg, var(--button-bg), var(--text-color)); 253 - color: var(--header-footer-bg); 254 - } 255 - 256 - .milestone-banner.minor { 257 - background: var(--button-bg); 258 - color: var(--text-color); 259 - opacity: 0.9; 260 - } 261 - 262 - .milestone-emoji { 263 - font-size: 1rem; 264 - filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.1)); 265 - } 266 - 267 - .milestone-text { 268 - letter-spacing: 0.02em; 269 - } 270 - 271 - /* Adjust card content when milestone is present */ 272 - .has-milestone .card-header { 273 - margin-bottom: 12px; 274 - } 275 - 276 - .has-milestone .card-content { 277 - min-height: 160px; 278 - } 279 - 280 - /* Header Styles */ 281 - .card-header { 282 - display: flex; 283 - align-items: flex-start; 284 - gap: 12px; 285 - margin-bottom: 16px; 286 - } 287 - 288 - .type-indicator { 289 - flex-shrink: 0; 290 - padding: 6px; 291 - border-radius: 0px; 292 - margin-top: 2px; 293 - transition: all 0.3s ease; 294 - } 295 - 296 - .post-indicator, .link-indicator { 297 - background: var(--button-bg); 298 - color: var(--text-color); 299 - opacity: 0.8; 300 - } 301 - 302 - .group:hover .type-indicator { 303 - transform: scale(1.1); 304 - } 305 - 306 - .card-title { 307 - flex: 1; 308 - margin: 0; 309 - font-size: 1.1rem; 310 - font-weight: 600; 311 - line-height: 1.4; 312 - color: var(--link-color); 313 - display: -webkit-box; 314 - -webkit-line-clamp: 3; 315 - line-clamp: 3; 316 - -webkit-box-orient: vertical; 317 - overflow: hidden; 318 - word-break: break-word; 319 - transition: color 0.3s ease; 320 - } 321 - 322 - .group:hover .card-title { 323 - color: var(--link-hover-color); 324 - } 325 - 326 - /* Body Styles */ 327 - .card-body { 328 - flex: 1; 329 - display: flex; 330 - align-items: center; 331 - } 332 - 333 - .reading-stats { 334 - display: flex; 335 - align-items: center; 336 - gap: 12px; 337 - font-size: 0.95rem; 338 - } 339 - 340 - .stat-item { 341 - display: flex; 342 - align-items: center; 343 - gap: 6px; 344 - } 345 - 346 - .reading-time-icon { 347 - display: flex; 348 - align-items: center; 349 - color: var(--text-color); 350 - opacity: 0.7; 351 - transition: all 0.3s ease; 352 - } 353 - 354 - .stat-number { 355 - font-weight: 600; 356 - color: var(--text-color); 357 - } 358 - 359 - .stat-label { 360 - font-size: 0.85rem; 361 - color: var(--text-color); 362 - opacity: 0.7; 363 - } 364 - 365 - .stat-item.highlight .stat-number { 366 - color: var(--link-color); 367 - font-weight: 700; 368 - } 369 - 370 - .stat-divider { 371 - color: var(--text-color); 372 - opacity: 0.4; 373 - } 374 - 375 - .link-value { 376 - color: var(--text-color); 377 - font-size: 0.9rem; 378 - line-height: 1.4; 379 - margin: 0; 380 - } 381 - 382 - /* Footer Styles */ 383 - .card-footer { 384 - margin-top: auto; 385 - padding-top: 16px; 386 - } 387 - 388 - .date-section { 389 - display: flex; 390 - flex-direction: column; 391 - gap: 4px; 392 - } 393 - 394 - .date-label { 395 - font-size: 0.75rem; 396 - color: var(--text-color); 397 - opacity: 0.6; 398 - text-transform: uppercase; 399 - letter-spacing: 0.5px; 400 - font-weight: 500; 401 - } 402 - 403 - .date-value { 404 - font-size: 0.9rem; 405 - color: var(--text-color); 406 - font-weight: 500; 407 - } 408 - 409 - .loading { 410 - opacity: 0.5; 411 - font-style: italic; 412 - } 413 - 414 - .link-domain { 415 - font-size: 0.8rem; 416 - color: var(--text-color); 417 - opacity: 0.7; 418 - font-family: monospace; 419 - background: var(--button-bg); 420 - padding: 4px 8px; 421 - border-radius: 0px; 422 - display: inline-block; 423 - } 424 - 425 - /* Adaptive sizing based on content */ 426 - .long-read .card-content { 427 - min-height: 160px; 428 - } 429 - 430 - .medium-read .card-content { 431 - min-height: 150px; 432 - } 433 - 434 - .long-read .card-content::before { 435 - height: 4px; 436 - background: var(--link-color); 437 - } 438 - 439 - /* Screen reader only content */ 440 - .sr-only { 441 - position: absolute; 442 - width: 1px; 443 - height: 1px; 444 - padding: 0; 445 - margin: -1px; 446 - overflow: hidden; 447 - clip: rect(0, 0, 0, 0); 448 - white-space: nowrap; 449 - border: 0; 450 - } 451 - 452 - /* Responsive adjustments */ 453 - @media (max-width: 640px) { 454 - .card-content { 455 - padding: 16px; 456 - min-height: 120px; 457 - } 458 - 459 - .card-title { 460 - font-size: 1rem; 461 - } 462 - 463 - .reading-stats { 464 - font-size: 0.85rem; 465 - } 466 - 467 - .milestone-banner { 468 - margin: -16px -16px 12px -16px; 469 - font-size: 0.8rem; 470 - } 471 - } 472 - </style> 22 + {:else if type === 'user'} 23 + <a 24 + href={url} 25 + class="block p-4 rounded-lg border hover:shadow-lg transition-shadow" 26 + style="background: var(--card-bg); border-color: var(--border-color);" 27 + > 28 + <h3 class="font-semibold text-lg mb-1">{title}</h3> 29 + <p class="text-sm text-link opacity-75 truncate">{url}</p> 30 + </a> 31 + {/if}
-35
src/lib/components/archive/MonthSection.svelte
··· 1 - <script lang="ts"> 2 - import { slide } from "svelte/transition"; 3 - 4 - import { quintOut } from "svelte/easing"; 5 - import { ArchiveCard } from "./index"; 6 - import StatsDisplay from "./StatsDisplay.svelte"; 7 - 8 - export let monthName: string; 9 - export let postsInMonth: any[]; 10 - export let monthIndex: number; 11 - export let localeLoaded: boolean; 12 - import { calculateTotalReadTime, calculateTotalWordCount, formatReadTime } from "$utils/tally"; 13 - 14 - $: rawTotalReadTime = calculateTotalReadTime(postsInMonth); 15 - $: totalReadTime = formatReadTime(rawTotalReadTime); 16 - $: totalWordCount = calculateTotalWordCount(postsInMonth); 17 - 18 - // Calculate the number of posts 19 - let postCount = postsInMonth.length; 20 - </script> 21 - 22 - <div 23 - class="mb-12 ml-4" 24 - in:slide={{ delay: 100 + monthIndex * 50, duration: 300, easing: quintOut }} 25 - > 26 - <h2 class="text-2xl font-bold mb-1 ml-2">{monthName}</h2> 27 - <StatsDisplay {totalReadTime} {totalWordCount} {postCount} /> 28 - <div 29 - class="grid grid-cols-[repeat(auto-fill,minmax(280px,1fr)_)] gap-x-6 gap-y-6 mx-4 my-8" 30 - > 31 - {#each postsInMonth as post, postIndex (post.rkey)} 32 - <ArchiveCard type="post" {post} {monthIndex} {postIndex} {localeLoaded} postNumber={post.postNumber} /> 33 - {/each} 34 - </div> 35 - </div>
+121
src/lib/components/archive/UserDirectory.svelte
··· 1 + <script lang="ts"> 2 + import { onMount } from "svelte"; 3 + import { goto } from "$app/navigation"; 4 + import type { User } from "$lib/components/shared/interfaces"; 5 + 6 + export let users: User[]; 7 + 8 + let loading = true; 9 + let userProfiles: any[] = []; 10 + 11 + onMount(async () => { 12 + if (users && users.length > 0) { 13 + // Fetch profile data for each user 14 + const profiles = await Promise.all( 15 + users.map(async (user) => { 16 + try { 17 + const response = await fetch( 18 + `https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=${user.did}` 19 + ); 20 + if (response.ok) { 21 + const profile = await response.json(); 22 + return { 23 + did: user.did, 24 + handle: profile.handle || user.handle, 25 + displayName: profile.displayName || user.displayName, 26 + avatar: profile.avatar, 27 + description: profile.description, 28 + banner: profile.banner 29 + }; 30 + } 31 + } catch (error) { 32 + console.error(`Error fetching profile for ${user.did}:`, error); 33 + } 34 + return user; // Return original if fetch fails 35 + }) 36 + ); 37 + userProfiles = profiles.filter(Boolean); 38 + } 39 + loading = false; 40 + }); 41 + 42 + function navigateToUser(did: string) { 43 + goto(`/user/${encodeURIComponent(did)}`); 44 + } 45 + </script> 46 + 47 + <div class="user-directory"> 48 + <h1 class="text-3xl font-bold mb-8 text-center">Users</h1> 49 + {#if loading} 50 + <div class="text-center py-8"> 51 + <p class="text-lg opacity-75">Loading user profiles...</p> 52 + </div> 53 + {:else if userProfiles.length === 0} 54 + <div class="text-center py-8"> 55 + <p class="text-lg opacity-75">No users configured. Add users in the configuration file.</p> 56 + </div> 57 + {:else} 58 + <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"> 59 + {#each userProfiles as user (user.did)} 60 + <button 61 + class="user-card cursor-pointer rounded-lg p-6 transition-transform hover:scale-105 text-left w-full" 62 + style="background: var(--card-bg); border: 1px solid var(--border-color);" 63 + onclick={() => navigateToUser(user.did)} 64 + > 65 + {#if user.banner} 66 + <div 67 + class="w-full h-32 rounded-t-lg mb-4 bg-cover bg-center" 68 + style="background-image: url({user.banner});" 69 + ></div> 70 + {/if} 71 + 72 + <div class="flex items-start gap-4"> 73 + {#if user.avatar} 74 + <img 75 + src={user.avatar} 76 + alt={user.displayName || user.handle} 77 + class="w-16 h-16 rounded-full object-cover" 78 + /> 79 + {:else} 80 + <div class="w-16 h-16 rounded-full bg-[var(--muted-bg)] flex items-center justify-center"> 81 + <span class="text-2xl font-bold text-[var(--text-color)]"> 82 + {(user.displayName || user.handle || '?').charAt(0).toUpperCase()} 83 + </span> 84 + </div> 85 + {/if} 86 + 87 + <div class="flex-1 min-w-0"> 88 + <h3 class="font-bold text-lg truncate"> 89 + {user.displayName || user.handle || 'Unknown User'} 90 + </h3> 91 + <p class="text-sm opacity-75 truncate"> 92 + @{user.handle || user.did} 93 + </p> 94 + {#if user.description} 95 + <p class="text-sm mt-2 line-clamp-2 opacity-90"> 96 + {user.description} 97 + </p> 98 + {/if} 99 + </div> 100 + </div> 101 + 102 + <div class="mt-4 text-center"> 103 + <span class="text-sm text-link hover:text-link-hover"> 104 + View links → 105 + </span> 106 + </div> 107 + </button> 108 + {/each} 109 + </div> 110 + {/if} 111 + </div> 112 + 113 + <style> 114 + .line-clamp-2 { 115 + display: -webkit-box; 116 + line-clamp: 2; 117 + -webkit-line-clamp: 2; 118 + -webkit-box-orient: vertical; 119 + overflow: hidden; 120 + } 121 + </style>
-15
src/lib/components/icons/index.ts
··· 1 - export { default as BlueskyIcon } from "./social/BlueskyIcon.svelte"; 2 - export { default as FacebookIcon } from "./social/FacebookIcon.svelte"; 3 - export { default as MastodonIcon } from './social/MastodonIcon.svelte'; 4 - export { default as RedditIcon } from './social/RedditIcon.svelte'; 5 - export { default as RssIcon } from './social/RssIcon.svelte'; 6 - export { default as ShareIcons } from './social/ShareIcons.svelte'; 7 - export { default as BookOpenIcon } from './utility/BookOpenIcon.svelte'; 8 1 export { default as CopyLinkIcon } from './utility/CopyLinkIcon.svelte'; 9 2 export { default as HomeIcon } from './utility/HomeIcon.svelte'; 10 - export { default as LinkIcon } from './utility/LinkIcon.svelte'; 11 3 export { default as MoonIcon } from './utility/MoonIcon.svelte'; 12 - export { default as PostIcon } from './utility/PostIcon.svelte'; 13 4 export { default as SunIcon } from './utility/SunIcon.svelte'; 14 - export { default as DocumentIcon } from "./utility/DocumentIcon.svelte"; 15 - export { default as LinkExternalIcon } from "./utility/LinkExternalIcon.svelte"; 16 - export { default as CoffeeIcon } from "./utility/CoffeeIcon.svelte"; 17 - export { default as ClockIcon } from "./utility/ClockIcon.svelte"; 18 - export { default as BookIcon } from "./utility/BookIcon.svelte"; 19 - export { default as BooksIcon } from "./utility/BooksIcon.svelte"; 20 5 export { default as EditIcon } from "./utility/EditIcon.svelte";
-17
src/lib/components/icons/social/BlueskyIcon.svelte
··· 1 - <script> 2 - export let size = "18"; 3 - export let fill = "currentColor"; 4 - </script> 5 - 6 - <svg 7 - role="img" 8 - viewBox="0 0 24 24" 9 - xmlns="http://www.w3.org/2000/svg" 10 - width={size} 11 - height={size} 12 - {fill} 13 - > 14 - <path 15 - d="M12 10.8c-1.087-2.114-4.046-6.053-6.798-7.995C2.566.944 1.561 1.266.902 1.565.139 1.908 0 3.08 0 3.768c0 .69.378 5.65.624 6.479.815 2.736 3.713 3.66 6.383 3.364.136-.02.275-.039.415-.056-.138.022-.276.04-.415.056-3.912.58-7.387 2.005-2.83 7.078 5.013 5.19 6.87-1.113 7.823-4.308.953 3.195 2.05 9.271 7.733 4.308 4.267-4.308 1.172-6.498-2.74-7.078a8.741 8.741 0 0 1-.415-.056c.14.017.279.036.415.056 2.67.297 5.568-.628 6.383-3.364.246-.828.624-5.79.624-6.478 0-.69-.139-1.861-.902-2.206-.659-.298-1.664-.62-4.3 1.24C16.046 4.748 13.087 8.687 12 10.8Z" 16 - /> 17 - </svg>
-17
src/lib/components/icons/social/FacebookIcon.svelte
··· 1 - <script> 2 - export let size = "18"; 3 - export let fill = "currentColor"; 4 - </script> 5 - 6 - <svg 7 - role="img" 8 - viewBox="0 0 24 24" 9 - xmlns="http://www.w3.org/2000/svg" 10 - width={size} 11 - height={size} 12 - {fill} 13 - > 14 - <path 15 - d="M9.101 23.691v-7.98H6.627v-3.667h2.474v-1.58c0-4.085 1.848-5.978 5.858-5.978.401 0 .955.042 1.468.103a8.68 8.68 0 0 1 1.141.195v3.325a8.623 8.623 0 0 0-.653-.036 26.805 26.805 0 0 0-.733-.009c-.707 0-1.259.096-1.675.309a1.686 1.686 0 0 0-.679.622c-.258.42-.374.995-.374 1.752v1.297h3.919l-.386 2.103-.287 1.564h-3.246v8.245C19.396 23.238 24 18.179 24 12.044c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.628 3.874 10.35 9.101 11.647Z" 16 - /> 17 - </svg>
-17
src/lib/components/icons/social/MastodonIcon.svelte
··· 1 - <script> 2 - export let size = "18"; 3 - export let fill = "currentColor"; 4 - </script> 5 - 6 - <svg 7 - role="img" 8 - viewBox="0 0 24 24" 9 - xmlns="http://www.w3.org/2000/svg" 10 - width={size} 11 - height={size} 12 - {fill} 13 - > 14 - <path 15 - d="M23.268 5.313c-.35-2.578-2.617-4.61-5.304-5.004C17.51.242 15.792 0 11.813 0h-.03c-3.98 0-4.835.242-5.288.309C3.882.692 1.496 2.518.917 5.127.64 6.412.61 7.837.661 9.143c.074 1.874.088 3.745.26 5.611.118 1.24.325 2.47.62 3.68.55 2.237 2.777 4.098 4.96 4.857 2.336.792 4.849.923 7.256.38.265-.061.527-.132.786-.213.585-.184 1.27-.39 1.774-.753a.057.057 0 0 0 .023-.043v-1.809a.052.052 0 0 0-.02-.041.053.053 0 0 0-.046-.01 20.282 20.282 0 0 1-4.709.545c-2.73 0-3.463-1.284-3.674-1.818a5.593 5.593 0 0 1-.319-1.433.053.053 0 0 1 .066-.054c1.517.363 3.072.546 4.632.546.376 0 .75 0 1.125-.01 1.57-.044 3.224-.124 4.768-.422.038-.008.077-.015.11-.024 2.435-.464 4.753-1.92 4.989-5.604.008-.145.03-1.52.03-1.67.002-.512.167-3.63-.024-5.545zm-3.748 9.195h-2.561V8.29c0-1.309-.55-1.976-1.67-1.976-1.23 0-1.846.79-1.846 2.35v3.403h-2.546V8.663c0-1.56-.617-2.35-1.848-2.35-1.112 0-1.668.668-1.67 1.977v6.218H4.822V8.102c0-1.31.337-2.35 1.011-3.12.696-.77 1.608-1.164 2.74-1.164 1.311 0 2.302.5 2.962 1.498l.638 1.06.638-1.06c.66-.999 1.65-1.498 2.96-1.498 1.13 0 2.043.395 2.74 1.164.675.77 1.012 1.81 1.012 3.12z" 16 - /> 17 - </svg>
-17
src/lib/components/icons/social/RedditIcon.svelte
··· 1 - <script> 2 - export let size = "18"; 3 - export let fill = "currentColor"; 4 - </script> 5 - 6 - <svg 7 - role="img" 8 - viewBox="0 0 24 24" 9 - xmlns="http://www.w3.org/2000/svg" 10 - width={size} 11 - height={size} 12 - {fill} 13 - > 14 - <path 15 - d="M12 0C5.373 0 0 5.373 0 12c0 3.314 1.343 6.314 3.515 8.485l-2.286 2.286C.775 23.225 1.097 24 1.738 24H12c6.627 0 12-5.373 12-12S18.627 0 12 0Zm4.388 3.199c1.104 0 1.999.895 1.999 1.999 0 1.105-.895 2-1.999 2-.946 0-1.739-.657-1.947-1.539v.002c-1.147.162-2.032 1.15-2.032 2.341v.007c1.776.067 3.4.567 4.686 1.363.473-.363 1.064-.58 1.707-.58 1.547 0 2.802 1.254 2.802 2.802 0 1.117-.655 2.081-1.601 2.531-.088 3.256-3.637 5.876-7.997 5.876-4.361 0-7.905-2.617-7.998-5.87-.954-.447-1.614-1.415-1.614-2.538 0-1.548 1.255-2.802 2.803-2.802.645 0 1.239.218 1.712.585 1.275-.79 2.881-1.291 4.64-1.365v-.01c0-1.663 1.263-3.034 2.88-3.207.188-.911.993-1.595 1.959-1.595Zm-8.085 8.376c-.784 0-1.459.78-1.506 1.797-.047 1.016.64 1.429 1.426 1.429.786 0 1.371-.369 1.418-1.385.047-1.017-.553-1.841-1.338-1.841Zm7.406 0c-.786 0-1.385.824-1.338 1.841.047 1.017.634 1.385 1.418 1.385.785 0 1.473-.413 1.426-1.429-.046-1.017-.721-1.797-1.506-1.797Zm-3.703 4.013c-.974 0-1.907.048-2.77.135-.147.015-.241.168-.183.305.483 1.154 1.622 1.964 2.953 1.964 1.33 0 2.47-.81 2.953-1.964.057-.137-.037-.29-.184-.305-.863-.087-1.795-.135-2.769-.135Z" 16 - /> 17 - </svg>
-21
src/lib/components/icons/social/RssIcon.svelte
··· 1 - <script> 2 - export let size = "24"; 3 - export let stroke = "currentColor"; 4 - export let fill = "none"; 5 - </script> 6 - 7 - <svg 8 - xmlns="http://www.w3.org/2000/svg" 9 - width={size} 10 - height={size} 11 - viewBox="0 0 24 24" 12 - {fill} 13 - {stroke} 14 - stroke-width="2" 15 - stroke-linecap="round" 16 - stroke-linejoin="round" 17 - > 18 - <path d="M4 11a9 9 0 0 1 9 9" /> 19 - <path d="M4 4a16 16 0 0 1 16 16" /> 20 - <circle cx="5" cy="19" r="1" /> 21 - </svg>
-262
src/lib/components/icons/social/ShareIcons.svelte
··· 1 - <script lang="ts"> 2 - import { getStores } from "$app/stores"; 3 - const { page } = getStores(); 4 - import { env } from '$env/dynamic/public'; 5 - 6 - // Icons 7 - import { BlueskyIcon, FacebookIcon, RedditIcon, MastodonIcon, CopyLinkIcon } from "$components/icons"; 8 - 9 - // Props 10 - export let title: string; 11 - export let showInHeader: boolean = false; 12 - export let profile: { handle: string; displayName?: string }; 13 - export let mastodonInstance: string = "mastodon.social"; 14 - // Add fediverseCreator prop for Mastodon tagging 15 - let fediverseCreator: string | undefined = env.PUBLIC_ACTIVITYPUB_USER; 16 - 17 - $: mastodonUserTag = 18 - fediverseCreator && 19 - (fediverseCreator.startsWith("http://") || 20 - fediverseCreator.startsWith("https://")) 21 - ? fediverseCreator 22 - : fediverseCreator && fediverseCreator.startsWith("@") 23 - ? fediverseCreator 24 - : `@${fediverseCreator}`; 25 - 26 - // Define specific share texts for each platform 27 - $: blueskyShareText = `${title} by @${profile?.handle} - ${$page.url.href}`; 28 - $: mastodonShareText = 29 - mastodonUserTag 30 - ? mastodonUserTag.startsWith("http://") || 31 - mastodonUserTag.startsWith("https://") 32 - ? `${title} by ${mastodonUserTag} - ${$page.url.href}` 33 - : `${title} by ${mastodonUserTag} - ${$page.url.href}` 34 - : `${title} - ${$page.url.href}`; 35 - $: facebookShareText = `${title} - ${$page.url.href}`; 36 - $: redditShareText = `${title} - ${$page.url.href}`; 37 - 38 - // Truncate for character limits 39 - $: truncatedBlueskyText = 40 - blueskyShareText.length > 300 41 - ? blueskyShareText.substring(0, 297) + "..." 42 - : blueskyShareText; 43 - $: truncatedMastodonText = 44 - mastodonShareText && 45 - (mastodonShareText.length > 500 46 - ? mastodonShareText.substring(0, 497) + "..." 47 - : mastodonShareText); 48 - $: truncatedFacebookText = 49 - facebookShareText.length > 280 50 - ? facebookShareText.substring(0, 277) + "..." 51 - : facebookShareText; 52 - $: truncatedRedditText = 53 - redditShareText.length > 300 54 - ? redditShareText.substring(0, 297) + "..." 55 - : redditShareText; 56 - 57 - // Encode the share texts for use in URLs 58 - $: encodedBlueskyText = encodeURIComponent(truncatedBlueskyText); 59 - $: encodedMastodonText = 60 - mastodonShareText && encodeURIComponent(truncatedMastodonText); 61 - $: encodedFacebookText = encodeURIComponent(truncatedFacebookText); 62 - $: encodedRedditText = encodeURIComponent(truncatedRedditText); 63 - 64 - // Construct the Bluesky share URL 65 - $: blueskyShareUrl = `https://bsky.app/intent/compose?text=${encodedBlueskyText}`; 66 - 67 - // Construct the Reddit share URL 68 - $: redditShareUrl = `https://www.reddit.com/submit?url=${encodeURIComponent($page.url.href)}&title=${encodedRedditText}`; 69 - 70 - // Construct the Facebook share URL 71 - $: facebookShareUrl = `https://www.facebook.com/sharer/sharer.php?u=${encodeURIComponent($page.url.href)}&quote=${encodedFacebookText}`; 72 - 73 - // Construct the Mastodon share URL 74 - $: mastodonShareUrl = 75 - mastodonShareText && 76 - `https://${mastodonInstance}/share?text=${encodedMastodonText}`; 77 - 78 - // Reactive statement to open Mastodon share URL when mastodonInstance changes 79 - let mastodonShareTrigger = false; 80 - $: if (mastodonShareTrigger && mastodonInstance && mastodonShareUrl) { 81 - window.open(mastodonShareUrl, "_blank", "noopener,noreferrer"); 82 - mastodonShareTrigger = false; // Reset trigger 83 - } 84 - 85 - // Copy Link 86 - let copyLinkText = "Copy Link"; 87 - let showCopyFeedback = false; 88 - 89 - const copyLink = async () => { 90 - try { 91 - await navigator.clipboard.writeText($page.url.href); 92 - copyLinkText = "Copied!"; 93 - showCopyFeedback = true; 94 - setTimeout(() => { 95 - showCopyFeedback = false; 96 - copyLinkText = "Copy Link"; 97 - }, 2000); 98 - } catch (err) { 99 - console.error("Failed to copy: ", err); 100 - copyLinkText = "Failed!"; 101 - showCopyFeedback = true; 102 - setTimeout(() => { 103 - showCopyFeedback = false; 104 - copyLinkText = "Copy Link"; 105 - }, 2000); 106 - } 107 - }; 108 - </script> 109 - 110 - <div 111 - class={`share-icons flex items-center gap-2 ${showInHeader ? "ml-auto mr-2" : "justify-center my-4"}`} 112 - > 113 - <!-- Bluesky Share Button --> 114 - <a 115 - href={blueskyShareUrl} 116 - target="_blank" 117 - rel="noopener noreferrer" 118 - class="icon-button p-2 rounded-full transition-all duration-300 hover:scale-110" 119 - style="background-color: var(--card-bg);" 120 - aria-label="Share on Bluesky" 121 - title="Share on Bluesky" 122 - > 123 - <BlueskyIcon /> 124 - </a> 125 - 126 - <!-- Facebook Share Button --> 127 - <a 128 - href={facebookShareUrl} 129 - target="_blank" 130 - rel="noopener noreferrer" 131 - class="icon-button p-2 rounded-full transition-all duration-300 hover:scale-110" 132 - style="background-color: var(--card-bg);" 133 - aria-label="Share on Facebook" 134 - title="Share on Facebook" 135 - > 136 - <FacebookIcon /> 137 - </a> 138 - 139 - <!-- Reddit Share Button --> 140 - <a 141 - href={redditShareUrl} 142 - target="_blank" 143 - rel="noopener noreferrer" 144 - class="icon-button p-2 rounded-full transition-all duration-300 hover:scale-110" 145 - style="background-color: var(--card-bg);" 146 - aria-label="Share on Reddit" 147 - title="Share on Reddit" 148 - > 149 - <RedditIcon /> 150 - </a> 151 - 152 - {#if env.PUBLIC_ACTIVITYPUB_USER && env.PUBLIC_ACTIVITYPUB_USER.length > 0} 153 - <button 154 - on:click|preventDefault={() => { 155 - const instance = prompt( 156 - "Enter your Mastodon instance (e.g. mastodon.social):", 157 - mastodonInstance 158 - ); 159 - if (instance) { 160 - mastodonInstance = instance; 161 - mastodonShareTrigger = true; 162 - } 163 - }} 164 - on:keydown={(e) => { 165 - if (e.key === "Enter" || e.key === " ") { 166 - const instance = prompt( 167 - "Enter your Mastodon instance (e.g. mastodon.social):", 168 - mastodonInstance 169 - ); 170 - if (instance) { 171 - mastodonInstance = instance; 172 - mastodonShareTrigger = true; 173 - } 174 - } 175 - }} 176 - class="icon-button p-2 rounded-full transition-all duration-300 hover:scale-110" 177 - style="background-color: var(--card-bg);" 178 - aria-label="Share on Mastodon" 179 - title="Share on Mastodon" 180 - tabindex="0" 181 - > 182 - <MastodonIcon /> 183 - </button> 184 - {/if} 185 - 186 - <!-- Copy Link Button --> 187 - <div class="relative flex items-center"> 188 - <button 189 - on:click={copyLink} 190 - class="icon-button p-2 rounded-full transition-all duration-300 hover:scale-110" 191 - style="background-color: var(--card-bg);" 192 - aria-label="Copy Link" 193 - title="Copy Link" 194 - > 195 - <CopyLinkIcon /> 196 - </button> 197 - {#if showCopyFeedback} 198 - <span 199 - class="copy-feedback absolute left-full ml-2 text-sm font-medium" 200 - class:copied={copyLinkText === 'Copied!'} 201 - class:failed={copyLinkText === 'Failed!'} 202 - > 203 - {copyLinkText} 204 - </span> 205 - {/if} 206 - </div> 207 - </div> 208 - 209 - <style> 210 - /* Common icon styling - this will match ThemeToggle.svelte */ 211 - .icon-button { 212 - color: var(--text-color); 213 - } 214 - 215 - .icon-button:hover { 216 - background-color: var(--button-hover-bg) !important; 217 - } 218 - 219 - /* Responsive adjustments */ 220 - @media (max-width: 640px) { 221 - .share-icons { 222 - gap: 0.5rem; 223 - } 224 - 225 - /* Hide copy feedback text on mobile */ 226 - .copy-feedback { 227 - display: none; 228 - } 229 - } 230 - 231 - .copy-feedback { 232 - opacity: 0; 233 - animation: fade-in-out 2s forwards; 234 - } 235 - 236 - .copy-feedback.copied { 237 - color: var(--accent-color); 238 - } 239 - 240 - .copy-feedback.failed { 241 - color: var(--error-color); 242 - } 243 - 244 - @keyframes fade-in-out { 245 - 0% { 246 - opacity: 0; 247 - transform: translateX(-10px); 248 - } 249 - 20% { 250 - opacity: 1; 251 - transform: translateX(0); 252 - } 253 - 80% { 254 - opacity: 1; 255 - transform: translateX(0); 256 - } 257 - 100% { 258 - opacity: 0; 259 - transform: translateX(10px); 260 - } 261 - } 262 - </style>
-20
src/lib/components/icons/utility/BookIcon.svelte
··· 1 - <script> 2 - export let size = "14"; 3 - export let fill = "none"; 4 - export let stroke = "currentColor"; 5 - export let strokeWidth = "2"; 6 - </script> 7 - 8 - <svg 9 - width={size} 10 - height={size} 11 - viewBox="0 0 24 24" 12 - {fill} 13 - {stroke} 14 - stroke-width={strokeWidth} 15 - stroke-linecap="round" 16 - stroke-linejoin="round" 17 - > 18 - <path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20" /> 19 - <path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z" /> 20 - </svg>
-21
src/lib/components/icons/utility/BookOpenIcon.svelte
··· 1 - <script> 2 - export let size = "24"; 3 - export let stroke = "currentColor"; 4 - export let fill = "none"; 5 - </script> 6 - 7 - <svg 8 - xmlns="http://www.w3.org/2000/svg" 9 - width={size} 10 - height={size} 11 - viewBox="0 0 24 24" 12 - {fill} 13 - {stroke} 14 - stroke-width="2" 15 - stroke-linecap="round" 16 - stroke-linejoin="round" 17 - class="feather feather-book-open" 18 - > 19 - <path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z" /> 20 - <path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z" /> 21 - </svg>
-20
src/lib/components/icons/utility/BooksIcon.svelte
··· 1 - <script> 2 - export let size = "14"; 3 - export let fill = "none"; 4 - export let stroke = "currentColor"; 5 - export let strokeWidth = "2"; 6 - </script> 7 - 8 - <svg 9 - width={size} 10 - height={size} 11 - viewBox="0 0 24 24" 12 - {fill} 13 - {stroke} 14 - stroke-width={strokeWidth} 15 - stroke-linecap="round" 16 - stroke-linejoin="round" 17 - > 18 - <path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z" /> 19 - <path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z" /> 20 - </svg>
-20
src/lib/components/icons/utility/ClockIcon.svelte
··· 1 - <script> 2 - export let size = "14"; 3 - export let fill = "none"; 4 - export let stroke = "currentColor"; 5 - export let strokeWidth = "2"; 6 - </script> 7 - 8 - <svg 9 - width={size} 10 - height={size} 11 - viewBox="0 0 24 24" 12 - {fill} 13 - {stroke} 14 - stroke-width={strokeWidth} 15 - stroke-linecap="round" 16 - stroke-linejoin="round" 17 - > 18 - <circle cx="12" cy="12" r="10" /> 19 - <polyline points="12,6 12,12 16,14" /> 20 - </svg>
-23
src/lib/components/icons/utility/CoffeeIcon.svelte
··· 1 - <script> 2 - export let size = "14"; 3 - export let fill = "none"; 4 - export let stroke = "currentColor"; 5 - export let strokeWidth = "2"; 6 - </script> 7 - 8 - <svg 9 - width={size} 10 - height={size} 11 - viewBox="0 0 24 24" 12 - {fill} 13 - {stroke} 14 - stroke-width={strokeWidth} 15 - stroke-linecap="round" 16 - stroke-linejoin="round" 17 - > 18 - <path d="M17 8h1a4 4 0 0 1 4 4v0a4 4 0 0 1-4 4h-1" /> 19 - <path d="M3 8h14v9a4 4 0 0 1-4 4H7a4 4 0 0 1-4-4V8Z" /> 20 - <line x1="6" y1="2" x2="6" y2="4" /> 21 - <line x1="10" y1="2" x2="10" y2="4" /> 22 - <line x1="14" y1="2" x2="14" y2="4" /> 23 - </svg>
-23
src/lib/components/icons/utility/DocumentIcon.svelte
··· 1 - <script> 2 - export let size = "14"; 3 - export let fill = "none"; 4 - export let stroke = "currentColor"; 5 - export let strokeWidth = "2"; 6 - </script> 7 - 8 - <svg 9 - width={size} 10 - height={size} 11 - viewBox="0 0 24 24" 12 - {fill} 13 - {stroke} 14 - stroke-width={strokeWidth} 15 - stroke-linecap="round" 16 - stroke-linejoin="round" 17 - > 18 - <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" /> 19 - <polyline points="14,2 14,8 20,8" /> 20 - <line x1="16" y1="13" x2="8" y2="13" /> 21 - <line x1="16" y1="17" x2="8" y2="17" /> 22 - <line x1="10" y1="9" x2="8" y2="9" /> 23 - </svg>
-9
src/lib/components/icons/utility/LinkIcon.svelte
··· 1 - <script lang="ts"> 2 - export let size: string = "14"; 3 - export let colour: string = "currentColor"; 4 - </script> 5 - 6 - <svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke={colour} stroke-width="2"> 7 - <path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/> 8 - <path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/> 9 - </svg>
-12
src/lib/components/icons/utility/PostIcon.svelte
··· 1 - <script lang="ts"> 2 - export let size: string = "14"; 3 - export let colour: string = "currentColor"; 4 - </script> 5 - 6 - <svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke={colour} stroke-width="2"> 7 - <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/> 8 - <polyline points="14,2 14,8 20,8"/> 9 - <line x1="16" y1="13" x2="8" y2="13"/> 10 - <line x1="16" y1="17" x2="8" y2="17"/> 11 - <line x1="10" y1="9" x2="8" y2="9"/> 12 - </svg>
+57
src/lib/components/layout/DirectoryHeader.svelte
··· 1 + <script lang="ts"> 2 + import { env } from "$env/dynamic/public"; 3 + import { page } from "$app/stores"; 4 + import { getProfile } from "$components/profile/profile"; 5 + 6 + let isUserPage = $derived($page.route.id === '/user/[did]'); 7 + 8 + let profile = $state<{ displayName?: string; handle?: string } | null>(null); 9 + let loading = $state(true); 10 + let error = $state<string | null>(null); 11 + 12 + $effect(() => { 13 + if (env.DIRECTORY_OWNER) { 14 + loading = true; 15 + getProfile(fetch) 16 + .then((p) => { 17 + profile = p; 18 + error = null; 19 + }) 20 + .catch((err) => { 21 + console.error('Failed to load profile:', err); 22 + error = err.message; 23 + profile = null; 24 + }) 25 + .finally(() => { 26 + loading = false; 27 + }); 28 + } else { 29 + loading = false; 30 + } 31 + }); 32 + </script> 33 + 34 + <header class="py-4 px-4 sm:px-8 mb-6"> 35 + <div class="max-w-[1000px] mx-auto"> 36 + <div class="flex justify-between items-center mb-4"> 37 + <h1 class="text-lg font-semibold text-[var(--text-color)]"> 38 + {env.DIRECTORY_OWNER ? ( 39 + loading ? 'Loading...' : ( 40 + profile ? 41 + `${profile.displayName || profile.handle || env.DIRECTORY_OWNER}'s Linkat Directory` : 42 + `${env.DIRECTORY_OWNER}'s Linkat Directory` 43 + ) 44 + ) : 'Linkat Directory'} 45 + </h1> 46 + 47 + {#if isUserPage} 48 + <a 49 + href="/" 50 + class="text-sm text-link hover:text-link-hover transition-colors" 51 + > 52 + ← Home 53 + </a> 54 + {/if} 55 + </div> 56 + </div> 57 + </header>
+61
src/lib/components/layout/DynamicHead.svelte
··· 1 + <script lang="ts"> 2 + import { getStores } from '$app/stores'; 3 + const { page } = getStores(); 4 + 5 + import type { DynamicHeadProps } from '$lib/components/shared/interfaces'; 6 + 7 + // Define props for dynamic head content 8 + let { 9 + title, 10 + description, 11 + keywords, 12 + ogUrl = $page.url.origin + $page.url.pathname, 13 + ogTitle, 14 + ogDescription, 15 + ogImage, 16 + ogImageWidth = '1200', 17 + ogImageHeight = '630', 18 + twitterCard = 'summary_large_image', 19 + twitterUrl = $page.url.origin + $page.url.pathname, 20 + twitterTitle, 21 + twitterDescription, 22 + twitterImage 23 + }: DynamicHeadProps = $props(); 24 + 25 + // Fallback for Open Graph and Twitter titles/descriptions if not provided 26 + ogTitle = ogTitle || title; 27 + ogDescription = ogDescription || description; 28 + twitterTitle = twitterTitle || title; 29 + twitterDescription = twitterDescription || description; 30 + </script> 31 + 32 + <svelte:head> 33 + <title>{title}</title> 34 + {#if description} 35 + <meta name="description" content={description} /> 36 + {/if} 37 + {#if keywords} 38 + <meta name="keywords" content={keywords} /> 39 + {/if} 40 + 41 + <!-- Open Graph / Facebook --> 42 + <meta property="og:type" content="website" /> 43 + <meta property="og:url" content={ogUrl} /> 44 + <meta property="og:title" content={ogTitle} /> 45 + <meta property="og:description" content={ogDescription} /> 46 + <meta property="og:site_name" content="Linkat Directory" /> 47 + {#if ogImage} 48 + <meta property="og:image" content={ogImage} /> 49 + <meta property="og:image:width" content={ogImageWidth} /> 50 + <meta property="og:image:height" content={ogImageHeight} /> 51 + {/if} 52 + 53 + <!-- Twitter --> 54 + <meta name="twitter:card" content={twitterCard} /> 55 + <meta name="twitter:url" content={twitterUrl} /> 56 + <meta name="twitter:title" content={twitterTitle} /> 57 + <meta name="twitter:description" content={twitterDescription} /> 58 + {#if twitterImage} 59 + <meta name="twitter:image" content={twitterImage} /> 60 + {/if} 61 + </svelte:head>
+2 -29
src/lib/components/layout/Navigation.svelte
··· 1 1 <script lang="ts"> 2 2 import { getStores } from "$app/stores"; 3 3 const { page } = getStores(); 4 - import ThemeToggle from "./ThemeToggle.svelte"; 5 - import { HomeIcon, RssIcon, BookOpenIcon } from "$components/icons"; 6 - 7 - export const isHomePage: boolean = false; 8 - export let isBlogIndex: boolean = false; 4 + import { HomeIcon } from "$components/icons"; 9 5 10 - // Reactive statement to get the origin without http:// or https:// 11 - $: cleanOrigin = $page.url.origin.replace(/^https?:\/\//, ''); 6 + let {} = $props(); 12 7 </script> 13 8 14 9 <nav class="flex items-center box-border my-6"> ··· 22 17 <HomeIcon /> 23 18 </a> 24 19 {/if} 25 - {#if isBlogIndex} 26 - <!-- RSS Feed Link --> 27 - <a 28 - href="{$page.url.origin}/blog/rss" 29 - class="font-medium text-[large] hover:text-[var(--link-hover-color)]" 30 - aria-label="RSS Feed" 31 - download="{cleanOrigin}_Blog.rss" 32 - > 33 - <RssIcon /> 34 - </a> 35 - {/if} 36 - {#if $page.url.pathname.startsWith("/blog/") && $page.url.pathname !== "/blog/"} 37 - <a 38 - href="/blog" 39 - class="flex items-center gap-2 text-sm text-[var(--text-color)] hover:text-[var(--link-hover-color)]" 40 - aria-label="Back to blog" 41 - > 42 - <BookOpenIcon /> 43 - </a> 44 - {/if} 45 20 </div> 46 - <div class="ml-auto"></div> 47 - <ThemeToggle /> 48 21 </nav>
-152
src/lib/components/layout/ThemeToggle.svelte
··· 1 - <script lang="ts"> 2 - import { onMount } from "svelte"; 3 - import { SunIcon, MoonIcon } from "$components/icons"; 4 - import EditIcon from "$components/icons/utility/EditIcon.svelte"; 5 - import { 6 - THEMES, 7 - applyTheme, 8 - getThemePreferences, 9 - saveThemePreferences, 10 - updateThemeColorMeta, 11 - dispatchThemeChangeEvent, 12 - setupSystemThemeListener 13 - } from "../../themeLoader"; 14 - 15 - let isDarkMode: boolean = true; 16 - let currentTheme: string = "default"; 17 - let isDropdownOpen: boolean = false; 18 - 19 - onMount(() => { 20 - // Retrieve current theme preferences (already applied by theme-loader) 21 - const preferences = getThemePreferences(); 22 - isDarkMode = preferences.isDarkMode; 23 - currentTheme = preferences.themeId; 24 - 25 - // Update meta tag and dispatch a theme change event 26 - updateThemeColorMeta(); 27 - dispatchThemeChangeEvent(isDarkMode, currentTheme); 28 - 29 - // Set up system theme listener 30 - setupSystemThemeListener(); 31 - 32 - // Close dropdown when clicking outside the theme controls 33 - document.addEventListener("click", (e: MouseEvent) => { 34 - const themeControls = document.querySelector(".theme-controls"); 35 - if ( 36 - isDropdownOpen && 37 - themeControls && 38 - !e.composedPath().includes(themeControls) 39 - ) { 40 - isDropdownOpen = false; 41 - } 42 - }); 43 - }); 44 - 45 - function toggleTheme(): void { 46 - isDarkMode = !isDarkMode; 47 - applyTheme(isDarkMode, currentTheme); 48 - saveThemePreferences(isDarkMode, currentTheme); 49 - updateThemeColorMeta(); 50 - dispatchThemeChangeEvent(isDarkMode, currentTheme); 51 - } 52 - 53 - // Change the colour theme 54 - function changeColorTheme(themeId: string): void { 55 - currentTheme = themeId; 56 - applyTheme(isDarkMode, currentTheme); 57 - saveThemePreferences(isDarkMode, currentTheme); 58 - updateThemeColorMeta(); 59 - dispatchThemeChangeEvent(isDarkMode, currentTheme); 60 - isDropdownOpen = false; 61 - } 62 - 63 - // Toggle the dropdown for theme selection 64 - function toggleDropdown(e: MouseEvent): void { 65 - e.stopPropagation(); 66 - isDropdownOpen = !isDropdownOpen; 67 - } 68 - </script> 69 - 70 - <div class="theme-controls relative"> 71 - <div class="flex items-center gap-2"> 72 - <button 73 - onclick={toggleDropdown} 74 - class="icon-button p-2 rounded-full transition-all duration-300 hover:scale-110" 75 - style="background-color: var(--card-bg);" 76 - aria-label="Change theme" 77 - aria-expanded={isDropdownOpen} 78 - > 79 - <EditIcon size="20" stroke="var(--text-color)" /> 80 - </button> 81 - 82 - <button 83 - onclick={toggleTheme} 84 - class="icon-button p-2 rounded-full transition-all duration-300 hover:scale-110" 85 - style="background-color: var(--card-bg);" 86 - aria-label={isDarkMode ? "Switch to light mode" : "Switch to dark mode"} 87 - > 88 - {#if isDarkMode} 89 - <!-- Sun icon for switching to light mode --> 90 - <SunIcon stroke="var(--text-color)" /> 91 - {:else} 92 - <!-- Moon icon for switching to dark mode --> 93 - <MoonIcon stroke="var(--text-color)" /> 94 - {/if} 95 - </button> 96 - </div> 97 - 98 - {#if isDropdownOpen} 99 - <div 100 - class="theme-dropdown absolute right-0 mt-2 py-2 w-48 rounded shadow-lg z-10" 101 - style="background-color: var(--card-bg); border: 1px solid var(--button-bg);" 102 - > 103 - <div class="max-h-80 overflow-y-auto"> 104 - {#each THEMES as theme} 105 - <button 106 - class="theme-option w-full text-left px-4 py-2 transition-colors duration-200" 107 - class:active={currentTheme === theme.id} 108 - onclick={() => changeColorTheme(theme.id)} 109 - > 110 - {theme.name} 111 - </button> 112 - {/each} 113 - </div> 114 - </div> 115 - {/if} 116 - </div> 117 - 118 - <style> 119 - /* Common icon styling */ 120 - .icon-button { 121 - color: var(--text-color); 122 - } 123 - 124 - .icon-button:hover { 125 - background-color: var(--button-hover-bg) !important; 126 - } 127 - 128 - .theme-option { 129 - color: var(--text-color); 130 - } 131 - 132 - .theme-option:hover { 133 - background-color: var(--button-bg); 134 - } 135 - 136 - .theme-option.active { 137 - background-color: var(--button-hover-bg); 138 - font-weight: 500; 139 - } 140 - 141 - .theme-dropdown { 142 - max-height: 80vh; 143 - } 144 - 145 - /* Responsive adjustments */ 146 - @media (max-width: 640px) { 147 - .theme-dropdown { 148 - width: 12rem; 149 - right: 0; 150 - } 151 - } 152 - </style>
+41 -39
src/lib/components/layout/footer/Main.svelte
··· 14 14 }); 15 15 </script> 16 16 17 - <footer class="text-center py-4 text-primary text-sm opacity-60"> 18 - <div class="flex flex-col justify-center items-center gap-2"> 19 - <div> 17 + <footer class="text-center py-8 text-primary text-sm opacity-60"> 18 + <div class="max-w-2xl mx-auto px-4"> 19 + <!-- Main attribution line --> 20 + <div class="mb-4"> 20 21 <span>&copy; <span id="copyright-year"></span></span> 21 - 22 - <span class="mx-1"></span> 23 - 22 + <span class="mx-2">•</span> 24 23 {#if profile?.handle} 25 24 <a 26 25 href="https://bsky.app/profile/{profile.did}" 27 - class="text-[var(--link-color)] hover:text-[var(--link-hover-color)]" 26 + class="text-[var(--link-color)] hover:text-[var(--link-hover-color)] transition-colors" 28 27 > 29 28 @{profile.handle} 30 29 </a> 31 30 {:else} 32 31 <span>{profile?.displayName || profile?.did}</span> 33 32 {/if} 34 - 35 - {#if env.PUBLIC_ACTIVITYPUB_USER && env.PUBLIC_ACTIVITYPUB_USER.length > 0 && profile?.handle} 36 - <span class="mx-1"></span> 37 - {/if} 38 - 39 - {#if env.PUBLIC_ACTIVITYPUB_USER && env.PUBLIC_ACTIVITYPUB_USER.length > 0} 33 + <span class="mx-2">•</span> 34 + <span>powered by 40 35 <a 41 - rel="me" 42 - href={`https://${env.PUBLIC_ACTIVITYPUB_USER.split("@")[2]}/@${env.PUBLIC_ACTIVITYPUB_USER.split("@")[1]}`} 43 - class="text-[var(--link-color)] hover:text-[var(--link-hover-color)]" 36 + class="text-[var(--link-color)] hover:text-[var(--link-hover-color)] transition-colors" 37 + href="https://atproto.com/guides/glossary#at-protocol" 44 38 > 45 - @{env.PUBLIC_ACTIVITYPUB_USER.split( 46 - "@", 47 - )[1]}@{env.PUBLIC_ACTIVITYPUB_USER.split("@")[2]} 39 + atproto 48 40 </a> 49 - {/if} 50 - </div> 51 - 52 - <div> 53 - <span 54 - >powered by <a 55 - class="text-[var(--link-color)] hover:text-[var(--link-hover-color)]" 56 - href="https://atproto.com/guides/glossary#at-protocol">atproto</a 57 - ></span 58 - > 41 + </span> 59 42 </div> 60 43 61 - <div> 62 - <span>template made by <a 63 - class="text-[var(--link-color)] hover:text-[var(--link-hover-color)]" 64 - href="https://bsky.app/profile/did:plc:ofrbh253gwicbkc5nktqepol">ewan</a 65 - ></span> 44 + <!-- Project info --> 45 + <div class="mb-4 text-xs opacity-75 leading-relaxed"> 46 + <div class="mb-2"> 47 + Linkat Directory made by 48 + <a 49 + class="text-[var(--link-color)] hover:text-[var(--link-hover-color)] transition-colors" 50 + href="https://bsky.app/profile/did:plc:ofrbh253gwicbkc5nktqepol" 51 + > 52 + ewan 53 + </a> 54 + </div> 55 + <div> 56 + <a 57 + class="text-[var(--link-color)] hover:text-[var(--link-hover-color)] transition-colors" 58 + href="https://github.com/ewanc26/linkat-directory" 59 + > 60 + Open source 61 + </a> 62 + and free to use. Not affiliated with 63 + <a 64 + class="text-[var(--link-color)] hover:text-[var(--link-hover-color)] transition-colors" 65 + href="https://linkat.blue" 66 + > 67 + Linkat 68 + </a> 69 + </div> 66 70 </div> 67 71 68 - <div> 69 - <span class="mx-1"></span> 70 - <TidClock /> 71 - </div> 72 + <!-- Clock --> 73 + <TidClock /> 72 74 </div> 73 - </footer> 75 + </footer>
-1
src/lib/components/layout/index.ts
··· 1 1 export { default as Navigation } from "./Navigation.svelte"; 2 2 export { default as Footer } from "./footer/Main.svelte"; 3 - export { default as ThemeToggle } from "./ThemeToggle.svelte";
+7 -2
src/lib/components/layout/main/DynamicLinks.svelte
··· 1 1 <script lang="ts"> 2 - import { ArchiveCard } from "$components/archive"; 2 + import ArchiveCard from "$lib/components/archive/ArchiveCard.svelte"; 3 3 import type { LinkBoard } from "$components/shared"; 4 4 5 5 // Export the data prop that will receive the fetched links ··· 12 12 class="grid grid-cols-[repeat(auto-fill,minmax(260px,1fr)_)] gap-x-6 gap-y-6 my-6" 13 13 > 14 14 {#each data.cards as link} 15 - <ArchiveCard type="link" url={link.url} title={link.text} value={link.emoji} /> 15 + <ArchiveCard 16 + type="link" 17 + url={link.url} 18 + title={link.text} 19 + value={link.emoji} 20 + /> 16 21 {/each} 17 22 </div> 18 23 </div>
-102
src/lib/components/layout/main/LatestBlogPost.svelte
··· 1 - <script lang="ts"> 2 - import { slide } from "svelte/transition"; 3 - import { quintOut } from "svelte/easing"; 4 - import { ArchiveCard } from "$components/archive"; 5 - import type { Post } from "$components/shared"; 6 - 7 - export let posts: Post[] = []; 8 - export let localeLoaded: boolean = false; 9 - 10 - // Get the latest post with proper validation 11 - $: latestPost = posts && posts.length > 0 ? posts[0] : null; 12 - 13 - // Additional validation to ensure the post has valid data 14 - $: isValidPost = latestPost && 15 - latestPost.title && 16 - latestPost.createdAt instanceof Date && 17 - !isNaN(latestPost.createdAt.getTime()) && 18 - latestPost.content; 19 - 20 - // Use the postNumber from the latestPost object 21 - $: postNumber = latestPost ? latestPost.postNumber : null; 22 - </script> 23 - 24 - {#if isValidPost} 25 - <section 26 - class="latest-blog-post" 27 - in:slide={{ delay: 200, duration: 400, easing: quintOut }} 28 - > 29 - <div class="section-header"> 30 - <h2 class="section-title">Latest Blog Post</h2> 31 - </div> 32 - 33 - <div class="latest-post-container"> 34 - <ArchiveCard 35 - type="post" 36 - post={latestPost} 37 - monthIndex={0} 38 - postIndex={0} 39 - postNumber={postNumber} 40 - {localeLoaded} 41 - /> 42 - </div> 43 - </section> 44 - {/if} 45 - 46 - <style> 47 - .latest-blog-post { 48 - margin-bottom: 3rem; 49 - } 50 - 51 - .section-header { 52 - display: flex; 53 - align-items: center; 54 - justify-content: space-between; 55 - margin-bottom: 1.5rem; 56 - padding: 0; 57 - } 58 - 59 - .section-title { 60 - margin: 0; 61 - font-size: 1.75rem; 62 - font-weight: 700; 63 - color: var(--text-color); 64 - position: relative; 65 - } 66 - 67 - .section-title::after { 68 - content: ''; 69 - position: absolute; 70 - bottom: -4px; 71 - left: 0; 72 - width: 3rem; 73 - height: 3px; 74 - background: var(--link-color); 75 - border-radius: 0px; 76 - } 77 - 78 - .latest-post-container { 79 - display: grid; 80 - grid-template-columns: 1fr; 81 - max-width: 400px; 82 - } 83 - 84 - /* Responsive adjustments */ 85 - @media (max-width: 640px) { 86 - .section-header { 87 - flex-direction: column; 88 - align-items: flex-start; 89 - gap: 1rem; 90 - } 91 - 92 - .section-title { 93 - font-size: 1.5rem; 94 - } 95 - } 96 - 97 - @media (min-width: 768px) { 98 - .latest-post-container { 99 - max-width: 420px; 100 - } 101 - } 102 - </style>
+61
src/lib/components/layout/main/MultiUserLinks.svelte
··· 1 + <script lang="ts"> 2 + import ArchiveCard from "$lib/components/archive/ArchiveCard.svelte"; 3 + import type { LinkBoard } from "$components/shared"; 4 + 5 + // Props that will receive the fetched links for multiple users 6 + let { userLinkBoards = {}, profile }: { 7 + userLinkBoards?: { [did: string]: LinkBoard | undefined }, 8 + profile: any 9 + } = $props(); 10 + 11 + // Filter out undefined boards and create a clean array 12 + let validBoards = $derived(Object.entries(userLinkBoards) 13 + .filter(([, board]) => board && board.cards && board.cards.length > 0) 14 + .map(([did, board]) => ({ did, board: board! }))); 15 + 16 + // Helper function to format user display name 17 + function getUserDisplayName(did: string): string { 18 + if (did === profile.did) { 19 + return profile.displayName || "My Links"; 20 + } 21 + // For now, use shortened DID for other users 22 + // In a future enhancement, we could fetch their profile info 23 + return `User ${did.slice(-8)}`; 24 + } 25 + </script> 26 + 27 + {#if validBoards.length > 0} 28 + <div class="space-y-8"> 29 + {#each validBoards as { did, board } (did)} 30 + <div class="user-links-section"> 31 + {#if validBoards.length > 1} 32 + <!-- Show user header only when there are multiple users --> 33 + <div class="flex items-center mb-4"> 34 + <h3 class="text-lg font-semibold text-[var(--text-color)]"> 35 + {getUserDisplayName(did)} 36 + </h3> 37 + <span class="ml-2 text-sm text-[var(--text-color-secondary)]"> 38 + ({board.cards.length} link{board.cards.length !== 1 ? 's' : ''}) 39 + </span> 40 + </div> 41 + {:else} 42 + <!-- Single user - add some top margin for spacing --> 43 + <div class="mb-4"></div> 44 + {/if} 45 + 46 + <div 47 + class="grid grid-cols-[repeat(auto-fill,minmax(260px,1fr)_)] gap-x-6 gap-y-6" 48 + > 49 + {#each board.cards as link} 50 + <ArchiveCard type="link" url={link.url} title={link.text} value={link.emoji} /> 51 + {/each} 52 + </div> 53 + </div> 54 + {/each} 55 + </div> 56 + {:else} 57 + <!-- Placeholder for blue.linkat.board --> 58 + <div class="mb-12 ml-4 text-center text-sm italic opacity-75"> 59 + create <code>blue.linkat.board</code> records at <a href="https://linkat.blue/" class="text-link hover:text-link-hover">https://linkat.blue/</a> 60 + </div> 61 + {/if}
+1 -2
src/lib/components/layout/main/index.ts
··· 1 - export { default as DynamicLinks } from "./DynamicLinks.svelte"; 2 - export { default as LatestBlogPost } from "./LatestBlogPost.svelte"; 1 + export { default as DynamicLinks } from "./DynamicLinks.svelte";
-69
src/lib/components/profile/Profile.svelte
··· 1 - <script lang="ts"> 2 - // The profile object is passed as a prop to this component. 3 - export let profile: any; 4 - import { Status } from "."; 5 - </script> 6 - 7 - <!-- Profile Banner: Displays the user's banner image. --> 8 - <div 9 - class="profile-banner p-4 relative rounded-none mx-2 mb-2" 10 - style="background-image: url({profile?.banner}); background-size: cover; background-position: center; min-height: 150px;" 11 - ></div> 12 - 13 - {#if profile} 14 - <!-- Profile Content: Main container for avatar, user info, and status. --> 15 - <div class="profile-content mx-2 mb-8 relative"> 16 - <!-- Mobile: Stack vertically, Desktop: Side by side --> 17 - <div class="flex flex-col sm:flex-row sm:items-start text-left sm:gap-6"> 18 - <!-- Profile Avatar --> 19 - <img 20 - src={profile?.avatar} 21 - alt="{profile?.displayName || 'User'}'s avatar" 22 - class="rounded-none shadow-lg hover:transform-none flex-shrink-0 relative z-10 23 - w-24 h-24 -mt-12 mx-auto mb-4 24 - sm:w-32 sm:h-32 sm:-mt-16 sm:mx-0 sm:mb-0" 25 - /> 26 - 27 - <!-- User Information: Display name, handle, DID, description, status --> 28 - <div class="flex-1 min-w-0 p-4 rounded-none overflow-hidden" style="background: var(--card-bg);"> 29 - <div class="mb-3"> 30 - <h4 class="text-lg font-semibold mb-1 leading-tight truncate text-center sm:text-left">{profile?.displayName}</h4> 31 - <h6 class="mb-2 text-center sm:text-left"> 32 - <a 33 - href="https://bsky.app/profile/{profile?.handle}" 34 - class="text-link hover:text-link-hover text-sm truncate block">@{profile?.handle}</a 35 - > 36 - </h6> 37 - <h6 class="opacity-40 mb-3 text-center sm:text-left"> 38 - <span class="text-xs font-mono overflow-hidden text-ellipsis whitespace-nowrap hidden sm:block">{profile?.did}</span> 39 - </h6> 40 - </div> 41 - 42 - <!-- Profile Description --> 43 - {#if profile?.description} 44 - <div class="mb-3"> 45 - <p class="text-sm leading-relaxed text-center sm:text-left">{profile?.description}</p> 46 - </div> 47 - {/if} 48 - 49 - <!-- Display consolidated status/music using the updated Status component --> 50 - <div class="text-center sm:text-left"> 51 - <Status {profile} /> 52 - </div> 53 - </div> 54 - </div> 55 - </div> 56 - {:else} 57 - <!-- Placeholder for app.bsky.actor.profile --> 58 - <div 59 - class="profile-content flex flex-col items-center justify-center text-center mx-2 p-4 relative rounded-none" 60 - style="background: var(--card-bg);" 61 - > 62 - <p class="text-center text-sm italic opacity-75"> 63 - create a `app.bsky.actor.profile` record at <a 64 - href="https://bsky.app/" 65 - class="text-link hover:text-link-hover">https://bsky.app/</a 66 - > 67 - </p> 68 - </div> 69 - {/if}
-125
src/lib/components/profile/Status.svelte
··· 1 - <script lang="ts"> 2 - import { onMount } from "svelte"; 3 - import { env } from "$env/dynamic/public"; 4 - import { fade } from "svelte/transition"; 5 - 6 - /** 7 - * The profile object is passed as a prop to this component. 8 - * It should contain at least 'displayName', 'handle', or 'did' fields. 9 - */ 10 - export let profile: any; 11 - 12 - // RecentFM-related state 13 - let musicLoading = false; 14 - let musicError: string | null = null; 15 - let trackData: { 16 - name: string; 17 - artist: string; 18 - url: string; 19 - } | null = null; 20 - let showContent = false; 21 - 22 - /** 23 - * Fetches recent music from Last.fm via RecentFM API 24 - */ 25 - async function fetchRecentMusic(): Promise<void> { 26 - const lastfmUsername = env.PUBLIC_LASTFM_USERNAME; 27 - 28 - if (!lastfmUsername) { 29 - return; 30 - } 31 - 32 - musicLoading = true; 33 - musicError = null; 34 - 35 - try { 36 - const params = new URLSearchParams({ 37 - username: lastfmUsername, 38 - emoji: "🎧", 39 - nomoji: "false", 40 - }); 41 - 42 - const url = `https://recentfm.rknight.me/now.php?${params.toString()}`; 43 - 44 - const response = await fetch(url); 45 - 46 - if (!response.ok) { 47 - throw new Error(`HTTP error! status: ${response.status}`); 48 - } 49 - 50 - const data = await response.json(); 51 - 52 - if (data.content) { 53 - const parser = new DOMParser(); 54 - const doc = parser.parseFromString(data.content, "text/html"); 55 - const link = doc.querySelector("a"); 56 - 57 - if (link) { 58 - const fullText = link.textContent || ""; 59 - const url = link.href; 60 - 61 - const match = fullText.match(/^(.+?) by (.+)$/); 62 - if (match) { 63 - trackData = { 64 - name: match[1].trim(), 65 - artist: match[2].trim(), 66 - url: url, 67 - }; 68 - } 69 - } 70 - } 71 - } catch (err) { 72 - console.error("[RecentFM] Error fetching RecentFM data:", err); 73 - musicError = "Failed to load recent tracks"; 74 - } finally { 75 - musicLoading = false; 76 - } 77 - } 78 - 79 - // Load data on mount 80 - onMount(async () => { 81 - await fetchRecentMusic(); 82 - 83 - // Introduce a delay before showing content 84 - setTimeout(() => { 85 - showContent = true; 86 - }, 2000); // 2 second delay 87 - }); 88 - </script> 89 - 90 - <!-- Last.fm Music Display --> 91 - {#if showContent && profile} 92 - <div transition:fade={{ duration: 500 }}> 93 - <div class="py-2"> 94 - {#if trackData} 95 - <!-- Music Display --> 96 - <div class="recent-track-info"> 97 - <p class="text-xs opacity-60 text-center sm:text-left"> 98 - {profile.displayName || profile.handle || profile.did} was last listening 99 - to 100 - </p> 101 - <p class="text-xs font-medium text-center sm:text-left mt-0.5"> 102 - <a 103 - href={trackData.url} 104 - class="text-link hover:text-link-hover" 105 - target="_blank" 106 - rel="noopener noreferrer" 107 - > 108 - "{trackData.name}" by {trackData.artist} 109 - </a> 110 - </p> 111 - </div> 112 - {:else if musicError} 113 - <p class="text-xs opacity-60 text-center sm:text-left text-red-500"> 114 - {musicError} 115 - </p> 116 - {/if} 117 - 118 - {#if musicLoading} 119 - <p class="text-xs opacity-60 italic text-center sm:text-left"> 120 - Loading recent tracks... 121 - </p> 122 - {/if} 123 - </div> 124 - </div> 125 - {/if}
-2
src/lib/components/profile/index.ts
··· 1 - export { default as Profile } from "./Profile.svelte"; 2 - export { default as Status } from "./Status.svelte";
+2 -2
src/lib/components/profile/profile.ts
··· 20 20 } 21 21 22 22 export async function getProfile(fetch: typeof globalThis.fetch): Promise<Profile> { 23 - const cacheKey = `profile_${env.PUBLIC_ATPROTOCOL_USER}`; 23 + const cacheKey = `profile_${env.DIRECTORY_OWNER}`; 24 24 let profile: Profile | null = getCache<Profile>(cacheKey); 25 25 26 26 if (profile) { ··· 29 29 30 30 try { 31 31 const fetchProfile = await safeFetch( 32 - `https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=${env.PUBLIC_ATPROTOCOL_USER}`, 32 + `https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=${env.DIRECTORY_OWNER}`, 33 33 fetch 34 34 ); 35 35 const split = fetchProfile["did"].split(":");
+1 -1
src/lib/components/shared/NotFoundMessage.svelte
··· 1 1 <div class="flex justify-center items-center min-h-[50vh]"> 2 2 <h1 class="text-center pt-4 pb-4"> 3 - it's either loading or the post doesn't exist 3 + It's either loading or the user doesn't exist 4 4 </h1> 5 5 </div>
+28 -49
src/lib/components/shared/interfaces.ts
··· 1 - /** 2 - * Represents a blog post with its content and metadata. 3 - */ 4 - export interface Post { 5 - postNumber?: number; 6 - title: string; // The title of the post. 7 - rkey: string; // A unique key for the post. 8 - createdAt: Date; // The date and time the post was created. 9 - content: string; // The content of the post, parsed to HTML. 10 - excerpt: string; // A plain text excerpt for meta descriptions. 11 - wordCount: number; // The word count for reading time calculation. 12 - } 13 - 14 - /** 15 - * Represents a blog post in its raw Markdown format. 16 - */ 17 - export interface MarkdownPost { 18 - title: string; // The title of the post. 19 - rkey: string; // A unique key for the post. 20 - createdAt: Date; // The date and time the post was created. 21 - mdcontent: string; // The raw Markdown content of the post. 22 - } 23 - 24 1 // Define the type for the fetched links data 25 2 /** 26 3 * Represents a single link card with a URL, text, and an emoji. ··· 32 9 } 33 10 34 11 /** 12 + * Represents the properties for the DynamicHead component. 13 + */ 14 + export interface DynamicHeadProps { 15 + title: string; 16 + description: string; 17 + keywords: string; 18 + ogUrl?: string; 19 + ogTitle: string; 20 + ogDescription: string; 21 + ogImage?: string; 22 + ogImageWidth?: string; 23 + ogImageHeight?: string; 24 + twitterCard?: string; 25 + twitterUrl?: string; 26 + twitterTitle: string; 27 + twitterDescription: string; 28 + twitterImage?: string; 29 + } 30 + 31 + /** 35 32 * Represents a board containing multiple link cards. 36 33 */ 37 34 export interface LinkBoard { ··· 51 48 * Represents public environment variables. 52 49 */ 53 50 export interface PublicEnv { 54 - PUBLIC_ATPROTOCOL_USER: string; // Public user for ATProtocol. 55 - PUBLIC_ACTIVITYPUB_USER: string; // Public user for ActivityPub. 51 + DIRECTORY_OWNER: string; // Public user for ATProtocol. 56 52 } 57 53 58 54 /** ··· 69 65 } 70 66 71 67 /** 72 - * Represents a status update or a short message. 73 - */ 74 - export interface StatusUpdate { 75 - text: string; // The content of the status update. 76 - createdAt: Date; // The date and time the status update was created. 77 - tid: string; // A unique identifier for the status update. 78 - } 79 - 80 - /** 81 - * Properties for displaying recent content, possibly from a feed or similar source. 82 - */ 83 - export interface RecentFMProps { 84 - nomoji?: boolean; // Optional flag to disable emojis. 85 - displayName?: string; // Optional display name. 86 - } 87 - 88 - /** 89 - * Represents the result of the BlogService, containing posts, profile, and utility functions. 68 + * Represents a user with basic details. 90 69 */ 91 - export interface BlogServiceResult { 92 - posts: Map<string, Post>; 93 - profile: Profile; 94 - sortedPosts: Post[]; 95 - getPost: (rkey: string) => Post | null; 96 - getAdjacentPosts: (rkey: string) => { previous: Post | null; next: Post | null }; 70 + export interface User { 71 + did: string; 72 + handle?: string; 73 + displayName?: string; 74 + avatar?: string; 75 + description?: string; 97 76 }
+36
src/lib/config/linkat-users.ts
··· 1 + import { env } from "$env/dynamic/public"; 2 + 3 + /** 4 + * Configuration for Linkat users to display 5 + * 6 + * Users can be configured via environment variables: 7 + * - PUBLIC_LINKAT_USERS: Comma-separated list of DIDs (e.g., "did:plc:abc123,did:web:example.com") 8 + * - DIRECTORY_OWNER: Primary user DID (fallback if no users configured) 9 + * 10 + * Format: "did:plc:xxxxxxxxxxxxxxxxxxxxxxxx" or "did:web:xxxxxxxxxxxxxxxxxxxxxxxx" 11 + * The first user will be treated as the primary user 12 + */ 13 + 14 + function parseUsersFromEnv(): string[] { 15 + const users: string[] = []; 16 + 17 + // Always include DIRECTORY_OWNER as the primary user if configured 18 + if (env.DIRECTORY_OWNER) { 19 + users.push(env.DIRECTORY_OWNER); 20 + } 21 + 22 + // Add additional users from PUBLIC_LINKAT_USERS, avoiding duplicates 23 + if (env.PUBLIC_LINKAT_USERS) { 24 + const envUsers = env.PUBLIC_LINKAT_USERS.split(',') 25 + .map(did => did.trim()) 26 + .filter(did => did.startsWith('did:') && did !== env.DIRECTORY_OWNER); 27 + users.push(...envUsers); 28 + } 29 + 30 + return users; 31 + } 32 + 33 + export const LINKAT_USERS = parseUsersFromEnv(); 34 + 35 + // Maximum number of users to display (1-10) 36 + export const MAX_USERS = 10;
+7 -138
src/lib/css/app.css
··· 1 1 @import "$css/variables.css"; 2 2 3 - /* Minimalist flat styles with gentle dark pastel green theme */ 3 + /* Linkat Directory - Core CSS Styles */ 4 4 @tailwind base; 5 5 @tailwind components; 6 6 @tailwind utilities; 7 7 8 8 @layer base { 9 9 10 - /* Scrollbar styling */ 10 + /* Custom scrollbar styling for webkit browsers */ 11 11 ::-webkit-scrollbar { 12 12 width: 10px; 13 13 height: 10px; ··· 33 33 body { 34 34 background-color: var(--background-color); 35 35 color: var(--text-color); 36 - font-family: "Recursive", sans-serif; 37 - font-variation-settings: "MONO" 0, "CASL" 0, "wght" 300, "slnt" 0, 38 - "CRSV" 0.5; 36 + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; 39 37 } 40 38 41 39 h1 { 42 40 @apply text-4xl font-bold; 43 - font-variation-settings: "MONO" 0, "CASL" 0.8, "wght" 700, "slnt" 0, 44 - "CRSV" 0.9; 45 41 } 46 42 47 43 h2 { 48 44 @apply text-3xl font-bold; 49 - font-variation-settings: "MONO" 0, "CASL" 0.7, "wght" 650, "slnt" 0, 50 - "CRSV" 0.85; 51 45 } 52 46 53 47 h3 { 54 48 @apply text-2xl font-bold; 55 - font-variation-settings: "MONO" 0, "CASL" 0.6, "wght" 600, "slnt" 0, 56 - "CRSV" 0.8; 57 49 } 58 50 59 51 h4 { 60 52 @apply text-xl font-semibold; 61 - font-variation-settings: "MONO" 0, "CASL" 0.5, "wght" 550, "slnt" 0, 62 - "CRSV" 0.75; 63 53 } 64 54 65 55 h5 { 66 56 @apply text-lg font-semibold; 67 - font-variation-settings: "MONO" 0, "CASL" 0.4, "wght" 500, "slnt" 0, 68 - "CRSV" 0.7; 69 57 } 70 58 71 59 h6 { 72 60 @apply text-sm font-semibold; 73 - font-variation-settings: "MONO" 0, "CASL" 0.3, "wght" 450, "slnt" 0, 74 - "CRSV" 0.6; 75 61 } 76 62 77 63 a { 78 64 @apply text-[var(--link-color)] hover:text-[var(--link-hover-color)] no-underline; 79 - font-variation-settings: "MONO" 0, "CASL" 0, "wght" 450, "slnt" 0, 80 - "CRSV" 0.5; 81 - /* Reduced transition complexity */ 65 + /* Simplified hover transitions */ 82 66 transition: color 0.2s ease; 83 67 } 84 68 85 69 a:hover { 86 - font-variation-settings: "MONO" 0, "CASL" 0, "wght" 600, "slnt" 0, 87 - "CRSV" 0.5; 70 + @apply font-semibold; 88 71 } 89 72 90 73 a, ··· 95 78 text-decoration: none !important; 96 79 } 97 80 98 - /* Last.FM info styles */ 99 - .recent-played { 100 - @apply text-center; 101 - } 102 - 103 - .recent-track-info { 104 - @apply space-y-0.5; 105 - } 106 - 107 81 /* Header links - simplified transitions */ 108 82 header a, 109 83 a.font-medium { ··· 117 91 transform: scale(1.1); 118 92 } 119 93 120 - /* Typography styles for blog content */ 94 + /* Typography styles for blog/prose content using Tailwind Typography plugin */ 121 95 .prose { 122 96 @apply max-w-none; 123 97 } ··· 153 127 text-decoration: none; 154 128 } 155 129 156 - /* Update prose elements to use variables */ 130 + /* Prose element styling with CSS variables for theme consistency */ 157 131 .prose a { 158 132 color: var(--link-color); 159 133 text-decoration: none; ··· 184 158 .prose pre code { 185 159 @apply bg-transparent p-0; 186 160 } 187 - 188 - /* Post grid and cards - simplified animations */ 189 - .post-card { 190 - @apply border-0 rounded-none p-4 shadow-md; 191 - background-color: var(--card-bg); 192 - color: var(--text-color); 193 - /* Only animate specific properties on hover */ 194 - transition: transform 0.2s ease, box-shadow 0.2s ease, background-color 0.2s ease; 195 - } 196 - 197 - .post-card:hover { 198 - background-color: var(--header-footer-bg); 199 - transform: scale(1.05); 200 - box-shadow: 0 10px 25px rgba(0, 0, 0, 0.15); 201 - } 202 - 203 - /* Profile banner - simplified */ 204 - .profile-banner { 205 - @apply bg-cover bg-center rounded-md border-0; 206 - transition: opacity 0.2s ease; 207 - } 208 - 209 - .profile-banner:hover { 210 - opacity: 0.95; 211 - } 212 - 213 - /* Profile section elements */ 214 - .flex-wrap { 215 - @apply gap-4 items-center; 216 - } 217 - 218 - div.items-center { 219 - @apply p-3; 220 - } 221 - 222 - .rounded-full { 223 - @apply shadow-none; 224 - transition: transform 0.2s ease; 225 - } 226 - 227 - .rounded-full:hover { 228 - transform: scale(1.05); 229 - } 230 - 231 - /* Account handle and related info */ 232 - h4.content-around, 233 - h6.content-around { 234 - @apply my-2 text-left; 235 - } 236 - 237 - /* Links styling within the profile section - simplified */ 238 - .profile-banner a, 239 - .flex-wrap a, 240 - div.items-center a, 241 - h4.content-around a, 242 - h6.content-around a { 243 - @apply no-underline hover:no-underline inline-block; 244 - color: var(--link-color); 245 - transition: color 0.2s ease, transform 0.2s ease; 246 - } 247 - 248 - .profile-banner a:hover, 249 - .flex-wrap a:hover, 250 - div.items-center a:hover, 251 - h4.content-around a:hover, 252 - h6.content-around a:hover { 253 - color: var(--link-hover-color); 254 - transform: scale(1.1); 255 - } 256 - 257 - /* Remove any borders throughout profile section */ 258 - .profile-banner, 259 - .profile-banner *, 260 - .flex-wrap *, 261 - div.items-center *, 262 - .rounded-full, 263 - h4.content-around *, 264 - h6.content-around * { 265 - @apply border-0; 266 - } 267 - 268 - /* Comments section styling */ 269 - :global(.comments-section) { 270 - @apply mt-8 p-4 border-0 rounded-md bg-[var(--header-footer-bg)]; 271 - } 272 - 273 - :global(.comments-section input, .comments-section button) { 274 - @apply bg-[var(--button-bg)] border-0 rounded-sm; 275 - } 276 - } 277 - 278 - .prose+.flex.justify-between { 279 - @apply mt-12 mb-8; 280 - } 281 - 282 - .prose+.flex.justify-between a { 283 - @apply max-w-[45%] truncate; 284 - } 285 - 286 - .prose+.flex.justify-between a:first-child { 287 - @apply text-left; 288 - } 289 - 290 - .prose+.flex.justify-between a:last-child { 291 - @apply text-right; 292 161 } 293 162 294 163 .text-link {
+35 -21
src/lib/css/variables.css
··· 1 - /* Default mono theme - dark mode */ 2 - :root { 3 - font-family: sans-serif, system-ui; 4 - --background-color: #1a1a1a; 5 - --text-color: #f0f0f0; 6 - --header-footer-bg: #2a2a2a; 7 - --button-bg: #4a4a4a; 8 - --button-hover-bg: #6a6a6a; 9 - --link-color: #9a9a9a; 10 - --link-hover-color: #b0b0b0; 11 - --card-bg: #2a2a2a; 1 + @media (prefers-color-scheme: dark) { 2 + /* Default mono theme - dark mode */ 3 + :root { 4 + font-family: sans-serif, system-ui; 5 + --background-color: #1a1a1a; 6 + --text-color: #f0f0f0; 7 + --header-footer-bg: #2a2a2a; 8 + --button-bg: #4a4a4a; 9 + --button-hover-bg: #6a6a6a; 10 + --link-color: #9a9a9a; 11 + --link-hover-color: #b0b0b0; 12 + --card-bg: #2a2a2a; 13 + --border-color: #404040; 14 + --error-color: #ff6b6b; 15 + --placeholder-color: #666666; 16 + --secondary-text-color: #b0b0b0; 17 + --muted-bg: #333333; 18 + } 12 19 } 13 20 14 - /* Default mono theme - light mode */ 15 - :root.light { 16 - --background-color: #f0f0f0; 17 - --text-color: #1a1a1a; 18 - --header-footer-bg: #e0e0e0; 19 - --button-bg: #b0b0b0; 20 - --button-hover-bg: #9a9a9a; 21 - --link-color: #6a6a6a; 22 - --link-hover-color: #4a4a4a; 23 - --card-bg: #e0e0e0; 21 + @media (prefers-color-scheme: light) { 22 + /* Default mono theme - light mode */ 23 + :root { 24 + --background-color: #f0f0f0; 25 + --text-color: #1a1a1a; 26 + --header-footer-bg: #e0e0e0; 27 + --button-bg: #b0b0b0; 28 + --button-hover-bg: #9a6a6a; 29 + --link-color: #6a6a6a; 30 + --link-hover-color: #4a4a4a; 31 + --card-bg: #e0e0e0; 32 + --border-color: #cccccc; 33 + --error-color: #dc2626; 34 + --placeholder-color: #999999; 35 + --secondary-text-color: #4a4a4a; 36 + --muted-bg: #d0d0d0; 37 + } 24 38 }
-130
src/lib/themeLoader.ts
··· 1 - import type { Theme } from "$components/shared/interfaces"; 2 - 3 - // Theme configuration - single source of truth 4 - export const THEMES: Theme[] = [ 5 - { id: "default", name: "Green (Default)" }, 6 - ]; 7 - 8 - export const THEME_STORAGE_KEYS = { 9 - MODE: "theme-mode", 10 - COLOR: "color-theme", 11 - } as const; 12 - 13 - export type ThemeMode = "light" | "dark"; 14 - 15 - /** 16 - * Applies theme classes to the document element 17 - */ 18 - export function applyTheme(isDarkMode: boolean, themeId: string): void { 19 - // Remove all existing theme classes 20 - document.documentElement.classList.remove("light"); 21 - THEMES.forEach((theme) => { 22 - if (theme.id !== "default") { 23 - document.documentElement.classList.remove(theme.id); 24 - } 25 - }); 26 - 27 - // Apply light mode class if needed 28 - if (!isDarkMode) { 29 - document.documentElement.classList.add("light"); 30 - } 31 - 32 - // Apply color theme class if not default 33 - if (themeId !== "default") { 34 - document.documentElement.classList.add(themeId); 35 - } 36 - } 37 - 38 - /** 39 - * Gets the user's theme preferences from localStorage and system 40 - */ 41 - export function getThemePreferences(): { isDarkMode: boolean; themeId: string } { 42 - const savedThemeMode = localStorage.getItem(THEME_STORAGE_KEYS.MODE); 43 - const savedColorTheme = localStorage.getItem(THEME_STORAGE_KEYS.COLOR); 44 - 45 - let isDarkMode: boolean; 46 - if (savedThemeMode) { 47 - isDarkMode = savedThemeMode === "dark"; 48 - } else { 49 - // Use system preference as default 50 - const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches; 51 - isDarkMode = prefersDark; 52 - } 53 - 54 - const themeId = savedColorTheme || "default"; 55 - 56 - return { isDarkMode, themeId }; 57 - } 58 - 59 - /** 60 - * Saves theme preferences to localStorage 61 - */ 62 - export function saveThemePreferences(isDarkMode: boolean, themeId: string): void { 63 - localStorage.setItem(THEME_STORAGE_KEYS.MODE, isDarkMode ? "dark" : "light"); 64 - localStorage.setItem(THEME_STORAGE_KEYS.COLOR, themeId); 65 - } 66 - 67 - /** 68 - * Updates the theme-color meta tag for browser UI 69 - */ 70 - export function updateThemeColorMeta(): void { 71 - const themeColor = getComputedStyle(document.documentElement) 72 - .getPropertyValue("--background-color") 73 - .trim(); 74 - 75 - let metaTag = document.querySelector('meta[name="theme-color"]') as HTMLMetaElement; 76 - if (!metaTag) { 77 - metaTag = document.createElement("meta"); 78 - metaTag.setAttribute("name", "theme-color"); 79 - document.head.appendChild(metaTag); 80 - } 81 - metaTag.setAttribute("content", themeColor); 82 - } 83 - 84 - /** 85 - * Dispatches a custom event when theme changes 86 - */ 87 - export function dispatchThemeChangeEvent(isDarkMode: boolean, themeId: string): void { 88 - document.dispatchEvent( 89 - new CustomEvent("themeChanged", { 90 - detail: { isDarkMode, theme: themeId }, 91 - }) 92 - ); 93 - } 94 - 95 - /** 96 - * Sets up system theme change listener 97 - */ 98 - export function setupSystemThemeListener(): void { 99 - window 100 - .matchMedia("(prefers-color-scheme: dark)") 101 - .addEventListener("change", (e) => { 102 - // Only apply system preference if user hasn't set a manual preference 103 - if (localStorage.getItem(THEME_STORAGE_KEYS.MODE) === null) { 104 - const { themeId } = getThemePreferences(); 105 - applyTheme(e.matches, themeId); 106 - updateThemeColorMeta(); 107 - dispatchThemeChangeEvent(e.matches, themeId); 108 - } 109 - }); 110 - } 111 - 112 - /** 113 - * Initializes theme system - should be called as early as possible 114 - */ 115 - export function initializeTheme(): void { 116 - const { isDarkMode, themeId } = getThemePreferences(); 117 - applyTheme(isDarkMode, themeId); 118 - 119 - // Update meta tag when DOM is ready 120 - if (document.readyState === "loading") { 121 - document.addEventListener("DOMContentLoaded", updateThemeColorMeta); 122 - } else { 123 - updateThemeColorMeta(); 124 - } 125 - } 126 - 127 - // Auto-initialize when script loads (for inline usage) 128 - if (typeof window !== "undefined") { 129 - initializeTheme(); 130 - }
-121
src/lib/utils/formatters.ts
··· 1 - /** 2 - * Returns the ordinal suffix for a given number (e.g., "st", "nd", "rd", "th"). 3 - * @param num The number to get the ordinal suffix for. 4 - * @returns The ordinal suffix. 5 - */ 6 - export function getOrdinalSuffix(num: number): string { 7 - const lastDigit = num % 10; 8 - const lastTwoDigits = num % 100; 9 - 10 - if (lastTwoDigits >= 11 && lastTwoDigits <= 13) { 11 - return 'th'; 12 - } 13 - 14 - switch (lastDigit) { 15 - case 1: 16 - return 'st'; 17 - case 2: 18 - return 'nd'; 19 - case 3: 20 - return 'rd'; 21 - default: 22 - return 'th'; 23 - } 24 - } 25 - 26 - export function formatDate( 27 - date: Date | string, 28 - locale: string = typeof window !== "undefined" 29 - ? window.navigator.language 30 - : "en-GB" 31 - ): string { 32 - const dateObj = new Date(date); 33 - 34 - const options: Intl.DateTimeFormatOptions = { 35 - year: "numeric", 36 - month: "long", 37 - day: "numeric", 38 - hour: "2-digit", 39 - minute: "2-digit", 40 - }; 41 - 42 - const formattedDate = new Intl.DateTimeFormat(locale, options).format( 43 - dateObj 44 - ); 45 - 46 - // Only add ordinal suffix for English locales 47 - if (locale.startsWith("en")) { 48 - const day = dateObj.getDate(); 49 - return formattedDate.replace(/(\d+)/, `$1${getOrdinalSuffix(day)}`); 50 - } 51 - 52 - return formattedDate; 53 - } 54 - 55 - export function formatMonthYear( 56 - date: Date | string, 57 - locale: string = typeof window !== "undefined" 58 - ? window.navigator.language 59 - : "en-GB" 60 - ): string { 61 - const dateObj = new Date(date); 62 - 63 - const options: Intl.DateTimeFormatOptions = { 64 - year: "numeric", 65 - month: "long", 66 - }; 67 - 68 - return new Intl.DateTimeFormat(locale, options).format(dateObj); 69 - } 70 - 71 - // Function to format a date relative to the current time (e.g., '2 hours ago') 72 - export function formatRelativeTime( 73 - date: Date | string, 74 - locale: string = typeof window !== "undefined" 75 - ? window.navigator.language 76 - : "en-GB" 77 - ): string { 78 - const dateObj = new Date(date); 79 - const now = new Date(); 80 - const diffInSeconds = Math.round((now.getTime() - dateObj.getTime()) / 1000); 81 - 82 - const rtf = new Intl.RelativeTimeFormat(locale, { numeric: "auto" }); 83 - 84 - const units: { unit: Intl.RelativeTimeFormatUnit; seconds: number }[] = [ 85 - { unit: "year", seconds: 31536000 }, 86 - { unit: "month", seconds: 2592000 }, 87 - { unit: "week", seconds: 604800 }, 88 - { unit: "day", seconds: 86400 }, 89 - { unit: "hour", seconds: 3600 }, 90 - { unit: "minute", seconds: 60 }, 91 - { unit: "second", seconds: 1 }, 92 - ]; 93 - 94 - for (const { unit, seconds } of units) { 95 - if (Math.abs(diffInSeconds) >= seconds) { 96 - const value = Math.round(diffInSeconds / seconds); 97 - return rtf.format(-value, unit); 98 - } 99 - } 100 - 101 - return rtf.format(0, "second"); // Should ideally not happen if date is in the past 102 - } 103 - 104 - /** 105 - * Formats a number according to the specified locale and options. 106 - * @param value The number to format. 107 - * @param locale The locale to use for formatting (e.g., 'en-GB', 'en-US'). Defaults to 'en-GB'. 108 - * @param options Options for number formatting, conforming to Intl.NumberFormatOptions. 109 - * @returns The formatted number string. 110 - */ 111 - export function formatNumber(value: number, locale: string = 'en-GB', options?: Intl.NumberFormatOptions): string { 112 - if (typeof value !== 'number') { 113 - return String(value); // Return as string if not a number 114 - } 115 - try { 116 - return new Intl.NumberFormat(locale, options).format(value); 117 - } catch (error) { 118 - console.error(`Error formatting number for locale ${locale}:`, error); 119 - return value.toLocaleString(locale); // Fallback to basic toLocaleString on error 120 - } 121 - }
-100
src/lib/utils/milestones.ts
··· 1 - import { formatNumber, getOrdinalSuffix } from './formatters'; 2 - 3 - 4 - export interface Milestone { 5 - text: string; 6 - emoji: string; 7 - type: 'special' | 'major' | 'minor'; 8 - } 9 - 10 - /** 11 - * Determines if a post number represents a milestone and returns milestone info 12 - */ 13 - export function getMilestone(postNumber: number): Milestone | null { 14 - // Special milestones defined in a more maintainable structure. 15 - const specialMilestones: { number: number; text: string; emoji: string; }[] = [ 16 - { number: 1, text: "First Post!", emoji: "🎉" }, 17 - { number: 100, text: "Centennial Post!", emoji: "💯" }, 18 - { number: 365, text: "Daily Dose Complete!", emoji: "�" }, 19 - { number: 500, text: "Half Thousand!", emoji: "🏆" }, 20 - { number: 1000, text: "One Thousand Posts!", emoji: "🌟" }, 21 - { number: 10000, text: "Ten Thousand Posts!", emoji: "🚀" }, 22 - { number: 200, text: "Double Century!", emoji: "🎉🎉" }, 23 - { number: 250, text: "Quarter Thousand!", emoji: "✨✨" }, 24 - { number: 750, text: "Three-Quarter Thousand!", emoji: "💫💫" }, 25 - ]; 26 - 27 - for (const milestone of specialMilestones) { 28 - if (postNumber === milestone.number) { 29 - return { 30 - text: milestone.text, 31 - emoji: milestone.emoji, 32 - type: 'special' 33 - }; 34 - } 35 - } 36 - 37 - // Major milestones (every 50 posts after 100) 38 - if (postNumber > 100 && postNumber % 250 === 0) { 39 - return { 40 - text: `${formatNumber(postNumber)} Posts!`, 41 - emoji: "�", 42 - type: 'major' 43 - }; 44 - } 45 - 46 - if (postNumber > 100 && postNumber % 50 === 0) { 47 - return { 48 - text: `${formatNumber(postNumber)} Posts!`, 49 - emoji: "🎯", 50 - type: 'major' 51 - }; 52 - } 53 - 54 - // Specific major milestone that doesn't fit the general rule. 55 - if (postNumber === 150) { 56 - return { 57 - text: "One Hundred Fifty Posts!", 58 - emoji: "🎉", 59 - type: 'major' 60 - }; 61 - } 62 - 63 - // Minor milestones (every 10 posts, but not major milestones). 64 - // This check should come after special and major milestones to ensure correct precedence. 65 - if (postNumber % 10 === 0 && postNumber % 50 !== 0) { 66 - const ordinal = getOrdinal(postNumber); 67 - return { 68 - text: `${ordinal} Post!`, 69 - emoji: "✨", 70 - type: 'minor' 71 - }; 72 - } 73 - 74 - // Very special fun ones that are not part of the main special milestones array. 75 - const funMilestones: { number: number; text: string; emoji: string; }[] = [ 76 - { number: 404, text: "Post Not Found!", emoji: "🔍" }, 77 - { number: 123, text: "One Two Three!", emoji: "🔢" }, 78 - { number: 333, text: "Triple Three!", emoji: "✨✨✨" }, 79 - ]; 80 - 81 - for (const milestone of funMilestones) { 82 - if (postNumber === milestone.number) { 83 - return { 84 - text: milestone.text, 85 - emoji: milestone.emoji, 86 - type: 'special' 87 - }; 88 - } 89 - } 90 - 91 - return null; 92 - } 93 - 94 - /** 95 - * Converts a number to its ordinal form (1st, 2nd, 3rd, etc.) 96 - */ 97 - function getOrdinal(num: number): string { 98 - const formatted = formatNumber(num); 99 - return formatted + getOrdinalSuffix(num); 100 - }
-40
src/lib/utils/tally.ts
··· 1 - /** 2 - * Calculates the total read time for a given array of posts. 3 - * Assumes an average reading speed of 200 words per minute. 4 - * @param posts - An array of post objects, each with a 'wordCount' property. 5 - * @returns The total read time in minutes, rounded up. 6 - */ 7 - export function calculateTotalReadTime(posts: { wordCount: number }[]): number { 8 - return posts.reduce((total, post) => { 9 - return total + Math.ceil(post.wordCount / 200); 10 - }, 0); 11 - } 12 - 13 - /** 14 - * Calculates the total word count for a given array of posts. 15 - * @param posts - An array of post objects, each with a 'wordCount' property. 16 - * @returns The total word count. 17 - */ 18 - export function calculateTotalWordCount(posts: { wordCount: number }[]): number { 19 - return posts.reduce((total, post) => total + post.wordCount, 0); 20 - } 21 - 22 - /** 23 - * Formats a given number of minutes into a human-readable string (e.g., "2 hours", "3 days"). 24 - * @param minutes - The total number of minutes. 25 - * @returns A formatted string representing the time. 26 - */ 27 - export function formatReadTime(minutes: number): string { 28 - if (minutes < 60) { 29 - return `${minutes} min`; 30 - } else if (minutes < 60 * 24) { 31 - const hours = Math.round(minutes / 60); 32 - return `${hours} hour${hours === 1 ? '' : 's'}`; 33 - } else if (minutes < 60 * 24 * 7) { 34 - const days = Math.round(minutes / (60 * 24)); 35 - return `${days} day${days === 1 ? '' : 's'}`; 36 - } else { 37 - const weeks = Math.round(minutes / (60 * 24 * 7)); 38 - return `${weeks} week${weeks === 1 ? '' : 's'}`; 39 - } 40 - }
-69
src/lib/utils/textProcessor.ts
··· 1 - import remarkParse from "remark-parse"; 2 - import remarkGfm from "remark-gfm"; 3 - import remarkRehype from "remark-rehype"; 4 - import rehypeStringify from "rehype-stringify"; 5 - import { unified } from "unified"; 6 - import type { Node } from "unist"; 7 - 8 - type TextNode = Node & { type: "text"; value: string }; 9 - type ParentNode = Node & { children: Node[] }; 10 - 11 - /** 12 - * Extracts plain text from markdown content and truncates it to a specified length. 13 - * @param markdown The markdown content to process. 14 - * @param maxLength The maximum length of the extracted text (default: 160). 15 - * @returns A promise that resolves to the extracted and truncated plain text. 16 - */ 17 - export async function extractTextFromMarkdown( 18 - markdown: string, 19 - maxLength: number = 160 20 - ): Promise<string> { 21 - // Process the markdown to get plain text 22 - const plainText = String( 23 - await unified() 24 - .use(remarkParse, { fragment: true }) 25 - .use(remarkGfm) 26 - .use(() => (tree) => { 27 - // Simple transformer that visits all nodes and removes everything but text 28 - const visit = (node: Node): string => { 29 - if (node.type === "text") { 30 - const textNode = node as TextNode; 31 - return textNode.value; 32 - } 33 - if ("children" in node) { 34 - const parentNode = node as ParentNode; 35 - return parentNode.children.map(visit).filter(Boolean).join(" "); 36 - } 37 - return ""; 38 - }; 39 - 40 - // Replace tree with just text content 41 - return { 42 - type: "root", 43 - children: [{ type: "text", value: visit(tree) }], 44 - }; 45 - }) 46 - .use(remarkRehype) 47 - .use(rehypeStringify) 48 - .process(markdown) 49 - ); 50 - 51 - // Clean up the text 52 - let cleaned = plainText.replace(/\s+/g, " ").trim(); 53 - 54 - // Truncate to maxLength if necessary 55 - if (cleaned.length > maxLength) { 56 - cleaned = cleaned.substring(0, maxLength) + "..."; 57 - } 58 - 59 - return cleaned; 60 - } 61 - 62 - /** 63 - * Calculates the word count of a given string. 64 - * @param text The input string. 65 - * @returns The number of words in the string. 66 - */ 67 - export function calculateWordCount(text: string): number { 68 - return text.split(/\s+/).filter((word) => word.length > 0).length; 69 - }
+9 -20
src/routes/+layout.svelte
··· 1 1 <script lang="ts"> 2 2 import "$css/app.css"; 3 - import { getStores } from "$app/stores"; 4 - const { page } = getStores(); 5 - import Profile from "$components/profile/Profile.svelte"; 6 - import { Navigation, Footer } from "$components/layout"; 3 + import { Footer } from "$components/layout"; 4 + import DirectoryHeader from "$lib/components/layout/DirectoryHeader.svelte"; 7 5 8 6 let { data, children } = $props(); 9 - 10 - // Check if we're on the home page or blog page using $derived 11 - const showProfile = $derived( 12 - $page.route.id ? ["/", "/blog"].includes($page.route.id) : false 13 - ); 14 - const isHomePage = $derived($page.route.id === "/"); 15 - const isBlogIndex = $derived($page.route.id === "/blog"); 16 7 </script> 17 8 18 - <div class="box-border mx-auto px-4 sm:px-8 max-w-[1000px] pb-8"> 19 - <Navigation {isHomePage} {isBlogIndex} /> 20 - 21 - {#if showProfile} 22 - <Profile profile={data.profile} /> 23 - {/if} 24 - 25 - {@render children()} 9 + <div class="min-h-screen"> 10 + <DirectoryHeader /> 11 + 12 + <div class="box-border mx-auto px-4 sm:px-8 max-w-[1000px] pb-8"> 13 + {@render children()} 26 14 27 - <Footer profile={data.profile} posts={data.posts} /> 15 + <Footer profile={data.profile} posts={data.posts} /> 16 + </div> 28 17 </div>
+81 -20
src/routes/+layout.ts
··· 1 - import { getProfile } from "$components/profile/profile"; 2 - import { getLatestPosts } from "$services/blogService"; 1 + import { getProfile, safeFetch } from "$components/profile/profile"; 3 2 import type { Profile, LinkBoard } from "$components/shared"; 3 + import { LINKAT_USERS } from "$lib/config/linkat-users"; 4 + import { env } from "$env/dynamic/public"; 4 5 5 6 // Profile data cache 6 - let profile: Profile; 7 + let profile: Profile | undefined; 7 8 let dynamicLinks: LinkBoard | undefined; 8 9 9 10 export async function load({ fetch }) { 10 - if (profile === undefined) { 11 - profile = await getProfile(fetch); 11 + const userDids = LINKAT_USERS; 12 + 13 + // If no users configured, return empty state 14 + if (userDids.length === 0) { 15 + return { 16 + profile: null, 17 + pdsUrl: null, 18 + did: null, 19 + posts: new Map(), 20 + dynamicLinks: null, 21 + userLinkBoards: {}, 22 + linkatUsers: [], 23 + noUsersConfigured: true, 24 + }; 25 + } 26 + 27 + // Use the first user as primary if not already cached 28 + const primaryUserDid = userDids[0]; 29 + if (!profile || profile.did !== primaryUserDid) { 30 + // Create a mock profile if we can't fetch the real one 31 + try { 32 + // Temporarily set env to the primary user for getProfile 33 + const originalEnv = env.DIRECTORY_OWNER; 34 + env.DIRECTORY_OWNER = primaryUserDid; 35 + profile = await getProfile(fetch); 36 + if (originalEnv) env.DIRECTORY_OWNER = originalEnv; 37 + } catch (error) { 38 + console.error("Error fetching primary user profile:", error); 39 + // Create fallback profile 40 + profile = { 41 + avatar: "", 42 + banner: "", 43 + displayName: "Linkat User", 44 + did: primaryUserDid, 45 + handle: primaryUserDid, 46 + description: "Linkat directory user", 47 + pds: "https://bsky.social", 48 + }; 49 + } 12 50 } 13 51 14 - // Fetch dynamic links only if not already cached 15 - if (dynamicLinks === undefined) { 52 + const userLinkBoards: { [did: string]: LinkBoard | undefined } = {}; 53 + 54 + // Fetch dynamic links for all configured users 55 + for (const userDid of userDids) { 16 56 try { 17 - const rawResponse = await fetch( 18 - `${profile.pds}/xrpc/com.atproto.repo.listRecords?repo=${profile.did}&collection=blue.linkat.board&rkey=self` 19 - ); 20 - const response = await rawResponse.json(); 21 - if (response && response.records && response.records.length > 0) { 22 - dynamicLinks = response.records[0].value as LinkBoard; 57 + // Get user's PDS 58 + const split = userDid.split(":"); 59 + let pdsurl; 60 + if (split[0] === "did") { 61 + if (split[1] === "plc") { 62 + const diddoc = await safeFetch(`https://plc.directory/${userDid}`, fetch); 63 + for (const service of diddoc["service"]) { 64 + if (service["id"] === "#atproto_pds") { 65 + pdsurl = service["serviceEndpoint"]; 66 + break; 67 + } 68 + } 69 + } else if (split[1] === "web") { 70 + pdsurl = `https://${split[2]}`; 71 + } 72 + } 73 + 74 + if (pdsurl) { 75 + const rawResponse = await fetch( 76 + `${pdsurl}/xrpc/com.atproto.repo.listRecords?repo=${userDid}&collection=blue.linkat.board&rkey=self` 77 + ); 78 + const response = await rawResponse.json(); 79 + if (response && response.records && response.records.length > 0) { 80 + userLinkBoards[userDid] = response.records[0].value as LinkBoard; 81 + } 23 82 } 24 83 } catch (error) { 25 - console.error("Error fetching dynamic links:", error); 84 + console.error(`Error fetching dynamic links for ${userDid}:`, error); 26 85 } 27 86 } 28 87 29 - // Fetch latest blog posts using the consolidated service 30 - const latestPosts = await getLatestPosts(fetch, 3); 88 + // For backward compatibility, keep the single dynamicLinks 89 + dynamicLinks = profile ? userLinkBoards[profile.did] : undefined; 31 90 32 91 return { 33 92 profile, 34 - pdsUrl: profile.pds, 35 - did: profile.did, 36 - posts: new Map(), // Keep this for compatibility with existing Footer component 93 + pdsUrl: profile?.pds, 94 + did: profile?.did, 95 + posts: new Map(), 37 96 dynamicLinks, 38 - latestPosts, 97 + userLinkBoards, 98 + linkatUsers: userDids, 99 + noUsersConfigured: false, 39 100 }; 40 101 }
+58 -43
src/routes/+page.svelte
··· 2 2 import { onMount } from "svelte"; 3 3 import { getStores } from "$app/stores"; 4 4 const { page } = getStores(); 5 - import { DynamicLinks, LatestBlogPost } from "$components/layout/main"; 5 + import UserDirectory from "$lib/components/archive/UserDirectory.svelte"; 6 + import DynamicHead from "$lib/components/layout/DynamicHead.svelte"; 6 7 7 8 let { data } = $props(); 8 9 ··· 15 16 localeLoaded = true; 16 17 }, 10); 17 18 }); 18 - </script> 19 19 20 - <svelte:head> 21 - <title>Site Name</title> 22 - <meta 23 - name="description" 24 - content="Welcome to Site Name - A personal space where I share my thoughts on coding, technology, and life." 25 - /> 26 - <meta 27 - name="keywords" 28 - content="Ewan, personal website, coding, technology, programming, tech blog, Site Name" 29 - /> 20 + import { getProfile } from "$lib/components/profile/profile"; 21 + let profile = $state<{ displayName?: string; handle?: string } | null>(null); 22 + let loading = $state(true); 23 + let error = $state<string | null>(null); 30 24 31 - <!-- Open Graph / Facebook --> 32 - <meta property="og:type" content="website" /> 33 - <meta property="og:url" content={$page.url.origin + $page.url.pathname} /> 34 - <meta property="og:title" content="Site Title" /> 35 - <meta 36 - property="og:description" 37 - content="Welcome to Site Name - A personal space where I share my thoughts on coding, technology, and life." 38 - /> 39 - <meta property="og:site_name" content="Site Name" /> 40 - {#if $page.url.origin} 41 - <meta property="og:image" content={$page.url.origin + "/embed/main.png"} /> 42 - {/if} 43 - <meta property="og:image:width" content="1200" /> 44 - <meta property="og:image:height" content="630" /> 25 + $effect(() => { 26 + if (import.meta.env.DIRECTORY_OWNER) { 27 + loading = true; 28 + getProfile(fetch) 29 + .then((p) => { 30 + profile = p; 31 + error = null; 32 + }) 33 + .catch((err) => { 34 + console.error('Failed to load profile:', err); 35 + error = err.message; 36 + profile = null; 37 + }) 38 + .finally(() => { 39 + loading = false; 40 + }); 41 + } else { 42 + loading = false; 43 + } 44 + }); 45 + </script> 45 46 46 - <!-- Twitter --> 47 - <meta name="twitter:card" content="summary_large_image" /> 48 - <meta name="twitter:url" content={$page.url.origin + $page.url.pathname} /> 49 - <meta name="twitter:title" content="Site Title" /> 50 - <meta 51 - name="twitter:description" content="A personal space where I share my thoughts on coding, technology, and life." 52 - /> 53 - {#if $page.url.origin} 54 - <meta name="twitter:image" content={$page.url.origin + "/embed/main.png"} /> 55 - {/if} 56 - </svelte:head> 57 - 58 - <!-- Latest Blog Post section (only show if we have posts) --> 59 - {#if data.latestPosts && data.latestPosts.length > 0} 60 - <LatestBlogPost posts={data.latestPosts} {localeLoaded} /> 61 - {/if} 47 + <DynamicHead 48 + title={profile?.displayName || "Linkat Directory"} 49 + description={profile?.displayName ? `Discover users' links curated by ${profile.displayName}` : "Discover amazing users curated by the Linkat community"} 50 + keywords={`Linkat, directory, links, Bluesky, community, curation${profile?.displayName ? `, ${profile.displayName}` : ''}`} 51 + ogTitle={profile?.displayName || "Linkat Directory"} 52 + ogDescription={profile?.displayName ? `Discover users' links curated by ${profile.displayName}` : "Discover amazing users' links curated by the Linkat community"} 53 + twitterTitle={profile?.displayName || "Linkat Directory"} 54 + twitterDescription={profile?.displayName ? `Discover users' links curated by ${profile.displayName}` : "Discover amazing users' links curated by the Linkat community"} 55 + /> 62 56 63 - <DynamicLinks data={data.dynamicLinks} /> 57 + <div class="container mx-auto px-4 py-8"> 58 + {#if data.noUsersConfigured} 59 + <div class="text-center py-12"> 60 + <div class="max-w-md mx-auto"> 61 + <p class="text-lg mb-4 opacity-75"> 62 + Welcome to Linkat Directory! No users are currently configured. 63 + </p> 64 + <div class="bg-[var(--muted-bg)] rounded-lg p-6 text-left"> 65 + <h3 class="font-semibold mb-2">To get started:</h3> 66 + <ol class="list-decimal list-inside space-y-2 text-sm"> 67 + <li>Create a <code>.env</code> file in your project root</li> 68 + <li>Add your user DID: <code>DIRECTORY_OWNER=did:plc:your-did-here</code></li> 69 + <li>Or add multiple users: <code>PUBLIC_LINKAT_USERS=did:plc:user1,did:web:user2</code></li> 70 + <li>Restart the development server</li> 71 + </ol> 72 + </div> 73 + </div> 74 + </div> 75 + {:else} 76 + <UserDirectory users={data.linkatUsers.map(did => ({ did }))} /> 77 + {/if} 78 + </div>
+76
src/routes/user/[did]/+layout.ts
··· 1 + import type { LayoutLoad } from "./$types"; 2 + 3 + export const load: LayoutLoad = async ({ params, fetch }) => { 4 + const { did } = params; 5 + 6 + try { 7 + // Fetch user profile 8 + const profileResponse = await fetch( 9 + `https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=${did}` 10 + ); 11 + 12 + if (!profileResponse.ok) { 13 + return { 14 + did, 15 + profile: null, 16 + dynamicLinks: undefined, 17 + error: "User not found" 18 + }; 19 + } 20 + 21 + const profile = await profileResponse.json(); 22 + 23 + // Get user's PDS and fetch Linkat links 24 + const split = did.split(":"); 25 + let pdsurl: string | null = null; 26 + let dynamicLinks = undefined; 27 + 28 + if (split[0] === "did") { 29 + if (split[1] === "plc") { 30 + const diddocResponse = await fetch(`https://plc.directory/${did}`); 31 + if (diddocResponse.ok) { 32 + const diddoc = await diddocResponse.json(); 33 + for (const service of diddoc["service"] || []) { 34 + if (service["id"] === "#atproto_pds") { 35 + pdsurl = service["serviceEndpoint"]; 36 + break; 37 + } 38 + } 39 + } 40 + } else if (split[1] === "web") { 41 + pdsurl = `https://${split[2]}`; 42 + } 43 + } 44 + 45 + if (pdsurl) { 46 + try { 47 + const linksResponse = await fetch( 48 + `${pdsurl}/xrpc/com.atproto.repo.listRecords?repo=${did}&collection=blue.linkat.board&rkey=self` 49 + ); 50 + 51 + if (linksResponse.ok) { 52 + const result = await linksResponse.json(); 53 + if (result.records && result.records.length > 0) { 54 + dynamicLinks = result.records[0].value; 55 + } 56 + } 57 + } catch (error) { 58 + console.error("Error fetching dynamic links:", error); 59 + } 60 + } 61 + 62 + return { 63 + did, 64 + profile, 65 + dynamicLinks, 66 + error: null 67 + }; 68 + } catch (error) { 69 + return { 70 + did, 71 + profile: null, 72 + dynamicLinks: undefined, 73 + error: error instanceof Error ? error.message : "An error occurred" 74 + }; 75 + } 76 + };
+73
src/routes/user/[did]/+page.svelte
··· 1 + <script lang="ts"> 2 + import DynamicLinks from "$lib/components/layout/main/DynamicLinks.svelte"; 3 + import DynamicHead from "$lib/components/layout/DynamicHead.svelte"; 4 + import { getStores } from "$app/stores"; 5 + const { page } = getStores(); 6 + 7 + let { data } = $props(); 8 + 9 + let profile = $derived(data.profile); 10 + let dynamicLinks = $derived(data.dynamicLinks); 11 + let error = $derived(data.error); 12 + let did = $derived(data.did); 13 + </script> 14 + 15 + <DynamicHead 16 + title={profile?.displayName || did + " - Linkat Directory"} 17 + description={"View " + (profile?.displayName || did) + "'s curated Linkat links"} 18 + ogTitle={profile?.displayName || did + " - Linkat Directory"} 19 + ogDescription={"View " + (profile?.displayName || did) + "'s curated Linkat links"} 20 + twitterTitle={profile?.displayName || did + " - Linkat Directory"} 21 + twitterDescription={"View " + (profile?.displayName || did) + "'s curated Linkat links"} 22 + keywords={`Linkat, directory, links, Bluesky, curation, ${profile?.displayName || did}`} 23 + /> 24 + 25 + <div class="container mx-auto px-4 py-8"> 26 + {#if error} 27 + <div class="text-center py-8"> 28 + <h1 class="text-2xl font-bold mb-4">Error</h1> 29 + <p class="text-[var(--error-color)]">{error}</p> 30 + </div> 31 + {:else if !profile} 32 + <div class="text-center py-8"> 33 + <h1 class="text-2xl font-bold mb-4">User Not Found</h1> 34 + <p class="text-[var(--placeholder-color)]">The user with DID {did} was not found.</p> 35 + </div> 36 + {:else} 37 + <div class="max-w-4xl mx-auto"> 38 + <!-- Profile Header --> 39 + <div class="bg-[var(--card-bg)] rounded-lg shadow-md p-6 mb-6"> 40 + <div class="flex flex-col sm:flex-row items-center sm:items-start gap-4 mb-4"> 41 + {#if profile.avatar} 42 + <img 43 + src={profile.avatar} 44 + alt={profile.displayName || profile.handle} 45 + class="w-20 h-20 rounded-full object-cover" 46 + /> 47 + {:else} 48 + <div class="w-20 h-20 rounded-full bg-[var(--muted-bg)] flex items-center justify-center"> 49 + <span class="text-3xl font-bold text-[var(--text-color)]"> 50 + {(profile.displayName || profile.handle || '?').charAt(0).toUpperCase()} 51 + </span> 52 + </div> 53 + {/if} 54 + 55 + <div class="text-center sm:text-left"> 56 + <h1 class="text-2xl font-bold">{profile.displayName || profile.handle}</h1> 57 + <p class="text-[var(--secondary-text-color)]">@{profile.handle}</p> 58 + <code class="text-[var(--secondary-text-color)] text-sm">{did}</code> 59 + {#if profile.description} 60 + <p class="text-[var(--text-color)] mt-2">{profile.description}</p> 61 + {/if} 62 + </div> 63 + </div> 64 + </div> 65 + 66 + <!-- Links Section --> 67 + <div class="bg-[var(--card-bg)] rounded-lg shadow-md p-6"> 68 + <h2 class="text-xl font-bold mb-4">Links</h2> 69 + <DynamicLinks data={dynamicLinks} /> 70 + </div> 71 + </div> 72 + {/if} 73 + </div>
-1
static/.well-known/atproto-did
··· 1 - placeholder - replace with your DID
static/logo.ico

This is a binary file and will not be displayed.

static/logo.png

This is a binary file and will not be displayed.

-79
static/scripts/themeLoader.js
··· 1 - // Inline theme loader - compiled from themeLoader.ts 2 - // This runs immediately when the script loads 3 - (function() { 4 - 'use strict'; 5 - 6 - // Theme configuration - single source of truth 7 - const THEMES = [ 8 - { id: "default", name: "Green (Default)" } 9 - ]; 10 - 11 - const THEME_STORAGE_KEYS = { 12 - MODE: "theme-mode", 13 - COLOR: "color-theme", 14 - }; 15 - 16 - /** 17 - * Applies theme classes to the document element 18 - */ 19 - function applyTheme(isDarkMode, themeId) { 20 - // Remove all existing theme classes 21 - document.documentElement.classList.remove("light"); 22 - THEMES.forEach((theme) => { 23 - if (theme.id !== "default") { 24 - document.documentElement.classList.remove(theme.id); 25 - } 26 - }); 27 - 28 - // Apply light mode class if needed 29 - if (!isDarkMode) { 30 - document.documentElement.classList.add("light"); 31 - } 32 - 33 - // Apply color theme class if not default 34 - if (themeId !== "default") { 35 - document.documentElement.classList.add(themeId); 36 - } 37 - } 38 - 39 - /** 40 - * Gets the user's theme preferences from localStorage and system 41 - */ 42 - function getThemePreferences() { 43 - const savedThemeMode = localStorage.getItem(THEME_STORAGE_KEYS.MODE); 44 - const savedColorTheme = localStorage.getItem(THEME_STORAGE_KEYS.COLOR); 45 - 46 - let isDarkMode; 47 - if (savedThemeMode) { 48 - isDarkMode = savedThemeMode === "dark"; 49 - } else { 50 - // Use system preference as default 51 - const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches; 52 - isDarkMode = prefersDark; 53 - } 54 - 55 - const themeId = savedColorTheme || "default"; 56 - 57 - return { isDarkMode, themeId }; 58 - } 59 - 60 - /** 61 - * Initializes theme system - runs immediately 62 - */ 63 - function initializeTheme() { 64 - const { isDarkMode, themeId } = getThemePreferences(); 65 - applyTheme(isDarkMode, themeId); 66 - } 67 - 68 - // Initialize theme immediately 69 - initializeTheme(); 70 - 71 - // Make theme functions available globally for the Svelte component 72 - window.__themeLoader = { 73 - THEMES, 74 - THEME_STORAGE_KEYS, 75 - applyTheme, 76 - getThemePreferences, 77 - initializeTheme 78 - }; 79 - })();
+3
svelte.config.js
··· 21 21 '$css': './src/lib/css', 22 22 '$services': './src/lib/services', 23 23 '$utils': './src/lib/utils' 24 + }, 25 + env: { 26 + publicPrefix: '' 24 27 } 25 28 } 26 29 };