your personal website on atproto - mirror blento.app

Merge branch 'main' into fix-time-color-background

authored by David Terterian and committed by GitHub 17c19d0b 18c97af1

+1638 -463
+8 -8
docs/Beta.md
··· 3 - site.standard 4 - move description to markdownDescription and set description as text only 5 6 - big button card 7 8 - card with big call to action button 9 10 - - link card allow changing favicon, og image (+ hide favicon) 11 - 12 - video card? 13 14 - allow setting base and accent color 15 16 - ask to fill with some default cards on page creation 17 18 - - share button (copy share link to blento, maybe post to bluesky?) 19 - 20 - - add icons to "change card to..." popover 21 - 22 - when adding images try to add them in a size that best fits aspect ratio 23 24 - onboarding 25 - 26 - - show alert when user tries to close window with unsaved changes
··· 3 - site.standard 4 - move description to markdownDescription and set description as text only 5 6 + - allow editing on mobile 7 + 8 + - get automatic layout for mobile if only edited on desktop (and vice versa) 9 + 10 + - add cards in middle of current position (both mobile and desktop version) 11 + 12 + - show nsfw warnings 13 + 14 - big button card 15 16 - card with big call to action button 17 18 - video card? 19 20 - allow setting base and accent color 21 22 - ask to fill with some default cards on page creation 23 24 - when adding images try to add them in a size that best fits aspect ratio 25 26 - onboarding
+12 -7
docs/CardIdeas.md
··· 3 ## media 4 5 - general video card 6 - - inline youtube video 7 - cartoons: aka https://www.opendoodles.com/ 8 - excalidraw (/svg card) 9 - latest blog post (e.g. leaflet) 10 - fake 3d image (with depth map) 11 - - fluid text effect (https://flo-bit.dev/projects/fluid-text-effect/) 12 - - gifs 13 - - little drawing app 14 - css voxel art 15 - 3d model 16 17 ## social accounts 18 19 - instagram card (showing follow button, follower count, latest posts) 20 - - github card (showing activity grid) 21 - bluesky account card (showing follow button, follower count, avatar, name, cover image) 22 - youtube channel card (showing channel name, latest videos, follow button?) 23 - bluesky posts workcloud ··· 40 - teal.fm 41 - [x] last played songs 42 - tangled.sh 43 - popfeed.social 44 - reading goal 45 - [x] latest ratings 46 - lists 47 - - smokesignal.events (https://pdsls.dev/at://did:plc:xbtmt2zjwlrfegqvch7fboei/events.smokesignal.calendar.event/3ltn2qrxf3626) 48 - - statusphere.xyz (https://googlefonts.github.io/noto-emoji-animation/, https://gist.github.com/sanjacob/a0ccdf6d88f15bf158d8895090722d14) 49 - goals.garden 50 - flashes.blue (https://pdsls.dev/at://did:plc:aytgljyikzbtgrnac2u4ccft/blue.flashes.actor.portfolio, https://app.flashes.blue/profile/j4ck.xyz) 51 - room: flo-bit.dev/room
··· 3 ## media 4 5 - general video card 6 + - [x] inline youtube video 7 - cartoons: aka https://www.opendoodles.com/ 8 - excalidraw (/svg card) 9 - latest blog post (e.g. leaflet) 10 - fake 3d image (with depth map) 11 + - [x] fluid text effect (https://flo-bit.dev/projects/fluid-text-effect/) 12 + - [x] gifs 13 + - [x] little drawing app 14 - css voxel art 15 - 3d model 16 + - spotify or apple music playlist 17 18 ## social accounts 19 20 - instagram card (showing follow button, follower count, latest posts) 21 + - [x] github card (showing activity grid) 22 - bluesky account card (showing follow button, follower count, avatar, name, cover image) 23 - youtube channel card (showing channel name, latest videos, follow button?) 24 - bluesky posts workcloud ··· 41 - teal.fm 42 - [x] last played songs 43 - tangled.sh 44 + - pinned repos 45 + - activity heatmap? 46 - popfeed.social 47 - reading goal 48 - [x] latest ratings 49 - lists 50 + - smokesignal.events 51 + - [x] specific event 52 + - all future events i'm hosting/attending 53 + - [x] statusphere.xyz (TODO: assing to specific record) 54 - goals.garden 55 - flashes.blue (https://pdsls.dev/at://did:plc:aytgljyikzbtgrnac2u4ccft/blue.flashes.actor.portfolio, https://app.flashes.blue/profile/j4ck.xyz) 56 - room: flo-bit.dev/room
+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>
+267
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) 130 + .filter((l) => l.handle && l.handle !== 'handle.invalid') 131 + .slice(0, 4) as recentLogin (recentLogin.did)} 132 + <div class="group"> 133 + <div 134 + 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" 135 + > 136 + <div class="flex items-center gap-2"> 137 + <Avatar class="size-6" src={recentLogin.avatar} /> 138 + {recentLogin.handle} 139 + </div> 140 + <button 141 + class="z-20 cursor-pointer" 142 + onclick={() => { 143 + value = recentLogin.handle; 144 + selectedActor = recentLogin; 145 + if (loginOnSelect) onSubmit(); 146 + else focusSubmit(); 147 + }} 148 + > 149 + <div class="absolute inset-0 h-full w-full"></div> 150 + <span class="sr-only">login</span> 151 + </button> 152 + 153 + <button 154 + onclick={() => { 155 + removeRecentLogin(recentLogin.did); 156 + }} 157 + class="z-30 cursor-pointer rounded-full p-0.5" 158 + > 159 + <svg 160 + xmlns="http://www.w3.org/2000/svg" 161 + fill="none" 162 + viewBox="0 0 24 24" 163 + stroke-width="1.5" 164 + stroke="currentColor" 165 + class="size-3" 166 + > 167 + <path 168 + stroke-linecap="round" 169 + stroke-linejoin="round" 170 + d="M6 18 18 6M6 6l12 12" 171 + /> 172 + </svg> 173 + <span class="sr-only">sign in with other account</span> 174 + </button> 175 + </div> 176 + </div> 177 + {/each} 178 + </div> 179 + {:else if !selectedActor} 180 + <div class="mt-4 w-full"> 181 + <HandleInput 182 + bind:value 183 + onselected={(a) => { 184 + selectedActor = a; 185 + value = a.handle; 186 + if (loginOnSelect) onSubmit(); 187 + else focusSubmit(); 188 + }} 189 + bind:ref={input} 190 + /> 191 + </div> 192 + {:else} 193 + <div 194 + 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" 195 + > 196 + <div class="flex items-center gap-2"> 197 + <Avatar class="size-6" src={selectedActor.avatar} /> 198 + {selectedActor.handle} 199 + </div> 200 + 201 + <button 202 + onclick={() => { 203 + selectedActor = undefined; 204 + value = ''; 205 + }} 206 + class="cursor-pointer rounded-full p-0.5" 207 + > 208 + <svg 209 + xmlns="http://www.w3.org/2000/svg" 210 + fill="none" 211 + viewBox="0 0 24 24" 212 + stroke-width="1.5" 213 + stroke="currentColor" 214 + class="size-3" 215 + > 216 + <path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" /> 217 + </svg> 218 + <span class="sr-only">sign in with other account</span> 219 + </button> 220 + </div> 221 + {/if} 222 + 223 + {#if error} 224 + <p class="text-accent-500 text-sm font-semibold">{error}</p> 225 + {/if} 226 + 227 + <div class="mt-4"> 228 + {#if showRecentLogins} 229 + <div class="mt-2 mb-4 text-sm font-medium">Or login with new handle</div> 230 + 231 + <Button 232 + onclick={() => { 233 + recentLoginsView = false; 234 + focusInput(); 235 + }} 236 + class="w-full">Login with new handle</Button 237 + > 238 + {:else} 239 + <Button bind:ref={submitButton} type="submit" disabled={loadingLogin} class="w-full" 240 + >{loadingLogin ? 'Loading...' : 'Login'}</Button 241 + > 242 + {/if} 243 + </div> 244 + 245 + {#if signUp} 246 + <div 247 + 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" 248 + > 249 + Don't have an account? 250 + <div class="mt-3"> 251 + <SecondaryButton 252 + onclick={async () => { 253 + loadingSignup = true; 254 + await signup(); 255 + }} 256 + disabled={loadingSignup} 257 + class="w-full">{loadingSignup ? 'Loading...' : 'Sign Up'}</SecondaryButton 258 + > 259 + </div> 260 + </div> 261 + {/if} 262 + </form> 263 + </div> 264 + </div> 265 + </div> 266 + </div> 267 + {/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>
+20 -9
src/lib/atproto/auth.svelte.ts
··· 17 WellKnownHandleResolver 18 } from '@atcute/identity-resolver'; 19 import { Client } from '@atcute/client'; 20 - import type { ActorIdentifier, Did } from '@atcute/lexicons'; 21 22 import { dev } from '$app/environment'; 23 import { replaceState } from '$app/navigation'; 24 25 import { metadata } from './metadata'; 26 - import { getDetailedProfile } from './methods'; 27 - import { signUpPDS } from './settings'; 28 import { SvelteURLSearchParams } from 'svelte/reactivity'; 29 30 export const user = $state({ 31 agent: null as OAuthUserAgent | null, ··· 41 42 const clientId = dev 43 ? `http://localhost` + 44 - `?redirect_uri=${encodeURIComponent('http://127.0.0.1:5179/oauth/callback')}` + 45 `&scope=${encodeURIComponent(metadata.scope)}` 46 : metadata.client_id; 47 48 const handleResolver = new CompositeHandleResolver({ 49 methods: { 50 - dns: new DohJsonHandleResolver({ dohUrl: 'https://mozilla.cloudflare-dns.com/dns-query' }), 51 http: new WellKnownHandleResolver() 52 } 53 }); ··· 55 configureOAuth({ 56 metadata: { 57 client_id: clientId, 58 - redirect_uri: dev ? 'http://127.0.0.1:5179/oauth/callback' : metadata.redirect_uris[0] 59 }, 60 identityResolver: new LocalActorResolver({ 61 handleResolver: handleResolver, ··· 151 } 152 } 153 154 - async function finalizeLogin(params: URLSearchParams, did?: Did) { 155 try { 156 const { session } = await finalizeAuthorization(params); 157 replaceState(location.pathname + location.search, {}); ··· 223 224 const response = await getDetailedProfile(); 225 226 - user.profile = response; 227 - localStorage.setItem(`profile-${actor}`, JSON.stringify(response)); 228 }
··· 17 WellKnownHandleResolver 18 } from '@atcute/identity-resolver'; 19 import { Client } from '@atcute/client'; 20 21 import { dev } from '$app/environment'; 22 import { replaceState } from '$app/navigation'; 23 24 import { metadata } from './metadata'; 25 + import { describeRepo, getDetailedProfile } from './methods'; 26 + import { DOH_RESOLVER, REDIRECT_PATH, signUpPDS } from './settings'; 27 import { SvelteURLSearchParams } from 'svelte/reactivity'; 28 + 29 + import type { ActorIdentifier, Did } from '@atcute/lexicons'; 30 31 export const user = $state({ 32 agent: null as OAuthUserAgent | null, ··· 42 43 const clientId = dev 44 ? `http://localhost` + 45 + `?redirect_uri=${encodeURIComponent('http://127.0.0.1:5179' + REDIRECT_PATH)}` + 46 `&scope=${encodeURIComponent(metadata.scope)}` 47 : metadata.client_id; 48 49 const handleResolver = new CompositeHandleResolver({ 50 methods: { 51 + dns: new DohJsonHandleResolver({ dohUrl: DOH_RESOLVER }), 52 http: new WellKnownHandleResolver() 53 } 54 }); ··· 56 configureOAuth({ 57 metadata: { 58 client_id: clientId, 59 + redirect_uri: dev ? 'http://127.0.0.1:5179' + REDIRECT_PATH : metadata.redirect_uris[0] 60 }, 61 identityResolver: new LocalActorResolver({ 62 handleResolver: handleResolver, ··· 152 } 153 } 154 155 + async function finalizeLogin(params: SvelteURLSearchParams, did?: Did) { 156 try { 157 const { session } = await finalizeAuthorization(params); 158 replaceState(location.pathname + location.search, {}); ··· 224 225 const response = await getDetailedProfile(); 226 227 + if (!response || response.handle === 'handle.invalid') { 228 + console.log('invalid handle or no profile from bsky, fetching from repo description'); 229 + const repo = await describeRepo({ did: actor }); 230 + user.profile = { 231 + did: actor, 232 + handle: repo?.handle || 'handle.invalid' 233 + }; 234 + localStorage.setItem(`profile-${actor}`, JSON.stringify(user.profile)); 235 + } else { 236 + user.profile = response; 237 + localStorage.setItem(`profile-${actor}`, JSON.stringify(response)); 238 + } 239 }
+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 uploadBlob, 15 describeRepo, 16 getBlobURL, 17 - getImageBlobUrl, 18 - searchActorsTypeahead 19 } from './methods';
··· 14 uploadBlob, 15 describeRepo, 16 getBlobURL, 17 + getCDNImageBlobUrl, 18 + searchActorsTypeahead, 19 + getAuthorFeed 20 } from './methods';
+9 -8
src/lib/atproto/metadata.ts
··· 1 import { resolve } from '$app/paths'; 2 - import { blobs, collections, rpcCalls, SITE } from './settings'; 3 4 function constructScope() { 5 - const repos = collections.map((collection) => 'repo:' + collection).join(' '); 6 7 let rpcs = ''; 8 - for (const [key, value] of Object.entries(rpcCalls)) { 9 if (Array.isArray(value)) { 10 rpcs += value.map((lxm) => 'rpc?lxm=' + lxm + '&aud=' + key).join(' '); 11 } else { 12 rpcs += 'rpc?lxm=' + value + '&aud=' + key; 13 } 14 } 15 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; 20 } 21 22 const scope = ['atproto', repos, rpcs, blobScope].filter((v) => v?.trim()).join(' '); ··· 25 26 export const metadata = { 27 client_id: SITE + resolve('/oauth-client-metadata.json'), 28 - redirect_uris: [SITE + resolve('/oauth/callback')], 29 scope: constructScope(), 30 grant_types: ['authorization_code', 'refresh_token'], 31 response_types: ['code'],
··· 1 import { resolve } from '$app/paths'; 2 + import { permissions, REDIRECT_PATH, SITE } from './settings'; 3 4 function constructScope() { 5 + const repos = permissions.collections.map((collection) => 'repo:' + collection).join(' '); 6 7 let rpcs = ''; 8 + for (const [key, value] of Object.entries(permissions.rpc ?? {})) { 9 if (Array.isArray(value)) { 10 rpcs += value.map((lxm) => 'rpc?lxm=' + lxm + '&aud=' + key).join(' '); 11 } else { 12 rpcs += 'rpc?lxm=' + value + '&aud=' + key; 13 } 14 } 15 + 16 let blobScope: string | undefined = undefined; 17 + if (Array.isArray(permissions.blobs) && permissions.blobs.length > 0) { 18 + blobScope = 'blob?' + permissions.blobs.map((b) => 'accept=' + b).join('&'); 19 + } else if (permissions.blobs && permissions.blobs.length > 0) { 20 + blobScope = 'blob:' + permissions.blobs; 21 } 22 23 const scope = ['atproto', repos, rpcs, blobScope].filter((v) => v?.trim()).join(' '); ··· 26 27 export const metadata = { 28 client_id: SITE + resolve('/oauth-client-metadata.json'), 29 + redirect_uris: [SITE + resolve(REDIRECT_PATH)], 30 scope: constructScope(), 31 grant_types: ['authorization_code', 'refresh_token'], 32 response_types: ['code'],
+196 -60
src/lib/atproto/methods.ts
··· 1 - import type { Did, Handle } from '@atcute/lexicons'; 2 import { user } from './auth.svelte'; 3 import { 4 CompositeDidDocumentResolver, 5 CompositeHandleResolver, ··· 9 WellKnownHandleResolver 10 } from '@atcute/identity-resolver'; 11 import { Client, simpleFetchHandler } from '@atcute/client'; 12 - import type { AppBskyActorDefs } from '@atcute/bluesky'; 13 - import { redirect } from '@sveltejs/kit'; 14 15 export type Collection = `${string}.${string}.${string}`; 16 17 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 } 25 26 export async function resolveHandle({ handle }: { handle: Handle }) { 27 const handleResolver = new CompositeHandleResolver({ 28 methods: { ··· 31 } 32 }); 33 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 - } 40 } 41 42 const didResolver = new CompositeDidDocumentResolver({ ··· 46 } 47 }); 48 49 export async function getPDS(did: Did) { 50 - const doc = await didResolver.resolve(did as `did:plc:${string}` | `did:web:${string}`); 51 if (!doc.service) throw new Error('No PDS found'); 52 for (const service of doc.service) { 53 if (service.id === '#atproto_pds') { ··· 56 } 57 } 58 59 export async function getDetailedProfile(data?: { did?: Did; client?: Client }) { 60 data ??= {}; 61 data.did ??= user.did; ··· 70 params: { actor: data.did } 71 }); 72 73 - if (!response.ok) return; 74 - 75 - return response.data; 76 - } 77 - 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 102 export async function getClient({ did }: { did: Did }) { 103 const pds = await getPDS(did); 104 if (!pds) throw new Error('PDS not found'); ··· 110 return client; 111 } 112 113 export async function listRecords({ 114 did, 115 collection, 116 cursor, 117 - limit = 0, 118 client 119 }: { 120 did?: Did; ··· 141 params: { 142 repo: did, 143 collection, 144 - limit: limit || 100, 145 cursor: currentCursor 146 } 147 }); ··· 157 return allRecords; 158 } 159 160 export async function getRecord({ 161 did, 162 collection, 163 - rkey, 164 client 165 }: { 166 did?: Did; ··· 169 client?: Client; 170 }) { 171 did ??= user.did; 172 - rkey ??= 'self'; 173 174 if (!collection) { 175 throw new Error('Missing parameters for getRecord'); ··· 188 } 189 }); 190 191 - if (!record.ok) return; 192 - 193 return JSON.parse(JSON.stringify(record.data)); 194 } 195 196 export async function putRecord({ 197 collection, 198 - rkey, 199 record 200 }: { 201 - collection: Collection; 202 - rkey: string; 203 record: Record<string, unknown>; 204 }) { 205 if (!user.client || !user.did) throw new Error('No rpc or did'); ··· 218 return response; 219 } 220 221 - export async function deleteRecord({ collection, rkey }: { collection: Collection; rkey: string }) { 222 if (!user.client || !user.did) throw new Error('No profile or rpc or did'); 223 224 const response = await user.client.post('com.atproto.repo.deleteRecord', { ··· 232 return response.ok; 233 } 234 235 export async function uploadBlob({ blob }: { blob: Blob }) { 236 if (!user.did || !user.client) throw new Error("Can't upload blob: Not logged in"); 237 238 const blobResponse = await user.client.post('com.atproto.repo.uploadBlob', { 239 - input: blob, 240 - data: { 241 repo: user.did 242 - } 243 }); 244 245 - if (!blobResponse?.ok) { 246 - return; 247 - } 248 249 const blobInfo = blobResponse?.data.blob as { 250 $type: 'blob'; ··· 258 return blobInfo; 259 } 260 261 export async function describeRepo({ client, did }: { client?: Client; did?: Did }) { 262 did ??= user.did; 263 if (!did) { ··· 275 return repo.data; 276 } 277 278 export async function getBlobURL({ 279 did, 280 blob ··· 291 return `${pds}/xrpc/com.atproto.sync.getBlob?did=${did}&cid=${blob.ref.$link}`; 292 } 293 294 - export function getImageBlobUrl({ 295 did, 296 blob 297 }: { 298 - did: string; 299 blob: { 300 $type: 'blob'; 301 ref: { ··· 303 }; 304 }; 305 }) { 306 - if (!did || !blob?.ref?.$link) return ''; 307 return `https://cdn.bsky.app/img/feed_thumbnail/plain/${did}/${blob.ref.$link}@webp`; 308 } 309 310 export async function searchActorsTypeahead( 311 q: string, 312 limit: number = 10, ··· 329 330 return { actors: response.data.actors, q }; 331 }
··· 1 + import { 2 + parseResourceUri, 3 + type ActorIdentifier, 4 + type Did, 5 + type Handle, 6 + type ResourceUri 7 + } from '@atcute/lexicons'; 8 import { user } from './auth.svelte'; 9 + import type { AllowedCollection } from './settings'; 10 import { 11 CompositeDidDocumentResolver, 12 CompositeHandleResolver, ··· 16 WellKnownHandleResolver 17 } from '@atcute/identity-resolver'; 18 import { Client, simpleFetchHandler } from '@atcute/client'; 19 + import { type AppBskyActorDefs } from '@atcute/bluesky'; 20 21 export type Collection = `${string}.${string}.${string}`; 22 + import * as TID from '@atcute/tid'; 23 24 + /** 25 + * Parses an AT Protocol URI into its components. 26 + * @param uri - The AT URI to parse (e.g., "at://did:plc:xyz/app.bsky.feed.post/abc123") 27 + * @returns An object containing the repo, collection, and rkey or undefined if not an AT uri 28 + */ 29 export function parseUri(uri: string) { 30 + const parts = parseResourceUri(uri); 31 + if (!parts.ok) return; 32 + return parts.value; 33 } 34 35 + /** 36 + * Resolves a handle to a DID using DNS and HTTP methods. 37 + * @param handle - The handle to resolve (e.g., "alice.bsky.social") 38 + * @returns The DID associated with the handle 39 + */ 40 export async function resolveHandle({ handle }: { handle: Handle }) { 41 const handleResolver = new CompositeHandleResolver({ 42 methods: { ··· 45 } 46 }); 47 48 + const data = await handleResolver.resolve(handle); 49 + return data; 50 } 51 52 const didResolver = new CompositeDidDocumentResolver({ ··· 56 } 57 }); 58 59 + /** 60 + * Gets the PDS (Personal Data Server) URL for a given DID. 61 + * @param did - The DID to look up 62 + * @returns The PDS service endpoint URL 63 + * @throws If no PDS is found in the DID document 64 + */ 65 export async function getPDS(did: Did) { 66 + const doc = await didResolver.resolve(did as Did<'plc'> | Did<'web'>); 67 if (!doc.service) throw new Error('No PDS found'); 68 for (const service of doc.service) { 69 if (service.id === '#atproto_pds') { ··· 72 } 73 } 74 75 + /** 76 + * Fetches a detailed Bluesky profile for a user. 77 + * @param data - Optional object with did and client 78 + * @param data.did - The DID to fetch the profile for (defaults to current user) 79 + * @param data.client - The client to use (defaults to public Bluesky API) 80 + * @returns The profile data or undefined if not found 81 + */ 82 export async function getDetailedProfile(data?: { did?: Did; client?: Client }) { 83 data ??= {}; 84 data.did ??= user.did; ··· 93 params: { actor: data.did } 94 }); 95 96 + if (!response.ok || response.data.handle === 'handle.invalid') { 97 + const repo = await describeRepo({ did: data.did }); 98 + return { handle: repo?.handle ?? 'handle.invalid', did: data.did }; 99 + } 100 101 return response.data; 102 } 103 104 + /** 105 + * Creates an AT Protocol client for a user's PDS. 106 + * @param did - The DID of the user 107 + * @returns A client configured for the user's PDS 108 + * @throws If the PDS cannot be found 109 + */ 110 export async function getClient({ did }: { did: Did }) { 111 const pds = await getPDS(did); 112 if (!pds) throw new Error('PDS not found'); ··· 118 return client; 119 } 120 121 + /** 122 + * Lists records from a repository collection with pagination support. 123 + * @param did - The DID of the repository (defaults to current user) 124 + * @param collection - The collection to list records from 125 + * @param cursor - Pagination cursor for continuing from a previous request 126 + * @param limit - Maximum number of records to return (default 100, set to 0 for all records) 127 + * @param client - The client to use (defaults to user's PDS client) 128 + * @returns An array of records from the collection 129 + */ 130 export async function listRecords({ 131 did, 132 collection, 133 cursor, 134 + limit = 100, 135 client 136 }: { 137 did?: Did; ··· 158 params: { 159 repo: did, 160 collection, 161 + limit: !limit || limit > 100 ? 100 : limit, 162 cursor: currentCursor 163 } 164 }); ··· 174 return allRecords; 175 } 176 177 + /** 178 + * Fetches a single record from a repository. 179 + * @param did - The DID of the repository (defaults to current user) 180 + * @param collection - The collection the record belongs to 181 + * @param rkey - The record key (defaults to "self") 182 + * @param client - The client to use (defaults to user's PDS client) 183 + * @returns The record data 184 + */ 185 export async function getRecord({ 186 did, 187 collection, 188 + rkey = 'self', 189 client 190 }: { 191 did?: Did; ··· 194 client?: Client; 195 }) { 196 did ??= user.did; 197 198 if (!collection) { 199 throw new Error('Missing parameters for getRecord'); ··· 212 } 213 }); 214 215 return JSON.parse(JSON.stringify(record.data)); 216 } 217 218 + /** 219 + * Creates or updates a record in the current user's repository. 220 + * Only accepts collections that are configured in permissions. 221 + * @param collection - The collection to write to (must be in permissions.collections) 222 + * @param rkey - The record key (defaults to "self") 223 + * @param record - The record data to write 224 + * @returns The response from the PDS 225 + * @throws If the user is not logged in 226 + */ 227 export async function putRecord({ 228 collection, 229 + rkey = 'self', 230 record 231 }: { 232 + collection: AllowedCollection; 233 + rkey?: string; 234 record: Record<string, unknown>; 235 }) { 236 if (!user.client || !user.did) throw new Error('No rpc or did'); ··· 249 return response; 250 } 251 252 + /** 253 + * Deletes a record from the current user's repository. 254 + * Only accepts collections that are configured in permissions. 255 + * @param collection - The collection the record belongs to (must be in permissions.collections) 256 + * @param rkey - The record key (defaults to "self") 257 + * @returns True if the deletion was successful 258 + * @throws If the user is not logged in 259 + */ 260 + export async function deleteRecord({ 261 + collection, 262 + rkey = 'self' 263 + }: { 264 + collection: AllowedCollection; 265 + rkey: string; 266 + }) { 267 if (!user.client || !user.did) throw new Error('No profile or rpc or did'); 268 269 const response = await user.client.post('com.atproto.repo.deleteRecord', { ··· 277 return response.ok; 278 } 279 280 + /** 281 + * Uploads a blob to the current user's PDS. 282 + * @param blob - The blob data to upload 283 + * @returns The blob metadata including ref, mimeType, and size, or undefined on failure 284 + * @throws If the user is not logged in 285 + */ 286 export async function uploadBlob({ blob }: { blob: Blob }) { 287 if (!user.did || !user.client) throw new Error("Can't upload blob: Not logged in"); 288 289 const blobResponse = await user.client.post('com.atproto.repo.uploadBlob', { 290 + params: { 291 repo: user.did 292 + }, 293 + input: blob 294 }); 295 296 + if (!blobResponse?.ok) return; 297 298 const blobInfo = blobResponse?.data.blob as { 299 $type: 'blob'; ··· 307 return blobInfo; 308 } 309 310 + /** 311 + * Gets metadata about a repository. 312 + * @param client - The client to use 313 + * @param did - The DID of the repository (defaults to current user) 314 + * @returns Repository metadata or undefined on failure 315 + */ 316 export async function describeRepo({ client, did }: { client?: Client; did?: Did }) { 317 did ??= user.did; 318 if (!did) { ··· 330 return repo.data; 331 } 332 333 + /** 334 + * Constructs a URL to fetch a blob directly from a user's PDS. 335 + * @param did - The DID of the user who owns the blob 336 + * @param blob - The blob reference object 337 + * @returns The URL to fetch the blob 338 + */ 339 export async function getBlobURL({ 340 did, 341 blob ··· 352 return `${pds}/xrpc/com.atproto.sync.getBlob?did=${did}&cid=${blob.ref.$link}`; 353 } 354 355 + /** 356 + * Constructs a Bluesky CDN URL for an image blob. 357 + * @param did - The DID of the user who owns the blob (defaults to current user) 358 + * @param blob - The blob reference object 359 + * @returns The CDN URL for the image in webp format 360 + */ 361 + export function getCDNImageBlobUrl({ 362 did, 363 blob 364 }: { 365 + did?: string; 366 blob: { 367 $type: 'blob'; 368 ref: { ··· 370 }; 371 }; 372 }) { 373 + did ??= user.did; 374 + 375 return `https://cdn.bsky.app/img/feed_thumbnail/plain/${did}/${blob.ref.$link}@webp`; 376 } 377 378 + /** 379 + * Searches for actors with typeahead/autocomplete functionality. 380 + * @param q - The search query 381 + * @param limit - Maximum number of results (default 10) 382 + * @param host - The API host to use (defaults to public Bluesky API) 383 + * @returns An object containing matching actors and the original query 384 + */ 385 export async function searchActorsTypeahead( 386 q: string, 387 limit: number = 10, ··· 404 405 return { actors: response.data.actors, q }; 406 } 407 + 408 + /** 409 + * Return a TID based on current time 410 + * 411 + * @returns TID for current time 412 + */ 413 + export function createTID() { 414 + return TID.now(); 415 + } 416 + 417 + export async function getAuthorFeed(data?: { 418 + did?: Did; 419 + client?: Client; 420 + filter?: string; 421 + limit?: number; 422 + }) { 423 + data ??= {}; 424 + data.did ??= user.did; 425 + 426 + if (!data.did) throw new Error('Error getting detailed profile: no did'); 427 + 428 + data.client ??= new Client({ 429 + handler: simpleFetchHandler({ service: 'https://public.api.bsky.app' }) 430 + }); 431 + 432 + const response = await data.client.get('app.bsky.feed.getAuthorFeed', { 433 + params: { actor: data.did, filter: data.filter ?? 'posts_with_media', limit: data.limit || 100 } 434 + }); 435 + 436 + if (!response.ok) return; 437 + 438 + return response.data; 439 + } 440 + 441 + /** 442 + * Fetches posts by their AT URIs. 443 + * @param uris - Array of AT URIs (e.g., "at://did:plc:xyz/app.bsky.feed.post/abc123") 444 + * @param client - The client to use (defaults to public Bluesky API) 445 + * @returns Array of posts or undefined on failure 446 + */ 447 + export async function getPosts(data: { uris: string[]; client?: Client }) { 448 + data.client ??= new Client({ 449 + handler: simpleFetchHandler({ service: 'https://public.api.bsky.app' }) 450 + }); 451 + 452 + const response = await data.client.get('app.bsky.feed.getPosts', { 453 + params: { uris: data.uris as ResourceUri[] } 454 + }); 455 + 456 + if (!response.ok) return; 457 + 458 + return response.data.posts; 459 + } 460 + 461 + export function getHandleOrDid(profile: AppBskyActorDefs.ProfileViewDetailed): ActorIdentifier { 462 + if (profile.handle && profile.handle !== 'handle.invalid') { 463 + return profile.handle; 464 + } else { 465 + return profile.did; 466 + } 467 + }
+51 -15
src/lib/atproto/settings.ts
··· 1 export const SITE = 'https://blento.app'; 2 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 - ]; 13 - 14 - export const rpcCalls: Record<string, string | string[]> = { 15 - //'did:web:api.bsky.app#bsky_appview': ['app.bsky.actor.getProfile'] 16 }; 17 18 - export const blobs = ['*/*'] as string | string[] | undefined; 19 20 - export const signUpPDS = 'https://pds.rip/';
··· 1 + import { dev } from '$app/environment'; 2 + 3 export const SITE = 'https://blento.app'; 4 5 + type Permissions = { 6 + collections: readonly string[]; 7 + rpc: Record<string, string | string[]>; 8 + blobs: readonly string[]; 9 }; 10 11 + export const permissions = { 12 + // collections you can create/delete/update 13 14 + // example: only allow create and delete 15 + // collections: ['xyz.statusphere.status?action=create&action=update'], 16 + collections: [ 17 + 'app.blento.card', 18 + 'app.blento.page', 19 + 'app.blento.settings', 20 + 'app.blento.comment', 21 + 'app.blento.guestbook.entry', 22 + 'site.standard.publication', 23 + 'site.standard.document', 24 + 'xyz.statusphere.status' 25 + ], 26 + 27 + // what types of authenticated proxied requests you can make to services 28 + 29 + // example: allow authenticated proxying to bsky appview to get a users liked posts 30 + //rpc: {'did:web:api.bsky.app#bsky_appview': ['app.bsky.feed.getActorLikes']} 31 + rpc: {}, 32 + 33 + // what types of blobs you can upload to a users PDS 34 + 35 + // example: allowing video and html uploads 36 + // blobs: ['video/*', 'text/html'] 37 + // example: allowing all blob types 38 + // blobs: ['*/*'] 39 + blobs: ['*/*'] 40 + } as const satisfies Permissions; 41 + 42 + // Extract base collection name (before any query params) 43 + type ExtractCollectionBase<T extends string> = T extends `${infer Base}?${string}` ? Base : T; 44 + 45 + export type AllowedCollection = ExtractCollectionBase<(typeof permissions.collections)[number]>; 46 + 47 + // which PDS to use for signup 48 + // ATTENTION: pds.rip is only for development, all accounts get deleted automatically after a week 49 + const devPDS = 'https://pds.rip/'; 50 + const prodPDS = 'https://selfhosted.social/'; 51 + export const signUpPDS = dev ? devPDS : prodPDS; 52 + 53 + // where to redirect after oauth login/signup, e.g. /oauth/callback 54 + export const REDIRECT_PATH = '/oauth/callback'; 55 + 56 + export const DOH_RESOLVER = 'https://mozilla.cloudflare-dns.com/dns-query';
+26 -24
src/lib/cards/BlueskyPostCard/BlueskyPostCard.svelte
··· 2 import type { Item } from '$lib/types'; 3 import { onMount } from 'svelte'; 4 import { BlueskyPost } from '../../components/bluesky-post'; 5 - import { getAdditionalUserData, getDidContext, getHandleContext } from '$lib/website/context'; 6 - import { CardDefinitionsByType } from '..'; 7 8 let { item }: { item: Item } = $props(); 9 10 const data = getAdditionalUserData(); 11 // svelte-ignore state_referenced_locally 12 - let feed = $state((data[item.cardType] as any)?.feed); 13 - 14 - let did = getDidContext(); 15 - let handle = getHandleContext(); 16 17 onMount(async () => { 18 - if (!feed) { 19 - feed = ( 20 - (await CardDefinitionsByType[item.cardType]?.loadData?.([], { 21 - did, 22 - handle 23 - })) as any 24 - ).feed; 25 - 26 - console.log(feed); 27 28 - data[item.cardType] = feed; 29 } 30 }); 31 </script> 32 33 <div class="flex h-full flex-col justify-center-safe overflow-y-scroll p-4"> 34 - <div 35 - class="accent:text-base-950 bg-base-200/50 dark:bg-base-700/30 mx-auto mb-6 w-fit rounded-xl p-1 px-2 text-2xl font-semibold" 36 - > 37 - My latest bluesky post 38 - </div> 39 - {#if feed?.[0]?.post} 40 - <BlueskyPost showLogo feedViewPost={feed?.[0].post}></BlueskyPost> 41 <div class="h-4 w-full"></div> 42 {:else} 43 - Your latest bluesky post will appear here. 44 {/if} 45 </div>
··· 2 import type { Item } from '$lib/types'; 3 import { onMount } from 'svelte'; 4 import { BlueskyPost } from '../../components/bluesky-post'; 5 + import { getAdditionalUserData } from '$lib/website/context'; 6 + import { getPosts, resolveHandle } from '$lib/atproto/methods'; 7 + import type { PostView } from '@atcute/bluesky/types/app/feed/defs'; 8 + import type { Handle } from '@atcute/lexicons'; 9 + import { isDid } from '@atcute/lexicons/syntax'; 10 + import { resolveUri } from './utils'; 11 12 let { item }: { item: Item } = $props(); 13 14 const data = getAdditionalUserData(); 15 + let uri = $derived(item.cardData.uri as string); 16 + 17 // svelte-ignore state_referenced_locally 18 + let post = $state((data['blueskyPost'] as Record<string, PostView>)?.[uri]); 19 20 onMount(async () => { 21 + if (!post && uri) { 22 + // Resolve handle to DID if needed 23 + const resolvedUri = await resolveUri(uri); 24 25 + const posts = await getPosts({ uris: [resolvedUri] }); 26 + if (posts && posts.length > 0) { 27 + post = posts[0]; 28 + // Store in data for future use (keyed by resolved URI) 29 + if (!data['blueskyPost']) { 30 + data['blueskyPost'] = {}; 31 + } 32 + (data['blueskyPost'] as Record<string, PostView>)[resolvedUri] = post; 33 + // Also store under original URI for lookup 34 + (data['blueskyPost'] as Record<string, PostView>)[uri] = post; 35 + } 36 } 37 }); 38 </script> 39 40 <div class="flex h-full flex-col justify-center-safe overflow-y-scroll p-4"> 41 + {#if post} 42 + <BlueskyPost showLogo feedViewPost={post}></BlueskyPost> 43 <div class="h-4 w-full"></div> 44 {:else} 45 + <p class="text-base-600 dark:text-base-400 text-center">A bluesky post will appear here</p> 46 {/if} 47 </div>
+75
src/lib/cards/BlueskyPostCard/CreateBlueskyPostCardModal.svelte
···
··· 1 + <script lang="ts"> 2 + import { Alert, Button, Input, Modal, Subheading } from '@foxui/core'; 3 + import type { CreationModalComponentProps } from '../types'; 4 + import { parseBlueskyPostUrl } from './utils'; 5 + 6 + let { item = $bindable(), oncreate, oncancel }: CreationModalComponentProps = $props(); 7 + 8 + let isValidating = $state(false); 9 + let errorMessage = $state(''); 10 + let postUrl = $state(''); 11 + 12 + async function validateAndCreate() { 13 + errorMessage = ''; 14 + isValidating = true; 15 + 16 + try { 17 + const parsed = parseBlueskyPostUrl(postUrl.trim()); 18 + 19 + if (!parsed) { 20 + throw new Error('Invalid URL format'); 21 + } 22 + 23 + // Construct AT URI using handle (will be resolved to DID when loading) 24 + item.cardData.uri = `at://${parsed.handle}/app.bsky.feed.post/${parsed.rkey}`; 25 + item.cardData.href = postUrl.trim(); 26 + 27 + return true; 28 + } catch (err) { 29 + errorMessage = 30 + err instanceof Error && err.message === 'Post not found' 31 + ? "Couldn't find that post. Please check the URL and try again." 32 + : err instanceof Error && err.message === 'Could not resolve handle' 33 + ? "Couldn't find that user. Please check the URL and try again." 34 + : 'Invalid URL. Please enter a valid Bluesky post URL (e.g., https://bsky.app/profile/handle/post/rkey).'; 35 + return false; 36 + } finally { 37 + isValidating = false; 38 + } 39 + } 40 + </script> 41 + 42 + <Modal open={true} closeButton={false}> 43 + <form 44 + onsubmit={async () => { 45 + if (await validateAndCreate()) oncreate(); 46 + }} 47 + class="flex flex-col gap-2" 48 + > 49 + <Subheading>Enter a Bluesky post URL</Subheading> 50 + <Input 51 + bind:value={postUrl} 52 + placeholder="https://bsky.app/profile/handle/post/..." 53 + class="mt-4" 54 + /> 55 + 56 + {#if errorMessage} 57 + <Alert type="error" title="Failed to create post card"><span>{errorMessage}</span></Alert> 58 + {/if} 59 + 60 + <p class="text-base-500 dark:text-base-400 mt-2 text-xs"> 61 + Paste a URL from <a 62 + href="https://bsky.app" 63 + class="text-accent-800 dark:text-accent-300" 64 + target="_blank">bsky.app</a 65 + > to embed a Bluesky post. 66 + </p> 67 + 68 + <div class="mt-4 flex justify-end gap-2"> 69 + <Button onclick={oncancel} variant="ghost">Cancel</Button> 70 + <Button type="submit" disabled={isValidating || !postUrl.trim()} 71 + >{isValidating ? 'Creating...' : 'Create'}</Button 72 + > 73 + </div> 74 + </form> 75 + </Modal>
src/lib/cards/BlueskyPostCard/SidebarItemBlueskyPostCard.svelte src/lib/cards/LatestBlueskyPostCard/SidebarItemLatestBlueskyPostCard.svelte
+54 -10
src/lib/cards/BlueskyPostCard/index.ts
··· 1 import type { CardDefinition } from '../types'; 2 import BlueskyPostCard from './BlueskyPostCard.svelte'; 3 - import SidebarItemBlueskyPostCard from './SidebarItemBlueskyPostCard.svelte'; 4 - import { getAuthorFeed } from '$lib/atproto/methods'; 5 6 export const BlueskyPostCardDefinition = { 7 - type: 'latestPost', 8 contentComponent: BlueskyPostCard, 9 createNew: (card) => { 10 - card.cardType = 'latestPost'; 11 card.w = 4; 12 card.mobileW = 8; 13 card.h = 4; 14 card.mobileH = 8; 15 }, 16 - sidebarButtonText: 'Latest Bluesky Post', 17 - loadData: async (items, { did }) => { 18 - const authorFeed = await getAuthorFeed({ did, filter: 'posts_no_replies', limit: 2 }); 19 20 - return JSON.parse(JSON.stringify(authorFeed)); 21 }, 22 - minW: 4 23 - } as CardDefinition & { type: 'latestPost' };
··· 1 import type { CardDefinition } from '../types'; 2 import BlueskyPostCard from './BlueskyPostCard.svelte'; 3 + import CreateBlueskyPostCardModal from './CreateBlueskyPostCardModal.svelte'; 4 + import { getPosts } from '$lib/atproto/methods'; 5 + import type { PostView } from '@atcute/bluesky/types/app/feed/defs'; 6 + import { parseBlueskyPostUrl, resolveUri } from './utils'; 7 8 export const BlueskyPostCardDefinition = { 9 + type: 'blueskyPost', 10 contentComponent: BlueskyPostCard, 11 + creationModalComponent: CreateBlueskyPostCardModal, 12 + sidebarButtonText: 'Bluesky Post', 13 createNew: (card) => { 14 + card.cardType = 'blueskyPost'; 15 card.w = 4; 16 card.mobileW = 8; 17 card.h = 4; 18 card.mobileH = 8; 19 }, 20 21 + onUrlHandler: (url, item) => { 22 + const parsed = parseBlueskyPostUrl(url); 23 + if (!parsed) return null; 24 + 25 + // Construct AT URI using handle (will be resolved to DID when loading) 26 + item.cardData.uri = `at://${parsed.handle}/app.bsky.feed.post/${parsed.rkey}`; 27 + item.cardData.href = url; 28 + 29 + item.w = 4; 30 + item.mobileW = 8; 31 + item.h = 4; 32 + item.mobileH = 8; 33 + 34 + return item; 35 }, 36 + urlHandlerPriority: 2, 37 + 38 + loadData: async (items) => { 39 + // Collect all unique URIs from blueskyPost cards 40 + const originalUris = items 41 + .filter((item) => item.cardData?.uri) 42 + .map((item) => item.cardData.uri as string); 43 + 44 + if (originalUris.length === 0) return {}; 45 + 46 + // Resolve handles to DIDs 47 + const resolvedUris = await Promise.all(originalUris.map(resolveUri)); 48 + 49 + const posts = await getPosts({ uris: resolvedUris }); 50 + if (!posts) return {}; 51 + 52 + // Create a map of URI -> PostView (keyed by both original and resolved URIs) 53 + const postsMap: Record<string, PostView> = {}; 54 + for (let i = 0; i < posts.length; i++) { 55 + const post = posts[i]; 56 + postsMap[post.uri] = post; 57 + // Also map by original URI for lookup 58 + if (originalUris[i] && originalUris[i] !== post.uri) { 59 + postsMap[originalUris[i]] = post; 60 + } 61 + } 62 + 63 + return postsMap; 64 + }, 65 + minW: 4, 66 + name: 'Bluesky Post' 67 + } as CardDefinition & { type: 'blueskyPost' };
+37
src/lib/cards/BlueskyPostCard/utils.ts
···
··· 1 + import { resolveHandle } from '$lib/atproto'; 2 + import type { Handle } from '@atcute/lexicons'; 3 + 4 + // Matches URLs like https://bsky.app/profile/jyc.dev/post/3mdfjepjpls24 5 + const blueskyPostUrlPattern = 6 + /^https?:\/\/(?:www\.)?bsky\.app\/profile\/([^/]+)\/post\/([A-Za-z0-9]+)\/?$/; 7 + 8 + /** 9 + * Extract handle and rkey from a Bluesky post URL 10 + * @param url URL to parse 11 + * @returns Object with handle and rkey, or undefined if not a valid Bluesky post URL 12 + */ 13 + export function parseBlueskyPostUrl(url: string): { handle: string; rkey: string } | undefined { 14 + const match = url.match(blueskyPostUrlPattern); 15 + if (!match) return undefined; 16 + return { handle: match[1], rkey: match[2] }; 17 + } 18 + 19 + // Resolve handle to DID if URI contains a handle (not starting with did:) 20 + export async function resolveUri(atUri: string): Promise<string> { 21 + const match = atUri.match(/^at:\/\/([^/]+)\/(.+)$/); 22 + if (!match) return atUri; 23 + 24 + const [, authority, rest] = match; 25 + 26 + // If already a DID, return as-is 27 + if (authority.startsWith('did:')) return atUri; 28 + 29 + // Resolve handle to DID 30 + try { 31 + const did = await resolveHandle({ handle: authority as Handle }); 32 + if (!did) return atUri; 33 + return `at://${did}/${rest}`; 34 + } catch { 35 + return atUri; 36 + } 37 + }
+5 -4
src/lib/cards/EventCard/EventCard.svelte
··· 8 import { parseUri } from '$lib/atproto'; 9 import { browser } from '$app/environment'; 10 import { qrOverlay } from '$lib/components/qr/qrOverlay.svelte'; 11 12 let { item }: ContentComponentProps = $props(); 13 ··· 27 let parsedUri = $derived(item.cardData?.uri ? parseUri(item.cardData.uri) : null); 28 29 onMount(async () => { 30 - if (!eventData && item.cardData?.uri) { 31 const loadedData = (await CardDefinitionsByType[item.cardType]?.loadData?.([item], { 32 - did: parsedUri?.did ?? ('' as `did:${string}:${string}`), 33 handle: '' 34 })) as Record<string, EventData> | undefined; 35 ··· 92 let eventUrl = $derived(() => { 93 if (eventData?.url) return eventData.url; 94 if (parsedUri) { 95 - return `https://smokesignal.events/${parsedUri.did}/${parsedUri.rkey}`; 96 } 97 return '#'; 98 }); ··· 104 const header = eventData.media.find((m) => m.role === 'header'); 105 if (!header?.content?.ref?.$link) return null; 106 return { 107 - url: `https://cdn.bsky.app/img/feed_thumbnail/plain/${parsedUri.did}/${header.content.ref.$link}@jpeg`, 108 alt: header.alt || eventData.name 109 }; 110 });
··· 8 import { parseUri } from '$lib/atproto'; 9 import { browser } from '$app/environment'; 10 import { qrOverlay } from '$lib/components/qr/qrOverlay.svelte'; 11 + import type { Did } from '@atcute/lexicons'; 12 13 let { item }: ContentComponentProps = $props(); 14 ··· 28 let parsedUri = $derived(item.cardData?.uri ? parseUri(item.cardData.uri) : null); 29 30 onMount(async () => { 31 + if (!eventData && item.cardData?.uri && parsedUri?.repo) { 32 const loadedData = (await CardDefinitionsByType[item.cardType]?.loadData?.([item], { 33 + did: parsedUri.repo as Did, 34 handle: '' 35 })) as Record<string, EventData> | undefined; 36 ··· 93 let eventUrl = $derived(() => { 94 if (eventData?.url) return eventData.url; 95 if (parsedUri) { 96 + return `https://smokesignal.events/${parsedUri.repo}/${parsedUri.rkey}`; 97 } 98 return '#'; 99 }); ··· 105 const header = eventData.media.find((m) => m.role === 'header'); 106 if (!header?.content?.ref?.$link) return null; 107 return { 108 + url: `https://cdn.bsky.app/img/feed_thumbnail/plain/${parsedUri.repo}/${header.content.ref.$link}@jpeg`, 109 alt: header.alt || eventData.name 110 }; 111 });
+3 -2
src/lib/cards/EventCard/index.ts
··· 58 const uri = item.cardData?.uri; 59 if (!uri) continue; 60 61 - const { did, rkey } = parseUri(uri); 62 63 try { 64 const response = await fetch( 65 - `https://smokesignal.events/xrpc/community.lexicon.calendar.GetEvent?repository=${encodeURIComponent(did)}&record_key=${encodeURIComponent(rkey)}` 66 ); 67 68 if (response.ok) {
··· 58 const uri = item.cardData?.uri; 59 if (!uri) continue; 60 61 + const parsedUri = parseUri(uri); 62 + if (!parsedUri || !parsedUri.rkey || !parsedUri.repo) continue; 63 64 try { 65 const response = await fetch( 66 + `https://smokesignal.events/xrpc/community.lexicon.calendar.GetEvent?repository=${encodeURIComponent(parsedUri.repo)}&record_key=${encodeURIComponent(parsedUri.rkey)}` 67 ); 68 69 if (response.ok) {
+11 -1
src/lib/cards/FluidTextCard/EditingFluidTextCard.svelte
··· 1 <script lang="ts"> 2 import type { Item } from '$lib/types'; 3 import type { ContentComponentProps } from '../types'; 4 import FluidTextCard from './FluidTextCard.svelte'; 5 ··· 26 isEditing = false; 27 } 28 } 29 </script> 30 31 <!-- svelte-ignore a11y_no_static_element_interactions --> ··· 36 : ''}" 37 onclick={handleClick} 38 > 39 - {#key item.color} 40 <FluidTextCard {item} /> 41 {/key} 42
··· 1 <script lang="ts"> 2 import type { Item } from '$lib/types'; 3 + import { onMount, tick } from 'svelte'; 4 import type { ContentComponentProps } from '../types'; 5 import FluidTextCard from './FluidTextCard.svelte'; 6 ··· 27 isEditing = false; 28 } 29 } 30 + 31 + let rerender = $state(0); 32 + onMount(() => { 33 + window.addEventListener('theme-changed', async () => { 34 + // Force re-render to update FluidTextCard colors 35 + await tick(); 36 + rerender = Math.random(); 37 + }); 38 + }); 39 </script> 40 41 <!-- svelte-ignore a11y_no_static_element_interactions --> ··· 46 : ''}" 47 onclick={handleClick} 48 > 49 + {#key item.color + '-' + rerender.toString()} 50 <FluidTextCard {item} /> 51 {/key} 52
+45
src/lib/cards/LatestBlueskyPostCard/LatestBlueskyPostCard.svelte
···
··· 1 + <script lang="ts"> 2 + import type { Item } from '$lib/types'; 3 + import { onMount } from 'svelte'; 4 + import { BlueskyPost } from '../../components/bluesky-post'; 5 + import { getAdditionalUserData, getDidContext, getHandleContext } from '$lib/website/context'; 6 + import { CardDefinitionsByType } from '..'; 7 + 8 + let { item }: { item: Item } = $props(); 9 + 10 + const data = getAdditionalUserData(); 11 + // svelte-ignore state_referenced_locally 12 + let feed = $state((data[item.cardType] as any)?.feed); 13 + 14 + let did = getDidContext(); 15 + let handle = getHandleContext(); 16 + 17 + onMount(async () => { 18 + if (!feed) { 19 + feed = ( 20 + (await CardDefinitionsByType[item.cardType]?.loadData?.([], { 21 + did, 22 + handle 23 + })) as any 24 + ).feed; 25 + 26 + console.log(feed); 27 + 28 + data[item.cardType] = feed; 29 + } 30 + }); 31 + </script> 32 + 33 + <div class="flex h-full flex-col justify-center-safe overflow-y-scroll p-4"> 34 + <div 35 + class="accent:text-base-950 bg-base-200/50 dark:bg-base-700/30 mx-auto mb-6 w-fit rounded-xl p-1 px-2 text-2xl font-semibold" 36 + > 37 + My latest bluesky post 38 + </div> 39 + {#if feed?.[0]?.post} 40 + <BlueskyPost showLogo feedViewPost={feed?.[0].post}></BlueskyPost> 41 + <div class="h-4 w-full"></div> 42 + {:else} 43 + Your latest bluesky post will appear here. 44 + {/if} 45 + </div>
+22
src/lib/cards/LatestBlueskyPostCard/index.ts
···
··· 1 + import type { CardDefinition } from '../types'; 2 + import LatestBlueskyPostCard from './LatestBlueskyPostCard.svelte'; 3 + import { getAuthorFeed } from '$lib/atproto/methods'; 4 + 5 + export const LatestBlueskyPostCardDefinition = { 6 + type: 'latestPost', 7 + contentComponent: LatestBlueskyPostCard, 8 + createNew: (card) => { 9 + card.cardType = 'latestPost'; 10 + card.w = 4; 11 + card.mobileW = 8; 12 + card.h = 4; 13 + card.mobileH = 8; 14 + }, 15 + sidebarButtonText: 'Latest Bluesky Post', 16 + loadData: async (items, { did }) => { 17 + const authorFeed = await getAuthorFeed({ did, filter: 'posts_no_replies', limit: 2 }); 18 + 19 + return JSON.parse(JSON.stringify(authorFeed)); 20 + }, 21 + minW: 4 22 + } as CardDefinition & { type: 'latestPost' };
+2 -2
src/lib/cards/LivestreamCard/index.ts
··· 1 - import { user, listRecords, getImageBlobUrl } from '$lib/atproto'; 2 import type { CardDefinition } from '../types'; 3 import LivestreamCard from './LivestreamCard.svelte'; 4 import LivestreamEmbedCard from './LivestreamEmbedCard.svelte'; ··· 35 createdAt: latest.value.createdAt, 36 title: latest.value?.title as string, 37 thumb: latest.value?.thumb?.ref?.$link 38 - ? getImageBlobUrl({ blob: latest.value.thumb, did }) 39 : undefined, 40 href: latest.value?.canonicalUrl || latest.value.url, 41 online: undefined
··· 1 + import { user, listRecords, getCDNImageBlobUrl } from '$lib/atproto'; 2 import type { CardDefinition } from '../types'; 3 import LivestreamCard from './LivestreamCard.svelte'; 4 import LivestreamEmbedCard from './LivestreamEmbedCard.svelte'; ··· 35 createdAt: latest.value.createdAt, 36 title: latest.value?.title as string, 37 thumb: latest.value?.thumb?.ref?.$link 38 + ? getCDNImageBlobUrl({ blob: latest.value.thumb, did }) 39 : undefined, 40 href: latest.value?.canonicalUrl || latest.value.url, 41 online: undefined
+3 -3
src/lib/cards/PhotoGalleryCard/PhotoGalleryCard.svelte
··· 8 getIsMobile 9 } from '$lib/website/context'; 10 import { CardDefinitionsByType } from '..'; 11 - import { getImageBlobUrl, parseUri } from '$lib/atproto'; 12 13 import { ImageMasonry } from '@foxui/visual'; 14 ··· 54 return (a.value.position ?? 0) - (b.value.position ?? 0); 55 }) 56 .map((i: PhotoItem) => { 57 - const { did: photoDid } = parseUri(i.uri); 58 return { 59 - src: getImageBlobUrl({ did: photoDid, blob: i.value.photo }), 60 name: '', 61 width: i.value.aspectRatio.width, 62 height: i.value.aspectRatio.height,
··· 8 getIsMobile 9 } from '$lib/website/context'; 10 import { CardDefinitionsByType } from '..'; 11 + import { getCDNImageBlobUrl, parseUri } from '$lib/atproto'; 12 13 import { ImageMasonry } from '@foxui/visual'; 14 ··· 54 return (a.value.position ?? 0) - (b.value.position ?? 0); 55 }) 56 .map((i: PhotoItem) => { 57 + const item = parseUri(i.uri); 58 return { 59 + src: getCDNImageBlobUrl({ did: item?.repo, blob: i.value.photo }), 60 name: '', 61 width: i.value.aspectRatio.width, 62 height: i.value.aspectRatio.height,
+10 -4
src/lib/cards/PhotoGalleryCard/index.ts
··· 1 import type { CardDefinition } from '../types'; 2 import { getRecord, listRecords, parseUri } from '$lib/atproto'; 3 import PhotoGalleryCard from './PhotoGalleryCard.svelte'; 4 5 interface GalleryItem { 6 value: { ··· 33 for (const item of items) { 34 if (!item.cardData.galleryUri) continue; 35 36 - const { did, collection } = parseUri(item.cardData.galleryUri); 37 38 - if (collection === 'social.grain.gallery') { 39 const itemCollection = 'social.grain.gallery.item'; 40 41 if (!galleryItems[itemCollection]) { 42 galleryItems[itemCollection] = (await listRecords({ 43 - did, 44 collection: itemCollection 45 })) as unknown as GalleryItem[]; 46 } ··· 52 .filter((i) => i.value.gallery === item.cardData.galleryUri) 53 .map(async (i) => { 54 const itemData = parseUri(i.value.item); 55 - const record = await getRecord(itemData); 56 return { ...record, value: { ...record.value, ...i.value } }; 57 }); 58
··· 1 import type { CardDefinition } from '../types'; 2 import { getRecord, listRecords, parseUri } from '$lib/atproto'; 3 import PhotoGalleryCard from './PhotoGalleryCard.svelte'; 4 + import type { Did } from '@atcute/lexicons'; 5 6 interface GalleryItem { 7 value: { ··· 34 for (const item of items) { 35 if (!item.cardData.galleryUri) continue; 36 37 + const parsedUri = parseUri(item.cardData.galleryUri); 38 39 + if (parsedUri?.collection === 'social.grain.gallery') { 40 const itemCollection = 'social.grain.gallery.item'; 41 42 if (!galleryItems[itemCollection]) { 43 galleryItems[itemCollection] = (await listRecords({ 44 + did: parsedUri.repo as Did, 45 collection: itemCollection 46 })) as unknown as GalleryItem[]; 47 } ··· 53 .filter((i) => i.value.gallery === item.cardData.galleryUri) 54 .map(async (i) => { 55 const itemData = parseUri(i.value.item); 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 + }); 62 return { ...record, value: { ...record.value, ...i.value } }; 63 }); 64
+11 -7
src/lib/cards/SpecialCards/UpdatedBlentos/UpdatedBlentosCard.svelte
··· 2 import type { ContentComponentProps } from '$lib/cards/types'; 3 import { getAdditionalUserData } from '$lib/website/context'; 4 import type { AppBskyActorDefs } from '@atcute/bluesky'; 5 6 let { item }: ContentComponentProps = $props(); 7 8 const data = getAdditionalUserData(); 9 // svelte-ignore state_referenced_locally 10 const profiles = data[item.cardType] as AppBskyActorDefs.ProfileViewDetailed[]; 11 </script> 12 13 <div class="flex h-full flex-col"> ··· 15 <div class="flex max-w-full grow items-center gap-4 overflow-x-scroll overflow-y-hidden px-4"> 16 {#each profiles as profile (profile.did)} 17 <a 18 - href="/{profile.handle}" 19 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 target="_blank" 21 > 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 - /> 28 <div class="text-md line-clamp-1 max-w-full text-center font-bold"> 29 {profile.displayName || profile.handle} 30 </div>
··· 2 import type { ContentComponentProps } from '$lib/cards/types'; 3 import { getAdditionalUserData } from '$lib/website/context'; 4 import type { AppBskyActorDefs } from '@atcute/bluesky'; 5 + import { Avatar } from '@foxui/core'; 6 7 let { item }: ContentComponentProps = $props(); 8 9 const data = getAdditionalUserData(); 10 // svelte-ignore state_referenced_locally 11 const profiles = data[item.cardType] as AppBskyActorDefs.ProfileViewDetailed[]; 12 + 13 + function getLink(profile: AppBskyActorDefs.ProfileViewDetailed): string { 14 + if (profile.handle && profile.handle !== 'handle.invalid') { 15 + return `/${profile.handle}`; 16 + } else { 17 + return `/${profile.did}`; 18 + } 19 + } 20 </script> 21 22 <div class="flex h-full flex-col"> ··· 24 <div class="flex max-w-full grow items-center gap-4 overflow-x-scroll overflow-y-hidden px-4"> 25 {#each profiles as profile (profile.did)} 26 <a 27 + href={getLink(profile)} 28 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" 29 target="_blank" 30 > 31 + <Avatar src={profile.avatar} class="size-28" alt="" /> 32 <div class="text-md line-clamp-1 max-w-full text-center font-bold"> 33 {profile.displayName || profile.handle} 34 </div>
+12 -2
src/lib/cards/SpecialCards/UpdatedBlentos/index.ts
··· 30 for (const did of Array.from(uniqueDids)) { 31 const profile = getDetailedProfile({ did }); 32 profiles.push(profile); 33 - if (profiles.length > 20) break; 34 } 35 36 - const result = [...(await Promise.all(profiles)), ...existingUsersArray]; 37 38 if (cache) { 39 await cache?.put('updatedBlentos', JSON.stringify(result));
··· 30 for (const did of Array.from(uniqueDids)) { 31 const profile = getDetailedProfile({ did }); 32 profiles.push(profile); 33 + if (profiles.length > 30) break; 34 } 35 36 + for (let i = existingUsersArray.length - 1; i >= 0; i--) { 37 + // if handle is handle.invalid, remove from existing users and add to profiles to refresh 38 + if (existingUsersArray[i].handle === 'handle.invalid') { 39 + const removed = existingUsersArray.splice(i, 1)[0]; 40 + profiles.push(getDetailedProfile({ did: removed.did })); 41 + } 42 + } 43 + 44 + const result = [...(await Promise.all(profiles)), ...existingUsersArray].filter( 45 + (v) => v && v.handle !== 'handle.invalid' 46 + ); 47 48 if (cache) { 49 await cache?.put('updatedBlentos', JSON.stringify(result));
+7 -1
src/lib/cards/StandardSiteDocumentListCard/index.ts
··· 22 if (!publications[site]) { 23 const siteParts = parseUri(site); 24 25 - const publicationRecord = await getRecord(siteParts); 26 27 publications[site] = publicationRecord.value.url as string; 28 }
··· 22 if (!publications[site]) { 23 const siteParts = parseUri(site); 24 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 + }); 32 33 publications[site] = publicationRecord.value.url as string; 34 }
+13 -47
src/lib/cards/TimerCard/TimerCard.svelte
··· 1 <script lang="ts"> 2 - import { Button } from '@foxui/core'; 3 - import { Timer, TimerState } from '@foxui/time'; 4 import NumberFlow, { NumberFlowGroup } from '@number-flow/svelte'; 5 import type { ContentComponentProps } from '../types'; 6 import type { TimerCardData } from './index'; 7 import { onMount } from 'svelte'; 8 9 - let { item, isEditing }: ContentComponentProps = $props(); 10 11 let cardData = $derived(item.cardData as TimerCardData); 12 - 13 - // For timer mode 14 - let timer = $state(new TimerState(cardData.duration ?? 1000 * 60 * 5)); 15 16 // For clock and event modes - current time 17 let now = $state(new Date()); ··· 85 }); 86 </script> 87 88 - <div class="flex h-full w-full flex-col items-center justify-center p-4"> 89 - <!-- Label --> 90 - {#if cardData.label} 91 - <div 92 - class="text-base-600 dark:text-base-400 accent:text-base-700 mb-1 text-center text-sm font-medium" 93 - > 94 - {cardData.label} 95 - </div> 96 - {/if} 97 - 98 <!-- Clock Mode --> 99 {#if cardData.mode === 'clock'} 100 <NumberFlowGroup> 101 <div 102 - class="text-base-900 dark:text-base-100 accent:text-base-900 flex items-center text-4xl font-bold" 103 style="font-variant-numeric: tabular-nums;" 104 > 105 <NumberFlow value={clockHours} format={{ minimumIntegerDigits: 2 }} /> ··· 108 value={clockMinutes} 109 format={{ minimumIntegerDigits: 2 }} 110 digits={{ 1: { max: 5 } }} 111 /> 112 <span class="text-base-400 dark:text-base-500 accent:text-accent-950 mx-0.5">:</span> 113 <NumberFlow 114 value={clockSeconds} 115 format={{ minimumIntegerDigits: 2 }} 116 digits={{ 1: { max: 5 } }} 117 /> 118 </div> 119 </NumberFlowGroup> 120 {#if timezoneDisplay} 121 - <div class="text-base-500 dark:text-base-400 accent:text-base-600 mt-1 text-xs"> 122 {timezoneDisplay} 123 </div> 124 {/if} 125 126 - <!-- Timer Mode --> 127 - {:else if cardData.mode === 'timer'} 128 - <Timer 129 - bind:timer 130 - showHours 131 - showMinutes 132 - showSeconds 133 - class="text-base-900 dark:text-base-100 accent:text-base-900 text-4xl" 134 - /> 135 - {#if isEditing} 136 - <div class="mt-3 flex gap-2"> 137 - {#if timer.isStopped} 138 - <Button size="sm" onclick={() => timer.start()}>Start</Button> 139 - {:else if timer.isRunning} 140 - <Button size="sm" variant="secondary" onclick={() => timer.pause()}>Pause</Button> 141 - {:else if timer.isPaused} 142 - <Button size="sm" onclick={() => timer.resume()}>Resume</Button> 143 - {/if} 144 - {#if !timer.isStopped} 145 - <Button size="sm" variant="ghost" onclick={() => timer.reset()}>Reset</Button> 146 - {/if} 147 - </div> 148 - {/if} 149 - 150 <!-- Event Countdown Mode --> 151 {:else if cardData.mode === 'event'} 152 {#if eventDiff !== null && !isEventComplete} 153 <NumberFlowGroup> 154 <div 155 - class="text-base-900 dark:text-base-100 accent:text-base-900 flex items-baseline gap-3 text-center" 156 style="font-variant-numeric: tabular-nums;" 157 > 158 {#if eventDays > 0} ··· 167 value={eventHours} 168 trend={-1} 169 format={{ minimumIntegerDigits: 2 }} 170 - class="text-4xl font-bold" 171 /> 172 <span class="text-base-500 dark:text-base-400 accent:text-base-700 text-xs">hrs</span> 173 </div> ··· 177 trend={-1} 178 format={{ minimumIntegerDigits: 2 }} 179 digits={{ 1: { max: 5 } }} 180 - class="text-4xl font-bold" 181 /> 182 <span class="text-base-500 dark:text-base-400 accent:text-base-700 text-xs">min</span> 183 </div> ··· 187 trend={-1} 188 format={{ minimumIntegerDigits: 2 }} 189 digits={{ 1: { max: 5 } }} 190 - class="text-4xl font-bold" 191 /> 192 <span class="text-base-500 dark:text-base-400 accent:text-base-700 text-xs">sec</span> 193 </div> 194 </div> 195 </NumberFlowGroup> 196 {:else if isEventComplete} 197 - <div class="text-accent-600 dark:text-accent-400 accent:text-accent-900 text-2xl font-bold"> 198 Event Started! 199 </div> 200 {:else}
··· 1 <script lang="ts"> 2 import NumberFlow, { NumberFlowGroup } from '@number-flow/svelte'; 3 import type { ContentComponentProps } from '../types'; 4 import type { TimerCardData } from './index'; 5 import { onMount } from 'svelte'; 6 7 + let { item }: ContentComponentProps = $props(); 8 9 let cardData = $derived(item.cardData as TimerCardData); 10 11 // For clock and event modes - current time 12 let now = $state(new Date()); ··· 80 }); 81 </script> 82 83 + <div class="@container flex h-full w-full flex-col items-center justify-center p-4"> 84 <!-- Clock Mode --> 85 {#if cardData.mode === 'clock'} 86 <NumberFlowGroup> 87 <div 88 + class="text-base-900 dark:text-base-100 accent:text-base-900 flex items-center text-3xl font-bold @xs:text-4xl @sm:text-5xl @md:text-6xl @lg:text-7xl" 89 style="font-variant-numeric: tabular-nums;" 90 > 91 <NumberFlow value={clockHours} format={{ minimumIntegerDigits: 2 }} /> ··· 94 value={clockMinutes} 95 format={{ minimumIntegerDigits: 2 }} 96 digits={{ 1: { max: 5 } }} 97 + trend={1} 98 /> 99 <span class="text-base-400 dark:text-base-500 accent:text-accent-950 mx-0.5">:</span> 100 <NumberFlow 101 value={clockSeconds} 102 format={{ minimumIntegerDigits: 2 }} 103 digits={{ 1: { max: 5 } }} 104 + trend={1} 105 /> 106 </div> 107 </NumberFlowGroup> 108 {#if timezoneDisplay} 109 + <div class="text-base-500 dark:text-base-400 accent:text-base-600 mt-1 text-xs @sm:text-sm"> 110 {timezoneDisplay} 111 </div> 112 {/if} 113 114 <!-- Event Countdown Mode --> 115 {:else if cardData.mode === 'event'} 116 {#if eventDiff !== null && !isEventComplete} 117 <NumberFlowGroup> 118 <div 119 + class="text-base-900 dark:text-base-100 accent:text-base-900 flex items-baseline gap-4 text-center @sm:gap-6 @md:gap-8" 120 style="font-variant-numeric: tabular-nums;" 121 > 122 {#if eventDays > 0} ··· 131 value={eventHours} 132 trend={-1} 133 format={{ minimumIntegerDigits: 2 }} 134 + class="text-3xl font-bold @xs:text-4xl @sm:text-5xl @md:text-6xl @lg:text-7xl" 135 /> 136 <span class="text-base-500 dark:text-base-400 accent:text-base-700 text-xs">hrs</span> 137 </div> ··· 141 trend={-1} 142 format={{ minimumIntegerDigits: 2 }} 143 digits={{ 1: { max: 5 } }} 144 + class="text-3xl font-bold @xs:text-4xl @sm:text-5xl @md:text-6xl @lg:text-7xl" 145 /> 146 <span class="text-base-500 dark:text-base-400 accent:text-base-700 text-xs">min</span> 147 </div> ··· 151 trend={-1} 152 format={{ minimumIntegerDigits: 2 }} 153 digits={{ 1: { max: 5 } }} 154 + class="text-3xl font-bold @xs:text-4xl @sm:text-5xl @md:text-6xl @lg:text-7xl" 155 /> 156 <span class="text-base-500 dark:text-base-400 accent:text-base-700 text-xs">sec</span> 157 </div> 158 </div> 159 </NumberFlowGroup> 160 {:else if isEventComplete} 161 + <div 162 + class="text-accent-600 dark:text-accent-400 accent:text-accent-900 text-xl font-bold @xs:text-2xl @sm:text-3xl @md:text-4xl" 163 + > 164 Event Started! 165 </div> 166 {:else}
+60 -66
src/lib/cards/TimerCard/TimerCardSettings.svelte
··· 1 <script lang="ts"> 2 import type { Item } from '$lib/types'; 3 - import { Input, Label } from '@foxui/core'; 4 import type { TimerCardData, TimerMode } from './index'; 5 6 let { item }: { item: Item; onclose: () => void } = $props(); 7 ··· 9 10 const modeOptions = [ 11 { value: 'clock', label: 'Clock', desc: 'Show current time' }, 12 - { value: 'timer', label: 'Timer', desc: 'Countdown timer' }, 13 { value: 'event', label: 'Event', desc: 'Countdown to date' } 14 ]; 15 16 const timezoneOptions = [ 17 - { value: 'UTC', label: 'UTC' }, 18 - { value: 'America/New_York', label: 'New York' }, 19 - { value: 'America/Chicago', label: 'Chicago' }, 20 - { value: 'America/Denver', label: 'Denver' }, 21 - { value: 'America/Los_Angeles', label: 'Los Angeles' }, 22 - { value: 'Europe/London', label: 'London' }, 23 - { value: 'Europe/Paris', label: 'Paris' }, 24 - { value: 'Europe/Berlin', label: 'Berlin' }, 25 - { value: 'Asia/Tokyo', label: 'Tokyo' }, 26 - { value: 'Asia/Shanghai', label: 'Shanghai' }, 27 - { value: 'Asia/Dubai', label: 'Dubai' }, 28 - { value: 'Asia/Kolkata', label: 'Mumbai' }, 29 - { value: 'Australia/Sydney', label: 'Sydney' } 30 ]; 31 32 - const durationOptions = [ 33 - { value: 1000 * 60, label: '1 minute' }, 34 - { value: 1000 * 60 * 5, label: '5 minutes' }, 35 - { value: 1000 * 60 * 10, label: '10 minutes' }, 36 - { value: 1000 * 60 * 15, label: '15 minutes' }, 37 - { value: 1000 * 60 * 30, label: '30 minutes' }, 38 - { value: 1000 * 60 * 60, label: '1 hour' } 39 - ]; 40 41 // Parse target date for inputs 42 let targetDateValue = $derived.by(() => { ··· 59 <!-- Mode Selection --> 60 <div class="flex flex-col gap-2"> 61 <Label>Mode</Label> 62 - <div class="grid grid-cols-3 gap-2"> 63 {#each modeOptions as opt (opt.value)} 64 <button 65 type="button" ··· 78 </div> 79 </div> 80 81 - <!-- Label --> 82 - <div class="flex flex-col gap-2"> 83 - <Label for="label">Label (optional)</Label> 84 - <Input 85 - id="label" 86 - value={cardData.label || ''} 87 - oninput={(e) => (item.cardData.label = e.currentTarget.value || undefined)} 88 - placeholder={cardData.mode === 'clock' 89 - ? 'e.g. Tokyo Time' 90 - : cardData.mode === 'event' 91 - ? 'e.g. New Year' 92 - : 'e.g. Focus Time'} 93 - /> 94 - </div> 95 - 96 <!-- Clock Settings --> 97 {#if cardData.mode === 'clock'} 98 <div class="flex flex-col gap-2"> 99 <Label for="timezone">Timezone</Label> 100 - <select 101 - id="timezone" 102 - value={cardData.timezone || 'UTC'} 103 - onchange={(e) => (item.cardData.timezone = e.currentTarget.value)} 104 - class="bg-base-100 dark:bg-base-800 border-base-300 dark:border-base-700 text-base-900 dark:text-base-100 rounded-xl border px-3 py-2" 105 - > 106 - {#each timezoneOptions as tz (tz.value)} 107 - <option value={tz.value}>{tz.label}</option> 108 - {/each} 109 - </select> 110 - </div> 111 - {/if} 112 - 113 - <!-- Timer Settings --> 114 - {#if cardData.mode === 'timer'} 115 - <div class="flex flex-col gap-2"> 116 - <Label for="duration">Duration</Label> 117 - <select 118 - id="duration" 119 - value={cardData.duration || 1000 * 60 * 5} 120 - onchange={(e) => (item.cardData.duration = parseInt(e.currentTarget.value))} 121 - class="bg-base-100 dark:bg-base-800 border-base-300 dark:border-base-700 text-base-900 dark:text-base-100 rounded-xl border px-3 py-2" 122 - > 123 - {#each durationOptions as dur (dur.value)} 124 - <option value={dur.value}>{dur.label}</option> 125 - {/each} 126 - </select> 127 </div> 128 {/if} 129
··· 1 <script lang="ts"> 2 import type { Item } from '$lib/types'; 3 + import { Button, Input, Label } from '@foxui/core'; 4 import type { TimerCardData, TimerMode } from './index'; 5 + import { onMount } from 'svelte'; 6 7 let { item }: { item: Item; onclose: () => void } = $props(); 8 ··· 10 11 const modeOptions = [ 12 { value: 'clock', label: 'Clock', desc: 'Show current time' }, 13 { value: 'event', label: 'Event', desc: 'Countdown to date' } 14 ]; 15 16 + // All 24 timezones with representative cities 17 const timezoneOptions = [ 18 + { value: 'Pacific/Midway', label: 'UTC-11 (Midway)' }, 19 + { value: 'Pacific/Honolulu', label: 'UTC-10 (Honolulu)' }, 20 + { value: 'America/Anchorage', label: 'UTC-9 (Anchorage)' }, 21 + { value: 'America/Los_Angeles', label: 'UTC-8 (Los Angeles)' }, 22 + { value: 'America/Denver', label: 'UTC-7 (Denver)' }, 23 + { value: 'America/Chicago', label: 'UTC-6 (Chicago)' }, 24 + { value: 'America/New_York', label: 'UTC-5 (New York)' }, 25 + { value: 'America/Halifax', label: 'UTC-4 (Halifax)' }, 26 + { value: 'America/Sao_Paulo', label: 'UTC-3 (São Paulo)' }, 27 + { value: 'Atlantic/South_Georgia', label: 'UTC-2 (South Georgia)' }, 28 + { value: 'Atlantic/Azores', label: 'UTC-1 (Azores)' }, 29 + { value: 'UTC', label: 'UTC+0 (London)' }, 30 + { value: 'Europe/Paris', label: 'UTC+1 (Paris)' }, 31 + { value: 'Europe/Helsinki', label: 'UTC+2 (Helsinki)' }, 32 + { value: 'Europe/Moscow', label: 'UTC+3 (Moscow)' }, 33 + { value: 'Asia/Dubai', label: 'UTC+4 (Dubai)' }, 34 + { value: 'Asia/Karachi', label: 'UTC+5 (Karachi)' }, 35 + { value: 'Asia/Kolkata', label: 'UTC+5:30 (Mumbai)' }, 36 + { value: 'Asia/Dhaka', label: 'UTC+6 (Dhaka)' }, 37 + { value: 'Asia/Bangkok', label: 'UTC+7 (Bangkok)' }, 38 + { value: 'Asia/Shanghai', label: 'UTC+8 (Shanghai)' }, 39 + { value: 'Asia/Tokyo', label: 'UTC+9 (Tokyo)' }, 40 + { value: 'Australia/Sydney', label: 'UTC+10 (Sydney)' }, 41 + { value: 'Pacific/Noumea', label: 'UTC+11 (Noumea)' }, 42 + { value: 'Pacific/Auckland', label: 'UTC+12 (Auckland)' } 43 ]; 44 45 + // Auto-detect timezone on mount if not set 46 + onMount(() => { 47 + if (!cardData.timezone) { 48 + try { 49 + item.cardData.timezone = Intl.DateTimeFormat().resolvedOptions().timeZone; 50 + } catch { 51 + item.cardData.timezone = 'UTC'; 52 + } 53 + } 54 + }); 55 + 56 + function useLocalTimezone() { 57 + try { 58 + item.cardData.timezone = Intl.DateTimeFormat().resolvedOptions().timeZone; 59 + } catch { 60 + item.cardData.timezone = 'UTC'; 61 + } 62 + } 63 64 // Parse target date for inputs 65 let targetDateValue = $derived.by(() => { ··· 82 <!-- Mode Selection --> 83 <div class="flex flex-col gap-2"> 84 <Label>Mode</Label> 85 + <div class="grid grid-cols-2 gap-2"> 86 {#each modeOptions as opt (opt.value)} 87 <button 88 type="button" ··· 101 </div> 102 </div> 103 104 <!-- Clock Settings --> 105 {#if cardData.mode === 'clock'} 106 <div class="flex flex-col gap-2"> 107 <Label for="timezone">Timezone</Label> 108 + <div class="flex gap-2"> 109 + <select 110 + id="timezone" 111 + value={cardData.timezone || 'UTC'} 112 + onchange={(e) => (item.cardData.timezone = e.currentTarget.value)} 113 + class="bg-base-100 dark:bg-base-800 border-base-300 dark:border-base-700 text-base-900 dark:text-base-100 flex-1 rounded-xl border px-3 py-2" 114 + > 115 + {#each timezoneOptions as tz (tz.value)} 116 + <option value={tz.value}>{tz.label}</option> 117 + {/each} 118 + </select> 119 + <Button size="sm" variant="ghost" onclick={useLocalTimezone}>Local</Button> 120 + </div> 121 </div> 122 {/if} 123
+4 -4
src/lib/cards/TimerCard/index.ts
··· 2 import TimerCard from './TimerCard.svelte'; 3 import TimerCardSettings from './TimerCardSettings.svelte'; 4 5 - export type TimerMode = 'clock' | 'timer' | 'event'; 6 7 export type TimerCardData = { 8 mode: TimerMode; ··· 11 timezone?: string; 12 // For event mode: target date as ISO string 13 targetDate?: string; 14 - // For timer mode: duration in ms 15 - duration?: number; 16 }; 17 18 export const TimerCardDefinition = { ··· 33 }, 34 35 allowSetColor: true, 36 - name: 'Timer Card' 37 } as CardDefinition & { type: 'timer' };
··· 2 import TimerCard from './TimerCard.svelte'; 3 import TimerCardSettings from './TimerCardSettings.svelte'; 4 5 + export type TimerMode = 'clock' | 'event'; 6 7 export type TimerCardData = { 8 mode: TimerMode; ··· 11 timezone?: string; 12 // For event mode: target date as ISO string 13 targetDate?: string; 14 }; 15 16 export const TimerCardDefinition = { ··· 31 }, 32 33 allowSetColor: true, 34 + name: 'Timer Card', 35 + minW: 4, 36 + canHaveLabel: true 37 } as CardDefinition & { type: 'timer' };
+2
src/lib/cards/index.ts
··· 3 import { BigSocialCardDefinition } from './BigSocialCard'; 4 import { BlueskyMediaCardDefinition } from './BlueskyMediaCard'; 5 import { BlueskyPostCardDefinition } from './BlueskyPostCard'; 6 import { DinoGameCardDefinition } from './GameCards/DinoGameCard'; 7 import { EmbedCardDefinition } from './EmbedCard'; 8 import { TetrisCardDefinition } from './GameCards/TetrisCard'; ··· 39 UpdatedBlentosCardDefitition, 40 YoutubeCardDefinition, 41 BlueskyPostCardDefinition, 42 LivestreamCardDefitition, 43 LivestreamEmbedCardDefitition, 44 EmbedCardDefinition,
··· 3 import { BigSocialCardDefinition } from './BigSocialCard'; 4 import { BlueskyMediaCardDefinition } from './BlueskyMediaCard'; 5 import { BlueskyPostCardDefinition } from './BlueskyPostCard'; 6 + import { LatestBlueskyPostCardDefinition } from './LatestBlueskyPostCard'; 7 import { DinoGameCardDefinition } from './GameCards/DinoGameCard'; 8 import { EmbedCardDefinition } from './EmbedCard'; 9 import { TetrisCardDefinition } from './GameCards/TetrisCard'; ··· 40 UpdatedBlentosCardDefitition, 41 YoutubeCardDefinition, 42 BlueskyPostCardDefinition, 43 + LatestBlueskyPostCardDefinition, 44 LivestreamCardDefitition, 45 LivestreamEmbedCardDefitition, 46 EmbedCardDefinition,
+101
src/lib/components/select-theme/SelectTheme.svelte
···
··· 1 + <script lang="ts"> 2 + import { Paragraph } from '@foxui/core'; 3 + import { ColorSelect } from '@foxui/colors'; 4 + 5 + let accentColors = [ 6 + { class: 'text-red-500', label: 'red' }, 7 + { class: 'text-orange-500', label: 'orange' }, 8 + { class: 'text-amber-500', label: 'amber' }, 9 + { class: 'text-yellow-500', label: 'yellow' }, 10 + { class: 'text-lime-500', label: 'lime' }, 11 + { class: 'text-green-500', label: 'green' }, 12 + { class: 'text-emerald-500', label: 'emerald' }, 13 + { class: 'text-teal-500', label: 'teal' }, 14 + { class: 'text-cyan-500', label: 'cyan' }, 15 + { class: 'text-sky-500', label: 'sky' }, 16 + { class: 'text-blue-500', label: 'blue' }, 17 + { class: 'text-indigo-500', label: 'indigo' }, 18 + { class: 'text-violet-500', label: 'violet' }, 19 + { class: 'text-purple-500', label: 'purple' }, 20 + { class: 'text-fuchsia-500', label: 'fuchsia' }, 21 + { class: 'text-pink-500', label: 'pink' }, 22 + { class: 'text-rose-500', label: 'rose' } 23 + ]; 24 + 25 + let baseColors = [ 26 + { class: 'text-gray-500', label: 'gray' }, 27 + { class: 'text-stone-500', label: 'stone' }, 28 + { class: 'text-zinc-500', label: 'zinc' }, 29 + { class: 'text-neutral-500', label: 'neutral' }, 30 + { class: 'text-slate-500', label: 'slate' } 31 + ]; 32 + 33 + let { 34 + accentColor = $bindable('pink'), 35 + baseColor = $bindable('stone'), 36 + selectAccentColor = true, 37 + selectBaseColor = true, 38 + onchanged 39 + }: { 40 + accentColor?: string; 41 + baseColor?: string; 42 + selectAccentColor?: boolean; 43 + selectBaseColor?: boolean; 44 + onchanged?: (accentColor: string, baseColor: string) => void; 45 + } = $props(); 46 + 47 + let selectedAccentColor = $derived( 48 + accentColors.find((c) => c.label === accentColor) ?? accentColors[15] 49 + ); 50 + 51 + let selectedBaseColor = $derived(baseColors.find((c) => c.label === baseColor) ?? baseColors[1]); 52 + </script> 53 + 54 + {#if selectAccentColor} 55 + <Paragraph class="mb-2">Accent Color</Paragraph> 56 + <ColorSelect 57 + selected={selectedAccentColor} 58 + colors={accentColors} 59 + onselected={(color, previous) => { 60 + if (typeof previous === 'string' || typeof color === 'string') { 61 + return; 62 + } 63 + 64 + document.documentElement.classList.remove(previous.label.toLowerCase()); 65 + document.documentElement.classList.add(color.label.toLowerCase()); 66 + 67 + accentColor = color.label; 68 + 69 + window.dispatchEvent( 70 + new CustomEvent('theme-changed', { detail: { accentColor: color.label } }) 71 + ); 72 + 73 + onchanged?.(accentColor, baseColor); 74 + }} 75 + class="w-64" 76 + /> 77 + {/if} 78 + 79 + {#if selectBaseColor} 80 + <Paragraph class="mt-4 mb-2">Base Color</Paragraph> 81 + <ColorSelect 82 + selected={selectedBaseColor} 83 + colors={baseColors} 84 + onselected={(color, previous) => { 85 + if (typeof previous === 'string' || typeof color === 'string') { 86 + return; 87 + } 88 + 89 + document.documentElement.classList.remove(previous.label.toLowerCase()); 90 + document.documentElement.classList.add(color.label.toLowerCase()); 91 + 92 + baseColor = color.label; 93 + 94 + window.dispatchEvent( 95 + new CustomEvent('theme-changed', { detail: { baseColor: color.label } }) 96 + ); 97 + 98 + onchanged?.(accentColor, baseColor); 99 + }} 100 + /> 101 + {/if}
+43
src/lib/components/select-theme/SelectThemePopover.svelte
···
··· 1 + <script lang="ts"> 2 + import { buttonVariants, Popover, cn } from '@foxui/core'; 3 + import SelectTheme from './SelectTheme.svelte'; 4 + 5 + let { 6 + accentColor = $bindable('pink'), 7 + baseColor = $bindable('stone'), 8 + selectAccentColor = true, 9 + selectBaseColor = true, 10 + onchanged 11 + }: { 12 + accentColor?: string; 13 + baseColor?: string; 14 + selectAccentColor?: boolean; 15 + selectBaseColor?: boolean; 16 + onchanged?: (accentColor: string, baseColor: string) => void; 17 + } = $props(); 18 + </script> 19 + 20 + <Popover> 21 + {#snippet child({ props })} 22 + <button 23 + {...props} 24 + class={cn( 25 + buttonVariants({ variant: 'link', size: 'default' }), 26 + 'flex cursor-pointer items-center gap-0 -space-x-2 backdrop-blur-none' 27 + )} 28 + > 29 + {#if selectAccentColor} 30 + <div 31 + class=" from-accent-500 to-accent-600 border-accent-700 dark:border-accent-400 z-10 size-6 rounded-full border bg-linear-to-b" 32 + ></div> 33 + {/if} 34 + 35 + {#if selectBaseColor} 36 + <div 37 + class=" from-base-500 to-base-600 border-base-700 dark:border-base-400 size-6 rounded-full border bg-linear-to-b" 38 + ></div> 39 + {/if} 40 + </button> 41 + {/snippet} 42 + <SelectTheme bind:accentColor bind:baseColor {selectAccentColor} {selectBaseColor} {onchanged} /> 43 + </Popover>
+2
src/lib/components/select-theme/index.ts
···
··· 1 + export { default as SelectTheme } from './SelectTheme.svelte'; 2 + export { default as SelectThemePopover } from './SelectThemePopover.svelte';
+2 -3
src/lib/helper.ts
··· 1 import type { Item, WebsiteData } from './types'; 2 import { COLUMNS, margin, mobileMargin } from '$lib'; 3 import { CardDefinitionsByType } from './cards'; 4 - import { deleteRecord, getImageBlobUrl, putRecord, uploadBlob } from '$lib/atproto'; 5 - import { toast } from '@foxui/core'; 6 import * as TID from '@atcute/tid'; 7 8 export function clamp(value: number, min: number, max: number): number { ··· 627 if (objectWithImage[key].objectUrl) return objectWithImage[key].objectUrl; 628 629 if (typeof objectWithImage[key] === 'object' && objectWithImage[key].$type === 'blob') { 630 - return getImageBlobUrl({ did, blob: objectWithImage[key] }); 631 } 632 return objectWithImage[key]; 633 }
··· 1 import type { Item, WebsiteData } from './types'; 2 import { COLUMNS, margin, mobileMargin } from '$lib'; 3 import { CardDefinitionsByType } from './cards'; 4 + import { deleteRecord, getCDNImageBlobUrl, putRecord, uploadBlob } from '$lib/atproto'; 5 import * as TID from '@atcute/tid'; 6 7 export function clamp(value: number, min: number, max: number): number { ··· 626 if (objectWithImage[key].objectUrl) return objectWithImage[key].objectUrl; 627 628 if (typeof objectWithImage[key] === 'object' && objectWithImage[key].$type === 'blob') { 629 + return getCDNImageBlobUrl({ did, blob: objectWithImage[key] }); 630 } 631 return objectWithImage[key]; 632 }
+4
src/lib/types.ts
··· 51 52 // 'side' (default on desktop) or 'top' (always top like mobile view) 53 profilePosition?: 'side' | 'top'; 54 }; 55 }; 56 profile: AppBskyActorDefs.ProfileViewDetailed;
··· 51 52 // 'side' (default on desktop) or 'top' (always top like mobile view) 53 profilePosition?: 'side' | 'top'; 54 + 55 + // theme colors 56 + accentColor?: string; 57 + baseColor?: string; 58 }; 59 }; 60 profile: AppBskyActorDefs.ProfileViewDetailed;
+2 -2
src/lib/website/Account.svelte
··· 2 import { user, login, logout } from '$lib/atproto'; 3 import type { WebsiteData } from '$lib/types'; 4 import type { ActorIdentifier } from '@atcute/lexicons'; 5 - import { Button, Popover } from '@foxui/core'; 6 7 let { 8 data ··· 18 <Popover sideOffset={8} bind:open={settingsPopoverOpen} class="bg-base-100 dark:bg-base-900"> 19 {#snippet child({ props })} 20 <button {...props}> 21 - <img src={user.profile?.avatar} alt="" class="size-15 rounded-full" /> 22 </button> 23 {/snippet} 24
··· 2 import { user, login, logout } from '$lib/atproto'; 3 import type { WebsiteData } from '$lib/types'; 4 import type { ActorIdentifier } from '@atcute/lexicons'; 5 + import { Avatar, Button, Popover } from '@foxui/core'; 6 7 let { 8 data ··· 18 <Popover sideOffset={8} bind:open={settingsPopoverOpen} class="bg-base-100 dark:bg-base-900"> 19 {#snippet child({ props })} 20 <button {...props}> 21 + <Avatar src={user.profile?.avatar} alt="" class="size-15 rounded-full" /> 22 </button> 23 {/snippet} 24
+123
src/lib/website/Controls.svelte
···
··· 1 + <script lang="ts"> 2 + import { SelectThemePopover } from '$lib/components/select-theme'; 3 + import { getHideProfileSection, getProfilePosition } from '$lib/helper'; 4 + import type { WebsiteData } from '$lib/types'; 5 + import { Button } from '@foxui/core'; 6 + import { getIsMobile } from './context'; 7 + 8 + let { data = $bindable() }: { data: WebsiteData } = $props(); 9 + 10 + let accentColor = $derived(data.publication?.preferences?.accentColor ?? 'pink'); 11 + let baseColor = $derived(data.publication?.preferences?.baseColor ?? 'stone'); 12 + 13 + function updateTheme(newAccent: string, newBase: string) { 14 + data.publication.preferences ??= {}; 15 + data.publication.preferences.accentColor = newAccent; 16 + data.publication.preferences.baseColor = newBase; 17 + data = { ...data }; 18 + } 19 + 20 + let profilePosition = $derived(getProfilePosition(data)); 21 + 22 + function toggleProfilePosition() { 23 + data.publication.preferences ??= {}; 24 + data.publication.preferences.profilePosition = profilePosition === 'side' ? 'top' : 'side'; 25 + data = { ...data }; 26 + } 27 + 28 + let isMobile = getIsMobile(); 29 + </script> 30 + 31 + <div class={['fixed top-2 left-14 z-20 flex gap-2']}> 32 + <Button 33 + size="icon" 34 + onclick={() => { 35 + data.publication.preferences ??= {}; 36 + data.publication.preferences.hideProfileSection = 37 + !data.publication.preferences?.hideProfileSection; 38 + data = { ...data }; 39 + }} 40 + variant="ghost" 41 + > 42 + {#if !getHideProfileSection(data)} 43 + <svg 44 + xmlns="http://www.w3.org/2000/svg" 45 + fill="none" 46 + viewBox="0 0 24 24" 47 + stroke-width="1.5" 48 + stroke="currentColor" 49 + class="size-5!" 50 + > 51 + <path 52 + stroke-linecap="round" 53 + stroke-linejoin="round" 54 + d="M3.98 8.223A10.477 10.477 0 0 0 1.934 12C3.226 16.338 7.244 19.5 12 19.5c.993 0 1.953-.138 2.863-.395M6.228 6.228A10.451 10.451 0 0 1 12 4.5c4.756 0 8.773 3.162 10.065 7.498a10.522 10.522 0 0 1-4.293 5.774M6.228 6.228 3 3m3.228 3.228 3.65 3.65m7.894 7.894L21 21m-3.228-3.228-3.65-3.65m0 0a3 3 0 1 0-4.243-4.243m4.242 4.242L9.88 9.88" 55 + /> 56 + </svg> 57 + {:else} 58 + <svg 59 + xmlns="http://www.w3.org/2000/svg" 60 + fill="none" 61 + viewBox="0 0 24 24" 62 + stroke-width="1.5" 63 + stroke="currentColor" 64 + class="size-5!" 65 + > 66 + <path 67 + stroke-linecap="round" 68 + stroke-linejoin="round" 69 + d="M2.036 12.322a1.012 1.012 0 0 1 0-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178Z" 70 + /> 71 + <path 72 + stroke-linecap="round" 73 + stroke-linejoin="round" 74 + d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" 75 + /> 76 + </svg> 77 + {/if} 78 + </Button> 79 + 80 + <!-- Position toggle button (desktop only) --> 81 + {#if !isMobile() && !getHideProfileSection(data)} 82 + <Button size="icon" type="button" onclick={toggleProfilePosition} variant="ghost"> 83 + {#if profilePosition === 'side'} 84 + <svg 85 + xmlns="http://www.w3.org/2000/svg" 86 + fill="none" 87 + viewBox="0 0 24 24" 88 + stroke-width="1.5" 89 + stroke="currentColor" 90 + class="size-5!" 91 + > 92 + <path 93 + stroke-linecap="round" 94 + stroke-linejoin="round" 95 + d="m4.5 19.5 15-15m0 0H8.25m11.25 0v11.25" 96 + /> 97 + </svg> 98 + {:else} 99 + <svg 100 + xmlns="http://www.w3.org/2000/svg" 101 + fill="none" 102 + viewBox="0 0 24 24" 103 + stroke-width="1.5" 104 + stroke="currentColor" 105 + class="size-5!" 106 + > 107 + <path 108 + stroke-linecap="round" 109 + stroke-linejoin="round" 110 + d="m19.5 4.5-15 15m0 0h11.25m-11.25 0V8.25" 111 + /> 112 + </svg> 113 + {/if} 114 + </Button> 115 + {/if} 116 + 117 + <!-- Theme selection --> 118 + <SelectThemePopover 119 + {accentColor} 120 + {baseColor} 121 + onchanged={(newAccent, newBase) => updateTheme(newAccent, newBase)} 122 + /> 123 + </div>
+11 -92
src/lib/website/EditableProfile.svelte
··· 3 import { getImage, compressImage, getProfilePosition } from '$lib/helper'; 4 import PlainTextEditor from '$lib/components/PlainTextEditor.svelte'; 5 import MarkdownTextEditor from '$lib/components/MarkdownTextEditor.svelte'; 6 - import { Button } from '@foxui/core'; 7 import { getIsMobile } from './context'; 8 - import type { Editor } from '@tiptap/core'; 9 import MadeWithBlento from './MadeWithBlento.svelte'; 10 11 let { data = $bindable(), hideBlento = false }: { data: WebsiteData; hideBlento?: boolean } = 12 $props(); 13 - 14 - let profilePosition = $derived(getProfilePosition(data)); 15 - 16 - function toggleProfilePosition() { 17 - data.publication.preferences ??= {}; 18 - data.publication.preferences.profilePosition = profilePosition === 'side' ? 'top' : 'side'; 19 - data = { ...data }; 20 - } 21 22 let fileInput: HTMLInputElement; 23 let isHoveringAvatar = $state(false); ··· 52 fileInput.click(); 53 } 54 55 - let isMobile = getIsMobile(); 56 </script> 57 58 <div ··· 65 > 66 <div 67 class={[ 68 - 'absolute left-2 z-20 flex gap-2', 69 - profilePosition === 'side' ? 'top-2 left-14' : 'top-2' 70 - ]} 71 - > 72 - <Button 73 - size="icon" 74 - onclick={() => { 75 - data.publication.preferences ??= {}; 76 - data.publication.preferences.hideProfileSection = true; 77 - data = { ...data }; 78 - }} 79 - variant="ghost" 80 - > 81 - <svg 82 - xmlns="http://www.w3.org/2000/svg" 83 - fill="none" 84 - viewBox="0 0 24 24" 85 - stroke-width="1.5" 86 - stroke="currentColor" 87 - class="size-6" 88 - > 89 - <path 90 - stroke-linecap="round" 91 - stroke-linejoin="round" 92 - d="M3.98 8.223A10.477 10.477 0 0 0 1.934 12C3.226 16.338 7.244 19.5 12 19.5c.993 0 1.953-.138 2.863-.395M6.228 6.228A10.451 10.451 0 0 1 12 4.5c4.756 0 8.773 3.162 10.065 7.498a10.522 10.522 0 0 1-4.293 5.774M6.228 6.228 3 3m3.228 3.228 3.65 3.65m7.894 7.894L21 21m-3.228-3.228-3.65-3.65m0 0a3 3 0 1 0-4.243-4.243m4.242 4.242L9.88 9.88" 93 - /> 94 - </svg> 95 - </Button> 96 - 97 - <!-- Position toggle button (desktop only) --> 98 - {#if !isMobile()} 99 - <Button size="icon" type="button" onclick={toggleProfilePosition} variant="ghost"> 100 - {#if profilePosition === 'side'} 101 - <svg 102 - xmlns="http://www.w3.org/2000/svg" 103 - fill="none" 104 - viewBox="0 0 24 24" 105 - stroke-width="1.5" 106 - stroke="currentColor" 107 - class="size-6" 108 - > 109 - <path 110 - stroke-linecap="round" 111 - stroke-linejoin="round" 112 - d="m4.5 19.5 15-15m0 0H8.25m11.25 0v11.25" 113 - /> 114 - </svg> 115 - {:else} 116 - <svg 117 - xmlns="http://www.w3.org/2000/svg" 118 - fill="none" 119 - viewBox="0 0 24 24" 120 - stroke-width="1.5" 121 - stroke="currentColor" 122 - class="size-6" 123 - > 124 - <path 125 - stroke-linecap="round" 126 - stroke-linejoin="round" 127 - d="m19.5 4.5-15 15m0 0h11.25m-11.25 0V8.25" 128 - /> 129 - </svg> 130 - {/if} 131 - </Button> 132 - {/if} 133 - </div> 134 - 135 - <div 136 - class={[ 137 - 'flex flex-col gap-4 pt-16 pb-8', 138 profilePosition === 'side' && '@5xl/wrapper:h-screen @5xl/wrapper:pt-24' 139 ]} 140 > ··· 149 onmouseleave={() => (isHoveringAvatar = false)} 150 onclick={handleFileInputClick} 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} 161 162 <!-- Hover overlay --> 163 <div ··· 217 /> 218 {/if} 219 </div> 220 - 221 - <div class={['h-10.5 w-1', profilePosition === 'side' && '@5xl/wrapper:hidden']}></div> 222 223 {#if !hideBlento} 224 <MadeWithBlento class="hidden {profilePosition === 'side' && '@5xl/wrapper:block'}" />
··· 3 import { getImage, compressImage, getProfilePosition } from '$lib/helper'; 4 import PlainTextEditor from '$lib/components/PlainTextEditor.svelte'; 5 import MarkdownTextEditor from '$lib/components/MarkdownTextEditor.svelte'; 6 + import { Avatar, Button } from '@foxui/core'; 7 import { getIsMobile } from './context'; 8 import MadeWithBlento from './MadeWithBlento.svelte'; 9 + import { SelectThemePopover } from '$lib/components/select-theme'; 10 11 let { data = $bindable(), hideBlento = false }: { data: WebsiteData; hideBlento?: boolean } = 12 $props(); 13 14 let fileInput: HTMLInputElement; 15 let isHoveringAvatar = $state(false); ··· 44 fileInput.click(); 45 } 46 47 + let profilePosition = $derived(getProfilePosition(data)); 48 </script> 49 50 <div ··· 57 > 58 <div 59 class={[ 60 + 'flex flex-col gap-4 pt-16 pb-4', 61 profilePosition === 'side' && '@5xl/wrapper:h-screen @5xl/wrapper:pt-24' 62 ]} 63 > ··· 72 onmouseleave={() => (isHoveringAvatar = false)} 73 onclick={handleFileInputClick} 74 > 75 + <Avatar 76 + src={getAvatarUrl()} 77 + class={[ 78 + 'border-base-400 dark:border-base-800 size-32 shrink-0 rounded-full border object-cover', 79 + profilePosition === 'side' && '@5xl/wrapper:size-44' 80 + ]} 81 + /> 82 83 <!-- Hover overlay --> 84 <div ··· 138 /> 139 {/if} 140 </div> 141 142 {#if !hideBlento} 143 <MadeWithBlento class="hidden {profilePosition === 'side' && '@5xl/wrapper:block'}" />
+19 -35
src/lib/website/EditableWebsite.svelte
··· 15 savePage, 16 scrollToItem, 17 setPositionOfNewItem, 18 - validateLink 19 } from '../helper'; 20 import EditableProfile from './EditableProfile.svelte'; 21 import type { Item, WebsiteData } from '../types'; ··· 29 import BaseEditingCard from '../cards/BaseCard/BaseEditingCard.svelte'; 30 import Context from './Context.svelte'; 31 import Head from './Head.svelte'; 32 - import { compressImage } from '../helper'; 33 import Account from './Account.svelte'; 34 import EditBar from './EditBar.svelte'; 35 import SaveModal from './SaveModal.svelte'; 36 import FloatingEditButton from './FloatingEditButton.svelte'; 37 import { user } from '$lib/atproto'; 38 import { launchConfetti } from '@foxui/visual'; 39 40 let { 41 data ··· 45 46 // Check if floating login button will be visible (to hide MadeWithBlento) 47 const showLoginOnEditPage = $derived(!user.isInitializing && !user.isLoggedIn); 48 49 let imageDragOver = $state(false); 50 ··· 569 /> 570 571 <Head 572 - favicon={data.profile.avatar ?? null} 573 title={getName(data)} 574 image={'/' + data.handle + '/og.png'} 575 /> 576 577 <Account {data} /> ··· 584 Editing on mobile is not supported yet. Please use a desktop browser. 585 </div> 586 {/if} 587 588 {#if showingMobileView} 589 <div ··· 630 : '@5xl/wrapper:max-w-4xl' 631 ]} 632 > 633 - {#if getHideProfileSection(data)} 634 - <Button 635 - size="icon" 636 - variant="ghost" 637 - onclick={() => { 638 - data.publication.preferences ??= {}; 639 - data.publication.preferences.hideProfileSection = false; 640 - data = { ...data }; 641 - }} 642 - class="pointer-events-auto absolute top-2 left-2 z-20" 643 - > 644 - <svg 645 - xmlns="http://www.w3.org/2000/svg" 646 - fill="none" 647 - viewBox="0 0 24 24" 648 - stroke-width="1.5" 649 - stroke="currentColor" 650 - class="size-6" 651 - > 652 - <path 653 - stroke-linecap="round" 654 - stroke-linejoin="round" 655 - d="M2.036 12.322a1.012 1.012 0 0 1 0-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178Z" 656 - /> 657 - <path 658 - stroke-linecap="round" 659 - stroke-linejoin="round" 660 - d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" 661 - /> 662 - </svg> 663 - </Button> 664 - {/if} 665 <div class="pointer-events-none"></div> 666 <!-- svelte-ignore a11y_no_static_element_interactions --> 667 <div
··· 15 savePage, 16 scrollToItem, 17 setPositionOfNewItem, 18 + validateLink, 19 + getImage 20 } from '../helper'; 21 import EditableProfile from './EditableProfile.svelte'; 22 import type { Item, WebsiteData } from '../types'; ··· 30 import BaseEditingCard from '../cards/BaseCard/BaseEditingCard.svelte'; 31 import Context from './Context.svelte'; 32 import Head from './Head.svelte'; 33 import Account from './Account.svelte'; 34 + import { SelectThemePopover } from '$lib/components/select-theme'; 35 import EditBar from './EditBar.svelte'; 36 import SaveModal from './SaveModal.svelte'; 37 import FloatingEditButton from './FloatingEditButton.svelte'; 38 import { user } from '$lib/atproto'; 39 import { launchConfetti } from '@foxui/visual'; 40 + import Controls from './Controls.svelte'; 41 42 let { 43 data ··· 47 48 // Check if floating login button will be visible (to hide MadeWithBlento) 49 const showLoginOnEditPage = $derived(!user.isInitializing && !user.isLoggedIn); 50 + 51 + let accentColor = $derived(data.publication?.preferences?.accentColor ?? 'pink'); 52 + let baseColor = $derived(data.publication?.preferences?.baseColor ?? 'stone'); 53 + 54 + function updateTheme(newAccent: string, newBase: string) { 55 + data.publication.preferences ??= {}; 56 + data.publication.preferences.accentColor = newAccent; 57 + data.publication.preferences.baseColor = newBase; 58 + data = { ...data }; 59 + } 60 61 let imageDragOver = $state(false); 62 ··· 581 /> 582 583 <Head 584 + favicon={getImage(data.publication, data.did, 'icon') || data.profile.avatar} 585 title={getName(data)} 586 image={'/' + data.handle + '/og.png'} 587 + accentColor={data.publication?.preferences?.accentColor} 588 + baseColor={data.publication?.preferences?.baseColor} 589 /> 590 591 <Account {data} /> ··· 598 Editing on mobile is not supported yet. Please use a desktop browser. 599 </div> 600 {/if} 601 + 602 + <Controls bind:data /> 603 604 {#if showingMobileView} 605 <div ··· 646 : '@5xl/wrapper:max-w-4xl' 647 ]} 648 > 649 <div class="pointer-events-none"></div> 650 <!-- svelte-ignore a11y_no_static_element_interactions --> 651 <div
+3 -6
src/lib/website/EmptyState.svelte
··· 1 <script lang="ts"> 2 - import { login, user } from '$lib/atproto'; 3 import BaseCard from '$lib/cards/BaseCard/BaseCard.svelte'; 4 import Card from '$lib/cards/Card/Card.svelte'; 5 import type { Item, WebsiteData } from '$lib/types'; 6 - import type { ActorIdentifier } from '@atcute/lexicons'; 7 - import { Button } from '@foxui/core'; 8 9 let { data }: { data: WebsiteData } = $props(); 10 ··· 23 mobileW: 8, 24 mobileH: 3, 25 cardType: 'text', 26 - color: 'red', 27 cardData: { 28 text: `## No blento yet!`, 29 textAlign: 'center', ··· 34 // Bluesky social icon 35 items.push({ 36 id: 'empty-bluesky', 37 - x: 0, 38 - y: 2, 39 w: 2, 40 h: 2, 41 mobileX: 0,
··· 1 <script lang="ts"> 2 import BaseCard from '$lib/cards/BaseCard/BaseCard.svelte'; 3 import Card from '$lib/cards/Card/Card.svelte'; 4 import type { Item, WebsiteData } from '$lib/types'; 5 6 let { data }: { data: WebsiteData } = $props(); 7 ··· 20 mobileW: 8, 21 mobileH: 3, 22 cardType: 'text', 23 + color: 'cyan', 24 cardData: { 25 text: `## No blento yet!`, 26 textAlign: 'center', ··· 31 // Bluesky social icon 32 items.push({ 33 id: 'empty-bluesky', 34 + x: 6, 35 + y: 0, 36 w: 2, 37 h: 2, 38 mobileX: 0,
+10 -8
src/lib/website/FloatingEditButton.svelte
··· 6 import type { WebsiteData } from '$lib/types'; 7 import { page } from '$app/state'; 8 import type { ActorIdentifier } from '@atcute/lexicons'; 9 10 let { data }: { data: WebsiteData } = $props(); 11 ··· 19 const showEditBlentoButton = $derived( 20 isBlento && user.isLoggedIn && user.profile?.handle !== data.handle 21 ); 22 </script> 23 24 {#if isOwnPage && !isEditPage} ··· 43 </div> 44 {:else if showLoginOnEditPage} 45 <div class="fixed bottom-6 left-6 z-49"> 46 - <Button size="lg" onclick={() => login(data.handle as ActorIdentifier)}> 47 <svg 48 xmlns="http://www.w3.org/2000/svg" 49 fill="none" ··· 63 </div> 64 {:else if showLoginOnBlento} 65 <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 - /> 72 </div> 73 {:else if showEditBlentoButton} 74 <div class="fixed bottom-6 left-6 z-49"> 75 - <Button size="lg" href="/{env.PUBLIC_IS_SELFHOSTED ? '' : user.profile?.handle}/edit"> 76 <svg 77 xmlns="http://www.w3.org/2000/svg" 78 fill="none"
··· 6 import type { WebsiteData } from '$lib/types'; 7 import { page } from '$app/state'; 8 import type { ActorIdentifier } from '@atcute/lexicons'; 9 + import { loginModalState } from '$lib/atproto/UI/LoginModal.svelte'; 10 + import { getHandleOrDid } from '$lib/atproto/methods'; 11 12 let { data }: { data: WebsiteData } = $props(); 13 ··· 21 const showEditBlentoButton = $derived( 22 isBlento && user.isLoggedIn && user.profile?.handle !== data.handle 23 ); 24 + 25 + function getUserIdentifier(): ActorIdentifier { 26 + const id = user.profile ? getHandleOrDid(user.profile) : (data.did as ActorIdentifier); 27 + return id; 28 + } 29 </script> 30 31 {#if isOwnPage && !isEditPage} ··· 50 </div> 51 {:else if showLoginOnEditPage} 52 <div class="fixed bottom-6 left-6 z-49"> 53 + <Button size="lg" onclick={() => login(getUserIdentifier())}> 54 <svg 55 xmlns="http://www.w3.org/2000/svg" 56 fill="none" ··· 70 </div> 71 {:else if showLoginOnBlento} 72 <div class="fixed bottom-6 left-6 z-49"> 73 + <Button size="lg" onclick={() => loginModalState.show()}>Login</Button> 74 </div> 75 {:else if showEditBlentoButton} 76 <div class="fixed bottom-6 left-6 z-49"> 77 + <Button size="lg" href="/{env.PUBLIC_IS_SELFHOSTED ? '' : getUserIdentifier()}/edit"> 78 <svg 79 xmlns="http://www.w3.org/2000/svg" 80 fill="none"
+15 -3
src/lib/website/Head.svelte
··· 1 <script lang="ts"> 2 let { 3 favicon, 4 title, 5 image, 6 - description 7 - }: { favicon: string | null; title: string | null; image?: string; description?: string } = 8 - $props(); 9 </script> 10 11 <svelte:head> 12 {#if favicon}
··· 1 <script lang="ts"> 2 + import ThemeScript from './ThemeScript.svelte'; 3 + 4 let { 5 favicon, 6 title, 7 image, 8 + description, 9 + accentColor, 10 + baseColor 11 + }: { 12 + favicon: string | null; 13 + title: string | null; 14 + image?: string; 15 + description?: string; 16 + accentColor?: string; 17 + baseColor?: string; 18 + } = $props(); 19 </script> 20 + 21 + <ThemeScript {accentColor} {baseColor} /> 22 23 <svelte:head> 24 {#if favicon}
+9 -18
src/lib/website/Profile.svelte
··· 5 import { page } from '$app/state'; 6 import { qrOverlay } from '$lib/components/qr/qrOverlay.svelte'; 7 import MadeWithBlento from './MadeWithBlento.svelte'; 8 9 let { 10 data, ··· 33 > 34 <div 35 class={[ 36 - 'flex flex-col gap-4 pt-16 pb-8', 37 profilePosition === 'side' && '@5xl/wrapper:h-screen @5xl/wrapper:pt-24' 38 ]} 39 > ··· 46 } 47 }} 48 > 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} 66 </a> 67 68 <div class="text-4xl font-bold wrap-anywhere">
··· 5 import { page } from '$app/state'; 6 import { qrOverlay } from '$lib/components/qr/qrOverlay.svelte'; 7 import MadeWithBlento from './MadeWithBlento.svelte'; 8 + import { Avatar } from '@foxui/core'; 9 10 let { 11 data, ··· 34 > 35 <div 36 class={[ 37 + 'flex flex-col gap-4 pt-16 pb-4', 38 profilePosition === 'side' && '@5xl/wrapper:h-screen @5xl/wrapper:pt-24' 39 ]} 40 > ··· 47 } 48 }} 49 > 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 + /> 57 </a> 58 59 <div class="text-4xl font-bold wrap-anywhere">
+18
src/lib/website/ThemeScript.svelte
···
··· 1 + <script lang="ts"> 2 + let { 3 + accentColor = 'pink', 4 + baseColor = 'stone' 5 + }: { 6 + accentColor?: string; 7 + baseColor?: string; 8 + } = $props(); 9 + 10 + let script = $derived( 11 + `<script>(function(){document.documentElement.classList.add(${JSON.stringify(accentColor)},${JSON.stringify(baseColor)});})();<` + 12 + '/script>' 13 + ); 14 + </script> 15 + 16 + <svelte:head> 17 + {@html script} 18 + </svelte:head>
+6 -3
src/lib/website/Website.svelte
··· 6 getHideProfileSection, 7 getProfilePosition, 8 getName, 9 - sortItems 10 } from '../helper'; 11 import { innerWidth } from 'svelte/reactivity/window'; 12 import { setDidContext, setHandleContext, setIsMobile } from './context'; ··· 56 </script> 57 58 <Head 59 - favicon={data.profile.avatar ?? null} 60 title={getName(data)} 61 image={'/' + data.handle + '/og.png'} 62 description={getDescription(data)} 63 /> 64 65 <Context {data}> ··· 79 > 80 <div></div> 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} 83 <EmptyState {data} /> 84 {:else} 85 {#each data.cards.toSorted(sortItems) as item (item.id)}
··· 6 getHideProfileSection, 7 getProfilePosition, 8 getName, 9 + sortItems, 10 + getImage 11 } from '../helper'; 12 import { innerWidth } from 'svelte/reactivity/window'; 13 import { setDidContext, setHandleContext, setIsMobile } from './context'; ··· 57 </script> 58 59 <Head 60 + favicon={getImage(data.publication, data.did, 'icon') || data.profile.avatar} 61 title={getName(data)} 62 image={'/' + data.handle + '/og.png'} 63 description={getDescription(data)} 64 + accentColor={data.publication?.preferences?.accentColor} 65 + baseColor={data.publication?.preferences?.baseColor} 66 /> 67 68 <Context {data}> ··· 82 > 83 <div></div> 84 <div bind:this={container} class="@container/grid relative col-span-3 px-2 py-8 lg:px-8"> 85 + {#if data.cards.length === 0 && data.page === 'blento.self'} 86 <EmptyState {data} /> 87 {:else} 88 {#each data.cards.toSorted(sortItems) as item (item.id)}
+14 -5
src/lib/website/load.ts
··· 3 import type { Item, UserCache, WebsiteData } from '$lib/types'; 4 import { compactItems, fixAllCollisions } from '$lib/helper'; 5 import { error } from '@sveltejs/kit'; 6 - import type { Handle } from '@atcute/lexicons'; 7 8 const CURRENT_CACHE_VERSION = 1; 9 ··· 31 result.page = 'blento.' + page; 32 33 result.publication = (result.publications as Awaited<ReturnType<typeof listRecords>>).find( 34 - (v) => parseUri(v.uri).rkey === result.page 35 )?.value; 36 result.publication ??= { 37 name: result.profile?.displayName || result.profile?.handle, ··· 48 } 49 50 export async function loadData( 51 - handle: Handle, 52 cache: UserCache | undefined, 53 forceUpdate: boolean = false, 54 page: string = 'self' ··· 62 if (cachedResult) return cachedResult; 63 } 64 65 - const did = await resolveHandle({ handle }); 66 67 const cards = await listRecords({ did, collection: 'app.blento.card' }).catch(() => { 68 console.error('error getting records for collection app.blento.card'); ··· 139 140 parsedResult.publication = ( 141 parsedResult.publications as Awaited<ReturnType<typeof listRecords>> 142 - ).find((v) => parseUri(v.uri).rkey === parsedResult.page)?.value; 143 parsedResult.publication ??= { 144 name: profile?.displayName || profile?.handle, 145 description: profile?.description
··· 3 import type { Item, UserCache, WebsiteData } from '$lib/types'; 4 import { compactItems, fixAllCollisions } from '$lib/helper'; 5 import { error } from '@sveltejs/kit'; 6 + import type { ActorIdentifier, Did } from '@atcute/lexicons'; 7 + 8 + import { isDid, isHandle } from '@atcute/lexicons/syntax'; 9 10 const CURRENT_CACHE_VERSION = 1; 11 ··· 33 result.page = 'blento.' + page; 34 35 result.publication = (result.publications as Awaited<ReturnType<typeof listRecords>>).find( 36 + (v) => parseUri(v.uri)?.rkey === result.page 37 )?.value; 38 result.publication ??= { 39 name: result.profile?.displayName || result.profile?.handle, ··· 50 } 51 52 export async function loadData( 53 + handle: ActorIdentifier, 54 cache: UserCache | undefined, 55 forceUpdate: boolean = false, 56 page: string = 'self' ··· 64 if (cachedResult) return cachedResult; 65 } 66 67 + let did: Did | undefined = undefined; 68 + if (isHandle(handle)) { 69 + did = await resolveHandle({ handle }); 70 + } else if (isDid(handle)) { 71 + did = handle; 72 + } else { 73 + throw error(404); 74 + } 75 76 const cards = await listRecords({ did, collection: 'app.blento.card' }).catch(() => { 77 console.error('error getting records for collection app.blento.card'); ··· 148 149 parsedResult.publication = ( 150 parsedResult.publications as Awaited<ReturnType<typeof listRecords>> 151 + ).find((v) => parseUri(v.uri)?.rkey === parsedResult.page)?.value; 152 parsedResult.publication ??= { 153 name: profile?.displayName || profile?.handle, 154 description: profile?.description
+1 -1
src/params/handle.ts
··· 1 import type { ParamMatcher } from '@sveltejs/kit'; 2 3 export const match = ((param: string) => { 4 - return param.includes('.'); 5 }) satisfies ParamMatcher;
··· 1 import type { ParamMatcher } from '@sveltejs/kit'; 2 3 export const match = ((param: string) => { 4 + return param.includes('.') || param.startsWith('did:'); 5 }) satisfies ParamMatcher;
+2 -1
src/routes/(auth)/oauth/callback/+page.svelte
··· 1 <script lang="ts"> 2 import { goto } from '$app/navigation'; 3 import { user } from '$lib/atproto'; 4 5 $effect(() => { 6 console.log('hello', user); 7 if (user.profile) { 8 - goto('/' + user.profile.handle + '/edit', {}); 9 } 10 }); 11 </script>
··· 1 <script lang="ts"> 2 import { goto } from '$app/navigation'; 3 import { user } from '$lib/atproto'; 4 + import { getHandleOrDid } from '$lib/atproto/methods'; 5 6 $effect(() => { 7 console.log('hello', user); 8 if (user.profile) { 9 + goto('/' + getHandleOrDid(user.profile) + '/edit', {}); 10 } 11 }); 12 </script>
+3
src/routes/+layout.svelte
··· 7 import YoutubeVideoPlayer, { videoPlayer } from '$lib/components/YoutubeVideoPlayer.svelte'; 8 import { page } from '$app/state'; 9 import { goto } from '$app/navigation'; 10 11 let { children } = $props(); 12 ··· 34 {#if videoPlayer.id} 35 <YoutubeVideoPlayer /> 36 {/if}
··· 7 import YoutubeVideoPlayer, { videoPlayer } from '$lib/components/YoutubeVideoPlayer.svelte'; 8 import { page } from '$app/state'; 9 import { goto } from '$app/navigation'; 10 + import LoginModal from '$lib/atproto/UI/LoginModal.svelte'; 11 12 let { children } = $props(); 13 ··· 35 {#if videoPlayer.id} 36 <YoutubeVideoPlayer /> 37 {/if} 38 + 39 + <LoginModal />