your personal website on atproto - mirror blento.app

small improvements

Florian 3c22fd5b 5a13fdd8

+726 -2
+1
package.json
··· 61 "bits-ui": "^2.14.4", 62 "clsx": "^2.1.1", 63 "gsap": "^3.14.2", 64 "leaflet": "^1.9.4", 65 "link-preview-js": "^4.0.0", 66 "marked": "^15.0.11",
··· 61 "bits-ui": "^2.14.4", 62 "clsx": "^2.1.1", 63 "gsap": "^3.14.2", 64 + "hls.js": "^1.6.15", 65 "leaflet": "^1.9.4", 66 "link-preview-js": "^4.0.0", 67 "marked": "^15.0.11",
+3
pnpm-lock.yaml
··· 68 gsap: 69 specifier: ^3.14.2 70 version: 3.14.2 71 leaflet: 72 specifier: ^1.9.4 73 version: 1.9.4
··· 68 gsap: 69 specifier: ^3.14.2 70 version: 3.14.2 71 + hls.js: 72 + specifier: ^1.6.15 73 + version: 1.6.15 74 leaflet: 75 specifier: ^1.9.4 76 version: 1.9.4
+3 -2
src/lib/cards/BlueskyPostCard/BlueskyPostCard.svelte
··· 1 <script lang="ts"> 2 import { getAdditionalUserData } from '$lib/helper'; 3 - import { BlueskyPost } from '@foxui/social'; 4 5 const feed = getAdditionalUserData().recentPosts?.feed; 6 7 $inspect(feed); 8 </script> 9 10 - <div class="flex max-h-full overflow-y-scroll p-4"> 11 {#if feed?.[0].post} 12 <BlueskyPost showLogo showBookmark={false} feedViewPost={feed?.[0].post}></BlueskyPost> 13 {:else} 14 Your latest bluesky post will appear here. 15 {/if}
··· 1 <script lang="ts"> 2 import { getAdditionalUserData } from '$lib/helper'; 3 + import { BlueskyPost } from '../../components/bluesky-post'; 4 5 const feed = getAdditionalUserData().recentPosts?.feed; 6 7 $inspect(feed); 8 </script> 9 10 + <div class="flex h-full flex-col justify-center-safe overflow-y-scroll p-4"> 11 {#if feed?.[0].post} 12 <BlueskyPost showLogo showBookmark={false} feedViewPost={feed?.[0].post}></BlueskyPost> 13 + <div class="h-4 w-full"></div> 14 {:else} 15 Your latest bluesky post will appear here. 16 {/if}
+36
src/lib/components/bluesky-post/BlueskyPost.svelte
···
··· 1 + <script lang="ts"> 2 + import type { FeedViewPost } from '@atproto/api/dist/client/types/app/bsky/feed/defs'; 3 + import { Post } from '../post'; 4 + import { blueskyPostToPostData } from '.'; 5 + import type { Snippet } from 'svelte'; 6 + 7 + let { 8 + feedViewPost, 9 + children, 10 + showLogo = false, 11 + ...restProps 12 + }: { feedViewPost?: FeedViewPost; children?: Snippet; showLogo?: boolean } = $props(); 13 + 14 + const postData = $derived(feedViewPost ? blueskyPostToPostData(feedViewPost) : undefined); 15 + </script> 16 + 17 + {#snippet logo()} 18 + <a 19 + class="text-accent-700 dark:text-accent-400 hover:text-accent-600 dark:hover:text-accent-500" 20 + href={postData?.href} 21 + > 22 + <svg xmlns="http://www.w3.org/2000/svg" version="1.1" class="size-4" viewBox="0 0 600 530"> 23 + <path 24 + d="m135.72 44.03c66.496 49.921 138.02 151.14 164.28 205.46 26.262-54.316 97.782-155.54 164.28-205.46 47.98-36.021 125.72-63.892 125.72 24.795 0 17.712-10.155 148.79-16.111 170.07-20.703 73.984-96.144 92.854-163.25 81.433 117.3 19.964 147.14 86.092 82.697 152.22-122.39 125.59-175.91-31.511-189.63-71.766-2.514-7.3797-3.6904-10.832-3.7077-7.8964-0.0174-2.9357-1.1937 0.51669-3.7077 7.8964-13.714 40.255-67.233 197.36-189.63 71.766-64.444-66.128-34.605-132.26 82.697-152.22-67.108 11.421-142.55-7.4491-163.25-81.433-5.9562-21.282-16.111-152.36-16.111-170.07 0-88.687 77.742-60.816 125.72-24.795z" 25 + fill="currentColor" 26 + /> 27 + </svg> 28 + <span class="sr-only">Bluesky</span> 29 + </a> 30 + {/snippet} 31 + 32 + {#if postData} 33 + <Post data={postData} logo={showLogo ? logo : undefined} {...restProps}> 34 + {@render children?.()} 35 + </Post> 36 + {/if}
+124
src/lib/components/bluesky-post/index.ts
···
··· 1 + import { RichText } from '@atproto/api'; 2 + import type { PostData, PostEmbed } from '../post'; 3 + import type { PostView } from '@atproto/api/dist/client/types/app/bsky/feed/defs'; 4 + 5 + function blueskyEmbedTypeToEmbedType(type: string) { 6 + console.log(type); 7 + switch (type) { 8 + case 'app.bsky.embed.external#view': 9 + case 'app.bsky.embed.external': 10 + return 'external'; 11 + case 'app.bsky.embed.images#view': 12 + case 'app.bsky.embed.images': 13 + return 'images'; 14 + case 'app.bsky.embed.video#view': 15 + case 'app.bsky.embed.video': 16 + return 'video'; 17 + default: 18 + return 'unknown'; 19 + } 20 + } 21 + 22 + export function blueskyPostToPostData( 23 + data: PostView, 24 + baseUrl: string = 'https://bsky.app' 25 + ): PostData { 26 + console.log(data); 27 + const post = data; 28 + // const reason = data.reason; 29 + // const reply = data.reply?.parent; 30 + // const replyId = reply?.uri?.split('/').pop(); 31 + 32 + const id = post.uri.split('/').pop(); 33 + 34 + return { 35 + id, 36 + href: `${baseUrl}/profile/${post.author.handle}/post/${id}`, 37 + // reposted: 38 + // reason && reason.$type === 'app.bsky.feed.defs#reasonRepost' 39 + // ? { 40 + // handle: reason.by.handle, 41 + // href: `${baseUrl}/profile/${reason.by.handle}` 42 + // } 43 + // : undefined, 44 + 45 + // replyTo: 46 + // reply && replyId 47 + // ? { 48 + // handle: reply.author.handle, 49 + // href: `${baseUrl}/profile/${reply.author.handle}/post/${replyId}` 50 + // } 51 + // : undefined, 52 + author: { 53 + displayName: post.author.displayName, 54 + handle: post.author.handle, 55 + avatar: post.author.avatar, 56 + href: `${baseUrl}/profile/${post.author.did}` 57 + }, 58 + replyCount: post.replyCount ?? 0, 59 + repostCount: post.repostCount ?? 0, 60 + likeCount: post.likeCount ?? 0, 61 + createdAt: post.record.createdAt ?? 0, 62 + 63 + embed: post.embed 64 + ? ({ 65 + type: blueskyEmbedTypeToEmbedType(post.embed?.$type), 66 + // eslint-disable-next-line @typescript-eslint/no-explicit-any 67 + images: post.embed?.images?.map((image: any) => ({ 68 + alt: image.alt, 69 + thumb: image.thumb, 70 + aspectRatio: image.aspectRatio, 71 + fullsize: image.fullsize 72 + })), 73 + external: post.embed?.external 74 + ? { 75 + href: post.embed.external.uri, 76 + title: post.embed.external.title, 77 + description: post.embed.external.description, 78 + thumb: post.embed.external.thumb 79 + } 80 + : undefined, 81 + video: post.embed 82 + ? { 83 + playlist: post.embed.playlist, 84 + thumb: post.embed.thumbnail, 85 + alt: post.embed.alt, 86 + aspectRatio: post.embed.aspectRatio 87 + } 88 + : undefined 89 + } as PostEmbed) 90 + : undefined, 91 + 92 + htmlContent: blueskyPostToHTML(post, baseUrl) 93 + }; 94 + } 95 + 96 + // eslint-disable-next-line @typescript-eslint/no-explicit-any 97 + export function blueskyPostToHTML(post: any, baseUrl: string = 'https://bsky.app') { 98 + if (!post?.record) { 99 + return ''; 100 + } 101 + const rt = new RichText(post.record); 102 + let html = ''; 103 + 104 + const createLink = (href: string, text: string) => { 105 + return `<a target="_blank" rel="noopener noreferrer nofollow" href="${encodeURI(href)}">${encodeURI(text)}</a>`; 106 + }; 107 + 108 + for (const segment of rt.segments()) { 109 + if (!segment) continue; 110 + if (segment.isLink() && segment.link?.uri) { 111 + html += createLink(segment.link?.uri, segment.text); 112 + } else if (segment.isMention() && segment.mention?.did) { 113 + html += createLink(`${baseUrl}/profile/${segment.mention?.did}`, segment.text); 114 + } else if (segment.isTag() && segment.tag?.tag) { 115 + html += createLink(`${baseUrl}/hashtag/${segment.tag?.tag}`, segment.text); 116 + } else { 117 + html += segment.text; 118 + } 119 + } 120 + 121 + return html.replace(/\n/g, '<br>'); 122 + } 123 + 124 + export { default as BlueskyPost } from './BlueskyPost.svelte';
+12
src/lib/components/index.ts
···
··· 1 + 2 + export function numberToHumanReadable(number: number) { 3 + if (number < 1000) { 4 + return number; 5 + } 6 + 7 + if (number < 1000000) { 8 + return `${(number / 1000).toFixed(1)}k`; 9 + } 10 + 11 + return `${(number / 1000000).toFixed(1)}m`; 12 + }
+299
src/lib/components/post/Post.svelte
···
··· 1 + <script lang="ts"> 2 + import Embed from './embeds/Embed.svelte'; 3 + import { cn, Avatar, Prose } from '@foxui/core'; 4 + import type { WithChildren, WithElementRef } from 'bits-ui'; 5 + import type { HTMLAttributes } from 'svelte/elements'; 6 + import type { PostData } from '.'; 7 + import PostAction from './PostAction.svelte'; 8 + import type { Snippet } from 'svelte'; 9 + import { numberToHumanReadable } from '..'; 10 + import { RelativeTime } from '@foxui/time'; 11 + 12 + let { 13 + ref = $bindable(), 14 + data, 15 + class: className, 16 + bookmarked = $bindable(false), 17 + liked = $bindable(false), 18 + 19 + showReply = $bindable(true), 20 + showRepost = $bindable(true), 21 + showLike = $bindable(true), 22 + showBookmark = $bindable(true), 23 + 24 + onReplyClick, 25 + onRepostClick, 26 + onLikeClick, 27 + onBookmarkClick, 28 + 29 + customActions, 30 + 31 + children, 32 + 33 + logo 34 + }: WithElementRef<WithChildren<HTMLAttributes<HTMLDivElement>>> & { 35 + data: PostData; 36 + class?: string; 37 + 38 + bookmarked?: boolean; 39 + liked?: boolean; 40 + 41 + showReply?: boolean; 42 + showRepost?: boolean; 43 + showLike?: boolean; 44 + showBookmark?: boolean; 45 + 46 + onReplyClick?: () => void; 47 + onRepostClick?: () => void; 48 + onLikeClick?: () => void; 49 + onBookmarkClick?: () => void; 50 + 51 + customActions?: Snippet; 52 + 53 + logo?: Snippet; 54 + } = $props(); 55 + </script> 56 + 57 + <div 58 + bind:this={ref} 59 + class={cn('text-base-950 dark:text-base-50 transition-colors duration-200', className)} 60 + > 61 + {#if data.reposted} 62 + <div class="mb-3 inline-flex items-center gap-2 text-xs"> 63 + <svg 64 + xmlns="http://www.w3.org/2000/svg" 65 + viewBox="0 0 24 24" 66 + fill="currentColor" 67 + class="size-3" 68 + > 69 + <path 70 + fill-rule="evenodd" 71 + 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" 72 + clip-rule="evenodd" 73 + /> 74 + </svg> 75 + 76 + <div class="inline-flex gap-1"> 77 + reposted by 78 + <a 79 + href={data.reposted.href} 80 + class="hover:text-accent-600 dark:hover:text-accent-400 font-bold" 81 + > 82 + @{data.reposted.handle} 83 + </a> 84 + </div> 85 + </div> 86 + {/if} 87 + {#if data.replyTo} 88 + <div class="mb-3 inline-flex items-center gap-2 text-xs"> 89 + <svg 90 + xmlns="http://www.w3.org/2000/svg" 91 + viewBox="0 0 24 24" 92 + fill="currentColor" 93 + class="size-3" 94 + > 95 + <path 96 + fill-rule="evenodd" 97 + d="M14.47 2.47a.75.75 0 0 1 1.06 0l6 6a.75.75 0 0 1 0 1.06l-6 6a.75.75 0 1 1-1.06-1.06l4.72-4.72H9a5.25 5.25 0 1 0 0 10.5h3a.75.75 0 0 1 0 1.5H9a6.75 6.75 0 0 1 0-13.5h10.19l-4.72-4.72a.75.75 0 0 1 0-1.06Z" 98 + clip-rule="evenodd" 99 + /> 100 + </svg> 101 + 102 + <div class="inline-flex gap-1"> 103 + replying to 104 + <a 105 + href={data.replyTo.href} 106 + class="hover:text-accent-600 dark:hover:text-accent-400 font-bold" 107 + > 108 + @{data.replyTo.handle} 109 + </a> 110 + </div> 111 + </div> 112 + {/if} 113 + <div class="flex gap-4"> 114 + <div class="w-full"> 115 + <div class="mb-1 flex items-start justify-between gap-2"> 116 + <div class="flex items-start gap-4"> 117 + {#if data.author.href} 118 + <a 119 + class="hover:bg-accent-900/5 group/post-author -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" 120 + href={data.author.href} 121 + > 122 + {#if data.author.displayName} 123 + <div 124 + class="text-base-900 group-hover/post-author:text-accent-600 dark:text-base-50 dark:group-hover/post-author:text-accent-300 line-clamp-1 text-sm leading-tight font-semibold" 125 + > 126 + {data.author.displayName} 127 + </div> 128 + {/if} 129 + <div 130 + class={cn( 131 + 'group-hover/post-author:text-accent-600 dark:group-hover/post-author:text-accent-400 text-sm', 132 + !data.author.displayName 133 + ? 'text-base-900 dark:text-base-50 font-semibold' 134 + : 'text-base-600 dark:text-base-400' 135 + )} 136 + > 137 + @{data.author.handle} 138 + </div> 139 + </a> 140 + {:else} 141 + <div 142 + class="-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" 143 + > 144 + <div class="text-base-900 dark:text-base-50 text-sm leading-tight font-semibold"> 145 + {data.author.displayName} 146 + </div> 147 + <div class="text-base-600 dark:text-base-400 text-sm"> 148 + @{data.author.handle} 149 + </div> 150 + </div> 151 + {/if} 152 + 153 + <div class="text-base-600 dark:text-base-400 block text-sm no-underline"> 154 + <RelativeTime date={new Date(data.createdAt)} locale="en" /> 155 + </div> 156 + </div> 157 + 158 + {#if logo} 159 + {@render logo?.()} 160 + {/if} 161 + </div> 162 + 163 + <Prose size="md"> 164 + {#if data.htmlContent} 165 + {@html data.htmlContent} 166 + {:else} 167 + {@render children?.()} 168 + {/if} 169 + </Prose> 170 + 171 + {#if data.embed} 172 + <Embed embed={data.embed} /> 173 + {/if} 174 + 175 + {#if showReply || showRepost || showLike || showBookmark || customActions} 176 + <div class="text-base-500 dark:text-base-400 mt-4 flex justify-between gap-2"> 177 + {#if showReply} 178 + <PostAction onclick={onReplyClick}> 179 + <svg 180 + xmlns="http://www.w3.org/2000/svg" 181 + fill="none" 182 + viewBox="0 0 24 24" 183 + stroke-width="1.5" 184 + stroke="currentColor" 185 + class="group-hover/post-action:bg-accent-500/10 group-hover/post-action:text-accent-700 dark:group-hover/post-action:text-accent-400 -m-1.5 size-7 rounded-full p-1.5 transition-all duration-100" 186 + > 187 + <path 188 + stroke-linecap="round" 189 + stroke-linejoin="round" 190 + 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" 191 + /> 192 + </svg> 193 + {#if data.replyCount} 194 + {numberToHumanReadable(data.replyCount)} 195 + {/if} 196 + </PostAction> 197 + {/if} 198 + 199 + {#if showRepost} 200 + <PostAction onclick={onRepostClick}> 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/post-action:bg-accent-500/10 group-hover/post-action:text-accent-700 dark:group-hover/post-action: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="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" 213 + /> 214 + </svg> 215 + {#if data.repostCount} 216 + {numberToHumanReadable(data.repostCount)} 217 + {/if} 218 + </PostAction> 219 + {/if} 220 + {#if showLike} 221 + <PostAction 222 + class={liked ? 'text-accent-700 dark:text-accent-500 font-semibold' : ''} 223 + onclick={onLikeClick} 224 + > 225 + {#if liked} 226 + <svg 227 + xmlns="http://www.w3.org/2000/svg" 228 + viewBox="0 0 24 24" 229 + fill="currentColor" 230 + class="group-hover/post-action:bg-accent-500/10 text-accent-700 dark:text-accent-500 -m-1.5 size-7 rounded-full p-1.5 transition-all duration-100" 231 + > 232 + <path 233 + 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" 234 + /> 235 + </svg> 236 + {:else} 237 + <svg 238 + xmlns="http://www.w3.org/2000/svg" 239 + fill="none" 240 + viewBox="0 0 24 24" 241 + stroke-width="1.5" 242 + stroke="currentColor" 243 + class="group-hover/post-action:bg-accent-500/10 group-hover/post-action:text-accent-700 dark:group-hover/post-action:text-accent-400 -m-1.5 size-7 rounded-full p-1.5 transition-all duration-100" 244 + > 245 + <path 246 + stroke-linecap="round" 247 + stroke-linejoin="round" 248 + 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" 249 + /> 250 + </svg> 251 + {/if} 252 + {#if data.likeCount} 253 + {numberToHumanReadable(data.likeCount)} 254 + {/if} 255 + </PostAction> 256 + {/if} 257 + 258 + {#if showBookmark} 259 + <PostAction onclick={onBookmarkClick}> 260 + <span class="sr-only">Bookmark</span> 261 + 262 + {#if bookmarked} 263 + <svg 264 + xmlns="http://www.w3.org/2000/svg" 265 + viewBox="0 0 24 24" 266 + fill="currentColor" 267 + class="group-hover/post-action:bg-accent-500/10 text-accent-700 dark:text-accent-400 -m-1.5 size-7 rounded-full p-1.5 transition-all duration-100" 268 + > 269 + <path 270 + fill-rule="evenodd" 271 + 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" 272 + clip-rule="evenodd" 273 + /> 274 + </svg> 275 + {:else} 276 + <svg 277 + xmlns="http://www.w3.org/2000/svg" 278 + fill="none" 279 + viewBox="0 0 24 24" 280 + stroke-width="1.5" 281 + stroke="currentColor" 282 + class="group-hover/post-action:bg-accent-500/10 group-hover/post-action:text-accent-700 dark:group-hover/post-action:text-accent-400 -m-1.5 size-7 rounded-full p-1.5 transition-all duration-100" 283 + > 284 + <path 285 + stroke-linecap="round" 286 + stroke-linejoin="round" 287 + 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" 288 + /> 289 + </svg> 290 + {/if} 291 + </PostAction> 292 + {/if} 293 + 294 + {@render customActions?.()} 295 + </div> 296 + {/if} 297 + </div> 298 + </div> 299 + </div>
+27
src/lib/components/post/PostAction.svelte
···
··· 1 + <script lang="ts"> 2 + import { cn } from '@foxui/core'; 3 + import type { Snippet } from 'svelte'; 4 + 5 + let { 6 + onclick, 7 + children, 8 + class: className 9 + }: { 10 + onclick?: () => void; 11 + children: Snippet; 12 + class?: string; 13 + } = $props(); 14 + </script> 15 + 16 + {#if onclick} 17 + <button 18 + class={cn('group/post-action inline-flex cursor-pointer items-center gap-2 text-sm', className)} 19 + {onclick} 20 + > 21 + {@render children?.()} 22 + </button> 23 + {:else} 24 + <div class={cn('inline-flex items-center gap-2 text-sm', className)}> 25 + {@render children?.()} 26 + </div> 27 + {/if}
+24
src/lib/components/post/embeds/Embed.svelte
···
··· 1 + <script lang="ts"> 2 + import type { PostEmbed } from '../'; 3 + import External from './External.svelte'; 4 + import Images from './Images.svelte'; 5 + import Video from './Video.svelte'; 6 + 7 + const { embed }: { embed: PostEmbed } = $props(); 8 + </script> 9 + 10 + <div class="flex flex-col gap-2 pt-3 text-sm"> 11 + {#if embed.type === 'images'} 12 + <Images data={embed} /> 13 + {:else if embed.type === 'external' && embed.external} 14 + <External data={embed} /> 15 + {:else if embed.type === 'video' && embed.video} 16 + <Video data={embed} /> 17 + {:else if embed.type === 'unknown'} 18 + <div 19 + class="text-base-700 dark:text-base-300 bg-base-200/50 dark:bg-base-900/50 border-base-300 dark:border-base-600/30 rounded-2xl border p-4 text-sm" 20 + > 21 + Unknown embed type 22 + </div> 23 + {/if} 24 + </div>
+39
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={[ 11 + 'group dark:bg-base-900 bg-base-200 border-base-300 dark:border-base-600/30 max-w-md relative isolate flex flex-col justify-end overflow-hidden rounded-2xl border p-4', 12 + data.external.thumb ? 'aspect-[16/9]' : '' 13 + ]} 14 + > 15 + {#if data.external.thumb} 16 + <img 17 + src={data.external.thumb} 18 + alt={data.external.description} 19 + class="absolute inset-0 -z-10 size-full object-cover transition-transform duration-500 ease-in-out group-hover:scale-102" 20 + /> 21 + {/if} 22 + <div 23 + 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" 24 + ></div> 25 + 26 + <div 27 + class="text-base-700 dark:text-base-300 flex flex-wrap items-center gap-y-1 overflow-hidden text-sm" 28 + > 29 + <div>{domain}</div> 30 + </div> 31 + <h3 32 + class="dark:text-base-50 text-base-900 group-hover:text-accent-600 dark:group-hover:text-accent-400 mt-1 text-lg/6 font-semibold transition-colors duration-200" 33 + > 34 + <a href={data.external.href} target="_blank" rel="noopener noreferrer nofollow"> 35 + <span class="absolute inset-0"></span> 36 + {data.external.title} 37 + </a> 38 + </h3> 39 + </article>
+38
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( 8 + image: { 9 + alt: string; 10 + thumb: string; 11 + fullsize: string; 12 + aspectRatio?: { width: number; height: number }; 13 + }, 14 + className?: string 15 + )} 16 + <img 17 + loading="lazy" 18 + src={image.thumb} 19 + alt={image.alt} 20 + style={image.aspectRatio 21 + ? `aspect-ratio: ${image.aspectRatio.width} / ${image.aspectRatio.height}` 22 + : 'aspect-ratio: 1 / 1'} 23 + class={[ 24 + 'border-base-500/20 dark:border-base-400/20 w-fit max-w-full rounded-2xl border max-h-[40rem] object-contain', 25 + className 26 + ]} 27 + /> 28 + {/snippet} 29 + 30 + {#if data.images.length === 1} 31 + {@render imageSnippet(data.images[0])} 32 + {:else} 33 + <div class="columns-2 gap-4"> 34 + {#each data.images as image} 35 + {@render imageSnippet(image, 'mb-4')} 36 + {/each} 37 + </div> 38 + {/if}
+45
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: ['play-large', 'play', 'progress', 'current-time', 'mute', 'volume', 'fullscreen'], 18 + ratio: data.video.aspectRatio 19 + ? `${data.video.aspectRatio.width}:${data.video.aspectRatio.height}` 20 + : '16:9' 21 + }); 22 + }); 23 + 24 + let element: HTMLMediaElement; 25 + </script> 26 + 27 + <svelte:head> 28 + <link rel="stylesheet" href="https://cdn.plyr.io/3.7.8/plyr.css" /> 29 + </svelte:head> 30 + 31 + <div 32 + style={data.video.aspectRatio 33 + ? `aspect-ratio: ${data.video.aspectRatio.width} / ${data.video.aspectRatio.height}` 34 + : 'aspect-ratio: 16 / 9'} 35 + class="border-base-300 dark:border-base-400/40 w-full max-w-full overflow-hidden rounded-2xl border" 36 + > 37 + <!-- svelte-ignore a11y_media_has_caption --> 38 + <video bind:this={element} class="h-full w-full" aria-label={data.video.alt}></video> 39 + </div> 40 + 41 + <style> 42 + * { 43 + --plyr-color-main: var(--color-accent-500); 44 + } 45 + </style>
+75
src/lib/components/post/index.ts
···
··· 1 + export type PostEmbedImage = { 2 + type: 'images'; 3 + 4 + images: { 5 + alt: string; 6 + thumb: string; 7 + fullsize: string; 8 + aspectRatio?: { 9 + width: number; 10 + height: number; 11 + }; 12 + }[]; 13 + }; 14 + 15 + export type PostEmbedExternal = { 16 + type: 'external'; 17 + 18 + external: { 19 + href: string; 20 + thumb: string; 21 + title: string; 22 + description: string; 23 + }; 24 + }; 25 + 26 + export type PostEmbedVideo = { 27 + type: 'video'; 28 + 29 + video: { 30 + playlist: string; 31 + 32 + thumb: string; 33 + alt: string; 34 + 35 + aspectRatio?: { 36 + width: number; 37 + height: number; 38 + }; 39 + }; 40 + }; 41 + 42 + export type UnknownEmbed = { 43 + type: 'unknown'; 44 + } & Record<string, unknown>; 45 + 46 + export type PostEmbed = PostEmbedImage | PostEmbedExternal | PostEmbedVideo | UnknownEmbed; 47 + 48 + export type PostData = { 49 + href?: string; 50 + id?: string; 51 + 52 + reposted?: { handle: string; href: string }; 53 + replyTo?: { handle: string; href: string }; 54 + 55 + author: { 56 + displayName: string; 57 + handle: string; 58 + avatar?: string; 59 + href?: string; 60 + }; 61 + 62 + replyCount: number; 63 + repostCount: number; 64 + likeCount: number; 65 + 66 + createdAt: string; 67 + 68 + embed?: PostEmbed; 69 + 70 + htmlContent?: string; 71 + 72 + replies?: PostData[]; 73 + }; 74 + 75 + export { default as Post } from './Post.svelte';