[READ-ONLY] Mirror of https://github.com/flo-bit/ui-kit. 🦊 fox ui, svelte 5 and tailwind 4 flo-bit.dev/ui-kit/
svelte tailwindcss ui-components
0

Configure Feed

Select the types of activity you want to include in your feed.

add social media post component

Florian (Mar 2, 2025, 10:29 PM +0100) a7056bc6 5597173b

+1224 -12
+2 -2
README.md
··· 65 65 66 66 6. copy components from `src/lib/components` to your project 67 67 68 - Some components might need additional dependencies, just install once you get an error, or install all of them at once: 68 + Some components might need additional dependencies, just install once you get an error (recommended), or install all of them at once: 69 69 70 70 ```bash 71 - npm install bits-ui tailwind-variants @tailwindcss/forms @tailwindcss/typography layerchart plyr svelte-sonner tailwindcss-animate three @threlte/extras @threlte/core @types/three @zerodevx/svelte-img @number-flow/svelte wavesurfer.js 71 + npm install bits-ui tailwind-variants @tailwindcss/forms @tailwindcss/typography layerchart plyr svelte-sonner tailwindcss-animate three @threlte/extras @threlte/core @types/three @zerodevx/svelte-img @number-flow/svelte wavesurfer.js hls.js 72 72 ``` 73 73 74 74 again tailwind plugins should be added to your `app.css`
+91 -2
package-lock.json
··· 8 8 "name": "ui-kit-25", 9 9 "version": "0.0.1", 10 10 "dependencies": { 11 + "@atproto/api": "^0.14.7", 11 12 "@number-flow/svelte": "^0.3.3", 12 13 "@sveltejs/adapter-static": "^3.0.8", 13 14 "@threlte/core": "^8.0.1", 14 15 "@threlte/extras": "^9.0.1", 15 16 "@types/three": "^0.173.0", 16 17 "bits-ui": "^1.3.4", 18 + "hls.js": "^1.5.20", 17 19 "layerchart": "^1.0.0", 18 20 "plyr": "^3.7.8", 19 21 "svelte-sonner": "^0.3.28", ··· 68 70 }, 69 71 "engines": { 70 72 "node": ">=6.0.0" 73 + } 74 + }, 75 + "node_modules/@atproto/api": { 76 + "version": "0.14.7", 77 + "resolved": "https://registry.npmjs.org/@atproto/api/-/api-0.14.7.tgz", 78 + "integrity": "sha512-YG2kvAtsgtajLlLrorYuHcxGgepG0c/RUB2/iJyBnwKjGqDLG8joOETf38JSNiGzs6NJbNKa9NHG6BQKourxBA==", 79 + "dependencies": { 80 + "@atproto/common-web": "^0.4.0", 81 + "@atproto/lexicon": "^0.4.7", 82 + "@atproto/syntax": "^0.3.3", 83 + "@atproto/xrpc": "^0.6.9", 84 + "await-lock": "^2.2.2", 85 + "multiformats": "^9.9.0", 86 + "tlds": "^1.234.0", 87 + "zod": "^3.23.8" 88 + } 89 + }, 90 + "node_modules/@atproto/common-web": { 91 + "version": "0.4.0", 92 + "resolved": "https://registry.npmjs.org/@atproto/common-web/-/common-web-0.4.0.tgz", 93 + "integrity": "sha512-ZYL0P9myHybNgwh/hBY0HaBzqiLR1B5/ie5bJpLQAg0whRzNA28t8/nU2vh99tbsWcAF0LOD29M8++LyENJLNQ==", 94 + "dependencies": { 95 + "graphemer": "^1.4.0", 96 + "multiformats": "^9.9.0", 97 + "uint8arrays": "3.0.0", 98 + "zod": "^3.23.8" 99 + } 100 + }, 101 + "node_modules/@atproto/lexicon": { 102 + "version": "0.4.7", 103 + "resolved": "https://registry.npmjs.org/@atproto/lexicon/-/lexicon-0.4.7.tgz", 104 + "integrity": "sha512-/x6h3tAiDNzSi4eXtC8ke65B7UzsagtlGRHmUD95698x5lBRpDnpizj0fZWTZVYed5qnOmz/ZEue+v3wDmO61g==", 105 + "dependencies": { 106 + "@atproto/common-web": "^0.4.0", 107 + "@atproto/syntax": "^0.3.3", 108 + "iso-datestring-validator": "^2.2.2", 109 + "multiformats": "^9.9.0", 110 + "zod": "^3.23.8" 111 + } 112 + }, 113 + "node_modules/@atproto/syntax": { 114 + "version": "0.3.3", 115 + "resolved": "https://registry.npmjs.org/@atproto/syntax/-/syntax-0.3.3.tgz", 116 + "integrity": "sha512-F1LZweesNYdBbZBXVa72N/cSvchG8Q1tG4/209ZXbIuM3FwQtkgn+zgmmV4P4ORmhOeXPBNXvMBpcqiwx/gEQQ==" 117 + }, 118 + "node_modules/@atproto/xrpc": { 119 + "version": "0.6.9", 120 + "resolved": "https://registry.npmjs.org/@atproto/xrpc/-/xrpc-0.6.9.tgz", 121 + "integrity": "sha512-vQGA7++DYMNaHx3C7vEjT+2X6hYYLG7JNbBnDLWu0km1/1KYXgRkAz4h+FfYqg1mvzvIorHU7DAs5wevkJDDlw==", 122 + "dependencies": { 123 + "@atproto/lexicon": "^0.4.7", 124 + "zod": "^3.23.8" 71 125 } 72 126 }, 73 127 "node_modules/@dagrejs/dagre": { ··· 2411 2465 "resolved": "https://registry.npmjs.org/array-source/-/array-source-0.0.4.tgz", 2412 2466 "integrity": "sha512-frNdc+zBn80vipY+GdcJkLEbMWj3xmzArYApmUGxoiV8uAu/ygcs9icPdsGdA26h0MkHUMW6EN2piIvVx+M5Mw==" 2413 2467 }, 2468 + "node_modules/await-lock": { 2469 + "version": "2.2.2", 2470 + "resolved": "https://registry.npmjs.org/await-lock/-/await-lock-2.2.2.tgz", 2471 + "integrity": "sha512-aDczADvlvTGajTDjcjpJMqRkOF6Qdz3YbPZm/PyW6tKPkx2hlYBzxMhEywM/tU72HrVZjgl5VCdRuMlA7pZ8Gw==" 2472 + }, 2414 2473 "node_modules/axobject-query": { 2415 2474 "version": "4.1.0", 2416 2475 "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", ··· 3579 3638 "node_modules/graphemer": { 3580 3639 "version": "1.4.0", 3581 3640 "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", 3582 - "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", 3583 - "dev": true 3641 + "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==" 3584 3642 }, 3585 3643 "node_modules/has-flag": { 3586 3644 "version": "4.0.0", ··· 3601 3659 "engines": { 3602 3660 "node": ">= 0.4" 3603 3661 } 3662 + }, 3663 + "node_modules/hls.js": { 3664 + "version": "1.5.20", 3665 + "resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.5.20.tgz", 3666 + "integrity": "sha512-uu0VXUK52JhihhnN/MVVo1lvqNNuhoxkonqgO3IpjvQiGpJBdIXMGkofjQb/j9zvV7a1SW8U9g1FslWx/1HOiQ==" 3604 3667 }, 3605 3668 "node_modules/iconv-lite": { 3606 3669 "version": "0.6.3", ··· 3768 3831 "version": "2.0.0", 3769 3832 "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", 3770 3833 "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==" 3834 + }, 3835 + "node_modules/iso-datestring-validator": { 3836 + "version": "2.2.2", 3837 + "resolved": "https://registry.npmjs.org/iso-datestring-validator/-/iso-datestring-validator-2.2.2.tgz", 3838 + "integrity": "sha512-yLEMkBbLZTlVQqOnQ4FiMujR6T4DEcCb1xizmvXS+OxuhwcbtynoosRzdMA69zZCShCNAbi+gJ71FxZBBXx1SA==" 3771 3839 }, 3772 3840 "node_modules/jackspeak": { 3773 3841 "version": "3.4.3", ··· 4304 4372 "version": "2.1.3", 4305 4373 "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", 4306 4374 "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" 4375 + }, 4376 + "node_modules/multiformats": { 4377 + "version": "9.9.0", 4378 + "resolved": "https://registry.npmjs.org/multiformats/-/multiformats-9.9.0.tgz", 4379 + "integrity": "sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg==" 4307 4380 }, 4308 4381 "node_modules/mz": { 4309 4382 "version": "2.7.0", ··· 5643 5716 "three": ">=0.162.0 <1.0.0" 5644 5717 } 5645 5718 }, 5719 + "node_modules/tlds": { 5720 + "version": "1.256.0", 5721 + "resolved": "https://registry.npmjs.org/tlds/-/tlds-1.256.0.tgz", 5722 + "integrity": "sha512-ZmyVB9DAw+FFTmLElGYJgdZFsKLYd/I59Bg9NHkCGPwAbVZNRilFWDMAdX8UG+bHuv7kfursd5XGqo/9wi26lA==", 5723 + "bin": { 5724 + "tlds": "bin.js" 5725 + } 5726 + }, 5646 5727 "node_modules/to-regex-range": { 5647 5728 "version": "5.0.1", 5648 5729 "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", ··· 5789 5870 "peerDependencies": { 5790 5871 "eslint": "^8.57.0 || ^9.0.0", 5791 5872 "typescript": ">=4.8.4 <5.8.0" 5873 + } 5874 + }, 5875 + "node_modules/uint8arrays": { 5876 + "version": "3.0.0", 5877 + "resolved": "https://registry.npmjs.org/uint8arrays/-/uint8arrays-3.0.0.tgz", 5878 + "integrity": "sha512-HRCx0q6O9Bfbp+HHSfQQKD7wU70+lydKVt4EghkdOvlK/NlrF90z+eXV34mUd48rNvVJXwkrMSPpCATkct8fJA==", 5879 + "dependencies": { 5880 + "multiformats": "^9.4.2" 5792 5881 } 5793 5882 }, 5794 5883 "node_modules/uri-js": {
+2
package.json
··· 38 38 "vite": "^6.0.0" 39 39 }, 40 40 "dependencies": { 41 + "@atproto/api": "^0.14.7", 41 42 "@number-flow/svelte": "^0.3.3", 42 43 "@sveltejs/adapter-static": "^3.0.8", 43 44 "@threlte/core": "^8.0.1", 44 45 "@threlte/extras": "^9.0.1", 45 46 "@types/three": "^0.173.0", 46 47 "bits-ui": "^1.3.4", 48 + "hls.js": "^1.5.20", 47 49 "layerchart": "^1.0.0", 48 50 "plyr": "^3.7.8", 49 51 "svelte-sonner": "^0.3.28",
+311
src/lib/assets/bluesky-posts.json
··· 1 + { 2 + "posts": [ 3 + { 4 + "uri": "at://did:plc:257wekqxg4hyapkq6k47igmp/app.bsky.feed.post/3lj3hqgiynk2v", 5 + "cid": "bafyreiexa3h3f6s5t5xrvlpxyo3dusqaxkoxoisjcnt5q56izeiquykqza", 6 + "author": { 7 + "did": "did:plc:257wekqxg4hyapkq6k47igmp", 8 + "handle": "flo-bit.dev", 9 + "displayName": "flo-bit", 10 + "avatar": "https://cdn.bsky.app/img/avatar/plain/did:plc:257wekqxg4hyapkq6k47igmp/bafkreib3idjnvcejkgivewbt4jji4wuv2f4rx6tuzwe6xqrzyajp4lmpjm@jpeg", 11 + "associated": { 12 + "chat": { 13 + "allowIncoming": "all" 14 + } 15 + }, 16 + "labels": [], 17 + "createdAt": "2024-11-08T02:10:04.377Z" 18 + }, 19 + "record": { 20 + "$type": "app.bsky.feed.post", 21 + "createdAt": "2025-02-26T13:19:36.422Z", 22 + "embed": { 23 + "$type": "app.bsky.embed.images", 24 + "images": [ 25 + { 26 + "alt": "screenshot of different colored buttons, with the red button being focused and showing a red focus outline", 27 + "aspectRatio": { 28 + "height": 378, 29 + "width": 1130 30 + }, 31 + "image": { 32 + "$type": "blob", 33 + "ref": { 34 + "$link": "bafkreiekmbdm756egttouenhintcn7cljzv2sejwrlw4avnpog664yqkw4" 35 + }, 36 + "mimeType": "image/jpeg", 37 + "size": 150449 38 + } 39 + } 40 + ] 41 + }, 42 + "facets": [ 43 + { 44 + "features": [ 45 + { 46 + "$type": "app.bsky.richtext.facet#tag", 47 + "tag": "a11y" 48 + } 49 + ], 50 + "index": { 51 + "byteEnd": 22, 52 + "byteStart": 17 53 + } 54 + } 55 + ], 56 + "langs": [ 57 + "en" 58 + ], 59 + "text": "question for any #a11y experts, is having different colored focus outlines a big no-no or is that ok?" 60 + }, 61 + "embed": { 62 + "$type": "app.bsky.embed.images#view", 63 + "images": [ 64 + { 65 + "thumb": "https://cdn.bsky.app/img/feed_thumbnail/plain/did:plc:257wekqxg4hyapkq6k47igmp/bafkreiekmbdm756egttouenhintcn7cljzv2sejwrlw4avnpog664yqkw4@jpeg", 66 + "fullsize": "https://cdn.bsky.app/img/feed_fullsize/plain/did:plc:257wekqxg4hyapkq6k47igmp/bafkreiekmbdm756egttouenhintcn7cljzv2sejwrlw4avnpog664yqkw4@jpeg", 67 + "alt": "screenshot of different colored buttons, with the red button being focused and showing a red focus outline", 68 + "aspectRatio": { 69 + "height": 378, 70 + "width": 1130 71 + } 72 + } 73 + ] 74 + }, 75 + "replyCount": 2, 76 + "repostCount": 0, 77 + "likeCount": 5, 78 + "quoteCount": 0, 79 + "indexedAt": "2025-02-26T13:19:38.252Z", 80 + "labels": [] 81 + }, 82 + { 83 + "uri": "at://did:plc:257wekqxg4hyapkq6k47igmp/app.bsky.feed.post/3lj6idw2ibc22", 84 + "cid": "bafyreihv5ytwewfrqfxhw6aw3r724tgox2bhxkwf46e7oa4ir775au53fq", 85 + "author": { 86 + "did": "did:plc:257wekqxg4hyapkq6k47igmp", 87 + "handle": "flo-bit.dev", 88 + "displayName": "flo-bit", 89 + "avatar": "https://cdn.bsky.app/img/avatar/plain/did:plc:257wekqxg4hyapkq6k47igmp/bafkreib3idjnvcejkgivewbt4jji4wuv2f4rx6tuzwe6xqrzyajp4lmpjm@jpeg", 90 + "associated": { 91 + "chat": { 92 + "allowIncoming": "all" 93 + } 94 + }, 95 + "labels": [], 96 + "createdAt": "2024-11-08T02:10:04.377Z" 97 + }, 98 + "record": { 99 + "$type": "app.bsky.feed.post", 100 + "createdAt": "2025-02-27T18:08:29.474Z", 101 + "embed": { 102 + "$type": "app.bsky.embed.external", 103 + "external": { 104 + "description": "my ui kit - work in progress", 105 + "thumb": { 106 + "$type": "blob", 107 + "ref": { 108 + "$link": "bafkreianchlkkifjggpd2rscx365cyzubsalvgcvk5qmnwjjpaooimevpi" 109 + }, 110 + "mimeType": "image/jpeg", 111 + "size": 519938 112 + }, 113 + "title": "my ui kit", 114 + "uri": "https://flo-bit.dev/ui-kit/" 115 + } 116 + }, 117 + "langs": [ 118 + "en" 119 + ], 120 + "reply": { 121 + "parent": { 122 + "cid": "bafyreiclsj47ieg2mtxmnlznr7vsahogotz2dsstg2qn4jqfg77ti52fqi", 123 + "uri": "at://did:plc:257wekqxg4hyapkq6k47igmp/app.bsky.feed.post/3lj6hwahre222" 124 + }, 125 + "root": { 126 + "cid": "bafyreiclsj47ieg2mtxmnlznr7vsahogotz2dsstg2qn4jqfg77ti52fqi", 127 + "uri": "at://did:plc:257wekqxg4hyapkq6k47igmp/app.bsky.feed.post/3lj6hwahre222" 128 + } 129 + }, 130 + "text": "not really ready to be used yet, but if you want to take a looksee:" 131 + }, 132 + "embed": { 133 + "$type": "app.bsky.embed.external#view", 134 + "external": { 135 + "uri": "https://flo-bit.dev/ui-kit/", 136 + "title": "my ui kit", 137 + "description": "my ui kit - work in progress", 138 + "thumb": "https://cdn.bsky.app/img/feed_thumbnail/plain/did:plc:257wekqxg4hyapkq6k47igmp/bafkreianchlkkifjggpd2rscx365cyzubsalvgcvk5qmnwjjpaooimevpi@jpeg" 139 + } 140 + }, 141 + "replyCount": 0, 142 + "repostCount": 0, 143 + "likeCount": 2, 144 + "quoteCount": 0, 145 + "indexedAt": "2025-02-27T18:08:31.951Z", 146 + "labels": [] 147 + }, 148 + { 149 + "uri": "at://did:plc:257wekqxg4hyapkq6k47igmp/app.bsky.feed.post/3lj6hwahre222", 150 + "cid": "bafyreiclsj47ieg2mtxmnlznr7vsahogotz2dsstg2qn4jqfg77ti52fqi", 151 + "author": { 152 + "did": "did:plc:257wekqxg4hyapkq6k47igmp", 153 + "handle": "flo-bit.dev", 154 + "displayName": "flo-bit", 155 + "avatar": "https://cdn.bsky.app/img/avatar/plain/did:plc:257wekqxg4hyapkq6k47igmp/bafkreib3idjnvcejkgivewbt4jji4wuv2f4rx6tuzwe6xqrzyajp4lmpjm@jpeg", 156 + "associated": { 157 + "chat": { 158 + "allowIncoming": "all" 159 + } 160 + }, 161 + "labels": [], 162 + "createdAt": "2024-11-08T02:10:04.377Z" 163 + }, 164 + "record": { 165 + "$type": "app.bsky.feed.post", 166 + "createdAt": "2025-02-27T18:00:50.633Z", 167 + "embed": { 168 + "$type": "app.bsky.embed.video", 169 + "alt": "screen recording of the hero section of my ui kit website, showcasing different themes and components", 170 + "aspectRatio": { 171 + "height": 1080, 172 + "width": 1620 173 + }, 174 + "video": { 175 + "$type": "blob", 176 + "ref": { 177 + "$link": "bafkreie3uo63ncfttybgnm2vfaqjskkervo3qv2wbpwas4kianffr7f3qu" 178 + }, 179 + "mimeType": "video/mp4", 180 + "size": 569758 181 + } 182 + }, 183 + "langs": [ 184 + "en" 185 + ], 186 + "text": "now that svelte 5 and tailwind 4 has been out for a bit, i'm back to working on my very own svelte+tailwind ui kit" 187 + }, 188 + "embed": { 189 + "$type": "app.bsky.embed.video#view", 190 + "cid": "bafkreie3uo63ncfttybgnm2vfaqjskkervo3qv2wbpwas4kianffr7f3qu", 191 + "playlist": "https://video.bsky.app/watch/did%3Aplc%3A257wekqxg4hyapkq6k47igmp/bafkreie3uo63ncfttybgnm2vfaqjskkervo3qv2wbpwas4kianffr7f3qu/playlist.m3u8", 192 + "thumbnail": "https://video.bsky.app/watch/did%3Aplc%3A257wekqxg4hyapkq6k47igmp/bafkreie3uo63ncfttybgnm2vfaqjskkervo3qv2wbpwas4kianffr7f3qu/thumbnail.jpg", 193 + "alt": "screen recording of the hero section of my ui kit website, showcasing different themes and components", 194 + "aspectRatio": { 195 + "height": 1080, 196 + "width": 1620 197 + } 198 + }, 199 + "replyCount": 3, 200 + "repostCount": 0, 201 + "likeCount": 27, 202 + "quoteCount": 0, 203 + "indexedAt": "2025-02-27T18:00:51.851Z", 204 + "labels": [] 205 + }, 206 + { 207 + "uri": "at://did:plc:257wekqxg4hyapkq6k47igmp/app.bsky.feed.post/3liu4v5sa7s2a", 208 + "cid": "bafyreihe7omhgzthlnb4vitgstmbsgkve4djgb6judrq32hjfojgsmcoke", 209 + "author": { 210 + "did": "did:plc:257wekqxg4hyapkq6k47igmp", 211 + "handle": "flo-bit.dev", 212 + "displayName": "flo-bit", 213 + "avatar": "https://cdn.bsky.app/img/avatar/plain/did:plc:257wekqxg4hyapkq6k47igmp/bafkreib3idjnvcejkgivewbt4jji4wuv2f4rx6tuzwe6xqrzyajp4lmpjm@jpeg", 214 + "associated": { 215 + "chat": { 216 + "allowIncoming": "all" 217 + } 218 + }, 219 + "labels": [], 220 + "createdAt": "2024-11-08T02:10:04.377Z" 221 + }, 222 + "record": { 223 + "$type": "app.bsky.feed.post", 224 + "createdAt": "2025-02-23T15:16:45.727Z", 225 + "embed": { 226 + "$type": "app.bsky.embed.images", 227 + "images": [ 228 + { 229 + "alt": "screenshot of documentation showing the quick start section", 230 + "aspectRatio": { 231 + "height": 1051, 232 + "width": 2000 233 + }, 234 + "image": { 235 + "$type": "blob", 236 + "ref": { 237 + "$link": "bafkreiaxec7fj34235gkd2tsuh6u5zcntwwf3ehvqzbubqqiwrvyg7c6xq" 238 + }, 239 + "mimeType": "image/jpeg", 240 + "size": 351879 241 + } 242 + } 243 + ] 244 + }, 245 + "facets": [ 246 + { 247 + "$type": "app.bsky.richtext.facet", 248 + "features": [ 249 + { 250 + "$type": "app.bsky.richtext.facet#mention", 251 + "did": "did:plc:6kf6jxl44h34mprhykvqljcx" 252 + } 253 + ], 254 + "index": { 255 + "byteEnd": 81, 256 + "byteStart": 69 257 + } 258 + }, 259 + { 260 + "features": [ 261 + { 262 + "$type": "app.bsky.richtext.facet#link", 263 + "uri": "https://github.com/flo-bit/dogumentation" 264 + } 265 + ], 266 + "index": { 267 + "byteEnd": 118, 268 + "byteStart": 92 269 + } 270 + }, 271 + { 272 + "features": [ 273 + { 274 + "$type": "app.bsky.richtext.facet#link", 275 + "uri": "https://flo-bit.dev/dogumentation/" 276 + } 277 + ], 278 + "index": { 279 + "byteEnd": 156, 280 + "byteStart": 130 281 + } 282 + } 283 + ], 284 + "langs": [ 285 + "en" 286 + ], 287 + "text": "currently making a simple, minimalistic documentation template using @astro.build \n\nsource: github.com/flo-bit/dogu...\nlive demo: flo-bit.dev/dogumentation/" 288 + }, 289 + "embed": { 290 + "$type": "app.bsky.embed.images#view", 291 + "images": [ 292 + { 293 + "thumb": "https://cdn.bsky.app/img/feed_thumbnail/plain/did:plc:257wekqxg4hyapkq6k47igmp/bafkreiaxec7fj34235gkd2tsuh6u5zcntwwf3ehvqzbubqqiwrvyg7c6xq@jpeg", 294 + "fullsize": "https://cdn.bsky.app/img/feed_fullsize/plain/did:plc:257wekqxg4hyapkq6k47igmp/bafkreiaxec7fj34235gkd2tsuh6u5zcntwwf3ehvqzbubqqiwrvyg7c6xq@jpeg", 295 + "alt": "screenshot of documentation showing the quick start section", 296 + "aspectRatio": { 297 + "height": 1051, 298 + "width": 2000 299 + } 300 + } 301 + ] 302 + }, 303 + "replyCount": 1, 304 + "repostCount": 0, 305 + "likeCount": 7, 306 + "quoteCount": 0, 307 + "indexedAt": "2025-02-23T15:16:49.557Z", 308 + "labels": [] 309 + } 310 + ] 311 + }
+1 -1
src/lib/preview/GradientPreview.svelte
··· 6 6 7 7 <Subheading class="mb-6">Gradient</Subheading> 8 8 9 - <Box class="bg-base-50 dark:bg-base-950"> 9 + <Box class="bg-base-50 dark:bg-base-950 min-h-96"> 10 10 <Gradient /> 11 11 </Box>
+27
src/lib/preview/PostPreview.svelte
··· 1 + <script lang="ts"> 2 + import { blueskyPostToPostData, type PostData } from '$lib/components/post'; 3 + import Post from '$lib/components/post/Post.svelte'; 4 + import { onMount } from 'svelte'; 5 + 6 + import data from '$lib/assets/bluesky-posts.json'; 7 + import Subheading from '$lib/components/heading/Subheading.svelte'; 8 + 9 + let blueskyPosts: PostData[] | null = $derived( 10 + data.posts.map((post: unknown) => { 11 + return blueskyPostToPostData(post); 12 + }) 13 + ); 14 + 15 + </script> 16 + 17 + <Subheading class="mb-4">Posts from Bluesky</Subheading> 18 + 19 + <div class="divide-base-500/20 mx-auto flex w-full flex-col divide-y"> 20 + {#if blueskyPosts} 21 + {#each blueskyPosts as post} 22 + <Post data={post} class="py-4"> 23 + hello 24 + </Post> 25 + {/each} 26 + {/if} 27 + </div>
+2 -1
src/routes/components/+layout.svelte
··· 49 49 { label: 'Number Input', href: 'number-input' }, 50 50 { label: 'Social Icons', href: 'social-icons' }, 51 51 { label: 'Waveform Audio Player', href: 'waveform-audio-player' }, 52 - { label: 'Image Card', href: 'image-card' } 52 + { label: 'Image Card', href: 'image-card' }, 53 + { label: 'Post', href: 'post' } 53 54 ]; 54 55 55 56 // sort components by label
+4
src/routes/components/+page.svelte
··· 35 35 import TimerPreview from '$lib/preview/TimerPreview.svelte'; 36 36 import WaveformAudioPlayerPreview from '$lib/preview/WaveformAudioPlayerPreview.svelte'; 37 37 import ImageCardPreview from '$lib/preview/ImageCardPreview.svelte'; 38 + import PostPreview from '$lib/preview/PostPreview.svelte'; 38 39 </script> 39 40 40 41 <Heading class="mb-8">All Components</Heading> ··· 99 100 </div> 100 101 <div class="flex flex-col gap-4 py-8"> 101 102 <PhonePreview></PhonePreview> 103 + </div> 104 + <div class="flex flex-col gap-4 py-8"> 105 + <PostPreview></PostPreview> 102 106 </div> 103 107 <div class="flex flex-col gap-4 py-8"> 104 108 <ProgressPreview></ProgressPreview>
+12 -2
src/lib/components/button/Button.svelte
··· 12 12 'focus-visible:outline-accent-500 border border-accent-500/20 dark:border-accent-500/20 hover:bg-accent-500/20 dark:hover:bg-accent-500/20 bg-accent-500/10 dark:bg-accent-500/10 text-accent-700 dark:text-accent-400', 13 13 secondary: 14 14 'focus-visible:outline-base-800 dark:focus-visible:outline-base-200 bg-base-300/30 dark:bg-base-800/50 text-base-900 dark:text-base-50 hover:bg-base-300/50 dark:hover:bg-base-700/50 border border-base-300/50 dark:border-base-700/30', 15 - link: 'focus-visible:outline-base-900 dark:focus-visible:outline-base-50 text-base-800 dark:text-base-200 font-semibold hover:text-accent-600 dark:hover:text-accent-400', 15 + link: 'focus-visible:outline-base-900 dark:focus-visible:outline-base-50 text-base-800 dark:text-base-200 font-semibold hover:text-accent-600 dark:hover:text-accent-400 data-[current=true]:text-accent-600 dark:data-[current=true]:text-accent-400', 16 16 17 17 red: 'focus-visible:outline-red-500 border border-red-500/20 dark:border-red-500/20 hover:bg-red-500/20 dark:hover:bg-red-500/20 bg-red-500/10 dark:bg-red-500/10 text-red-700 dark:text-red-400', 18 18 yellow: ··· 63 63 WithElementRef<HTMLAnchorAttributes> & { 64 64 variant?: ButtonVariant; 65 65 size?: ButtonSize; 66 + checkCurrent?: boolean; 66 67 }; 67 68 </script> 68 69 69 70 <script lang="ts"> 71 + import { page } from '$app/state'; 72 + 70 73 let { 71 74 class: className, 72 75 variant = 'primary', ··· 75 78 href = undefined, 76 79 type = 'button', 77 80 children, 81 + checkCurrent = true, 78 82 ...restProps 79 83 }: ButtonProps = $props(); 80 84 </script> 81 85 82 86 {#if href} 83 - <a bind:this={ref} class={cn(buttonVariants({ variant, size }), className)} {href} {...restProps}> 87 + <a 88 + bind:this={ref} 89 + data-current={variant === 'link' && checkCurrent && page.url.pathname === href} 90 + class={cn(buttonVariants({ variant, size }), className)} 91 + {href} 92 + {...restProps} 93 + > 84 94 {@render children?.()} 85 95 </a> 86 96 {:else}
+1 -1
src/lib/components/heatmap/Heatmap.svelte
··· 1 1 <script lang="ts"> 2 2 let colors = [ 3 - 'bg-accent-200/50 dark:bg-accent-950/30', 3 + 'bg-accent-200/50 dark:bg-accent-950/50', 4 4 'bg-accent-300/50 dark:bg-accent-800/70', 5 5 'bg-accent-400/80 dark:bg-accent-700', 6 6 'bg-accent-500/80 dark:bg-accent-600',
+262
src/lib/components/post/Post.svelte
··· 1 + 2 + 3 + <script lang="ts"> 4 + import RelativeTime from './relative-time'; 5 + import Embed from './embeds/Embed.svelte'; 6 + import { cn } from '$lib/utils'; 7 + import Avatar from '../avatar/Avatar.svelte'; 8 + import type { WithChildren, WithElementRef } from 'bits-ui'; 9 + import type { HTMLAttributes } from 'svelte/elements'; 10 + import type { PostData } from '.'; 11 + 12 + let { 13 + ref, 14 + data, 15 + class: className, 16 + bookmarked = $bindable(false), 17 + liked = $bindable(false), 18 + children 19 + }: WithElementRef<WithChildren<HTMLAttributes<HTMLDivElement>>> & { 20 + data: PostData; 21 + class?: string; 22 + intersect?: () => void; 23 + bookmarked?: boolean; 24 + liked?: boolean; 25 + } = $props(); 26 + 27 + export function numberToHumanReadable(number: number) { 28 + if (number < 1000) { 29 + return number; 30 + } 31 + if (number < 1000000) { 32 + return `${Math.floor(number / 1000)}k`; 33 + } 34 + return `${Math.floor(number / 1000000)}m`; 35 + } 36 + </script> 37 + 38 + <div 39 + bind:this={ref} 40 + class={cn('text-base-950 dark:text-base-50 transition-colors duration-200', className)} 41 + > 42 + {#if data.reposted} 43 + <div class="mb-3 inline-flex items-center gap-2 pl-4 text-xs font-medium"> 44 + <svg 45 + xmlns="http://www.w3.org/2000/svg" 46 + viewBox="0 0 24 24" 47 + fill="currentColor" 48 + class="size-3" 49 + > 50 + <path 51 + fill-rule="evenodd" 52 + d="M4.755 10.059a7.5 7.5 0 0 1 12.548-3.364l1.903 1.903h-3.183a.75.75 0 1 0 0 1.5h4.992a.75.75 0 0 0 .75-.75V4.356a.75.75 0 0 0-1.5 0v3.18l-1.9-1.9A9 9 0 0 0 3.306 9.67a.75.75 0 1 0 1.45.388Zm15.408 3.352a.75.75 0 0 0-.919.53 7.5 7.5 0 0 1-12.548 3.364l-1.902-1.903h3.183a.75.75 0 0 0 0-1.5H2.984a.75.75 0 0 0-.75.75v4.992a.75.75 0 0 0 1.5 0v-3.18l1.9 1.9a9 9 0 0 0 15.059-4.035.75.75 0 0 0-.53-.918Z" 53 + clip-rule="evenodd" 54 + /> 55 + </svg> 56 + 57 + <div class="inline-flex gap-1"> 58 + reposted by 59 + <a href={data.reposted.href} class="hover:text-accent-400 font-bold"> 60 + @{data.reposted.handle} 61 + </a> 62 + </div> 63 + </div> 64 + {/if} 65 + <div class="flex gap-4"> 66 + <Avatar src={data.author.avatar} /> 67 + 68 + <div class="w-full"> 69 + <div class="mb-1 flex items-start justify-between gap-2"> 70 + <a 71 + class="group hover:bg-accent-900/5 -mx-2 -my-0.5 flex flex-col items-baseline gap-x-2 gap-y-0.5 rounded-xl px-2 py-0.5 sm:flex-row" 72 + href={data.author.href} 73 + > 74 + <div 75 + class="text-base-900 group-hover:text-accent-600 dark:text-base-50 dark:group-hover:text-accent-300 text-sm leading-tight font-semibold" 76 + > 77 + {data.author.displayName} 78 + </div> 79 + <div 80 + class="text-base-600 group-hover:text-accent-600 dark:text-base-400 dark:group-hover:text-accent-400 text-sm" 81 + > 82 + @{data.author.handle} 83 + </div> 84 + </a> 85 + 86 + {#if data.createdAt} 87 + <a 88 + href={'#'} 89 + class="text-base-600 hover:text-accent-700 dark:text-base-400 dark:hover:text-accent-300 block text-sm no-underline" 90 + > 91 + <RelativeTime date={new Date(data.createdAt)} locale="en" /> 92 + </a> 93 + {/if} 94 + </div> 95 + 96 + <div class="prose-base"> 97 + <div 98 + class="prose dark:prose-invert prose-sm prose-a:no-underline prose-a:text-accent-600 dark:prose-a:text-accent-500" 99 + > 100 + {#if data.htmlContent} 101 + {@html data.htmlContent} 102 + {:else} 103 + {@render children?.()} 104 + {/if} 105 + </div> 106 + </div> 107 + 108 + <Embed {data} /> 109 + 110 + <div class="text-base-500 dark:text-base-400 mt-4 flex justify-between gap-2"> 111 + <button class="group inline-flex items-center gap-2 text-sm"> 112 + <svg 113 + xmlns="http://www.w3.org/2000/svg" 114 + fill="none" 115 + viewBox="0 0 24 24" 116 + stroke-width="1.5" 117 + stroke="currentColor" 118 + class="group-hover:bg-accent-500/10 group-hover:text-accent-700 dark:group-hover:text-accent-400 -m-1.5 size-7 rounded-full p-1.5 transition-all duration-100" 119 + > 120 + <path 121 + stroke-linecap="round" 122 + stroke-linejoin="round" 123 + d="M12 20.25c4.97 0 9-3.694 9-8.25s-4.03-8.25-9-8.25S3 7.444 3 12c0 2.104.859 4.023 2.273 5.48.432.447.74 1.04.586 1.641a4.483 4.483 0 0 1-.923 1.785A5.969 5.969 0 0 0 6 21c1.282 0 2.47-.402 3.445-1.087.81.22 1.668.337 2.555.337Z" 124 + /> 125 + </svg> 126 + {#if data.replyCount} 127 + {numberToHumanReadable(data.replyCount)} 128 + {/if} 129 + </button> 130 + 131 + <button class="group inline-flex items-center gap-2 text-sm"> 132 + <svg 133 + xmlns="http://www.w3.org/2000/svg" 134 + fill="none" 135 + viewBox="0 0 24 24" 136 + stroke-width="1.5" 137 + stroke="currentColor" 138 + class="group-hover:bg-accent-500/10 group-hover:text-accent-700 dark:group-hover:text-accent-400 -m-1.5 size-7 rounded-full p-1.5 transition-all duration-100" 139 + > 140 + <path 141 + stroke-linecap="round" 142 + stroke-linejoin="round" 143 + d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0 3.181 3.183a8.25 8.25 0 0 0 13.803-3.7M4.031 9.865a8.25 8.25 0 0 1 13.803-3.7l3.181 3.182m0-4.991v4.99" 144 + /> 145 + </svg> 146 + {#if data.repostCount} 147 + {numberToHumanReadable(data.repostCount)} 148 + {/if} 149 + </button> 150 + 151 + <button class="group inline-flex items-center gap-2 text-sm" onclick={() => {}}> 152 + {#if liked} 153 + <svg 154 + xmlns="http://www.w3.org/2000/svg" 155 + viewBox="0 0 24 24" 156 + fill="currentColor" 157 + class="group-hover:bg-accent-500/10 group-hover:text-accent-700 dark:group-hover:text-accent-400 -m-1.5 size-7 rounded-full p-1.5 transition-all duration-100" 158 + > 159 + <path 160 + d="m11.645 20.91-.007-.003-.022-.012a15.247 15.247 0 0 1-.383-.218 25.18 25.18 0 0 1-4.244-3.17C4.688 15.36 2.25 12.174 2.25 8.25 2.25 5.322 4.714 3 7.688 3A5.5 5.5 0 0 1 12 5.052 5.5 5.5 0 0 1 16.313 3c2.973 0 5.437 2.322 5.437 5.25 0 3.925-2.438 7.111-4.739 9.256a25.175 25.175 0 0 1-4.244 3.17 15.247 15.247 0 0 1-.383.219l-.022.012-.007.004-.003.001a.752.752 0 0 1-.704 0l-.003-.001Z" 161 + /> 162 + </svg> 163 + {:else} 164 + <svg 165 + xmlns="http://www.w3.org/2000/svg" 166 + fill="none" 167 + viewBox="0 0 24 24" 168 + stroke-width="1.5" 169 + stroke="currentColor" 170 + class="group-hover:bg-accent-500/10 group-hover:text-accent-700 dark:group-hover:text-accent-400 -m-1.5 size-7 rounded-full p-1.5 transition-all duration-100" 171 + > 172 + <path 173 + stroke-linecap="round" 174 + stroke-linejoin="round" 175 + d="M21 8.25c0-2.485-2.099-4.5-4.688-4.5-1.935 0-3.597 1.126-4.312 2.733-.715-1.607-2.377-2.733-4.313-2.733C5.1 3.75 3 5.765 3 8.25c0 7.22 9 12 9 12s9-4.78 9-12Z" 176 + /> 177 + </svg> 178 + {/if} 179 + {#if data.likeCount} 180 + {numberToHumanReadable(data.likeCount)} 181 + {/if} 182 + </button> 183 + 184 + <button onclick={async () => {}} class="group inline-flex items-center gap-2 text-sm"> 185 + <span class="sr-only">Bookmark</span> 186 + 187 + {#if bookmarked} 188 + <svg 189 + xmlns="http://www.w3.org/2000/svg" 190 + viewBox="0 0 24 24" 191 + fill="currentColor" 192 + class="group-hover:bg-accent-500/10 group-hover:text-accent-700 dark:group-hover:text-accent-400 -m-1.5 size-7 rounded-full p-1.5 transition-all duration-100" 193 + > 194 + <path 195 + fill-rule="evenodd" 196 + d="M6.32 2.577a49.255 49.255 0 0 1 11.36 0c1.497.174 2.57 1.46 2.57 2.93V21a.75.75 0 0 1-1.085.67L12 18.089l-7.165 3.583A.75.75 0 0 1 3.75 21V5.507c0-1.47 1.073-2.756 2.57-2.93Z" 197 + clip-rule="evenodd" 198 + /> 199 + </svg> 200 + {:else} 201 + <svg 202 + xmlns="http://www.w3.org/2000/svg" 203 + fill="none" 204 + viewBox="0 0 24 24" 205 + stroke-width="1.5" 206 + stroke="currentColor" 207 + class="group-hover:bg-accent-500/10 group-hover:text-accent-700 dark:group-hover:text-accent-400 -m-1.5 size-7 rounded-full p-1.5 transition-all duration-100" 208 + > 209 + <path 210 + stroke-linecap="round" 211 + stroke-linejoin="round" 212 + d="M17.593 3.322c1.1.128 1.907 1.077 1.907 2.185V21L12 17.25 4.5 21V5.507c0-1.108.806-2.057 1.907-2.185a48.507 48.507 0 0 1 11.186 0Z" 213 + /> 214 + </svg> 215 + {/if} 216 + </button> 217 + </div> 218 + </div> 219 + </div> 220 + </div> 221 + 222 + <style> 223 + .prose-base { 224 + --tw-prose-body: var(--color-base-700); 225 + --tw-prose-headings: var(--color-base-900); 226 + --tw-prose-lead: var(--color-base-600); 227 + --tw-prose-links: var(--color-base-900); 228 + --tw-prose-bold: var(--color-base-900); 229 + --tw-prose-counters: var(--color-base-500); 230 + --tw-prose-bullets: var(--color-base-300); 231 + --tw-prose-hr: var(--color-base-200); 232 + --tw-prose-quotes: var(--color-base-900); 233 + --tw-prose-quote-borders: var(--color-base-200); 234 + --tw-prose-captions: var(--color-base-500); 235 + --tw-prose-kbd: var(--color-base-900); 236 + --tw-prose-kbd-shadows: var(--color-base-900); 237 + --tw-prose-code: var(--color-base-900); 238 + --tw-prose-pre-code: var(--color-base-200); 239 + --tw-prose-pre-bg: var(--color-base-800); 240 + --tw-prose-th-borders: var(--color-base-300); 241 + --tw-prose-td-borders: var(--color-base-200); 242 + 243 + --tw-prose-invert-body: var(--color-base-300); 244 + --tw-prose-invert-headings: var(--color-white); 245 + --tw-prose-invert-lead: var(--color-base-400); 246 + --tw-prose-invert-links: var(--color-white); 247 + --tw-prose-invert-bold: var(--color-white); 248 + --tw-prose-invert-counters: var(--color-base-400); 249 + --tw-prose-invert-bullets: var(--color-base-600); 250 + --tw-prose-invert-hr: var(--color-base-700); 251 + --tw-prose-invert-quotes: var(--color-base-100); 252 + --tw-prose-invert-quote-borders: var(--color-base-700); 253 + --tw-prose-invert-captions: var(--color-base-400); 254 + --tw-prose-invert-kbd: var(--color-white); 255 + --tw-prose-invert-kbd-shadows: var(--color-white); 256 + --tw-prose-invert-code: var(--color-white); 257 + --tw-prose-invert-pre-code: var(--color-base-300); 258 + --tw-prose-invert-pre-bg: rgb(0 0 0 / 50%); 259 + --tw-prose-invert-th-borders: var(--color-base-600); 260 + --tw-prose-invert-td-borders: var(--color-base-700); 261 + } 262 + </style>
+156
src/lib/components/post/index.ts
··· 1 + import { RichText } from '@atproto/api'; 2 + 3 + export type PostEmbedImage = { 4 + type: 'images'; 5 + 6 + images: { 7 + alt: string; 8 + thumb: string; 9 + fullsize: string; 10 + aspectRatio?: { 11 + width: number; 12 + height: number; 13 + }; 14 + }[]; 15 + }; 16 + 17 + export type PostEmbedExternal = { 18 + type: 'external'; 19 + 20 + external: { 21 + href: string; 22 + thumb: string; 23 + title: string; 24 + description: string; 25 + }; 26 + }; 27 + 28 + export type PostEmbedVideo = { 29 + type: 'video'; 30 + 31 + video: { 32 + playlist: string; 33 + 34 + thumb: string; 35 + alt: string; 36 + 37 + aspectRatio?: { 38 + width: number; 39 + height: number; 40 + }; 41 + }; 42 + }; 43 + 44 + export type UnknownEmbed = { 45 + type: 'unknown'; 46 + } & Record<string, unknown>; 47 + 48 + function blueskyEmbedTypeToEmbedType(type: string) { 49 + switch (type) { 50 + case 'app.bsky.embed.external#view': 51 + case 'app.bsky.embed.external': 52 + return 'external'; 53 + case 'app.bsky.embed.images#view': 54 + case 'app.bsky.embed.images': 55 + return 'images'; 56 + case 'app.bsky.embed.video#view': 57 + case 'app.bsky.embed.video': 58 + return 'video'; 59 + default: 60 + return 'unknown'; 61 + } 62 + } 63 + 64 + export type PostData = { 65 + reposted?: { handle: string; href: string }; 66 + 67 + author: { 68 + displayName: string; 69 + handle: string; 70 + avatar: string; 71 + href: string; 72 + }; 73 + 74 + replyCount: number; 75 + repostCount: number; 76 + likeCount: number; 77 + 78 + createdAt: string; 79 + 80 + embed?: PostEmbedImage | PostEmbedExternal | PostEmbedVideo | UnknownEmbed; 81 + 82 + htmlContent?: string; 83 + }; 84 + 85 + // eslint-disable-next-line @typescript-eslint/no-explicit-any 86 + export function blueskyPostToPostData(post: any): PostData { 87 + return { 88 + author: { 89 + displayName: post.author.displayName, 90 + handle: post.author.handle, 91 + avatar: post.author.avatar, 92 + href: '#' 93 + }, 94 + replyCount: post.replyCount, 95 + repostCount: post.repostCount, 96 + likeCount: post.likeCount, 97 + createdAt: post.createdAt, 98 + 99 + embed: { 100 + type: blueskyEmbedTypeToEmbedType(post.embed.$type), 101 + // eslint-disable-next-line @typescript-eslint/no-explicit-any 102 + images: post.embed.images?.map((image: any) => ({ 103 + alt: image.alt, 104 + thumb: image.thumb, 105 + aspectRatio: image.aspectRatio, 106 + fullsize: image.fullsize 107 + })), 108 + external: post.embed.external 109 + ? { 110 + href: post.embed.external.uri, 111 + title: post.embed.external.title, 112 + description: post.embed.external.description, 113 + thumb: post.embed.external.thumb 114 + } 115 + : undefined, 116 + video: post.embed 117 + ? { 118 + playlist: post.embed.playlist, 119 + thumb: post.embed.thumbnail, 120 + alt: post.embed.alt, 121 + aspectRatio: post.embed.aspectRatio 122 + } 123 + : undefined 124 + } as PostEmbedExternal | PostEmbedVideo | PostEmbedImage | UnknownEmbed, 125 + 126 + htmlContent: blueskyPostToHTML(post) 127 + }; 128 + } 129 + 130 + // eslint-disable-next-line @typescript-eslint/no-explicit-any 131 + export function blueskyPostToHTML(post: any, baseBskyUrl: string = 'https://bsky.app') { 132 + if (!post?.record) { 133 + return ''; 134 + } 135 + const rt = new RichText(post.record); 136 + let html = ''; 137 + 138 + const createLink = (href: string, text: string) => { 139 + return `<a target="_blank" rel="noopener noreferrer nofollow" href="${encodeURI(href)}">${encodeURI(text)}</a>`; 140 + }; 141 + 142 + for (const segment of rt.segments()) { 143 + if (!segment) continue; 144 + if (segment.isLink() && segment.link?.uri) { 145 + html += createLink(segment.link?.uri, segment.text); 146 + } else if (segment.isMention() && segment.mention?.did) { 147 + html += createLink(`${baseBskyUrl}/profile/${segment.mention?.did}`, segment.text); 148 + } else if (segment.isTag() && segment.tag?.tag) { 149 + html += createLink(`${baseBskyUrl}/hashtag/${segment.tag?.tag}`, segment.text); 150 + } else { 151 + html += segment.text; 152 + } 153 + } 154 + 155 + return html.replace(/\n/g, '<br>'); 156 + }
+1 -1
src/lib/components/ring-chart/RingChart.svelte
··· 54 54 <div 55 55 bind:this={ref} 56 56 class={cn( 57 - 'flex w-full items-center gap-2 overflow-hidden', 57 + 'flex w-full items-end gap-2 overflow-hidden', 58 58 showLegend ? 'justify-between' : 'justify-center', 59 59 className 60 60 )}
+1 -1
src/lib/components/video-player/FullscreenVideoPlayer.svelte
··· 154 154 155 155 <style> 156 156 * { 157 - --plyr-color-main: hsl(var(--accent-500)); 157 + --plyr-color-main: var(--color-accent-500); 158 158 } 159 159 </style>
+1 -1
src/lib/components/video-player/VideoPlayer.svelte
··· 84 84 85 85 <style> 86 86 * { 87 - --plyr-color-main: hsl(var(--accent-500)); 87 + --plyr-color-main: var(--color-accent-500); 88 88 } 89 89 </style>
+5
src/routes/components/post/+page.svelte
··· 1 + <script> 2 + import PostPreview from '$lib/preview/PostPreview.svelte'; 3 + </script> 4 + 5 + <PostPreview />
+22
src/lib/components/post/embeds/Embed.svelte
··· 1 + <script lang="ts"> 2 + import type { PostData } from '../'; 3 + import External from './External.svelte'; 4 + import Images from './Images.svelte'; 5 + import Video from './Video.svelte'; 6 + 7 + const { data }: { data: PostData } = $props(); 8 + </script> 9 + 10 + {#if data.embed} 11 + <div class="flex flex-col gap-2 pt-3 text-sm"> 12 + {#if data.embed.type === 'images'} 13 + <Images data={data.embed} /> 14 + {:else if data.embed.type === 'external' && data.embed.external} 15 + <External data={data.embed} /> 16 + {:else if data.embed.type === 'video' && data.embed.video} 17 + <Video data={data.embed} /> 18 + {:else if data.embed.type === 'unknown'} 19 + <div class="text-base-700 dark:text-base-300 text-sm p-4 rounded-2xl bg-base-200/50 dark:bg-base-900/50 border border-base-300 dark:border-base-600/30">Unknown embed type</div> 20 + {/if} 21 + </div> 22 + {/if}
+32
src/lib/components/post/embeds/External.svelte
··· 1 + <script lang="ts"> 2 + import type { PostEmbedExternal } from ".."; 3 + 4 + const { data }: {data: PostEmbedExternal} = $props(); 5 + 6 + const domain = new URL(data.external.href).hostname.replace('www.', ''); 7 + </script> 8 + 9 + <article 10 + class={["group dark:bg-base-900 bg-base-200 border-base-300 dark:border-base-600/30 relative isolate flex flex-col justify-end overflow-hidden rounded-2xl border p-4", 11 + data.external.thumb ? 'aspect-[16/9]' : '' 12 + ]} 13 + > 14 + {#if data.external.thumb} 15 + <img 16 + src={data.external.thumb} 17 + alt={data.external.description} 18 + class="absolute inset-0 -z-10 size-full object-cover transition-transform duration-500 group-hover:scale-105 ease-in-out" 19 + /> 20 + {/if} 21 + <div class="dark:from-base-950/90 dark:via-base-950/40 from-base-50/90 via-base-50/40 absolute inset-0 -z-10 bg-gradient-to-t"></div> 22 + 23 + <div class="text-base-700 dark:text-base-300 flex flex-wrap items-center gap-y-1 overflow-hidden text-sm"> 24 + <div>{domain}</div> 25 + </div> 26 + <h3 class="mt-1 text-lg/6 font-semibold dark:text-base-50 text-base-900 group-hover:text-accent-600 dark:group-hover:text-accent-400 transition-colors duration-200"> 27 + <a href={data.external.href} target="_blank" rel="noopener noreferrer nofollow"> 28 + <span class="absolute inset-0"></span> 29 + {data.external.title} 30 + </a> 31 + </h3> 32 + </article>
+32
src/lib/components/post/embeds/Images.svelte
··· 1 + <script lang="ts"> 2 + import type { PostEmbedImage } from '..'; 3 + 4 + const { data }: { data: PostEmbedImage } = $props(); 5 + </script> 6 + 7 + {#snippet imageSnippet(image: { 8 + alt: string; 9 + thumb: string; 10 + fullsize: string; 11 + aspectRatio?: { width: number; height: number }; 12 + })} 13 + <img 14 + loading="lazy" 15 + src={image.thumb} 16 + alt={image.alt} 17 + style={image.aspectRatio 18 + ? `aspect-ratio: ${image.aspectRatio.width} / ${image.aspectRatio.height}` 19 + : 'aspect-ratio: 1 / 1'} 20 + class="border-base-500/20 dark:border-base-400/20 w-full rounded-2xl border object-cover" 21 + /> 22 + {/snippet} 23 + 24 + {#if data.images.length === 1} 25 + {@render imageSnippet(data.images[0])} 26 + {:else} 27 + <div class="grid grid-cols-2 gap-4"> 28 + {#each data.images as image} 29 + {@render imageSnippet(image)} 30 + {/each} 31 + </div> 32 + {/if}
+53
src/lib/components/post/embeds/Video.svelte
··· 1 + <script lang="ts"> 2 + import { onMount } from 'svelte'; 3 + import Hls from 'hls.js'; 4 + import type { PostEmbedVideo } from '..'; 5 + 6 + const { data }: { data: PostEmbedVideo } = $props(); 7 + 8 + onMount(async () => { 9 + const Plyr = (await import('plyr')).default; 10 + if (Hls.isSupported()) { 11 + var hls = new Hls(); 12 + hls.loadSource(data.video.playlist); 13 + hls.attachMedia(element); 14 + } 15 + 16 + new Plyr(element, { 17 + controls: [ 18 + 'play-large', 19 + 'play', 20 + 'progress', 21 + 'current-time', 22 + 'mute', 23 + 'volume', 24 + 'fullscreen', 25 + ], 26 + ratio: data.video.aspectRatio 27 + ? `${data.video.aspectRatio.width}:${data.video.aspectRatio.height}` 28 + : '16:9' 29 + }); 30 + }); 31 + 32 + let element: HTMLMediaElement; 33 + </script> 34 + 35 + <svelte:head> 36 + <link rel="stylesheet" href="https://cdn.plyr.io/3.7.8/plyr.css" /> 37 + </svelte:head> 38 + 39 + <div 40 + style={data.video.aspectRatio 41 + ? `aspect-ratio: ${data.video.aspectRatio.width} / ${data.video.aspectRatio.height}` 42 + : 'aspect-ratio: 16 / 9'} 43 + class="border-base-300 dark:border-base-400/40 w-full max-w-full overflow-hidden rounded-2xl border" 44 + > 45 + <!-- svelte-ignore a11y_media_has_caption --> 46 + <video bind:this={element} class="h-full w-full" aria-label={data.video.alt}></video> 47 + </div> 48 + 49 + <style> 50 + * { 51 + --plyr-color-main: var(--color-accent-500); 52 + } 53 + </style>
+17
src/lib/components/post/relative-time/RelativeTime.svelte
··· 1 + <script lang="ts"> 2 + import { onDestroy } from 'svelte'; 3 + import { register, unregister } from './state'; 4 + 5 + export let date: Date | number; 6 + export let locale: string; 7 + export let live = true; 8 + 9 + let instance = new Object(); 10 + let text = ''; 11 + 12 + register(instance, date, locale, live, (value) => ({ text } = value)); 13 + 14 + onDestroy(() => unregister(instance)); 15 + </script> 16 + 17 + <span class={$$props.class}>{text}</span>
+31
src/lib/components/post/relative-time/action.ts
··· 1 + import type { Callback } from './render'; 2 + import { register, unregister } from './state'; 3 + 4 + export interface Options { 5 + date: Date | number; 6 + locale?: string; 7 + live?: boolean; 8 + } 9 + 10 + export function relativeTime(node: HTMLElement, options: Options) { 11 + const callback: Callback = ({ text }) => (node.textContent = text); 12 + 13 + function init(options: Options) { 14 + const date = options.date; 15 + const locale = options.locale ?? navigator.language; 16 + const live = (options.live = true); 17 + 18 + register(node, date, locale, live, callback); 19 + } 20 + 21 + init(options); 22 + 23 + return { 24 + update(options: Options) { 25 + init(options); 26 + }, 27 + destroy() { 28 + unregister(node); 29 + } 30 + }; 31 + }
+12
src/lib/components/post/relative-time/formatter.ts
··· 1 + // keep a cache of formatter per locale, to avoid re-creating them (GC) 2 + const formatters = new Map<string, Intl.RelativeTimeFormat>(); 3 + 4 + // get the Intl.RelativeTimeFormat formatter for the given locale 5 + export function getFormatter(locale: string) { 6 + if (formatters.has(locale)) { 7 + return formatters.get(locale)!; 8 + } 9 + const formatter = new Intl.RelativeTimeFormat(locale, { numeric: 'always', style: 'narrow' }); 10 + formatters.set(locale, formatter); 11 + return formatter; 12 + }
+6
src/lib/components/post/relative-time/index.ts
··· 1 + // adapted from https://github.com/CaptainCodeman/svelte-relative-time 2 + 3 + export * from './action'; 4 + export type { Callback } from './render'; 5 + export { register, unregister } from './state'; 6 + export { default } from './RelativeTime.svelte';
+80
src/lib/components/post/relative-time/render.ts
··· 1 + export type Callback = (result: { 2 + seconds: number; 3 + count: number; 4 + units: Intl.RelativeTimeFormatUnit; 5 + text: string; 6 + }) => void; 7 + 8 + export interface RenderState { 9 + date: Date | number; 10 + callback: Callback; 11 + formatter: Intl.RelativeTimeFormat; 12 + } 13 + 14 + // Array reprsenting one minute, hour, day, week, month, etc in seconds 15 + const cutoffs = [60, 3600, 86400, 86400 * 7, 86400 * 30, 86400 * 365, Infinity]; 16 + 17 + // Array equivalent to the above but in the string representation of the units 18 + const formatUnits: Intl.RelativeTimeFormatUnit[] = [ 19 + 'seconds', 20 + 'minutes', 21 + 'hours', 22 + 'days', 23 + 'weeks', 24 + 'months', 25 + 'years' 26 + ]; 27 + 28 + // function to render relative time into 29 + export function render(state: RenderState, now: number) { 30 + const { date, callback, formatter } = state; 31 + 32 + // Allow dates or times to be passed 33 + const timeMs = typeof date === 'number' ? date : date.getTime(); 34 + 35 + // Get the amount of seconds between the given date and now 36 + const delta = timeMs - now; 37 + const seconds = Math.round(delta / 1000); 38 + 39 + // Grab the ideal cutoff unit 40 + const unitIndex = cutoffs.findIndex((cutoff) => cutoff > Math.abs(seconds)); 41 + 42 + // units 43 + const units = formatUnits[unitIndex]; 44 + 45 + // Get the divisor to divide from the seconds. E.g. if our unit is 'day' our divisor 46 + // is one day in seconds, so we can divide our seconds by this to get the # of days 47 + const divisor = unitIndex ? cutoffs[unitIndex - 1] : 1; 48 + 49 + // count of units 50 + const count = Math.round(seconds / divisor); 51 + 52 + // Intl.RelativeTimeFormat do its magic 53 + callback({ 54 + seconds: seconds, 55 + count, 56 + units, 57 + text: formatter.format(count, units).replace('ago', '') 58 + }); 59 + 60 + // calculate time to next update, taking account rounding (e.g. it goes from 2 minutes to 1 minute at 90 seconds) 61 + // and also the changeover from units (59 seconds shouldn't show as 1 minute, so at 61 seconds we schedule the next 62 + // update for 1 second time) 63 + 64 + const divisorMs = divisor * 1000; 65 + 66 + let updateIn; 67 + 68 + if (unitIndex) { 69 + updateIn = divisorMs / 2 - (Math.abs(delta) % divisorMs); 70 + if (updateIn < 0) { 71 + updateIn += divisorMs; 72 + } 73 + } else { 74 + updateIn = divisorMs - (Math.abs(delta) % divisorMs); 75 + } 76 + 77 + const updateAt = now + updateIn; 78 + 79 + return updateAt; 80 + }
+60
src/lib/components/post/relative-time/state.ts
··· 1 + import { getFormatter } from './formatter'; 2 + import { render } from './render'; 3 + import type { Callback, RenderState } from './render'; 4 + 5 + interface UpdateState extends RenderState { 6 + update: number; 7 + } 8 + 9 + // keep track of each instance 10 + const instances = new Map<Object, UpdateState>(); 11 + 12 + // we use a single timer for efficiency and to keep updates in sync 13 + let updateInterval: number | NodeJS.Timeout; 14 + 15 + // register or update instance 16 + export function register( 17 + instance: Object, 18 + date: Date | number, 19 + locale: string, 20 + live: boolean, 21 + callback: Callback 22 + ) { 23 + // get the formatter for the given locale, we do this here so we don't keep having to look it up on each tick 24 + const formatter = getFormatter(locale); 25 + 26 + // create state to render 27 + const state = { date, callback, formatter }; 28 + 29 + // initial render is immediate, so works for SSR 30 + const update = render(state, Date.now()); 31 + 32 + // if it's to update live, we keep a track and schedule the next update 33 + if (live) { 34 + instances.set(instance, { ...state, update }); 35 + } else { 36 + instances.delete(instance); 37 + } 38 + 39 + // start the clock ticking if there are any live instances 40 + if (instances.size) { 41 + updateInterval = 42 + updateInterval || 43 + setInterval(() => { 44 + const now = Date.now(); 45 + for (const state of instances.values()) { 46 + if (state.update <= now) { 47 + state.update = render(state, now); 48 + } 49 + } 50 + }, 1000); 51 + } 52 + } 53 + 54 + export function unregister(instance: Object) { 55 + instances.delete(instance); 56 + if (instances.size === 0) { 57 + clearInterval(updateInterval); 58 + updateInterval = 0; 59 + } 60 + }