your personal website on atproto - mirror blento.app

add signup

+825 -155
+22
src/lib/atproto/UI/Button.svelte
··· 1 + <script lang="ts"> 2 + import type { HTMLButtonAttributes } from 'svelte/elements'; 3 + 4 + type Props = HTMLButtonAttributes & { 5 + children: () => any; 6 + ref?: HTMLButtonElement | null; 7 + }; 8 + 9 + let { children, ref = $bindable(), class: className, ...props }: Props = $props(); 10 + </script> 11 + 12 + <button 13 + bind:this={ref} 14 + class={[ 15 + 'bg-accent-600 hover:bg-accent-500 focus-visible:outline-accent-600 text-white', 16 + 'inline-flex cursor-pointer justify-center rounded-full px-3 py-2 text-sm font-semibold shadow-sm focus-visible:outline focus-visible:outline-offset-2 disabled:cursor-not-allowed disabled:opacity-50', 17 + className 18 + ]} 19 + {...props} 20 + > 21 + {@render children()} 22 + </button>
+88
src/lib/atproto/UI/HandleInput.svelte
··· 1 + <script lang="ts"> 2 + import { AppBskyActorDefs } from '@atcute/bluesky'; 3 + import { Combobox } from 'bits-ui'; 4 + import { searchActorsTypeahead } from '$lib/atproto'; 5 + import { Avatar } from '@foxui/core'; 6 + 7 + let results: AppBskyActorDefs.ProfileViewBasic[] = $state([]); 8 + 9 + async function search(q: string) { 10 + if (!q || q.length < 2) { 11 + results = []; 12 + return; 13 + } 14 + results = (await searchActorsTypeahead(q, 5)).actors; 15 + } 16 + let open = $state(false); 17 + 18 + let { 19 + value = $bindable(), 20 + onselected, 21 + ref = $bindable() 22 + }: { 23 + value: string; 24 + onselected: (actor: AppBskyActorDefs.ProfileViewBasic) => void; 25 + ref?: HTMLInputElement | null; 26 + } = $props(); 27 + </script> 28 + 29 + <Combobox.Root 30 + type="single" 31 + onOpenChangeComplete={(o) => { 32 + if (!o) results = []; 33 + }} 34 + bind:value={ 35 + () => { 36 + return value; 37 + }, 38 + (val) => { 39 + const profile = results.find((v) => v.handle === val); 40 + if (profile) onselected?.(profile); 41 + 42 + value = val; 43 + } 44 + } 45 + bind:open={ 46 + () => { 47 + return open && results.length > 0; 48 + }, 49 + (val) => { 50 + open = val; 51 + } 52 + } 53 + > 54 + <Combobox.Input 55 + bind:ref 56 + oninput={(e) => { 57 + value = e.currentTarget.value; 58 + search(e.currentTarget.value); 59 + }} 60 + class="focus-within:outline-accent-600 dark:focus-within:outline-accent-500 dark:placeholder:text-base-400 w-full touch-none rounded-full border-0 bg-white ring-0 outline-1 -outline-offset-1 outline-gray-300 focus-within:outline-2 focus-within:-outline-offset-2 dark:bg-white/5 dark:outline-white/10" 61 + placeholder="handle" 62 + id="" 63 + aria-label="enter your handle" 64 + /> 65 + <Combobox.Content 66 + class="border-base-300 bg-base-50 dark:bg-base-900 dark:border-base-800 z-100 max-h-[30dvh] w-full rounded-2xl border shadow-lg" 67 + sideOffset={10} 68 + align="start" 69 + side="top" 70 + > 71 + <Combobox.Viewport class="w-full p-1"> 72 + {#each results as actor (actor.did)} 73 + <Combobox.Item 74 + class="rounded-button data-highlighted:bg-accent-100 dark:data-highlighted:bg-accent-600/30 my-0.5 flex w-full cursor-pointer items-center gap-2 rounded-xl p-2 px-2" 75 + value={actor.handle} 76 + label={actor.handle} 77 + > 78 + <Avatar 79 + src={actor.avatar?.replace('avatar', 'avatar_thumbnail')} 80 + alt="" 81 + class="size-6 rounded-full" 82 + /> 83 + {actor.handle} 84 + </Combobox.Item> 85 + {/each} 86 + </Combobox.Viewport> 87 + </Combobox.Content> 88 + </Combobox.Root>
+265
src/lib/atproto/UI/LoginModal.svelte
··· 1 + <script lang="ts" module> 2 + export const loginModalState = $state({ 3 + visible: false, 4 + show: () => (loginModalState.visible = true), 5 + hide: () => (loginModalState.visible = false) 6 + }); 7 + </script> 8 + 9 + <script lang="ts"> 10 + import { login, signup } from '$lib/atproto'; 11 + import type { ActorIdentifier, Did } from '@atcute/lexicons'; 12 + import Button from './Button.svelte'; 13 + import { onMount, tick } from 'svelte'; 14 + import SecondaryButton from './SecondaryButton.svelte'; 15 + import HandleInput from './HandleInput.svelte'; 16 + import { AppBskyActorDefs } from '@atcute/bluesky'; 17 + import { Avatar } from '@foxui/core'; 18 + 19 + let { signUp = true, loginOnSelect = true }: { signUp?: boolean; loginOnSelect?: boolean } = 20 + $props(); 21 + 22 + let value = $state(''); 23 + let error: string | null = $state(null); 24 + let loadingLogin = $state(false); 25 + let loadingSignup = $state(false); 26 + 27 + async function onSubmit(event?: Event) { 28 + event?.preventDefault(); 29 + if (loadingLogin) return; 30 + 31 + error = null; 32 + loadingLogin = true; 33 + 34 + try { 35 + await login(value as ActorIdentifier); 36 + } catch (err) { 37 + error = err instanceof Error ? err.message : String(err); 38 + } finally { 39 + loadingLogin = false; 40 + } 41 + } 42 + 43 + let input: HTMLInputElement | null = $state(null); 44 + let submitButton: HTMLButtonElement | null = $state(null); 45 + 46 + $effect(() => { 47 + if (!loginModalState.visible) { 48 + error = null; 49 + value = ''; 50 + loadingLogin = false; 51 + selectedActor = undefined; 52 + } else { 53 + focusInput(); 54 + } 55 + }); 56 + 57 + function focusInput() { 58 + tick().then(() => { 59 + input?.focus(); 60 + }); 61 + } 62 + function focusSubmit() { 63 + tick().then(() => { 64 + submitButton?.focus(); 65 + }); 66 + } 67 + 68 + let selectedActor: AppBskyActorDefs.ProfileViewBasic | undefined = $state(); 69 + 70 + let recentLogins: Record<Did, AppBskyActorDefs.ProfileViewBasic> = $state({}); 71 + 72 + onMount(() => { 73 + try { 74 + recentLogins = JSON.parse(localStorage.getItem('recent-logins') || '{}'); 75 + } catch { 76 + console.error('Failed to load recent logins'); 77 + } 78 + }); 79 + 80 + function removeRecentLogin(did: Did) { 81 + try { 82 + delete recentLogins[did]; 83 + 84 + localStorage.setItem('recent-logins', JSON.stringify(recentLogins)); 85 + } catch { 86 + console.error('Failed to remove recent login'); 87 + } 88 + } 89 + 90 + let recentLoginsView = $state(true); 91 + 92 + let showRecentLogins = $derived( 93 + Object.keys(recentLogins).length > 0 && !loadingLogin && !selectedActor && recentLoginsView 94 + ); 95 + </script> 96 + 97 + {#if loginModalState.visible} 98 + <div 99 + class="fixed inset-0 z-100 w-screen overflow-y-auto" 100 + aria-labelledby="modal-title" 101 + role="dialog" 102 + aria-modal="true" 103 + > 104 + <div 105 + class="bg-base-50/90 dark:bg-base-950/90 fixed inset-0 backdrop-blur-sm transition-opacity" 106 + onclick={() => (loginModalState.visible = false)} 107 + aria-hidden="true" 108 + ></div> 109 + 110 + <div class="pointer-events-none fixed inset-0 isolate z-10 w-screen overflow-y-auto"> 111 + <div 112 + class="flex min-h-full w-screen items-end justify-center p-4 text-center sm:items-center sm:p-0" 113 + > 114 + <div 115 + class="border-base-200 bg-base-100 dark:border-base-700 dark:bg-base-800 pointer-events-auto relative w-full transform overflow-hidden rounded-2xl border px-4 pt-4 pb-4 text-left shadow-xl transition-all sm:my-8 sm:max-w-sm sm:p-6" 116 + > 117 + <h3 class="text-base-900 dark:text-base-100 font-semibold" id="modal-title"> 118 + Login with your internet handle 119 + </h3> 120 + 121 + <div class="text-base-800 dark:text-base-200 mt-2 mb-2 text-xs font-light"> 122 + e.g. your bluesky account 123 + </div> 124 + 125 + <form onsubmit={onSubmit} class="mt-2 flex w-full flex-col gap-2"> 126 + {#if showRecentLogins} 127 + <div class="mt-2 mb-2 text-sm font-medium">Recent logins</div> 128 + <div class="flex flex-col gap-2"> 129 + {#each Object.values(recentLogins).slice(0, 4) as recentLogin (recentLogin.did)} 130 + <div class="group"> 131 + <div 132 + class="group-hover:bg-base-300 bg-base-200 dark:bg-base-700 dark:hover:bg-base-600 dark:border-base-500/50 border-base-300 relative flex h-10 w-full items-center justify-between gap-2 rounded-full border px-2 font-semibold transition-colors duration-100" 133 + > 134 + <div class="flex items-center gap-2"> 135 + <Avatar class="size-6" src={recentLogin.avatar} /> 136 + {recentLogin.handle} 137 + </div> 138 + <button 139 + class="z-20 cursor-pointer" 140 + onclick={() => { 141 + value = recentLogin.handle; 142 + selectedActor = recentLogin; 143 + if (loginOnSelect) onSubmit(); 144 + else focusSubmit(); 145 + }} 146 + > 147 + <div class="absolute inset-0 h-full w-full"></div> 148 + <span class="sr-only">login</span> 149 + </button> 150 + 151 + <button 152 + onclick={() => { 153 + removeRecentLogin(recentLogin.did); 154 + }} 155 + class="z-30 cursor-pointer rounded-full p-0.5" 156 + > 157 + <svg 158 + xmlns="http://www.w3.org/2000/svg" 159 + fill="none" 160 + viewBox="0 0 24 24" 161 + stroke-width="1.5" 162 + stroke="currentColor" 163 + class="size-3" 164 + > 165 + <path 166 + stroke-linecap="round" 167 + stroke-linejoin="round" 168 + d="M6 18 18 6M6 6l12 12" 169 + /> 170 + </svg> 171 + <span class="sr-only">sign in with other account</span> 172 + </button> 173 + </div> 174 + </div> 175 + {/each} 176 + </div> 177 + {:else if !selectedActor} 178 + <div class="mt-4 w-full"> 179 + <HandleInput 180 + bind:value 181 + onselected={(a) => { 182 + selectedActor = a; 183 + value = a.handle; 184 + if (loginOnSelect) onSubmit(); 185 + else focusSubmit(); 186 + }} 187 + bind:ref={input} 188 + /> 189 + </div> 190 + {:else} 191 + <div 192 + class="bg-base-200 dark:bg-base-700 border-base-300 dark:border-base-600 mt-4 flex h-10 w-full items-center justify-between gap-2 rounded-full border px-2 font-semibold" 193 + > 194 + <div class="flex items-center gap-2"> 195 + <Avatar class="size-6" src={selectedActor.avatar} /> 196 + {selectedActor.handle} 197 + </div> 198 + 199 + <button 200 + onclick={() => { 201 + selectedActor = undefined; 202 + value = ''; 203 + }} 204 + class="cursor-pointer rounded-full p-0.5" 205 + > 206 + <svg 207 + xmlns="http://www.w3.org/2000/svg" 208 + fill="none" 209 + viewBox="0 0 24 24" 210 + stroke-width="1.5" 211 + stroke="currentColor" 212 + class="size-3" 213 + > 214 + <path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" /> 215 + </svg> 216 + <span class="sr-only">sign in with other account</span> 217 + </button> 218 + </div> 219 + {/if} 220 + 221 + {#if error} 222 + <p class="text-accent-500 text-sm font-semibold">{error}</p> 223 + {/if} 224 + 225 + <div class="mt-4"> 226 + {#if showRecentLogins} 227 + <div class="mt-2 mb-4 text-sm font-medium">Or login with new handle</div> 228 + 229 + <Button 230 + onclick={() => { 231 + recentLoginsView = false; 232 + focusInput(); 233 + }} 234 + class="w-full">Login with new handle</Button 235 + > 236 + {:else} 237 + <Button bind:ref={submitButton} type="submit" disabled={loadingLogin} class="w-full" 238 + >{loadingLogin ? 'Loading...' : 'Login'}</Button 239 + > 240 + {/if} 241 + </div> 242 + 243 + {#if signUp} 244 + <div 245 + class="border-base-200 dark:border-base-700 text-base-800 dark:text-base-200 mt-4 border-t pt-4 text-sm leading-7" 246 + > 247 + Don't have an account? 248 + <div class="mt-3"> 249 + <SecondaryButton 250 + onclick={async () => { 251 + loadingSignup = true; 252 + await signup(); 253 + }} 254 + disabled={loadingSignup} 255 + class="w-full">{loadingSignup ? 'Loading...' : 'Sign Up'}</SecondaryButton 256 + > 257 + </div> 258 + </div> 259 + {/if} 260 + </form> 261 + </div> 262 + </div> 263 + </div> 264 + </div> 265 + {/if}
+20
src/lib/atproto/UI/SecondaryButton.svelte
··· 1 + <script lang="ts"> 2 + import type { HTMLButtonAttributes } from 'svelte/elements'; 3 + 4 + type Props = HTMLButtonAttributes & { 5 + children: () => any; 6 + }; 7 + 8 + let { children, class: className, ...props }: Props = $props(); 9 + </script> 10 + 11 + <button 12 + class={[ 13 + 'bg-base-300 dark:bg-base-700 dark:text-base-50 dark:hover:bg-base-600 hover:bg-base-200 focus-visible:outline-base-600 text-black transition-colors duration-100', 14 + 'inline-flex cursor-pointer justify-center rounded-full px-3 py-2 text-sm font-semibold shadow-sm focus-visible:outline focus-visible:outline-offset-2 disabled:cursor-not-allowed disabled:opacity-50', 15 + className 16 + ]} 17 + {...props} 18 + > 19 + {@render children()} 20 + </button>
+3 -2
src/lib/atproto/auth.svelte.ts
··· 17 17 WellKnownHandleResolver 18 18 } from '@atcute/identity-resolver'; 19 19 import { Client } from '@atcute/client'; 20 - import type { ActorIdentifier, Did } from '@atcute/lexicons'; 21 20 22 21 import { dev } from '$app/environment'; 23 22 import { replaceState } from '$app/navigation'; ··· 26 25 import { getDetailedProfile } from './methods'; 27 26 import { signUpPDS } from './settings'; 28 27 import { SvelteURLSearchParams } from 'svelte/reactivity'; 28 + 29 + import type { ActorIdentifier, Did } from '@atcute/lexicons'; 29 30 30 31 export const user = $state({ 31 32 agent: null as OAuthUserAgent | null, ··· 151 152 } 152 153 } 153 154 154 - async function finalizeLogin(params: URLSearchParams, did?: Did) { 155 + async function finalizeLogin(params: SvelteURLSearchParams, did?: Did) { 155 156 try { 156 157 const { session } = await finalizeAuthorization(params); 157 158 replaceState(location.pathname + location.search, {});
+152
src/lib/atproto/image-helper.ts
··· 1 + import { getCDNImageBlobUrl, uploadBlob } from './methods'; 2 + 3 + export function compressImage( 4 + file: File | Blob, 5 + maxSize: number = 900 * 1024, 6 + maxDimension: number = 2048 7 + ): Promise<{ 8 + blob: Blob; 9 + aspectRatio: { 10 + width: number; 11 + height: number; 12 + }; 13 + }> { 14 + return new Promise((resolve, reject) => { 15 + const img = new Image(); 16 + const reader = new FileReader(); 17 + 18 + reader.onload = (e) => { 19 + if (!e.target?.result) { 20 + return reject(new Error('Failed to read file.')); 21 + } 22 + img.src = e.target.result as string; 23 + }; 24 + 25 + reader.onerror = (err) => reject(err); 26 + reader.readAsDataURL(file); 27 + 28 + img.onload = () => { 29 + let width = img.width; 30 + let height = img.height; 31 + 32 + // If image is already small enough, return original 33 + if (file.size <= maxSize) { 34 + console.log('skipping compression+resizing, already small enough'); 35 + return resolve({ 36 + blob: file, 37 + aspectRatio: { 38 + width, 39 + height 40 + } 41 + }); 42 + } 43 + 44 + if (width > maxDimension || height > maxDimension) { 45 + if (width > height) { 46 + height = Math.round((maxDimension / width) * height); 47 + width = maxDimension; 48 + } else { 49 + width = Math.round((maxDimension / height) * width); 50 + height = maxDimension; 51 + } 52 + } 53 + 54 + // Create a canvas to draw the image 55 + const canvas = document.createElement('canvas'); 56 + canvas.width = width; 57 + canvas.height = height; 58 + const ctx = canvas.getContext('2d'); 59 + if (!ctx) return reject(new Error('Failed to get canvas context.')); 60 + ctx.drawImage(img, 0, 0, width, height); 61 + 62 + // Use WebP for both compression and transparency support 63 + let quality = 0.9; 64 + 65 + function attemptCompression() { 66 + canvas.toBlob( 67 + (blob) => { 68 + if (!blob) { 69 + return reject(new Error('Compression failed.')); 70 + } 71 + if (blob.size <= maxSize || quality < 0.3) { 72 + resolve({ 73 + blob, 74 + aspectRatio: { 75 + width, 76 + height 77 + } 78 + }); 79 + } else { 80 + quality -= 0.1; 81 + attemptCompression(); 82 + } 83 + }, 84 + 'image/webp', 85 + quality 86 + ); 87 + } 88 + 89 + attemptCompression(); 90 + }; 91 + 92 + img.onerror = (err) => reject(err); 93 + }); 94 + } 95 + 96 + export async function checkAndUploadImage( 97 + recordWithImage: Record<string, any>, 98 + key: string = 'image', 99 + // e.g. /api/image-proxy?url= 100 + imageProxy?: string 101 + ) { 102 + if (!recordWithImage[key]) return; 103 + 104 + // Already uploaded as blob 105 + if (typeof recordWithImage[key] === 'object' && recordWithImage[key].$type === 'blob') { 106 + return; 107 + } 108 + 109 + if (typeof recordWithImage[key] === 'string' && imageProxy) { 110 + const proxyUrl = imageProxy + encodeURIComponent(recordWithImage[key]); 111 + const response = await fetch(proxyUrl); 112 + if (!response.ok) { 113 + throw Error('failed to get image from image proxy'); 114 + } 115 + 116 + const blob = await response.blob(); 117 + const compressedBlob = await compressImage(blob); 118 + 119 + recordWithImage[key] = await uploadBlob({ blob: compressedBlob.blob }); 120 + 121 + return; 122 + } 123 + 124 + if (recordWithImage[key]?.blob) { 125 + if (recordWithImage[key].objectUrl) { 126 + URL.revokeObjectURL(recordWithImage[key].objectUrl); 127 + } 128 + const compressedBlob = await compressImage(recordWithImage[key].blob); 129 + recordWithImage[key] = await uploadBlob({ blob: compressedBlob.blob }); 130 + } 131 + } 132 + 133 + export function getImageFromRecord( 134 + recordWithImage: Record<string, any> | undefined, 135 + did: string, 136 + key: string = 'image' 137 + ): string | undefined { 138 + if (!recordWithImage?.[key]) return; 139 + 140 + if (typeof recordWithImage[key] === 'object' && recordWithImage[key].$type === 'blob') { 141 + return getCDNImageBlobUrl({ did, blob: recordWithImage[key] }); 142 + } 143 + 144 + if (recordWithImage[key].objectUrl) return recordWithImage[key].objectUrl; 145 + 146 + if (recordWithImage[key].blob) { 147 + recordWithImage[key].objectUrl = URL.createObjectURL(recordWithImage[key].blob); 148 + return recordWithImage[key].objectUrl; 149 + } 150 + 151 + return recordWithImage[key]; 152 + }
+3 -2
src/lib/atproto/index.ts
··· 14 14 uploadBlob, 15 15 describeRepo, 16 16 getBlobURL, 17 - getImageBlobUrl, 18 - searchActorsTypeahead 17 + getCDNImageBlobUrl, 18 + searchActorsTypeahead, 19 + getAuthorFeed 19 20 } from './methods';
+8 -7
src/lib/atproto/metadata.ts
··· 1 1 import { resolve } from '$app/paths'; 2 - import { blobs, collections, rpcCalls, SITE } from './settings'; 2 + import { permissions, SITE } from './settings'; 3 3 4 4 function constructScope() { 5 - const repos = collections.map((collection) => 'repo:' + collection).join(' '); 5 + const repos = permissions.collections.map((collection) => 'repo:' + collection).join(' '); 6 6 7 7 let rpcs = ''; 8 - for (const [key, value] of Object.entries(rpcCalls)) { 8 + for (const [key, value] of Object.entries(permissions.rpc ?? {})) { 9 9 if (Array.isArray(value)) { 10 10 rpcs += value.map((lxm) => 'rpc?lxm=' + lxm + '&aud=' + key).join(' '); 11 11 } else { 12 12 rpcs += 'rpc?lxm=' + value + '&aud=' + key; 13 13 } 14 14 } 15 + 15 16 let blobScope: string | undefined = undefined; 16 - if (Array.isArray(blobs)) { 17 - blobScope = 'blob?' + blobs.map((b) => 'accept=' + b).join('&'); 18 - } else if (blobs) { 19 - blobScope = 'blob:' + blobs; 17 + if (Array.isArray(permissions.blobs)) { 18 + blobScope = 'blob?' + permissions.blobs.map((b) => 'accept=' + b).join('&'); 19 + } else if (permissions.blobs) { 20 + blobScope = 'blob:' + permissions.blobs; 20 21 } 21 22 22 23 const scope = ['atproto', repos, rpcs, blobScope].filter((v) => v?.trim()).join(' ');
+158 -59
src/lib/atproto/methods.ts
··· 1 - import type { Did, Handle } from '@atcute/lexicons'; 1 + import { parseResourceUri, type Did, type Handle } from '@atcute/lexicons'; 2 2 import { user } from './auth.svelte'; 3 + import type { AllowedCollection } from './settings'; 3 4 import { 4 5 CompositeDidDocumentResolver, 5 6 CompositeHandleResolver, ··· 9 10 WellKnownHandleResolver 10 11 } from '@atcute/identity-resolver'; 11 12 import { Client, simpleFetchHandler } from '@atcute/client'; 12 - import type { AppBskyActorDefs } from '@atcute/bluesky'; 13 - import { redirect } from '@sveltejs/kit'; 13 + import { type AppBskyActorDefs } from '@atcute/bluesky'; 14 14 15 15 export type Collection = `${string}.${string}.${string}`; 16 + import * as TID from '@atcute/tid'; 16 17 18 + /** 19 + * Parses an AT Protocol URI into its components. 20 + * @param uri - The AT URI to parse (e.g., "at://did:plc:xyz/app.bsky.feed.post/abc123") 21 + * @returns An object containing the repo, collection, and rkey or undefined if not an AT uri 22 + */ 17 23 export function parseUri(uri: string) { 18 - const [did, collection, rkey] = uri.replace('at://', '').split('/'); 19 - return { did, collection, rkey } as { 20 - collection: `${string}.${string}.${string}`; 21 - rkey: string; 22 - did: Did; 23 - }; 24 + const parts = parseResourceUri(uri); 25 + if (!parts.ok) return; 26 + return parts.value; 24 27 } 25 28 29 + /** 30 + * Resolves a handle to a DID using DNS and HTTP methods. 31 + * @param handle - The handle to resolve (e.g., "alice.bsky.social") 32 + * @returns The DID associated with the handle 33 + */ 26 34 export async function resolveHandle({ handle }: { handle: Handle }) { 27 35 const handleResolver = new CompositeHandleResolver({ 28 36 methods: { ··· 31 39 } 32 40 }); 33 41 34 - try { 35 - const data = await handleResolver.resolve(handle); 36 - return data; 37 - } catch (error) { 38 - redirect(307, '/?error=handle_not_found&handle=' + handle); 39 - } 42 + const data = await handleResolver.resolve(handle); 43 + return data; 40 44 } 41 45 42 46 const didResolver = new CompositeDidDocumentResolver({ ··· 46 50 } 47 51 }); 48 52 53 + /** 54 + * Gets the PDS (Personal Data Server) URL for a given DID. 55 + * @param did - The DID to look up 56 + * @returns The PDS service endpoint URL 57 + * @throws If no PDS is found in the DID document 58 + */ 49 59 export async function getPDS(did: Did) { 50 - const doc = await didResolver.resolve(did as `did:plc:${string}` | `did:web:${string}`); 60 + const doc = await didResolver.resolve(did as Did<'plc'> | Did<'web'>); 51 61 if (!doc.service) throw new Error('No PDS found'); 52 62 for (const service of doc.service) { 53 63 if (service.id === '#atproto_pds') { ··· 56 66 } 57 67 } 58 68 69 + /** 70 + * Fetches a detailed Bluesky profile for a user. 71 + * @param data - Optional object with did and client 72 + * @param data.did - The DID to fetch the profile for (defaults to current user) 73 + * @param data.client - The client to use (defaults to public Bluesky API) 74 + * @returns The profile data or undefined if not found 75 + */ 59 76 export async function getDetailedProfile(data?: { did?: Did; client?: Client }) { 60 77 data ??= {}; 61 78 data.did ??= user.did; ··· 75 92 return response.data; 76 93 } 77 94 78 - export async function getAuthorFeed(data?: { 79 - did?: Did; 80 - client?: Client; 81 - filter?: string; 82 - limit?: number; 83 - }) { 84 - data ??= {}; 85 - data.did ??= user.did; 86 - 87 - if (!data.did) throw new Error('Error getting detailed profile: no did'); 88 - 89 - data.client ??= new Client({ 90 - handler: simpleFetchHandler({ service: 'https://public.api.bsky.app' }) 91 - }); 92 - 93 - const response = await data.client.get('app.bsky.feed.getAuthorFeed', { 94 - params: { actor: data.did, filter: data.filter ?? 'posts_with_media', limit: data.limit || 100 } 95 - }); 96 - 97 - if (!response.ok) return; 98 - 99 - return response.data; 100 - } 101 - 95 + /** 96 + * Creates an AT Protocol client for a user's PDS. 97 + * @param did - The DID of the user 98 + * @returns A client configured for the user's PDS 99 + * @throws If the PDS cannot be found 100 + */ 102 101 export async function getClient({ did }: { did: Did }) { 103 102 const pds = await getPDS(did); 104 103 if (!pds) throw new Error('PDS not found'); ··· 110 109 return client; 111 110 } 112 111 112 + /** 113 + * Lists records from a repository collection with pagination support. 114 + * @param did - The DID of the repository (defaults to current user) 115 + * @param collection - The collection to list records from 116 + * @param cursor - Pagination cursor for continuing from a previous request 117 + * @param limit - Maximum number of records to return (default 100, set to 0 for all records) 118 + * @param client - The client to use (defaults to user's PDS client) 119 + * @returns An array of records from the collection 120 + */ 113 121 export async function listRecords({ 114 122 did, 115 123 collection, 116 124 cursor, 117 - limit = 0, 125 + limit = 100, 118 126 client 119 127 }: { 120 128 did?: Did; ··· 141 149 params: { 142 150 repo: did, 143 151 collection, 144 - limit: limit || 100, 152 + limit: !limit || limit > 100 ? 100 : limit, 145 153 cursor: currentCursor 146 154 } 147 155 }); ··· 157 165 return allRecords; 158 166 } 159 167 168 + /** 169 + * Fetches a single record from a repository. 170 + * @param did - The DID of the repository (defaults to current user) 171 + * @param collection - The collection the record belongs to 172 + * @param rkey - The record key (defaults to "self") 173 + * @param client - The client to use (defaults to user's PDS client) 174 + * @returns The record data 175 + */ 160 176 export async function getRecord({ 161 177 did, 162 178 collection, 163 - rkey, 179 + rkey = 'self', 164 180 client 165 181 }: { 166 182 did?: Did; ··· 169 185 client?: Client; 170 186 }) { 171 187 did ??= user.did; 172 - rkey ??= 'self'; 173 188 174 189 if (!collection) { 175 190 throw new Error('Missing parameters for getRecord'); ··· 188 203 } 189 204 }); 190 205 191 - if (!record.ok) return; 192 - 193 206 return JSON.parse(JSON.stringify(record.data)); 194 207 } 195 208 209 + /** 210 + * Creates or updates a record in the current user's repository. 211 + * Only accepts collections that are configured in permissions. 212 + * @param collection - The collection to write to (must be in permissions.collections) 213 + * @param rkey - The record key (defaults to "self") 214 + * @param record - The record data to write 215 + * @returns The response from the PDS 216 + * @throws If the user is not logged in 217 + */ 196 218 export async function putRecord({ 197 219 collection, 198 - rkey, 220 + rkey = 'self', 199 221 record 200 222 }: { 201 - collection: Collection; 202 - rkey: string; 223 + collection: AllowedCollection; 224 + rkey?: string; 203 225 record: Record<string, unknown>; 204 226 }) { 205 227 if (!user.client || !user.did) throw new Error('No rpc or did'); ··· 218 240 return response; 219 241 } 220 242 221 - export async function deleteRecord({ collection, rkey }: { collection: Collection; rkey: string }) { 243 + /** 244 + * Deletes a record from the current user's repository. 245 + * Only accepts collections that are configured in permissions. 246 + * @param collection - The collection the record belongs to (must be in permissions.collections) 247 + * @param rkey - The record key (defaults to "self") 248 + * @returns True if the deletion was successful 249 + * @throws If the user is not logged in 250 + */ 251 + export async function deleteRecord({ 252 + collection, 253 + rkey = 'self' 254 + }: { 255 + collection: AllowedCollection; 256 + rkey: string; 257 + }) { 222 258 if (!user.client || !user.did) throw new Error('No profile or rpc or did'); 223 259 224 260 const response = await user.client.post('com.atproto.repo.deleteRecord', { ··· 232 268 return response.ok; 233 269 } 234 270 271 + /** 272 + * Uploads a blob to the current user's PDS. 273 + * @param blob - The blob data to upload 274 + * @returns The blob metadata including ref, mimeType, and size, or undefined on failure 275 + * @throws If the user is not logged in 276 + */ 235 277 export async function uploadBlob({ blob }: { blob: Blob }) { 236 278 if (!user.did || !user.client) throw new Error("Can't upload blob: Not logged in"); 237 279 238 280 const blobResponse = await user.client.post('com.atproto.repo.uploadBlob', { 239 - input: blob, 240 - data: { 281 + params: { 241 282 repo: user.did 242 - } 283 + }, 284 + input: blob 243 285 }); 244 286 245 - if (!blobResponse?.ok) { 246 - return; 247 - } 287 + if (!blobResponse?.ok) return; 248 288 249 289 const blobInfo = blobResponse?.data.blob as { 250 290 $type: 'blob'; ··· 258 298 return blobInfo; 259 299 } 260 300 301 + /** 302 + * Gets metadata about a repository. 303 + * @param client - The client to use 304 + * @param did - The DID of the repository (defaults to current user) 305 + * @returns Repository metadata or undefined on failure 306 + */ 261 307 export async function describeRepo({ client, did }: { client?: Client; did?: Did }) { 262 308 did ??= user.did; 263 309 if (!did) { ··· 275 321 return repo.data; 276 322 } 277 323 324 + /** 325 + * Constructs a URL to fetch a blob directly from a user's PDS. 326 + * @param did - The DID of the user who owns the blob 327 + * @param blob - The blob reference object 328 + * @returns The URL to fetch the blob 329 + */ 278 330 export async function getBlobURL({ 279 331 did, 280 332 blob ··· 291 343 return `${pds}/xrpc/com.atproto.sync.getBlob?did=${did}&cid=${blob.ref.$link}`; 292 344 } 293 345 294 - export function getImageBlobUrl({ 346 + /** 347 + * Constructs a Bluesky CDN URL for an image blob. 348 + * @param did - The DID of the user who owns the blob (defaults to current user) 349 + * @param blob - The blob reference object 350 + * @returns The CDN URL for the image in webp format 351 + */ 352 + export function getCDNImageBlobUrl({ 295 353 did, 296 354 blob 297 355 }: { 298 - did: string; 356 + did?: string; 299 357 blob: { 300 358 $type: 'blob'; 301 359 ref: { ··· 303 361 }; 304 362 }; 305 363 }) { 306 - if (!did || !blob?.ref?.$link) return ''; 364 + did ??= user.did; 365 + 307 366 return `https://cdn.bsky.app/img/feed_thumbnail/plain/${did}/${blob.ref.$link}@webp`; 308 367 } 309 368 369 + /** 370 + * Searches for actors with typeahead/autocomplete functionality. 371 + * @param q - The search query 372 + * @param limit - Maximum number of results (default 10) 373 + * @param host - The API host to use (defaults to public Bluesky API) 374 + * @returns An object containing matching actors and the original query 375 + */ 310 376 export async function searchActorsTypeahead( 311 377 q: string, 312 378 limit: number = 10, ··· 329 395 330 396 return { actors: response.data.actors, q }; 331 397 } 398 + 399 + /** 400 + * Return a TID based on current time 401 + * 402 + * @returns TID for current time 403 + */ 404 + export function createTID() { 405 + return TID.now(); 406 + } 407 + 408 + export async function getAuthorFeed(data?: { 409 + did?: Did; 410 + client?: Client; 411 + filter?: string; 412 + limit?: number; 413 + }) { 414 + data ??= {}; 415 + data.did ??= user.did; 416 + 417 + if (!data.did) throw new Error('Error getting detailed profile: no did'); 418 + 419 + data.client ??= new Client({ 420 + handler: simpleFetchHandler({ service: 'https://public.api.bsky.app' }) 421 + }); 422 + 423 + const response = await data.client.get('app.bsky.feed.getAuthorFeed', { 424 + params: { actor: data.did, filter: data.filter ?? 'posts_with_media', limit: data.limit || 100 } 425 + }); 426 + 427 + if (!response.ok) return; 428 + 429 + return response.data; 430 + }
+43 -16
src/lib/atproto/settings.ts
··· 1 - export const SITE = 'https://blento.app'; 1 + export const SITE = 'https://flo-bit.dev'; 2 + 3 + type Permissions = { 4 + collections: readonly string[]; 5 + rpc: Record<string, string | string[]>; 6 + blobs: readonly string[]; 7 + }; 8 + 9 + export const permissions = { 10 + // collections you can create/delete/update 11 + 12 + // example: only allow create and delete 13 + // collections: ['xyz.statusphere.status?action=create&action=update'], 14 + collections: [ 15 + 'app.blento.card', 16 + 'app.blento.page', 17 + 'app.blento.settings', 18 + 'app.blento.comment', 19 + 'app.blento.guestbook.entry', 20 + 'site.standard.publication', 21 + 'site.standard.document', 22 + 'xyz.statusphere.status' 23 + ], 24 + 25 + // what types of authenticated proxied requests you can make to services 26 + 27 + // example: allow authenticated proxying to bsky appview to get a users liked posts 28 + //rpc: {'did:web:api.bsky.app#bsky_appview': ['app.bsky.feed.getActorLikes']} 29 + rpc: {}, 30 + 31 + // what types of blobs you can upload to a users PDS 2 32 3 - export const collections: string[] = [ 4 - 'app.blento.card', 5 - 'app.blento.page', 6 - 'app.blento.settings', 7 - 'app.blento.comment', 8 - 'app.blento.guestbook.entry', 9 - 'site.standard.publication', 10 - 'site.standard.document', 11 - 'xyz.statusphere.status' 12 - ]; 33 + // example: allowing video and html uploads 34 + // blobs: ['video/*', 'text/html'] 35 + // example: allowing all blob types 36 + // blobs: ['*/*'] 37 + blobs: ['*/*'] 38 + } as const satisfies Permissions; 13 39 14 - export const rpcCalls: Record<string, string | string[]> = { 15 - //'did:web:api.bsky.app#bsky_appview': ['app.bsky.actor.getProfile'] 16 - }; 40 + // Extract base collection name (before any query params) 41 + type ExtractCollectionBase<T extends string> = T extends `${infer Base}?${string}` ? Base : T; 17 42 18 - export const blobs = ['*/*'] as string | string[] | undefined; 43 + export type AllowedCollection = ExtractCollectionBase<(typeof permissions.collections)[number]>; 19 44 20 - export const signUpPDS = 'https://pds.rip/'; 45 + // which PDS to use for signup 46 + // ATTENTION: pds.rip is only for development, all accounts get deleted automatically after a week 47 + export const signUpPDS = 'https://selfhosted.social/';
+5 -4
src/lib/cards/EventCard/EventCard.svelte
··· 8 8 import { parseUri } from '$lib/atproto'; 9 9 import { browser } from '$app/environment'; 10 10 import { qrOverlay } from '$lib/components/qr/qrOverlay.svelte'; 11 + import type { Did } from '@atcute/lexicons'; 11 12 12 13 let { item }: ContentComponentProps = $props(); 13 14 ··· 27 28 let parsedUri = $derived(item.cardData?.uri ? parseUri(item.cardData.uri) : null); 28 29 29 30 onMount(async () => { 30 - if (!eventData && item.cardData?.uri) { 31 + if (!eventData && item.cardData?.uri && parsedUri?.repo) { 31 32 const loadedData = (await CardDefinitionsByType[item.cardType]?.loadData?.([item], { 32 - did: parsedUri?.did ?? ('' as `did:${string}:${string}`), 33 + did: parsedUri.repo as Did, 33 34 handle: '' 34 35 })) as Record<string, EventData> | undefined; 35 36 ··· 92 93 let eventUrl = $derived(() => { 93 94 if (eventData?.url) return eventData.url; 94 95 if (parsedUri) { 95 - return `https://smokesignal.events/${parsedUri.did}/${parsedUri.rkey}`; 96 + return `https://smokesignal.events/${parsedUri.repo}/${parsedUri.rkey}`; 96 97 } 97 98 return '#'; 98 99 }); ··· 104 105 const header = eventData.media.find((m) => m.role === 'header'); 105 106 if (!header?.content?.ref?.$link) return null; 106 107 return { 107 - url: `https://cdn.bsky.app/img/feed_thumbnail/plain/${parsedUri.did}/${header.content.ref.$link}@jpeg`, 108 + url: `https://cdn.bsky.app/img/feed_thumbnail/plain/${parsedUri.repo}/${header.content.ref.$link}@jpeg`, 108 109 alt: header.alt || eventData.name 109 110 }; 110 111 });
+3 -2
src/lib/cards/EventCard/index.ts
··· 58 58 const uri = item.cardData?.uri; 59 59 if (!uri) continue; 60 60 61 - const { did, rkey } = parseUri(uri); 61 + const parsedUri = parseUri(uri); 62 + if (!parsedUri || !parsedUri.rkey || !parsedUri.repo) continue; 62 63 63 64 try { 64 65 const response = await fetch( 65 - `https://smokesignal.events/xrpc/community.lexicon.calendar.GetEvent?repository=${encodeURIComponent(did)}&record_key=${encodeURIComponent(rkey)}` 66 + `https://smokesignal.events/xrpc/community.lexicon.calendar.GetEvent?repository=${encodeURIComponent(parsedUri.repo)}&record_key=${encodeURIComponent(parsedUri.rkey)}` 66 67 ); 67 68 68 69 if (response.ok) {
+2 -2
src/lib/cards/LivestreamCard/index.ts
··· 1 - import { user, listRecords, getImageBlobUrl } from '$lib/atproto'; 1 + import { user, listRecords, getCDNImageBlobUrl } from '$lib/atproto'; 2 2 import type { CardDefinition } from '../types'; 3 3 import LivestreamCard from './LivestreamCard.svelte'; 4 4 import LivestreamEmbedCard from './LivestreamEmbedCard.svelte'; ··· 35 35 createdAt: latest.value.createdAt, 36 36 title: latest.value?.title as string, 37 37 thumb: latest.value?.thumb?.ref?.$link 38 - ? getImageBlobUrl({ blob: latest.value.thumb, did }) 38 + ? getCDNImageBlobUrl({ blob: latest.value.thumb, did }) 39 39 : undefined, 40 40 href: latest.value?.canonicalUrl || latest.value.url, 41 41 online: undefined
+3 -3
src/lib/cards/PhotoGalleryCard/PhotoGalleryCard.svelte
··· 8 8 getIsMobile 9 9 } from '$lib/website/context'; 10 10 import { CardDefinitionsByType } from '..'; 11 - import { getImageBlobUrl, parseUri } from '$lib/atproto'; 11 + import { getCDNImageBlobUrl, parseUri } from '$lib/atproto'; 12 12 13 13 import { ImageMasonry } from '@foxui/visual'; 14 14 ··· 54 54 return (a.value.position ?? 0) - (b.value.position ?? 0); 55 55 }) 56 56 .map((i: PhotoItem) => { 57 - const { did: photoDid } = parseUri(i.uri); 57 + const item = parseUri(i.uri); 58 58 return { 59 - src: getImageBlobUrl({ did: photoDid, blob: i.value.photo }), 59 + src: getCDNImageBlobUrl({ did: item?.repo, blob: i.value.photo }), 60 60 name: '', 61 61 width: i.value.aspectRatio.width, 62 62 height: i.value.aspectRatio.height,
+10 -4
src/lib/cards/PhotoGalleryCard/index.ts
··· 1 1 import type { CardDefinition } from '../types'; 2 2 import { getRecord, listRecords, parseUri } from '$lib/atproto'; 3 3 import PhotoGalleryCard from './PhotoGalleryCard.svelte'; 4 + import type { Did } from '@atcute/lexicons'; 4 5 5 6 interface GalleryItem { 6 7 value: { ··· 33 34 for (const item of items) { 34 35 if (!item.cardData.galleryUri) continue; 35 36 36 - const { did, collection } = parseUri(item.cardData.galleryUri); 37 + const parsedUri = parseUri(item.cardData.galleryUri); 37 38 38 - if (collection === 'social.grain.gallery') { 39 + if (parsedUri?.collection === 'social.grain.gallery') { 39 40 const itemCollection = 'social.grain.gallery.item'; 40 41 41 42 if (!galleryItems[itemCollection]) { 42 43 galleryItems[itemCollection] = (await listRecords({ 43 - did, 44 + did: parsedUri.repo as Did, 44 45 collection: itemCollection 45 46 })) as unknown as GalleryItem[]; 46 47 } ··· 52 53 .filter((i) => i.value.gallery === item.cardData.galleryUri) 53 54 .map(async (i) => { 54 55 const itemData = parseUri(i.value.item); 55 - const record = await getRecord(itemData); 56 + if (!itemData) return null; 57 + const record = await getRecord({ 58 + did: itemData.repo as Did, 59 + collection: itemData.collection!, 60 + rkey: itemData.rkey 61 + }); 56 62 return { ...record, value: { ...record.value, ...i.value } }; 57 63 }); 58 64
+2 -6
src/lib/cards/SpecialCards/UpdatedBlentos/UpdatedBlentosCard.svelte
··· 2 2 import type { ContentComponentProps } from '$lib/cards/types'; 3 3 import { getAdditionalUserData } from '$lib/website/context'; 4 4 import type { AppBskyActorDefs } from '@atcute/bluesky'; 5 + import { Avatar } from '@foxui/core'; 5 6 6 7 let { item }: ContentComponentProps = $props(); 7 8 ··· 19 20 class="bg-base-100 dark:bg-base-800 hover:bg-base-200 dark:hover:bg-base-700 accent:bg-accent-200/30 accent:hover:bg-accent-200/50 flex h-52 w-44 min-w-44 flex-col items-center justify-center gap-2 rounded-xl p-2 transition-colors duration-150" 20 21 target="_blank" 21 22 > 22 - <img 23 - src={profile.avatar} 24 - class="bg-base-200 dark:bg-base-700 accent:bg-accent-300 aspect-square size-28 rounded-full" 25 - alt="" 26 - loading="lazy" 27 - /> 23 + <Avatar src={profile.avatar} class="size-28" alt="" /> 28 24 <div class="text-md line-clamp-1 max-w-full text-center font-bold"> 29 25 {profile.displayName || profile.handle} 30 26 </div>
+7 -1
src/lib/cards/StandardSiteDocumentListCard/index.ts
··· 22 22 if (!publications[site]) { 23 23 const siteParts = parseUri(site); 24 24 25 - const publicationRecord = await getRecord(siteParts); 25 + if (!siteParts) continue; 26 + 27 + const publicationRecord = await getRecord({ 28 + did: siteParts.repo as `did:${string}:${string}`, 29 + collection: siteParts.collection!, 30 + rkey: siteParts.rkey 31 + }); 26 32 27 33 publications[site] = publicationRecord.value.url as string; 28 34 }
+2 -3
src/lib/helper.ts
··· 1 1 import type { Item, WebsiteData } from './types'; 2 2 import { COLUMNS, margin, mobileMargin } from '$lib'; 3 3 import { CardDefinitionsByType } from './cards'; 4 - import { deleteRecord, getImageBlobUrl, putRecord, uploadBlob } from '$lib/atproto'; 5 - import { toast } from '@foxui/core'; 4 + import { deleteRecord, getCDNImageBlobUrl, putRecord, uploadBlob } from '$lib/atproto'; 6 5 import * as TID from '@atcute/tid'; 7 6 8 7 export function clamp(value: number, min: number, max: number): number { ··· 627 626 if (objectWithImage[key].objectUrl) return objectWithImage[key].objectUrl; 628 627 629 628 if (typeof objectWithImage[key] === 'object' && objectWithImage[key].$type === 'blob') { 630 - return getImageBlobUrl({ did, blob: objectWithImage[key] }); 629 + return getCDNImageBlobUrl({ did, blob: objectWithImage[key] }); 631 630 } 632 631 return objectWithImage[key]; 633 632 }
+2 -2
src/lib/website/Account.svelte
··· 2 2 import { user, login, logout } from '$lib/atproto'; 3 3 import type { WebsiteData } from '$lib/types'; 4 4 import type { ActorIdentifier } from '@atcute/lexicons'; 5 - import { Button, Popover } from '@foxui/core'; 5 + import { Avatar, Button, Popover } from '@foxui/core'; 6 6 7 7 let { 8 8 data ··· 18 18 <Popover sideOffset={8} bind:open={settingsPopoverOpen} class="bg-base-100 dark:bg-base-900"> 19 19 {#snippet child({ props })} 20 20 <button {...props}> 21 - <img src={user.profile?.avatar} alt="" class="size-15 rounded-full" /> 21 + <Avatar src={user.profile?.avatar} alt="" class="size-15 rounded-full" /> 22 22 </button> 23 23 {/snippet} 24 24
+8 -10
src/lib/website/EditableProfile.svelte
··· 3 3 import { getImage, compressImage, getProfilePosition } from '$lib/helper'; 4 4 import PlainTextEditor from '$lib/components/PlainTextEditor.svelte'; 5 5 import MarkdownTextEditor from '$lib/components/MarkdownTextEditor.svelte'; 6 - import { Button } from '@foxui/core'; 6 + import { Avatar, Button } from '@foxui/core'; 7 7 import { getIsMobile } from './context'; 8 8 import type { Editor } from '@tiptap/core'; 9 9 import MadeWithBlento from './MadeWithBlento.svelte'; ··· 149 149 onmouseleave={() => (isHoveringAvatar = false)} 150 150 onclick={handleFileInputClick} 151 151 > 152 - {#if getAvatarUrl()} 153 - <img 154 - class="border-base-400 dark:border-base-800 size-full shrink-0 rounded-full border object-cover" 155 - src={getAvatarUrl()} 156 - alt="" 157 - /> 158 - {:else} 159 - <div class="bg-base-300 dark:bg-base-700 size-full rounded-full"></div> 160 - {/if} 152 + <Avatar 153 + src={getAvatarUrl()} 154 + class={[ 155 + 'border-base-400 dark:border-base-800 size-32 shrink-0 rounded-full border object-cover', 156 + profilePosition === 'side' && '@5xl/wrapper:size-44' 157 + ]} 158 + /> 161 159 162 160 <!-- Hover overlay --> 163 161 <div
+3 -6
src/lib/website/EmptyState.svelte
··· 1 1 <script lang="ts"> 2 - import { login, user } from '$lib/atproto'; 3 2 import BaseCard from '$lib/cards/BaseCard/BaseCard.svelte'; 4 3 import Card from '$lib/cards/Card/Card.svelte'; 5 4 import type { Item, WebsiteData } from '$lib/types'; 6 - import type { ActorIdentifier } from '@atcute/lexicons'; 7 - import { Button } from '@foxui/core'; 8 5 9 6 let { data }: { data: WebsiteData } = $props(); 10 7 ··· 23 20 mobileW: 8, 24 21 mobileH: 3, 25 22 cardType: 'text', 26 - color: 'red', 23 + color: 'cyan', 27 24 cardData: { 28 25 text: `## No blento yet!`, 29 26 textAlign: 'center', ··· 34 31 // Bluesky social icon 35 32 items.push({ 36 33 id: 'empty-bluesky', 37 - x: 0, 38 - y: 2, 34 + x: 6, 35 + y: 0, 39 36 w: 2, 40 37 h: 2, 41 38 mobileX: 0,
+2 -6
src/lib/website/FloatingEditButton.svelte
··· 6 6 import type { WebsiteData } from '$lib/types'; 7 7 import { page } from '$app/state'; 8 8 import type { ActorIdentifier } from '@atcute/lexicons'; 9 + import { loginModalState } from '$lib/atproto/UI/LoginModal.svelte'; 9 10 10 11 let { data }: { data: WebsiteData } = $props(); 11 12 ··· 63 64 </div> 64 65 {:else if showLoginOnBlento} 65 66 <div class="fixed bottom-6 left-6 z-49"> 66 - <BlueskyLogin 67 - login={async (handle) => { 68 - await login(handle as ActorIdentifier); 69 - return true; 70 - }} 71 - /> 67 + <Button size="lg" onclick={() => loginModalState.show()}>Login</Button> 72 68 </div> 73 69 {:else if showEditBlentoButton} 74 70 <div class="fixed bottom-6 left-6 z-49">
+8 -17
src/lib/website/Profile.svelte
··· 5 5 import { page } from '$app/state'; 6 6 import { qrOverlay } from '$lib/components/qr/qrOverlay.svelte'; 7 7 import MadeWithBlento from './MadeWithBlento.svelte'; 8 + import { Avatar } from '@foxui/core'; 8 9 9 10 let { 10 11 data, ··· 46 47 } 47 48 }} 48 49 > 49 - {#if data.publication?.icon || data.profile.avatar} 50 - <img 51 - class={[ 52 - 'border-base-400 dark:border-base-800 size-32 shrink-0 rounded-full border object-cover', 53 - profilePosition === 'side' && '@5xl/wrapper:size-44' 54 - ]} 55 - src={getImage(data.publication, data.did, 'icon') || data.profile.avatar} 56 - alt="" 57 - /> 58 - {:else} 59 - <div 60 - class={[ 61 - 'bg-base-300 dark:bg-base-700 size-32 shrink-0 rounded-full', 62 - profilePosition === 'side' && '@5xl/wrapper:size-44' 63 - ]} 64 - ></div> 65 - {/if} 50 + <Avatar 51 + src={getImage(data.publication, data.did, 'icon') || data.profile.avatar} 52 + class={[ 53 + 'border-base-400 dark:border-base-800 size-32 shrink-0 rounded-full border object-cover', 54 + profilePosition === 'side' && '@5xl/wrapper:size-44' 55 + ]} 56 + /> 66 57 </a> 67 58 68 59 <div class="text-4xl font-bold wrap-anywhere">
+1 -1
src/lib/website/Website.svelte
··· 79 79 > 80 80 <div></div> 81 81 <div bind:this={container} class="@container/grid relative col-span-3 px-2 py-8 lg:px-8"> 82 - {#if data.cards.length === 0} 82 + {#if data.cards.length === 0 && data.page === 'blento.self'} 83 83 <EmptyState {data} /> 84 84 {:else} 85 85 {#each data.cards.toSorted(sortItems) as item (item.id)}
+2 -2
src/lib/website/load.ts
··· 31 31 result.page = 'blento.' + page; 32 32 33 33 result.publication = (result.publications as Awaited<ReturnType<typeof listRecords>>).find( 34 - (v) => parseUri(v.uri).rkey === result.page 34 + (v) => parseUri(v.uri)?.rkey === result.page 35 35 )?.value; 36 36 result.publication ??= { 37 37 name: result.profile?.displayName || result.profile?.handle, ··· 139 139 140 140 parsedResult.publication = ( 141 141 parsedResult.publications as Awaited<ReturnType<typeof listRecords>> 142 - ).find((v) => parseUri(v.uri).rkey === parsedResult.page)?.value; 142 + ).find((v) => parseUri(v.uri)?.rkey === parsedResult.page)?.value; 143 143 parsedResult.publication ??= { 144 144 name: profile?.displayName || profile?.handle, 145 145 description: profile?.description
+3
src/routes/+layout.svelte
··· 7 7 import YoutubeVideoPlayer, { videoPlayer } from '$lib/components/YoutubeVideoPlayer.svelte'; 8 8 import { page } from '$app/state'; 9 9 import { goto } from '$app/navigation'; 10 + import LoginModal from '$lib/atproto/UI/LoginModal.svelte'; 10 11 11 12 let { children } = $props(); 12 13 ··· 34 35 {#if videoPlayer.id} 35 36 <YoutubeVideoPlayer /> 36 37 {/if} 38 + 39 + <LoginModal />