[READ-ONLY] Mirror of https://github.com/flo-bit/skywatched. review movies and tv shows, based on at proto skywatched.app
0

Configure Feed

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

add alpha version

Yamar100 (Feb 9, 2025, 3:19 PM +0100) e359434f 500fbb47

+1003 -1
+43
src/lib/Components/Items/BlueskyPost/Avatar.svelte
··· 1 + <script lang="ts"> 2 + type Props = { 3 + link?: string; 4 + size?: string; 5 + src?: string; 6 + alt?: string; 7 + class?: string; 8 + }; 9 + 10 + const { link, size = 'size-11', src, alt, class: className }: Props = $props(); 11 + </script> 12 + 13 + {#snippet avatar()} 14 + <div 15 + class={[ 16 + 'border-base-400/50 bg-base-100 dark:border-base-700 dark:bg-base-800 overflow-hidden rounded-full border', 17 + size, 18 + className 19 + ]} 20 + > 21 + {#if src} 22 + <img loading="lazy" class="h-full w-full object-cover" {src} {alt} /> 23 + {:else} 24 + <svg 25 + class="text-base-300 dark:text-base-600 size-full" 26 + fill="currentColor" 27 + viewBox="0 0 24 24" 28 + > 29 + <path 30 + d="M24 20.993V24H0v-2.996A14.977 14.977 0 0112.004 15c4.904 0 9.26 2.354 11.996 5.993zM16.002 8.999a4 4 0 11-8 0 4 4 0 018 0z" 31 + /> 32 + </svg> 33 + {/if} 34 + </div> 35 + {/snippet} 36 + 37 + {#if link} 38 + <a href={link} class="inline-block" target="_blank" rel="noopener noreferrer nofollow"> 39 + {@render avatar()} 40 + </a> 41 + {:else} 42 + {@render avatar()} 43 + {/if}
+174
src/lib/Components/Items/BlueskyPost/Comment.svelte
··· 1 + <script lang="ts"> 2 + import { atUriToPostUri, renderPostAsHtml, numberToHumanReadable } from './utils'; 3 + import Comment from './Comment.svelte'; 4 + import Avatar from './Avatar.svelte'; 5 + import RelativeTime from './relative-time'; 6 + import Embed from './embeds/Embed.svelte'; 7 + 8 + const { comment, depth = 0 } = $props(); 9 + 10 + let expanded = $state(true); 11 + 12 + $inspect(comment.post.embed); 13 + </script> 14 + 15 + {#snippet top(expand: boolean)} 16 + <div class="text-base-600 dark:text-base-500 -ml-6 flex items-center text-sm"> 17 + <div class="relative size-6"> 18 + <Avatar 19 + src={comment.post.author.avatar} 20 + link={`https://bsky.app/profile/${comment.post.author.did}`} 21 + size="size-6" 22 + /> 23 + {#if expand} 24 + <button 25 + class="absolute inset-0 flex size-6 cursor-pointer items-center justify-center rounded-full bg-black/50 text-white" 26 + onclick={() => (expanded = !expanded)} 27 + > 28 + <svg 29 + xmlns="http://www.w3.org/2000/svg" 30 + fill="none" 31 + viewBox="0 0 24 24" 32 + stroke-width="2" 33 + stroke="currentColor" 34 + class="size-3" 35 + > 36 + <path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" /> 37 + </svg> 38 + 39 + <span class="sr-only">expand comment</span> 40 + </button> 41 + {/if} 42 + </div> 43 + <a 44 + target="_blank" 45 + rel="noopener noreferrer nofollow" 46 + class="dark:text-base-100 hover:text-base-500 dark:hover:text-base-300 ml-2" 47 + href={`https://bsky.app/profile/${comment.post.author.did}`} 48 + > 49 + {comment.post.author.displayName || comment.post.author.handle} 50 + </a> 51 + 52 + <div class="text-base-400 ml-2 text-xs"> 53 + <RelativeTime date={new Date(comment.post.record.createdAt)} locale="en" /> 54 + </div> 55 + </div> 56 + {/snippet} 57 + 58 + <div class="relative pl-3"> 59 + <button 60 + class="group absolute top-0 -left-1.5 flex h-full w-3 cursor-pointer items-center" 61 + onclick={() => (expanded = !expanded)} 62 + > 63 + <div 64 + class="bg-base-200 dark:bg-base-800 group-hover:bg-base-300 dark:group-hover:bg-base-700 mx-auto h-full w-0.5" 65 + ></div> 66 + <span class="sr-only">collapse comment</span> 67 + </button> 68 + 69 + <div class="mt-2 pb-2"> 70 + {#if expanded} 71 + {@render top(false)} 72 + <div class="prose prose-sm prose-neutral dark:prose-invert"> 73 + {@html renderPostAsHtml(comment.post)} 74 + </div> 75 + 76 + {#if comment.post.embed} 77 + <Embed post={comment.post} /> 78 + {/if} 79 + 80 + <div class="text-base-500 dark:text-base-400 mt-2 flex gap-8"> 81 + <a 82 + href={atUriToPostUri(comment.post.uri)} 83 + target="_blank" 84 + rel="noopener noreferrer nofollow" 85 + class="group inline-flex items-center gap-2 text-sm" 86 + > 87 + <svg 88 + xmlns="http://www.w3.org/2000/svg" 89 + fill="none" 90 + viewBox="0 0 24 24" 91 + stroke-width="1.5" 92 + stroke="currentColor" 93 + 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" 94 + > 95 + <path 96 + stroke-linecap="round" 97 + stroke-linejoin="round" 98 + 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" 99 + /> 100 + </svg> 101 + <span class="sr-only">Replies</span> 102 + {numberToHumanReadable(comment.post.replyCount)} 103 + </a> 104 + 105 + <a 106 + href={atUriToPostUri(comment.post.uri)} 107 + target="_blank" 108 + rel="noopener noreferrer nofollow" 109 + class="group inline-flex items-center gap-2 text-sm" 110 + > 111 + <svg 112 + xmlns="http://www.w3.org/2000/svg" 113 + fill="none" 114 + viewBox="0 0 24 24" 115 + stroke-width="1.5" 116 + stroke="currentColor" 117 + 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" 118 + > 119 + <path 120 + stroke-linecap="round" 121 + stroke-linejoin="round" 122 + 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" 123 + /> 124 + </svg> 125 + {numberToHumanReadable(comment.post.repostCount)} 126 + </a> 127 + 128 + <a 129 + href={atUriToPostUri(comment.post.uri)} 130 + target="_blank" 131 + rel="noopener noreferrer nofollow" 132 + class="group inline-flex items-center gap-2 text-sm" 133 + > 134 + <svg 135 + xmlns="http://www.w3.org/2000/svg" 136 + fill="none" 137 + viewBox="0 0 24 24" 138 + stroke-width="1.5" 139 + stroke="currentColor" 140 + 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" 141 + > 142 + <path 143 + stroke-linecap="round" 144 + stroke-linejoin="round" 145 + 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" 146 + /> 147 + </svg> 148 + <span class="sr-only">Likes</span> 149 + {numberToHumanReadable(comment.post.likeCount)} 150 + </a> 151 + </div> 152 + 153 + {#if comment.replies?.length > 0 && depth > 4} 154 + <a 155 + href={atUriToPostUri(comment.post.uri)} 156 + target="_blank" 157 + rel="noopener noreferrer nofollow" 158 + class="text-base-500 dark:text-base-400 dark:hover:text-base-300 hover:text-base-600 text-sm font-medium" 159 + >View more replies on bluesky</a 160 + > 161 + {/if} 162 + {:else} 163 + <button onclick={() => (expanded = true)} class="cursor-pointer"> 164 + {@render top(true)} 165 + </button> 166 + {/if} 167 + </div> 168 + 169 + {#if comment.replies?.length > 0 && depth <= 4 && expanded} 170 + {#each comment.replies.toSorted((a: any, b: any) => new Date(a.post.record.createdAt).getTime() - new Date(b.post.record.createdAt).getTime()) as reply} 171 + <Comment comment={reply} depth={depth + 1} /> 172 + {/each} 173 + {/if} 174 + </div>
+41
src/lib/Components/Items/BlueskyPost/Comments.svelte
··· 1 + <script lang="ts"> 2 + import { onMount } from "svelte"; 3 + import { getUserPosts, getComments, getCommentCount } from "./utils"; 4 + import Comment from "./Comment.svelte"; 5 + 6 + let { uri, user, comments, url } = $props(); 7 + 8 + let postUri = $state(uri); 9 + 10 + onMount(async () => { 11 + if (!uri && user) { 12 + let posts = await getUserPosts(user); 13 + 14 + // @ts-expect-error: weird type fuckery 15 + const post = posts.find((post) => post.post.embed?.external?.uri === url); 16 + 17 + if (post) { 18 + postUri = post.post.uri; 19 + comments = await getComments(post.post.uri); 20 + } 21 + } else if (uri) { 22 + comments = await getComments(uri); 23 + } 24 + }); 25 + </script> 26 + 27 + <div class="not-prose mt-2"> 28 + {#if comments.length > 0} 29 + <div class="text-sm text-base-950 dark:text-base-100 font-semibold"> 30 + {getCommentCount(comments)} comments, sorted by newest first 31 + </div> 32 + {/if} 33 + 34 + {#if comments.length > 0} 35 + <div class="pt-4"> 36 + {#each comments as comment} 37 + <Comment {comment} /> 38 + {/each} 39 + </div> 40 + {/if} 41 + </div>
+72
src/lib/Components/Items/BlueskyPost/Likes.svelte
··· 1 + <script lang="ts"> 2 + import { onMount } from 'svelte'; 3 + import { getUserPosts, getLikes, getPost } from './utils'; 4 + 5 + const { uri, likesData, user, url } = $props(); 6 + 7 + let postUri = $state(uri); 8 + let postLikesCount = $state(likesData.length); 9 + let postLikesData = $state(likesData.filter((like: any) => like.actor.avatar)); 10 + 11 + onMount(async () => { 12 + if (!uri && user) { 13 + let posts = await getUserPosts(user); 14 + 15 + // @ts-expect-error: weird type fuckery 16 + const post = posts.find((post) => post.post.embed?.external?.uri === url); 17 + 18 + if (post) { 19 + postUri = post.post.uri; 20 + postLikesCount = post.post.likeCount; 21 + 22 + postLikesData = (await getLikes(post.post.uri)).filter((like) => like.actor.avatar); 23 + } 24 + } else if (uri) { 25 + postLikesData = (await getLikes(uri)).filter((like) => like.actor.avatar); 26 + 27 + console.log(postLikesData); 28 + const post = await getPost(uri); 29 + 30 + if (post) { 31 + postUri = post.uri; 32 + postLikesCount = post.likeCount; 33 + } 34 + } 35 + }); 36 + </script> 37 + 38 + {#if postUri} 39 + <div class="not-prose flex flex-col gap-4"> 40 + <div class="text-base-950 dark:text-base-100 text-sm font-semibold"> 41 + {postLikesCount} like{postLikesCount === 1 ? '' : 's'} 42 + </div> 43 + 44 + <div class="isolate flex flex-wrap -space-x-2 overflow-hidden px-4"> 45 + {#each postLikesData as user, index} 46 + <a 47 + href={`https://bsky.app/profile/${user.actor.handle}`} 48 + class={[ 49 + 'ring-base-50 dark:ring-base-900 bg-base-950 relative inline-block size-12 overflow-hidden rounded-full ring-2', 50 + index === 0 ? '-ml-2' : '' 51 + ]} 52 + target="_blank" 53 + > 54 + <img 55 + title={user.actor.handle} 56 + loading="lazy" 57 + src={user.actor.avatar.replace('avatar', 'avatar_thumbnail')} 58 + alt={'liked by ' + user.actor.displayName} 59 + /> 60 + </a> 61 + {/each} 62 + 63 + {#if postLikesData.length < postLikesCount} 64 + <div 65 + class="text-accent-700 dark:text-accent-300 bg-accent-100 dark:bg-accent-950 ring-base-50 dark:ring-base-900 z-10 mb-4 flex size-12 items-center justify-center rounded-full text-sm font-semibold ring-2" 66 + > 67 + +{postLikesCount - postLikesData.length} 68 + </div> 69 + {/if} 70 + </div> 71 + </div> 72 + {/if}
+20
src/lib/Components/Items/BlueskyPost/embeds/Embed.svelte
··· 1 + <script lang="ts"> 2 + import External from './External.svelte'; 3 + import Images from './Images.svelte'; 4 + // import Video from "./Video.svelte"; 5 + const { post } = $props(); 6 + </script> 7 + 8 + {#if post.embed} 9 + <div class="mt-4"> 10 + {#if post.embed.$type === 'app.bsky.embed.images#view'} 11 + <Images data={post.embed.images} /> 12 + {:else if post.embed.$type === 'app.bsky.embed.external#view' && post.embed.external?.uri} 13 + <External data={post.embed.external} /> 14 + <!-- {:else if data.post.embed.$type === "app.bsky.embed.video#view"} 15 + <Video data={data.post.embed} /> --> 16 + {:else} 17 + Unsupported embed 18 + {/if} 19 + </div> 20 + {/if}
+36
src/lib/Components/Items/BlueskyPost/embeds/External.svelte
··· 1 + <script lang="ts"> 2 + const { data } = $props(); 3 + $inspect(data); 4 + console.log(data.uri); 5 + console.log(new URL(data.uri)); 6 + const domain = new URL(data.uri).hostname.replace('www.', ''); 7 + </script> 8 + 9 + <article 10 + class="bg-base-900 ring-base-800 relative isolate flex aspect-16/9 h-64 flex-col justify-end rounded-2xl p-4 ring-1" 11 + > 12 + {#if data.thumb} 13 + <img 14 + src={data.thumb} 15 + alt={data.description} 16 + class="absolute inset-0 -z-10 size-full rounded-2xl object-cover transition-transform duration-300 group-hover:scale-105" 17 + /> 18 + {/if} 19 + <div 20 + class="from-base-950/90 via-base-950/40 absolute inset-0 -z-10 rounded-xl bg-linear-to-t" 21 + ></div> 22 + 23 + <div 24 + class="inset-shadow-md inset-ring-base-950/50 inset-shadow-base-950/40 ring-base-800 absolute inset-0 -z-10 h-full w-full rounded-2xl inset-ring-1 ring-1" 25 + ></div> 26 + 27 + <div class="text-base-300 flex flex-wrap items-center gap-y-1 overflow-hidden text-sm/6"> 28 + <div>{domain}</div> 29 + </div> 30 + <h3 class="mt-1 text-lg/6 font-semibold text-white"> 31 + <a href={data.uri} target="_blank" rel="noopener noreferrer nofollow"> 32 + <span class="absolute inset-0"></span> 33 + {data.title} 34 + </a> 35 + </h3> 36 + </article>
+20
src/lib/Components/Items/BlueskyPost/embeds/Images.svelte
··· 1 + <script lang="ts"> 2 + const { data } = $props(); 3 + </script> 4 + 5 + {#each data as { fullsize: string; alt: string; thumb: string; aspectRatio: { width: number; height: number } }[] as image} 6 + <div class="ring-base-800 relative h-fit w-fit rounded-2xl ring-1"> 7 + <img 8 + loading="lazy" 9 + src={image.thumb} 10 + alt={image.alt} 11 + height={image.aspectRatio.height} 12 + width={image.aspectRatio.width} 13 + class="h-72 w-auto rounded-2xl" 14 + /> 15 + 16 + <div 17 + class="inset-shadow-md inset-shadow-base-950/30 inset-ring-base-950/10 absolute inset-0 h-full w-full rounded-2xl inset-ring-2" 18 + ></div> 19 + </div> 20 + {/each}
+39
src/lib/Components/Items/BlueskyPost/embeds/Video.svelte
··· 1 + <script lang="ts"> 2 + import { onMount } from "svelte"; 3 + import Hls from "hls.js"; 4 + 5 + const { data } = $props(); 6 + 7 + import Plyr from "plyr"; 8 + 9 + onMount(() => { 10 + if (Hls.isSupported()) { 11 + var hls = new Hls(); 12 + hls.loadSource(data.playlist); 13 + hls.attachMedia(element); 14 + } 15 + 16 + new Plyr(element, { 17 + controls: ["play-large", "play", "progress", "current-time", "mute", "volume", "fullscreen", "settings"], 18 + hideControls: true 19 + }); 20 + }); 21 + 22 + let element: HTMLMediaElement; 23 + </script> 24 + 25 + <svelte:head> 26 + <link rel="stylesheet" href="https://cdn.plyr.io/3.7.8/plyr.css" /> 27 + </svelte:head> 28 + 29 + <div class="max-w-full w-full aspect-video rounded-2xl overflow-hidden border border-base-700"> 30 + <video bind:this={element}> 31 + <track kind="captions" /> 32 + </video> 33 + </div> 34 + 35 + <style> 36 + * { 37 + --plyr-color-main: #db2777; 38 + } 39 + </style>
+17
src/lib/Components/Items/BlueskyPost/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/Items/BlueskyPost/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/Items/BlueskyPost/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 + }
+4
src/lib/Components/Items/BlueskyPost/relative-time/index.ts
··· 1 + export * from './action' 2 + export type { Callback } from './render' 3 + export { register, unregister } from './state' 4 + export { default as default } from './RelativeTime.svelte'
+62
src/lib/Components/Items/BlueskyPost/relative-time/render.ts
··· 1 + export type Callback = (result: { seconds: number; count: number; units: Intl.RelativeTimeFormatUnit; text: string }) => void 2 + 3 + export interface RenderState { 4 + date: Date | number 5 + callback: Callback 6 + formatter: Intl.RelativeTimeFormat 7 + } 8 + 9 + // Array reprsenting one minute, hour, day, week, month, etc in seconds 10 + const cutoffs = [60, 3600, 86400, 86400 * 7, 86400 * 30, 86400 * 365, Infinity] 11 + 12 + // Array equivalent to the above but in the string representation of the units 13 + const formatUnits: Intl.RelativeTimeFormatUnit[] = ['seconds', 'minutes', 'hours', 'days', 'weeks', 'months', 'years'] 14 + 15 + // function to render relative time into 16 + export function render(state: RenderState, now: number) { 17 + const { date, callback, formatter } = state 18 + 19 + // Allow dates or times to be passed 20 + const timeMs = typeof date === 'number' ? date : date.getTime() 21 + 22 + // Get the amount of seconds between the given date and now 23 + const delta = timeMs - now 24 + const seconds = Math.round(delta / 1000) 25 + 26 + // Grab the ideal cutoff unit 27 + const unitIndex = cutoffs.findIndex(cutoff => cutoff > Math.abs(seconds)) 28 + 29 + // units 30 + const units = formatUnits[unitIndex] 31 + 32 + // Get the divisor to divide from the seconds. E.g. if our unit is 'day' our divisor 33 + // is one day in seconds, so we can divide our seconds by this to get the # of days 34 + const divisor = unitIndex ? cutoffs[unitIndex - 1] : 1 35 + 36 + // count of units 37 + const count = Math.round(seconds / divisor) 38 + 39 + // Intl.RelativeTimeFormat do its magic 40 + callback({ seconds: seconds, count, units, text: formatter.format(count, units) }) 41 + 42 + // calculate time to next update, taking account rounding (e.g. it goes from 2 minutes to 1 minute at 90 seconds) 43 + // and also the changeover from units (59 seconds shouldn't show as 1 minute, so at 61 seconds we schedule the next 44 + // update for 1 second time) 45 + 46 + const divisorMs = divisor * 1000 47 + 48 + let updateIn 49 + 50 + if (unitIndex) { 51 + updateIn = divisorMs / 2 - (Math.abs(delta) % divisorMs) 52 + if (updateIn < 0) { 53 + updateIn += divisorMs 54 + } 55 + } else { 56 + updateIn = divisorMs - (Math.abs(delta) % divisorMs) 57 + } 58 + 59 + const updateAt = now + updateIn 60 + 61 + return updateAt 62 + }
+54
src/lib/Components/Items/BlueskyPost/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(instance: Object, date: Date | number, locale: string, live: boolean, callback: Callback) { 17 + // get the formatter for the given locale, we do this here so we don't keep having to look it up on each tick 18 + const formatter = getFormatter(locale) 19 + 20 + // create state to render 21 + const state = { date, callback, formatter } 22 + 23 + // initial render is immediate, so works for SSR 24 + const update = render(state, Date.now()) 25 + 26 + // if it's to update live, we keep a track and schedule the next update 27 + if (live) { 28 + instances.set(instance, { ...state, update }) 29 + } else { 30 + instances.delete(instance) 31 + } 32 + 33 + // start the clock ticking if there are any live instances 34 + if (instances.size) { 35 + updateInterval = 36 + updateInterval || 37 + setInterval(() => { 38 + const now = Date.now() 39 + for (const state of instances.values()) { 40 + if (state.update <= now) { 41 + state.update = render(state, now) 42 + } 43 + } 44 + }, 1000) 45 + } 46 + } 47 + 48 + export function unregister(instance: Object) { 49 + instances.delete(instance) 50 + if (instances.size === 0) { 51 + clearInterval(updateInterval) 52 + updateInterval = 0 53 + } 54 + }
+21
src/lib/Components/Items/BlueskyPost/types.ts
··· 1 + import { 2 + AppBskyFeedPost, 3 + AppBskyFeedDefs, 4 + AppBskyEmbedRecord, 5 + AppBskyEmbedExternal, 6 + AppBskyEmbedImages, 7 + AppBskyEmbedRecordWithMedia, 8 + AppBskyEmbedVideo, 9 + } from '@atproto/api'; 10 + 11 + export interface Post extends AppBskyFeedDefs.PostView { 12 + record: AppBskyFeedPost.Record; 13 + } 14 + 15 + export type EmbedView = 16 + | AppBskyEmbedRecord.View 17 + | AppBskyEmbedImages.View 18 + | AppBskyEmbedVideo.View 19 + | AppBskyEmbedExternal.View 20 + | AppBskyEmbedRecordWithMedia.View 21 + | { [k: string]: unknown; $type: string };
+228
src/lib/Components/Items/BlueskyPost/utils.ts
··· 1 + import { 2 + AppBskyEmbedExternal, 3 + AppBskyEmbedImages, 4 + AppBskyEmbedRecord, 5 + AppBskyEmbedRecordWithMedia, 6 + AppBskyEmbedVideo, 7 + AppBskyFeedDefs, 8 + AtpAgent, 9 + RichText, 10 + type RichTextProps 11 + } from '@atproto/api'; 12 + import type { Post } from './types'; 13 + 14 + const escapeMap: Record<string, string> = { 15 + '&': '&amp;', 16 + '<': '&lt;', 17 + '>': '&gt;', 18 + '"': '&quot;', 19 + "'": '&#39;' 20 + }; 21 + 22 + export const escapeHTML = (str?: string) => 23 + str?.replace(/[&<>"']/g, (match) => escapeMap[match] || match) ?? ''; 24 + 25 + export function renderPostAsHtml(post?: AppBskyFeedDefs.PostView | Post) { 26 + if (!post) { 27 + return ''; 28 + } 29 + const rt = new RichText(post.record as RichTextProps); 30 + let html = ''; 31 + 32 + const preLink = `<a target="_blank" rel="noopener noreferrer nofollow" class="text-accent-500 hover:text-accent-600 dark:hover:text-accent-400"`; 33 + for (const segment of rt.segments()) { 34 + if (segment.isLink()) { 35 + html += `${preLink} href="${encodeURI(segment.link?.uri ?? '')}">${escapeHTML(segment.text)}</a>`; 36 + } else if (segment.isMention()) { 37 + html += `${preLink} href="https://bsky.app/profile/${encodeURI( 38 + segment.mention?.did ?? '' 39 + )}">${escapeHTML(segment.text)}</a>`; 40 + } else if (segment.isTag()) { 41 + html += `${preLink} href="https://bsky.app/hastag/${encodeURI( 42 + segment.tag?.tag ?? '' 43 + )}">#${escapeHTML(segment.tag?.tag ?? '')}</a>`; 44 + } else { 45 + html += escapeHTML(segment.text); 46 + } 47 + } 48 + // replace new lines with <br> 49 + html = html.replace(/\n/g, '<br>'); 50 + return html; 51 + } 52 + 53 + export function viewRecordToPostView( 54 + viewRecord: AppBskyEmbedRecord.ViewRecord 55 + ): AppBskyFeedDefs.PostView { 56 + const { value, embeds, ...rest } = viewRecord; 57 + return { 58 + ...rest, 59 + $type: 'app.bsky.feed.defs#postView', 60 + record: value, 61 + embed: embeds?.[0] 62 + } as AppBskyFeedDefs.PostView; 63 + } 64 + 65 + export function viewRecordToEmbed( 66 + viewRecord: AppBskyEmbedRecord.ViewRecord, 67 + allowNestedQuotes = false 68 + ) { 69 + const { embed } = viewRecordToPostView(viewRecord); 70 + 71 + if (allowNestedQuotes) { 72 + return embed; 73 + } 74 + if ( 75 + AppBskyEmbedImages.isView(embed) || 76 + AppBskyEmbedExternal.isView(embed) || 77 + AppBskyEmbedVideo.isView(embed) 78 + ) { 79 + return embed; 80 + } else if ( 81 + AppBskyEmbedRecordWithMedia.isView(embed) && 82 + (AppBskyEmbedImages.isView(embed.media) || 83 + AppBskyEmbedExternal.isView(embed.media) || 84 + AppBskyEmbedVideo.isView(embed.media)) 85 + ) { 86 + return embed.media; 87 + } 88 + return undefined; 89 + } 90 + 91 + const agent = new AtpAgent({ 92 + service: 'https://public.api.bsky.app' 93 + }); 94 + 95 + export async function resolvePost( 96 + postUrl: string | Post | AppBskyFeedDefs.PostView 97 + ): Promise<Post | undefined> { 98 + let atUri; 99 + 100 + if (typeof postUrl === 'object') { 101 + return postUrl as Post; 102 + } 103 + 104 + if (postUrl.startsWith('at:')) { 105 + atUri = postUrl; 106 + } else { 107 + if (!postUrl.startsWith('https://bsky.app/')) { 108 + return undefined; 109 + } 110 + const urlParts = new URL(postUrl).pathname.split('/'); 111 + let did = urlParts[2]!; 112 + const postId = urlParts[4]!; 113 + if (!did || !postId) { 114 + return undefined; 115 + } 116 + if (!did.startsWith('did:')) { 117 + try { 118 + const handleResolution = await agent.resolveHandle({ handle: did }); 119 + if (!handleResolution.data.did) { 120 + return undefined; 121 + } 122 + did = handleResolution.data.did; 123 + } catch (e: any) { 124 + console.error(`[error] astro-embed` + '\n ' + (e?.message ?? e)); 125 + return undefined; 126 + } 127 + } 128 + 129 + atUri = `at://${did}/app.bsky.feed.post/${postId}`; 130 + } 131 + 132 + try { 133 + const hydratedPost = await agent.getPosts({ uris: [atUri] }); 134 + return hydratedPost.data.posts[0] as unknown as Post; 135 + } catch (e: any) { 136 + console.error(`[error] astro-embed` + '\n ' + (e?.message ?? e)); 137 + return undefined; 138 + } 139 + } 140 + 141 + export function atUriToPostUri(atUri: string) { 142 + const [, , did, , postId] = atUri.split('/'); 143 + return `https://bsky.app/profile/${did}/post/${postId}`; 144 + } 145 + 146 + export function atUriToStarterPackUri(atUri: string) { 147 + const [, , did, , packId] = atUri.split('/'); 148 + return `https://bsky.app/starter-pack/${did}/${packId}`; 149 + } 150 + 151 + export function atUriToListUri(atUri: string) { 152 + const [, , did, , listId] = atUri.split('/'); 153 + return `https://bsky.app/profile/${did}/lists/${listId}`; 154 + } 155 + 156 + export function starterPackOgImage(uri: string) { 157 + const [, , did, , packId] = uri.split('/'); 158 + return `https://ogcard.cdn.bsky.app/start/${did}/${packId}`; 159 + } 160 + 161 + export async function getLikes(uri: string, getMore = false) { 162 + const allLikes = []; 163 + 164 + let likesData, cursor; 165 + do { 166 + let getLikesURL = `https://public.api.bsky.app/xrpc/app.bsky.feed.getLikes?uri=${uri}&limit=100`; 167 + 168 + if (cursor) { 169 + getLikesURL += `&cursor=${cursor}`; 170 + } 171 + const likes = await fetch(getLikesURL); 172 + 173 + likesData = await likes.json(); 174 + cursor = likesData.cursor; 175 + allLikes.push(...likesData.likes); 176 + } while (cursor && getMore); 177 + 178 + return allLikes; 179 + } 180 + 181 + export async function getPost(uri: string) { 182 + try { 183 + const hydratedPost = await agent.getPosts({ uris: [uri] }); 184 + return hydratedPost.data.posts[0] as unknown as Post; 185 + } catch (e: any) { 186 + console.error(`[error] astro-embed` + '\n ' + (e?.message ?? e)); 187 + return undefined; 188 + } 189 + } 190 + 191 + export async function getUserPosts(actor: string) { 192 + const getPostURL = `https://public.api.bsky.app/xrpc/app.bsky.feed.getAuthorFeed?actor=${actor}&limit=100`; 193 + const posts = await fetch(getPostURL); 194 + const postsData = await posts.json(); 195 + return postsData.feed; 196 + } 197 + 198 + export async function getComments(uri: string) { 199 + const getCommentsURL = `https://public.api.bsky.app/xrpc/app.bsky.feed.getPostThread?uri=${uri}&depth=10`; 200 + const comments = await fetch(getCommentsURL); 201 + const commentsData = await comments.json(); 202 + const replies = commentsData.thread.replies; 203 + return replies.sort( 204 + (a: any, b: any) => 205 + new Date(b.post.record.createdAt).getTime() - new Date(a.post.record.createdAt).getTime() 206 + ); 207 + } 208 + 209 + export function numberToHumanReadable(number: number) { 210 + if (number < 1000) { 211 + return number; 212 + } 213 + if (number < 1000000) { 214 + return `${Math.floor(number / 1000)}k`; 215 + } 216 + return `${Math.floor(number / 1000000)}m`; 217 + } 218 + 219 + export function getCommentCount(comments: { replies?: any }[]) { 220 + // recursively check for replies and add them up 221 + let count = comments.length; 222 + for (const comment of comments) { 223 + if (comment.replies?.length) { 224 + count += getCommentCount(comment.replies); 225 + } 226 + } 227 + return count; 228 + }
+11 -1
src/routes/+page.svelte
··· 26 26 <div 27 27 class="md:max-w-84 order-first flex h-96 flex-col items-center justify-center px-8 text-base-100" 28 28 > 29 - <h1 class="text-balance text-3xl font-semibold tracking-tight sm:text-5xl">skywatched</h1> 29 + <h1 30 + class="text-balance text-3xl font-semibold tracking-tight sm:text-5xl flex items-start gap-3" 31 + > 32 + skywatched 33 + 34 + <span 35 + class="inline-flex w-fit items-center tracking-normal rounded-xl border border-accent-500/20 bg-accent-700/10 px-2 py-1 text-xs font-normal text-accent-400 shadow-sm transition-all duration-100" 36 + > 37 + public alpha 38 + </span> 39 + </h1> 30 40 <p class="text-md mt-8 max-w-xs text-pretty text-center font-medium text-base-300 sm:text-lg"> 31 41 review movies and shows with your friends from bluesky. 32 42 </p>
+118
src/routes/crosspost-image.png/+server.ts
··· 1 + // src/routes/og/+server.ts 2 + import { ImageResponse } from '@ethercorps/sveltekit-og'; 3 + import { type RequestHandler } from '@sveltejs/kit'; 4 + 5 + const template = ({ 6 + backdrop, 7 + poster, 8 + avatar, 9 + displayName, 10 + title, 11 + rating 12 + }: { 13 + backdrop: string; 14 + poster: string; 15 + avatar: string; 16 + displayName: string; 17 + title: string; 18 + rating: number; 19 + }) => { 20 + return ` 21 + <div tw="bg-zinc-900 flex flex-col w-full h-full items-center justify-center"> 22 + <div tw="flex absolute bottom-0 left-0 right-0 top-0 bg-sky-400"> 23 + <img src="https://image.tmdb.org/t/p/w780${backdrop}" alt="" class="flex h-full w-full rounded-xl opacity-50" /> 24 + 25 + <div tw="flex absolute h-full w-full bg-black/80"> 26 + </div> 27 + </div> 28 + <div tw="flex flex-row w-full py-12 px-4 items-center justify-start p-8"> 29 + <div tw="flex h-auto aspect-[3/2] w-82"> 30 + <img src="https://image.tmdb.org/t/p/w500${poster}" alt="" 31 + class="flex h-full w-full rounded-xl border-2 border-zinc-800" style="border-radius: 16px; border-width: 1px; border-color: #3f3f46;" /> 32 + </div> 33 + 34 + <div tw="flex flex-col text-7xl font-bold text-zinc-100 text-left px-8 max-w-3xl"> 35 + <div tw="flex items-center"> 36 + ${avatar ? `<img src=${avatar} tw="w-16 h-16 rounded-full" />` : ``} 37 + <div tw="flex truncate text-4xl text-zinc-100 pl-4"> 38 + ${displayName} 39 + <span tw="flex truncate text-sky-500 pl-2"> 40 + reviewed 41 + </span> 42 + </div> 43 + </div> 44 + 45 + <span tw="flex tracking-tight mt-4">${title}</span> 46 + 47 + ${ 48 + rating 49 + ? `<div tw="flex items-center mt-8"> 50 + <div tw="flex items-center"> 51 + 52 + ${new Array(5) 53 + .fill(0) 54 + .map( 55 + (_, i) => `<svg 56 + tw="w-20 h-20 flex ${i * 2 < (rating ?? 0) - 1 ? 'text-sky-400' : 'text-zinc-600'}" 57 + viewBox="0 0 24 24" 58 + fill="currentColor"> 59 + <path 60 + fill-rule="evenodd" 61 + d="M10.788 3.21c.448-1.077 1.976-1.077 2.424 0l2.082 5.006 5.404.434c1.164.093 1.636 1.545.749 2.305l-4.117 3.527 1.257 5.273c.271 1.136-.964 2.033-1.96 1.425L12 18.354 7.373 21.18c-.996.608-2.231-.29-1.96-1.425l1.257-5.273-4.117-3.527c-.887-.76-.415-2.212.749-2.305l5.404-.434 2.082-5.005Z" 62 + clip-rule="evenodd" 63 + /> 64 + </svg>` 65 + ) 66 + .join('')} 67 + </div> 68 + </div>` 69 + : `` 70 + } 71 + </div> 72 + </div> 73 + 74 + <div tw="flex text-4xl text-sky-400 items-end justify-end w-full px-6 pb-4"> 75 + <div tw="flex"> 76 + skywatched.app 77 + </div> 78 + </div> 79 + </div>`; 80 + }; 81 + 82 + const host = import.meta.env.DEV ? 'http://localhost:5173' : 'https://skywatched.app'; 83 + const fontPath = `fonts/inter-latin-ext-400-normal.woff`; 84 + const fontFile = await fetch(`${host}/${fontPath}`); 85 + const fontData: ArrayBuffer = await fontFile.arrayBuffer(); 86 + 87 + export const GET: RequestHandler = async ({ url }) => { 88 + const backdrop = url.searchParams.get('backdrop'); 89 + const poster = url.searchParams.get('poster'); 90 + 91 + const avatar = url.searchParams.get('avatar'); 92 + const displayName = url.searchParams.get('displayName'); 93 + const title = url.searchParams.get('title'); 94 + const rating = url.searchParams.get('rating'); 95 + 96 + console.log(rating); 97 + 98 + return new ImageResponse( 99 + template({ 100 + backdrop: backdrop ?? '', 101 + poster: poster ?? '', 102 + avatar: avatar ?? '', 103 + displayName: displayName ?? '', 104 + title: title ?? '', 105 + rating: rating ? parseInt(rating) * 2 : 0 106 + }), 107 + { 108 + fonts: [ 109 + { 110 + name: 'Inter Latin', 111 + data: fontData, 112 + weight: 400 113 + } 114 + ] 115 + }, 116 + {} 117 + ); 118 + };