my website at ewancroft.uk

chore: Reformat files using Prettier

ewancroft.uk baff6166 96f35ab1

verified
+158 -168
.cspell.json
··· 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 - ] 170 }
··· 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": ["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 + ] 160 }
+10 -8
.github/ISSUE_TEMPLATE/bug_report.md
··· 4 title: '' 5 labels: '' 6 assignees: '' 7 - 8 --- 9 10 **Describe the bug** ··· 12 13 **To Reproduce** 14 Steps to reproduce the behavior: 15 1. Go to '...' 16 2. Click on '....' 17 3. Scroll down to '....' ··· 24 If applicable, add screenshots to help explain your problem. 25 26 **Desktop (please complete the following information):** 27 - - OS: [e.g. iOS] 28 - - Browser [e.g. chrome, safari] 29 - - Version [e.g. 22] 30 31 **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] 36 37 **Additional context** 38 Add any other context about the problem here.
··· 4 title: '' 5 labels: '' 6 assignees: '' 7 --- 8 9 **Describe the bug** ··· 11 12 **To Reproduce** 13 Steps to reproduce the behavior: 14 + 15 1. Go to '...' 16 2. Click on '....' 17 3. Scroll down to '....' ··· 24 If applicable, add screenshots to help explain your problem. 25 26 **Desktop (please complete the following information):** 27 + 28 + - OS: [e.g. iOS] 29 + - Browser [e.g. chrome, safari] 30 + - Version [e.g. 22] 31 32 **Smartphone (please complete the following information):** 33 + 34 + - Device: [e.g. iPhone6] 35 + - OS: [e.g. iOS8.1] 36 + - Browser [e.g. stock browser, safari] 37 + - Version [e.g. 22] 38 39 **Additional context** 40 Add any other context about the problem here.
+6 -6
.vscode/settings.json
··· 1 { 2 - "css.customData": [".vscode/tailwind.json"], 3 - "css.validate": false, 4 - "tailwindCSS.includeLanguages": { 5 - "svelte": "html" 6 - } 7 - }
··· 1 { 2 + "css.customData": [".vscode/tailwind.json"], 3 + "css.validate": false, 4 + "tailwindCSS.includeLanguages": { 5 + "svelte": "html" 6 + } 7 + }
+54 -54
.vscode/tailwind.json
··· 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 - }
··· 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 + }
+17 -17
README.md
··· 125 126 ```typescript 127 export const slugMappings: SlugMapping[] = [ 128 - { slug: 'blog', publicationRkey: '3m3x4bgbsh22k' }, 129 - { slug: 'essays', publicationRkey: 'abc123xyz' }, 130 - { slug: 'notes', publicationRkey: 'def456uvw' } 131 ]; 132 ``` 133 ··· 250 ### Usage Examples 251 252 ```typescript 253 - import { 254 - fetchProfile, 255 - fetchBlogPosts, 256 - fetchLatestBlueskyPost, 257 - fetchMusicStatus, 258 - fetchTangledRepos 259 } from '$lib/services/atproto'; 260 261 // Fetch profile data ··· 284 285 ```typescript 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 - } 295 ]; 296 ``` 297
··· 125 126 ```typescript 127 export const slugMappings: SlugMapping[] = [ 128 + { slug: 'blog', publicationRkey: '3m3x4bgbsh22k' }, 129 + { slug: 'essays', publicationRkey: 'abc123xyz' }, 130 + { slug: 'notes', publicationRkey: 'def456uvw' } 131 ]; 132 ``` 133 ··· 250 ### Usage Examples 251 252 ```typescript 253 + import { 254 + fetchProfile, 255 + fetchBlogPosts, 256 + fetchLatestBlueskyPost, 257 + fetchMusicStatus, 258 + fetchTangledRepos 259 } from '$lib/services/atproto'; 260 261 // Fetch profile data ··· 284 285 ```typescript 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 + } 295 ]; 296 ``` 297
+98 -92
src/app.css
··· 2 @import 'tailwindcss'; 3 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'; 7 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)); 20 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)); 33 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)); 46 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)); 59 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)); 72 } 73 74 @layer base { 75 - /* Base styles for consistent typography and accessibility */ 76 - html { 77 - scroll-behavior: smooth; 78 - overflow-x: hidden; 79 - width: 100%; 80 - } 81 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 - } 91 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 - } 107 } 108 109 @plugin '@tailwindcss/typography';
··· 2 @import 'tailwindcss'; 3 4 @theme { 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'; 9 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)); 22 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)); 35 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)); 48 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)); 61 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)); 74 } 75 76 @layer base { 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 + } 99 100 + /* Ensure all elements stay within viewport */ 101 + * { 102 + min-width: 0; 103 + } 104 105 + img, 106 + video, 107 + iframe, 108 + embed, 109 + object { 110 + max-width: 100%; 111 + height: auto; 112 + } 113 } 114 115 @plugin '@tailwindcss/typography';
+4 -4
src/hooks.server.ts
··· 3 4 /** 5 * Global request handler with CORS support 6 - * 7 * CORS headers are dynamically configured via the PUBLIC_CORS_ALLOWED_ORIGINS environment variable. 8 * Set it to a comma-separated list of allowed origins, or "*" to allow all origins. 9 */ ··· 11 // Handle OPTIONS preflight requests for CORS 12 if (event.request.method === 'OPTIONS' && event.url.pathname.startsWith('/api/')) { 13 const origin = event.request.headers.get('origin'); 14 - const allowedOrigins = PUBLIC_CORS_ALLOWED_ORIGINS?.split(',').map(o => o.trim()) || []; 15 16 const headers: Record<string, string> = { 17 'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS', ··· 38 // Add CORS headers for API routes 39 if (event.url.pathname.startsWith('/api/')) { 40 const origin = event.request.headers.get('origin'); 41 - const allowedOrigins = PUBLIC_CORS_ALLOWED_ORIGINS?.split(',').map(o => o.trim()) || []; 42 43 // If * is specified, allow any origin 44 if (allowedOrigins.includes('*')) { ··· 55 } 56 57 return response; 58 - };
··· 3 4 /** 5 * Global request handler with CORS support 6 + * 7 * CORS headers are dynamically configured via the PUBLIC_CORS_ALLOWED_ORIGINS environment variable. 8 * Set it to a comma-separated list of allowed origins, or "*" to allow all origins. 9 */ ··· 11 // Handle OPTIONS preflight requests for CORS 12 if (event.request.method === 'OPTIONS' && event.url.pathname.startsWith('/api/')) { 13 const origin = event.request.headers.get('origin'); 14 + const allowedOrigins = PUBLIC_CORS_ALLOWED_ORIGINS?.split(',').map((o) => o.trim()) || []; 15 16 const headers: Record<string, string> = { 17 'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS', ··· 38 // Add CORS headers for API routes 39 if (event.url.pathname.startsWith('/api/')) { 40 const origin = event.request.headers.get('origin'); 41 + const allowedOrigins = PUBLIC_CORS_ALLOWED_ORIGINS?.split(',').map((o) => o.trim()) || []; 42 43 // If * is specified, allow any origin 44 if (allowedOrigins.includes('*')) { ··· 55 } 56 57 return response; 58 + };
+5 -3
src/lib/components/layout/Footer.svelte
··· 8 let copyrightText: string; 9 10 const currentYear = new Date().getFullYear(); 11 - 12 $: { 13 console.log('[Footer] Reactive: siteInfo updated:', siteInfo); 14 const birthYear = siteInfo?.additionalInfo?.websiteBirthYear; 15 console.log('[Footer] Current year:', currentYear); 16 console.log('[Footer] Birth year:', birthYear); 17 console.log('[Footer] Birth year type:', typeof birthYear); 18 - 19 if (!birthYear || typeof birthYear !== 'number') { 20 console.log('[Footer] Using current year (invalid/missing birth year)'); 21 copyrightText = `${currentYear}`; ··· 37 <footer 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 > 40 - <div class="container mx-auto space-y-2 px-4 text-center text-sm font-medium text-ink-800 dark:text-ink-100"> 41 <!-- Line 1: Copyright & Profile --> 42 <div class="flex flex-col items-center justify-center gap-1 sm:flex-row sm:gap-2"> 43 <span>&copy; <span>{copyrightText}</span></span>
··· 8 let copyrightText: string; 9 10 const currentYear = new Date().getFullYear(); 11 + 12 $: { 13 console.log('[Footer] Reactive: siteInfo updated:', siteInfo); 14 const birthYear = siteInfo?.additionalInfo?.websiteBirthYear; 15 console.log('[Footer] Current year:', currentYear); 16 console.log('[Footer] Birth year:', birthYear); 17 console.log('[Footer] Birth year type:', typeof birthYear); 18 + 19 if (!birthYear || typeof birthYear !== 'number') { 20 console.log('[Footer] Using current year (invalid/missing birth year)'); 21 copyrightText = `${currentYear}`; ··· 37 <footer 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 > 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 + > 43 <!-- Line 1: Copyright & Profile --> 44 <div class="flex flex-col items-center justify-center gap-1 sm:flex-row sm:gap-2"> 45 <span>&copy; <span>{copyrightText}</span></span>
+12 -4
src/lib/components/layout/Header.svelte
··· 9 const siteMeta: SiteMetadata = createSiteMeta(defaultSiteMeta); 10 </script> 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"> 14 <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"> 16 {siteMeta.title} 17 </span> 18 </a> ··· 25 </div> 26 </div> 27 </nav> 28 - </header>
··· 9 const siteMeta: SiteMetadata = createSiteMeta(defaultSiteMeta); 10 </script> 11 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 + > 19 <a href="/" class="group flex min-w-0 items-center gap-2"> 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 + > 24 {siteMeta.title} 25 </span> 26 </a> ··· 33 </div> 34 </div> 35 </nav> 36 + </header>
+39 -39
src/lib/components/layout/NavLinks.svelte
··· 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'; 5 6 - const { page } = getStores(); 7 - export let navItems: NavItem[] = []; 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 - }); 17 </script> 18 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 25 {$page.url.pathname === item.href 26 - ? 'text-primary-600 dark:text-primary-400' 27 - : 'text-ink-700 dark:text-ink-200'} 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>
··· 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'; 5 6 + const { page } = getStores(); 7 + export let navItems: NavItem[] = []; 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 + }); 17 </script> 18 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 25 {$page.url.pathname === item.href 26 + ? 'text-primary-600 dark:text-primary-400' 27 + : 'text-ink-700 dark:text-ink-200'} 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="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 // Check localStorage and system preference 10 const stored = localStorage.getItem('theme'); 11 const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches; 12 - 13 isDark = stored === 'dark' || (!stored && prefersDark); 14 updateTheme(); 15 mounted = true; ··· 31 32 function updateTheme() { 33 const htmlElement = document.documentElement; 34 - 35 if (isDark) { 36 htmlElement.classList.add('dark'); 37 htmlElement.style.colorScheme = 'dark'; ··· 58 <div class="relative h-5 w-5"> 59 <Sun 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'}" 63 aria-hidden="true" 64 /> 65 <Moon 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'}" 69 aria-hidden="true" 70 /> 71 </div> 72 {:else} 73 - <div class="h-5 w-5 animate-pulse bg-canvas-300 dark:bg-canvas-700 rounded"></div> 74 {/if} 75 </button>
··· 9 // Check localStorage and system preference 10 const stored = localStorage.getItem('theme'); 11 const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches; 12 + 13 isDark = stored === 'dark' || (!stored && prefersDark); 14 updateTheme(); 15 mounted = true; ··· 31 32 function updateTheme() { 33 const htmlElement = document.documentElement; 34 + 35 if (isDark) { 36 htmlElement.classList.add('dark'); 37 htmlElement.style.colorScheme = 'dark'; ··· 58 <div class="relative h-5 w-5"> 59 <Sun 60 class="absolute inset-0 h-5 w-5 transition-all duration-300 {isDark 61 + ? 'scale-0 rotate-90 opacity-0' 62 + : 'scale-100 rotate-0 opacity-100'}" 63 aria-hidden="true" 64 /> 65 <Moon 66 class="absolute inset-0 h-5 w-5 transition-all duration-300 {isDark 67 + ? 'scale-100 rotate-0 opacity-100' 68 + : 'scale-0 -rotate-90 opacity-0'}" 69 aria-hidden="true" 70 /> 71 </div> 72 {:else} 73 + <div class="h-5 w-5 animate-pulse rounded bg-canvas-300 dark:bg-canvas-700"></div> 74 {/if} 75 </button>
+1 -1
src/lib/components/layout/WolfToggle.svelte
··· 23 type="button" 24 title={isWolfMode ? 'Return to normal text' : 'Transform to wolf speak - awoo!'} 25 > 26 - <span 27 class="text-2xl transition-transform duration-300 {isWolfMode ? 'scale-125' : 'scale-100'}" 28 aria-hidden="true" 29 >
··· 23 type="button" 24 title={isWolfMode ? 'Return to normal text' : 'Transform to wolf speak - awoo!'} 25 > 26 + <span 27 class="text-2xl transition-transform duration-300 {isWolfMode ? 'scale-125' : 'scale-100'}" 28 aria-hidden="true" 29 >
+4 -3
src/lib/components/layout/main/DynamicLinks.svelte
··· 50 {#snippet children()} 51 <div class="text-center"> 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 56 <a 57 href="https://linkat.blue/" 58 class="text-primary-600 hover:underline dark:text-primary-400"
··· 50 {#snippet children()} 51 <div class="text-center"> 52 <p class="text-ink-700 dark:text-ink-300"> 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 57 <a 58 href="https://linkat.blue/" 59 class="text-primary-600 hover:underline dark:text-primary-400"
+33 -33
src/lib/components/layout/main/ScrollToTop.svelte
··· 1 <script lang="ts"> 2 - import { onMount } from "svelte"; 3 - import { ChevronUp } from "@lucide/svelte"; 4 5 - let isVisible = false; 6 - let scrollY = 0; 7 8 - $: isVisible = scrollY > 300; 9 10 - function scrollToTop() { 11 - window.scrollTo({ top: 0, behavior: "smooth" }); 12 - } 13 14 - function handleKeydown(event: KeyboardEvent) { 15 - if (event.key === "Enter" || event.key === " ") { 16 - event.preventDefault(); 17 - scrollToTop(); 18 - } 19 - } 20 21 - onMount(() => { 22 - const updateScrollY = () => (scrollY = window.scrollY); 23 - window.addEventListener("scroll", updateScrollY, { passive: true }); 24 - return () => window.removeEventListener("scroll", updateScrollY); 25 - }); 26 </script> 27 28 <svelte:window bind:scrollY /> 29 30 <!-- just Tailwind fade via opacity --> 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} 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>
··· 1 <script lang="ts"> 2 + import { onMount } from 'svelte'; 3 + import { ChevronUp } from '@lucide/svelte'; 4 5 + let isVisible = false; 6 + let scrollY = 0; 7 8 + $: isVisible = scrollY > 300; 9 10 + function scrollToTop() { 11 + window.scrollTo({ top: 0, behavior: 'smooth' }); 12 + } 13 14 + function handleKeydown(event: KeyboardEvent) { 15 + if (event.key === 'Enter' || event.key === ' ') { 16 + event.preventDefault(); 17 + scrollToTop(); 18 + } 19 + } 20 21 + onMount(() => { 22 + const updateScrollY = () => (scrollY = window.scrollY); 23 + window.addEventListener('scroll', updateScrollY, { passive: true }); 24 + return () => window.removeEventListener('scroll', updateScrollY); 25 + }); 26 </script> 27 28 <svelte:window bind:scrollY /> 29 30 <!-- just Tailwind fade via opacity --> 31 <div 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 > 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 12 onMount(async () => { 13 try { 14 - const [reposData, profile] = await Promise.all([ 15 - fetchTangledRepos(), 16 - fetchProfile() 17 - ]); 18 repos = reposData; 19 handle = profile.handle; 20 } catch (err) { ··· 43 {@const safeRepos = repos} 44 <Card variant="elevated" padding="md"> 45 {#snippet children()} 46 - <h2 class="mb-4 text-2xl font-bold text-ink-900 dark:text-ink-50"> 47 - Tangled Repositories 48 - </h2> 49 <div class="space-y-3"> 50 {#each safeRepos.repos as repo} 51 <TangledRepoCard {repo} {handle} />
··· 11 12 onMount(async () => { 13 try { 14 + const [reposData, profile] = await Promise.all([fetchTangledRepos(), fetchProfile()]); 15 repos = reposData; 16 handle = profile.handle; 17 } catch (err) { ··· 40 {@const safeRepos = repos} 41 <Card variant="elevated" padding="md"> 42 {#snippet children()} 43 + <h2 class="mb-4 text-2xl font-bold text-ink-900 dark:text-ink-50">Tangled Repositories</h2> 44 <div class="space-y-3"> 45 {#each safeRepos.repos as repo} 46 <TangledRepoCard {repo} {handle} />
+93 -58
src/lib/components/layout/main/card/BlueskyPostCard.svelte
··· 93 94 for (const facet of sortedFacets) { 95 const { byteStart, byteEnd } = facet.index; 96 - 97 // Extract text before facet 98 if (lastByteIndex < byteStart) { 99 const beforeBytes = bytes.slice(lastByteIndex, byteStart); 100 result += escapeHtml(decoder.decode(beforeBytes)); 101 } 102 - 103 // Extract facet text 104 const facetBytes = bytes.slice(byteStart, byteEnd); 105 const facetText = decoder.decode(facetBytes); ··· 198 href={getProfileUrl(postData.author.handle)} 199 target="_blank" 200 rel="noopener noreferrer" 201 - class="transition-opacity hover:opacity-80 shrink-0" 202 > 203 {#if postData.author.avatar} 204 <img 205 src={postData.author.avatar} 206 alt={postData.author.displayName || postData.author.handle} 207 - class="h-8 w-8 sm:h-10 sm:w-10 rounded-full object-cover" 208 loading="lazy" 209 /> 210 {:else} 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" 213 > 214 - <span class="text-sm sm:text-base font-semibold text-primary-700 dark:text-primary-300"> 215 {(postData.author.displayName || postData.author.handle).charAt(0).toUpperCase()} 216 </span> 217 </div> ··· 222 href={getProfileUrl(postData.author.handle)} 223 target="_blank" 224 rel="noopener noreferrer" 225 - class="transition-opacity hover:opacity-80 shrink-0" 226 > 227 {#if postData.author.avatar} 228 <img 229 src={postData.author.avatar} 230 alt={postData.author.displayName || postData.author.handle} 231 - class="h-10 w-10 sm:h-12 sm:w-12 rounded-full object-cover" 232 loading="lazy" 233 /> 234 {:else} 235 <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" 237 > 238 - <span class="text-base sm:text-lg font-semibold text-primary-700 dark:text-primary-300"> 239 {(postData.author.displayName || postData.author.handle).charAt(0).toUpperCase()} 240 </span> 241 </div> 242 {/if} 243 </a> 244 {/if} 245 - <div class="flex-1 min-w-0"> 246 <!-- Author name and handle --> 247 <a 248 href={getProfileUrl(postData.author.handle)} ··· 251 class="inline-block {isReplyParent ? 'mb-1' : 'mb-2'} transition-opacity hover:opacity-80" 252 > 253 <div class="flex flex-col"> 254 - <span class="text-{isReplyParent ? 'sm' : 'base'} font-semibold text-ink-900 dark:text-ink-50 leading-tight"> 255 {postData.author.displayName || postData.author.handle} 256 </span> 257 - <span class="text-xs text-ink-600 dark:text-ink-400 leading-tight"> 258 @{postData.author.handle} 259 </span> 260 </div> ··· 262 263 <!-- Post Text with Rich Text Support --> 264 <div 265 - class="{isReplyParent ? 'mb-2' : 'mb-3'} overflow-wrap-anywhere break-words whitespace-pre-wrap text-{isReplyParent 266 ? 'sm' 267 : 'base'} leading-relaxed text-ink-900 dark:text-ink-50" 268 > ··· 271 272 <!-- Video --> 273 {#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"> 275 <video 276 use:setupVideo={postData.videoUrl} 277 controls ··· 289 <!-- Images --> 290 {#if postData.hasImages && postData.imageUrls && postData.imageUrls.length > 0} 291 <div 292 - class="{isReplyParent ? 'mb-2' : 'mb-3'} grid max-w-full gap-1 {postData.imageUrls.length === 1 293 ? 'grid-cols-1' 294 : postData.imageUrls.length === 2 295 ? 'grid-cols-2' ··· 299 > 300 {#each postData.imageUrls as imageUrl, index} 301 <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}`} 307 > 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> 322 {/each} 323 </div> 324 {/if} ··· 329 href={postData.externalLink.uri} 330 target="_blank" 331 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" 333 > 334 {#if postData.externalLink.thumb} 335 <img ··· 341 {/if} 342 <div class="p-3"> 343 <h3 344 - class="mb-1 overflow-wrap-anywhere break-words text-sm font-semibold text-ink-900 dark:text-ink-50 line-clamp-2" 345 > 346 {postData.externalLink.title} 347 </h3> 348 {#if postData.externalLink.description} 349 <p 350 - class="mb-2 overflow-wrap-anywhere break-words text-xs text-ink-700 dark:text-ink-300 line-clamp-2" 351 > 352 {postData.externalLink.description} 353 </p> 354 {/if} 355 - <p class="overflow-wrap-anywhere break-words text-xs text-ink-600 dark:text-ink-400"> 356 {new URL(postData.externalLink.uri).hostname} 357 </p> 358 </div> ··· 361 362 <!-- Recursively render quoted post --> 363 {#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"> 365 {@render postContent(postData.quotedPost, depth + 1, depth === 0)} 366 </div> 367 {/if} 368 369 <!-- Engagement Stats (only for non-reply-parent posts) --> 370 {#if !isReplyParent} 371 - <div class="flex flex-wrap items-center gap-3 sm:gap-6 text-xs sm:text-sm pt-1"> 372 {#if postData.replyCount !== undefined} 373 - <div class="flex items-center gap-1 sm:gap-1.5 text-ink-600 dark:text-ink-400"> 374 <MessageCircle class="h-3.5 w-3.5 sm:h-4 sm:w-4" aria-hidden="true" /> 375 <span class="font-medium">{formatCompactNumber(postData.replyCount, locale)}</span> 376 </div> 377 {/if} 378 379 {#if postData.repostCount !== undefined} 380 - <div class="flex items-center gap-1 sm:gap-1.5 text-ink-600 dark:text-ink-400"> 381 <Repeat2 class="h-3.5 w-3.5 sm:h-4 sm:w-4" aria-hidden="true" /> 382 <span class="font-medium">{formatCompactNumber(postData.repostCount, locale)}</span> 383 </div> 384 {/if} 385 386 {#if postData.likeCount !== undefined} 387 - <div class="flex items-center gap-1 sm:gap-1.5 text-ink-600 dark:text-ink-400"> 388 <Heart class="h-3.5 w-3.5 sm:h-4 sm:w-4" aria-hidden="true" /> 389 <span class="font-medium">{formatCompactNumber(postData.likeCount, locale)}</span> 390 </div> ··· 440 {:else if error} 441 <Card error={true} errorMessage={error} /> 442 {: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"> 444 <!-- 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"> 448 Latest Bluesky Post 449 </span> 450 {#if post.isRepost && post.repostAuthor} 451 - <span class="hidden sm:inline text-xs text-ink-600 dark:text-ink-400">·</span> 452 <div class="flex items-center gap-1.5 text-xs text-ink-600 dark:text-ink-400"> 453 <Repeat2 class="h-3 w-3 shrink-0" aria-hidden="true" /> 454 <a 455 href={getProfileUrl(post.repostAuthor.handle)} 456 target="_blank" 457 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" 459 > 460 {post.repostAuthor.displayName || post.repostAuthor.handle} 461 </a> 462 <span class="whitespace-nowrap">reposted</span> 463 </div> 464 {:else if post.replyParent} 465 - <span class="hidden sm:inline text-xs text-ink-600 dark:text-ink-400">·</span> 466 <div class="flex items-center gap-1.5 text-xs text-ink-600 dark:text-ink-400"> 467 <MessageCircle class="h-3 w-3 shrink-0" aria-hidden="true" /> 468 <span class="whitespace-nowrap">Replying to</span> ··· 470 href={getProfileUrl(post.replyParent.author.handle)} 471 target="_blank" 472 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" 474 > 475 @{post.replyParent.author.handle} 476 </a> ··· 481 href={getPostUrl(post.uri)} 482 target="_blank" 483 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" 485 aria-label="View post on Bluesky" 486 > 487 <ExternalLink class="h-4 w-4" aria-hidden="true" /> ··· 490 491 <!-- Reply Context --> 492 {#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"> 494 {@render postContent(post.replyParent, 0, true)} 495 </div> 496 {/if} ··· 522 <button 523 type="button" 524 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" 526 aria-label="Close" 527 > 528 <X class="h-5 w-5 sm:h-6 sm:w-6" /> 529 </button> 530 - <div class="relative flex max-h-[90vh] w-full max-w-[95vw] sm:max-w-[90vw] flex-col items-center"> 531 <img 532 src={lightboxImage.url} 533 alt={lightboxImage.alt} 534 title={lightboxImage.alt} 535 - class="max-h-[75vh] sm:max-h-[80vh] w-full object-contain" 536 loading="lazy" 537 /> 538 {#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);"> 540 {lightboxImage.alt} 541 </div> 542 {/if}
··· 93 94 for (const facet of sortedFacets) { 95 const { byteStart, byteEnd } = facet.index; 96 + 97 // Extract text before facet 98 if (lastByteIndex < byteStart) { 99 const beforeBytes = bytes.slice(lastByteIndex, byteStart); 100 result += escapeHtml(decoder.decode(beforeBytes)); 101 } 102 + 103 // Extract facet text 104 const facetBytes = bytes.slice(byteStart, byteEnd); 105 const facetText = decoder.decode(facetBytes); ··· 198 href={getProfileUrl(postData.author.handle)} 199 target="_blank" 200 rel="noopener noreferrer" 201 + class="shrink-0 transition-opacity hover:opacity-80" 202 > 203 {#if postData.author.avatar} 204 <img 205 src={postData.author.avatar} 206 alt={postData.author.displayName || postData.author.handle} 207 + class="h-8 w-8 rounded-full object-cover sm:h-10 sm:w-10" 208 loading="lazy" 209 /> 210 {:else} 211 <div 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 > 214 + <span 215 + class="text-sm font-semibold text-primary-700 sm:text-base dark:text-primary-300" 216 + > 217 {(postData.author.displayName || postData.author.handle).charAt(0).toUpperCase()} 218 </span> 219 </div> ··· 224 href={getProfileUrl(postData.author.handle)} 225 target="_blank" 226 rel="noopener noreferrer" 227 + class="shrink-0 transition-opacity hover:opacity-80" 228 > 229 {#if postData.author.avatar} 230 <img 231 src={postData.author.avatar} 232 alt={postData.author.displayName || postData.author.handle} 233 + class="h-10 w-10 rounded-full object-cover sm:h-12 sm:w-12" 234 loading="lazy" 235 /> 236 {:else} 237 <div 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" 239 > 240 + <span 241 + class="text-base font-semibold text-primary-700 sm:text-lg dark:text-primary-300" 242 + > 243 {(postData.author.displayName || postData.author.handle).charAt(0).toUpperCase()} 244 </span> 245 </div> 246 {/if} 247 </a> 248 {/if} 249 + <div class="min-w-0 flex-1"> 250 <!-- Author name and handle --> 251 <a 252 href={getProfileUrl(postData.author.handle)} ··· 255 class="inline-block {isReplyParent ? 'mb-1' : 'mb-2'} transition-opacity hover:opacity-80" 256 > 257 <div class="flex flex-col"> 258 + <span 259 + class="text-{isReplyParent 260 + ? 'sm' 261 + : 'base'} leading-tight font-semibold text-ink-900 dark:text-ink-50" 262 + > 263 {postData.author.displayName || postData.author.handle} 264 </span> 265 + <span class="text-xs leading-tight text-ink-600 dark:text-ink-400"> 266 @{postData.author.handle} 267 </span> 268 </div> ··· 270 271 <!-- Post Text with Rich Text Support --> 272 <div 273 + class="{isReplyParent 274 + ? 'mb-2' 275 + : 'mb-3'} overflow-wrap-anywhere break-words whitespace-pre-wrap text-{isReplyParent 276 ? 'sm' 277 : 'base'} leading-relaxed text-ink-900 dark:text-ink-50" 278 > ··· 281 282 <!-- Video --> 283 {#if postData.hasVideo && postData.videoUrl} 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 + > 289 <video 290 use:setupVideo={postData.videoUrl} 291 controls ··· 303 <!-- Images --> 304 {#if postData.hasImages && postData.imageUrls && postData.imageUrls.length > 0} 305 <div 306 + class="{isReplyParent ? 'mb-2' : 'mb-3'} grid max-w-full gap-1 {postData.imageUrls 307 + .length === 1 308 ? 'grid-cols-1' 309 : postData.imageUrls.length === 2 310 ? 'grid-cols-2' ··· 314 > 315 {#each postData.imageUrls as imageUrl, index} 316 <button 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}`} 325 > 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> 340 {/each} 341 </div> 342 {/if} ··· 347 href={postData.externalLink.uri} 348 target="_blank" 349 rel="noopener noreferrer" 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" 353 > 354 {#if postData.externalLink.thumb} 355 <img ··· 361 {/if} 362 <div class="p-3"> 363 <h3 364 + class="overflow-wrap-anywhere mb-1 line-clamp-2 text-sm font-semibold break-words text-ink-900 dark:text-ink-50" 365 > 366 {postData.externalLink.title} 367 </h3> 368 {#if postData.externalLink.description} 369 <p 370 + class="overflow-wrap-anywhere mb-2 line-clamp-2 text-xs break-words text-ink-700 dark:text-ink-300" 371 > 372 {postData.externalLink.description} 373 </p> 374 {/if} 375 + <p class="overflow-wrap-anywhere text-xs break-words text-ink-600 dark:text-ink-400"> 376 {new URL(postData.externalLink.uri).hostname} 377 </p> 378 </div> ··· 381 382 <!-- Recursively render quoted post --> 383 {#if postData.quotedPost && depth < 3} 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 + > 389 {@render postContent(postData.quotedPost, depth + 1, depth === 0)} 390 </div> 391 {/if} 392 393 <!-- Engagement Stats (only for non-reply-parent posts) --> 394 {#if !isReplyParent} 395 + <div class="flex flex-wrap items-center gap-3 pt-1 text-xs sm:gap-6 sm:text-sm"> 396 {#if postData.replyCount !== undefined} 397 + <div class="flex items-center gap-1 text-ink-600 sm:gap-1.5 dark:text-ink-400"> 398 <MessageCircle class="h-3.5 w-3.5 sm:h-4 sm:w-4" aria-hidden="true" /> 399 <span class="font-medium">{formatCompactNumber(postData.replyCount, locale)}</span> 400 </div> 401 {/if} 402 403 {#if postData.repostCount !== undefined} 404 + <div class="flex items-center gap-1 text-ink-600 sm:gap-1.5 dark:text-ink-400"> 405 <Repeat2 class="h-3.5 w-3.5 sm:h-4 sm:w-4" aria-hidden="true" /> 406 <span class="font-medium">{formatCompactNumber(postData.repostCount, locale)}</span> 407 </div> 408 {/if} 409 410 {#if postData.likeCount !== undefined} 411 + <div class="flex items-center gap-1 text-ink-600 sm:gap-1.5 dark:text-ink-400"> 412 <Heart class="h-3.5 w-3.5 sm:h-4 sm:w-4" aria-hidden="true" /> 413 <span class="font-medium">{formatCompactNumber(postData.likeCount, locale)}</span> 414 </div> ··· 464 {:else if error} 465 <Card error={true} errorMessage={error} /> 466 {:else if post} 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 + > 470 <!-- Header --> 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 + > 476 Latest Bluesky Post 477 </span> 478 {#if post.isRepost && post.repostAuthor} 479 + <span class="hidden text-xs text-ink-600 sm:inline dark:text-ink-400">·</span> 480 <div class="flex items-center gap-1.5 text-xs text-ink-600 dark:text-ink-400"> 481 <Repeat2 class="h-3 w-3 shrink-0" aria-hidden="true" /> 482 <a 483 href={getProfileUrl(post.repostAuthor.handle)} 484 target="_blank" 485 rel="noopener noreferrer" 486 + class="truncate font-medium text-primary-600 transition-colors hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300" 487 > 488 {post.repostAuthor.displayName || post.repostAuthor.handle} 489 </a> 490 <span class="whitespace-nowrap">reposted</span> 491 </div> 492 {:else if post.replyParent} 493 + <span class="hidden text-xs text-ink-600 sm:inline dark:text-ink-400">·</span> 494 <div class="flex items-center gap-1.5 text-xs text-ink-600 dark:text-ink-400"> 495 <MessageCircle class="h-3 w-3 shrink-0" aria-hidden="true" /> 496 <span class="whitespace-nowrap">Replying to</span> ··· 498 href={getProfileUrl(post.replyParent.author.handle)} 499 target="_blank" 500 rel="noopener noreferrer" 501 + class="truncate font-medium text-primary-600 transition-colors hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300" 502 > 503 @{post.replyParent.author.handle} 504 </a> ··· 509 href={getPostUrl(post.uri)} 510 target="_blank" 511 rel="noopener noreferrer" 512 + class="shrink-0 text-ink-600 transition-colors hover:text-primary-600 dark:text-ink-400 dark:hover:text-primary-400" 513 aria-label="View post on Bluesky" 514 > 515 <ExternalLink class="h-4 w-4" aria-hidden="true" /> ··· 518 519 <!-- Reply Context --> 520 {#if post.replyParent} 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 + > 524 {@render postContent(post.replyParent, 0, true)} 525 </div> 526 {/if} ··· 552 <button 553 type="button" 554 onclick={closeLightbox} 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" 556 aria-label="Close" 557 > 558 <X class="h-5 w-5 sm:h-6 sm:w-6" /> 559 </button> 560 + <div 561 + class="relative flex max-h-[90vh] w-full max-w-[95vw] flex-col items-center sm:max-w-[90vw]" 562 + > 563 <img 564 src={lightboxImage.url} 565 alt={lightboxImage.alt} 566 title={lightboxImage.alt} 567 + class="max-h-[75vh] w-full object-contain sm:max-h-[80vh]" 568 loading="lazy" 569 /> 570 {#if lightboxImage.alt && lightboxImage.alt !== `Post attachment ${lightboxImage.url.split('/').pop()}`} 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 + > 575 {lightboxImage.alt} 576 </div> 577 {/if}
+85 -77
src/lib/components/layout/main/card/LinkCard.svelte
··· 1 <script lang="ts"> 2 - import { ExternalLink } from '@lucide/svelte'; 3 4 - interface Badge { 5 - text: string; 6 - color?: 'mint' | 'sage'; 7 - } 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 - } 18 19 - let { url, title, emoji, description, badges, meta, variant = 'default' }: Props = $props(); 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 - } 29 30 - const displayDescription = description || getDomain(url); 31 </script> 32 33 <a 34 - href={url} 35 - target="_blank" 36 - rel="noopener noreferrer" 37 - class=" 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'} 41 " 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} 72 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> 75 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} 81 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> 88 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} 94 </a>
··· 1 <script lang="ts"> 2 + import { ExternalLink } from '@lucide/svelte'; 3 4 + interface Badge { 5 + text: string; 6 + color?: 'mint' | 'sage'; 7 + } 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 + } 18 19 + let { url, title, emoji, description, badges, meta, variant = 'default' }: Props = $props(); 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 + } 29 30 + const displayDescription = description || getDomain(url); 31 </script> 32 33 <a 34 + href={url} 35 + target="_blank" 36 + rel="noopener noreferrer" 37 + class=" 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'} 41 " 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 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} 76 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> 81 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} 89 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> 96 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} 102 </a>
+24 -24
src/lib/components/layout/main/card/MusicStatusCard.svelte
··· 5 import { formatRelativeTime } from '$lib/utils/formatDate'; 6 7 // Icons 8 - import { 9 - Music, 10 - Disc3, 11 - Users, 12 - Album, 13 - Clock, 14 - Radio 15 - } from '@lucide/svelte'; 16 17 let musicStatus: MusicStatusData | null = null; 18 let loading = true; ··· 37 38 function formatArtists(artists: { artistName: string }[]): string { 39 if (!artists || artists.length === 0) return 'Unknown Artist'; 40 - return artists.map(a => a.artistName).join(', '); 41 } 42 43 function formatDuration(seconds?: number): string { ··· 76 </div> 77 {/snippet} 78 </Card> 79 - 80 {:else if error} 81 <Card error={true} errorMessage={error} /> 82 - 83 {:else if musicStatus} 84 {@const safeMusicStatus = musicStatus} 85 <Card variant="elevated" padding="md"> 86 {#snippet children()} 87 <div class="flex items-start gap-4"> 88 - 89 <!-- Artwork --> 90 <div class="flex-shrink-0"> 91 {#if safeMusicStatus.artworkUrl && !artworkError} ··· 97 onerror={handleImageError} 98 /> 99 {:else} 100 - <div class="h-20 w-20 rounded-lg bg-canvas-200 dark:bg-canvas-700 flex items-center justify-center shadow-md"> 101 <Disc3 class="h-10 w-10 text-ink-500 dark:text-ink-400" aria-hidden="true" /> 102 </div> 103 {/if} 104 </div> 105 106 <!-- Info --> 107 - <div class="flex-1 min-w-0"> 108 <!-- Header (Now Listening / Last Played) --> 109 <div class="mb-2 flex items-center gap-2"> 110 <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"> 112 {safeMusicStatus.$type === 'fm.teal.alpha.actor.status' 113 ? 'Now Listening' 114 : 'Last Played'} ··· 117 118 <!-- Content --> 119 <div class="mb-2"> 120 - 121 <!-- Track Name --> 122 <a 123 href={safeMusicStatus.originUrl || '#'} 124 target="_blank" 125 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" 127 class:pointer-events-none={!safeMusicStatus.originUrl} 128 class:cursor-default={!safeMusicStatus.originUrl} 129 class:opacity-70={!safeMusicStatus.originUrl} ··· 132 </a> 133 134 <!-- 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" /> 137 {formatArtists(safeMusicStatus.artists)} 138 </p> 139 140 <!-- Album + Duration --> 141 {#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" /> 144 <span> 145 {safeMusicStatus.releaseName} 146 147 {#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)} 150 </span> 151 {/if} 152 </span> ··· 167 href="https://teal.fm" 168 target="_blank" 169 rel="noopener noreferrer" 170 - class="inline-flex items-center gap-1 hover:text-primary-600 dark:hover:text-primary-400 transition-colors" 171 title="Powered by teal.fm" 172 > 173 <Radio class="h-3 w-3" />
··· 5 import { formatRelativeTime } from '$lib/utils/formatDate'; 6 7 // Icons 8 + import { Music, Disc3, Users, Album, Clock, Radio } from '@lucide/svelte'; 9 10 let musicStatus: MusicStatusData | null = null; 11 let loading = true; ··· 30 31 function formatArtists(artists: { artistName: string }[]): string { 32 if (!artists || artists.length === 0) return 'Unknown Artist'; 33 + return artists.map((a) => a.artistName).join(', '); 34 } 35 36 function formatDuration(seconds?: number): string { ··· 69 </div> 70 {/snippet} 71 </Card> 72 {:else if error} 73 <Card error={true} errorMessage={error} /> 74 {:else if musicStatus} 75 {@const safeMusicStatus = musicStatus} 76 <Card variant="elevated" padding="md"> 77 {#snippet children()} 78 <div class="flex items-start gap-4"> 79 <!-- Artwork --> 80 <div class="flex-shrink-0"> 81 {#if safeMusicStatus.artworkUrl && !artworkError} ··· 87 onerror={handleImageError} 88 /> 89 {:else} 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 + > 93 <Disc3 class="h-10 w-10 text-ink-500 dark:text-ink-400" aria-hidden="true" /> 94 </div> 95 {/if} 96 </div> 97 98 <!-- Info --> 99 + <div class="min-w-0 flex-1"> 100 <!-- Header (Now Listening / Last Played) --> 101 <div class="mb-2 flex items-center gap-2"> 102 <Music class="h-4 w-4 text-primary-600 dark:text-primary-400" aria-hidden="true" /> 103 + <span 104 + class="text-xs font-semibold tracking-wide text-ink-800 uppercase dark:text-ink-100" 105 + > 106 {safeMusicStatus.$type === 'fm.teal.alpha.actor.status' 107 ? 'Now Listening' 108 : 'Last Played'} ··· 111 112 <!-- Content --> 113 <div class="mb-2"> 114 <!-- Track Name --> 115 <a 116 href={safeMusicStatus.originUrl || '#'} 117 target="_blank" 118 rel="noopener noreferrer" 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" 120 class:pointer-events-none={!safeMusicStatus.originUrl} 121 class:cursor-default={!safeMusicStatus.originUrl} 122 class:opacity-70={!safeMusicStatus.originUrl} ··· 125 </a> 126 127 <!-- Artists --> 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" /> 132 {formatArtists(safeMusicStatus.artists)} 133 </p> 134 135 <!-- Album + Duration --> 136 {#if safeMusicStatus.releaseName} 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" /> 141 <span> 142 {safeMusicStatus.releaseName} 143 144 {#if 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 </span> 151 {/if} 152 </span> ··· 167 href="https://teal.fm" 168 target="_blank" 169 rel="noopener noreferrer" 170 + class="inline-flex items-center gap-1 transition-colors hover:text-primary-600 dark:hover:text-primary-400" 171 title="Powered by teal.fm" 172 > 173 <Radio class="h-3 w-3" />
+10 -4
src/lib/components/layout/main/card/PostCard.svelte
··· 103 {/if} 104 105 <!-- Title --> 106 - <h3 class="overflow-wrap-anywhere break-words font-semibold text-ink-900 dark:text-ink-50">{post.title}</h3> 107 108 <!-- Description --> 109 {#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> 113 {/if} 114 115 <!-- Timestamp -->
··· 103 {/if} 104 105 <!-- Title --> 106 + <h3 107 + class="overflow-wrap-anywhere font-semibold break-words text-ink-900 dark:text-ink-50" 108 + > 109 + {post.title} 110 + </h3> 111 112 <!-- Description --> 113 {#if post.description} 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> 119 {/if} 120 121 <!-- Timestamp -->
+5 -3
src/lib/components/layout/main/card/ProfileCard.svelte
··· 108 <p class="font-medium text-ink-700 dark:text-ink-200">@{safeProfile.handle}</p> 109 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> 114 {/if} 115 116 <div class="flex gap-6 text-sm font-medium">
··· 108 <p class="font-medium text-ink-700 dark:text-ink-200">@{safeProfile.handle}</p> 109 110 {#if safeProfile.description} 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> 116 {/if} 117 118 <div class="flex gap-6 text-sm font-medium">
+5 -5
src/lib/components/layout/main/card/TangledRepoCard.svelte
··· 35 rel="noopener noreferrer" 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 > 38 - <div class="flex items-center gap-3 min-w-0 flex-1"> 39 <GitBranch 40 class="h-5 w-5 flex-shrink-0 text-primary-600 dark:text-primary-400" 41 aria-hidden="true" 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"> 45 {repo.name} 46 </h3> 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"> 49 <Server class="h-3 w-3 flex-shrink-0" aria-hidden="true" /> 50 <span class="truncate">{getKnotServerName(repo.knot)}</span> 51 </div> 52 - <div class="flex items-center gap-1 min-w-0"> 53 <User class="h-3 w-3 flex-shrink-0" aria-hidden="true" /> 54 <span class="truncate">{handle || PUBLIC_ATPROTO_DID}</span> 55 </div>
··· 35 rel="noopener noreferrer" 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 > 38 + <div class="flex min-w-0 flex-1 items-center gap-3"> 39 <GitBranch 40 class="h-5 w-5 flex-shrink-0 text-primary-600 dark:text-primary-400" 41 aria-hidden="true" 42 /> 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 {repo.name} 46 </h3> 47 <div class="flex flex-wrap items-center gap-3 text-xs text-ink-700 dark:text-ink-200"> 48 + <div class="flex min-w-0 items-center gap-1"> 49 <Server class="h-3 w-3 flex-shrink-0" aria-hidden="true" /> 50 <span class="truncate">{getKnotServerName(repo.knot)}</span> 51 </div> 52 + <div class="flex min-w-0 items-center gap-1"> 53 <User class="h-3 w-3 flex-shrink-0" aria-hidden="true" /> 54 <span class="truncate">{handle || PUBLIC_ATPROTO_DID}</span> 55 </div>
+11 -11
src/lib/config/slugs.ts
··· 2 3 /** 4 * Normalize a slug to be URI-compatible 5 - * 6 * Transformations: 7 * - Convert to lowercase 8 * - Replace spaces with hyphens 9 * - Remove all characters except alphanumeric, hyphens, and underscores 10 * - Collapse multiple hyphens into single hyphen 11 * - Remove leading/trailing hyphens 12 - * 13 * @param slug - The slug to normalize 14 * @returns URI-compatible slug 15 - * 16 * @example 17 * normalizeSlug('My Blog Post!') // 'my-blog-post' 18 * normalizeSlug('Hello World') // 'hello-world' ··· 31 /** 32 * Get publication rkey from slug 33 * Automatically normalizes the slug before lookup 34 - * 35 * @param slug - The slug to look up (will be normalized) 36 * @returns The publication rkey or null if not found 37 */ 38 export function getPublicationRkeyFromSlug(slug: string): string | null { 39 const normalizedSlug = normalizeSlug(slug); 40 - const mapping = slugMappings.find(m => normalizeSlug(m.slug) === normalizedSlug); 41 return mapping?.publicationRkey || null; 42 } 43 44 /** 45 * Get slug from publication rkey 46 - * 47 * @param rkey - The publication rkey 48 * @returns The slug or null if not found 49 */ 50 export function getSlugFromPublicationRkey(rkey: string): string | null { 51 - const mapping = slugMappings.find(m => m.publicationRkey === rkey); 52 return mapping?.slug || null; 53 } 54 55 /** 56 * Get all configured slugs (normalized) 57 - * 58 * @returns Array of normalized slugs 59 */ 60 export function getAllSlugs(): string[] { 61 - return slugMappings.map(m => normalizeSlug(m.slug)); 62 } 63 64 /** 65 * Get all slug mappings with normalized slugs 66 - * 67 * @returns Array of slug mappings with normalized slugs 68 */ 69 export function getAllSlugMappings(): SlugMapping[] { 70 - return slugMappings.map(m => ({ 71 ...m, 72 slug: normalizeSlug(m.slug) 73 }));
··· 2 3 /** 4 * Normalize a slug to be URI-compatible 5 + * 6 * Transformations: 7 * - Convert to lowercase 8 * - Replace spaces with hyphens 9 * - Remove all characters except alphanumeric, hyphens, and underscores 10 * - Collapse multiple hyphens into single hyphen 11 * - Remove leading/trailing hyphens 12 + * 13 * @param slug - The slug to normalize 14 * @returns URI-compatible slug 15 + * 16 * @example 17 * normalizeSlug('My Blog Post!') // 'my-blog-post' 18 * normalizeSlug('Hello World') // 'hello-world' ··· 31 /** 32 * Get publication rkey from slug 33 * Automatically normalizes the slug before lookup 34 + * 35 * @param slug - The slug to look up (will be normalized) 36 * @returns The publication rkey or null if not found 37 */ 38 export function getPublicationRkeyFromSlug(slug: string): string | null { 39 const normalizedSlug = normalizeSlug(slug); 40 + const mapping = slugMappings.find((m) => normalizeSlug(m.slug) === normalizedSlug); 41 return mapping?.publicationRkey || null; 42 } 43 44 /** 45 * Get slug from publication rkey 46 + * 47 * @param rkey - The publication rkey 48 * @returns The slug or null if not found 49 */ 50 export function getSlugFromPublicationRkey(rkey: string): string | null { 51 + const mapping = slugMappings.find((m) => m.publicationRkey === rkey); 52 return mapping?.slug || null; 53 } 54 55 /** 56 * Get all configured slugs (normalized) 57 + * 58 * @returns Array of normalized slugs 59 */ 60 export function getAllSlugs(): string[] { 61 + return slugMappings.map((m) => normalizeSlug(m.slug)); 62 } 63 64 /** 65 * Get all slug mappings with normalized slugs 66 + * 67 * @returns Array of slug mappings with normalized slugs 68 */ 69 export function getAllSlugMappings(): SlugMapping[] { 70 + return slugMappings.map((m) => ({ 71 ...m, 72 slug: normalizeSlug(m.slug) 73 }));
+4 -4
src/lib/data/navItems.ts
··· 1 export interface NavItem { 2 href: string; 3 label: string; 4 - // The property holds the Lucide component name (e.g., 'Home') 5 iconPath: string; 6 } 7 8 export const navItems: NavItem[] = [ 9 - { href: '/', label: 'Home', iconPath: 'Home' }, 10 - { href: '/site/meta', label: 'Site Meta', iconPath: 'Info' } 11 - ];
··· 1 export interface NavItem { 2 href: string; 3 label: string; 4 + // The property holds the Lucide component name (e.g., 'Home') 5 iconPath: string; 6 } 7 8 export const navItems: NavItem[] = [ 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 /** 2 * Slug to Leaflet Publication mapping data 3 - * 4 * Maps friendly URL slugs to Leaflet publication rkeys. 5 * This allows you to access publications via /{slug} instead of /blog 6 - * 7 * Example: 8 * - /blog → maps to publication with rkey "3m3x4bgbsh22k" 9 * - /notes → maps to publication with rkey "xyz123abc" ··· 19 /** 20 * Slug to publication rkey mappings 21 * Add your custom mappings here 22 - * 23 * Note: Slugs will be automatically normalized to be URI-compatible: 24 * - Converted to lowercase 25 * - Spaces converted to hyphens
··· 1 /** 2 * Slug to Leaflet Publication mapping data 3 + * 4 * Maps friendly URL slugs to Leaflet publication rkeys. 5 * This allows you to access publications via /{slug} instead of /blog 6 + * 7 * Example: 8 * - /blog → maps to publication with rkey "3m3x4bgbsh22k" 9 * - /notes → maps to publication with rkey "xyz123abc" ··· 19 /** 20 * Slug to publication rkey mappings 21 * Add your custom mappings here 22 + * 23 * Note: Slugs will be automatically normalized to be URI-compatible: 24 * - Converted to lowercase 25 * - Spaces converted to hyphens
+1 -1
src/lib/helper/index.ts
··· 1 export * from './siteMeta'; 2 export * from './ogImages'; 3 - export * from './metaTags';
··· 1 export * from './siteMeta'; 2 export * from './ogImages'; 3 + export * from './metaTags';
+7 -3
src/lib/helper/metaTags.ts
··· 25 { property: 'og:description', content: finalMeta.description }, 26 { property: 'og:site_name', content: defaults.title }, // always site title for OG 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() }] : []), 30 31 { name: 'twitter:card', content: 'summary_large_image' }, 32 { name: 'twitter:url', content: finalMeta.url }, ··· 34 { name: 'twitter:description', content: finalMeta.description }, 35 { name: 'twitter:image', content: finalMeta.image } 36 ]; 37 - }
··· 25 { property: 'og:description', content: finalMeta.description }, 26 { property: 'og:site_name', content: defaults.title }, // always site title for OG 27 { property: 'og:image', content: finalMeta.image }, 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 + : []), 34 35 { name: 'twitter:card', content: 'summary_large_image' }, 36 { name: 'twitter:url', content: finalMeta.url }, ··· 38 { name: 'twitter:description', content: finalMeta.description }, 39 { name: 'twitter:image', content: finalMeta.image } 40 ]; 41 + }
+4 -4
src/lib/helper/ogImages.ts
··· 1 /** 2 * OG (Open Graph) image paths 3 - * 4 * These images are served from the ./static/og/ directory. 5 * They are accessible at /og/ URLs in the application. 6 - * 7 * To add new OG images: 8 * 1. Place the image in ./static/og/ 9 * 2. Add the path here as /og/filename.png 10 */ 11 export const ogImages: Record<string, string> = { 12 - main: '/og/main.png', 13 - siteMeta: '/og/site-meta.png' 14 };
··· 1 /** 2 * OG (Open Graph) image paths 3 + * 4 * These images are served from the ./static/og/ directory. 5 * They are accessible at /og/ URLs in the application. 6 + * 7 * To add new OG images: 8 * 1. Place the image in ./static/og/ 9 * 2. Add the path here as /og/filename.png 10 */ 11 export const ogImages: Record<string, string> = { 12 + main: '/og/main.png', 13 + siteMeta: '/og/site-meta.png' 14 };
+22 -22
src/lib/helper/siteMeta.ts
··· 1 import { ogImages } from '$lib/helper/ogImages'; 2 import { 3 - PUBLIC_SITE_TITLE, 4 - PUBLIC_SITE_DESCRIPTION, 5 - PUBLIC_SITE_KEYWORDS, 6 - PUBLIC_SITE_URL 7 } from '$env/static/public'; 8 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; 17 } 18 19 /** ··· 21 * Can be overridden dynamically for each page or component. 22 */ 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 31 }; 32 33 /** ··· 35 * Merges defaults with any overrides provided. 36 */ 37 export function createSiteMeta(overrides: Partial<SiteMetadata> = {}): SiteMetadata { 38 - return { 39 - ...defaultSiteMeta, 40 - ...overrides 41 - }; 42 }
··· 1 import { ogImages } from '$lib/helper/ogImages'; 2 import { 3 + PUBLIC_SITE_TITLE, 4 + PUBLIC_SITE_DESCRIPTION, 5 + PUBLIC_SITE_KEYWORDS, 6 + PUBLIC_SITE_URL 7 } from '$env/static/public'; 8 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; 17 } 18 19 /** ··· 21 * Can be overridden dynamically for each page or component. 22 */ 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 31 }; 32 33 /** ··· 35 * Merges defaults with any overrides provided. 36 */ 37 export function createSiteMeta(overrides: Partial<SiteMetadata> = {}): SiteMetadata { 38 + return { 39 + ...defaultSiteMeta, 40 + ...overrides 41 + }; 42 }
+34 -31
src/lib/services/atproto/agents.ts
··· 5 * Creates an AtpAgent with optional fetch function injection 6 */ 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); 15 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; 28 29 - return new AtpAgent({ 30 - service, 31 - ...(wrappedFetch && { fetch: wrappedFetch }) 32 - }); 33 } 34 35 // Primary Microcosm Constellation endpoint ··· 62 63 if (!response.ok) { 64 console.error(`[Identity] Resolution failed: ${response.status} ${response.statusText}`); 65 - throw new Error(`Failed to resolve identifier via Slingshot: ${response.status} ${response.statusText}`); 66 } 67 68 // Some fetch implementations in Node (undici wrappers) can throw when calling Response.clone(). ··· 108 console.warn('[Agent] Constellation endpoint unreachable:', constellationErr); 109 } 110 111 - // Then try Slingshot for PDS resolution 112 - console.info('[Agent] Attempting Slingshot resolution'); 113 - const resolved = await resolveIdentity(did, fetchFn); 114 console.info(`[Agent] Resolved PDS endpoint: ${resolved.pds}`); 115 resolvedAgent = createAgent(resolved.pds, fetchFn); 116 return resolvedAgent; ··· 119 resolvedAgent = defaultAgent; 120 return resolvedAgent; 121 } 122 - }/** 123 * Gets or creates a PDS-specific agent 124 */ 125 export async function getPDSAgent(did: string, fetchFn?: typeof fetch): Promise<AtpAgent> { ··· 147 usePDSFirst = false, 148 fetchFn?: typeof fetch 149 ): Promise<T> { 150 - const defaultAgentFn = () => fetchFn 151 - ? createAgent('https://public.api.bsky.app', fetchFn) 152 - : Promise.resolve(defaultAgent); 153 154 const agents = usePDSFirst 155 ? [() => getPDSAgent(did, fetchFn), defaultAgentFn]
··· 5 * Creates an AtpAgent with optional fetch function injection 6 */ 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 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 + } 22 23 + return new Response(response.body, { 24 + status: response.status, 25 + statusText: response.statusText, 26 + headers 27 + }); 28 + } 29 + : undefined; 30 31 + return new AtpAgent({ 32 + service, 33 + ...(wrappedFetch && { fetch: wrappedFetch }) 34 + }); 35 } 36 37 // Primary Microcosm Constellation endpoint ··· 64 65 if (!response.ok) { 66 console.error(`[Identity] Resolution failed: ${response.status} ${response.statusText}`); 67 + throw new Error( 68 + `Failed to resolve identifier via Slingshot: ${response.status} ${response.statusText}` 69 + ); 70 } 71 72 // Some fetch implementations in Node (undici wrappers) can throw when calling Response.clone(). ··· 112 console.warn('[Agent] Constellation endpoint unreachable:', constellationErr); 113 } 114 115 + // Then try Slingshot for PDS resolution 116 + console.info('[Agent] Attempting Slingshot resolution'); 117 + const resolved = await resolveIdentity(did, fetchFn); 118 console.info(`[Agent] Resolved PDS endpoint: ${resolved.pds}`); 119 resolvedAgent = createAgent(resolved.pds, fetchFn); 120 return resolvedAgent; ··· 123 resolvedAgent = defaultAgent; 124 return resolvedAgent; 125 } 126 + } /** 127 * Gets or creates a PDS-specific agent 128 */ 129 export async function getPDSAgent(did: string, fetchFn?: typeof fetch): Promise<AtpAgent> { ··· 151 usePDSFirst = false, 152 fetchFn?: typeof fetch 153 ): Promise<T> { 154 + const defaultAgentFn = () => 155 + fetchFn ? createAgent('https://public.api.bsky.app', fetchFn) : Promise.resolve(defaultAgent); 156 157 const agents = usePDSFirst 158 ? [() => getPDSAgent(did, fetchFn), defaultAgentFn]
+58 -61
src/lib/services/atproto/engagement.ts
··· 3 export type EngagementType = 'app.bsky.feed.like' | 'app.bsky.feed.repost'; 4 5 interface EngagementResponse { 6 - dids: string[]; 7 - cursor?: string; 8 } 9 10 /** 11 * Fetches engagement data (likes/reposts) for a post from Constellation as a fallback 12 */ 13 export async function fetchEngagementFromConstellation( 14 - uri: string, 15 - type: EngagementType, 16 - cursor?: string 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 - } 26 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 - } 36 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 47 - const result: EngagementResponse = { 48 - dids: data.dids || [], 49 - cursor: data.cursor 50 - }; 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 - } 59 } 60 61 /** 62 * Fetches all engagement data by paginating through results 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; 72 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); 79 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 - }
··· 3 export type EngagementType = 'app.bsky.feed.like' | 'app.bsky.feed.repost'; 4 5 interface EngagementResponse { 6 + dids: string[]; 7 + cursor?: string; 8 } 9 10 /** 11 * Fetches engagement data (likes/reposts) for a post from Constellation as a fallback 12 */ 13 export async function fetchEngagementFromConstellation( 14 + uri: string, 15 + type: EngagementType, 16 + cursor?: string 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 + } 26 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 + } 36 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 47 + const result: EngagementResponse = { 48 + dids: data.dids || [], 49 + cursor: data.cursor 50 + }; 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 + } 59 } 60 61 /** 62 * Fetches all engagement data by paginating through results 63 */ 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; 69 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); 76 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 155 try { 156 console.info('[MusicStatus] Cache miss, fetching from network'); 157 - 158 // Try the actor status collection first (shorter-lived status) 159 try { 160 const statusRecords = await withFallback( ··· 174 if (statusRecords && statusRecords.length > 0) { 175 const record = statusRecords[0]; 176 const value = record.value as any; 177 - 178 // Check if status is still valid (not expired) 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 - } 215 } 216 217 const data: MusicStatusData = { 218 trackName: value.item?.trackName || value.trackName, ··· 224 releaseMbId: value.item?.releaseMbId || value.releaseMbId, 225 isrc: value.isrc, 226 duration: value.duration, 227 - musicServiceBaseDomain: value.item?.musicServiceBaseDomain || value.musicServiceBaseDomain, 228 - submissionClientAgent: value.item?.submissionClientAgent || value.submissionClientAgent, 229 $type: 'fm.teal.alpha.actor.status', 230 expiry: value.expiry, 231 artwork: value.item?.artwork || value.artwork, ··· 259 if (playRecords && playRecords.length > 0) { 260 const record = playRecords[0]; 261 const value = record.value as any; 262 - 263 // Build artwork URL - prioritize album art over individual track art 264 let artworkUrl: string | undefined; 265 const trackName = value.trackName; ··· 267 const releaseName = value.releaseName; 268 const artistName = artists[0]?.artistName; 269 const releaseMbId = value.releaseMbId; 270 - 271 - console.debug('[MusicStatus] Looking for artwork:', { trackName, artistName, releaseName, releaseMbId }); 272 - 273 // Priority 1: If we have album info, search for album art (more accurate) 274 if (releaseName && artistName) { 275 console.info('[MusicStatus] Prioritizing album artwork search'); 276 - artworkUrl = await findArtwork(releaseName, artistName, releaseName, releaseMbId) || undefined; 277 } 278 - 279 // Priority 2: Fall back to track-based search if album search failed 280 if (!artworkUrl && trackName && artistName) { 281 console.info('[MusicStatus] Falling back to track-based artwork search'); 282 - artworkUrl = await findArtwork(trackName, artistName, releaseName, releaseMbId) || undefined; 283 } 284 - 285 // Priority 3: Final fallback to atproto blob if no external artwork found 286 if (!artworkUrl) { 287 const artwork = value.artwork; ··· 292 console.info('[MusicStatus] Using atproto blob artwork URL:', artworkUrl); 293 } 294 } 295 - 296 const data: MusicStatusData = { 297 trackName: value.trackName, 298 artists: value.artists || [],
··· 154 155 try { 156 console.info('[MusicStatus] Cache miss, fetching from network'); 157 + 158 // Try the actor status collection first (shorter-lived status) 159 try { 160 const statusRecords = await withFallback( ··· 174 if (statusRecords && statusRecords.length > 0) { 175 const record = statusRecords[0]; 176 const value = record.value as any; 177 + 178 // Check if status is still valid (not expired) 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:', { 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); 224 } 225 + } 226 227 const data: MusicStatusData = { 228 trackName: value.item?.trackName || value.trackName, ··· 234 releaseMbId: value.item?.releaseMbId || value.releaseMbId, 235 isrc: value.isrc, 236 duration: value.duration, 237 + musicServiceBaseDomain: 238 + value.item?.musicServiceBaseDomain || value.musicServiceBaseDomain, 239 + submissionClientAgent: 240 + value.item?.submissionClientAgent || value.submissionClientAgent, 241 $type: 'fm.teal.alpha.actor.status', 242 expiry: value.expiry, 243 artwork: value.item?.artwork || value.artwork, ··· 271 if (playRecords && playRecords.length > 0) { 272 const record = playRecords[0]; 273 const value = record.value as any; 274 + 275 // Build artwork URL - prioritize album art over individual track art 276 let artworkUrl: string | undefined; 277 const trackName = value.trackName; ··· 279 const releaseName = value.releaseName; 280 const artistName = artists[0]?.artistName; 281 const releaseMbId = value.releaseMbId; 282 + 283 + console.debug('[MusicStatus] Looking for artwork:', { 284 + trackName, 285 + artistName, 286 + releaseName, 287 + releaseMbId 288 + }); 289 + 290 // Priority 1: If we have album info, search for album art (more accurate) 291 if (releaseName && artistName) { 292 console.info('[MusicStatus] Prioritizing album artwork search'); 293 + artworkUrl = 294 + (await findArtwork(releaseName, artistName, releaseName, releaseMbId)) || undefined; 295 } 296 + 297 // Priority 2: Fall back to track-based search if album search failed 298 if (!artworkUrl && trackName && artistName) { 299 console.info('[MusicStatus] Falling back to track-based artwork search'); 300 + artworkUrl = 301 + (await findArtwork(trackName, artistName, releaseName, releaseMbId)) || undefined; 302 } 303 + 304 // Priority 3: Final fallback to atproto blob if no external artwork found 305 if (!artworkUrl) { 306 const artwork = value.artwork; ··· 311 console.info('[MusicStatus] Using atproto blob artwork URL:', artworkUrl); 312 } 313 } 314 + 315 const data: MusicStatusData = { 316 trackName: value.trackName, 317 artists: value.artists || [],
+5 -5
src/lib/services/atproto/index.ts
··· 1 /** 2 * Unified AT Protocol service exports 3 - * 4 * This module provides a clean API for interacting with AT Protocol services, 5 * including profile data, blog posts, Bluesky posts, and custom lexicons. 6 */ ··· 51 52 export { resolveIdentity, withFallback, resetAgents } from './agents'; 53 54 - export { 55 - searchMusicBrainzRelease, 56 - buildCoverArtUrl, 57 searchiTunesArtwork, 58 searchDeezerArtwork, 59 searchLastFmArtwork, 60 - findArtwork 61 } from './musicbrainz'; 62 63 // Export cache for advanced use cases
··· 1 /** 2 * Unified AT Protocol service exports 3 + * 4 * This module provides a clean API for interacting with AT Protocol services, 5 * including profile data, blog posts, Bluesky posts, and custom lexicons. 6 */ ··· 51 52 export { resolveIdentity, withFallback, resetAgents } from './agents'; 53 54 + export { 55 + searchMusicBrainzRelease, 56 + buildCoverArtUrl, 57 searchiTunesArtwork, 58 searchDeezerArtwork, 59 searchLastFmArtwork, 60 + findArtwork 61 } from './musicbrainz'; 62 63 // Export cache for advanced use cases
+15 -8
src/lib/services/atproto/media.ts
··· 28 * and nested structures. 29 * - also detects 'app.bsky.embed.video' shapes and returns the video blob URL first 30 */ 31 - export function extractImageUrlsFromValue( 32 - value: any, 33 - did: string, 34 - limit = 4 35 - ): string[] { 36 const urls: string[] = []; 37 38 try { ··· 93 } 94 95 // Video in recordWithMedia 96 - if (media && (media.$type === 'app.bsky.embed.video#view' || media.$type === 'app.bsky.embed.video')) { 97 const videoCid = (media as any)?.video?.ref?.$link ?? (media as any)?.video?.cid ?? null; 98 if (videoCid) { 99 const videoUrl = `https://video.bsky.app/watch/${did}/${videoCid}/playlist.m3u8`; ··· 146 } 147 148 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; 150 if (videoCid) { 151 const videoUrl = `https://video.bsky.app/watch/${did}/${videoCid}/playlist.m3u8`; 152 urls.push(videoUrl); ··· 156 157 if (e.$type === 'app.bsky.embed.recordWithMedia#view') { 158 const media = e.media; 159 - if (media && media.$type === 'app.bsky.embed.images#view' && Array.isArray(media.images)) { 160 for (const img of media.images) { 161 const imageUrl = img.fullsize || img.thumb; 162 if (imageUrl) {
··· 28 * and nested structures. 29 * - also detects 'app.bsky.embed.video' shapes and returns the video blob URL first 30 */ 31 + export function extractImageUrlsFromValue(value: any, did: string, limit = 4): string[] { 32 const urls: string[] = []; 33 34 try { ··· 89 } 90 91 // Video in recordWithMedia 92 + if ( 93 + media && 94 + (media.$type === 'app.bsky.embed.video#view' || media.$type === 'app.bsky.embed.video') 95 + ) { 96 const videoCid = (media as any)?.video?.ref?.$link ?? (media as any)?.video?.cid ?? null; 97 if (videoCid) { 98 const videoUrl = `https://video.bsky.app/watch/${did}/${videoCid}/playlist.m3u8`; ··· 145 } 146 147 if (e.$type === 'app.bsky.embed.video#view' || e.$type === 'app.bsky.embed.video') { 148 + const videoCid = 149 + (e as any)?.jobStatus?.blob ?? 150 + (e as any)?.video?.ref?.$link ?? 151 + (e as any)?.video?.cid ?? 152 + null; 153 if (videoCid) { 154 const videoUrl = `https://video.bsky.app/watch/${did}/${videoCid}/playlist.m3u8`; 155 urls.push(videoUrl); ··· 159 160 if (e.$type === 'app.bsky.embed.recordWithMedia#view') { 161 const media = e.media; 162 + if ( 163 + media && 164 + media.$type === 'app.bsky.embed.images#view' && 165 + Array.isArray(media.images) 166 + ) { 167 for (const img of media.images) { 168 const imageUrl = img.fullsize || img.thumb; 169 if (imageUrl) {
+13 -9
src/lib/services/atproto/musicbrainz.ts
··· 95 const response = await fetch(url, { 96 headers: { 97 'User-Agent': 'ewancroft.uk/1.0.0 (https://ewancroft.uk)', 98 - 'Accept': 'application/json' 99 } 100 }); 101 ··· 134 const response = await fetch(url, { 135 headers: { 136 'User-Agent': 'ewancroft.uk/1.0.0 (https://ewancroft.uk)', 137 - 'Accept': 'application/json' 138 } 139 }); 140 ··· 180 181 try { 182 // Prefer searching by album + artist for better accuracy 183 - const searchTerm = releaseName 184 - ? `${releaseName} ${artistName}` 185 - : `${trackName} ${artistName}`; 186 187 const url = `https://itunes.apple.com/search?term=${encodeURIComponent(searchTerm)}&entity=album&limit=5`; 188 ··· 330 331 // Get the largest image available 332 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'); 336 337 if (largeImage?.['#text']) { 338 const artworkUrl = largeImage['#text']; ··· 371 if (releaseName) params.set('releaseName', releaseName); 372 if (releaseMbId) params.set('releaseMbId', releaseMbId); 373 374 - console.info('[Artwork] Fetching via server API:', { trackName, artistName, releaseName, releaseMbId }); 375 376 // Call our server-side API endpoint 377 const response = await fetch(`/api/artwork?${params.toString()}`);
··· 95 const response = await fetch(url, { 96 headers: { 97 'User-Agent': 'ewancroft.uk/1.0.0 (https://ewancroft.uk)', 98 + Accept: 'application/json' 99 } 100 }); 101 ··· 134 const response = await fetch(url, { 135 headers: { 136 'User-Agent': 'ewancroft.uk/1.0.0 (https://ewancroft.uk)', 137 + Accept: 'application/json' 138 } 139 }); 140 ··· 180 181 try { 182 // Prefer searching by album + artist for better accuracy 183 + const searchTerm = releaseName ? `${releaseName} ${artistName}` : `${trackName} ${artistName}`; 184 185 const url = `https://itunes.apple.com/search?term=${encodeURIComponent(searchTerm)}&entity=album&limit=5`; 186 ··· 328 329 // Get the largest image available 330 const images = data.album.image; 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'); 335 336 if (largeImage?.['#text']) { 337 const artworkUrl = largeImage['#text']; ··· 370 if (releaseName) params.set('releaseName', releaseName); 371 if (releaseMbId) params.set('releaseMbId', releaseMbId); 372 373 + console.info('[Artwork] Fetching via server API:', { 374 + trackName, 375 + artistName, 376 + releaseName, 377 + releaseMbId 378 + }); 379 380 // Call our server-side API endpoint 381 const response = await fetch(`/api/artwork?${params.toString()}`);
+44 -38
src/lib/services/atproto/posts.ts
··· 17 /** 18 * Fetches all Leaflet publications for a user 19 */ 20 - export async function fetchLeafletPublications(fetchFn?: typeof fetch): Promise<LeafletPublicationsData> { 21 console.info('[Leaflet] Fetching publications'); 22 const cacheKey = `leaflet:publications:${PUBLIC_ATPROTO_DID}`; 23 const cached = cache.get<LeafletPublicationsData>(cacheKey); ··· 134 // Fetch Leaflet publications and documents 135 try { 136 // Get all publications first 137 - const publicationsData = await fetchLeafletPublications(fetchFn); 138 const publicationsMap = new Map<string, LeafletPublication>(); 139 for (const pub of publicationsData.publications) { 140 publicationsMap.set(pub.uri, pub); ··· 156 ); 157 158 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); 163 164 - // Determine URL based on priority: publication base_path → Leaflet /lish format 165 - let url: string; 166 - const publicationRkey = publicationUri ? publicationUri.split('/').pop() : ''; 167 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 - } 179 180 posts.push({ 181 title: value.title || 'Untitled Document', ··· 240 const latestFeedItem = feed[0]; 241 const latestPostData = latestFeedItem.post; 242 console.log('[fetchLatestBlueskyPost] Found latest feed item:', latestPostData.uri); 243 - 244 // Check if this is a repost 245 const isRepost = latestFeedItem.reason?.$type === 'app.bsky.feed.defs#reasonRepost'; 246 let repostAuthor: PostAuthor | undefined; 247 let repostCreatedAt: string | undefined; 248 - 249 if (isRepost && latestFeedItem.reason) { 250 const reason = latestFeedItem.reason as any; 251 repostAuthor = { ··· 257 repostCreatedAt = reason.indexedAt; 258 console.log('[fetchLatestBlueskyPost] This is a repost by:', repostAuthor.handle); 259 } 260 - 261 // Fetch the full post data 262 const post = await fetchPostFromUri(latestPostData.uri, 0, fetchFn); 263 - 264 if (!post) { 265 console.warn('[fetchLatestBlueskyPost] fetchPostFromUri returned null'); 266 return null; 267 } 268 - 269 // Add repost context if applicable 270 if (isRepost) { 271 post.isRepost = true; ··· 288 * Recursively fetches a Bluesky post by URI, supporting quoted posts up to 2 levels deep 289 */ 290 export async function fetchPostFromUri( 291 - uri: string, 292 - depth: number, 293 fetchFn?: typeof fetch 294 ): Promise<BlueskyPost | null> { 295 console.log(`[fetchPostFromUri] Starting fetch at depth ${depth} for URI:`, uri); ··· 406 407 // Extract images from media 408 if (media?.$type === 'app.bsky.embed.images#view' && Array.isArray(media.images)) { 409 - console.log(`[fetchPostFromUri] Processing images in recordWithMedia, count:`, media.images.length); 410 hasImages = true; 411 imageUrls = []; 412 imageAlts = []; ··· 455 const quotedRecord = embed.record?.record || embed.record; 456 console.log(`[fetchPostFromUri] Quoted record in recordWithMedia:`, quotedRecord?.uri); 457 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; 465 console.log(`[fetchPostFromUri] Quoted post fetched:`, quotedPost ? 'success' : 'failed'); 466 } 467 } ··· 491 if (value.reply) { 492 console.log(`[fetchPostFromUri] Post is a reply, fetching parent...`); 493 if (value.reply.parent?.uri) { 494 - replyParent = (await fetchPostFromUri(value.reply.parent.uri, depth + 1, fetchFn)) ?? undefined; 495 } 496 if (value.reply.root?.uri && value.reply.root.uri !== value.reply.parent?.uri) { 497 replyRoot = (await fetchPostFromUri(value.reply.root.uri, depth + 1, fetchFn)) ?? undefined; ··· 501 // Get engagement data from Constellation as a fallback 502 let finalLikeCount = postData.likeCount; 503 let finalRepostCount = postData.repostCount; 504 - 505 try { 506 const [likers, reposters] = await Promise.all([ 507 fetchAllEngagement(postData.uri, 'app.bsky.feed.like'), ··· 548 console.error(`[fetchPostFromUri] Failed to fetch post at depth ${depth}:`, err); 549 return null; 550 } 551 - }
··· 17 /** 18 * Fetches all Leaflet publications for a user 19 */ 20 + export async function fetchLeafletPublications( 21 + fetchFn?: typeof fetch 22 + ): Promise<LeafletPublicationsData> { 23 console.info('[Leaflet] Fetching publications'); 24 const cacheKey = `leaflet:publications:${PUBLIC_ATPROTO_DID}`; 25 const cached = cache.get<LeafletPublicationsData>(cacheKey); ··· 136 // Fetch Leaflet publications and documents 137 try { 138 // Get all publications first 139 + const publicationsData = await fetchLeafletPublications(fetchFn); 140 const publicationsMap = new Map<string, LeafletPublication>(); 141 for (const pub of publicationsData.publications) { 142 publicationsMap.set(pub.uri, pub); ··· 158 ); 159 160 for (const record of leafletDocsRecords) { 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); 165 166 + // Determine URL based on priority: publication base_path → Leaflet /lish format 167 + let url: string; 168 + const publicationRkey = publicationUri ? publicationUri.split('/').pop() : ''; 169 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 + } 181 182 posts.push({ 183 title: value.title || 'Untitled Document', ··· 242 const latestFeedItem = feed[0]; 243 const latestPostData = latestFeedItem.post; 244 console.log('[fetchLatestBlueskyPost] Found latest feed item:', latestPostData.uri); 245 + 246 // Check if this is a repost 247 const isRepost = latestFeedItem.reason?.$type === 'app.bsky.feed.defs#reasonRepost'; 248 let repostAuthor: PostAuthor | undefined; 249 let repostCreatedAt: string | undefined; 250 + 251 if (isRepost && latestFeedItem.reason) { 252 const reason = latestFeedItem.reason as any; 253 repostAuthor = { ··· 259 repostCreatedAt = reason.indexedAt; 260 console.log('[fetchLatestBlueskyPost] This is a repost by:', repostAuthor.handle); 261 } 262 + 263 // Fetch the full post data 264 const post = await fetchPostFromUri(latestPostData.uri, 0, fetchFn); 265 + 266 if (!post) { 267 console.warn('[fetchLatestBlueskyPost] fetchPostFromUri returned null'); 268 return null; 269 } 270 + 271 // Add repost context if applicable 272 if (isRepost) { 273 post.isRepost = true; ··· 290 * Recursively fetches a Bluesky post by URI, supporting quoted posts up to 2 levels deep 291 */ 292 export async function fetchPostFromUri( 293 + uri: string, 294 + depth: number, 295 fetchFn?: typeof fetch 296 ): Promise<BlueskyPost | null> { 297 console.log(`[fetchPostFromUri] Starting fetch at depth ${depth} for URI:`, uri); ··· 408 409 // Extract images from media 410 if (media?.$type === 'app.bsky.embed.images#view' && Array.isArray(media.images)) { 411 + console.log( 412 + `[fetchPostFromUri] Processing images in recordWithMedia, count:`, 413 + media.images.length 414 + ); 415 hasImages = true; 416 imageUrls = []; 417 imageAlts = []; ··· 460 const quotedRecord = embed.record?.record || embed.record; 461 console.log(`[fetchPostFromUri] Quoted record in recordWithMedia:`, quotedRecord?.uri); 462 if (quotedRecord && typeof quotedRecord.uri === 'string') { 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; 470 console.log(`[fetchPostFromUri] Quoted post fetched:`, quotedPost ? 'success' : 'failed'); 471 } 472 } ··· 496 if (value.reply) { 497 console.log(`[fetchPostFromUri] Post is a reply, fetching parent...`); 498 if (value.reply.parent?.uri) { 499 + replyParent = 500 + (await fetchPostFromUri(value.reply.parent.uri, depth + 1, fetchFn)) ?? undefined; 501 } 502 if (value.reply.root?.uri && value.reply.root.uri !== value.reply.parent?.uri) { 503 replyRoot = (await fetchPostFromUri(value.reply.root.uri, depth + 1, fetchFn)) ?? undefined; ··· 507 // Get engagement data from Constellation as a fallback 508 let finalLikeCount = postData.likeCount; 509 let finalRepostCount = postData.repostCount; 510 + 511 try { 512 const [likers, reposters] = await Promise.all([ 513 fetchAllEngagement(postData.uri, 'app.bsky.feed.like'), ··· 554 console.error(`[fetchPostFromUri] Failed to fetch post at depth ${depth}:`, err); 555 return null; 556 } 557 + }
+20 -24
src/lib/stores/wolfMode.ts
··· 67 function getWolfSoundForWord(word: string, position: number): string { 68 // Normalize the word to lowercase for consistent mapping 69 const normalizedWord = word.toLowerCase(); 70 - 71 // If we've seen this word before, return the same sound 72 if (wordToSoundMap.has(normalizedWord)) { 73 return wordToSoundMap.get(normalizedWord)!; 74 } 75 - 76 // Otherwise, assign a new sound based on position and store it 77 const wolfSound = getWolfSoundByPosition(position); 78 wordToSoundMap.set(normalizedWord, wolfSound); ··· 94 if (!hasAlphabeticalCharacters(word)) { 95 return false; 96 } 97 - 98 // Don't transform if it's a number abbreviation 99 if (isNumberAbbreviation(word)) { 100 return false; 101 } 102 - 103 return true; 104 } 105 106 function splitWordAndPunctuation(token: string): { prefix: string; word: string; suffix: string } { 107 // Match leading punctuation, word, and trailing punctuation 108 const match = token.match(/^([^a-zA-Z0-9]*)([a-zA-Z0-9]+)([^a-zA-Z0-9]*)$/); 109 - 110 if (match) { 111 return { 112 prefix: match[1], ··· 114 suffix: match[3] 115 }; 116 } 117 - 118 // If no match, treat entire token as word 119 return { 120 prefix: '', ··· 127 // Split by words and replace each with a wolf sound 128 const words = text.split(/(\s+)/); // Keep whitespace 129 let currentPosition = startPosition; 130 - 131 return words 132 .map((token) => { 133 if (token.trim().length === 0) { 134 return token; // Preserve whitespace 135 } 136 - 137 // Split word from surrounding punctuation 138 const { prefix, word, suffix } = splitWordAndPunctuation(token); 139 - 140 // Only transform words that should be transformed 141 if (!shouldTransform(word)) { 142 return token; // Keep numbers, abbreviations, punctuation, etc. as-is 143 } 144 - 145 const wolfSound = getWolfSoundForWord(word, currentPosition); 146 currentPosition++; 147 - 148 // Apply capitalization pattern to the wolf sound 149 let transformedWord = wolfSound; 150 if (word === word.toUpperCase() && word.length > 1) { ··· 152 } else if (word[0] === word[0].toUpperCase()) { 153 transformedWord = wolfSound.charAt(0).toUpperCase() + wolfSound.slice(1); 154 } 155 - 156 // Reconstruct with original punctuation 157 return prefix + transformedWord + suffix; 158 }) ··· 167 return true; 168 } 169 } 170 - 171 // Skip buttons in the header navigation 172 if (element.closest('header button')) { 173 return true; 174 } 175 - 176 // Skip nav elements 177 if (element.tagName === 'NAV' || element.closest('nav')) { 178 return true; 179 } 180 - 181 return false; 182 } 183 ··· 186 callback(node as Text); 187 } else if (node.nodeType === Node.ELEMENT_NODE) { 188 const element = node as Element; 189 - 190 // Skip script, style tags, and navigation elements 191 - if ( 192 - element.tagName === 'SCRIPT' || 193 - element.tagName === 'STYLE' || 194 - shouldSkipElement(element) 195 - ) { 196 return; 197 } 198 - 199 for (const child of Array.from(node.childNodes)) { 200 walkTextNodes(child, callback); 201 } ··· 206 originalTexts.clear(); 207 wordToSoundMap.clear(); 208 wordCounter = 0; 209 - 210 walkTextNodes(document.body, (textNode) => { 211 const originalText = textNode.textContent || ''; 212 if (originalText.trim().length > 0) { ··· 214 const transformedText = convertToWolfSpeak(originalText, wordCounter); 215 textNode.textContent = transformedText; 216 // Update counter based on number of transformable words processed 217 - wordCounter += originalText.split(/\s+/).filter(w => { 218 const { word } = splitWordAndPunctuation(w); 219 return shouldTransform(word); 220 }).length;
··· 67 function getWolfSoundForWord(word: string, position: number): string { 68 // Normalize the word to lowercase for consistent mapping 69 const normalizedWord = word.toLowerCase(); 70 + 71 // If we've seen this word before, return the same sound 72 if (wordToSoundMap.has(normalizedWord)) { 73 return wordToSoundMap.get(normalizedWord)!; 74 } 75 + 76 // Otherwise, assign a new sound based on position and store it 77 const wolfSound = getWolfSoundByPosition(position); 78 wordToSoundMap.set(normalizedWord, wolfSound); ··· 94 if (!hasAlphabeticalCharacters(word)) { 95 return false; 96 } 97 + 98 // Don't transform if it's a number abbreviation 99 if (isNumberAbbreviation(word)) { 100 return false; 101 } 102 + 103 return true; 104 } 105 106 function splitWordAndPunctuation(token: string): { prefix: string; word: string; suffix: string } { 107 // Match leading punctuation, word, and trailing punctuation 108 const match = token.match(/^([^a-zA-Z0-9]*)([a-zA-Z0-9]+)([^a-zA-Z0-9]*)$/); 109 + 110 if (match) { 111 return { 112 prefix: match[1], ··· 114 suffix: match[3] 115 }; 116 } 117 + 118 // If no match, treat entire token as word 119 return { 120 prefix: '', ··· 127 // Split by words and replace each with a wolf sound 128 const words = text.split(/(\s+)/); // Keep whitespace 129 let currentPosition = startPosition; 130 + 131 return words 132 .map((token) => { 133 if (token.trim().length === 0) { 134 return token; // Preserve whitespace 135 } 136 + 137 // Split word from surrounding punctuation 138 const { prefix, word, suffix } = splitWordAndPunctuation(token); 139 + 140 // Only transform words that should be transformed 141 if (!shouldTransform(word)) { 142 return token; // Keep numbers, abbreviations, punctuation, etc. as-is 143 } 144 + 145 const wolfSound = getWolfSoundForWord(word, currentPosition); 146 currentPosition++; 147 + 148 // Apply capitalization pattern to the wolf sound 149 let transformedWord = wolfSound; 150 if (word === word.toUpperCase() && word.length > 1) { ··· 152 } else if (word[0] === word[0].toUpperCase()) { 153 transformedWord = wolfSound.charAt(0).toUpperCase() + wolfSound.slice(1); 154 } 155 + 156 // Reconstruct with original punctuation 157 return prefix + transformedWord + suffix; 158 }) ··· 167 return true; 168 } 169 } 170 + 171 // Skip buttons in the header navigation 172 if (element.closest('header button')) { 173 return true; 174 } 175 + 176 // Skip nav elements 177 if (element.tagName === 'NAV' || element.closest('nav')) { 178 return true; 179 } 180 + 181 return false; 182 } 183 ··· 186 callback(node as Text); 187 } else if (node.nodeType === Node.ELEMENT_NODE) { 188 const element = node as Element; 189 + 190 // Skip script, style tags, and navigation elements 191 + if (element.tagName === 'SCRIPT' || element.tagName === 'STYLE' || shouldSkipElement(element)) { 192 return; 193 } 194 + 195 for (const child of Array.from(node.childNodes)) { 196 walkTextNodes(child, callback); 197 } ··· 202 originalTexts.clear(); 203 wordToSoundMap.clear(); 204 wordCounter = 0; 205 + 206 walkTextNodes(document.body, (textNode) => { 207 const originalText = textNode.textContent || ''; 208 if (originalText.trim().length > 0) { ··· 210 const transformedText = convertToWolfSpeak(originalText, wordCounter); 211 textNode.textContent = transformedText; 212 // Update counter based on number of transformable words processed 213 + wordCounter += originalText.split(/\s+/).filter((w) => { 214 const { word } = splitWordAndPunctuation(w); 215 return shouldTransform(word); 216 }).length;
+3 -7
src/lib/utils/formatNumber.ts
··· 6 * Determines the effective locale, preferring system locale with fallback to 'en-GB'. 7 */ 8 function getLocale(locale?: string): string { 9 - return ( 10 - locale || 11 - (typeof navigator !== 'undefined' && navigator.language) || 12 - 'en-GB' 13 - ); 14 } 15 16 /** ··· 33 const roundedDown = Math.floor((num / divisor) * 10) / 10; 34 // Re-multiply to get the actual number to format 35 const adjustedNum = roundedDown * divisor; 36 - 37 return new Intl.NumberFormat(effectiveLocale, { 38 notation: 'compact', 39 compactDisplay: 'short', ··· 59 export function formatNumber(num: number, locale?: string): string { 60 const effectiveLocale = getLocale(locale); 61 return new Intl.NumberFormat(effectiveLocale).format(num); 62 - }
··· 6 * Determines the effective locale, preferring system locale with fallback to 'en-GB'. 7 */ 8 function getLocale(locale?: string): string { 9 + return locale || (typeof navigator !== 'undefined' && navigator.language) || 'en-GB'; 10 } 11 12 /** ··· 29 const roundedDown = Math.floor((num / divisor) * 10) / 10; 30 // Re-multiply to get the actual number to format 31 const adjustedNum = roundedDown * divisor; 32 + 33 return new Intl.NumberFormat(effectiveLocale, { 34 notation: 'compact', 35 compactDisplay: 'short', ··· 55 export function formatNumber(num: number, locale?: string): string { 56 const effectiveLocale = getLocale(locale); 57 return new Intl.NumberFormat(effectiveLocale).format(num); 58 + }
+1 -1
src/lib/utils/url.ts
··· 44 */ 45 export function isExternalUrl(url: string): boolean { 46 if (typeof window === 'undefined') return true; 47 - 48 try { 49 const urlObj = new URL(url, window.location.href); 50 return urlObj.origin !== window.location.origin;
··· 44 */ 45 export function isExternalUrl(url: string): boolean { 46 if (typeof window === 'undefined') return true; 47 + 48 try { 49 const urlObj = new URL(url, window.location.href); 50 return urlObj.origin !== window.location.origin;
+5 -3
src/routes/+error.svelte
··· 48 <div class="text-center"> 49 <!-- Large status code number --> 50 <div class="mb-6"> 51 - <h1 class="text-8xl font-bold text-primary-500 dark:text-primary-400 md:text-9xl"> 52 {status} 53 </h1> 54 </div> ··· 65 66 <!-- Show additional error message if it's different from the description --> 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"> 69 {errorMessage} 70 </p> 71 {/if} ··· 78 > 79 Return to Home 80 </a> 81 - 82 {#if status !== 404} 83 <button 84 onclick={() => window.location.reload()}
··· 48 <div class="text-center"> 49 <!-- Large status code number --> 50 <div class="mb-6"> 51 + <h1 class="text-8xl font-bold text-primary-500 md:text-9xl dark:text-primary-400"> 52 {status} 53 </h1> 54 </div> ··· 65 66 <!-- Show additional error message if it's different from the description --> 67 {#if errorMessage && errorMessage !== errorDetails.description && status !== 404} 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 + > 71 {errorMessage} 72 </p> 73 {/if} ··· 80 > 81 Return to Home 82 </a> 83 + 84 {#if status !== 404} 85 <button 86 onclick={() => window.location.reload()}
+14 -10
src/routes/+layout.svelte
··· 22 23 onMount(() => { 24 console.info('[App] Application mounted'); 25 - 26 // Setup global error handler 27 window.onerror = (msg, url, lineNo, columnNo, error) => { 28 console.error('[App] Global error:', { ··· 40 console.error('[App] Unhandled promise rejection:', event.reason); 41 }; 42 }); 43 - 44 // Reactive meta updates on navigation 45 - let headMeta = $derived(createSiteMeta({ 46 - ...data.siteMeta, 47 - ...data.meta 48 - })); 49 </script> 50 51 <svelte:head> 52 <script> 53 // Prevent flash of unstyled content (FOUC) by applying theme before page renders 54 - (function() { 55 const stored = localStorage.getItem('theme'); 56 const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches; 57 const isDark = stored === 'dark' || (!stored && prefersDark); 58 const htmlElement = document.documentElement; 59 - 60 if (isDark) { 61 htmlElement.classList.add('dark'); 62 htmlElement.style.colorScheme = 'dark'; ··· 76 <!-- Bespoke MetaTags component --> 77 <MetaTags meta={headMeta} siteMeta={data.siteMeta} /> 78 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"> 80 <Header /> 81 - 82 <main class="container mx-auto flex-grow px-4 py-8"> 83 <ScrollToTop /> 84 {@render children()}
··· 22 23 onMount(() => { 24 console.info('[App] Application mounted'); 25 + 26 // Setup global error handler 27 window.onerror = (msg, url, lineNo, columnNo, error) => { 28 console.error('[App] Global error:', { ··· 40 console.error('[App] Unhandled promise rejection:', event.reason); 41 }; 42 }); 43 + 44 // Reactive meta updates on navigation 45 + let headMeta = $derived( 46 + createSiteMeta({ 47 + ...data.siteMeta, 48 + ...data.meta 49 + }) 50 + ); 51 </script> 52 53 <svelte:head> 54 <script> 55 // Prevent flash of unstyled content (FOUC) by applying theme before page renders 56 + (function () { 57 const stored = localStorage.getItem('theme'); 58 const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches; 59 const isDark = stored === 'dark' || (!stored && prefersDark); 60 const htmlElement = document.documentElement; 61 + 62 if (isDark) { 63 htmlElement.classList.add('dark'); 64 htmlElement.style.colorScheme = 'dark'; ··· 78 <!-- Bespoke MetaTags component --> 79 <MetaTags meta={headMeta} siteMeta={data.siteMeta} /> 80 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 + > 84 <Header /> 85 + 86 <main class="container mx-auto flex-grow px-4 py-8"> 87 <ScrollToTop /> 88 {@render children()}
+12 -3
src/routes/+page.svelte
··· 1 <script lang="ts"> 2 import { DynamicLinks, TangledRepos } from '$lib/components/layout'; 3 - import { ProfileCard, PostCard, BlueskyPostCard, MusicStatusCard } from '$lib/components/layout/main/card'; 4 import { createSiteMeta, type SiteMetadata } from '$lib/helper/siteMeta'; 5 6 // The `data` object includes merged layout/page load data. ··· 17 18 <div class="mx-auto max-w-6xl"> 19 <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"> 21 Welcome to {meta.title} 22 </h1> 23 - <p class="mx-auto max-w-2xl overflow-wrap-anywhere break-words text-lg text-ink-700 dark:text-ink-200"> 24 {meta.description} 25 </p> 26 </div>
··· 1 <script lang="ts"> 2 import { DynamicLinks, TangledRepos } from '$lib/components/layout'; 3 + import { 4 + ProfileCard, 5 + PostCard, 6 + BlueskyPostCard, 7 + MusicStatusCard 8 + } from '$lib/components/layout/main/card'; 9 import { createSiteMeta, type SiteMetadata } from '$lib/helper/siteMeta'; 10 11 // The `data` object includes merged layout/page load data. ··· 22 23 <div class="mx-auto max-w-6xl"> 24 <div class="mb-8 text-center"> 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 + > 28 Welcome to {meta.title} 29 </h1> 30 + <p 31 + class="overflow-wrap-anywhere mx-auto max-w-2xl text-lg break-words text-ink-700 dark:text-ink-200" 32 + > 33 {meta.description} 34 </p> 35 </div>
+10 -10
src/routes/[slug=slug]/+server.ts
··· 5 6 /** 7 * Dynamic slug root redirect handler 8 - * 9 * Redirects /{slug} to the appropriate Leaflet publication: 10 * - Uses the slug mapping config to find the publication rkey 11 * - Priority 1: Publication base_path from Leaflet API 12 * - Priority 2: Leaflet /lish format 13 - * 14 * Individual posts are handled by the [rkey] route. 15 */ 16 export const GET: RequestHandler = async ({ params, url }) => { 17 const slug = params.slug; 18 - 19 // If there's a path after /{slug}, let it fall through to other routes 20 const slugPath = url.pathname.replace(new RegExp(`^/${slug}/?`), ''); 21 - 22 if (slugPath && !['rss', 'atom'].includes(slugPath)) { 23 // This will be caught by the [rkey] route 24 return new Response(null, { ··· 40 } 41 }); 42 } 43 - 44 const publicationRkey = getPublicationRkeyFromSlug(slug); 45 - 46 if (!publicationRkey) { 47 return new Response( 48 `Slug not configured: ${slug}\n\nPlease add this slug to src/lib/config/slugs.ts`, ··· 60 try { 61 // Fetch publications to get base path 62 const { publications } = await fetchLeafletPublications(); 63 - const publication = publications.find(p => p.rkey === publicationRkey); 64 - 65 if (publication?.basePath) { 66 // Ensure basePath is a complete URL 67 - redirectUrl = publication.basePath.startsWith('http') 68 - ? publication.basePath 69 : `https://${publication.basePath}`; 70 } else { 71 // Use Leaflet /lish format
··· 5 6 /** 7 * Dynamic slug root redirect handler 8 + * 9 * Redirects /{slug} to the appropriate Leaflet publication: 10 * - Uses the slug mapping config to find the publication rkey 11 * - Priority 1: Publication base_path from Leaflet API 12 * - Priority 2: Leaflet /lish format 13 + * 14 * Individual posts are handled by the [rkey] route. 15 */ 16 export const GET: RequestHandler = async ({ params, url }) => { 17 const slug = params.slug; 18 + 19 // If there's a path after /{slug}, let it fall through to other routes 20 const slugPath = url.pathname.replace(new RegExp(`^/${slug}/?`), ''); 21 + 22 if (slugPath && !['rss', 'atom'].includes(slugPath)) { 23 // This will be caught by the [rkey] route 24 return new Response(null, { ··· 40 } 41 }); 42 } 43 + 44 const publicationRkey = getPublicationRkeyFromSlug(slug); 45 + 46 if (!publicationRkey) { 47 return new Response( 48 `Slug not configured: ${slug}\n\nPlease add this slug to src/lib/config/slugs.ts`, ··· 60 try { 61 // Fetch publications to get base path 62 const { publications } = await fetchLeafletPublications(); 63 + const publication = publications.find((p) => p.rkey === publicationRkey); 64 + 65 if (publication?.basePath) { 66 // Ensure basePath is a complete URL 67 + redirectUrl = publication.basePath.startsWith('http') 68 + ? publication.basePath 69 : `https://${publication.basePath}`; 70 } else { 71 // Use Leaflet /lish format
+19 -20
src/routes/[slug=slug]/[rkey]/+server.ts
··· 69 70 if (publication?.basePath) { 71 // Ensure basePath is a complete URL 72 - const basePath = publication.basePath.startsWith('http') 73 - ? publication.basePath 74 : `https://${publication.basePath}`; 75 url = `${basePath}/${rkey}`; 76 } else if (docPublicationRkey) { ··· 88 // Check WhiteWind as fallback (only if enabled) 89 if (PUBLIC_ENABLE_WHITEWIND === 'true') { 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 - }, 105 true // Use PDS first for custom collections 106 ); 107 ··· 140 141 // Get the publication rkey from the slug 142 const publicationRkey = getPublicationRkeyFromSlug(slug); 143 - 144 if (!publicationRkey) { 145 return new Response( 146 `Slug not configured: ${slug}\n\nPlease add this slug to src/lib/config/slugs.ts`, ··· 180 } else { 181 // No fallback configured, return 404 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 - : ''; 186 187 return new Response( 188 `Document not found: ${rkey}
··· 69 70 if (publication?.basePath) { 71 // Ensure basePath is a complete URL 72 + const basePath = publication.basePath.startsWith('http') 73 + ? publication.basePath 74 : `https://${publication.basePath}`; 75 url = `${basePath}/${rkey}`; 76 } else if (docPublicationRkey) { ··· 88 // Check WhiteWind as fallback (only if enabled) 89 if (PUBLIC_ENABLE_WHITEWIND === 'true') { 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 + }, 105 true // Use PDS first for custom collections 106 ); 107 ··· 140 141 // Get the publication rkey from the slug 142 const publicationRkey = getPublicationRkeyFromSlug(slug); 143 + 144 if (!publicationRkey) { 145 return new Response( 146 `Slug not configured: ${slug}\n\nPlease add this slug to src/lib/config/slugs.ts`, ··· 180 } else { 181 // No fallback configured, return 404 182 const publicationNote = `\n\nNote: Only checking Leaflet publication with rkey: ${publicationRkey}`; 183 + const whiteWindNote = 184 + PUBLIC_ENABLE_WHITEWIND === 'true' ? '\n- WhiteWind: https://whtwnd.com' : ''; 185 186 return new Response( 187 `Document not found: ${rkey}
+3 -3
src/routes/[slug=slug]/atom/+server.ts
··· 14 */ 15 export const GET: RequestHandler = ({ params }) => { 16 const slug = params.slug; 17 - 18 // Validate slug 19 if (!slug) { 20 return new Response('Invalid slug', { ··· 24 } 25 }); 26 } 27 - 28 // Validate slug exists in config 29 const publicationRkey = getPublicationRkeyFromSlug(slug); 30 - 31 if (!publicationRkey) { 32 return new Response( 33 `Slug not configured: ${slug}\n\nPlease add this slug to src/lib/config/slugs.ts`,
··· 14 */ 15 export const GET: RequestHandler = ({ params }) => { 16 const slug = params.slug; 17 + 18 // Validate slug 19 if (!slug) { 20 return new Response('Invalid slug', { ··· 24 } 25 }); 26 } 27 + 28 // Validate slug exists in config 29 const publicationRkey = getPublicationRkeyFromSlug(slug); 30 + 31 if (!publicationRkey) { 32 return new Response( 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 */ 20 export const GET: RequestHandler = async ({ params }) => { 21 const slug = params.slug; 22 - 23 // Validate slug 24 if (!slug) { 25 return new Response('Invalid slug', { ··· 29 } 30 }); 31 } 32 - 33 // Get the publication rkey from the slug 34 const publicationRkey = getPublicationRkeyFromSlug(slug); 35 - 36 if (!publicationRkey) { 37 return new Response( 38 `Slug not configured: ${slug}\n\nPlease add this slug to src/lib/config/slugs.ts`, ··· 50 51 // Filter posts for this specific publication 52 const publicationPosts = posts.filter( 53 - p => p.publicationRkey === publicationRkey || p.platform === 'WhiteWind' 54 ); 55 56 // Separate WhiteWind and Leaflet posts ··· 134 135 // Find the specific publication 136 const publication = publications.find((p) => p.rkey === publicationRkey); 137 - 138 if (publication) { 139 const rssUrl = getLeafletRSSUrl(publication); 140 return Response.redirect(rssUrl, 307); // Temporary redirect 141 } 142 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 - } 151 } 152 - ); 153 } catch (error) { 154 console.error('Error redirecting to Leaflet RSS:', error); 155 return new Response('Error finding Leaflet RSS feed', { ··· 167 function getLeafletRSSUrl(publication: { basePath?: string; rkey: string }): string { 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 return `${basePath}/rss`; 174 }
··· 19 */ 20 export const GET: RequestHandler = async ({ params }) => { 21 const slug = params.slug; 22 + 23 // Validate slug 24 if (!slug) { 25 return new Response('Invalid slug', { ··· 29 } 30 }); 31 } 32 + 33 // Get the publication rkey from the slug 34 const publicationRkey = getPublicationRkeyFromSlug(slug); 35 + 36 if (!publicationRkey) { 37 return new Response( 38 `Slug not configured: ${slug}\n\nPlease add this slug to src/lib/config/slugs.ts`, ··· 50 51 // Filter posts for this specific publication 52 const publicationPosts = posts.filter( 53 + (p) => p.publicationRkey === publicationRkey || p.platform === 'WhiteWind' 54 ); 55 56 // Separate WhiteWind and Leaflet posts ··· 134 135 // Find the specific publication 136 const publication = publications.find((p) => p.rkey === publicationRkey); 137 + 138 if (publication) { 139 const rssUrl = getLeafletRSSUrl(publication); 140 return Response.redirect(rssUrl, 307); // Temporary redirect 141 } 142 143 // Publication not found 144 + return new Response(`Leaflet publication not found for rkey: ${publicationRkey}`, { 145 + status: 404, 146 + headers: { 147 + 'Content-Type': 'text/plain; charset=utf-8' 148 } 149 + }); 150 } catch (error) { 151 console.error('Error redirecting to Leaflet RSS:', error); 152 return new Response('Error finding Leaflet RSS feed', { ··· 164 function getLeafletRSSUrl(publication: { basePath?: string; rkey: string }): string { 165 if (publication.basePath) { 166 // Ensure basePath is a complete URL 167 + const basePath = publication.basePath.startsWith('http') 168 + ? publication.basePath 169 : `https://${publication.basePath}`; 170 return `${basePath}/rss`; 171 }
+3 -8
src/routes/api/artwork/+server.ts
··· 143 releaseName?: string 144 ): Promise<string | null> { 145 try { 146 - const searchTerm = releaseName 147 - ? `${releaseName} ${artistName}` 148 - : `${trackName} ${artistName}`; 149 150 const url = `https://itunes.apple.com/search?term=${encodeURIComponent(searchTerm)}&entity=album&limit=5`; 151 ··· 196 /** 197 * Search Last.fm for artwork 198 */ 199 - async function searchLastFm( 200 - artistName: string, 201 - releaseName?: string 202 - ): Promise<string | null> { 203 if (!releaseName) return null; 204 205 try { ··· 229 /** 230 * GET /api/artwork 231 * Query params: trackName, artistName, releaseName?, releaseMbId? 232 - * 233 * Features: 234 * - Intelligent caching (1 hour TTL) 235 * - Multiple fallback sources (MusicBrainz, iTunes, Deezer, Last.fm)
··· 143 releaseName?: string 144 ): Promise<string | null> { 145 try { 146 + const searchTerm = releaseName ? `${releaseName} ${artistName}` : `${trackName} ${artistName}`; 147 148 const url = `https://itunes.apple.com/search?term=${encodeURIComponent(searchTerm)}&entity=album&limit=5`; 149 ··· 194 /** 195 * Search Last.fm for artwork 196 */ 197 + async function searchLastFm(artistName: string, releaseName?: string): Promise<string | null> { 198 if (!releaseName) return null; 199 200 try { ··· 224 /** 225 * GET /api/artwork 226 * Query params: trackName, artistName, releaseName?, releaseMbId? 227 + * 228 * Features: 229 * - Intelligent caching (1 hour TTL) 230 * - Multiple fallback sources (MusicBrainz, iTunes, Deezer, Last.fm)
+2 -2
src/routes/favicon.ico/+server.ts
··· 2 3 /** 4 * Redirects /favicon.ico to /favicon/favicon.ico 5 - * 6 * This handles browsers that request favicon.ico from the root 7 * and redirects them to the actual location in the /favicon/ directory. 8 */ ··· 14 'Cache-Control': 'public, max-age=31536000, immutable' 15 } 16 }); 17 - };
··· 2 3 /** 4 * Redirects /favicon.ico to /favicon/favicon.ico 5 + * 6 * This handles browsers that request favicon.ico from the root 7 * and redirects them to the actual location in the /favicon/ directory. 8 */ ··· 14 'Cache-Control': 'public, max-age=31536000, immutable' 15 } 16 }); 17 + };
+23 -13
src/routes/site/meta/+page.svelte
··· 31 </div> 32 {:else if siteInfo} 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} 39 {#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"> 41 <h2 class="mb-4 text-2xl font-bold text-ink-900 dark:text-ink-50">{section.title}</h2> 42 <p class="whitespace-pre-wrap text-ink-700 dark:text-ink-300">{section.content}</p> 43 </section> ··· 45 {/each} 46 47 {#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"> 49 <h2 class="mb-4 text-2xl font-bold text-ink-900 dark:text-ink-50">Technology Stack</h2> 50 <div class="space-y-2"> 51 {#each siteInfo.technologyStack as tech} ··· 56 {/if} 57 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"> 60 <h2 class="mb-4 text-2xl font-bold text-ink-900 dark:text-ink-50">Open Source</h2> 61 {#if siteInfo.openSourceInfo.description} 62 - <p class="mb-4 whitespace-pre-wrap text-ink-700 dark:text-ink-300">{siteInfo.openSourceInfo.description}</p> 63 {/if} 64 {#if siteInfo.openSourceInfo.repositories?.length} 65 <div class="space-y-2"> ··· 80 {/if} 81 82 {#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"> 84 <h2 class="mb-4 text-2xl font-bold text-ink-900 dark:text-ink-50">Credits</h2> 85 <div class="grid gap-4 md:grid-cols-2"> 86 {#each siteInfo.credits as credit} 87 <div class="rounded-lg bg-canvas-200 p-4 dark:bg-canvas-800"> 88 <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} 91 </div> 92 {/each} 93 </div> ··· 99 <p class="text-ink-700 dark:text-ink-300">No site information available.</p> 100 </div> 101 {/if} 102 - </div>
··· 31 </div> 32 {:else if siteInfo} 33 <div class="space-y-8"> 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} 35 {#if section.content} 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 + > 39 <h2 class="mb-4 text-2xl font-bold text-ink-900 dark:text-ink-50">{section.title}</h2> 40 <p class="whitespace-pre-wrap text-ink-700 dark:text-ink-300">{section.content}</p> 41 </section> ··· 43 {/each} 44 45 {#if siteInfo.technologyStack?.length} 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 <h2 class="mb-4 text-2xl font-bold text-ink-900 dark:text-ink-50">Technology Stack</h2> 50 <div class="space-y-2"> 51 {#each siteInfo.technologyStack as tech} ··· 56 {/if} 57 58 {#if siteInfo.openSourceInfo} 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 + > 62 <h2 class="mb-4 text-2xl font-bold text-ink-900 dark:text-ink-50">Open Source</h2> 63 {#if siteInfo.openSourceInfo.description} 64 + <p class="mb-4 whitespace-pre-wrap text-ink-700 dark:text-ink-300"> 65 + {siteInfo.openSourceInfo.description} 66 + </p> 67 {/if} 68 {#if siteInfo.openSourceInfo.repositories?.length} 69 <div class="space-y-2"> ··· 84 {/if} 85 86 {#if siteInfo.credits?.length} 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 + > 90 <h2 class="mb-4 text-2xl font-bold text-ink-900 dark:text-ink-50">Credits</h2> 91 <div class="grid gap-4 md:grid-cols-2"> 92 {#each siteInfo.credits as credit} 93 <div class="rounded-lg bg-canvas-200 p-4 dark:bg-canvas-800"> 94 <h4 class="font-medium text-ink-900 dark:text-ink-50">{credit.name}</h4> 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} 101 </div> 102 {/each} 103 </div> ··· 109 <p class="text-ink-700 dark:text-ink-300">No site information available.</p> 110 </div> 111 {/if} 112 + </div>
+15 -15
src/routes/site/meta/+page.ts
··· 4 import { ogImages } from '$lib/helper/ogImages'; 5 6 export const load: PageLoad = async ({ parent, fetch }) => { 7 - const { siteMeta } = await parent(); 8 9 - let siteInfo: SiteInfoData | null = null; 10 - let error: string | null = null; 11 12 - try { 13 - siteInfo = await fetchSiteInfo(fetch); 14 - } catch (err) { 15 - error = err instanceof Error ? err.message : 'Failed to load site information'; 16 - } 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 - }); 24 25 - return { siteInfo, error, meta }; 26 };
··· 4 import { ogImages } from '$lib/helper/ogImages'; 5 6 export const load: PageLoad = async ({ parent, fetch }) => { 7 + const { siteMeta } = await parent(); 8 9 + let siteInfo: SiteInfoData | null = null; 10 + let error: string | null = null; 11 12 + try { 13 + siteInfo = await fetchSiteInfo(fetch); 14 + } catch (err) { 15 + error = err instanceof Error ? err.message : 'Failed to load site information'; 16 + } 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 + }); 24 25 + return { siteInfo, error, meta }; 26 };
+12 -12
tsconfig.json
··· 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 - } 14 }
··· 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 + } 14 }