simple atproto oauth for static svelte apps flo-bit.dev/svelte-atproto-client-oauth/

fixes, add image stuff

Florian 42f02ab8 9c93cbdc

+307 -107
+5
.claude/settings.local.json
··· 1 + { 2 + "permissions": { 3 + "allow": ["Bash(npx tsc:*)"] 4 + } 5 + }
+6 -3
README.md
··· 74 74 75 75 6. (optionally) set your base in `svelte.config.js` (e.g. for github pages: `base: '/your-repo-name/'`) while keeping it as `''` in development. 76 76 77 - 78 77 ```ts 79 78 const config = { 80 79 // ... ··· 93 92 94 93 7. setup the correct permissions (see below) 95 94 96 - 97 95 ## how to use 98 96 99 97 ### set permissions you request on sign-in in `$lib/atproto/settings.ts` (see commented out examples for more info) 100 98 101 99 - add collections to the collections array 102 - - add rpcs to rpcCalls 100 + - rpcs for authenticated proxied requests 103 101 - blobs for uploading blobs 104 102 105 103 ### change sign up pds ··· 152 150 } 153 151 }); 154 152 ``` 153 + 154 + ## todo 155 + 156 + - check if pds supports prompt=create 157 + - add lexicon stuff
+1 -1
src/lib/UI/Button.svelte
··· 10 10 11 11 <button 12 12 class={[ 13 - 'bg-rose-600 text-white hover:bg-rose-500 focus-visible:outline-rose-600', 13 + 'bg-accent-600 text-white hover:bg-accent-500 focus-visible:outline-accent-600', 14 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 15 className 16 16 ]}
+3 -3
src/lib/UI/HandleInput.svelte
··· 57 57 value = e.currentTarget.value; 58 58 search(e.currentTarget.value); 59 59 }} 60 - class="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 focus-within:outline-rose-600 dark:bg-white/5 dark:outline-white/10 dark:focus-within:outline-rose-500" 60 + class="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 focus-within:outline-accent-600 dark:bg-white/5 dark:outline-white/10 dark:focus-within:outline-accent-500" 61 61 placeholder="handle" 62 62 id="" 63 63 aria-label="enter your handle" 64 64 /> 65 65 <Combobox.Content 66 - class="z-100 max-h-[30dvh] w-full rounded-2xl border border-neutral-300 bg-neutral-50 shadow-lg" 66 + class="z-100 max-h-[30dvh] w-full rounded-2xl border border-base-300 bg-base-50 shadow-lg" 67 67 sideOffset={10} 68 68 align="start" 69 69 side="top" ··· 71 71 <Combobox.Viewport class="w-full p-1"> 72 72 {#each results as actor (actor.did)} 73 73 <Combobox.Item 74 - class="rounded-button my-0.5 flex w-full cursor-pointer items-center gap-2 rounded-xl p-2 px-2 data-highlighted:bg-rose-100" 74 + class="rounded-button my-0.5 flex w-full cursor-pointer items-center gap-2 rounded-xl p-2 px-2 data-highlighted:bg-accent-100" 75 75 value={actor.handle} 76 76 label={actor.handle} 77 77 >
+78 -65
src/lib/UI/LoginModal.svelte
··· 16 16 import { AppBskyActorDefs } from '@atcute/bluesky'; 17 17 import Avatar from './Avatar.svelte'; 18 18 19 - let { signIn = true, loginOnSelect = true }: { signIn?: boolean; loginOnSelect?: boolean } = 19 + let { signUp = true, loginOnSelect = true }: { signUp?: boolean; loginOnSelect?: boolean } = 20 20 $props(); 21 21 22 22 let value = $state(''); ··· 72 72 localStorage.setItem('recent-logins', JSON.stringify(recentLogins)); 73 73 } catch {} 74 74 } 75 + 76 + let recentLoginsView = $state(true); 77 + 78 + let showRecentLogins = $derived( 79 + Object.keys(recentLogins).length > 0 && !loadingLogin && !selectedActor && recentLoginsView 80 + ); 75 81 </script> 76 82 77 83 {#if loginModalState.visible} ··· 82 88 aria-modal="true" 83 89 > 84 90 <div 85 - class="fixed inset-0 bg-neutral-50/90 backdrop-blur-sm transition-opacity dark:bg-neutral-950/90" 91 + class="bg-base-50/90 dark:bg-base-950/90 fixed inset-0 backdrop-blur-sm transition-opacity" 86 92 onclick={() => (loginModalState.visible = false)} 87 93 aria-hidden="true" 88 94 ></div> ··· 92 98 class="flex min-h-full w-screen items-end justify-center p-4 text-center sm:items-center sm:p-0" 93 99 > 94 100 <div 95 - class="pointer-events-auto relative w-full transform overflow-hidden rounded-2xl border border-neutral-200 bg-neutral-100 px-4 pt-4 pb-4 text-left shadow-xl transition-all sm:my-8 sm:max-w-sm sm:p-6 dark:border-neutral-700 dark:bg-neutral-800" 101 + 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" 96 102 > 97 - <h3 class="font-semibold text-neutral-900 dark:text-neutral-100" id="modal-title"> 103 + <h3 class="text-base-900 dark:text-base-100 font-semibold" id="modal-title"> 98 104 Login with your internet handle 99 105 </h3> 100 106 101 - <div class="mt-2 mb-2 text-xs font-light text-neutral-800">e.g. your bluesky account</div> 107 + <div class="text-base-800 mt-2 mb-2 text-xs font-light">e.g. your bluesky account</div> 102 108 103 - {#if Object.keys(recentLogins).length > 0 && !loadingLogin && !selectedActor} 104 - <div class="mt-2 mb-2 text-sm font-medium">Recent logins</div> 105 - <div class="flex flex-col gap-2"> 106 - {#each Object.values(recentLogins) as recentLogin} 107 - <div class="group"> 108 - <div 109 - class="group-hover:bg-base-300 bg-base-200 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" 110 - > 111 - <div class="flex items-center gap-2"> 112 - <Avatar src={recentLogin.avatar} /> 113 - {recentLogin.handle} 114 - </div> 115 - <button 116 - class="z-20 cursor-pointer" 117 - onclick={() => { 118 - value = recentLogin.handle; 119 - selectedActor = recentLogin; 120 - onSubmit(); 121 - }} 109 + <form onsubmit={onSubmit} class="mt-2 flex w-full flex-col gap-2"> 110 + {#if showRecentLogins} 111 + <div class="mt-2 mb-2 text-sm font-medium">Recent logins</div> 112 + <div class="flex flex-col gap-2"> 113 + {#each Object.values(recentLogins).slice(0, 4) as recentLogin} 114 + <div class="group"> 115 + <div 116 + class="group-hover:bg-base-300 bg-base-200 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" 122 117 > 123 - <div class="absolute inset-0 h-full w-full"></div> 124 - <span class="sr-only">login</span> 125 - </button> 118 + <div class="flex items-center gap-2"> 119 + <Avatar src={recentLogin.avatar} /> 120 + {recentLogin.handle} 121 + </div> 122 + <button 123 + class="z-20 cursor-pointer" 124 + onclick={() => { 125 + value = recentLogin.handle; 126 + selectedActor = recentLogin; 127 + onSubmit(); 128 + }} 129 + > 130 + <div class="absolute inset-0 h-full w-full"></div> 131 + <span class="sr-only">login</span> 132 + </button> 126 133 127 - <button 128 - onclick={() => { 129 - removeRecentLogin(recentLogin.did); 130 - }} 131 - class="z-30 cursor-pointer rounded-full p-0.5" 132 - > 133 - <svg 134 - xmlns="http://www.w3.org/2000/svg" 135 - fill="none" 136 - viewBox="0 0 24 24" 137 - stroke-width="1.5" 138 - stroke="currentColor" 139 - class="size-3" 134 + <button 135 + onclick={() => { 136 + removeRecentLogin(recentLogin.did); 137 + }} 138 + class="z-30 cursor-pointer rounded-full p-0.5" 140 139 > 141 - <path 142 - stroke-linecap="round" 143 - stroke-linejoin="round" 144 - d="M6 18 18 6M6 6l12 12" 145 - /> 146 - </svg> 147 - <span class="sr-only">sign in with other account</span> 148 - </button> 140 + <svg 141 + xmlns="http://www.w3.org/2000/svg" 142 + fill="none" 143 + viewBox="0 0 24 24" 144 + stroke-width="1.5" 145 + stroke="currentColor" 146 + class="size-3" 147 + > 148 + <path 149 + stroke-linecap="round" 150 + stroke-linejoin="round" 151 + d="M6 18 18 6M6 6l12 12" 152 + /> 153 + </svg> 154 + <span class="sr-only">sign in with other account</span> 155 + </button> 156 + </div> 149 157 </div> 150 - </div> 151 - {/each} 152 - </div> 153 - 154 - <div class="mt-4 text-sm font-medium">Or new handle</div> 155 - {/if} 156 - 157 - <form onsubmit={onSubmit} class="mt-2 flex w-full flex-col gap-2"> 158 - {#if !selectedActor} 159 - <div class="w-full"> 158 + {/each} 159 + </div> 160 + {:else if !selectedActor} 161 + <div class="mt-4 w-full"> 160 162 <HandleInput 161 163 bind:value 162 164 onselected={(a) => { ··· 169 171 </div> 170 172 {:else} 171 173 <div 172 - class="bg-base-200 border-base-300 flex h-10 w-full items-center justify-between gap-2 rounded-full border px-2 font-semibold" 174 + class="bg-base-200 border-base-300 mt-4 flex h-10 w-full items-center justify-between gap-2 rounded-full border px-2 font-semibold" 173 175 > 174 176 <div class="flex items-center gap-2"> 175 177 <Avatar src={selectedActor.avatar} /> ··· 199 201 {/if} 200 202 201 203 {#if error} 202 - <p class="text-sm font-semibold text-rose-500">{error}</p> 204 + <p class="text-accent-500 text-sm font-semibold">{error}</p> 203 205 {/if} 204 206 205 207 <div class="mt-4"> 206 - <Button type="submit" disabled={loadingLogin} class="w-full" 207 - >{loadingLogin ? 'Loading...' : 'Login'}</Button 208 - > 208 + {#if showRecentLogins} 209 + <div class="mt-2 mb-4 text-sm font-medium">Or login with new handle</div> 210 + 211 + <Button 212 + onclick={() => { 213 + recentLoginsView = false; 214 + }} 215 + class="w-full">Login with new handle</Button 216 + > 217 + {:else} 218 + <Button type="submit" disabled={loadingLogin} class="w-full" 219 + >{loadingLogin ? 'Loading...' : 'Login'}</Button 220 + > 221 + {/if} 209 222 </div> 210 223 211 - {#if signIn} 212 - <div class="mt-4 border-t border-neutral-200 pt-4 text-sm leading-7 text-neutral-800"> 224 + {#if signUp} 225 + <div class="border-base-200 text-base-800 mt-4 border-t pt-4 text-sm leading-7"> 213 226 Don't have an account? 214 227 <div class="mt-3"> 215 228 <SecondaryButton
+1 -1
src/lib/UI/SecondaryButton.svelte
··· 10 10 11 11 <button 12 12 class={[ 13 - 'bg-neutral-300 text-black transition-colors duration-100 hover:bg-neutral-200 focus-visible:outline-neutral-600', 13 + 'bg-base-300 text-black transition-colors duration-100 hover:bg-base-200 focus-visible:outline-base-600', 14 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 15 className 16 16 ]}
+2 -1
src/lib/atproto/auth.svelte.ts
··· 7 7 deleteStoredSession 8 8 } from '@atcute/oauth-browser-client'; 9 9 import { AppBskyActorDefs } from '@atcute/bluesky'; 10 - import type { ActorIdentifier, Did } from '@atcute/lexicons'; 11 10 import { 12 11 CompositeDidDocumentResolver, 13 12 CompositeHandleResolver, ··· 25 24 import { metadata } from './metadata'; 26 25 import { getDetailedProfile } from './methods'; 27 26 import { signUpPDS } from './settings'; 27 + 28 + import type { ActorIdentifier, Did } from '@atcute/lexicons'; 28 29 29 30 export const user = $state({ 30 31 agent: null as OAuthUserAgent | null,
+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 + }
+1 -1
src/lib/atproto/index.ts
··· 14 14 uploadBlob, 15 15 describeRepo, 16 16 getBlobURL, 17 - getImageBlobUrl, 17 + getCDNImageBlobUrl as getImageBlobUrl, 18 18 searchActorsTypeahead 19 19 } from './methods';
+8 -7
src/lib/atproto/metadata.ts
··· 1 1 import { resolve } from '$app/paths'; 2 - import { blobs, collections, rpcCalls, SITE } from './settings'; 2 + import { permissions, SITE } from './settings'; 3 3 4 4 function constructScope() { 5 - const repos = collections.map((collection) => 'repo:' + collection).join(' '); 5 + const repos = permissions.collections.map((collection) => 'repo:' + collection).join(' '); 6 6 7 7 let rpcs = ''; 8 - for (const [key, value] of Object.entries(rpcCalls)) { 8 + for (const [key, value] of Object.entries(permissions.rpc ?? {})) { 9 9 if (Array.isArray(value)) { 10 10 rpcs += value.map((lxm) => 'rpc?lxm=' + lxm + '&aud=' + key).join(' '); 11 11 } else { 12 12 rpcs += 'rpc?lxm=' + value + '&aud=' + key; 13 13 } 14 14 } 15 + 15 16 let blobScope: string | undefined = undefined; 16 - if (Array.isArray(blobs)) { 17 - blobScope = 'blob?' + blobs.map((b) => 'accept=' + b).join('&'); 18 - } else if (blobs) { 19 - blobScope = 'blob:' + blobs; 17 + if (Array.isArray(permissions.blobs)) { 18 + blobScope = 'blob?' + permissions.blobs.map((b) => 'accept=' + b).join('&'); 19 + } else if (permissions.blobs) { 20 + blobScope = 'blob:' + permissions.blobs; 20 21 } 21 22 22 23 const scope = ['atproto', repos, rpcs, blobScope].filter((v) => v?.trim()).join(' ');
+17 -11
src/lib/atproto/methods.ts
··· 1 1 import type { Did, Handle } from '@atcute/lexicons'; 2 2 import { user } from './auth.svelte'; 3 + import type { AllowedCollection } from './settings'; 3 4 import { 4 5 CompositeDidDocumentResolver, 5 6 CompositeHandleResolver, ··· 16 17 export function parseUri(uri: string) { 17 18 const [did, collection, rkey] = uri.replace('at://', '').split('/'); 18 19 return { did, collection, rkey } as { 19 - collection: `${string}.${string}.${string}`; 20 + collection: Collection; 20 21 rkey: string; 21 22 did: string; 22 23 }; ··· 85 86 did, 86 87 collection, 87 88 cursor, 88 - limit = 0, 89 + limit = 100, 89 90 client 90 91 }: { 91 92 did?: Did; ··· 112 113 params: { 113 114 repo: did, 114 115 collection, 115 - limit: limit || 100, 116 + limit: !limit || limit > 100 ? 100 : limit, 116 117 cursor: currentCursor 117 118 } 118 119 }); ··· 131 132 export async function getRecord({ 132 133 did, 133 134 collection, 134 - rkey, 135 + rkey = 'self', 135 136 client 136 137 }: { 137 138 did?: Did; ··· 140 141 client?: Client; 141 142 }) { 142 143 did ??= user.did; 143 - rkey ??= 'self'; 144 144 145 145 if (!collection) { 146 146 throw new Error('Missing parameters for getRecord'); ··· 164 164 165 165 export async function putRecord({ 166 166 collection, 167 - rkey, 167 + rkey = 'self', 168 168 record 169 169 }: { 170 - collection: Collection; 171 - rkey: string; 170 + collection: AllowedCollection; 171 + rkey?: string; 172 172 record: Record<string, unknown>; 173 173 }) { 174 174 if (!user.client || !user.did) throw new Error('No rpc or did'); ··· 187 187 return response; 188 188 } 189 189 190 - export async function deleteRecord({ collection, rkey }: { collection: Collection; rkey: string }) { 190 + export async function deleteRecord({ 191 + collection, 192 + rkey = 'self' 193 + }: { 194 + collection: AllowedCollection; 195 + rkey: string; 196 + }) { 191 197 if (!user.client || !user.did) throw new Error('No profile or rpc or did'); 192 198 193 199 const response = await user.client.post('com.atproto.repo.deleteRecord', { ··· 258 264 return `${pds}/xrpc/com.atproto.sync.getBlob?did=${did}&cid=${blob.ref.$link}`; 259 265 } 260 266 261 - export function getImageBlobUrl({ 267 + export function getCDNImageBlobUrl({ 262 268 did, 263 269 blob 264 270 }: { ··· 270 276 }; 271 277 }; 272 278 }) { 273 - return `https://cdn.bsky.app/img/feed_thumbnail/plain/${did}/${blob.ref.$link}@jpeg`; 279 + return `https://cdn.bsky.app/img/feed_thumbnail/plain/${did}/${blob.ref.$link}@webp`; 274 280 } 275 281 276 282 export async function searchActorsTypeahead(
+27 -13
src/lib/atproto/settings.ts
··· 1 1 export const SITE = 'https://flo-bit.dev'; 2 2 3 - // optionally add action=create/update/delete to only allow those actions for a collection 4 - export const collections: string[] = ['xyz.statusphere.status']; 5 - // example: only allow create and delete 6 - // export const collections: string[] = ['xyz.statusphere.status?action=create&action=update']; 3 + type Permissions = { 4 + collections: readonly string[]; 5 + rpc: Record<string, string | string[]>; 6 + blobs: readonly string[]; 7 + }; 7 8 8 - export const rpcCalls: Record<string, string | string[]> = { 9 + export const permissions = { 10 + // collections you can create/delete/update 11 + 12 + // example: only allow create and delete 13 + // collections: ['xyz.statusphere.status?action=create&action=update'], 14 + collections: ['xyz.statusphere.status'], 15 + 16 + // what types of authenticated proxied requests you can make to services 17 + 9 18 // example: allow authenticated proxying to bsky appview to get a users liked posts 10 - //'did:web:api.bsky.app#bsky_appview': ['app.bsky.feed.getActorLikes'] 11 - // https://docs.bsky.app/docs/api/app-bsky-feed-get-actor-likes 12 - }; 19 + //rpc: {'did:web:api.bsky.app#bsky_appview': ['app.bsky.feed.getActorLikes']} 20 + rpc: {}, 13 21 14 - export const blobs = [] as string | string[] | undefined; 22 + // what types of blobs you can upload to a users PDS 15 23 16 - // example: allowing video and html uploads 17 - // export const blobs = ['video/*', 'text/html'] as string | string[] | undefined; 24 + // example: allowing video and html uploads 25 + // blobs: ['video/*', 'text/html'] 26 + // example: allowing all blob types 27 + // blobs: ['*/*'] 28 + blobs: ['hello'] 29 + } as const satisfies Permissions; 18 30 19 - // example: allowing all blob types 20 - // export const blobs = ['*/*'] as string | string[] | undefined; 31 + // Extract base collection name (before any query params) 32 + type ExtractCollectionBase<T extends string> = T extends `${infer Base}?${string}` ? Base : T; 33 + 34 + export type AllowedCollection = ExtractCollectionBase<(typeof permissions.collections)[number]>; 21 35 22 36 // which PDS to use for signup 23 37 // ATTENTION: pds.rip is only for development, all accounts get deleted automatically after a week
+6 -1
src/routes/+page.svelte
··· 1 1 <script lang="ts"> 2 - import { user, logout } from '$lib/atproto'; 2 + import { user, logout, putRecord } from '$lib/atproto'; 3 3 import Avatar from '$lib/UI/Avatar.svelte'; 4 4 import Button from '$lib/UI/Button.svelte'; 5 5 import { loginModalState } from '$lib/UI/LoginModal.svelte'; 6 + import { onMount } from 'svelte'; 7 + 8 + onMount(() => { 9 + putRecord({ collection: 'xyz.statusphere.status', record: {} }); 10 + }); 6 11 </script> 7 12 8 13 <div class="mx-auto my-4 max-w-3xl px-4 md:my-32">