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 2 3 [![No Maintenance Intended](http://unmaintained.tech/badge.svg)](http://unmaintained.tech/) 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). 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. 8 9 ## Installation 10 11 - To commence using this template, ensure Node.js and npm are installed on your system. 12 13 - ### Prerequisites 14 15 - - Node.js (LTS version recommended) 16 - - npm (comes with Node.js) 17 - - Docker and Docker Compose (for Dockerised deployment) 18 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. 34 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. 38 39 ## Usage 40 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 55 56 - - `app.bsky.actor.profile`: Your profile. 57 - - `com.whtwnd.blog.entry`: Your blog posts. 58 - - `blue.linkat.board`: Your links. 59 - 60 - ### Deployment 61 62 - #### Standalone 63 64 - To build and run the project as a standalone application: 65 66 - ```sh 67 - npm install 68 - npm run build 69 - node index.js 70 - ``` 71 72 - Environment variables can be set before the last command, and the port can be configured with the `PORT` variable. 73 74 - #### Dockerised 75 76 - To deploy using Docker: 77 78 - 1. Modify `compose.yaml` to change the host port if necessary. 79 - 2. Run the following command: 80 81 - ```sh 82 - docker compose up -d 83 - ``` 84 85 - ## Licensing 86 87 - This project is a template based on WhiteBreeze. For comprehensive licensing details, please consult the `LICENSE` file within this repository.
··· 1 + # Linkat Directory 2 3 [![No Maintenance Intended](http://unmaintained.tech/badge.svg)](http://unmaintained.tech/) 4 5 + <img src="./static/logo.png" alt="Linkat Directory" width="100"/> 6 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. 10 11 ## Installation 12 13 + To set up the Linkat Directory locally, follow these steps: 14 15 + 1. **Clone the repository:** 16 + ```bash 17 + git clone git@github.com:ewanc26/linkat-directory.git 18 + cd linkat-directory 19 + ``` 20 21 + 2. **Install dependencies:** 22 + ```bash 23 + npm install 24 + ``` 25 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 + ``` 38 39 + 4. **Run the development server:** 40 + ```bash 41 + npm run dev 42 + ``` 43 + The application will be accessible at `http://localhost:5173`. 44 45 ## Usage 46 47 + Once the application is running, you can: 48 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. 52 53 + ## Project Structure 54 55 + Key directories and files: 56 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. 62 63 + ## Contributing 64 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. 66 67 + ## Credits 68 69 + This project utilises data and concepts from: 70 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) 73 74 + ## License 75 76 + This project is licensed under the [GNU Affero General Public License Version 3](LICENSE).
+896 -36
package-lock.json
··· 22 "unified": "^11.0.5" 23 }, 24 "devDependencies": { 25 - "@sveltejs/adapter-vercel": "^5.7.0", 26 "@sveltejs/kit": "^2.24.0", 27 "@sveltejs/vite-plugin-svelte": "^4.0.4", 28 "@tailwindcss/forms": "^0.5.9", ··· 65 "node": ">=6.0.0" 66 } 67 }, 68 "node_modules/@esbuild/darwin-arm64": { 69 "version": "0.21.5", 70 "cpu": [ ··· 80 "node": ">=12" 81 } 82 }, 83 "node_modules/@eslint-community/eslint-utils": { 84 "version": "4.4.1", 85 "dev": true, ··· 271 }, 272 "node_modules/@isaacs/fs-minipass": { 273 "version": "4.0.1", 274 "dev": true, 275 "license": "ISC", 276 "dependencies": { ··· 325 }, 326 "node_modules/@mapbox/node-pre-gyp": { 327 "version": "2.0.0", 328 "dev": true, 329 "license": "BSD-3-Clause", 330 "dependencies": { ··· 425 } 426 }, 427 "node_modules/@rollup/pluginutils": { 428 - "version": "5.1.3", 429 "dev": true, 430 "license": "MIT", 431 "dependencies": { ··· 480 } 481 }, 482 "node_modules/@sveltejs/adapter-vercel": { 483 - "version": "5.7.0", 484 "dev": true, 485 "license": "MIT", 486 "dependencies": { 487 - "@vercel/nft": "^0.29.2", 488 - "esbuild": "^0.24.0" 489 }, 490 "peerDependencies": { 491 "@sveltejs/kit": "^2.4.0" 492 } 493 }, 494 "node_modules/@sveltejs/adapter-vercel/node_modules/@esbuild/darwin-arm64": { 495 - "version": "0.24.2", 496 "cpu": [ 497 "arm64" 498 ], ··· 507 } 508 }, 509 "node_modules/@sveltejs/adapter-vercel/node_modules/esbuild": { 510 - "version": "0.24.2", 511 "dev": true, 512 "hasInstallScript": true, 513 "license": "MIT", ··· 518 "node": ">=18" 519 }, 520 "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" 546 } 547 }, 548 "node_modules/@sveltejs/kit": { ··· 944 "license": "ISC" 945 }, 946 "node_modules/@vercel/nft": { 947 - "version": "0.29.2", 948 "dev": true, 949 "license": "MIT", 950 "dependencies": { ··· 970 }, 971 "node_modules/@vercel/nft/node_modules/resolve-from": { 972 "version": "5.0.0", 973 "dev": true, 974 "license": "MIT", 975 "engines": { ··· 977 } 978 }, 979 "node_modules/abbrev": { 980 - "version": "3.0.0", 981 "dev": true, 982 "license": "ISC", 983 "engines": { ··· 997 }, 998 "node_modules/acorn-import-attributes": { 999 "version": "1.9.5", 1000 "dev": true, 1001 "license": "MIT", 1002 "peerDependencies": { ··· 1012 } 1013 }, 1014 "node_modules/agent-base": { 1015 - "version": "7.1.3", 1016 "dev": true, 1017 "license": "MIT", 1018 "engines": { ··· 1107 }, 1108 "node_modules/async-sema": { 1109 "version": "3.1.1", 1110 "dev": true, 1111 "license": "MIT" 1112 }, ··· 1187 }, 1188 "node_modules/bindings": { 1189 "version": "1.5.0", 1190 "dev": true, 1191 "license": "MIT", 1192 "dependencies": { ··· 1349 }, 1350 "node_modules/chownr": { 1351 "version": "3.0.0", 1352 "dev": true, 1353 "license": "BlueOak-1.0.0", 1354 "engines": { ··· 1401 }, 1402 "node_modules/consola": { 1403 "version": "3.4.2", 1404 "dev": true, 1405 "license": "MIT", 1406 "engines": { ··· 1515 } 1516 }, 1517 "node_modules/detect-libc": { 1518 - "version": "2.0.3", 1519 "dev": true, 1520 "license": "Apache-2.0", 1521 "engines": { ··· 1657 "@esbuild/win32-x64": "0.21.5" 1658 } 1659 }, 1660 "node_modules/escalade": { 1661 "version": "3.2.0", 1662 "dev": true, ··· 1871 }, 1872 "node_modules/estree-walker": { 1873 "version": "2.0.2", 1874 "dev": true, 1875 "license": "MIT" 1876 }, ··· 1965 }, 1966 "node_modules/file-uri-to-path": { 1967 "version": "1.0.0", 1968 "dev": true, 1969 "license": "MIT" 1970 }, ··· 2122 }, 2123 "node_modules/graceful-fs": { 2124 "version": "4.2.11", 2125 "dev": true, 2126 "license": "ISC" 2127 }, ··· 2314 }, 2315 "node_modules/https-proxy-agent": { 2316 "version": "7.0.6", 2317 "dev": true, 2318 "license": "MIT", 2319 "dependencies": { ··· 3372 }, 3373 "node_modules/minizlib": { 3374 "version": "3.0.2", 3375 "dev": true, 3376 "license": "MIT", 3377 "dependencies": { ··· 3383 }, 3384 "node_modules/mkdirp": { 3385 "version": "3.0.1", 3386 "dev": true, 3387 "license": "MIT", 3388 "bin": { ··· 3448 }, 3449 "node_modules/node-fetch": { 3450 "version": "2.7.0", 3451 "dev": true, 3452 "license": "MIT", 3453 "dependencies": { ··· 3467 }, 3468 "node_modules/node-gyp-build": { 3469 "version": "4.8.4", 3470 "dev": true, 3471 "license": "MIT", 3472 "bin": { ··· 3482 }, 3483 "node_modules/nopt": { 3484 "version": "8.1.0", 3485 "dev": true, 3486 "license": "ISC", 3487 "dependencies": { ··· 4663 }, 4664 "node_modules/tar": { 4665 "version": "7.4.3", 4666 "dev": true, 4667 "license": "ISC", 4668 "dependencies": { ··· 4721 }, 4722 "node_modules/tr46": { 4723 "version": "0.0.3", 4724 "dev": true, 4725 "license": "MIT" 4726 }, ··· 5054 }, 5055 "node_modules/webidl-conversions": { 5056 "version": "3.0.1", 5057 "dev": true, 5058 "license": "BSD-2-Clause" 5059 }, 5060 "node_modules/whatwg-url": { 5061 "version": "5.0.0", 5062 "dev": true, 5063 "license": "MIT", 5064 "dependencies": { ··· 5171 }, 5172 "node_modules/yallist": { 5173 "version": "5.0.0", 5174 "dev": true, 5175 "license": "BlueOak-1.0.0", 5176 "engines": {
··· 22 "unified": "^11.0.5" 23 }, 24 "devDependencies": { 25 + "@sveltejs/adapter-vercel": "^5.8.1", 26 "@sveltejs/kit": "^2.24.0", 27 "@sveltejs/vite-plugin-svelte": "^4.0.4", 28 "@tailwindcss/forms": "^0.5.9", ··· 65 "node": ">=6.0.0" 66 } 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 + }, 136 "node_modules/@esbuild/darwin-arm64": { 137 "version": "0.21.5", 138 "cpu": [ ··· 148 "node": ">=12" 149 } 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 + }, 508 "node_modules/@eslint-community/eslint-utils": { 509 "version": "4.4.1", 510 "dev": true, ··· 696 }, 697 "node_modules/@isaacs/fs-minipass": { 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==", 701 "dev": true, 702 "license": "ISC", 703 "dependencies": { ··· 752 }, 753 "node_modules/@mapbox/node-pre-gyp": { 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==", 757 "dev": true, 758 "license": "BSD-3-Clause", 759 "dependencies": { ··· 854 } 855 }, 856 "node_modules/@rollup/pluginutils": { 857 + "version": "5.2.0", 858 + "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.2.0.tgz", 859 + "integrity": "sha512-qWJ2ZTbmumwiLFomfzTyt5Kng4hwPi9rwCYN4SHb6eaRU1KNO4ccxINHr/VhH4GgPlt1XfSTLX2LBTme8ne4Zw==", 860 "dev": true, 861 "license": "MIT", 862 "dependencies": { ··· 911 } 912 }, 913 "node_modules/@sveltejs/adapter-vercel": { 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==", 917 "dev": true, 918 "license": "MIT", 919 "dependencies": { 920 + "@vercel/nft": "^0.30.0", 921 + "esbuild": "^0.25.4" 922 }, 923 "peerDependencies": { 924 "@sveltejs/kit": "^2.4.0" 925 } 926 }, 927 "node_modules/@sveltejs/adapter-vercel/node_modules/@esbuild/darwin-arm64": { 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==", 931 "cpu": [ 932 "arm64" 933 ], ··· 942 } 943 }, 944 "node_modules/@sveltejs/adapter-vercel/node_modules/esbuild": { 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==", 948 "dev": true, 949 "hasInstallScript": true, 950 "license": "MIT", ··· 955 "node": ">=18" 956 }, 957 "optionalDependencies": { 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" 984 } 985 }, 986 "node_modules/@sveltejs/kit": { ··· 1382 "license": "ISC" 1383 }, 1384 "node_modules/@vercel/nft": { 1385 + "version": "0.30.0", 1386 + "resolved": "https://registry.npmjs.org/@vercel/nft/-/nft-0.30.0.tgz", 1387 + "integrity": "sha512-xVye7Z0riD9czsMuEJYpFqm2FR33r3euYaFzuEPCoUtYuDwmus3rJfKtcFU7Df+pgj8p4zs78x5lOWYoLNr+7Q==", 1388 "dev": true, 1389 "license": "MIT", 1390 "dependencies": { ··· 1410 }, 1411 "node_modules/@vercel/nft/node_modules/resolve-from": { 1412 "version": "5.0.0", 1413 + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", 1414 + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", 1415 "dev": true, 1416 "license": "MIT", 1417 "engines": { ··· 1419 } 1420 }, 1421 "node_modules/abbrev": { 1422 + "version": "3.0.1", 1423 + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-3.0.1.tgz", 1424 + "integrity": "sha512-AO2ac6pjRB3SJmGJo+v5/aK6Omggp6fsLrs6wN9bd35ulu4cCwaAU9+7ZhXjeqHVkaHThLuzH0nZr0YpCDhygg==", 1425 "dev": true, 1426 "license": "ISC", 1427 "engines": { ··· 1441 }, 1442 "node_modules/acorn-import-attributes": { 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==", 1446 "dev": true, 1447 "license": "MIT", 1448 "peerDependencies": { ··· 1458 } 1459 }, 1460 "node_modules/agent-base": { 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==", 1464 "dev": true, 1465 "license": "MIT", 1466 "engines": { ··· 1555 }, 1556 "node_modules/async-sema": { 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==", 1560 "dev": true, 1561 "license": "MIT" 1562 }, ··· 1637 }, 1638 "node_modules/bindings": { 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==", 1642 "dev": true, 1643 "license": "MIT", 1644 "dependencies": { ··· 1801 }, 1802 "node_modules/chownr": { 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==", 1806 "dev": true, 1807 "license": "BlueOak-1.0.0", 1808 "engines": { ··· 1855 }, 1856 "node_modules/consola": { 1857 "version": "3.4.2", 1858 + "resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz", 1859 + "integrity": "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==", 1860 "dev": true, 1861 "license": "MIT", 1862 "engines": { ··· 1971 } 1972 }, 1973 "node_modules/detect-libc": { 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==", 1977 "dev": true, 1978 "license": "Apache-2.0", 1979 "engines": { ··· 2115 "@esbuild/win32-x64": "0.21.5" 2116 } 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 + }, 2492 "node_modules/escalade": { 2493 "version": "3.2.0", 2494 "dev": true, ··· 2703 }, 2704 "node_modules/estree-walker": { 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==", 2708 "dev": true, 2709 "license": "MIT" 2710 }, ··· 2799 }, 2800 "node_modules/file-uri-to-path": { 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==", 2804 "dev": true, 2805 "license": "MIT" 2806 }, ··· 2958 }, 2959 "node_modules/graceful-fs": { 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==", 2963 "dev": true, 2964 "license": "ISC" 2965 }, ··· 3152 }, 3153 "node_modules/https-proxy-agent": { 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==", 3157 "dev": true, 3158 "license": "MIT", 3159 "dependencies": { ··· 4212 }, 4213 "node_modules/minizlib": { 4214 "version": "3.0.2", 4215 + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.0.2.tgz", 4216 + "integrity": "sha512-oG62iEk+CYt5Xj2YqI5Xi9xWUeZhDI8jjQmC5oThVH5JGCTgIjr7ciJDzC7MBzYd//WvR1OTmP5Q38Q8ShQtVA==", 4217 "dev": true, 4218 "license": "MIT", 4219 "dependencies": { ··· 4225 }, 4226 "node_modules/mkdirp": { 4227 "version": "3.0.1", 4228 + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-3.0.1.tgz", 4229 + "integrity": "sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg==", 4230 "dev": true, 4231 "license": "MIT", 4232 "bin": { ··· 4292 }, 4293 "node_modules/node-fetch": { 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==", 4297 "dev": true, 4298 "license": "MIT", 4299 "dependencies": { ··· 4313 }, 4314 "node_modules/node-gyp-build": { 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==", 4318 "dev": true, 4319 "license": "MIT", 4320 "bin": { ··· 4330 }, 4331 "node_modules/nopt": { 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==", 4335 "dev": true, 4336 "license": "ISC", 4337 "dependencies": { ··· 5513 }, 5514 "node_modules/tar": { 5515 "version": "7.4.3", 5516 + "resolved": "https://registry.npmjs.org/tar/-/tar-7.4.3.tgz", 5517 + "integrity": "sha512-5S7Va8hKfV7W5U6g3aYxXmlPoZVAwUMy9AOKyF2fVuZa2UD3qZjg578OrLRt8PcNN1PleVaL/5/yYATNL0ICUw==", 5518 "dev": true, 5519 "license": "ISC", 5520 "dependencies": { ··· 5573 }, 5574 "node_modules/tr46": { 5575 "version": "0.0.3", 5576 + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", 5577 + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", 5578 "dev": true, 5579 "license": "MIT" 5580 }, ··· 5908 }, 5909 "node_modules/webidl-conversions": { 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==", 5913 "dev": true, 5914 "license": "BSD-2-Clause" 5915 }, 5916 "node_modules/whatwg-url": { 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==", 5920 "dev": true, 5921 "license": "MIT", 5922 "dependencies": { ··· 6029 }, 6030 "node_modules/yallist": { 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==", 6034 "dev": true, 6035 "license": "BlueOak-1.0.0", 6036 "engines": {
+1 -2
package.json
··· 11 "lint": "eslint ." 12 }, 13 "devDependencies": { 14 - 15 - "@sveltejs/adapter-vercel": "^5.7.0", 16 "@sveltejs/kit": "^2.24.0", 17 "@sveltejs/vite-plugin-svelte": "^4.0.4", 18 "@tailwindcss/forms": "^0.5.9",
··· 11 "lint": "eslint ." 12 }, 13 "devDependencies": { 14 + "@sveltejs/adapter-vercel": "^5.8.1", 15 "@sveltejs/kit": "^2.24.0", 16 "@sveltejs/vite-plugin-svelte": "^4.0.4", 17 "@tailwindcss/forms": "^0.5.9",
+1 -7
src/app.html
··· 10 <meta name="mobile-web-app-capable" content="yes" /> 11 <meta name="apple-mobile-web-app-capable" content="yes" /> 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> 20 %sveltekit.head% 21 </head> 22 <body data-sveltekit-preload-data="hover">
··· 10 <meta name="mobile-web-app-capable" content="yes" /> 11 <meta name="apple-mobile-web-app-capable" content="yes" /> 12 <meta name="msapplication-TileColor" content="#000000" /> 13 + <link rel="icon" href="/logo.ico" /> 14 %sveltekit.head% 15 </head> 16 <body data-sveltekit-preload-data="hover">
+27 -468
src/lib/components/archive/ArchiveCard.svelte
··· 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 - }; 56 </script> 57 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> 157 </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>
··· 1 <script lang="ts"> 2 + export let type: 'link' | 'user'; 3 + export let url: string; 4 + export let title: string; 5 + export let value: string | undefined = undefined; 6 </script> 7 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> 21 </a> 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 export { default as CopyLinkIcon } from './utility/CopyLinkIcon.svelte'; 9 export { default as HomeIcon } from './utility/HomeIcon.svelte'; 10 - export { default as LinkIcon } from './utility/LinkIcon.svelte'; 11 export { default as MoonIcon } from './utility/MoonIcon.svelte'; 12 - export { default as PostIcon } from './utility/PostIcon.svelte'; 13 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 export { default as EditIcon } from "./utility/EditIcon.svelte";
··· 1 export { default as CopyLinkIcon } from './utility/CopyLinkIcon.svelte'; 2 export { default as HomeIcon } from './utility/HomeIcon.svelte'; 3 export { default as MoonIcon } from './utility/MoonIcon.svelte'; 4 export { default as SunIcon } from './utility/SunIcon.svelte'; 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 <script lang="ts"> 2 import { getStores } from "$app/stores"; 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; 9 10 - // Reactive statement to get the origin without http:// or https:// 11 - $: cleanOrigin = $page.url.origin.replace(/^https?:\/\//, ''); 12 </script> 13 14 <nav class="flex items-center box-border my-6"> ··· 22 <HomeIcon /> 23 </a> 24 {/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 </div> 46 - <div class="ml-auto"></div> 47 - <ThemeToggle /> 48 </nav>
··· 1 <script lang="ts"> 2 import { getStores } from "$app/stores"; 3 const { page } = getStores(); 4 + import { HomeIcon } from "$components/icons"; 5 6 + let {} = $props(); 7 </script> 8 9 <nav class="flex items-center box-border my-6"> ··· 17 <HomeIcon /> 18 </a> 19 {/if} 20 </div> 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 }); 15 </script> 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> 20 <span>&copy; <span id="copyright-year"></span></span> 21 - 22 - <span class="mx-1"></span> 23 - 24 {#if profile?.handle} 25 <a 26 href="https://bsky.app/profile/{profile.did}" 27 - class="text-[var(--link-color)] hover:text-[var(--link-hover-color)]" 28 > 29 @{profile.handle} 30 </a> 31 {:else} 32 <span>{profile?.displayName || profile?.did}</span> 33 {/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} 40 <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)]" 44 > 45 - @{env.PUBLIC_ACTIVITYPUB_USER.split( 46 - "@", 47 - )[1]}@{env.PUBLIC_ACTIVITYPUB_USER.split("@")[2]} 48 </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 - > 59 </div> 60 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> 66 </div> 67 68 - <div> 69 - <span class="mx-1"></span> 70 - <TidClock /> 71 - </div> 72 </div> 73 - </footer>
··· 14 }); 15 </script> 16 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"> 21 <span>&copy; <span id="copyright-year"></span></span> 22 + <span class="mx-2">•</span> 23 {#if profile?.handle} 24 <a 25 href="https://bsky.app/profile/{profile.did}" 26 + class="text-[var(--link-color)] hover:text-[var(--link-hover-color)] transition-colors" 27 > 28 @{profile.handle} 29 </a> 30 {:else} 31 <span>{profile?.displayName || profile?.did}</span> 32 {/if} 33 + <span class="mx-2">•</span> 34 + <span>powered by 35 <a 36 + class="text-[var(--link-color)] hover:text-[var(--link-hover-color)] transition-colors" 37 + href="https://atproto.com/guides/glossary#at-protocol" 38 > 39 + atproto 40 </a> 41 + </span> 42 </div> 43 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> 70 </div> 71 72 + <!-- Clock --> 73 + <TidClock /> 74 </div> 75 + </footer>
-1
src/lib/components/layout/index.ts
··· 1 export { default as Navigation } from "./Navigation.svelte"; 2 export { default as Footer } from "./footer/Main.svelte"; 3 - export { default as ThemeToggle } from "./ThemeToggle.svelte";
··· 1 export { default as Navigation } from "./Navigation.svelte"; 2 export { default as Footer } from "./footer/Main.svelte";
+7 -2
src/lib/components/layout/main/DynamicLinks.svelte
··· 1 <script lang="ts"> 2 - import { ArchiveCard } from "$components/archive"; 3 import type { LinkBoard } from "$components/shared"; 4 5 // Export the data prop that will receive the fetched links ··· 12 class="grid grid-cols-[repeat(auto-fill,minmax(260px,1fr)_)] gap-x-6 gap-y-6 my-6" 13 > 14 {#each data.cards as link} 15 - <ArchiveCard type="link" url={link.url} title={link.text} value={link.emoji} /> 16 {/each} 17 </div> 18 </div>
··· 1 <script lang="ts"> 2 + import ArchiveCard from "$lib/components/archive/ArchiveCard.svelte"; 3 import type { LinkBoard } from "$components/shared"; 4 5 // Export the data prop that will receive the fetched links ··· 12 class="grid grid-cols-[repeat(auto-fill,minmax(260px,1fr)_)] gap-x-6 gap-y-6 my-6" 13 > 14 {#each data.cards as link} 15 + <ArchiveCard 16 + type="link" 17 + url={link.url} 18 + title={link.text} 19 + value={link.emoji} 20 + /> 21 {/each} 22 </div> 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 } 21 22 export async function getProfile(fetch: typeof globalThis.fetch): Promise<Profile> { 23 - const cacheKey = `profile_${env.PUBLIC_ATPROTOCOL_USER}`; 24 let profile: Profile | null = getCache<Profile>(cacheKey); 25 26 if (profile) { ··· 29 30 try { 31 const fetchProfile = await safeFetch( 32 - `https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=${env.PUBLIC_ATPROTOCOL_USER}`, 33 fetch 34 ); 35 const split = fetchProfile["did"].split(":");
··· 20 } 21 22 export async function getProfile(fetch: typeof globalThis.fetch): Promise<Profile> { 23 + const cacheKey = `profile_${env.DIRECTORY_OWNER}`; 24 let profile: Profile | null = getCache<Profile>(cacheKey); 25 26 if (profile) { ··· 29 30 try { 31 const fetchProfile = await safeFetch( 32 + `https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=${env.DIRECTORY_OWNER}`, 33 fetch 34 ); 35 const split = fetchProfile["did"].split(":");
+1 -1
src/lib/components/shared/NotFoundMessage.svelte
··· 1 <div class="flex justify-center items-center min-h-[50vh]"> 2 <h1 class="text-center pt-4 pb-4"> 3 - it's either loading or the post doesn't exist 4 </h1> 5 </div>
··· 1 <div class="flex justify-center items-center min-h-[50vh]"> 2 <h1 class="text-center pt-4 pb-4"> 3 + It's either loading or the user doesn't exist 4 </h1> 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 // Define the type for the fetched links data 25 /** 26 * Represents a single link card with a URL, text, and an emoji. ··· 32 } 33 34 /** 35 * Represents a board containing multiple link cards. 36 */ 37 export interface LinkBoard { ··· 51 * Represents public environment variables. 52 */ 53 export interface PublicEnv { 54 - PUBLIC_ATPROTOCOL_USER: string; // Public user for ATProtocol. 55 - PUBLIC_ACTIVITYPUB_USER: string; // Public user for ActivityPub. 56 } 57 58 /** ··· 69 } 70 71 /** 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. 90 */ 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 }; 97 }
··· 1 // Define the type for the fetched links data 2 /** 3 * Represents a single link card with a URL, text, and an emoji. ··· 9 } 10 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 + /** 32 * Represents a board containing multiple link cards. 33 */ 34 export interface LinkBoard { ··· 48 * Represents public environment variables. 49 */ 50 export interface PublicEnv { 51 + DIRECTORY_OWNER: string; // Public user for ATProtocol. 52 } 53 54 /** ··· 65 } 66 67 /** 68 + * Represents a user with basic details. 69 */ 70 + export interface User { 71 + did: string; 72 + handle?: string; 73 + displayName?: string; 74 + avatar?: string; 75 + description?: string; 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 @import "$css/variables.css"; 2 3 - /* Minimalist flat styles with gentle dark pastel green theme */ 4 @tailwind base; 5 @tailwind components; 6 @tailwind utilities; 7 8 @layer base { 9 10 - /* Scrollbar styling */ 11 ::-webkit-scrollbar { 12 width: 10px; 13 height: 10px; ··· 33 body { 34 background-color: var(--background-color); 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; 39 } 40 41 h1 { 42 @apply text-4xl font-bold; 43 - font-variation-settings: "MONO" 0, "CASL" 0.8, "wght" 700, "slnt" 0, 44 - "CRSV" 0.9; 45 } 46 47 h2 { 48 @apply text-3xl font-bold; 49 - font-variation-settings: "MONO" 0, "CASL" 0.7, "wght" 650, "slnt" 0, 50 - "CRSV" 0.85; 51 } 52 53 h3 { 54 @apply text-2xl font-bold; 55 - font-variation-settings: "MONO" 0, "CASL" 0.6, "wght" 600, "slnt" 0, 56 - "CRSV" 0.8; 57 } 58 59 h4 { 60 @apply text-xl font-semibold; 61 - font-variation-settings: "MONO" 0, "CASL" 0.5, "wght" 550, "slnt" 0, 62 - "CRSV" 0.75; 63 } 64 65 h5 { 66 @apply text-lg font-semibold; 67 - font-variation-settings: "MONO" 0, "CASL" 0.4, "wght" 500, "slnt" 0, 68 - "CRSV" 0.7; 69 } 70 71 h6 { 72 @apply text-sm font-semibold; 73 - font-variation-settings: "MONO" 0, "CASL" 0.3, "wght" 450, "slnt" 0, 74 - "CRSV" 0.6; 75 } 76 77 a { 78 @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 */ 82 transition: color 0.2s ease; 83 } 84 85 a:hover { 86 - font-variation-settings: "MONO" 0, "CASL" 0, "wght" 600, "slnt" 0, 87 - "CRSV" 0.5; 88 } 89 90 a, ··· 95 text-decoration: none !important; 96 } 97 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 /* Header links - simplified transitions */ 108 header a, 109 a.font-medium { ··· 117 transform: scale(1.1); 118 } 119 120 - /* Typography styles for blog content */ 121 .prose { 122 @apply max-w-none; 123 } ··· 153 text-decoration: none; 154 } 155 156 - /* Update prose elements to use variables */ 157 .prose a { 158 color: var(--link-color); 159 text-decoration: none; ··· 184 .prose pre code { 185 @apply bg-transparent p-0; 186 } 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 } 293 294 .text-link {
··· 1 @import "$css/variables.css"; 2 3 + /* Linkat Directory - Core CSS Styles */ 4 @tailwind base; 5 @tailwind components; 6 @tailwind utilities; 7 8 @layer base { 9 10 + /* Custom scrollbar styling for webkit browsers */ 11 ::-webkit-scrollbar { 12 width: 10px; 13 height: 10px; ··· 33 body { 34 background-color: var(--background-color); 35 color: var(--text-color); 36 + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; 37 } 38 39 h1 { 40 @apply text-4xl font-bold; 41 } 42 43 h2 { 44 @apply text-3xl font-bold; 45 } 46 47 h3 { 48 @apply text-2xl font-bold; 49 } 50 51 h4 { 52 @apply text-xl font-semibold; 53 } 54 55 h5 { 56 @apply text-lg font-semibold; 57 } 58 59 h6 { 60 @apply text-sm font-semibold; 61 } 62 63 a { 64 @apply text-[var(--link-color)] hover:text-[var(--link-hover-color)] no-underline; 65 + /* Simplified hover transitions */ 66 transition: color 0.2s ease; 67 } 68 69 a:hover { 70 + @apply font-semibold; 71 } 72 73 a, ··· 78 text-decoration: none !important; 79 } 80 81 /* Header links - simplified transitions */ 82 header a, 83 a.font-medium { ··· 91 transform: scale(1.1); 92 } 93 94 + /* Typography styles for blog/prose content using Tailwind Typography plugin */ 95 .prose { 96 @apply max-w-none; 97 } ··· 127 text-decoration: none; 128 } 129 130 + /* Prose element styling with CSS variables for theme consistency */ 131 .prose a { 132 color: var(--link-color); 133 text-decoration: none; ··· 158 .prose pre code { 159 @apply bg-transparent p-0; 160 } 161 } 162 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; 12 } 13 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; 24 }
··· 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 + } 19 } 20 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 + } 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 <script lang="ts"> 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"; 7 8 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 </script> 17 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()} 26 27 - <Footer profile={data.profile} posts={data.posts} /> 28 </div>
··· 1 <script lang="ts"> 2 import "$css/app.css"; 3 + import { Footer } from "$components/layout"; 4 + import DirectoryHeader from "$lib/components/layout/DirectoryHeader.svelte"; 5 6 let { data, children } = $props(); 7 </script> 8 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()} 14 15 + <Footer profile={data.profile} posts={data.posts} /> 16 + </div> 17 </div>
+81 -20
src/routes/+layout.ts
··· 1 - import { getProfile } from "$components/profile/profile"; 2 - import { getLatestPosts } from "$services/blogService"; 3 import type { Profile, LinkBoard } from "$components/shared"; 4 5 // Profile data cache 6 - let profile: Profile; 7 let dynamicLinks: LinkBoard | undefined; 8 9 export async function load({ fetch }) { 10 - if (profile === undefined) { 11 - profile = await getProfile(fetch); 12 } 13 14 - // Fetch dynamic links only if not already cached 15 - if (dynamicLinks === undefined) { 16 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; 23 } 24 } catch (error) { 25 - console.error("Error fetching dynamic links:", error); 26 } 27 } 28 29 - // Fetch latest blog posts using the consolidated service 30 - const latestPosts = await getLatestPosts(fetch, 3); 31 32 return { 33 profile, 34 - pdsUrl: profile.pds, 35 - did: profile.did, 36 - posts: new Map(), // Keep this for compatibility with existing Footer component 37 dynamicLinks, 38 - latestPosts, 39 }; 40 }
··· 1 + import { getProfile, safeFetch } from "$components/profile/profile"; 2 import type { Profile, LinkBoard } from "$components/shared"; 3 + import { LINKAT_USERS } from "$lib/config/linkat-users"; 4 + import { env } from "$env/dynamic/public"; 5 6 // Profile data cache 7 + let profile: Profile | undefined; 8 let dynamicLinks: LinkBoard | undefined; 9 10 export async function load({ 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 + } 50 } 51 52 + const userLinkBoards: { [did: string]: LinkBoard | undefined } = {}; 53 + 54 + // Fetch dynamic links for all configured users 55 + for (const userDid of userDids) { 56 try { 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 + } 82 } 83 } catch (error) { 84 + console.error(`Error fetching dynamic links for ${userDid}:`, error); 85 } 86 } 87 88 + // For backward compatibility, keep the single dynamicLinks 89 + dynamicLinks = profile ? userLinkBoards[profile.did] : undefined; 90 91 return { 92 profile, 93 + pdsUrl: profile?.pds, 94 + did: profile?.did, 95 + posts: new Map(), 96 dynamicLinks, 97 + userLinkBoards, 98 + linkatUsers: userDids, 99 + noUsersConfigured: false, 100 }; 101 }
+58 -43
src/routes/+page.svelte
··· 2 import { onMount } from "svelte"; 3 import { getStores } from "$app/stores"; 4 const { page } = getStores(); 5 - import { DynamicLinks, LatestBlogPost } from "$components/layout/main"; 6 7 let { data } = $props(); 8 ··· 15 localeLoaded = true; 16 }, 10); 17 }); 18 - </script> 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 - /> 30 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" /> 45 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} 62 63 - <DynamicLinks data={data.dynamicLinks} />
··· 2 import { onMount } from "svelte"; 3 import { getStores } from "$app/stores"; 4 const { page } = getStores(); 5 + import UserDirectory from "$lib/components/archive/UserDirectory.svelte"; 6 + import DynamicHead from "$lib/components/layout/DynamicHead.svelte"; 7 8 let { data } = $props(); 9 ··· 16 localeLoaded = true; 17 }, 10); 18 }); 19 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); 24 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> 46 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 + /> 56 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 '$css': './src/lib/css', 22 '$services': './src/lib/services', 23 '$utils': './src/lib/utils' 24 } 25 } 26 };
··· 21 '$css': './src/lib/css', 22 '$services': './src/lib/services', 23 '$utils': './src/lib/utils' 24 + }, 25 + env: { 26 + publicPrefix: '' 27 } 28 } 29 };