my website at ewancroft.uk

chore: Reformat files using Prettier

ewancroft.uk baff6166 96f35ab1

verified
+158 -168
.cspell.json
··· 1 1 { 2 - "version": "0.2", 3 - "language": "en", 4 - "words": [ 5 - "ACTIVITYPUB", 6 - "apdisk", 7 - "apos", 8 - "ardenivanov", 9 - "atproto", 10 - "ATPROTOCOL", 11 - "AWOO", 12 - "bandcamp", 13 - "Batarong", 14 - "Behaviour", 15 - "bgbsh", 16 - "blogposts", 17 - "blueskypost", 18 - "bradlc", 19 - "Brainz", 20 - "bsky", 21 - "Caddyfile", 22 - "cailean", 23 - "Caligraphic", 24 - "CASL", 25 - "Centralised", 26 - "colour", 27 - "colours", 28 - "Containerisation", 29 - "containerised", 30 - "CRSV", 31 - "cssnano", 32 - "customisable", 33 - "Customisation", 34 - "customisations", 35 - "Customise", 36 - "customised", 37 - "dbaeumer", 38 - "Decentralised", 39 - "Deezer", 40 - "diddoc", 41 - "Dids", 42 - "Dockerised", 43 - "donotpresent", 44 - "dotenv", 45 - "Dragonsbreath", 46 - "eamodio", 47 - "esbenp", 48 - "eslintcache", 49 - "Ewan", 50 - "Ewan's", 51 - "ewanc", 52 - "ewancroft", 53 - "Ewans", 54 - "extralarge", 55 - "fediverse", 56 - "Fira", 57 - "Flexbox", 58 - "formulahendry", 59 - "FOUC", 60 - "fseventsd", 61 - "fullsize", 62 - "genericised", 63 - "greenwerewolf", 64 - "greenwolf", 65 - "gwicbkc", 66 - "icns", 67 - "initialise", 68 - "Instgrm", 69 - "introend", 70 - "isrc", 71 - "jscoverage", 72 - "jspm", 73 - "jzfcijpj", 74 - "katex", 75 - "kohler", 76 - "kzcijpj", 77 - "Licence", 78 - "licences", 79 - "linecap", 80 - "linkat", 81 - "lish", 82 - "maxage", 83 - "MBID", 84 - "Mbps", 85 - "mdcontent", 86 - "mdposts", 87 - "Microbundle", 88 - "Moonshadow", 89 - "mpegurl", 90 - "Multibase", 91 - "Multikey", 92 - "musicbrainz", 93 - "myhandle", 94 - "nktqepol", 95 - "nomoji", 96 - "nosniff", 97 - "nuxt", 98 - "ofrbh", 99 - "ogimage", 100 - "OKLCH", 101 - "optimisation", 102 - "Optimised", 103 - "organisation", 104 - "pdsurl", 105 - "Personalising", 106 - "pids", 107 - "precomposed", 108 - "prioritisation", 109 - "Prioritised", 110 - "qimg", 111 - "recentfm", 112 - "referrerpolicy", 113 - "repost", 114 - "reposters", 115 - "reposts", 116 - "resvg", 117 - "Resvg", 118 - "rgba", 119 - "Rickroll", 120 - "rkey", 121 - "Rkeys", 122 - "rknight", 123 - "Sanitise", 124 - "scrobbler", 125 - "scrobbling", 126 - "searchi", 127 - "shapeshifting", 128 - "siteinfo", 129 - "slnt", 130 - "Spacedust", 131 - "steeze", 132 - "stylelintcache", 133 - "svelte", 134 - "timemachine", 135 - "ttfb", 136 - "Varepsilon", 137 - "vercel", 138 - "vercelignore", 139 - "Vite", 140 - "vuepress", 141 - "vurl", 142 - "WCAG", 143 - "wght", 144 - "whitebreeze", 145 - "WhiteWind", 146 - "whtwnd", 147 - "wscript", 148 - "Wyrmrest", 149 - "xrpc" 150 - ], 151 - "flagWords": [], 152 - "ignorePaths": [ 153 - "node_modules", 154 - "package-lock.json", 155 - "dist", 156 - "build" 157 - ], 158 - "ignoreRegExpList": [ 159 - "/(\\w+)'s/g" 160 - ], 161 - "overrides": [ 162 - { 163 - "filename": "**/*.svelte", 164 - "ignoreRegExpList": [ 165 - "/>.*</", 166 - "/(\\w+)'s/g" 167 - ] 168 - } 169 - ] 2 + "version": "0.2", 3 + "language": "en", 4 + "words": [ 5 + "ACTIVITYPUB", 6 + "apdisk", 7 + "apos", 8 + "ardenivanov", 9 + "atproto", 10 + "ATPROTOCOL", 11 + "AWOO", 12 + "bandcamp", 13 + "Batarong", 14 + "Behaviour", 15 + "bgbsh", 16 + "blogposts", 17 + "blueskypost", 18 + "bradlc", 19 + "Brainz", 20 + "bsky", 21 + "Caddyfile", 22 + "cailean", 23 + "Caligraphic", 24 + "CASL", 25 + "Centralised", 26 + "colour", 27 + "colours", 28 + "Containerisation", 29 + "containerised", 30 + "CRSV", 31 + "cssnano", 32 + "customisable", 33 + "Customisation", 34 + "customisations", 35 + "Customise", 36 + "customised", 37 + "dbaeumer", 38 + "Decentralised", 39 + "Deezer", 40 + "diddoc", 41 + "Dids", 42 + "Dockerised", 43 + "donotpresent", 44 + "dotenv", 45 + "Dragonsbreath", 46 + "eamodio", 47 + "esbenp", 48 + "eslintcache", 49 + "Ewan", 50 + "Ewan's", 51 + "ewanc", 52 + "ewancroft", 53 + "Ewans", 54 + "extralarge", 55 + "fediverse", 56 + "Fira", 57 + "Flexbox", 58 + "formulahendry", 59 + "FOUC", 60 + "fseventsd", 61 + "fullsize", 62 + "genericised", 63 + "greenwerewolf", 64 + "greenwolf", 65 + "gwicbkc", 66 + "icns", 67 + "initialise", 68 + "Instgrm", 69 + "introend", 70 + "isrc", 71 + "jscoverage", 72 + "jspm", 73 + "jzfcijpj", 74 + "katex", 75 + "kohler", 76 + "kzcijpj", 77 + "Licence", 78 + "licences", 79 + "linecap", 80 + "linkat", 81 + "lish", 82 + "maxage", 83 + "MBID", 84 + "Mbps", 85 + "mdcontent", 86 + "mdposts", 87 + "Microbundle", 88 + "Moonshadow", 89 + "mpegurl", 90 + "Multibase", 91 + "Multikey", 92 + "musicbrainz", 93 + "myhandle", 94 + "nktqepol", 95 + "nomoji", 96 + "nosniff", 97 + "nuxt", 98 + "ofrbh", 99 + "ogimage", 100 + "OKLCH", 101 + "optimisation", 102 + "Optimised", 103 + "organisation", 104 + "pdsurl", 105 + "Personalising", 106 + "pids", 107 + "precomposed", 108 + "prioritisation", 109 + "Prioritised", 110 + "qimg", 111 + "recentfm", 112 + "referrerpolicy", 113 + "repost", 114 + "reposters", 115 + "reposts", 116 + "resvg", 117 + "Resvg", 118 + "rgba", 119 + "Rickroll", 120 + "rkey", 121 + "Rkeys", 122 + "rknight", 123 + "Sanitise", 124 + "scrobbler", 125 + "scrobbling", 126 + "searchi", 127 + "shapeshifting", 128 + "siteinfo", 129 + "slnt", 130 + "Spacedust", 131 + "steeze", 132 + "stylelintcache", 133 + "svelte", 134 + "timemachine", 135 + "ttfb", 136 + "Varepsilon", 137 + "vercel", 138 + "vercelignore", 139 + "Vite", 140 + "vuepress", 141 + "vurl", 142 + "WCAG", 143 + "wght", 144 + "whitebreeze", 145 + "WhiteWind", 146 + "whtwnd", 147 + "wscript", 148 + "Wyrmrest", 149 + "xrpc" 150 + ], 151 + "flagWords": [], 152 + "ignorePaths": ["node_modules", "package-lock.json", "dist", "build"], 153 + "ignoreRegExpList": ["/(\\w+)'s/g"], 154 + "overrides": [ 155 + { 156 + "filename": "**/*.svelte", 157 + "ignoreRegExpList": ["/>.*</", "/(\\w+)'s/g"] 158 + } 159 + ] 170 160 }
+10 -8
.github/ISSUE_TEMPLATE/bug_report.md
··· 4 4 title: '' 5 5 labels: '' 6 6 assignees: '' 7 - 8 7 --- 9 8 10 9 **Describe the bug** ··· 12 11 13 12 **To Reproduce** 14 13 Steps to reproduce the behavior: 14 + 15 15 1. Go to '...' 16 16 2. Click on '....' 17 17 3. Scroll down to '....' ··· 24 24 If applicable, add screenshots to help explain your problem. 25 25 26 26 **Desktop (please complete the following information):** 27 - - OS: [e.g. iOS] 28 - - Browser [e.g. chrome, safari] 29 - - Version [e.g. 22] 27 + 28 + - OS: [e.g. iOS] 29 + - Browser [e.g. chrome, safari] 30 + - Version [e.g. 22] 30 31 31 32 **Smartphone (please complete the following information):** 32 - - Device: [e.g. iPhone6] 33 - - OS: [e.g. iOS8.1] 34 - - Browser [e.g. stock browser, safari] 35 - - Version [e.g. 22] 33 + 34 + - Device: [e.g. iPhone6] 35 + - OS: [e.g. iOS8.1] 36 + - Browser [e.g. stock browser, safari] 37 + - Version [e.g. 22] 36 38 37 39 **Additional context** 38 40 Add any other context about the problem here.
+6 -6
.vscode/settings.json
··· 1 1 { 2 - "css.customData": [".vscode/tailwind.json"], 3 - "css.validate": false, 4 - "tailwindCSS.includeLanguages": { 5 - "svelte": "html" 6 - } 7 - } 2 + "css.customData": [".vscode/tailwind.json"], 3 + "css.validate": false, 4 + "tailwindCSS.includeLanguages": { 5 + "svelte": "html" 6 + } 7 + }
+54 -54
.vscode/tailwind.json
··· 1 1 { 2 - "version": 1.1, 3 - "atDirectives": [ 4 - { 5 - "name": "@tailwind", 6 - "description": "Use the @tailwind directive to insert Tailwind's base, components, and utilities styles into your CSS.", 7 - "references": [ 8 - { 9 - "name": "Tailwind CSS Documentation", 10 - "url": "https://tailwindcss.com/docs/functions-and-directives#tailwind" 11 - } 12 - ] 13 - }, 14 - { 15 - "name": "@apply", 16 - "description": "Use @apply to inline any existing utility classes into your own custom CSS.", 17 - "references": [ 18 - { 19 - "name": "Tailwind CSS Documentation", 20 - "url": "https://tailwindcss.com/docs/functions-and-directives#apply" 21 - } 22 - ] 23 - }, 24 - { 25 - "name": "@responsive", 26 - "description": "You can generate responsive variants of your own classes by wrapping their definitions in the @responsive directive.", 27 - "references": [ 28 - { 29 - "name": "Tailwind CSS Documentation", 30 - "url": "https://tailwindcss.com/docs/functions-and-directives#responsive" 31 - } 32 - ] 33 - }, 34 - { 35 - "name": "@screen", 36 - "description": "The @screen directive allows you to create media queries that reference your breakpoints by name instead of duplicating their values in your own CSS.", 37 - "references": [ 38 - { 39 - "name": "Tailwind CSS Documentation", 40 - "url": "https://tailwindcss.com/docs/functions-and-directives#screen" 41 - } 42 - ] 43 - }, 44 - { 45 - "name": "@variants", 46 - "description": "Generate variants for your own utilities by wrapping their definitions in the @variants directive.", 47 - "references": [ 48 - { 49 - "name": "Tailwind CSS Documentation", 50 - "url": "https://tailwindcss.com/docs/functions-and-directives#variants" 51 - } 52 - ] 53 - } 54 - ] 55 - } 2 + "version": 1.1, 3 + "atDirectives": [ 4 + { 5 + "name": "@tailwind", 6 + "description": "Use the @tailwind directive to insert Tailwind's base, components, and utilities styles into your CSS.", 7 + "references": [ 8 + { 9 + "name": "Tailwind CSS Documentation", 10 + "url": "https://tailwindcss.com/docs/functions-and-directives#tailwind" 11 + } 12 + ] 13 + }, 14 + { 15 + "name": "@apply", 16 + "description": "Use @apply to inline any existing utility classes into your own custom CSS.", 17 + "references": [ 18 + { 19 + "name": "Tailwind CSS Documentation", 20 + "url": "https://tailwindcss.com/docs/functions-and-directives#apply" 21 + } 22 + ] 23 + }, 24 + { 25 + "name": "@responsive", 26 + "description": "You can generate responsive variants of your own classes by wrapping their definitions in the @responsive directive.", 27 + "references": [ 28 + { 29 + "name": "Tailwind CSS Documentation", 30 + "url": "https://tailwindcss.com/docs/functions-and-directives#responsive" 31 + } 32 + ] 33 + }, 34 + { 35 + "name": "@screen", 36 + "description": "The @screen directive allows you to create media queries that reference your breakpoints by name instead of duplicating their values in your own CSS.", 37 + "references": [ 38 + { 39 + "name": "Tailwind CSS Documentation", 40 + "url": "https://tailwindcss.com/docs/functions-and-directives#screen" 41 + } 42 + ] 43 + }, 44 + { 45 + "name": "@variants", 46 + "description": "Generate variants for your own utilities by wrapping their definitions in the @variants directive.", 47 + "references": [ 48 + { 49 + "name": "Tailwind CSS Documentation", 50 + "url": "https://tailwindcss.com/docs/functions-and-directives#variants" 51 + } 52 + ] 53 + } 54 + ] 55 + }
+17 -17
README.md
··· 125 125 126 126 ```typescript 127 127 export const slugMappings: SlugMapping[] = [ 128 - { slug: 'blog', publicationRkey: '3m3x4bgbsh22k' }, 129 - { slug: 'essays', publicationRkey: 'abc123xyz' }, 130 - { slug: 'notes', publicationRkey: 'def456uvw' } 128 + { slug: 'blog', publicationRkey: '3m3x4bgbsh22k' }, 129 + { slug: 'essays', publicationRkey: 'abc123xyz' }, 130 + { slug: 'notes', publicationRkey: 'def456uvw' } 131 131 ]; 132 132 ``` 133 133 ··· 250 250 ### Usage Examples 251 251 252 252 ```typescript 253 - import { 254 - fetchProfile, 255 - fetchBlogPosts, 256 - fetchLatestBlueskyPost, 257 - fetchMusicStatus, 258 - fetchTangledRepos 253 + import { 254 + fetchProfile, 255 + fetchBlogPosts, 256 + fetchLatestBlueskyPost, 257 + fetchMusicStatus, 258 + fetchTangledRepos 259 259 } from '$lib/services/atproto'; 260 260 261 261 // Fetch profile data ··· 284 284 285 285 ```typescript 286 286 export const slugMappings: SlugMapping[] = [ 287 - { 288 - slug: 'blog', // Access via /blog 289 - publicationRkey: '3m3x4bgbsh22k' // Leaflet publication rkey 290 - }, 291 - { 292 - slug: 'notes', // Access via /notes 293 - publicationRkey: 'xyz123abc' 294 - } 287 + { 288 + slug: 'blog', // Access via /blog 289 + publicationRkey: '3m3x4bgbsh22k' // Leaflet publication rkey 290 + }, 291 + { 292 + slug: 'notes', // Access via /notes 293 + publicationRkey: 'xyz123abc' 294 + } 295 295 ]; 296 296 ``` 297 297
+98 -92
src/app.css
··· 2 2 @import 'tailwindcss'; 3 3 4 4 @theme { 5 - /* Font Family */ 6 - --font-family-sans: 'Inter', ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji'; 5 + /* Font Family */ 6 + --font-family-sans: 7 + 'Inter', ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 8 + 'Segoe UI Symbol', 'Noto Color Emoji'; 7 9 8 - /* Ink - Text colors (adjusted for WCAG AA compliance) */ 9 - --color-ink-50: light-dark(oklch(97.31% 0.015 123.04), oklch(17.39% 0.023 124.58)); 10 - --color-ink-100: light-dark(oklch(93.00% 0.032 124.47), oklch(24.90% 0.042 126.80)); 11 - --color-ink-200: light-dark(oklch(85.00% 0.061 123.88), oklch(38.03% 0.070 126.15)); 12 - --color-ink-300: light-dark(oklch(75.00% 0.093 124.99), oklch(50.28% 0.098 126.82)); 13 - --color-ink-400: light-dark(oklch(65.00% 0.123 125.63), oklch(61.88% 0.124 126.72)); 14 - --color-ink-500: light-dark(oklch(55.00% 0.149 127.03), oklch(72.90% 0.149 127.03)); 15 - --color-ink-600: light-dark(oklch(45.00% 0.124 126.72), oklch(78.19% 0.123 125.63)); 16 - --color-ink-700: light-dark(oklch(35.00% 0.098 126.82), oklch(83.50% 0.093 124.99)); 17 - --color-ink-800: light-dark(oklch(25.00% 0.070 126.15), oklch(88.94% 0.061 123.88)); 18 - --color-ink-900: light-dark(oklch(18.00% 0.042 126.80), oklch(94.52% 0.032 124.47)); 19 - --color-ink-950: light-dark(oklch(12.00% 0.023 124.58), oklch(97.31% 0.015 123.04)); 10 + /* Ink - Text colors (adjusted for WCAG AA compliance) */ 11 + --color-ink-50: light-dark(oklch(97.31% 0.015 123.04), oklch(17.39% 0.023 124.58)); 12 + --color-ink-100: light-dark(oklch(93% 0.032 124.47), oklch(24.9% 0.042 126.8)); 13 + --color-ink-200: light-dark(oklch(85% 0.061 123.88), oklch(38.03% 0.07 126.15)); 14 + --color-ink-300: light-dark(oklch(75% 0.093 124.99), oklch(50.28% 0.098 126.82)); 15 + --color-ink-400: light-dark(oklch(65% 0.123 125.63), oklch(61.88% 0.124 126.72)); 16 + --color-ink-500: light-dark(oklch(55% 0.149 127.03), oklch(72.9% 0.149 127.03)); 17 + --color-ink-600: light-dark(oklch(45% 0.124 126.72), oklch(78.19% 0.123 125.63)); 18 + --color-ink-700: light-dark(oklch(35% 0.098 126.82), oklch(83.5% 0.093 124.99)); 19 + --color-ink-800: light-dark(oklch(25% 0.07 126.15), oklch(88.94% 0.061 123.88)); 20 + --color-ink-900: light-dark(oklch(18% 0.042 126.8), oklch(94.52% 0.032 124.47)); 21 + --color-ink-950: light-dark(oklch(12% 0.023 124.58), oklch(97.31% 0.015 123.04)); 20 22 21 - /* Canvas - Background colors (adjusted for better contrast) */ 22 - --color-canvas-50: light-dark(oklch(98.50% 0.010 123.97), oklch(17.69% 0.027 125.57)); 23 - --color-canvas-100: light-dark(oklch(96.50% 0.020 123.69), oklch(25.56% 0.047 126.44)); 24 - --color-canvas-200: light-dark(oklch(92.00% 0.045 125.14), oklch(39.36% 0.083 127.85)); 25 - --color-canvas-300: light-dark(oklch(86.00% 0.075 125.55), oklch(51.84% 0.112 127.68)); 26 - --color-canvas-400: light-dark(oklch(80.00% 0.105 126.87), oklch(63.78% 0.141 128.14)); 27 - --color-canvas-500: light-dark(oklch(75.25% 0.135 128.13), oklch(75.25% 0.169 128.13)); 28 - --color-canvas-600: light-dark(oklch(63.78% 0.141 128.14), oklch(80.00% 0.105 126.87)); 29 - --color-canvas-700: light-dark(oklch(51.84% 0.112 127.68), oklch(86.00% 0.075 125.55)); 30 - --color-canvas-800: light-dark(oklch(39.36% 0.083 127.85), oklch(92.00% 0.045 125.14)); 31 - --color-canvas-900: light-dark(oklch(25.56% 0.047 126.44), oklch(96.50% 0.020 123.69)); 32 - --color-canvas-950: light-dark(oklch(17.69% 0.027 125.57), oklch(98.50% 0.010 123.97)); 23 + /* Canvas - Background colors (adjusted for better contrast) */ 24 + --color-canvas-50: light-dark(oklch(98.5% 0.01 123.97), oklch(17.69% 0.027 125.57)); 25 + --color-canvas-100: light-dark(oklch(96.5% 0.02 123.69), oklch(25.56% 0.047 126.44)); 26 + --color-canvas-200: light-dark(oklch(92% 0.045 125.14), oklch(39.36% 0.083 127.85)); 27 + --color-canvas-300: light-dark(oklch(86% 0.075 125.55), oklch(51.84% 0.112 127.68)); 28 + --color-canvas-400: light-dark(oklch(80% 0.105 126.87), oklch(63.78% 0.141 128.14)); 29 + --color-canvas-500: light-dark(oklch(75.25% 0.135 128.13), oklch(75.25% 0.169 128.13)); 30 + --color-canvas-600: light-dark(oklch(63.78% 0.141 128.14), oklch(80% 0.105 126.87)); 31 + --color-canvas-700: light-dark(oklch(51.84% 0.112 127.68), oklch(86% 0.075 125.55)); 32 + --color-canvas-800: light-dark(oklch(39.36% 0.083 127.85), oklch(92% 0.045 125.14)); 33 + --color-canvas-900: light-dark(oklch(25.56% 0.047 126.44), oklch(96.5% 0.02 123.69)); 34 + --color-canvas-950: light-dark(oklch(17.69% 0.027 125.57), oklch(98.5% 0.01 123.97)); 33 35 34 - /* Sage - Primary colors (adjusted for WCAG AA compliance) */ 35 - --color-primary-50: light-dark(oklch(97.73% 0.020 121.83), oklch(18.09% 0.031 123.74)); 36 - --color-primary-100: light-dark(oklch(94.00% 0.042 123.12), oklch(26.23% 0.053 126.29)); 37 - --color-primary-200: light-dark(oklch(88.00% 0.082 123.68), oklch(40.39% 0.088 126.72)); 38 - --color-primary-300: light-dark(oklch(78.00% 0.122 124.71), oklch(53.63% 0.122 127.17)); 39 - --color-primary-400: light-dark(oklch(68.00% 0.155 125.79), oklch(65.86% 0.152 127.23)); 40 - --color-primary-500: light-dark(oklch(58.00% 0.182 127.42), oklch(77.77% 0.182 127.42)); 41 - --color-primary-600: light-dark(oklch(48.00% 0.152 127.23), oklch(81.83% 0.155 125.79)); 42 - --color-primary-700: light-dark(oklch(38.00% 0.122 127.17), oklch(86.28% 0.122 124.71)); 43 - --color-primary-800: light-dark(oklch(28.00% 0.088 126.72), oklch(90.67% 0.082 123.68)); 44 - --color-primary-900: light-dark(oklch(20.00% 0.053 126.29), oklch(95.38% 0.042 123.12)); 45 - --color-primary-950: light-dark(oklch(14.00% 0.031 123.74), oklch(97.73% 0.020 121.83)); 36 + /* Sage - Primary colors (adjusted for WCAG AA compliance) */ 37 + --color-primary-50: light-dark(oklch(97.73% 0.02 121.83), oklch(18.09% 0.031 123.74)); 38 + --color-primary-100: light-dark(oklch(94% 0.042 123.12), oklch(26.23% 0.053 126.29)); 39 + --color-primary-200: light-dark(oklch(88% 0.082 123.68), oklch(40.39% 0.088 126.72)); 40 + --color-primary-300: light-dark(oklch(78% 0.122 124.71), oklch(53.63% 0.122 127.17)); 41 + --color-primary-400: light-dark(oklch(68% 0.155 125.79), oklch(65.86% 0.152 127.23)); 42 + --color-primary-500: light-dark(oklch(58% 0.182 127.42), oklch(77.77% 0.182 127.42)); 43 + --color-primary-600: light-dark(oklch(48% 0.152 127.23), oklch(81.83% 0.155 125.79)); 44 + --color-primary-700: light-dark(oklch(38% 0.122 127.17), oklch(86.28% 0.122 124.71)); 45 + --color-primary-800: light-dark(oklch(28% 0.088 126.72), oklch(90.67% 0.082 123.68)); 46 + --color-primary-900: light-dark(oklch(20% 0.053 126.29), oklch(95.38% 0.042 123.12)); 47 + --color-primary-950: light-dark(oklch(14% 0.031 123.74), oklch(97.73% 0.02 121.83)); 46 48 47 - /* Mint - Secondary colors (adjusted for WCAG AA compliance) */ 48 - --color-secondary-50: light-dark(oklch(97.87% 0.024 121.90), oklch(18.72% 0.037 126.20)); 49 - --color-secondary-100: light-dark(oklch(94.50% 0.048 123.90), oklch(26.82% 0.058 127.38)); 50 - --color-secondary-200: light-dark(oklch(89.00% 0.097 124.41), oklch(42.08% 0.101 128.02)); 51 - --color-secondary-300: light-dark(oklch(80.00% 0.141 125.62), oklch(55.72% 0.137 128.49)); 52 - --color-secondary-400: light-dark(oklch(70.00% 0.178 127.04), oklch(68.58% 0.171 128.75)); 53 - --color-secondary-500: light-dark(oklch(60.00% 0.205 129.04), oklch(81.09% 0.205 129.04)); 54 - --color-secondary-600: light-dark(oklch(50.00% 0.171 128.75), oklch(84.30% 0.178 127.04)); 55 - --color-secondary-700: light-dark(oklch(40.00% 0.137 128.49), oklch(87.99% 0.141 125.62)); 56 - --color-secondary-800: light-dark(oklch(30.00% 0.101 128.02), oklch(91.89% 0.097 124.41)); 57 - --color-secondary-900: light-dark(oklch(22.00% 0.058 127.38), oklch(95.73% 0.048 123.90)); 58 - --color-secondary-950: light-dark(oklch(15.00% 0.037 126.20), oklch(97.87% 0.024 121.90)); 49 + /* Mint - Secondary colors (adjusted for WCAG AA compliance) */ 50 + --color-secondary-50: light-dark(oklch(97.87% 0.024 121.9), oklch(18.72% 0.037 126.2)); 51 + --color-secondary-100: light-dark(oklch(94.5% 0.048 123.9), oklch(26.82% 0.058 127.38)); 52 + --color-secondary-200: light-dark(oklch(89% 0.097 124.41), oklch(42.08% 0.101 128.02)); 53 + --color-secondary-300: light-dark(oklch(80% 0.141 125.62), oklch(55.72% 0.137 128.49)); 54 + --color-secondary-400: light-dark(oklch(70% 0.178 127.04), oklch(68.58% 0.171 128.75)); 55 + --color-secondary-500: light-dark(oklch(60% 0.205 129.04), oklch(81.09% 0.205 129.04)); 56 + --color-secondary-600: light-dark(oklch(50% 0.171 128.75), oklch(84.3% 0.178 127.04)); 57 + --color-secondary-700: light-dark(oklch(40% 0.137 128.49), oklch(87.99% 0.141 125.62)); 58 + --color-secondary-800: light-dark(oklch(30% 0.101 128.02), oklch(91.89% 0.097 124.41)); 59 + --color-secondary-900: light-dark(oklch(22% 0.058 127.38), oklch(95.73% 0.048 123.9)); 60 + --color-secondary-950: light-dark(oklch(15% 0.037 126.2), oklch(97.87% 0.024 121.9)); 59 61 60 - /* Jade - Accent colors (adjusted for WCAG AA compliance) */ 61 - --color-accent-50: light-dark(oklch(98.05% 0.027 122.65), oklch(19.03% 0.041 126.73)); 62 - --color-accent-100: light-dark(oklch(95.00% 0.056 123.80), oklch(27.78% 0.066 127.71)); 63 - --color-accent-200: light-dark(oklch(90.00% 0.110 124.83), oklch(43.51% 0.110 128.91)); 64 - --color-accent-300: light-dark(oklch(82.00% 0.159 126.06), oklch(57.90% 0.149 129.35)); 65 - --color-accent-400: light-dark(oklch(72.00% 0.198 127.63), oklch(71.44% 0.186 129.59)); 66 - --color-accent-500: light-dark(oklch(62.00% 0.221 129.75), oklch(84.36% 0.221 129.75)); 67 - --color-accent-600: light-dark(oklch(52.00% 0.186 129.59), oklch(86.93% 0.198 127.63)); 68 - --color-accent-700: light-dark(oklch(42.00% 0.149 129.35), oklch(89.79% 0.159 126.06)); 69 - --color-accent-800: light-dark(oklch(32.00% 0.110 128.91), oklch(92.93% 0.110 124.83)); 70 - --color-accent-900: light-dark(oklch(23.00% 0.066 127.71), oklch(96.35% 0.056 123.80)); 71 - --color-accent-950: light-dark(oklch(16.00% 0.041 126.73), oklch(98.05% 0.027 122.65)); 62 + /* Jade - Accent colors (adjusted for WCAG AA compliance) */ 63 + --color-accent-50: light-dark(oklch(98.05% 0.027 122.65), oklch(19.03% 0.041 126.73)); 64 + --color-accent-100: light-dark(oklch(95% 0.056 123.8), oklch(27.78% 0.066 127.71)); 65 + --color-accent-200: light-dark(oklch(90% 0.11 124.83), oklch(43.51% 0.11 128.91)); 66 + --color-accent-300: light-dark(oklch(82% 0.159 126.06), oklch(57.9% 0.149 129.35)); 67 + --color-accent-400: light-dark(oklch(72% 0.198 127.63), oklch(71.44% 0.186 129.59)); 68 + --color-accent-500: light-dark(oklch(62% 0.221 129.75), oklch(84.36% 0.221 129.75)); 69 + --color-accent-600: light-dark(oklch(52% 0.186 129.59), oklch(86.93% 0.198 127.63)); 70 + --color-accent-700: light-dark(oklch(42% 0.149 129.35), oklch(89.79% 0.159 126.06)); 71 + --color-accent-800: light-dark(oklch(32% 0.11 128.91), oklch(92.93% 0.11 124.83)); 72 + --color-accent-900: light-dark(oklch(23% 0.066 127.71), oklch(96.35% 0.056 123.8)); 73 + --color-accent-950: light-dark(oklch(16% 0.041 126.73), oklch(98.05% 0.027 122.65)); 72 74 } 73 75 74 76 @layer base { 75 - /* Base styles for consistent typography and accessibility */ 76 - html { 77 - scroll-behavior: smooth; 78 - overflow-x: hidden; 79 - width: 100%; 80 - } 77 + /* Base styles for consistent typography and accessibility */ 78 + html { 79 + scroll-behavior: smooth; 80 + overflow-x: hidden; 81 + width: 100%; 82 + } 83 + 84 + body { 85 + font-family: var(--font-family-sans); 86 + text-rendering: optimizeLegibility; 87 + -webkit-font-smoothing: antialiased; 88 + -moz-osx-font-smoothing: grayscale; 89 + overflow-x: hidden; 90 + width: 100%; 91 + max-width: 100vw; 92 + } 93 + 94 + /* Focus visible styles for accessibility */ 95 + *:focus-visible { 96 + outline: 2px solid var(--color-primary-600); 97 + outline-offset: 2px; 98 + } 81 99 82 - body { 83 - font-family: var(--font-family-sans); 84 - text-rendering: optimizeLegibility; 85 - -webkit-font-smoothing: antialiased; 86 - -moz-osx-font-smoothing: grayscale; 87 - overflow-x: hidden; 88 - width: 100%; 89 - max-width: 100vw; 90 - } 100 + /* Ensure all elements stay within viewport */ 101 + * { 102 + min-width: 0; 103 + } 91 104 92 - /* Focus visible styles for accessibility */ 93 - *:focus-visible { 94 - outline: 2px solid var(--color-primary-600); 95 - outline-offset: 2px; 96 - } 97 - 98 - /* Ensure all elements stay within viewport */ 99 - * { 100 - min-width: 0; 101 - } 102 - 103 - img, video, iframe, embed, object { 104 - max-width: 100%; 105 - height: auto; 106 - } 105 + img, 106 + video, 107 + iframe, 108 + embed, 109 + object { 110 + max-width: 100%; 111 + height: auto; 112 + } 107 113 } 108 114 109 115 @plugin '@tailwindcss/typography';
+4 -4
src/hooks.server.ts
··· 3 3 4 4 /** 5 5 * Global request handler with CORS support 6 - * 6 + * 7 7 * CORS headers are dynamically configured via the PUBLIC_CORS_ALLOWED_ORIGINS environment variable. 8 8 * Set it to a comma-separated list of allowed origins, or "*" to allow all origins. 9 9 */ ··· 11 11 // Handle OPTIONS preflight requests for CORS 12 12 if (event.request.method === 'OPTIONS' && event.url.pathname.startsWith('/api/')) { 13 13 const origin = event.request.headers.get('origin'); 14 - const allowedOrigins = PUBLIC_CORS_ALLOWED_ORIGINS?.split(',').map(o => o.trim()) || []; 14 + const allowedOrigins = PUBLIC_CORS_ALLOWED_ORIGINS?.split(',').map((o) => o.trim()) || []; 15 15 16 16 const headers: Record<string, string> = { 17 17 'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS', ··· 38 38 // Add CORS headers for API routes 39 39 if (event.url.pathname.startsWith('/api/')) { 40 40 const origin = event.request.headers.get('origin'); 41 - const allowedOrigins = PUBLIC_CORS_ALLOWED_ORIGINS?.split(',').map(o => o.trim()) || []; 41 + const allowedOrigins = PUBLIC_CORS_ALLOWED_ORIGINS?.split(',').map((o) => o.trim()) || []; 42 42 43 43 // If * is specified, allow any origin 44 44 if (allowedOrigins.includes('*')) { ··· 55 55 } 56 56 57 57 return response; 58 - }; 58 + };
+5 -3
src/lib/components/layout/Footer.svelte
··· 8 8 let copyrightText: string; 9 9 10 10 const currentYear = new Date().getFullYear(); 11 - 11 + 12 12 $: { 13 13 console.log('[Footer] Reactive: siteInfo updated:', siteInfo); 14 14 const birthYear = siteInfo?.additionalInfo?.websiteBirthYear; 15 15 console.log('[Footer] Current year:', currentYear); 16 16 console.log('[Footer] Birth year:', birthYear); 17 17 console.log('[Footer] Birth year type:', typeof birthYear); 18 - 18 + 19 19 if (!birthYear || typeof birthYear !== 'number') { 20 20 console.log('[Footer] Using current year (invalid/missing birth year)'); 21 21 copyrightText = `${currentYear}`; ··· 37 37 <footer 38 38 class="mt-auto w-full border-t border-canvas-200 bg-canvas-50 py-6 dark:border-canvas-800 dark:bg-canvas-950" 39 39 > 40 - <div class="container mx-auto space-y-2 px-4 text-center text-sm font-medium text-ink-800 dark:text-ink-100"> 40 + <div 41 + class="container mx-auto space-y-2 px-4 text-center text-sm font-medium text-ink-800 dark:text-ink-100" 42 + > 41 43 <!-- Line 1: Copyright & Profile --> 42 44 <div class="flex flex-col items-center justify-center gap-1 sm:flex-row sm:gap-2"> 43 45 <span>&copy; <span>{copyrightText}</span></span>
+12 -4
src/lib/components/layout/Header.svelte
··· 9 9 const siteMeta: SiteMetadata = createSiteMeta(defaultSiteMeta); 10 10 </script> 11 11 12 - <header class="sticky top-0 z-50 w-full border-b border-canvas-200 bg-canvas-50/90 backdrop-blur-md dark:border-canvas-800 dark:bg-canvas-950/90"> 13 - <nav class="container mx-auto flex items-center justify-between px-4 py-4" aria-label="Main navigation"> 12 + <header 13 + class="sticky top-0 z-50 w-full border-b border-canvas-200 bg-canvas-50/90 backdrop-blur-md dark:border-canvas-800 dark:bg-canvas-950/90" 14 + > 15 + <nav 16 + class="container mx-auto flex items-center justify-between px-4 py-4" 17 + aria-label="Main navigation" 18 + > 14 19 <a href="/" class="group flex min-w-0 items-center gap-2"> 15 - <span class="max-w-[200px] truncate text-xl font-bold text-ink-900 dark:text-ink-50" aria-label="{siteMeta.title} - Home"> 20 + <span 21 + class="max-w-[200px] truncate text-xl font-bold text-ink-900 dark:text-ink-50" 22 + aria-label="{siteMeta.title} - Home" 23 + > 16 24 {siteMeta.title} 17 25 </span> 18 26 </a> ··· 25 33 </div> 26 34 </div> 27 35 </nav> 28 - </header> 36 + </header>
+39 -39
src/lib/components/layout/NavLinks.svelte
··· 1 1 <script lang="ts"> 2 - import { getStores } from '$app/stores'; 3 - import type { NavItem } from '$lib/data/navItems'; 4 - import * as LucideIcons from '@lucide/svelte'; 2 + import { getStores } from '$app/stores'; 3 + import type { NavItem } from '$lib/data/navItems'; 4 + import * as LucideIcons from '@lucide/svelte'; 5 5 6 - const { page } = getStores(); 7 - export let navItems: NavItem[] = []; 6 + const { page } = getStores(); 7 + export let navItems: NavItem[] = []; 8 8 9 - // Map of icon names to Lucide components 10 - let iconComponents: Record<string, typeof import('svelte').SvelteComponent> = {}; 11 - navItems.forEach((item) => { 12 - const iconName = item.iconPath; 13 - if (iconName && (LucideIcons as any)[iconName]) { 14 - iconComponents[item.href] = (LucideIcons as any)[iconName]; 15 - } 16 - }); 9 + // Map of icon names to Lucide components 10 + let iconComponents: Record<string, typeof import('svelte').SvelteComponent> = {}; 11 + navItems.forEach((item) => { 12 + const iconName = item.iconPath; 13 + if (iconName && (LucideIcons as any)[iconName]) { 14 + iconComponents[item.href] = (LucideIcons as any)[iconName]; 15 + } 16 + }); 17 17 </script> 18 18 19 19 <ul class="flex items-center gap-6"> 20 - {#each navItems as item} 21 - <li> 22 - <a 23 - href={item.href} 24 - class="group flex items-center gap-2 font-medium transition-colors 20 + {#each navItems as item} 21 + <li> 22 + <a 23 + href={item.href} 24 + class="group flex items-center gap-2 font-medium transition-colors 25 25 {$page.url.pathname === item.href 26 - ? 'text-primary-600 dark:text-primary-400' 27 - : 'text-ink-700 dark:text-ink-200'} 26 + ? 'text-primary-600 dark:text-primary-400' 27 + : 'text-ink-700 dark:text-ink-200'} 28 28 hover:text-primary-500" 29 - aria-current={$page.url.pathname === item.href ? 'page' : undefined} 30 - title={item.label} 31 - > 32 - {#if iconComponents[item.href]} 33 - <svelte:component 34 - this={iconComponents[item.href]} 35 - class="h-5 w-5 transition-transform group-hover:scale-110" 36 - aria-hidden="true" 37 - /> 38 - {:else} 39 - <div class="h-5 w-5 flex items-center justify-center" aria-hidden="true"> 40 - <div class="h-3 w-3 animate-pulse rounded-full bg-primary-500"></div> 41 - </div> 42 - {/if} 43 - <span class="hidden sm:inline">{item.label}</span> 44 - </a> 45 - </li> 46 - {/each} 47 - </ul> 29 + aria-current={$page.url.pathname === item.href ? 'page' : undefined} 30 + title={item.label} 31 + > 32 + {#if iconComponents[item.href]} 33 + <svelte:component 34 + this={iconComponents[item.href]} 35 + class="h-5 w-5 transition-transform group-hover:scale-110" 36 + aria-hidden="true" 37 + /> 38 + {:else} 39 + <div class="flex h-5 w-5 items-center justify-center" aria-hidden="true"> 40 + <div class="h-3 w-3 animate-pulse rounded-full bg-primary-500"></div> 41 + </div> 42 + {/if} 43 + <span class="hidden sm:inline">{item.label}</span> 44 + </a> 45 + </li> 46 + {/each} 47 + </ul>
+7 -7
src/lib/components/layout/ThemeToggle.svelte
··· 9 9 // Check localStorage and system preference 10 10 const stored = localStorage.getItem('theme'); 11 11 const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches; 12 - 12 + 13 13 isDark = stored === 'dark' || (!stored && prefersDark); 14 14 updateTheme(); 15 15 mounted = true; ··· 31 31 32 32 function updateTheme() { 33 33 const htmlElement = document.documentElement; 34 - 34 + 35 35 if (isDark) { 36 36 htmlElement.classList.add('dark'); 37 37 htmlElement.style.colorScheme = 'dark'; ··· 58 58 <div class="relative h-5 w-5"> 59 59 <Sun 60 60 class="absolute inset-0 h-5 w-5 transition-all duration-300 {isDark 61 - ? 'rotate-90 scale-0 opacity-0' 62 - : 'rotate-0 scale-100 opacity-100'}" 61 + ? 'scale-0 rotate-90 opacity-0' 62 + : 'scale-100 rotate-0 opacity-100'}" 63 63 aria-hidden="true" 64 64 /> 65 65 <Moon 66 66 class="absolute inset-0 h-5 w-5 transition-all duration-300 {isDark 67 - ? 'rotate-0 scale-100 opacity-100' 68 - : '-rotate-90 scale-0 opacity-0'}" 67 + ? 'scale-100 rotate-0 opacity-100' 68 + : 'scale-0 -rotate-90 opacity-0'}" 69 69 aria-hidden="true" 70 70 /> 71 71 </div> 72 72 {:else} 73 - <div class="h-5 w-5 animate-pulse bg-canvas-300 dark:bg-canvas-700 rounded"></div> 73 + <div class="h-5 w-5 animate-pulse rounded bg-canvas-300 dark:bg-canvas-700"></div> 74 74 {/if} 75 75 </button>
+1 -1
src/lib/components/layout/WolfToggle.svelte
··· 23 23 type="button" 24 24 title={isWolfMode ? 'Return to normal text' : 'Transform to wolf speak - awoo!'} 25 25 > 26 - <span 26 + <span 27 27 class="text-2xl transition-transform duration-300 {isWolfMode ? 'scale-125' : 'scale-100'}" 28 28 aria-hidden="true" 29 29 >
+4 -3
src/lib/components/layout/main/DynamicLinks.svelte
··· 50 50 {#snippet children()} 51 51 <div class="text-center"> 52 52 <p class="text-ink-700 dark:text-ink-300"> 53 - No links available. Create a <code 54 - class="rounded bg-canvas-200 px-1 dark:bg-canvas-800">blue.linkat.board</code 55 - > record at 53 + No links available. Create a <code class="rounded bg-canvas-200 px-1 dark:bg-canvas-800" 54 + >blue.linkat.board</code 55 + > 56 + record at 56 57 <a 57 58 href="https://linkat.blue/" 58 59 class="text-primary-600 hover:underline dark:text-primary-400"
+33 -33
src/lib/components/layout/main/ScrollToTop.svelte
··· 1 1 <script lang="ts"> 2 - import { onMount } from "svelte"; 3 - import { ChevronUp } from "@lucide/svelte"; 2 + import { onMount } from 'svelte'; 3 + import { ChevronUp } from '@lucide/svelte'; 4 4 5 - let isVisible = false; 6 - let scrollY = 0; 5 + let isVisible = false; 6 + let scrollY = 0; 7 7 8 - $: isVisible = scrollY > 300; 8 + $: isVisible = scrollY > 300; 9 9 10 - function scrollToTop() { 11 - window.scrollTo({ top: 0, behavior: "smooth" }); 12 - } 10 + function scrollToTop() { 11 + window.scrollTo({ top: 0, behavior: 'smooth' }); 12 + } 13 13 14 - function handleKeydown(event: KeyboardEvent) { 15 - if (event.key === "Enter" || event.key === " ") { 16 - event.preventDefault(); 17 - scrollToTop(); 18 - } 19 - } 14 + function handleKeydown(event: KeyboardEvent) { 15 + if (event.key === 'Enter' || event.key === ' ') { 16 + event.preventDefault(); 17 + scrollToTop(); 18 + } 19 + } 20 20 21 - onMount(() => { 22 - const updateScrollY = () => (scrollY = window.scrollY); 23 - window.addEventListener("scroll", updateScrollY, { passive: true }); 24 - return () => window.removeEventListener("scroll", updateScrollY); 25 - }); 21 + onMount(() => { 22 + const updateScrollY = () => (scrollY = window.scrollY); 23 + window.addEventListener('scroll', updateScrollY, { passive: true }); 24 + return () => window.removeEventListener('scroll', updateScrollY); 25 + }); 26 26 </script> 27 27 28 28 <svelte:window bind:scrollY /> 29 29 30 30 <!-- just Tailwind fade via opacity --> 31 31 <div 32 - class="fixed bottom-8 left-8 z-50 sm:bottom-6 sm:left-6 transition-opacity duration-300 motion-reduce:transition-none" 33 - class:opacity-100={isVisible} 34 - class:opacity-0={!isVisible} 32 + class="fixed bottom-8 left-8 z-50 transition-opacity duration-300 motion-reduce:transition-none sm:bottom-6 sm:left-6" 33 + class:opacity-100={isVisible} 34 + class:opacity-0={!isVisible} 35 35 > 36 - <button 37 - on:click={scrollToTop} 38 - on:keydown={handleKeydown} 39 - aria-label="Scroll to top" 40 - title="Scroll to top" 41 - type="button" 42 - class="flex h-12 w-12 items-center justify-center rounded-full border bg-canvas-100 text-ink-900 border-primary-200 shadow-lg transition-all duration-300 ease-out hover:-translate-y-0.5 hover:bg-primary-500 hover:text-ink-50 hover:shadow-xl focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 dark:bg-canvas-900 dark:text-ink-50 dark:border-primary-800 dark:hover:bg-primary-600 motion-reduce:transition-none motion-reduce:hover:translate-y-0 sm:h-11 sm:w-11" 43 - > 44 - <ChevronUp width="20" height="20" aria-hidden="true" /> 45 - </button> 46 - </div> 36 + <button 37 + on:click={scrollToTop} 38 + on:keydown={handleKeydown} 39 + aria-label="Scroll to top" 40 + title="Scroll to top" 41 + type="button" 42 + class="flex h-12 w-12 items-center justify-center rounded-full border border-primary-200 bg-canvas-100 text-ink-900 shadow-lg transition-all duration-300 ease-out hover:-translate-y-0.5 hover:bg-primary-500 hover:text-ink-50 hover:shadow-xl focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:outline-none motion-reduce:transition-none motion-reduce:hover:translate-y-0 sm:h-11 sm:w-11 dark:border-primary-800 dark:bg-canvas-900 dark:text-ink-50 dark:hover:bg-primary-600" 43 + > 44 + <ChevronUp width="20" height="20" aria-hidden="true" /> 45 + </button> 46 + </div>
+2 -7
src/lib/components/layout/main/TangledRepos.svelte
··· 11 11 12 12 onMount(async () => { 13 13 try { 14 - const [reposData, profile] = await Promise.all([ 15 - fetchTangledRepos(), 16 - fetchProfile() 17 - ]); 14 + const [reposData, profile] = await Promise.all([fetchTangledRepos(), fetchProfile()]); 18 15 repos = reposData; 19 16 handle = profile.handle; 20 17 } catch (err) { ··· 43 40 {@const safeRepos = repos} 44 41 <Card variant="elevated" padding="md"> 45 42 {#snippet children()} 46 - <h2 class="mb-4 text-2xl font-bold text-ink-900 dark:text-ink-50"> 47 - Tangled Repositories 48 - </h2> 43 + <h2 class="mb-4 text-2xl font-bold text-ink-900 dark:text-ink-50">Tangled Repositories</h2> 49 44 <div class="space-y-3"> 50 45 {#each safeRepos.repos as repo} 51 46 <TangledRepoCard {repo} {handle} />
+93 -58
src/lib/components/layout/main/card/BlueskyPostCard.svelte
··· 93 93 94 94 for (const facet of sortedFacets) { 95 95 const { byteStart, byteEnd } = facet.index; 96 - 96 + 97 97 // Extract text before facet 98 98 if (lastByteIndex < byteStart) { 99 99 const beforeBytes = bytes.slice(lastByteIndex, byteStart); 100 100 result += escapeHtml(decoder.decode(beforeBytes)); 101 101 } 102 - 102 + 103 103 // Extract facet text 104 104 const facetBytes = bytes.slice(byteStart, byteEnd); 105 105 const facetText = decoder.decode(facetBytes); ··· 198 198 href={getProfileUrl(postData.author.handle)} 199 199 target="_blank" 200 200 rel="noopener noreferrer" 201 - class="transition-opacity hover:opacity-80 shrink-0" 201 + class="shrink-0 transition-opacity hover:opacity-80" 202 202 > 203 203 {#if postData.author.avatar} 204 204 <img 205 205 src={postData.author.avatar} 206 206 alt={postData.author.displayName || postData.author.handle} 207 - class="h-8 w-8 sm:h-10 sm:w-10 rounded-full object-cover" 207 + class="h-8 w-8 rounded-full object-cover sm:h-10 sm:w-10" 208 208 loading="lazy" 209 209 /> 210 210 {:else} 211 211 <div 212 - class="flex h-8 w-8 sm:h-10 sm:w-10 items-center justify-center rounded-full bg-primary-200 dark:bg-primary-800" 212 + class="flex h-8 w-8 items-center justify-center rounded-full bg-primary-200 sm:h-10 sm:w-10 dark:bg-primary-800" 213 213 > 214 - <span class="text-sm sm:text-base font-semibold text-primary-700 dark:text-primary-300"> 214 + <span 215 + class="text-sm font-semibold text-primary-700 sm:text-base dark:text-primary-300" 216 + > 215 217 {(postData.author.displayName || postData.author.handle).charAt(0).toUpperCase()} 216 218 </span> 217 219 </div> ··· 222 224 href={getProfileUrl(postData.author.handle)} 223 225 target="_blank" 224 226 rel="noopener noreferrer" 225 - class="transition-opacity hover:opacity-80 shrink-0" 227 + class="shrink-0 transition-opacity hover:opacity-80" 226 228 > 227 229 {#if postData.author.avatar} 228 230 <img 229 231 src={postData.author.avatar} 230 232 alt={postData.author.displayName || postData.author.handle} 231 - class="h-10 w-10 sm:h-12 sm:w-12 rounded-full object-cover" 233 + class="h-10 w-10 rounded-full object-cover sm:h-12 sm:w-12" 232 234 loading="lazy" 233 235 /> 234 236 {:else} 235 237 <div 236 - class="flex h-10 w-10 sm:h-12 sm:w-12 items-center justify-center rounded-full bg-primary-200 dark:bg-primary-800" 238 + class="flex h-10 w-10 items-center justify-center rounded-full bg-primary-200 sm:h-12 sm:w-12 dark:bg-primary-800" 237 239 > 238 - <span class="text-base sm:text-lg font-semibold text-primary-700 dark:text-primary-300"> 240 + <span 241 + class="text-base font-semibold text-primary-700 sm:text-lg dark:text-primary-300" 242 + > 239 243 {(postData.author.displayName || postData.author.handle).charAt(0).toUpperCase()} 240 244 </span> 241 245 </div> 242 246 {/if} 243 247 </a> 244 248 {/if} 245 - <div class="flex-1 min-w-0"> 249 + <div class="min-w-0 flex-1"> 246 250 <!-- Author name and handle --> 247 251 <a 248 252 href={getProfileUrl(postData.author.handle)} ··· 251 255 class="inline-block {isReplyParent ? 'mb-1' : 'mb-2'} transition-opacity hover:opacity-80" 252 256 > 253 257 <div class="flex flex-col"> 254 - <span class="text-{isReplyParent ? 'sm' : 'base'} font-semibold text-ink-900 dark:text-ink-50 leading-tight"> 258 + <span 259 + class="text-{isReplyParent 260 + ? 'sm' 261 + : 'base'} leading-tight font-semibold text-ink-900 dark:text-ink-50" 262 + > 255 263 {postData.author.displayName || postData.author.handle} 256 264 </span> 257 - <span class="text-xs text-ink-600 dark:text-ink-400 leading-tight"> 265 + <span class="text-xs leading-tight text-ink-600 dark:text-ink-400"> 258 266 @{postData.author.handle} 259 267 </span> 260 268 </div> ··· 262 270 263 271 <!-- Post Text with Rich Text Support --> 264 272 <div 265 - class="{isReplyParent ? 'mb-2' : 'mb-3'} overflow-wrap-anywhere break-words whitespace-pre-wrap text-{isReplyParent 273 + class="{isReplyParent 274 + ? 'mb-2' 275 + : 'mb-3'} overflow-wrap-anywhere break-words whitespace-pre-wrap text-{isReplyParent 266 276 ? 'sm' 267 277 : 'base'} leading-relaxed text-ink-900 dark:text-ink-50" 268 278 > ··· 271 281 272 282 <!-- Video --> 273 283 {#if postData.hasVideo && postData.videoUrl} 274 - <div class="{isReplyParent ? 'mb-2' : 'mb-3'} max-w-full overflow-hidden rounded-xl bg-black border border-canvas-300 dark:border-canvas-700"> 284 + <div 285 + class="{isReplyParent 286 + ? 'mb-2' 287 + : 'mb-3'} max-w-full overflow-hidden rounded-xl border border-canvas-300 bg-black dark:border-canvas-700" 288 + > 275 289 <video 276 290 use:setupVideo={postData.videoUrl} 277 291 controls ··· 289 303 <!-- Images --> 290 304 {#if postData.hasImages && postData.imageUrls && postData.imageUrls.length > 0} 291 305 <div 292 - class="{isReplyParent ? 'mb-2' : 'mb-3'} grid max-w-full gap-1 {postData.imageUrls.length === 1 306 + class="{isReplyParent ? 'mb-2' : 'mb-3'} grid max-w-full gap-1 {postData.imageUrls 307 + .length === 1 293 308 ? 'grid-cols-1' 294 309 : postData.imageUrls.length === 2 295 310 ? 'grid-cols-2' ··· 299 314 > 300 315 {#each postData.imageUrls as imageUrl, index} 301 316 <button 302 - type="button" 303 - onclick={() => 304 - openLightbox(imageUrl, postData.imageAlts?.[index] || `Post attachment ${index + 1}`)} 305 - class="h-auto w-full max-w-full overflow-hidden rounded-lg transition-opacity hover:opacity-90 focus:ring-2 focus:ring-primary-500 focus:outline-none dark:focus:ring-primary-400 border border-canvas-300 dark:border-canvas-700" 306 - title={postData.imageAlts?.[index] || `Post attachment ${index + 1}`} 317 + type="button" 318 + onclick={() => 319 + openLightbox( 320 + imageUrl, 321 + postData.imageAlts?.[index] || `Post attachment ${index + 1}` 322 + )} 323 + class="h-auto w-full max-w-full overflow-hidden rounded-lg border border-canvas-300 transition-opacity hover:opacity-90 focus:ring-2 focus:ring-primary-500 focus:outline-none dark:border-canvas-700 dark:focus:ring-primary-400" 324 + title={postData.imageAlts?.[index] || `Post attachment ${index + 1}`} 307 325 > 308 - <img 309 - src={imageUrl} 310 - alt={postData.imageAlts?.[index] || `Post attachment ${index + 1}`} 311 - title={postData.imageAlts?.[index] || `Post attachment ${index + 1}`} 312 - class="h-auto w-full max-w-full object-cover {postData.imageUrls.length === 4 313 - ? 'aspect-square' 314 - : postData.imageUrls.length > 1 315 - ? 'aspect-video' 316 - : isReplyParent 317 - ? 'max-h-64' 318 - : 'max-h-96'}" 319 - loading="lazy" 320 - /> 321 - </button> 326 + <img 327 + src={imageUrl} 328 + alt={postData.imageAlts?.[index] || `Post attachment ${index + 1}`} 329 + title={postData.imageAlts?.[index] || `Post attachment ${index + 1}`} 330 + class="h-auto w-full max-w-full object-cover {postData.imageUrls.length === 4 331 + ? 'aspect-square' 332 + : postData.imageUrls.length > 1 333 + ? 'aspect-video' 334 + : isReplyParent 335 + ? 'max-h-64' 336 + : 'max-h-96'}" 337 + loading="lazy" 338 + /> 339 + </button> 322 340 {/each} 323 341 </div> 324 342 {/if} ··· 329 347 href={postData.externalLink.uri} 330 348 target="_blank" 331 349 rel="noopener noreferrer" 332 - class="{isReplyParent ? 'mb-2' : 'mb-3'} flex max-w-full flex-col overflow-hidden rounded-xl border border-canvas-300 bg-canvas-200 transition-colors hover:bg-canvas-300 dark:border-canvas-700 dark:bg-canvas-800 dark:hover:bg-canvas-700" 350 + class="{isReplyParent 351 + ? 'mb-2' 352 + : 'mb-3'} flex max-w-full flex-col overflow-hidden rounded-xl border border-canvas-300 bg-canvas-200 transition-colors hover:bg-canvas-300 dark:border-canvas-700 dark:bg-canvas-800 dark:hover:bg-canvas-700" 333 353 > 334 354 {#if postData.externalLink.thumb} 335 355 <img ··· 341 361 {/if} 342 362 <div class="p-3"> 343 363 <h3 344 - class="mb-1 overflow-wrap-anywhere break-words text-sm font-semibold text-ink-900 dark:text-ink-50 line-clamp-2" 364 + class="overflow-wrap-anywhere mb-1 line-clamp-2 text-sm font-semibold break-words text-ink-900 dark:text-ink-50" 345 365 > 346 366 {postData.externalLink.title} 347 367 </h3> 348 368 {#if postData.externalLink.description} 349 369 <p 350 - class="mb-2 overflow-wrap-anywhere break-words text-xs text-ink-700 dark:text-ink-300 line-clamp-2" 370 + class="overflow-wrap-anywhere mb-2 line-clamp-2 text-xs break-words text-ink-700 dark:text-ink-300" 351 371 > 352 372 {postData.externalLink.description} 353 373 </p> 354 374 {/if} 355 - <p class="overflow-wrap-anywhere break-words text-xs text-ink-600 dark:text-ink-400"> 375 + <p class="overflow-wrap-anywhere text-xs break-words text-ink-600 dark:text-ink-400"> 356 376 {new URL(postData.externalLink.uri).hostname} 357 377 </p> 358 378 </div> ··· 361 381 362 382 <!-- Recursively render quoted post --> 363 383 {#if postData.quotedPost && depth < 3} 364 - <div class="{isReplyParent ? 'mb-2' : 'mb-3'} rounded-xl border border-canvas-300 bg-canvas-200 p-3 dark:border-canvas-700 dark:bg-canvas-800"> 384 + <div 385 + class="{isReplyParent 386 + ? 'mb-2' 387 + : 'mb-3'} rounded-xl border border-canvas-300 bg-canvas-200 p-3 dark:border-canvas-700 dark:bg-canvas-800" 388 + > 365 389 {@render postContent(postData.quotedPost, depth + 1, depth === 0)} 366 390 </div> 367 391 {/if} 368 392 369 393 <!-- Engagement Stats (only for non-reply-parent posts) --> 370 394 {#if !isReplyParent} 371 - <div class="flex flex-wrap items-center gap-3 sm:gap-6 text-xs sm:text-sm pt-1"> 395 + <div class="flex flex-wrap items-center gap-3 pt-1 text-xs sm:gap-6 sm:text-sm"> 372 396 {#if postData.replyCount !== undefined} 373 - <div class="flex items-center gap-1 sm:gap-1.5 text-ink-600 dark:text-ink-400"> 397 + <div class="flex items-center gap-1 text-ink-600 sm:gap-1.5 dark:text-ink-400"> 374 398 <MessageCircle class="h-3.5 w-3.5 sm:h-4 sm:w-4" aria-hidden="true" /> 375 399 <span class="font-medium">{formatCompactNumber(postData.replyCount, locale)}</span> 376 400 </div> 377 401 {/if} 378 402 379 403 {#if postData.repostCount !== undefined} 380 - <div class="flex items-center gap-1 sm:gap-1.5 text-ink-600 dark:text-ink-400"> 404 + <div class="flex items-center gap-1 text-ink-600 sm:gap-1.5 dark:text-ink-400"> 381 405 <Repeat2 class="h-3.5 w-3.5 sm:h-4 sm:w-4" aria-hidden="true" /> 382 406 <span class="font-medium">{formatCompactNumber(postData.repostCount, locale)}</span> 383 407 </div> 384 408 {/if} 385 409 386 410 {#if postData.likeCount !== undefined} 387 - <div class="flex items-center gap-1 sm:gap-1.5 text-ink-600 dark:text-ink-400"> 411 + <div class="flex items-center gap-1 text-ink-600 sm:gap-1.5 dark:text-ink-400"> 388 412 <Heart class="h-3.5 w-3.5 sm:h-4 sm:w-4" aria-hidden="true" /> 389 413 <span class="font-medium">{formatCompactNumber(postData.likeCount, locale)}</span> 390 414 </div> ··· 440 464 {:else if error} 441 465 <Card error={true} errorMessage={error} /> 442 466 {:else if post} 443 - <article class="rounded-xl bg-canvas-100 p-4 sm:p-6 shadow-lg transition-all duration-300 hover:shadow-xl dark:bg-canvas-900"> 467 + <article 468 + class="rounded-xl bg-canvas-100 p-4 shadow-lg transition-all duration-300 hover:shadow-xl sm:p-6 dark:bg-canvas-900" 469 + > 444 470 <!-- Header --> 445 - <div class="mb-3 sm:mb-4 flex items-start sm:items-center justify-between gap-2"> 446 - <div class="flex flex-col sm:flex-row sm:items-center gap-1 sm:gap-2 min-w-0"> 447 - <span class="text-xs font-semibold tracking-wide text-ink-700 uppercase dark:text-ink-300 whitespace-nowrap"> 471 + <div class="mb-3 flex items-start justify-between gap-2 sm:mb-4 sm:items-center"> 472 + <div class="flex min-w-0 flex-col gap-1 sm:flex-row sm:items-center sm:gap-2"> 473 + <span 474 + class="text-xs font-semibold tracking-wide whitespace-nowrap text-ink-700 uppercase dark:text-ink-300" 475 + > 448 476 Latest Bluesky Post 449 477 </span> 450 478 {#if post.isRepost && post.repostAuthor} 451 - <span class="hidden sm:inline text-xs text-ink-600 dark:text-ink-400">·</span> 479 + <span class="hidden text-xs text-ink-600 sm:inline dark:text-ink-400">·</span> 452 480 <div class="flex items-center gap-1.5 text-xs text-ink-600 dark:text-ink-400"> 453 481 <Repeat2 class="h-3 w-3 shrink-0" aria-hidden="true" /> 454 482 <a 455 483 href={getProfileUrl(post.repostAuthor.handle)} 456 484 target="_blank" 457 485 rel="noopener noreferrer" 458 - class="font-medium text-primary-600 transition-colors hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300 truncate" 486 + class="truncate font-medium text-primary-600 transition-colors hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300" 459 487 > 460 488 {post.repostAuthor.displayName || post.repostAuthor.handle} 461 489 </a> 462 490 <span class="whitespace-nowrap">reposted</span> 463 491 </div> 464 492 {:else if post.replyParent} 465 - <span class="hidden sm:inline text-xs text-ink-600 dark:text-ink-400">·</span> 493 + <span class="hidden text-xs text-ink-600 sm:inline dark:text-ink-400">·</span> 466 494 <div class="flex items-center gap-1.5 text-xs text-ink-600 dark:text-ink-400"> 467 495 <MessageCircle class="h-3 w-3 shrink-0" aria-hidden="true" /> 468 496 <span class="whitespace-nowrap">Replying to</span> ··· 470 498 href={getProfileUrl(post.replyParent.author.handle)} 471 499 target="_blank" 472 500 rel="noopener noreferrer" 473 - class="font-medium text-primary-600 transition-colors hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300 truncate" 501 + class="truncate font-medium text-primary-600 transition-colors hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300" 474 502 > 475 503 @{post.replyParent.author.handle} 476 504 </a> ··· 481 509 href={getPostUrl(post.uri)} 482 510 target="_blank" 483 511 rel="noopener noreferrer" 484 - class="text-ink-600 transition-colors hover:text-primary-600 dark:text-ink-400 dark:hover:text-primary-400 shrink-0" 512 + class="shrink-0 text-ink-600 transition-colors hover:text-primary-600 dark:text-ink-400 dark:hover:text-primary-400" 485 513 aria-label="View post on Bluesky" 486 514 > 487 515 <ExternalLink class="h-4 w-4" aria-hidden="true" /> ··· 490 518 491 519 <!-- Reply Context --> 492 520 {#if post.replyParent} 493 - <div class="mb-3 sm:mb-4 rounded-xl border border-canvas-300 bg-canvas-200 p-2.5 sm:p-3 dark:border-canvas-700 dark:bg-canvas-800"> 521 + <div 522 + class="mb-3 rounded-xl border border-canvas-300 bg-canvas-200 p-2.5 sm:mb-4 sm:p-3 dark:border-canvas-700 dark:bg-canvas-800" 523 + > 494 524 {@render postContent(post.replyParent, 0, true)} 495 525 </div> 496 526 {/if} ··· 522 552 <button 523 553 type="button" 524 554 onclick={closeLightbox} 525 - class="absolute top-2 right-2 sm:top-4 sm:right-4 rounded-full bg-black/50 p-2 text-white transition-colors hover:bg-black/70 focus:ring-2 focus:ring-white focus:outline-none z-10" 555 + class="absolute top-2 right-2 z-10 rounded-full bg-black/50 p-2 text-white transition-colors hover:bg-black/70 focus:ring-2 focus:ring-white focus:outline-none sm:top-4 sm:right-4" 526 556 aria-label="Close" 527 557 > 528 558 <X class="h-5 w-5 sm:h-6 sm:w-6" /> 529 559 </button> 530 - <div class="relative flex max-h-[90vh] w-full max-w-[95vw] sm:max-w-[90vw] flex-col items-center"> 560 + <div 561 + class="relative flex max-h-[90vh] w-full max-w-[95vw] flex-col items-center sm:max-w-[90vw]" 562 + > 531 563 <img 532 564 src={lightboxImage.url} 533 565 alt={lightboxImage.alt} 534 566 title={lightboxImage.alt} 535 - class="max-h-[75vh] sm:max-h-[80vh] w-full object-contain" 567 + class="max-h-[75vh] w-full object-contain sm:max-h-[80vh]" 536 568 loading="lazy" 537 569 /> 538 570 {#if lightboxImage.alt && lightboxImage.alt !== `Post attachment ${lightboxImage.url.split('/').pop()}`} 539 - <div class="mt-2 sm:mt-4 w-full max-w-full overflow-y-auto rounded-lg bg-black/70 px-3 py-2 sm:px-4 text-center text-xs sm:text-sm text-white" style="max-height: calc(15vh - 1rem); sm:max-height: calc(10vh - 2rem);"> 571 + <div 572 + class="mt-2 w-full max-w-full overflow-y-auto rounded-lg bg-black/70 px-3 py-2 text-center text-xs text-white sm:mt-4 sm:px-4 sm:text-sm" 573 + style="max-height: calc(15vh - 1rem); sm:max-height: calc(10vh - 2rem);" 574 + > 540 575 {lightboxImage.alt} 541 576 </div> 542 577 {/if}
+85 -77
src/lib/components/layout/main/card/LinkCard.svelte
··· 1 1 <script lang="ts"> 2 - import { ExternalLink } from '@lucide/svelte'; 2 + import { ExternalLink } from '@lucide/svelte'; 3 3 4 - interface Badge { 5 - text: string; 6 - color?: 'mint' | 'sage'; 7 - } 4 + interface Badge { 5 + text: string; 6 + color?: 'mint' | 'sage'; 7 + } 8 8 9 - interface Props { 10 - url: string; 11 - title: string; 12 - emoji?: string; 13 - description?: string; 14 - badges?: Badge[]; 15 - meta?: string; // e.g., timestamp or extra info 16 - variant?: 'default' | 'button'; 17 - } 9 + interface Props { 10 + url: string; 11 + title: string; 12 + emoji?: string; 13 + description?: string; 14 + badges?: Badge[]; 15 + meta?: string; // e.g., timestamp or extra info 16 + variant?: 'default' | 'button'; 17 + } 18 18 19 - let { url, title, emoji, description, badges, meta, variant = 'default' }: Props = $props(); 19 + let { url, title, emoji, description, badges, meta, variant = 'default' }: Props = $props(); 20 20 21 - function getDomain(url: string): string { 22 - try { 23 - const urlObj = new URL(url); 24 - return urlObj.hostname.replace('www.', ''); 25 - } catch { 26 - return ''; 27 - } 28 - } 21 + function getDomain(url: string): string { 22 + try { 23 + const urlObj = new URL(url); 24 + return urlObj.hostname.replace('www.', ''); 25 + } catch { 26 + return ''; 27 + } 28 + } 29 29 30 - const displayDescription = description || getDomain(url); 30 + const displayDescription = description || getDomain(url); 31 31 </script> 32 32 33 33 <a 34 - href={url} 35 - target="_blank" 36 - rel="noopener noreferrer" 37 - class=" 34 + href={url} 35 + target="_blank" 36 + rel="noopener noreferrer" 37 + class=" 38 38 {variant === 'button' 39 - ? 'inline-flex items-center justify-center gap-2 rounded-lg bg-canvas-200 px-4 py-3 font-medium text-ink-900 transition-colors duration-200 hover:bg-canvas-300 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary-600 dark:bg-canvas-800 dark:text-ink-50 dark:hover:bg-canvas-700' 40 - : 'flex items-start justify-between gap-3 rounded-lg bg-canvas-200 p-4 transition-colors hover:bg-canvas-300 dark:bg-canvas-800 dark:hover:bg-canvas-700'} 39 + ? 'inline-flex items-center justify-center gap-2 rounded-lg bg-canvas-200 px-4 py-3 font-medium text-ink-900 transition-colors duration-200 hover:bg-canvas-300 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary-600 dark:bg-canvas-800 dark:text-ink-50 dark:hover:bg-canvas-700' 40 + : 'flex items-start justify-between gap-3 rounded-lg bg-canvas-200 p-4 transition-colors hover:bg-canvas-300 dark:bg-canvas-800 dark:hover:bg-canvas-700'} 41 41 " 42 42 > 43 - {#if variant === 'button'} 44 - <span class="font-medium">{title}</span> 45 - <ExternalLink class="h-4 w-4 flex-shrink-0" aria-hidden="true" /> 46 - {:else} 47 - <div class="flex-1 space-y-1"> 48 - {#if emoji || (badges && badges.length > 0)} 49 - <div class="flex items-center gap-2 flex-wrap"> 50 - {#if emoji} 51 - <span class="text-lg leading-none">{emoji}</span> 52 - {/if} 53 - {#if badges && badges.length > 0} 54 - {#each badges as badge} 55 - {#if badge.color === 'mint'} 56 - <span class="rounded bg-secondary-100 px-2 py-0.5 text-xs font-medium text-secondary-800 dark:bg-secondary-900 dark:text-secondary-200"> 57 - {badge.text} 58 - </span> 59 - {:else if badge.color === 'sage'} 60 - <span class="rounded bg-primary-100 px-2 py-0.5 text-xs font-medium text-primary-800 dark:bg-primary-900 dark:text-primary-200"> 61 - {badge.text} 62 - </span> 63 - {:else} 64 - <span class="text-xs font-semibold uppercase text-ink-800 dark:text-ink-100"> 65 - {badge.text} 66 - </span> 67 - {/if} 68 - {/each} 69 - {/if} 70 - </div> 71 - {/if} 43 + {#if variant === 'button'} 44 + <span class="font-medium">{title}</span> 45 + <ExternalLink class="h-4 w-4 flex-shrink-0" aria-hidden="true" /> 46 + {:else} 47 + <div class="flex-1 space-y-1"> 48 + {#if emoji || (badges && badges.length > 0)} 49 + <div class="flex flex-wrap items-center gap-2"> 50 + {#if emoji} 51 + <span class="text-lg leading-none">{emoji}</span> 52 + {/if} 53 + {#if badges && badges.length > 0} 54 + {#each badges as badge} 55 + {#if badge.color === 'mint'} 56 + <span 57 + class="rounded bg-secondary-100 px-2 py-0.5 text-xs font-medium text-secondary-800 dark:bg-secondary-900 dark:text-secondary-200" 58 + > 59 + {badge.text} 60 + </span> 61 + {:else if badge.color === 'sage'} 62 + <span 63 + class="rounded bg-primary-100 px-2 py-0.5 text-xs font-medium text-primary-800 dark:bg-primary-900 dark:text-primary-200" 64 + > 65 + {badge.text} 66 + </span> 67 + {:else} 68 + <span class="text-xs font-semibold text-ink-800 uppercase dark:text-ink-100"> 69 + {badge.text} 70 + </span> 71 + {/if} 72 + {/each} 73 + {/if} 74 + </div> 75 + {/if} 72 76 73 - <!-- 👇 Title is always below the badges --> 74 - <h3 class="overflow-wrap-anywhere break-words font-semibold text-ink-900 dark:text-ink-50">{title}</h3> 77 + <!-- 👇 Title is always below the badges --> 78 + <h3 class="overflow-wrap-anywhere font-semibold break-words text-ink-900 dark:text-ink-50"> 79 + {title} 80 + </h3> 75 81 76 - {#if displayDescription} 77 - <p class="overflow-wrap-anywhere break-words text-sm text-ink-700 line-clamp-2 dark:text-ink-200"> 78 - {displayDescription} 79 - </p> 80 - {/if} 82 + {#if displayDescription} 83 + <p 84 + class="overflow-wrap-anywhere line-clamp-2 text-sm break-words text-ink-700 dark:text-ink-200" 85 + > 86 + {displayDescription} 87 + </p> 88 + {/if} 81 89 82 - {#if meta} 83 - <p class="text-xs font-medium text-ink-800 dark:text-ink-100"> 84 - {meta} 85 - </p> 86 - {/if} 87 - </div> 90 + {#if meta} 91 + <p class="text-xs font-medium text-ink-800 dark:text-ink-100"> 92 + {meta} 93 + </p> 94 + {/if} 95 + </div> 88 96 89 - <ExternalLink 90 - class="h-4 w-4 flex-shrink-0 text-ink-700 transition-colors group-hover:text-primary-600 dark:text-ink-200 dark:group-hover:text-primary-400" 91 - aria-hidden="true" 92 - /> 93 - {/if} 97 + <ExternalLink 98 + class="h-4 w-4 flex-shrink-0 text-ink-700 transition-colors group-hover:text-primary-600 dark:text-ink-200 dark:group-hover:text-primary-400" 99 + aria-hidden="true" 100 + /> 101 + {/if} 94 102 </a>
+24 -24
src/lib/components/layout/main/card/MusicStatusCard.svelte
··· 5 5 import { formatRelativeTime } from '$lib/utils/formatDate'; 6 6 7 7 // Icons 8 - import { 9 - Music, 10 - Disc3, 11 - Users, 12 - Album, 13 - Clock, 14 - Radio 15 - } from '@lucide/svelte'; 8 + import { Music, Disc3, Users, Album, Clock, Radio } from '@lucide/svelte'; 16 9 17 10 let musicStatus: MusicStatusData | null = null; 18 11 let loading = true; ··· 37 30 38 31 function formatArtists(artists: { artistName: string }[]): string { 39 32 if (!artists || artists.length === 0) return 'Unknown Artist'; 40 - return artists.map(a => a.artistName).join(', '); 33 + return artists.map((a) => a.artistName).join(', '); 41 34 } 42 35 43 36 function formatDuration(seconds?: number): string { ··· 76 69 </div> 77 70 {/snippet} 78 71 </Card> 79 - 80 72 {:else if error} 81 73 <Card error={true} errorMessage={error} /> 82 - 83 74 {:else if musicStatus} 84 75 {@const safeMusicStatus = musicStatus} 85 76 <Card variant="elevated" padding="md"> 86 77 {#snippet children()} 87 78 <div class="flex items-start gap-4"> 88 - 89 79 <!-- Artwork --> 90 80 <div class="flex-shrink-0"> 91 81 {#if safeMusicStatus.artworkUrl && !artworkError} ··· 97 87 onerror={handleImageError} 98 88 /> 99 89 {:else} 100 - <div class="h-20 w-20 rounded-lg bg-canvas-200 dark:bg-canvas-700 flex items-center justify-center shadow-md"> 90 + <div 91 + class="flex h-20 w-20 items-center justify-center rounded-lg bg-canvas-200 shadow-md dark:bg-canvas-700" 92 + > 101 93 <Disc3 class="h-10 w-10 text-ink-500 dark:text-ink-400" aria-hidden="true" /> 102 94 </div> 103 95 {/if} 104 96 </div> 105 97 106 98 <!-- Info --> 107 - <div class="flex-1 min-w-0"> 99 + <div class="min-w-0 flex-1"> 108 100 <!-- Header (Now Listening / Last Played) --> 109 101 <div class="mb-2 flex items-center gap-2"> 110 102 <Music class="h-4 w-4 text-primary-600 dark:text-primary-400" aria-hidden="true" /> 111 - <span class="text-xs font-semibold tracking-wide text-ink-800 uppercase dark:text-ink-100"> 103 + <span 104 + class="text-xs font-semibold tracking-wide text-ink-800 uppercase dark:text-ink-100" 105 + > 112 106 {safeMusicStatus.$type === 'fm.teal.alpha.actor.status' 113 107 ? 'Now Listening' 114 108 : 'Last Played'} ··· 117 111 118 112 <!-- Content --> 119 113 <div class="mb-2"> 120 - 121 114 <!-- Track Name --> 122 115 <a 123 116 href={safeMusicStatus.originUrl || '#'} 124 117 target="_blank" 125 118 rel="noopener noreferrer" 126 - class="block text-lg font-semibold text-primary-600 hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300 transition-colors whitespace-normal break-words max-w-full" 119 + class="block max-w-full text-lg font-semibold break-words whitespace-normal text-primary-600 transition-colors hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300" 127 120 class:pointer-events-none={!safeMusicStatus.originUrl} 128 121 class:cursor-default={!safeMusicStatus.originUrl} 129 122 class:opacity-70={!safeMusicStatus.originUrl} ··· 132 125 </a> 133 126 134 127 <!-- Artists --> 135 - <p class="mt-1 flex items-start gap-1.5 text-base text-ink-800 dark:text-ink-100 whitespace-normal break-words max-w-full"> 136 - <Users class="h-4 w-4 text-ink-600 dark:text-ink-300 flex-shrink-0 mt-0.5" /> 128 + <p 129 + class="mt-1 flex max-w-full items-start gap-1.5 text-base break-words whitespace-normal text-ink-800 dark:text-ink-100" 130 + > 131 + <Users class="mt-0.5 h-4 w-4 flex-shrink-0 text-ink-600 dark:text-ink-300" /> 137 132 {formatArtists(safeMusicStatus.artists)} 138 133 </p> 139 134 140 135 <!-- Album + Duration --> 141 136 {#if safeMusicStatus.releaseName} 142 - <p class="mt-1 flex items-start gap-1.5 text-sm text-ink-700 dark:text-ink-200 whitespace-normal break-words max-w-full"> 143 - <Album class="h-4 w-4 text-ink-500 dark:text-ink-400 flex-shrink-0 mt-0.5" /> 137 + <p 138 + class="mt-1 flex max-w-full items-start gap-1.5 text-sm break-words whitespace-normal text-ink-700 dark:text-ink-200" 139 + > 140 + <Album class="mt-0.5 h-4 w-4 flex-shrink-0 text-ink-500 dark:text-ink-400" /> 144 141 <span> 145 142 {safeMusicStatus.releaseName} 146 143 147 144 {#if safeMusicStatus.duration} 148 - <span class="inline-flex items-center gap-1 ml-1 text-ink-600 dark:text-ink-300"> 149 - · <Clock class="h-3 w-3" /> {formatDuration(safeMusicStatus.duration)} 145 + <span 146 + class="ml-1 inline-flex items-center gap-1 text-ink-600 dark:text-ink-300" 147 + > 148 + · <Clock class="h-3 w-3" /> 149 + {formatDuration(safeMusicStatus.duration)} 150 150 </span> 151 151 {/if} 152 152 </span> ··· 167 167 href="https://teal.fm" 168 168 target="_blank" 169 169 rel="noopener noreferrer" 170 - class="inline-flex items-center gap-1 hover:text-primary-600 dark:hover:text-primary-400 transition-colors" 170 + class="inline-flex items-center gap-1 transition-colors hover:text-primary-600 dark:hover:text-primary-400" 171 171 title="Powered by teal.fm" 172 172 > 173 173 <Radio class="h-3 w-3" />
+10 -4
src/lib/components/layout/main/card/PostCard.svelte
··· 103 103 {/if} 104 104 105 105 <!-- Title --> 106 - <h3 class="overflow-wrap-anywhere break-words font-semibold text-ink-900 dark:text-ink-50">{post.title}</h3> 106 + <h3 107 + class="overflow-wrap-anywhere font-semibold break-words text-ink-900 dark:text-ink-50" 108 + > 109 + {post.title} 110 + </h3> 107 111 108 112 <!-- Description --> 109 113 {#if post.description} 110 - <p class="overflow-wrap-anywhere break-words line-clamp-2 text-sm text-ink-700 dark:text-ink-200"> 111 - {post.description} 112 - </p> 114 + <p 115 + class="overflow-wrap-anywhere line-clamp-2 text-sm break-words text-ink-700 dark:text-ink-200" 116 + > 117 + {post.description} 118 + </p> 113 119 {/if} 114 120 115 121 <!-- Timestamp -->
+5 -3
src/lib/components/layout/main/card/ProfileCard.svelte
··· 108 108 <p class="font-medium text-ink-700 dark:text-ink-200">@{safeProfile.handle}</p> 109 109 110 110 {#if safeProfile.description} 111 - <p class="mb-4 overflow-wrap-anywhere break-words whitespace-pre-wrap text-ink-700 dark:text-ink-200"> 112 - {safeProfile.description} 113 - </p> 111 + <p 112 + class="overflow-wrap-anywhere mb-4 break-words whitespace-pre-wrap text-ink-700 dark:text-ink-200" 113 + > 114 + {safeProfile.description} 115 + </p> 114 116 {/if} 115 117 116 118 <div class="flex gap-6 text-sm font-medium">
+5 -5
src/lib/components/layout/main/card/TangledRepoCard.svelte
··· 35 35 rel="noopener noreferrer" 36 36 class="flex items-center justify-between gap-3 rounded-lg bg-canvas-200 p-4 transition-colors hover:bg-canvas-300 dark:bg-canvas-800 dark:hover:bg-canvas-700" 37 37 > 38 - <div class="flex items-center gap-3 min-w-0 flex-1"> 38 + <div class="flex min-w-0 flex-1 items-center gap-3"> 39 39 <GitBranch 40 40 class="h-5 w-5 flex-shrink-0 text-primary-600 dark:text-primary-400" 41 41 aria-hidden="true" 42 42 /> 43 - <div class="flex flex-col gap-1 min-w-0 flex-1"> 44 - <h3 class="overflow-wrap-anywhere break-words font-semibold text-ink-900 dark:text-ink-50"> 43 + <div class="flex min-w-0 flex-1 flex-col gap-1"> 44 + <h3 class="overflow-wrap-anywhere font-semibold break-words text-ink-900 dark:text-ink-50"> 45 45 {repo.name} 46 46 </h3> 47 47 <div class="flex flex-wrap items-center gap-3 text-xs text-ink-700 dark:text-ink-200"> 48 - <div class="flex items-center gap-1 min-w-0"> 48 + <div class="flex min-w-0 items-center gap-1"> 49 49 <Server class="h-3 w-3 flex-shrink-0" aria-hidden="true" /> 50 50 <span class="truncate">{getKnotServerName(repo.knot)}</span> 51 51 </div> 52 - <div class="flex items-center gap-1 min-w-0"> 52 + <div class="flex min-w-0 items-center gap-1"> 53 53 <User class="h-3 w-3 flex-shrink-0" aria-hidden="true" /> 54 54 <span class="truncate">{handle || PUBLIC_ATPROTO_DID}</span> 55 55 </div>
+11 -11
src/lib/config/slugs.ts
··· 2 2 3 3 /** 4 4 * Normalize a slug to be URI-compatible 5 - * 5 + * 6 6 * Transformations: 7 7 * - Convert to lowercase 8 8 * - Replace spaces with hyphens 9 9 * - Remove all characters except alphanumeric, hyphens, and underscores 10 10 * - Collapse multiple hyphens into single hyphen 11 11 * - Remove leading/trailing hyphens 12 - * 12 + * 13 13 * @param slug - The slug to normalize 14 14 * @returns URI-compatible slug 15 - * 15 + * 16 16 * @example 17 17 * normalizeSlug('My Blog Post!') // 'my-blog-post' 18 18 * normalizeSlug('Hello World') // 'hello-world' ··· 31 31 /** 32 32 * Get publication rkey from slug 33 33 * Automatically normalizes the slug before lookup 34 - * 34 + * 35 35 * @param slug - The slug to look up (will be normalized) 36 36 * @returns The publication rkey or null if not found 37 37 */ 38 38 export function getPublicationRkeyFromSlug(slug: string): string | null { 39 39 const normalizedSlug = normalizeSlug(slug); 40 - const mapping = slugMappings.find(m => normalizeSlug(m.slug) === normalizedSlug); 40 + const mapping = slugMappings.find((m) => normalizeSlug(m.slug) === normalizedSlug); 41 41 return mapping?.publicationRkey || null; 42 42 } 43 43 44 44 /** 45 45 * Get slug from publication rkey 46 - * 46 + * 47 47 * @param rkey - The publication rkey 48 48 * @returns The slug or null if not found 49 49 */ 50 50 export function getSlugFromPublicationRkey(rkey: string): string | null { 51 - const mapping = slugMappings.find(m => m.publicationRkey === rkey); 51 + const mapping = slugMappings.find((m) => m.publicationRkey === rkey); 52 52 return mapping?.slug || null; 53 53 } 54 54 55 55 /** 56 56 * Get all configured slugs (normalized) 57 - * 57 + * 58 58 * @returns Array of normalized slugs 59 59 */ 60 60 export function getAllSlugs(): string[] { 61 - return slugMappings.map(m => normalizeSlug(m.slug)); 61 + return slugMappings.map((m) => normalizeSlug(m.slug)); 62 62 } 63 63 64 64 /** 65 65 * Get all slug mappings with normalized slugs 66 - * 66 + * 67 67 * @returns Array of slug mappings with normalized slugs 68 68 */ 69 69 export function getAllSlugMappings(): SlugMapping[] { 70 - return slugMappings.map(m => ({ 70 + return slugMappings.map((m) => ({ 71 71 ...m, 72 72 slug: normalizeSlug(m.slug) 73 73 }));
+4 -4
src/lib/data/navItems.ts
··· 1 1 export interface NavItem { 2 2 href: string; 3 3 label: string; 4 - // The property holds the Lucide component name (e.g., 'Home') 4 + // The property holds the Lucide component name (e.g., 'Home') 5 5 iconPath: string; 6 6 } 7 7 8 8 export const navItems: NavItem[] = [ 9 - { href: '/', label: 'Home', iconPath: 'Home' }, 10 - { href: '/site/meta', label: 'Site Meta', iconPath: 'Info' } 11 - ]; 9 + { href: '/', label: 'Home', iconPath: 'Home' }, 10 + { href: '/site/meta', label: 'Site Meta', iconPath: 'Info' } 11 + ];
+3 -3
src/lib/data/slug-mappings.ts
··· 1 1 /** 2 2 * Slug to Leaflet Publication mapping data 3 - * 3 + * 4 4 * Maps friendly URL slugs to Leaflet publication rkeys. 5 5 * This allows you to access publications via /{slug} instead of /blog 6 - * 6 + * 7 7 * Example: 8 8 * - /blog → maps to publication with rkey "3m3x4bgbsh22k" 9 9 * - /notes → maps to publication with rkey "xyz123abc" ··· 19 19 /** 20 20 * Slug to publication rkey mappings 21 21 * Add your custom mappings here 22 - * 22 + * 23 23 * Note: Slugs will be automatically normalized to be URI-compatible: 24 24 * - Converted to lowercase 25 25 * - Spaces converted to hyphens
+1 -1
src/lib/helper/index.ts
··· 1 1 export * from './siteMeta'; 2 2 export * from './ogImages'; 3 - export * from './metaTags'; 3 + export * from './metaTags';
+7 -3
src/lib/helper/metaTags.ts
··· 25 25 { property: 'og:description', content: finalMeta.description }, 26 26 { property: 'og:site_name', content: defaults.title }, // always site title for OG 27 27 { property: 'og:image', content: finalMeta.image }, 28 - ...(finalMeta.imageWidth ? [{ property: 'og:image:width', content: finalMeta.imageWidth.toString() }] : []), 29 - ...(finalMeta.imageHeight ? [{ property: 'og:image:height', content: finalMeta.imageHeight.toString() }] : []), 28 + ...(finalMeta.imageWidth 29 + ? [{ property: 'og:image:width', content: finalMeta.imageWidth.toString() }] 30 + : []), 31 + ...(finalMeta.imageHeight 32 + ? [{ property: 'og:image:height', content: finalMeta.imageHeight.toString() }] 33 + : []), 30 34 31 35 { name: 'twitter:card', content: 'summary_large_image' }, 32 36 { name: 'twitter:url', content: finalMeta.url }, ··· 34 38 { name: 'twitter:description', content: finalMeta.description }, 35 39 { name: 'twitter:image', content: finalMeta.image } 36 40 ]; 37 - } 41 + }
+4 -4
src/lib/helper/ogImages.ts
··· 1 1 /** 2 2 * OG (Open Graph) image paths 3 - * 3 + * 4 4 * These images are served from the ./static/og/ directory. 5 5 * They are accessible at /og/ URLs in the application. 6 - * 6 + * 7 7 * To add new OG images: 8 8 * 1. Place the image in ./static/og/ 9 9 * 2. Add the path here as /og/filename.png 10 10 */ 11 11 export const ogImages: Record<string, string> = { 12 - main: '/og/main.png', 13 - siteMeta: '/og/site-meta.png' 12 + main: '/og/main.png', 13 + siteMeta: '/og/site-meta.png' 14 14 };
+22 -22
src/lib/helper/siteMeta.ts
··· 1 1 import { ogImages } from '$lib/helper/ogImages'; 2 2 import { 3 - PUBLIC_SITE_TITLE, 4 - PUBLIC_SITE_DESCRIPTION, 5 - PUBLIC_SITE_KEYWORDS, 6 - PUBLIC_SITE_URL 3 + PUBLIC_SITE_TITLE, 4 + PUBLIC_SITE_DESCRIPTION, 5 + PUBLIC_SITE_KEYWORDS, 6 + PUBLIC_SITE_URL 7 7 } from '$env/static/public'; 8 8 9 9 export interface SiteMetadata { 10 - title: string; 11 - description: string; 12 - keywords: string; 13 - url: string; 14 - image: string; 15 - imageWidth?: number; 16 - imageHeight?: number; 10 + title: string; 11 + description: string; 12 + keywords: string; 13 + url: string; 14 + image: string; 15 + imageWidth?: number; 16 + imageHeight?: number; 17 17 } 18 18 19 19 /** ··· 21 21 * Can be overridden dynamically for each page or component. 22 22 */ 23 23 export const defaultSiteMeta: SiteMetadata = { 24 - title: PUBLIC_SITE_TITLE, 25 - description: PUBLIC_SITE_DESCRIPTION, 26 - keywords: PUBLIC_SITE_KEYWORDS, 27 - url: PUBLIC_SITE_URL, 28 - image: ogImages.main, 29 - imageWidth: 1200, 30 - imageHeight: 630 24 + title: PUBLIC_SITE_TITLE, 25 + description: PUBLIC_SITE_DESCRIPTION, 26 + keywords: PUBLIC_SITE_KEYWORDS, 27 + url: PUBLIC_SITE_URL, 28 + image: ogImages.main, 29 + imageWidth: 1200, 30 + imageHeight: 630 31 31 }; 32 32 33 33 /** ··· 35 35 * Merges defaults with any overrides provided. 36 36 */ 37 37 export function createSiteMeta(overrides: Partial<SiteMetadata> = {}): SiteMetadata { 38 - return { 39 - ...defaultSiteMeta, 40 - ...overrides 41 - }; 38 + return { 39 + ...defaultSiteMeta, 40 + ...overrides 41 + }; 42 42 }
+34 -31
src/lib/services/atproto/agents.ts
··· 5 5 * Creates an AtpAgent with optional fetch function injection 6 6 */ 7 7 export function createAgent(service: string, fetchFn?: typeof fetch): AtpAgent { 8 - // If we have an injected fetch, wrap it to ensure we handle headers correctly 9 - const wrappedFetch = fetchFn ? async (url: URL | RequestInfo, init?: RequestInit) => { 10 - // Convert URL to string if needed 11 - const urlStr = url instanceof URL ? url.toString() : url; 12 - 13 - // Make the request with the injected fetch 14 - const response = await fetchFn(urlStr, init); 8 + // If we have an injected fetch, wrap it to ensure we handle headers correctly 9 + const wrappedFetch = fetchFn 10 + ? async (url: URL | RequestInfo, init?: RequestInit) => { 11 + // Convert URL to string if needed 12 + const urlStr = url instanceof URL ? url.toString() : url; 13 + 14 + // Make the request with the injected fetch 15 + const response = await fetchFn(urlStr, init); 16 + 17 + // Create a new response with the same body but add content-type if missing 18 + const headers = new Headers(response.headers); 19 + if (!headers.has('content-type')) { 20 + headers.set('content-type', 'application/json'); 21 + } 15 22 16 - // Create a new response with the same body but add content-type if missing 17 - const headers = new Headers(response.headers); 18 - if (!headers.has('content-type')) { 19 - headers.set('content-type', 'application/json'); 20 - } 21 - 22 - return new Response(response.body, { 23 - status: response.status, 24 - statusText: response.statusText, 25 - headers 26 - }); 27 - } : undefined; 23 + return new Response(response.body, { 24 + status: response.status, 25 + statusText: response.statusText, 26 + headers 27 + }); 28 + } 29 + : undefined; 28 30 29 - return new AtpAgent({ 30 - service, 31 - ...(wrappedFetch && { fetch: wrappedFetch }) 32 - }); 31 + return new AtpAgent({ 32 + service, 33 + ...(wrappedFetch && { fetch: wrappedFetch }) 34 + }); 33 35 } 34 36 35 37 // Primary Microcosm Constellation endpoint ··· 62 64 63 65 if (!response.ok) { 64 66 console.error(`[Identity] Resolution failed: ${response.status} ${response.statusText}`); 65 - throw new Error(`Failed to resolve identifier via Slingshot: ${response.status} ${response.statusText}`); 67 + throw new Error( 68 + `Failed to resolve identifier via Slingshot: ${response.status} ${response.statusText}` 69 + ); 66 70 } 67 71 68 72 // Some fetch implementations in Node (undici wrappers) can throw when calling Response.clone(). ··· 108 112 console.warn('[Agent] Constellation endpoint unreachable:', constellationErr); 109 113 } 110 114 111 - // Then try Slingshot for PDS resolution 112 - console.info('[Agent] Attempting Slingshot resolution'); 113 - const resolved = await resolveIdentity(did, fetchFn); 115 + // Then try Slingshot for PDS resolution 116 + console.info('[Agent] Attempting Slingshot resolution'); 117 + const resolved = await resolveIdentity(did, fetchFn); 114 118 console.info(`[Agent] Resolved PDS endpoint: ${resolved.pds}`); 115 119 resolvedAgent = createAgent(resolved.pds, fetchFn); 116 120 return resolvedAgent; ··· 119 123 resolvedAgent = defaultAgent; 120 124 return resolvedAgent; 121 125 } 122 - }/** 126 + } /** 123 127 * Gets or creates a PDS-specific agent 124 128 */ 125 129 export async function getPDSAgent(did: string, fetchFn?: typeof fetch): Promise<AtpAgent> { ··· 147 151 usePDSFirst = false, 148 152 fetchFn?: typeof fetch 149 153 ): Promise<T> { 150 - const defaultAgentFn = () => fetchFn 151 - ? createAgent('https://public.api.bsky.app', fetchFn) 152 - : Promise.resolve(defaultAgent); 154 + const defaultAgentFn = () => 155 + fetchFn ? createAgent('https://public.api.bsky.app', fetchFn) : Promise.resolve(defaultAgent); 153 156 154 157 const agents = usePDSFirst 155 158 ? [() => getPDSAgent(did, fetchFn), defaultAgentFn]
+58 -61
src/lib/services/atproto/engagement.ts
··· 3 3 export type EngagementType = 'app.bsky.feed.like' | 'app.bsky.feed.repost'; 4 4 5 5 interface EngagementResponse { 6 - dids: string[]; 7 - cursor?: string; 6 + dids: string[]; 7 + cursor?: string; 8 8 } 9 9 10 10 /** 11 11 * Fetches engagement data (likes/reposts) for a post from Constellation as a fallback 12 12 */ 13 13 export async function fetchEngagementFromConstellation( 14 - uri: string, 15 - type: EngagementType, 16 - cursor?: string 14 + uri: string, 15 + type: EngagementType, 16 + cursor?: string 17 17 ): Promise<EngagementResponse> { 18 - console.info(`[Constellation] Fetching ${type} data for ${uri}`); 19 - 20 - const cacheKey = `engagement:${type}:${uri}:${cursor || 'initial'}`; 21 - const cached = cache.get<EngagementResponse>(cacheKey); 22 - if (cached) { 23 - console.debug('[Constellation] Returning cached engagement data'); 24 - return cached; 25 - } 18 + console.info(`[Constellation] Fetching ${type} data for ${uri}`); 26 19 27 - try { 28 - const url = new URL('https://constellation.microcosm.blue/links/distinct-dids'); 29 - url.searchParams.append('target', uri); 30 - url.searchParams.append('collection', type); 31 - url.searchParams.append('path', ''); 32 - url.searchParams.append('limit', '100'); 33 - if (cursor) { 34 - url.searchParams.append('cursor', cursor); 35 - } 20 + const cacheKey = `engagement:${type}:${uri}:${cursor || 'initial'}`; 21 + const cached = cache.get<EngagementResponse>(cacheKey); 22 + if (cached) { 23 + console.debug('[Constellation] Returning cached engagement data'); 24 + return cached; 25 + } 36 26 37 - console.debug(`[Constellation] Requesting: ${url.toString()}`); 38 - const response = await fetch(url); 39 - 40 - if (!response.ok) { 41 - throw new Error(`Constellation HTTP error! Status: ${response.status}`); 42 - } 27 + try { 28 + const url = new URL('https://constellation.microcosm.blue/links/distinct-dids'); 29 + url.searchParams.append('target', uri); 30 + url.searchParams.append('collection', type); 31 + url.searchParams.append('path', ''); 32 + url.searchParams.append('limit', '100'); 33 + if (cursor) { 34 + url.searchParams.append('cursor', cursor); 35 + } 43 36 44 - const data = await response.json(); 45 - console.debug('[Constellation] Response received:', data); 37 + console.debug(`[Constellation] Requesting: ${url.toString()}`); 38 + const response = await fetch(url); 39 + 40 + if (!response.ok) { 41 + throw new Error(`Constellation HTTP error! Status: ${response.status}`); 42 + } 43 + 44 + const data = await response.json(); 45 + console.debug('[Constellation] Response received:', data); 46 46 47 - const result: EngagementResponse = { 48 - dids: data.dids || [], 49 - cursor: data.cursor 50 - }; 47 + const result: EngagementResponse = { 48 + dids: data.dids || [], 49 + cursor: data.cursor 50 + }; 51 51 52 - // Cache the results 53 - cache.set(cacheKey, result); 54 - return result; 55 - } catch (error) { 56 - console.error('[Constellation] Failed to fetch engagement data:', error); 57 - throw error; 58 - } 52 + // Cache the results 53 + cache.set(cacheKey, result); 54 + return result; 55 + } catch (error) { 56 + console.error('[Constellation] Failed to fetch engagement data:', error); 57 + throw error; 58 + } 59 59 } 60 60 61 61 /** 62 62 * Fetches all engagement data by paginating through results 63 63 */ 64 - export async function fetchAllEngagement( 65 - uri: string, 66 - type: EngagementType 67 - ): Promise<string[]> { 68 - console.info(`[Constellation] Fetching all ${type} data for ${uri}`); 69 - 70 - const allDids: Set<string> = new Set(); 71 - let cursor: string | undefined = undefined; 64 + export async function fetchAllEngagement(uri: string, type: EngagementType): Promise<string[]> { 65 + console.info(`[Constellation] Fetching all ${type} data for ${uri}`); 66 + 67 + const allDids: Set<string> = new Set(); 68 + let cursor: string | undefined = undefined; 72 69 73 - try { 74 - do { 75 - const response = await fetchEngagementFromConstellation(uri, type, cursor); 76 - response.dids.forEach(did => allDids.add(did)); 77 - cursor = response.cursor; 78 - } while (cursor); 70 + try { 71 + do { 72 + const response = await fetchEngagementFromConstellation(uri, type, cursor); 73 + response.dids.forEach((did) => allDids.add(did)); 74 + cursor = response.cursor; 75 + } while (cursor); 79 76 80 - return Array.from(allDids); 81 - } catch (error) { 82 - console.error('[Constellation] Failed to fetch all engagement:', error); 83 - return Array.from(allDids); // Return what we have so far 84 - } 85 - } 77 + return Array.from(allDids); 78 + } catch (error) { 79 + console.error('[Constellation] Failed to fetch all engagement:', error); 80 + return Array.from(allDids); // Return what we have so far 81 + } 82 + }
+67 -48
src/lib/services/atproto/fetch.ts
··· 154 154 155 155 try { 156 156 console.info('[MusicStatus] Cache miss, fetching from network'); 157 - 157 + 158 158 // Try the actor status collection first (shorter-lived status) 159 159 try { 160 160 const statusRecords = await withFallback( ··· 174 174 if (statusRecords && statusRecords.length > 0) { 175 175 const record = statusRecords[0]; 176 176 const value = record.value as any; 177 - 177 + 178 178 // Check if status is still valid (not expired) 179 179 if (value.expiry) { 180 - const expiryTime = parseInt(value.expiry) * 1000; 181 - if (Date.now() > expiryTime) { 182 - console.debug('[MusicStatus] Actor status expired, falling back to feed play'); 183 - } else { 184 - // Build artwork URL - prioritize album art over individual track art 185 - let artworkUrl: string | undefined; 186 - const trackName = value.item?.trackName || value.trackName; 187 - const artists = value.item?.artists || value.artists || []; 188 - const releaseName = value.item?.releaseName || value.releaseName; 189 - const artistName = artists[0]?.artistName; 190 - const releaseMbId = value.item?.releaseMbId || value.releaseMbId; 191 - 192 - console.debug('[MusicStatus] Looking for artwork:', { trackName, artistName, releaseName, releaseMbId }); 193 - 194 - // Priority 1: If we have album info, search for album art (more accurate) 195 - if (releaseName && artistName) { 196 - console.info('[MusicStatus] Prioritizing album artwork search'); 197 - artworkUrl = await findArtwork(releaseName, artistName, releaseName, releaseMbId) || undefined; 198 - } 199 - 200 - // Priority 2: Fall back to track-based search if album search failed 201 - if (!artworkUrl && trackName && artistName) { 202 - console.info('[MusicStatus] Falling back to track-based artwork search'); 203 - artworkUrl = await findArtwork(trackName, artistName, releaseName, releaseMbId) || undefined; 204 - } 205 - 206 - // Priority 3: Final fallback to atproto blob if no external artwork found 207 - if (!artworkUrl) { 208 - const artwork = value.item?.artwork || value.artwork; 209 - console.debug('[MusicStatus] No external artwork found, checking atproto blob:', artwork); 210 - if (artwork?.ref?.$link) { 211 - const identity = await resolveIdentity(PUBLIC_ATPROTO_DID, fetchFn); 212 - artworkUrl = buildPdsBlobUrl(identity.pds, PUBLIC_ATPROTO_DID, artwork.ref.$link); 213 - console.info('[MusicStatus] Using atproto blob artwork URL:', artworkUrl); 214 - } 180 + const expiryTime = parseInt(value.expiry) * 1000; 181 + if (Date.now() > expiryTime) { 182 + console.debug('[MusicStatus] Actor status expired, falling back to feed play'); 183 + } else { 184 + // Build artwork URL - prioritize album art over individual track art 185 + let artworkUrl: string | undefined; 186 + const trackName = value.item?.trackName || value.trackName; 187 + const artists = value.item?.artists || value.artists || []; 188 + const releaseName = value.item?.releaseName || value.releaseName; 189 + const artistName = artists[0]?.artistName; 190 + const releaseMbId = value.item?.releaseMbId || value.releaseMbId; 191 + 192 + console.debug('[MusicStatus] Looking for artwork:', { 193 + trackName, 194 + artistName, 195 + releaseName, 196 + releaseMbId 197 + }); 198 + 199 + // Priority 1: If we have album info, search for album art (more accurate) 200 + if (releaseName && artistName) { 201 + console.info('[MusicStatus] Prioritizing album artwork search'); 202 + artworkUrl = 203 + (await findArtwork(releaseName, artistName, releaseName, releaseMbId)) || undefined; 204 + } 205 + 206 + // Priority 2: Fall back to track-based search if album search failed 207 + if (!artworkUrl && trackName && artistName) { 208 + console.info('[MusicStatus] Falling back to track-based artwork search'); 209 + artworkUrl = 210 + (await findArtwork(trackName, artistName, releaseName, releaseMbId)) || undefined; 211 + } 212 + 213 + // Priority 3: Final fallback to atproto blob if no external artwork found 214 + if (!artworkUrl) { 215 + const artwork = value.item?.artwork || value.artwork; 216 + console.debug( 217 + '[MusicStatus] No external artwork found, checking atproto blob:', 218 + artwork 219 + ); 220 + if (artwork?.ref?.$link) { 221 + const identity = await resolveIdentity(PUBLIC_ATPROTO_DID, fetchFn); 222 + artworkUrl = buildPdsBlobUrl(identity.pds, PUBLIC_ATPROTO_DID, artwork.ref.$link); 223 + console.info('[MusicStatus] Using atproto blob artwork URL:', artworkUrl); 215 224 } 225 + } 216 226 217 227 const data: MusicStatusData = { 218 228 trackName: value.item?.trackName || value.trackName, ··· 224 234 releaseMbId: value.item?.releaseMbId || value.releaseMbId, 225 235 isrc: value.isrc, 226 236 duration: value.duration, 227 - musicServiceBaseDomain: value.item?.musicServiceBaseDomain || value.musicServiceBaseDomain, 228 - submissionClientAgent: value.item?.submissionClientAgent || value.submissionClientAgent, 237 + musicServiceBaseDomain: 238 + value.item?.musicServiceBaseDomain || value.musicServiceBaseDomain, 239 + submissionClientAgent: 240 + value.item?.submissionClientAgent || value.submissionClientAgent, 229 241 $type: 'fm.teal.alpha.actor.status', 230 242 expiry: value.expiry, 231 243 artwork: value.item?.artwork || value.artwork, ··· 259 271 if (playRecords && playRecords.length > 0) { 260 272 const record = playRecords[0]; 261 273 const value = record.value as any; 262 - 274 + 263 275 // Build artwork URL - prioritize album art over individual track art 264 276 let artworkUrl: string | undefined; 265 277 const trackName = value.trackName; ··· 267 279 const releaseName = value.releaseName; 268 280 const artistName = artists[0]?.artistName; 269 281 const releaseMbId = value.releaseMbId; 270 - 271 - console.debug('[MusicStatus] Looking for artwork:', { trackName, artistName, releaseName, releaseMbId }); 272 - 282 + 283 + console.debug('[MusicStatus] Looking for artwork:', { 284 + trackName, 285 + artistName, 286 + releaseName, 287 + releaseMbId 288 + }); 289 + 273 290 // Priority 1: If we have album info, search for album art (more accurate) 274 291 if (releaseName && artistName) { 275 292 console.info('[MusicStatus] Prioritizing album artwork search'); 276 - artworkUrl = await findArtwork(releaseName, artistName, releaseName, releaseMbId) || undefined; 293 + artworkUrl = 294 + (await findArtwork(releaseName, artistName, releaseName, releaseMbId)) || undefined; 277 295 } 278 - 296 + 279 297 // Priority 2: Fall back to track-based search if album search failed 280 298 if (!artworkUrl && trackName && artistName) { 281 299 console.info('[MusicStatus] Falling back to track-based artwork search'); 282 - artworkUrl = await findArtwork(trackName, artistName, releaseName, releaseMbId) || undefined; 300 + artworkUrl = 301 + (await findArtwork(trackName, artistName, releaseName, releaseMbId)) || undefined; 283 302 } 284 - 303 + 285 304 // Priority 3: Final fallback to atproto blob if no external artwork found 286 305 if (!artworkUrl) { 287 306 const artwork = value.artwork; ··· 292 311 console.info('[MusicStatus] Using atproto blob artwork URL:', artworkUrl); 293 312 } 294 313 } 295 - 314 + 296 315 const data: MusicStatusData = { 297 316 trackName: value.trackName, 298 317 artists: value.artists || [],
+5 -5
src/lib/services/atproto/index.ts
··· 1 1 /** 2 2 * Unified AT Protocol service exports 3 - * 3 + * 4 4 * This module provides a clean API for interacting with AT Protocol services, 5 5 * including profile data, blog posts, Bluesky posts, and custom lexicons. 6 6 */ ··· 51 51 52 52 export { resolveIdentity, withFallback, resetAgents } from './agents'; 53 53 54 - export { 55 - searchMusicBrainzRelease, 56 - buildCoverArtUrl, 54 + export { 55 + searchMusicBrainzRelease, 56 + buildCoverArtUrl, 57 57 searchiTunesArtwork, 58 58 searchDeezerArtwork, 59 59 searchLastFmArtwork, 60 - findArtwork 60 + findArtwork 61 61 } from './musicbrainz'; 62 62 63 63 // Export cache for advanced use cases
+15 -8
src/lib/services/atproto/media.ts
··· 28 28 * and nested structures. 29 29 * - also detects 'app.bsky.embed.video' shapes and returns the video blob URL first 30 30 */ 31 - export function extractImageUrlsFromValue( 32 - value: any, 33 - did: string, 34 - limit = 4 35 - ): string[] { 31 + export function extractImageUrlsFromValue(value: any, did: string, limit = 4): string[] { 36 32 const urls: string[] = []; 37 33 38 34 try { ··· 93 89 } 94 90 95 91 // Video in recordWithMedia 96 - if (media && (media.$type === 'app.bsky.embed.video#view' || media.$type === 'app.bsky.embed.video')) { 92 + if ( 93 + media && 94 + (media.$type === 'app.bsky.embed.video#view' || media.$type === 'app.bsky.embed.video') 95 + ) { 97 96 const videoCid = (media as any)?.video?.ref?.$link ?? (media as any)?.video?.cid ?? null; 98 97 if (videoCid) { 99 98 const videoUrl = `https://video.bsky.app/watch/${did}/${videoCid}/playlist.m3u8`; ··· 146 145 } 147 146 148 147 if (e.$type === 'app.bsky.embed.video#view' || e.$type === 'app.bsky.embed.video') { 149 - const videoCid = (e as any)?.jobStatus?.blob ?? (e as any)?.video?.ref?.$link ?? (e as any)?.video?.cid ?? null; 148 + const videoCid = 149 + (e as any)?.jobStatus?.blob ?? 150 + (e as any)?.video?.ref?.$link ?? 151 + (e as any)?.video?.cid ?? 152 + null; 150 153 if (videoCid) { 151 154 const videoUrl = `https://video.bsky.app/watch/${did}/${videoCid}/playlist.m3u8`; 152 155 urls.push(videoUrl); ··· 156 159 157 160 if (e.$type === 'app.bsky.embed.recordWithMedia#view') { 158 161 const media = e.media; 159 - if (media && media.$type === 'app.bsky.embed.images#view' && Array.isArray(media.images)) { 162 + if ( 163 + media && 164 + media.$type === 'app.bsky.embed.images#view' && 165 + Array.isArray(media.images) 166 + ) { 160 167 for (const img of media.images) { 161 168 const imageUrl = img.fullsize || img.thumb; 162 169 if (imageUrl) {
+13 -9
src/lib/services/atproto/musicbrainz.ts
··· 95 95 const response = await fetch(url, { 96 96 headers: { 97 97 'User-Agent': 'ewancroft.uk/1.0.0 (https://ewancroft.uk)', 98 - 'Accept': 'application/json' 98 + Accept: 'application/json' 99 99 } 100 100 }); 101 101 ··· 134 134 const response = await fetch(url, { 135 135 headers: { 136 136 'User-Agent': 'ewancroft.uk/1.0.0 (https://ewancroft.uk)', 137 - 'Accept': 'application/json' 137 + Accept: 'application/json' 138 138 } 139 139 }); 140 140 ··· 180 180 181 181 try { 182 182 // Prefer searching by album + artist for better accuracy 183 - const searchTerm = releaseName 184 - ? `${releaseName} ${artistName}` 185 - : `${trackName} ${artistName}`; 183 + const searchTerm = releaseName ? `${releaseName} ${artistName}` : `${trackName} ${artistName}`; 186 184 187 185 const url = `https://itunes.apple.com/search?term=${encodeURIComponent(searchTerm)}&entity=album&limit=5`; 188 186 ··· 330 328 331 329 // Get the largest image available 332 330 const images = data.album.image; 333 - const largeImage = images.find((img: any) => img.size === 'extralarge') || 334 - images.find((img: any) => img.size === 'large') || 335 - images.find((img: any) => img.size === 'medium'); 331 + const largeImage = 332 + images.find((img: any) => img.size === 'extralarge') || 333 + images.find((img: any) => img.size === 'large') || 334 + images.find((img: any) => img.size === 'medium'); 336 335 337 336 if (largeImage?.['#text']) { 338 337 const artworkUrl = largeImage['#text']; ··· 371 370 if (releaseName) params.set('releaseName', releaseName); 372 371 if (releaseMbId) params.set('releaseMbId', releaseMbId); 373 372 374 - console.info('[Artwork] Fetching via server API:', { trackName, artistName, releaseName, releaseMbId }); 373 + console.info('[Artwork] Fetching via server API:', { 374 + trackName, 375 + artistName, 376 + releaseName, 377 + releaseMbId 378 + }); 375 379 376 380 // Call our server-side API endpoint 377 381 const response = await fetch(`/api/artwork?${params.toString()}`);
+44 -38
src/lib/services/atproto/posts.ts
··· 17 17 /** 18 18 * Fetches all Leaflet publications for a user 19 19 */ 20 - export async function fetchLeafletPublications(fetchFn?: typeof fetch): Promise<LeafletPublicationsData> { 20 + export async function fetchLeafletPublications( 21 + fetchFn?: typeof fetch 22 + ): Promise<LeafletPublicationsData> { 21 23 console.info('[Leaflet] Fetching publications'); 22 24 const cacheKey = `leaflet:publications:${PUBLIC_ATPROTO_DID}`; 23 25 const cached = cache.get<LeafletPublicationsData>(cacheKey); ··· 134 136 // Fetch Leaflet publications and documents 135 137 try { 136 138 // Get all publications first 137 - const publicationsData = await fetchLeafletPublications(fetchFn); 139 + const publicationsData = await fetchLeafletPublications(fetchFn); 138 140 const publicationsMap = new Map<string, LeafletPublication>(); 139 141 for (const pub of publicationsData.publications) { 140 142 publicationsMap.set(pub.uri, pub); ··· 156 158 ); 157 159 158 160 for (const record of leafletDocsRecords) { 159 - const value = record.value as any; 160 - const rkey = record.uri.split('/').pop() || ''; 161 - const publicationUri = value.publication; 162 - const publication = publicationsMap.get(publicationUri); 161 + const value = record.value as any; 162 + const rkey = record.uri.split('/').pop() || ''; 163 + const publicationUri = value.publication; 164 + const publication = publicationsMap.get(publicationUri); 163 165 164 - // Determine URL based on priority: publication base_path → Leaflet /lish format 165 - let url: string; 166 - const publicationRkey = publicationUri ? publicationUri.split('/').pop() : ''; 166 + // Determine URL based on priority: publication base_path → Leaflet /lish format 167 + let url: string; 168 + const publicationRkey = publicationUri ? publicationUri.split('/').pop() : ''; 167 169 168 - if (publication?.basePath) { 169 - // Ensure basePath is a complete URL 170 - const basePath = publication.basePath.startsWith('http') 171 - ? publication.basePath 172 - : `https://${publication.basePath}`; 173 - url = `${basePath}/${rkey}`; 174 - } else if (publicationRkey) { 175 - url = `https://leaflet.pub/lish/${PUBLIC_ATPROTO_DID}/${publicationRkey}/${rkey}`; 176 - } else { 177 - url = `https://leaflet.pub/${PUBLIC_ATPROTO_DID}/${rkey}`; 178 - } 170 + if (publication?.basePath) { 171 + // Ensure basePath is a complete URL 172 + const basePath = publication.basePath.startsWith('http') 173 + ? publication.basePath 174 + : `https://${publication.basePath}`; 175 + url = `${basePath}/${rkey}`; 176 + } else if (publicationRkey) { 177 + url = `https://leaflet.pub/lish/${PUBLIC_ATPROTO_DID}/${publicationRkey}/${rkey}`; 178 + } else { 179 + url = `https://leaflet.pub/${PUBLIC_ATPROTO_DID}/${rkey}`; 180 + } 179 181 180 182 posts.push({ 181 183 title: value.title || 'Untitled Document', ··· 240 242 const latestFeedItem = feed[0]; 241 243 const latestPostData = latestFeedItem.post; 242 244 console.log('[fetchLatestBlueskyPost] Found latest feed item:', latestPostData.uri); 243 - 245 + 244 246 // Check if this is a repost 245 247 const isRepost = latestFeedItem.reason?.$type === 'app.bsky.feed.defs#reasonRepost'; 246 248 let repostAuthor: PostAuthor | undefined; 247 249 let repostCreatedAt: string | undefined; 248 - 250 + 249 251 if (isRepost && latestFeedItem.reason) { 250 252 const reason = latestFeedItem.reason as any; 251 253 repostAuthor = { ··· 257 259 repostCreatedAt = reason.indexedAt; 258 260 console.log('[fetchLatestBlueskyPost] This is a repost by:', repostAuthor.handle); 259 261 } 260 - 262 + 261 263 // Fetch the full post data 262 264 const post = await fetchPostFromUri(latestPostData.uri, 0, fetchFn); 263 - 265 + 264 266 if (!post) { 265 267 console.warn('[fetchLatestBlueskyPost] fetchPostFromUri returned null'); 266 268 return null; 267 269 } 268 - 270 + 269 271 // Add repost context if applicable 270 272 if (isRepost) { 271 273 post.isRepost = true; ··· 288 290 * Recursively fetches a Bluesky post by URI, supporting quoted posts up to 2 levels deep 289 291 */ 290 292 export async function fetchPostFromUri( 291 - uri: string, 292 - depth: number, 293 + uri: string, 294 + depth: number, 293 295 fetchFn?: typeof fetch 294 296 ): Promise<BlueskyPost | null> { 295 297 console.log(`[fetchPostFromUri] Starting fetch at depth ${depth} for URI:`, uri); ··· 406 408 407 409 // Extract images from media 408 410 if (media?.$type === 'app.bsky.embed.images#view' && Array.isArray(media.images)) { 409 - console.log(`[fetchPostFromUri] Processing images in recordWithMedia, count:`, media.images.length); 411 + console.log( 412 + `[fetchPostFromUri] Processing images in recordWithMedia, count:`, 413 + media.images.length 414 + ); 410 415 hasImages = true; 411 416 imageUrls = []; 412 417 imageAlts = []; ··· 455 460 const quotedRecord = embed.record?.record || embed.record; 456 461 console.log(`[fetchPostFromUri] Quoted record in recordWithMedia:`, quotedRecord?.uri); 457 462 if (quotedRecord && typeof quotedRecord.uri === 'string') { 458 - quotedPostUri = quotedRecord.uri; 459 - console.log( 460 - `[fetchPostFromUri] Recursively fetching quoted post at depth ${depth + 1}:`, 461 - quotedPostUri 462 - ); 463 - if (quotedPostUri) { 464 - quotedPost = (await fetchPostFromUri(quotedPostUri, depth + 1, fetchFn)) ?? undefined; 463 + quotedPostUri = quotedRecord.uri; 464 + console.log( 465 + `[fetchPostFromUri] Recursively fetching quoted post at depth ${depth + 1}:`, 466 + quotedPostUri 467 + ); 468 + if (quotedPostUri) { 469 + quotedPost = (await fetchPostFromUri(quotedPostUri, depth + 1, fetchFn)) ?? undefined; 465 470 console.log(`[fetchPostFromUri] Quoted post fetched:`, quotedPost ? 'success' : 'failed'); 466 471 } 467 472 } ··· 491 496 if (value.reply) { 492 497 console.log(`[fetchPostFromUri] Post is a reply, fetching parent...`); 493 498 if (value.reply.parent?.uri) { 494 - replyParent = (await fetchPostFromUri(value.reply.parent.uri, depth + 1, fetchFn)) ?? undefined; 499 + replyParent = 500 + (await fetchPostFromUri(value.reply.parent.uri, depth + 1, fetchFn)) ?? undefined; 495 501 } 496 502 if (value.reply.root?.uri && value.reply.root.uri !== value.reply.parent?.uri) { 497 503 replyRoot = (await fetchPostFromUri(value.reply.root.uri, depth + 1, fetchFn)) ?? undefined; ··· 501 507 // Get engagement data from Constellation as a fallback 502 508 let finalLikeCount = postData.likeCount; 503 509 let finalRepostCount = postData.repostCount; 504 - 510 + 505 511 try { 506 512 const [likers, reposters] = await Promise.all([ 507 513 fetchAllEngagement(postData.uri, 'app.bsky.feed.like'), ··· 548 554 console.error(`[fetchPostFromUri] Failed to fetch post at depth ${depth}:`, err); 549 555 return null; 550 556 } 551 - } 557 + }
+20 -24
src/lib/stores/wolfMode.ts
··· 67 67 function getWolfSoundForWord(word: string, position: number): string { 68 68 // Normalize the word to lowercase for consistent mapping 69 69 const normalizedWord = word.toLowerCase(); 70 - 70 + 71 71 // If we've seen this word before, return the same sound 72 72 if (wordToSoundMap.has(normalizedWord)) { 73 73 return wordToSoundMap.get(normalizedWord)!; 74 74 } 75 - 75 + 76 76 // Otherwise, assign a new sound based on position and store it 77 77 const wolfSound = getWolfSoundByPosition(position); 78 78 wordToSoundMap.set(normalizedWord, wolfSound); ··· 94 94 if (!hasAlphabeticalCharacters(word)) { 95 95 return false; 96 96 } 97 - 97 + 98 98 // Don't transform if it's a number abbreviation 99 99 if (isNumberAbbreviation(word)) { 100 100 return false; 101 101 } 102 - 102 + 103 103 return true; 104 104 } 105 105 106 106 function splitWordAndPunctuation(token: string): { prefix: string; word: string; suffix: string } { 107 107 // Match leading punctuation, word, and trailing punctuation 108 108 const match = token.match(/^([^a-zA-Z0-9]*)([a-zA-Z0-9]+)([^a-zA-Z0-9]*)$/); 109 - 109 + 110 110 if (match) { 111 111 return { 112 112 prefix: match[1], ··· 114 114 suffix: match[3] 115 115 }; 116 116 } 117 - 117 + 118 118 // If no match, treat entire token as word 119 119 return { 120 120 prefix: '', ··· 127 127 // Split by words and replace each with a wolf sound 128 128 const words = text.split(/(\s+)/); // Keep whitespace 129 129 let currentPosition = startPosition; 130 - 130 + 131 131 return words 132 132 .map((token) => { 133 133 if (token.trim().length === 0) { 134 134 return token; // Preserve whitespace 135 135 } 136 - 136 + 137 137 // Split word from surrounding punctuation 138 138 const { prefix, word, suffix } = splitWordAndPunctuation(token); 139 - 139 + 140 140 // Only transform words that should be transformed 141 141 if (!shouldTransform(word)) { 142 142 return token; // Keep numbers, abbreviations, punctuation, etc. as-is 143 143 } 144 - 144 + 145 145 const wolfSound = getWolfSoundForWord(word, currentPosition); 146 146 currentPosition++; 147 - 147 + 148 148 // Apply capitalization pattern to the wolf sound 149 149 let transformedWord = wolfSound; 150 150 if (word === word.toUpperCase() && word.length > 1) { ··· 152 152 } else if (word[0] === word[0].toUpperCase()) { 153 153 transformedWord = wolfSound.charAt(0).toUpperCase() + wolfSound.slice(1); 154 154 } 155 - 155 + 156 156 // Reconstruct with original punctuation 157 157 return prefix + transformedWord + suffix; 158 158 }) ··· 167 167 return true; 168 168 } 169 169 } 170 - 170 + 171 171 // Skip buttons in the header navigation 172 172 if (element.closest('header button')) { 173 173 return true; 174 174 } 175 - 175 + 176 176 // Skip nav elements 177 177 if (element.tagName === 'NAV' || element.closest('nav')) { 178 178 return true; 179 179 } 180 - 180 + 181 181 return false; 182 182 } 183 183 ··· 186 186 callback(node as Text); 187 187 } else if (node.nodeType === Node.ELEMENT_NODE) { 188 188 const element = node as Element; 189 - 189 + 190 190 // Skip script, style tags, and navigation elements 191 - if ( 192 - element.tagName === 'SCRIPT' || 193 - element.tagName === 'STYLE' || 194 - shouldSkipElement(element) 195 - ) { 191 + if (element.tagName === 'SCRIPT' || element.tagName === 'STYLE' || shouldSkipElement(element)) { 196 192 return; 197 193 } 198 - 194 + 199 195 for (const child of Array.from(node.childNodes)) { 200 196 walkTextNodes(child, callback); 201 197 } ··· 206 202 originalTexts.clear(); 207 203 wordToSoundMap.clear(); 208 204 wordCounter = 0; 209 - 205 + 210 206 walkTextNodes(document.body, (textNode) => { 211 207 const originalText = textNode.textContent || ''; 212 208 if (originalText.trim().length > 0) { ··· 214 210 const transformedText = convertToWolfSpeak(originalText, wordCounter); 215 211 textNode.textContent = transformedText; 216 212 // Update counter based on number of transformable words processed 217 - wordCounter += originalText.split(/\s+/).filter(w => { 213 + wordCounter += originalText.split(/\s+/).filter((w) => { 218 214 const { word } = splitWordAndPunctuation(w); 219 215 return shouldTransform(word); 220 216 }).length;
+3 -7
src/lib/utils/formatNumber.ts
··· 6 6 * Determines the effective locale, preferring system locale with fallback to 'en-GB'. 7 7 */ 8 8 function getLocale(locale?: string): string { 9 - return ( 10 - locale || 11 - (typeof navigator !== 'undefined' && navigator.language) || 12 - 'en-GB' 13 - ); 9 + return locale || (typeof navigator !== 'undefined' && navigator.language) || 'en-GB'; 14 10 } 15 11 16 12 /** ··· 33 29 const roundedDown = Math.floor((num / divisor) * 10) / 10; 34 30 // Re-multiply to get the actual number to format 35 31 const adjustedNum = roundedDown * divisor; 36 - 32 + 37 33 return new Intl.NumberFormat(effectiveLocale, { 38 34 notation: 'compact', 39 35 compactDisplay: 'short', ··· 59 55 export function formatNumber(num: number, locale?: string): string { 60 56 const effectiveLocale = getLocale(locale); 61 57 return new Intl.NumberFormat(effectiveLocale).format(num); 62 - } 58 + }
+1 -1
src/lib/utils/url.ts
··· 44 44 */ 45 45 export function isExternalUrl(url: string): boolean { 46 46 if (typeof window === 'undefined') return true; 47 - 47 + 48 48 try { 49 49 const urlObj = new URL(url, window.location.href); 50 50 return urlObj.origin !== window.location.origin;
+5 -3
src/routes/+error.svelte
··· 48 48 <div class="text-center"> 49 49 <!-- Large status code number --> 50 50 <div class="mb-6"> 51 - <h1 class="text-8xl font-bold text-primary-500 dark:text-primary-400 md:text-9xl"> 51 + <h1 class="text-8xl font-bold text-primary-500 md:text-9xl dark:text-primary-400"> 52 52 {status} 53 53 </h1> 54 54 </div> ··· 65 65 66 66 <!-- Show additional error message if it's different from the description --> 67 67 {#if errorMessage && errorMessage !== errorDetails.description && status !== 404} 68 - <p class="mb-6 rounded-lg bg-canvas-200 p-4 text-sm text-ink-600 dark:bg-canvas-800 dark:text-ink-300"> 68 + <p 69 + class="mb-6 rounded-lg bg-canvas-200 p-4 text-sm text-ink-600 dark:bg-canvas-800 dark:text-ink-300" 70 + > 69 71 {errorMessage} 70 72 </p> 71 73 {/if} ··· 78 80 > 79 81 Return to Home 80 82 </a> 81 - 83 + 82 84 {#if status !== 404} 83 85 <button 84 86 onclick={() => window.location.reload()}
+14 -10
src/routes/+layout.svelte
··· 22 22 23 23 onMount(() => { 24 24 console.info('[App] Application mounted'); 25 - 25 + 26 26 // Setup global error handler 27 27 window.onerror = (msg, url, lineNo, columnNo, error) => { 28 28 console.error('[App] Global error:', { ··· 40 40 console.error('[App] Unhandled promise rejection:', event.reason); 41 41 }; 42 42 }); 43 - 43 + 44 44 // Reactive meta updates on navigation 45 - let headMeta = $derived(createSiteMeta({ 46 - ...data.siteMeta, 47 - ...data.meta 48 - })); 45 + let headMeta = $derived( 46 + createSiteMeta({ 47 + ...data.siteMeta, 48 + ...data.meta 49 + }) 50 + ); 49 51 </script> 50 52 51 53 <svelte:head> 52 54 <script> 53 55 // Prevent flash of unstyled content (FOUC) by applying theme before page renders 54 - (function() { 56 + (function () { 55 57 const stored = localStorage.getItem('theme'); 56 58 const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches; 57 59 const isDark = stored === 'dark' || (!stored && prefersDark); 58 60 const htmlElement = document.documentElement; 59 - 61 + 60 62 if (isDark) { 61 63 htmlElement.classList.add('dark'); 62 64 htmlElement.style.colorScheme = 'dark'; ··· 76 78 <!-- Bespoke MetaTags component --> 77 79 <MetaTags meta={headMeta} siteMeta={data.siteMeta} /> 78 80 79 - <div class="flex min-h-screen flex-col overflow-x-hidden bg-canvas-50 text-ink-900 dark:bg-canvas-950 dark:text-ink-50"> 81 + <div 82 + class="flex min-h-screen flex-col overflow-x-hidden bg-canvas-50 text-ink-900 dark:bg-canvas-950 dark:text-ink-50" 83 + > 80 84 <Header /> 81 - 85 + 82 86 <main class="container mx-auto flex-grow px-4 py-8"> 83 87 <ScrollToTop /> 84 88 {@render children()}
+12 -3
src/routes/+page.svelte
··· 1 1 <script lang="ts"> 2 2 import { DynamicLinks, TangledRepos } from '$lib/components/layout'; 3 - import { ProfileCard, PostCard, BlueskyPostCard, MusicStatusCard } from '$lib/components/layout/main/card'; 3 + import { 4 + ProfileCard, 5 + PostCard, 6 + BlueskyPostCard, 7 + MusicStatusCard 8 + } from '$lib/components/layout/main/card'; 4 9 import { createSiteMeta, type SiteMetadata } from '$lib/helper/siteMeta'; 5 10 6 11 // The `data` object includes merged layout/page load data. ··· 17 22 18 23 <div class="mx-auto max-w-6xl"> 19 24 <div class="mb-8 text-center"> 20 - <h1 class="mb-4 overflow-wrap-anywhere break-words text-4xl font-bold text-ink-900 md:text-5xl dark:text-ink-50"> 25 + <h1 26 + class="overflow-wrap-anywhere mb-4 text-4xl font-bold break-words text-ink-900 md:text-5xl dark:text-ink-50" 27 + > 21 28 Welcome to {meta.title} 22 29 </h1> 23 - <p class="mx-auto max-w-2xl overflow-wrap-anywhere break-words text-lg text-ink-700 dark:text-ink-200"> 30 + <p 31 + class="overflow-wrap-anywhere mx-auto max-w-2xl text-lg break-words text-ink-700 dark:text-ink-200" 32 + > 24 33 {meta.description} 25 34 </p> 26 35 </div>
+10 -10
src/routes/[slug=slug]/+server.ts
··· 5 5 6 6 /** 7 7 * Dynamic slug root redirect handler 8 - * 8 + * 9 9 * Redirects /{slug} to the appropriate Leaflet publication: 10 10 * - Uses the slug mapping config to find the publication rkey 11 11 * - Priority 1: Publication base_path from Leaflet API 12 12 * - Priority 2: Leaflet /lish format 13 - * 13 + * 14 14 * Individual posts are handled by the [rkey] route. 15 15 */ 16 16 export const GET: RequestHandler = async ({ params, url }) => { 17 17 const slug = params.slug; 18 - 18 + 19 19 // If there's a path after /{slug}, let it fall through to other routes 20 20 const slugPath = url.pathname.replace(new RegExp(`^/${slug}/?`), ''); 21 - 21 + 22 22 if (slugPath && !['rss', 'atom'].includes(slugPath)) { 23 23 // This will be caught by the [rkey] route 24 24 return new Response(null, { ··· 40 40 } 41 41 }); 42 42 } 43 - 43 + 44 44 const publicationRkey = getPublicationRkeyFromSlug(slug); 45 - 45 + 46 46 if (!publicationRkey) { 47 47 return new Response( 48 48 `Slug not configured: ${slug}\n\nPlease add this slug to src/lib/config/slugs.ts`, ··· 60 60 try { 61 61 // Fetch publications to get base path 62 62 const { publications } = await fetchLeafletPublications(); 63 - const publication = publications.find(p => p.rkey === publicationRkey); 64 - 63 + const publication = publications.find((p) => p.rkey === publicationRkey); 64 + 65 65 if (publication?.basePath) { 66 66 // Ensure basePath is a complete URL 67 - redirectUrl = publication.basePath.startsWith('http') 68 - ? publication.basePath 67 + redirectUrl = publication.basePath.startsWith('http') 68 + ? publication.basePath 69 69 : `https://${publication.basePath}`; 70 70 } else { 71 71 // Use Leaflet /lish format
+19 -20
src/routes/[slug=slug]/[rkey]/+server.ts
··· 69 69 70 70 if (publication?.basePath) { 71 71 // Ensure basePath is a complete URL 72 - const basePath = publication.basePath.startsWith('http') 73 - ? publication.basePath 72 + const basePath = publication.basePath.startsWith('http') 73 + ? publication.basePath 74 74 : `https://${publication.basePath}`; 75 75 url = `${basePath}/${rkey}`; 76 76 } else if (docPublicationRkey) { ··· 88 88 // Check WhiteWind as fallback (only if enabled) 89 89 if (PUBLIC_ENABLE_WHITEWIND === 'true') { 90 90 const whiteWindRecord = await withFallback( 91 - PUBLIC_ATPROTO_DID, 92 - async (agent) => { 93 - try { 94 - const response = await agent.com.atproto.repo.getRecord({ 95 - repo: PUBLIC_ATPROTO_DID, 96 - collection: 'com.whtwnd.blog.entry', 97 - rkey 98 - }); 99 - return response.data; 100 - } catch (err) { 101 - // Record not found 102 - return null; 103 - } 104 - }, 91 + PUBLIC_ATPROTO_DID, 92 + async (agent) => { 93 + try { 94 + const response = await agent.com.atproto.repo.getRecord({ 95 + repo: PUBLIC_ATPROTO_DID, 96 + collection: 'com.whtwnd.blog.entry', 97 + rkey 98 + }); 99 + return response.data; 100 + } catch (err) { 101 + // Record not found 102 + return null; 103 + } 104 + }, 105 105 true // Use PDS first for custom collections 106 106 ); 107 107 ··· 140 140 141 141 // Get the publication rkey from the slug 142 142 const publicationRkey = getPublicationRkeyFromSlug(slug); 143 - 143 + 144 144 if (!publicationRkey) { 145 145 return new Response( 146 146 `Slug not configured: ${slug}\n\nPlease add this slug to src/lib/config/slugs.ts`, ··· 180 180 } else { 181 181 // No fallback configured, return 404 182 182 const publicationNote = `\n\nNote: Only checking Leaflet publication with rkey: ${publicationRkey}`; 183 - const whiteWindNote = PUBLIC_ENABLE_WHITEWIND === 'true' 184 - ? '\n- WhiteWind: https://whtwnd.com' 185 - : ''; 183 + const whiteWindNote = 184 + PUBLIC_ENABLE_WHITEWIND === 'true' ? '\n- WhiteWind: https://whtwnd.com' : ''; 186 185 187 186 return new Response( 188 187 `Document not found: ${rkey}
+3 -3
src/routes/[slug=slug]/atom/+server.ts
··· 14 14 */ 15 15 export const GET: RequestHandler = ({ params }) => { 16 16 const slug = params.slug; 17 - 17 + 18 18 // Validate slug 19 19 if (!slug) { 20 20 return new Response('Invalid slug', { ··· 24 24 } 25 25 }); 26 26 } 27 - 27 + 28 28 // Validate slug exists in config 29 29 const publicationRkey = getPublicationRkeyFromSlug(slug); 30 - 30 + 31 31 if (!publicationRkey) { 32 32 return new Response( 33 33 `Slug not configured: ${slug}\n\nPlease add this slug to src/lib/config/slugs.ts`,
+12 -15
src/routes/[slug=slug]/rss/+server.ts
··· 19 19 */ 20 20 export const GET: RequestHandler = async ({ params }) => { 21 21 const slug = params.slug; 22 - 22 + 23 23 // Validate slug 24 24 if (!slug) { 25 25 return new Response('Invalid slug', { ··· 29 29 } 30 30 }); 31 31 } 32 - 32 + 33 33 // Get the publication rkey from the slug 34 34 const publicationRkey = getPublicationRkeyFromSlug(slug); 35 - 35 + 36 36 if (!publicationRkey) { 37 37 return new Response( 38 38 `Slug not configured: ${slug}\n\nPlease add this slug to src/lib/config/slugs.ts`, ··· 50 50 51 51 // Filter posts for this specific publication 52 52 const publicationPosts = posts.filter( 53 - p => p.publicationRkey === publicationRkey || p.platform === 'WhiteWind' 53 + (p) => p.publicationRkey === publicationRkey || p.platform === 'WhiteWind' 54 54 ); 55 55 56 56 // Separate WhiteWind and Leaflet posts ··· 134 134 135 135 // Find the specific publication 136 136 const publication = publications.find((p) => p.rkey === publicationRkey); 137 - 137 + 138 138 if (publication) { 139 139 const rssUrl = getLeafletRSSUrl(publication); 140 140 return Response.redirect(rssUrl, 307); // Temporary redirect 141 141 } 142 142 143 143 // Publication not found 144 - return new Response( 145 - `Leaflet publication not found for rkey: ${publicationRkey}`, 146 - { 147 - status: 404, 148 - headers: { 149 - 'Content-Type': 'text/plain; charset=utf-8' 150 - } 144 + return new Response(`Leaflet publication not found for rkey: ${publicationRkey}`, { 145 + status: 404, 146 + headers: { 147 + 'Content-Type': 'text/plain; charset=utf-8' 151 148 } 152 - ); 149 + }); 153 150 } catch (error) { 154 151 console.error('Error redirecting to Leaflet RSS:', error); 155 152 return new Response('Error finding Leaflet RSS feed', { ··· 167 164 function getLeafletRSSUrl(publication: { basePath?: string; rkey: string }): string { 168 165 if (publication.basePath) { 169 166 // Ensure basePath is a complete URL 170 - const basePath = publication.basePath.startsWith('http') 171 - ? publication.basePath 167 + const basePath = publication.basePath.startsWith('http') 168 + ? publication.basePath 172 169 : `https://${publication.basePath}`; 173 170 return `${basePath}/rss`; 174 171 }
+3 -8
src/routes/api/artwork/+server.ts
··· 143 143 releaseName?: string 144 144 ): Promise<string | null> { 145 145 try { 146 - const searchTerm = releaseName 147 - ? `${releaseName} ${artistName}` 148 - : `${trackName} ${artistName}`; 146 + const searchTerm = releaseName ? `${releaseName} ${artistName}` : `${trackName} ${artistName}`; 149 147 150 148 const url = `https://itunes.apple.com/search?term=${encodeURIComponent(searchTerm)}&entity=album&limit=5`; 151 149 ··· 196 194 /** 197 195 * Search Last.fm for artwork 198 196 */ 199 - async function searchLastFm( 200 - artistName: string, 201 - releaseName?: string 202 - ): Promise<string | null> { 197 + async function searchLastFm(artistName: string, releaseName?: string): Promise<string | null> { 203 198 if (!releaseName) return null; 204 199 205 200 try { ··· 229 224 /** 230 225 * GET /api/artwork 231 226 * Query params: trackName, artistName, releaseName?, releaseMbId? 232 - * 227 + * 233 228 * Features: 234 229 * - Intelligent caching (1 hour TTL) 235 230 * - Multiple fallback sources (MusicBrainz, iTunes, Deezer, Last.fm)
+2 -2
src/routes/favicon.ico/+server.ts
··· 2 2 3 3 /** 4 4 * Redirects /favicon.ico to /favicon/favicon.ico 5 - * 5 + * 6 6 * This handles browsers that request favicon.ico from the root 7 7 * and redirects them to the actual location in the /favicon/ directory. 8 8 */ ··· 14 14 'Cache-Control': 'public, max-age=31536000, immutable' 15 15 } 16 16 }); 17 - }; 17 + };
+23 -13
src/routes/site/meta/+page.svelte
··· 31 31 </div> 32 32 {:else if siteInfo} 33 33 <div class="space-y-8"> 34 - {#each [ 35 - { title: 'Purpose', content: siteInfo.additionalInfo?.purpose }, 36 - { title: 'History', content: siteInfo.additionalInfo?.websiteBirthYear ? `This website was first launched in ${siteInfo.additionalInfo.websiteBirthYear}.` : null }, 37 - { title: 'Privacy', content: siteInfo.privacyStatement } 38 - ] as section} 34 + {#each [{ title: 'Purpose', content: siteInfo.additionalInfo?.purpose }, { title: 'History', content: siteInfo.additionalInfo?.websiteBirthYear ? `This website was first launched in ${siteInfo.additionalInfo.websiteBirthYear}.` : null }, { title: 'Privacy', content: siteInfo.privacyStatement }] as section} 39 35 {#if section.content} 40 - <section class="rounded-xl bg-canvas-100 p-6 shadow-lg transition-all duration-300 hover:shadow-xl dark:bg-canvas-900"> 36 + <section 37 + class="rounded-xl bg-canvas-100 p-6 shadow-lg transition-all duration-300 hover:shadow-xl dark:bg-canvas-900" 38 + > 41 39 <h2 class="mb-4 text-2xl font-bold text-ink-900 dark:text-ink-50">{section.title}</h2> 42 40 <p class="whitespace-pre-wrap text-ink-700 dark:text-ink-300">{section.content}</p> 43 41 </section> ··· 45 43 {/each} 46 44 47 45 {#if siteInfo.technologyStack?.length} 48 - <section class="rounded-xl bg-canvas-100 p-6 shadow-lg transition-all duration-300 hover:shadow-xl dark:bg-canvas-900"> 46 + <section 47 + class="rounded-xl bg-canvas-100 p-6 shadow-lg transition-all duration-300 hover:shadow-xl dark:bg-canvas-900" 48 + > 49 49 <h2 class="mb-4 text-2xl font-bold text-ink-900 dark:text-ink-50">Technology Stack</h2> 50 50 <div class="space-y-2"> 51 51 {#each siteInfo.technologyStack as tech} ··· 56 56 {/if} 57 57 58 58 {#if siteInfo.openSourceInfo} 59 - <section class="rounded-xl bg-canvas-100 p-6 shadow-lg transition-all duration-300 hover:shadow-xl dark:bg-canvas-900"> 59 + <section 60 + class="rounded-xl bg-canvas-100 p-6 shadow-lg transition-all duration-300 hover:shadow-xl dark:bg-canvas-900" 61 + > 60 62 <h2 class="mb-4 text-2xl font-bold text-ink-900 dark:text-ink-50">Open Source</h2> 61 63 {#if siteInfo.openSourceInfo.description} 62 - <p class="mb-4 whitespace-pre-wrap text-ink-700 dark:text-ink-300">{siteInfo.openSourceInfo.description}</p> 64 + <p class="mb-4 whitespace-pre-wrap text-ink-700 dark:text-ink-300"> 65 + {siteInfo.openSourceInfo.description} 66 + </p> 63 67 {/if} 64 68 {#if siteInfo.openSourceInfo.repositories?.length} 65 69 <div class="space-y-2"> ··· 80 84 {/if} 81 85 82 86 {#if siteInfo.credits?.length} 83 - <section class="rounded-xl bg-canvas-100 p-6 shadow-lg transition-all duration-300 hover:shadow-xl dark:bg-canvas-900"> 87 + <section 88 + class="rounded-xl bg-canvas-100 p-6 shadow-lg transition-all duration-300 hover:shadow-xl dark:bg-canvas-900" 89 + > 84 90 <h2 class="mb-4 text-2xl font-bold text-ink-900 dark:text-ink-50">Credits</h2> 85 91 <div class="grid gap-4 md:grid-cols-2"> 86 92 {#each siteInfo.credits as credit} 87 93 <div class="rounded-lg bg-canvas-200 p-4 dark:bg-canvas-800"> 88 94 <h4 class="font-medium text-ink-900 dark:text-ink-50">{credit.name}</h4> 89 - {#if credit.author}<p class="text-sm text-ink-600 dark:text-ink-400">by {credit.author}</p>{/if} 90 - {#if credit.description}<p class="mt-1 text-sm text-ink-700 dark:text-ink-300">{credit.description}</p>{/if} 95 + {#if credit.author}<p class="text-sm text-ink-600 dark:text-ink-400"> 96 + by {credit.author} 97 + </p>{/if} 98 + {#if credit.description}<p class="mt-1 text-sm text-ink-700 dark:text-ink-300"> 99 + {credit.description} 100 + </p>{/if} 91 101 </div> 92 102 {/each} 93 103 </div> ··· 99 109 <p class="text-ink-700 dark:text-ink-300">No site information available.</p> 100 110 </div> 101 111 {/if} 102 - </div> 112 + </div>
+15 -15
src/routes/site/meta/+page.ts
··· 4 4 import { ogImages } from '$lib/helper/ogImages'; 5 5 6 6 export const load: PageLoad = async ({ parent, fetch }) => { 7 - const { siteMeta } = await parent(); 7 + const { siteMeta } = await parent(); 8 8 9 - let siteInfo: SiteInfoData | null = null; 10 - let error: string | null = null; 9 + let siteInfo: SiteInfoData | null = null; 10 + let error: string | null = null; 11 11 12 - try { 13 - siteInfo = await fetchSiteInfo(fetch); 14 - } catch (err) { 15 - error = err instanceof Error ? err.message : 'Failed to load site information'; 16 - } 12 + try { 13 + siteInfo = await fetchSiteInfo(fetch); 14 + } catch (err) { 15 + error = err instanceof Error ? err.message : 'Failed to load site information'; 16 + } 17 17 18 - const meta: SiteMetadata = createSiteMeta({ 19 - ...siteMeta, 20 - title: `Site Meta - ${defaultSiteMeta.title}`, 21 - description: 'Information about this website, its technology stack, and credits.', 22 - image: ogImages.siteMeta, 23 - }); 18 + const meta: SiteMetadata = createSiteMeta({ 19 + ...siteMeta, 20 + title: `Site Meta - ${defaultSiteMeta.title}`, 21 + description: 'Information about this website, its technology stack, and credits.', 22 + image: ogImages.siteMeta 23 + }); 24 24 25 - return { siteInfo, error, meta }; 25 + return { siteInfo, error, meta }; 26 26 };
+12 -12
tsconfig.json
··· 1 1 { 2 - "extends": "./.svelte-kit/tsconfig.json", 3 - "compilerOptions": { 4 - "allowJs": true, 5 - "checkJs": true, 6 - "esModuleInterop": true, 7 - "forceConsistentCasingInFileNames": true, 8 - "resolveJsonModule": true, 9 - "skipLibCheck": true, 10 - "sourceMap": true, 11 - "strict": true, 12 - "moduleResolution": "bundler" 13 - } 2 + "extends": "./.svelte-kit/tsconfig.json", 3 + "compilerOptions": { 4 + "allowJs": true, 5 + "checkJs": true, 6 + "esModuleInterop": true, 7 + "forceConsistentCasingInFileNames": true, 8 + "resolveJsonModule": true, 9 + "skipLibCheck": true, 10 + "sourceMap": true, 11 + "strict": true, 12 + "moduleResolution": "bundler" 13 + } 14 14 }