atprotocol stickers
0
fork

Configure Feed

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

yeetus the foetus

goose.art 965f351f

+3561
+29
package.json
··· 1 + { 2 + "name": "atstickies", 3 + "version": "0.0.1", 4 + "private": true, 5 + "scripts": { 6 + "dev": "vite dev", 7 + "build": "vite build && node -e \"const fs=require('fs'); fs.mkdirSync('build/oauth/callback',{recursive:true}); fs.copyFileSync('build/index.html','build/oauth/callback/index.html');\"", 8 + "preview": "vite preview", 9 + "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", 10 + "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", 11 + "deploy": "vite build && wisp-cli deploy goose.art --path ./build --site stickies --spa" 12 + }, 13 + "devDependencies": { 14 + "@sveltejs/adapter-static": "^3.0.8", 15 + "@sveltejs/kit": "^2.16.0", 16 + "@sveltejs/vite-plugin-svelte": "^5.0.0", 17 + "svelte": "^5.0.0", 18 + "svelte-check": "^4.0.0", 19 + "typescript": "^5.0.0", 20 + "vite": "^6.0.0" 21 + }, 22 + "dependencies": { 23 + "@atcute/client": "^2.0.0", 24 + "@atcute/oauth-browser-client": "^3.0.0", 25 + "animejs": "^4.0.0", 26 + "svelte-easy-crop": "^5.0.0" 27 + }, 28 + "type": "module" 29 + }
+12
src/app.html
··· 1 + <!doctype html> 2 + <html lang="en"> 3 + <head> 4 + <meta charset="utf-8" /> 5 + <link rel="icon" href="%sveltekit.assets%/favicon.png" /> 6 + <meta name="viewport" content="width=device-width, initial-scale=1" /> 7 + %sveltekit.head% 8 + </head> 9 + <body data-sveltekit-preload-data="hover"> 10 + <div style="display: contents">%sveltekit.body%</div> 11 + </body> 12 + </html>
+240
src/lib/atproto.ts
··· 1 + import type { OAuthUserAgent } from '@atcute/oauth-browser-client'; 2 + import type { StickerRecord } from './types.js'; 3 + 4 + export interface ListRecordsResponse { 5 + records: StickerRecord[]; 6 + cursor?: string; 7 + } 8 + 9 + /** 10 + * List boo.sky.sticker records from any repo/PDS 11 + */ 12 + export async function listStickers( 13 + pds: string, 14 + did: string, 15 + cursor?: string 16 + ): Promise<ListRecordsResponse> { 17 + const url = new URL(`${pds}/xrpc/com.atproto.repo.listRecords`); 18 + url.searchParams.set('repo', did); 19 + url.searchParams.set('collection', 'boo.sky.sticker'); 20 + url.searchParams.set('limit', '50'); 21 + if (cursor) url.searchParams.set('cursor', cursor); 22 + 23 + const res = await fetch(url.toString()); 24 + if (!res.ok) { 25 + throw new Error(`Failed to list stickers: ${res.status}`); 26 + } 27 + const data = await res.json(); 28 + return { 29 + records: (data.records ?? []) as StickerRecord[], 30 + cursor: data.cursor 31 + }; 32 + } 33 + 34 + /** 35 + * Resolve a handle to DID using com.atproto.identity.resolveHandle 36 + */ 37 + export async function resolveHandle(handle: string): Promise<string> { 38 + const url = `https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle?handle=${encodeURIComponent(handle)}`; 39 + const res = await fetch(url); 40 + if (!res.ok) throw new Error(`Failed to resolve handle: ${res.status}`); 41 + const data = await res.json(); 42 + return data.did as string; 43 + } 44 + 45 + /** 46 + * Fetch and parse a DID document, returning PDS endpoint and handle. 47 + */ 48 + async function fetchDidDoc(did: string): Promise<{ pds: string; handle: string | null }> { 49 + let docUrl: string; 50 + if (did.startsWith('did:plc:')) { 51 + docUrl = `https://plc.directory/${did}`; 52 + } else if (did.startsWith('did:web:')) { 53 + const host = did.slice('did:web:'.length); 54 + docUrl = `https://${host}/.well-known/did.json`; 55 + } else { 56 + throw new Error(`Unsupported DID method: ${did}`); 57 + } 58 + 59 + const res = await fetch(docUrl); 60 + if (!res.ok) throw new Error(`Failed to fetch DID doc: ${res.status}`); 61 + const doc = await res.json(); 62 + 63 + const pdsService = (doc.service ?? []).find( 64 + (s: { type: string; serviceEndpoint: string }) => s.type === 'AtprotoPersonalDataServer' 65 + ); 66 + if (!pdsService) throw new Error(`No PDS found in DID document for ${did}`); 67 + 68 + const alsoKnownAs: string[] = doc.alsoKnownAs ?? []; 69 + const atUri = alsoKnownAs.find((a: string) => a.startsWith('at://')); 70 + const handle = atUri ? atUri.slice('at://'.length) : null; 71 + 72 + return { pds: pdsService.serviceEndpoint as string, handle }; 73 + } 74 + 75 + /** 76 + * Get PDS endpoint from DID document 77 + */ 78 + export async function getPdsFromDid(did: string): Promise<string> { 79 + const { pds } = await fetchDidDoc(did); 80 + return pds; 81 + } 82 + 83 + /** 84 + * Resolve a DID to its atproto handle via the DID document alsoKnownAs field. 85 + * Returns null if no handle is found. 86 + */ 87 + export async function getHandleFromDid(did: string): Promise<string | null> { 88 + try { 89 + const { handle } = await fetchDidDoc(did); 90 + return handle; 91 + } catch { 92 + return null; 93 + } 94 + } 95 + 96 + /** 97 + * Upload a blob to the PDS using an authenticated agent 98 + */ 99 + export async function uploadBlob( 100 + agent: OAuthUserAgent, 101 + pds: string, 102 + data: Blob, 103 + mimeType: string 104 + ): Promise<{ ref: { $link: string }; mimeType: string; size: number }> { 105 + const response = await agent.handle('/xrpc/com.atproto.repo.uploadBlob', { 106 + method: 'POST', 107 + headers: { 'Content-Type': mimeType }, 108 + body: data 109 + }); 110 + 111 + if (!response.ok) { 112 + const text = await response.text(); 113 + throw new Error(`Upload failed: ${response.status} ${text}`); 114 + } 115 + 116 + const result = await response.json(); 117 + return result.blob; 118 + } 119 + 120 + /** 121 + * Create a boo.sky.sticker record 122 + */ 123 + export async function createStickerRecord( 124 + agent: OAuthUserAgent, 125 + pds: string, 126 + did: string, 127 + record: { 128 + shortname: string; 129 + image: { ref: { $link: string }; mimeType: string; size: number }; 130 + maskPath?: string; 131 + imageWidth?: number; 132 + imageHeight?: number; 133 + borderColor?: string; 134 + borderThickness?: number; 135 + } 136 + ): Promise<{ uri: string; cid: string }> { 137 + const body = { 138 + repo: did, 139 + collection: 'boo.sky.sticker', 140 + record: { 141 + $type: 'boo.sky.sticker', 142 + shortname: record.shortname, 143 + image: record.image, 144 + ...(record.maskPath ? { maskPath: record.maskPath } : {}), 145 + ...(record.imageWidth != null ? { imageWidth: record.imageWidth } : {}), 146 + ...(record.imageHeight != null ? { imageHeight: record.imageHeight } : {}), 147 + ...(record.borderColor ? { borderColor: record.borderColor } : {}), 148 + ...(record.borderThickness != null ? { borderThickness: record.borderThickness } : {}), 149 + createdAt: new Date().toISOString() 150 + } 151 + }; 152 + 153 + const response = await agent.handle('/xrpc/com.atproto.repo.createRecord', { 154 + method: 'POST', 155 + headers: { 'Content-Type': 'application/json' }, 156 + body: JSON.stringify(body) 157 + }); 158 + 159 + if (!response.ok) { 160 + const text = await response.text(); 161 + throw new Error(`Create record failed: ${response.status} ${text}`); 162 + } 163 + 164 + return response.json(); 165 + } 166 + 167 + /** 168 + * Update (replace) an existing boo.sky.sticker record 169 + */ 170 + export async function putStickerRecord( 171 + agent: OAuthUserAgent, 172 + did: string, 173 + rkey: string, 174 + record: { 175 + shortname: string; 176 + image: { ref: { $link: string }; mimeType: string; size?: number }; 177 + maskPath?: string; 178 + imageWidth?: number; 179 + imageHeight?: number; 180 + borderColor?: string; 181 + borderThickness?: number; 182 + createdAt: string; 183 + } 184 + ): Promise<void> { 185 + const body = { 186 + repo: did, 187 + collection: 'boo.sky.sticker', 188 + rkey, 189 + record: { 190 + $type: 'boo.sky.sticker', 191 + shortname: record.shortname, 192 + image: record.image, 193 + ...(record.maskPath ? { maskPath: record.maskPath } : {}), 194 + ...(record.imageWidth != null ? { imageWidth: record.imageWidth } : {}), 195 + ...(record.imageHeight != null ? { imageHeight: record.imageHeight } : {}), 196 + ...(record.borderColor ? { borderColor: record.borderColor } : {}), 197 + ...(record.borderThickness != null && record.borderThickness > 0 198 + ? { borderThickness: record.borderThickness } 199 + : {}), 200 + createdAt: record.createdAt 201 + } 202 + }; 203 + 204 + const response = await agent.handle('/xrpc/com.atproto.repo.putRecord', { 205 + method: 'POST', 206 + headers: { 'Content-Type': 'application/json' }, 207 + body: JSON.stringify(body) 208 + }); 209 + 210 + if (!response.ok) { 211 + const text = await response.text(); 212 + throw new Error(`Put record failed: ${response.status} ${text}`); 213 + } 214 + } 215 + 216 + /** 217 + * Delete a boo.sky.sticker record 218 + */ 219 + export async function deleteStickerRecord( 220 + agent: OAuthUserAgent, 221 + did: string, 222 + rkey: string 223 + ): Promise<void> { 224 + const body = { 225 + repo: did, 226 + collection: 'boo.sky.sticker', 227 + rkey 228 + }; 229 + 230 + const response = await agent.handle('/xrpc/com.atproto.repo.deleteRecord', { 231 + method: 'POST', 232 + headers: { 'Content-Type': 'application/json' }, 233 + body: JSON.stringify(body) 234 + }); 235 + 236 + if (!response.ok) { 237 + const text = await response.text(); 238 + throw new Error(`Delete record failed: ${response.status} ${text}`); 239 + } 240 + }
+90
src/lib/auth.svelte.ts
··· 1 + import { OAuthUserAgent, getSession, deleteStoredSession, listStoredSessions } from '@atcute/oauth-browser-client'; 2 + import type { Session } from '@atcute/oauth-browser-client'; 3 + import { getHandleFromDid } from './atproto.js'; 4 + 5 + export interface AuthState { 6 + agent: OAuthUserAgent | null; 7 + did: string | null; 8 + handle: string | null; 9 + pds: string | null; 10 + loading: boolean; 11 + error: string | null; 12 + } 13 + 14 + function createAuth() { 15 + let state = $state<AuthState>({ 16 + agent: null, 17 + did: null, 18 + handle: null, 19 + pds: null, 20 + loading: false, 21 + error: null 22 + }); 23 + 24 + return { 25 + get agent() { return state.agent; }, 26 + get did() { return state.did; }, 27 + get handle() { return state.handle; }, 28 + get pds() { return state.pds; }, 29 + get loading() { return state.loading; }, 30 + get error() { return state.error; }, 31 + get isLoggedIn() { return state.agent !== null; }, 32 + 33 + async tryRestoreSession() { 34 + state.loading = true; 35 + state.error = null; 36 + try { 37 + const sessions = listStoredSessions(); 38 + if (sessions.length > 0) { 39 + const did = sessions[0]; 40 + const session = await getSession(did, { allowStale: true }); 41 + const agent = new OAuthUserAgent(session); 42 + state.agent = agent; 43 + state.did = session.info.sub; 44 + state.pds = session.info.aud; 45 + // Resolve handle in the background — don't block session restore 46 + getHandleFromDid(session.info.sub).then((h) => { 47 + if (h) state.handle = h; 48 + }).catch(() => {}); 49 + } 50 + } catch (_) { 51 + state.agent = null; 52 + state.did = null; 53 + state.handle = null; 54 + state.pds = null; 55 + } finally { 56 + state.loading = false; 57 + } 58 + }, 59 + 60 + setFromSession(session: Session) { 61 + const agent = new OAuthUserAgent(session); 62 + state.agent = agent; 63 + state.did = session.info.sub; 64 + state.pds = session.info.aud; 65 + }, 66 + 67 + setHandle(handle: string) { 68 + state.handle = handle; 69 + }, 70 + 71 + async signOut() { 72 + if (state.agent) { 73 + try { 74 + await state.agent.signOut(); 75 + } catch (_) {} 76 + } 77 + if (state.did) { 78 + try { 79 + deleteStoredSession(state.did as `did:${string}:${string}`); 80 + } catch (_) {} 81 + } 82 + state.agent = null; 83 + state.did = null; 84 + state.handle = null; 85 + state.pds = null; 86 + } 87 + }; 88 + } 89 + 90 + export const auth = createAuth();
+322
src/lib/components/DraggableSticker.svelte
··· 1 + <script module lang="ts"> 2 + // Shared across all instances so the last-touched sticker always stays on top 3 + let topZ = 10; 4 + </script> 5 + 6 + <script lang="ts"> 7 + import { onMount } from 'svelte'; 8 + import { getBlobUrl } from '$lib/types.js'; 9 + import type { Draggable } from 'animejs/draggable'; 10 + 11 + interface Props { 12 + shortname: string; 13 + imageCid: string; 14 + did: string; 15 + pds: string; 16 + maskPath?: string; 17 + imageWidth?: number; 18 + imageHeight?: number; 19 + borderColor?: string; 20 + borderThickness?: number; 21 + showDelete?: boolean; 22 + ondelete?: () => void; 23 + // Absolute initial placement (for wall / preview stage) 24 + initialTop?: number; 25 + initialLeft?: number; 26 + initialRotate?: number; 27 + // Reactive display transforms (preview sliders) 28 + rotate?: number; 29 + scale?: number; 30 + // Largest side in display px; defaults to 160 (wall size) 31 + maxSize?: number; 32 + // Drag container; defaults to document.documentElement (full page) 33 + container?: Element | null; 34 + // Callbacks 35 + onDragStart?: () => void; 36 + onDragRelease?: () => void; 37 + } 38 + 39 + let { 40 + shortname, 41 + imageCid, 42 + did, 43 + pds, 44 + maskPath, 45 + imageWidth, 46 + imageHeight, 47 + borderColor = '', 48 + borderThickness = 0, 49 + showDelete = false, 50 + ondelete, 51 + initialTop, 52 + initialLeft, 53 + initialRotate = 0, 54 + rotate = 0, 55 + scale = 100, 56 + maxSize = 160, 57 + container: dragContainer, 58 + onDragStart, 59 + onDragRelease 60 + }: Props = $props(); 61 + 62 + let outerEl: HTMLElement; 63 + let imgLoaded = $state(false); 64 + let imgError = $state(false); 65 + 66 + const blobUrl = $derived(getBlobUrl(pds, did, imageCid)); 67 + const clipId = $derived(`clip-${imageCid.replace(/[^a-zA-Z0-9]/g, '')}`); 68 + 69 + const MIN = 40; 70 + const svgW = $derived( 71 + imageWidth && imageHeight 72 + ? Math.max(MIN, Math.round(imageWidth * Math.min(maxSize / imageWidth, maxSize / imageHeight, 1))) 73 + : 0 74 + ); 75 + const svgH = $derived( 76 + imageWidth && imageHeight 77 + ? Math.max(MIN, Math.round(imageHeight * Math.min(maxSize / imageWidth, maxSize / imageHeight, 1))) 78 + : 0 79 + ); 80 + 81 + const isSvgSticker = $derived(!!(maskPath && imageWidth && imageHeight)); 82 + 83 + onMount(async () => { 84 + const [{ createDraggable }, { animate, spring }] = await Promise.all([ 85 + import('animejs/draggable'), 86 + import('animejs') 87 + ]); 88 + 89 + createDraggable(outerEl, { 90 + container: dragContainer !== undefined ? dragContainer : document.documentElement, 91 + releaseContainerFriction: 1, 92 + velocityMultiplier: 0, 93 + onGrab(self: Draggable) { 94 + self.$target.style.zIndex = String(++topZ); 95 + const wrap = self.$target.querySelector<HTMLElement>('.ds-wrap'); 96 + const img = self.$target.querySelector<HTMLElement>('.ds-svg, .ds-img'); 97 + if (img) img.style.filter = 'drop-shadow(4px 14px 20px rgba(0,0,0,0.35))'; 98 + if (wrap) animate(wrap, { scale: 1.08, translateY: -10, duration: 180, ease: 'out(3)' }); 99 + onDragStart?.(); 100 + }, 101 + onRelease(self: Draggable) { 102 + const wrap = self.$target.querySelector<HTMLElement>('.ds-wrap'); 103 + const img = self.$target.querySelector<HTMLElement>('.ds-svg, .ds-img'); 104 + if (img) img.style.filter = ''; 105 + if (wrap) animate(wrap, { scale: 1, translateY: 0, ease: spring({ stiffness: 300, damping: 25 }) }); 106 + onDragRelease?.(); 107 + } 108 + }); 109 + }); 110 + </script> 111 + 112 + <div 113 + class="ds-outer" 114 + bind:this={outerEl} 115 + data-did={did} 116 + data-shortname={shortname} 117 + style:position={initialTop !== undefined || initialLeft !== undefined ? 'absolute' : undefined} 118 + style:top={initialTop !== undefined ? `${initialTop}px` : undefined} 119 + style:left={initialLeft !== undefined ? `${initialLeft}px` : undefined} 120 + style:transform={initialRotate ? `rotate(${initialRotate}deg)` : undefined} 121 + style:transform-origin="center top" 122 + > 123 + <div class="ds-wrap"> 124 + <!-- Reactive rotate/scale from sliders sits on a separate inner layer so it doesn't 125 + conflict with animejs's translateX/Y on the outer element --> 126 + <div 127 + class="ds-transform" 128 + style:transform="rotate({rotate}deg) scale({scale / 100})" 129 + style:transform-origin="center center" 130 + > 131 + {#if isSvgSticker} 132 + {#if !imgError} 133 + <svg 134 + class="ds-svg" 135 + class:loaded={imgLoaded} 136 + width={svgW} 137 + height={svgH} 138 + viewBox="0 0 {imageWidth} {imageHeight}" 139 + style="overflow: visible; display: block;" 140 + > 141 + <defs> 142 + <clipPath id={clipId}> 143 + <path d={maskPath} /> 144 + </clipPath> 145 + </defs> 146 + {#if borderColor && borderThickness} 147 + <path 148 + d={maskPath} 149 + fill="none" 150 + stroke={borderColor} 151 + stroke-width={borderThickness * 2} 152 + stroke-linejoin="round" 153 + stroke-linecap="round" 154 + /> 155 + {/if} 156 + <image 157 + href={blobUrl} 158 + x="0" 159 + y="0" 160 + width={imageWidth} 161 + height={imageHeight} 162 + clip-path="url(#{clipId})" 163 + preserveAspectRatio="none" 164 + onload={() => (imgLoaded = true)} 165 + onerror={() => (imgError = true)} 166 + /> 167 + </svg> 168 + {:else} 169 + <div class="ds-error">🖼</div> 170 + {/if} 171 + {:else} 172 + <div class="ds-legacy" class:loaded={imgLoaded}> 173 + {#if !imgError} 174 + <img 175 + class="ds-img" 176 + src={blobUrl} 177 + alt={shortname} 178 + onload={() => (imgLoaded = true)} 179 + onerror={() => (imgError = true)} 180 + draggable="false" 181 + /> 182 + {:else} 183 + <div class="ds-error">🖼</div> 184 + {/if} 185 + </div> 186 + {/if} 187 + </div> 188 + 189 + <div class="ds-label">{shortname}</div> 190 + 191 + {#if showDelete} 192 + <button class="ds-delete" onclick={ondelete} title="delete sticker">✕</button> 193 + {/if} 194 + </div> 195 + </div> 196 + 197 + <style> 198 + .ds-outer { 199 + display: inline-block; 200 + cursor: grab; 201 + user-select: none; 202 + } 203 + 204 + .ds-outer:active { 205 + cursor: grabbing; 206 + } 207 + 208 + .ds-wrap { 209 + position: relative; 210 + display: inline-flex; 211 + flex-direction: column; 212 + align-items: center; 213 + } 214 + 215 + /* SVG sticker */ 216 + .ds-svg { 217 + opacity: 0; 218 + transition: opacity 0.2s ease; 219 + filter: drop-shadow(2px 4px 8px rgba(0, 0, 0, 0.2)); 220 + display: block; 221 + } 222 + 223 + .ds-svg.loaded { 224 + opacity: 1; 225 + } 226 + 227 + /* Legacy PNG sticker */ 228 + .ds-legacy { 229 + background: white; 230 + box-shadow: 231 + 2px 3px 8px rgba(0, 0, 0, 0.15), 232 + 0 1px 2px rgba(0, 0, 0, 0.1); 233 + display: flex; 234 + align-items: center; 235 + justify-content: center; 236 + overflow: hidden; 237 + border-radius: 1px; 238 + opacity: 0; 239 + position: relative; 240 + transition: opacity 0.2s ease; 241 + } 242 + 243 + .ds-legacy.loaded { 244 + opacity: 1; 245 + } 246 + 247 + /* Folded corner peel for legacy stickers */ 248 + .ds-legacy::after { 249 + content: ''; 250 + position: absolute; 251 + bottom: 0; 252 + right: 0; 253 + width: 0; 254 + height: 0; 255 + background: linear-gradient( 256 + 225deg, 257 + #f5f2ee 0%, 258 + #e8e4de 40%, 259 + rgba(0, 0, 0, 0.08) 44%, 260 + transparent 48% 261 + ); 262 + transition: width 0.22s ease, height 0.22s ease; 263 + pointer-events: none; 264 + z-index: 1; 265 + } 266 + 267 + .ds-outer:hover .ds-legacy::after { 268 + width: 30px; 269 + height: 30px; 270 + } 271 + 272 + .ds-img { 273 + max-width: 160px; 274 + max-height: 160px; 275 + object-fit: contain; 276 + display: block; 277 + } 278 + 279 + .ds-error { 280 + font-size: 2rem; 281 + padding: 1rem; 282 + opacity: 0.4; 283 + } 284 + 285 + .ds-label { 286 + font-family: 'Caveat', cursive; 287 + font-size: 0.85rem; 288 + color: #666; 289 + margin-top: 4px; 290 + white-space: nowrap; 291 + max-width: 180px; 292 + overflow: hidden; 293 + text-overflow: ellipsis; 294 + text-align: center; 295 + } 296 + 297 + .ds-delete { 298 + position: absolute; 299 + top: -4px; 300 + right: -4px; 301 + width: 18px; 302 + height: 18px; 303 + background: #ff8888; 304 + border: none; 305 + border-radius: 50%; 306 + color: white; 307 + font-size: 0.65rem; 308 + cursor: pointer; 309 + display: flex; 310 + align-items: center; 311 + justify-content: center; 312 + padding: 0; 313 + line-height: 1; 314 + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2); 315 + opacity: 0; 316 + transition: opacity 0.15s; 317 + } 318 + 319 + .ds-outer:hover .ds-delete { 320 + opacity: 1; 321 + } 322 + </style>
+152
src/lib/components/LoginButton.svelte
··· 1 + <script lang="ts"> 2 + import { auth } from '$lib/auth.svelte.js'; 3 + import { createAuthorizationUrl } from '@atcute/oauth-browser-client'; 4 + import type { Handle } from '@atcute/lexicons/syntax'; 5 + 6 + let handle = $state(''); 7 + let showInput = $state(false); 8 + let loading = $state(false); 9 + let error = $state(''); 10 + 11 + async function startLogin() { 12 + if (!handle.trim()) { 13 + showInput = true; 14 + return; 15 + } 16 + loading = true; 17 + error = ''; 18 + try { 19 + const identifier = handle.trim() as Handle; 20 + const url = await createAuthorizationUrl({ 21 + target: { type: 'account', identifier }, 22 + scope: 'atproto transition:generic' 23 + }); 24 + window.location.href = url.toString(); 25 + } catch (e) { 26 + error = e instanceof Error ? e.message : 'Login failed'; 27 + loading = false; 28 + } 29 + } 30 + 31 + function handleKeydown(e: KeyboardEvent) { 32 + if (e.key === 'Enter') startLogin(); 33 + if (e.key === 'Escape') { showInput = false; handle = ''; } 34 + } 35 + </script> 36 + 37 + {#if auth.isLoggedIn} 38 + <div class="auth-menu"> 39 + <a href="/profile/{auth.handle ?? auth.did}" class="user-chip"> 40 + @{auth.handle ?? auth.did?.slice(0, 20)} 41 + </a> 42 + <button class="btn-small" onclick={() => auth.signOut()}>sign out</button> 43 + </div> 44 + {:else if showInput} 45 + <div class="login-form"> 46 + <input 47 + type="text" 48 + placeholder="your.handle.bsky.social" 49 + bind:value={handle} 50 + onkeydown={handleKeydown} 51 + autofocus 52 + /> 53 + <button class="btn" onclick={startLogin} disabled={loading}> 54 + {loading ? '…' : 'go →'} 55 + </button> 56 + <button class="btn-small" onclick={() => { showInput = false; handle = ''; }}>✕</button> 57 + </div> 58 + {#if error}<p class="error">{error}</p>{/if} 59 + {:else} 60 + <button class="btn" onclick={() => { showInput = true; }}>sign in</button> 61 + {/if} 62 + 63 + <style> 64 + .auth-menu { 65 + display: flex; 66 + align-items: center; 67 + gap: 0.5rem; 68 + } 69 + 70 + .user-chip { 71 + font-family: 'Caveat', cursive; 72 + font-size: 1.1rem; 73 + color: #5a4a6a; 74 + text-decoration: none; 75 + background: #f0e8ff; 76 + border: 1.5px solid #c4a8e8; 77 + padding: 0.2rem 0.6rem; 78 + border-radius: 2px; 79 + transform: rotate(-0.5deg); 80 + display: inline-block; 81 + } 82 + 83 + .user-chip:hover { 84 + background: #e8d8ff; 85 + } 86 + 87 + .login-form { 88 + display: flex; 89 + align-items: center; 90 + gap: 0.4rem; 91 + } 92 + 93 + input { 94 + font-family: 'Caveat', cursive; 95 + font-size: 1rem; 96 + padding: 0.3rem 0.6rem; 97 + border: 2px solid #c4a8e8; 98 + border-radius: 2px; 99 + background: #fffef8; 100 + outline: none; 101 + width: 220px; 102 + } 103 + 104 + input:focus { 105 + border-color: #a88ad8; 106 + box-shadow: 0 0 0 2px rgba(168, 138, 216, 0.2); 107 + } 108 + 109 + .btn { 110 + font-family: 'Caveat', cursive; 111 + font-size: 1rem; 112 + padding: 0.3rem 0.8rem; 113 + background: #f0e8ff; 114 + border: 2px solid #c4a8e8; 115 + border-radius: 2px; 116 + cursor: pointer; 117 + transform: rotate(-0.5deg); 118 + transition: background 0.15s; 119 + } 120 + 121 + .btn:hover { 122 + background: #e0d0ff; 123 + } 124 + 125 + .btn:disabled { 126 + opacity: 0.6; 127 + cursor: not-allowed; 128 + } 129 + 130 + .btn-small { 131 + font-family: 'Caveat', cursive; 132 + font-size: 0.85rem; 133 + padding: 0.15rem 0.5rem; 134 + background: transparent; 135 + border: 1.5px solid #ddd; 136 + border-radius: 2px; 137 + cursor: pointer; 138 + color: #888; 139 + } 140 + 141 + .btn-small:hover { 142 + border-color: #bbb; 143 + color: #555; 144 + } 145 + 146 + .error { 147 + font-family: 'Caveat', cursive; 148 + color: #c44; 149 + font-size: 0.9rem; 150 + margin: 0; 151 + } 152 + </style>
+260
src/lib/components/Sticker.svelte
··· 1 + <script lang="ts"> 2 + import { getBlobUrl } from '$lib/types.js'; 3 + 4 + interface Props { 5 + shortname: string; 6 + imageCid: string; 7 + did: string; 8 + pds: string; 9 + maskPath?: string; 10 + imageWidth?: number; 11 + imageHeight?: number; 12 + borderColor?: string; 13 + borderThickness?: number; 14 + showDelete?: boolean; 15 + ondelete?: () => void; 16 + } 17 + 18 + let { 19 + shortname, 20 + imageCid, 21 + did, 22 + pds, 23 + maskPath, 24 + imageWidth, 25 + imageHeight, 26 + borderColor = '', 27 + borderThickness = 0, 28 + showDelete = false, 29 + ondelete 30 + }: Props = $props(); 31 + 32 + let imgError = $state(false); 33 + let imgLoaded = $state(false); 34 + 35 + const blobUrl = $derived(getBlobUrl(pds, did, imageCid)); 36 + 37 + // A unique ID for the SVG clipPath element (CIDs are alphanumeric base32) 38 + const clipId = $derived(`clip-${imageCid.replace(/[^a-zA-Z0-9]/g, '')}`); 39 + 40 + // Compute display dimensions fitting within 160×160px, min 40px 41 + const MAX = 160; 42 + const MIN = 40; 43 + const svgW = $derived( 44 + imageWidth && imageHeight 45 + ? Math.max(MIN, Math.round(imageWidth * Math.min(MAX / imageWidth, MAX / imageHeight, 1))) 46 + : 0 47 + ); 48 + const svgH = $derived( 49 + imageWidth && imageHeight 50 + ? Math.max(MIN, Math.round(imageHeight * Math.min(MAX / imageWidth, MAX / imageHeight, 1))) 51 + : 0 52 + ); 53 + 54 + const isSvgSticker = $derived(!!(maskPath && imageWidth && imageHeight)); 55 + </script> 56 + 57 + <div class="sticker-wrap"> 58 + {#if isSvgSticker} 59 + <!-- SVG sticker: arbitrary clip shape, no white rectangle --> 60 + {#if !imgError} 61 + <svg 62 + class="sticker-svg" 63 + class:loaded={imgLoaded} 64 + width={svgW} 65 + height={svgH} 66 + viewBox="0 0 {imageWidth} {imageHeight}" 67 + style="overflow: visible; display: block;" 68 + > 69 + <defs> 70 + <clipPath id={clipId}> 71 + <path d={maskPath} /> 72 + </clipPath> 73 + </defs> 74 + {#if borderColor && borderThickness} 75 + <!-- Border drawn before image so inner half is covered by the clipped image --> 76 + <path 77 + d={maskPath} 78 + fill="none" 79 + stroke={borderColor} 80 + stroke-width={borderThickness * 2} 81 + stroke-linejoin="round" 82 + stroke-linecap="round" 83 + /> 84 + {/if} 85 + <image 86 + href={blobUrl} 87 + x="0" 88 + y="0" 89 + width={imageWidth} 90 + height={imageHeight} 91 + clip-path="url(#{clipId})" 92 + preserveAspectRatio="none" 93 + onload={() => imgLoaded = true} 94 + onerror={() => imgError = true} 95 + /> 96 + </svg> 97 + {:else} 98 + <div class="img-error">🖼</div> 99 + {/if} 100 + {:else} 101 + <!-- Legacy PNG sticker with white backing --> 102 + <div class="sticker" class:loaded={imgLoaded}> 103 + {#if !imgError} 104 + <img 105 + src={blobUrl} 106 + alt={shortname} 107 + onload={() => imgLoaded = true} 108 + onerror={() => imgError = true} 109 + draggable="false" 110 + /> 111 + {:else} 112 + <div class="img-error">🖼</div> 113 + {/if} 114 + </div> 115 + {/if} 116 + 117 + <div class="sticker-label">{shortname}</div> 118 + {#if showDelete} 119 + <button class="delete-btn" onclick={ondelete} title="delete sticker">✕</button> 120 + {/if} 121 + </div> 122 + 123 + <style> 124 + .sticker-wrap { 125 + position: relative; 126 + display: inline-flex; 127 + flex-direction: column; 128 + align-items: center; 129 + user-select: none; 130 + cursor: grab; 131 + } 132 + 133 + .sticker-wrap:active { 134 + cursor: grabbing; 135 + } 136 + 137 + /* SVG sticker */ 138 + .sticker-svg { 139 + opacity: 0; 140 + transition: opacity 0.2s ease, filter 0.2s ease; 141 + filter: drop-shadow(2px 3px 6px rgba(0,0,0,0.18)); 142 + } 143 + 144 + .sticker-svg.loaded { 145 + opacity: 1; 146 + } 147 + 148 + .sticker-wrap:hover .sticker-svg { 149 + filter: drop-shadow(3px 8px 18px rgba(0,0,0,0.28)); 150 + } 151 + 152 + /* Legacy PNG sticker */ 153 + .sticker { 154 + background: white; 155 + box-shadow: 156 + 2px 3px 8px rgba(0,0,0,0.15), 157 + 0 1px 2px rgba(0,0,0,0.1); 158 + max-width: 160px; 159 + max-height: 160px; 160 + min-width: 80px; 161 + min-height: 80px; 162 + display: flex; 163 + align-items: center; 164 + justify-content: center; 165 + overflow: hidden; 166 + border-radius: 1px; 167 + opacity: 0; 168 + position: relative; 169 + transition: opacity 0.2s ease, box-shadow 0.2s ease; 170 + } 171 + 172 + .sticker.loaded { 173 + opacity: 1; 174 + } 175 + 176 + /* Folded corner peel for legacy PNG stickers */ 177 + .sticker::after { 178 + content: ''; 179 + position: absolute; 180 + bottom: 0; 181 + right: 0; 182 + width: 0; 183 + height: 0; 184 + background: linear-gradient( 185 + 225deg, 186 + #f5f2ee 0%, 187 + #e8e4de 40%, 188 + rgba(0, 0, 0, 0.08) 44%, 189 + transparent 48% 190 + ); 191 + transition: width 0.22s ease, height 0.22s ease, box-shadow 0.22s ease; 192 + pointer-events: none; 193 + z-index: 1; 194 + } 195 + 196 + .sticker-wrap:hover .sticker::after { 197 + width: 30px; 198 + height: 30px; 199 + box-shadow: -2px -2px 6px rgba(0, 0, 0, 0.12); 200 + } 201 + 202 + .sticker-wrap:hover .sticker { 203 + box-shadow: 204 + 3px 6px 16px rgba(0, 0, 0, 0.22), 205 + 0 1px 3px rgba(0, 0, 0, 0.1); 206 + } 207 + 208 + .sticker img { 209 + width: 100%; 210 + height: 100%; 211 + object-fit: contain; 212 + display: block; 213 + max-width: 160px; 214 + max-height: 160px; 215 + } 216 + 217 + .img-error { 218 + font-size: 2rem; 219 + padding: 1rem; 220 + opacity: 0.4; 221 + } 222 + 223 + .sticker-label { 224 + font-family: 'Caveat', cursive; 225 + font-size: 0.85rem; 226 + color: #666; 227 + margin-top: 4px; 228 + white-space: nowrap; 229 + max-width: 160px; 230 + overflow: hidden; 231 + text-overflow: ellipsis; 232 + text-align: center; 233 + } 234 + 235 + .delete-btn { 236 + position: absolute; 237 + top: -4px; 238 + right: -4px; 239 + width: 18px; 240 + height: 18px; 241 + background: #ff8888; 242 + border: none; 243 + border-radius: 50%; 244 + color: white; 245 + font-size: 0.65rem; 246 + cursor: pointer; 247 + display: flex; 248 + align-items: center; 249 + justify-content: center; 250 + padding: 0; 251 + line-height: 1; 252 + box-shadow: 0 1px 3px rgba(0,0,0,0.2); 253 + opacity: 0; 254 + transition: opacity 0.15s; 255 + } 256 + 257 + .sticker-wrap:hover .delete-btn { 258 + opacity: 1; 259 + } 260 + </style>
+524
src/lib/components/StickerEditor.svelte
··· 1 + <script lang="ts"> 2 + import { onMount } from 'svelte'; 3 + 4 + interface Props { 5 + file: File; 6 + onDone: (result: { 7 + imageBlob: Blob; 8 + maskPath: string; 9 + imageWidth: number; 10 + imageHeight: number; 11 + borderColor: string; 12 + borderThickness: number; 13 + shortname: string; 14 + }) => void; 15 + onCancel: () => void; 16 + } 17 + 18 + let { file, onDone, onCancel }: Props = $props(); 19 + 20 + // Form state 21 + let shortname = $state(''); 22 + let borderColor = $state('#ff99cc'); 23 + let borderThickness = $state(8); 24 + let borderEnabled = $state(false); 25 + let processing = $state(false); 26 + 27 + // Canvas + image 28 + let canvas: HTMLCanvasElement; 29 + let img: HTMLImageElement; 30 + let imgLoaded = $state(false); 31 + let displayW = 0; 32 + let displayH = 0; 33 + let scaleX = 1; 34 + let scaleY = 1; 35 + 36 + // Drawing state 37 + type Point = { x: number; y: number }; 38 + let points = $state<Point[]>([]); 39 + let isDrawing = $state(false); 40 + let pathClosed = $state(false); 41 + const CLOSE_RADIUS = 14; // px — snap-close distance 42 + 43 + onMount(() => { 44 + shortname = file.name.replace(/\.[^.]+$/, '').slice(0, 50); 45 + img = new Image(); 46 + img.onload = () => { 47 + imgLoaded = true; 48 + // Fit image into max 520×420 display area 49 + const maxW = 520; 50 + const maxH = 420; 51 + const ratio = Math.min(maxW / img.naturalWidth, maxH / img.naturalHeight, 1); 52 + displayW = Math.round(img.naturalWidth * ratio); 53 + displayH = Math.round(img.naturalHeight * ratio); 54 + scaleX = img.naturalWidth / displayW; 55 + scaleY = img.naturalHeight / displayH; 56 + canvas.width = displayW; 57 + canvas.height = displayH; 58 + redraw(); 59 + }; 60 + img.src = URL.createObjectURL(file); 61 + return () => URL.revokeObjectURL(img.src); 62 + }); 63 + 64 + $effect(() => { 65 + // Redraw preview when border settings change 66 + const _deps = [borderEnabled, borderColor, borderThickness, pathClosed, points.length]; 67 + if (imgLoaded) redraw(); 68 + }); 69 + 70 + function redraw() { 71 + if (!canvas || !img) return; 72 + const ctx = canvas.getContext('2d')!; 73 + ctx.clearRect(0, 0, displayW, displayH); 74 + 75 + // Draw image dimmed 76 + ctx.globalAlpha = pathClosed ? 0.35 : 0.75; 77 + ctx.drawImage(img, 0, 0, displayW, displayH); 78 + ctx.globalAlpha = 1; 79 + 80 + if (points.length < 2) return; 81 + 82 + // Draw the current/closed path 83 + ctx.beginPath(); 84 + ctx.moveTo(points[0].x, points[0].y); 85 + for (let i = 1; i < points.length; i++) { 86 + const prev = points[i - 1]; 87 + const curr = points[i]; 88 + // Smooth with midpoints 89 + ctx.quadraticCurveTo(prev.x, prev.y, (prev.x + curr.x) / 2, (prev.y + curr.y) / 2); 90 + } 91 + if (pathClosed) ctx.closePath(); 92 + 93 + // If closed: border outside, then image clipped inside 94 + if (pathClosed) { 95 + // Border first (before clip) so it sits outside the image 96 + if (borderEnabled && borderThickness > 0) { 97 + ctx.strokeStyle = borderColor; 98 + ctx.lineWidth = borderThickness * 2; 99 + ctx.lineJoin = 'round'; 100 + ctx.lineCap = 'round'; 101 + ctx.stroke(); 102 + } 103 + // Clip and draw image on top, covering the inner half of the stroke 104 + ctx.save(); 105 + ctx.clip(); 106 + ctx.globalAlpha = 1; 107 + ctx.drawImage(img, 0, 0, displayW, displayH); 108 + ctx.restore(); 109 + } 110 + 111 + // Draw path outline 112 + ctx.strokeStyle = pathClosed ? 'rgba(255,255,255,0.6)' : '#ffffff'; 113 + ctx.lineWidth = pathClosed ? 1 : 2; 114 + ctx.setLineDash(pathClosed ? [] : [6, 3]); 115 + ctx.lineJoin = 'round'; 116 + ctx.lineCap = 'round'; 117 + ctx.stroke(); 118 + ctx.setLineDash([]); 119 + 120 + // Close-snap indicator when near start 121 + if (!pathClosed && points.length > 3) { 122 + ctx.beginPath(); 123 + ctx.arc(points[0].x, points[0].y, CLOSE_RADIUS, 0, Math.PI * 2); 124 + ctx.strokeStyle = 'rgba(255,255,100,0.7)'; 125 + ctx.lineWidth = 1.5; 126 + ctx.stroke(); 127 + } 128 + } 129 + 130 + function getPos(e: MouseEvent | TouchEvent): Point { 131 + const rect = canvas.getBoundingClientRect(); 132 + const src = e instanceof TouchEvent ? e.touches[0] : e; 133 + return { 134 + x: (src.clientX - rect.left) * (displayW / rect.width), 135 + y: (src.clientY - rect.top) * (displayH / rect.height) 136 + }; 137 + } 138 + 139 + function isNearStart(p: Point): boolean { 140 + if (points.length < 4) return false; 141 + const dx = p.x - points[0].x; 142 + const dy = p.y - points[0].y; 143 + return Math.sqrt(dx * dx + dy * dy) < CLOSE_RADIUS; 144 + } 145 + 146 + function startDraw(e: MouseEvent | TouchEvent) { 147 + e.preventDefault(); 148 + if (pathClosed) return; 149 + isDrawing = true; 150 + const p = getPos(e); 151 + points = [p]; 152 + } 153 + 154 + function continueDraw(e: MouseEvent | TouchEvent) { 155 + e.preventDefault(); 156 + if (!isDrawing || pathClosed) return; 157 + const p = getPos(e); 158 + // Sub-sample: only add point if moved enough 159 + const last = points[points.length - 1]; 160 + const dx = p.x - last.x; 161 + const dy = p.y - last.y; 162 + if (dx * dx + dy * dy < 9) return; 163 + points = [...points, p]; 164 + redraw(); 165 + } 166 + 167 + function endDraw(e: MouseEvent | TouchEvent) { 168 + e.preventDefault(); 169 + if (!isDrawing) return; 170 + isDrawing = false; 171 + const p = getPos(e); 172 + if (isNearStart(p) || points.length > 8) { 173 + pathClosed = true; 174 + } 175 + redraw(); 176 + } 177 + 178 + function closePath() { 179 + if (points.length > 2) { 180 + pathClosed = true; 181 + redraw(); 182 + } 183 + } 184 + 185 + function resetPath() { 186 + points = []; 187 + pathClosed = false; 188 + redraw(); 189 + } 190 + 191 + async function buildImageAndPath(): Promise<{ 192 + imageBlob: Blob; 193 + maskPath: string; 194 + imageWidth: number; 195 + imageHeight: number; 196 + }> { 197 + // Bounding box of drawn path in display pixels 198 + const xs = points.map((p) => p.x); 199 + const ys = points.map((p) => p.y); 200 + const bboxX = Math.max(0, Math.floor(Math.min(...xs))); 201 + const bboxY = Math.max(0, Math.floor(Math.min(...ys))); 202 + const bboxMaxX = Math.min(displayW, Math.ceil(Math.max(...xs))); 203 + const bboxMaxY = Math.min(displayH, Math.ceil(Math.max(...ys))); 204 + 205 + // Scale to natural image pixel crop region 206 + const cropX = Math.round(bboxX * scaleX); 207 + const cropY = Math.round(bboxY * scaleY); 208 + const cropW = Math.round((bboxMaxX - bboxX) * scaleX); 209 + const cropH = Math.round((bboxMaxY - bboxY) * scaleY); 210 + 211 + // Build SVG path in natural image pixels, relative to crop origin 212 + const sx = (x: number) => Math.round((x - bboxX) * scaleX); 213 + const sy = (y: number) => Math.round((y - bboxY) * scaleY); 214 + let d = `M ${sx(points[0].x)} ${sy(points[0].y)}`; 215 + for (let i = 1; i < points.length; i++) { 216 + const prev = points[i - 1]; 217 + const curr = points[i]; 218 + d += ` Q ${sx(prev.x)} ${sy(prev.y)} ${sx((prev.x + curr.x) / 2)} ${sy((prev.y + curr.y) / 2)}`; 219 + } 220 + d += ' Z'; 221 + 222 + // Crop to bbox, then mask out pixels outside the drawn path using destination-in compositing. 223 + // Exported as PNG to preserve transparency. 224 + const out = document.createElement('canvas'); 225 + out.width = cropW; 226 + out.height = cropH; 227 + const outCtx = out.getContext('2d')!; 228 + outCtx.drawImage(img, cropX, cropY, cropW, cropH, 0, 0, cropW, cropH); 229 + 230 + // Keep only pixels inside the mask path; clear everything outside 231 + outCtx.globalCompositeOperation = 'destination-in'; 232 + outCtx.fill(new Path2D(d)); 233 + outCtx.globalCompositeOperation = 'source-over'; 234 + 235 + const imageBlob = await new Promise<Blob>((resolve, reject) => 236 + out.toBlob((b) => (b ? resolve(b) : reject(new Error('toBlob failed'))), 'image/png') 237 + ); 238 + 239 + return { imageBlob, maskPath: d, imageWidth: cropW, imageHeight: cropH }; 240 + } 241 + 242 + async function handleSubmit() { 243 + if (!shortname.trim() || !pathClosed) return; 244 + processing = true; 245 + try { 246 + const { imageBlob, maskPath, imageWidth, imageHeight } = await buildImageAndPath(); 247 + onDone({ 248 + imageBlob, 249 + maskPath, 250 + imageWidth, 251 + imageHeight, 252 + borderColor: borderEnabled ? borderColor : '', 253 + // Store border thickness in natural image pixels so SVG can use directly 254 + borderThickness: borderEnabled ? Math.round(borderThickness * scaleX) : 0, 255 + shortname: shortname.trim() 256 + }); 257 + } finally { 258 + processing = false; 259 + } 260 + } 261 + </script> 262 + 263 + <div class="editor"> 264 + <h2>create sticker</h2> 265 + 266 + <div class="editor-body"> 267 + <!-- Drawing canvas --> 268 + <div class="canvas-wrap"> 269 + {#if !imgLoaded} 270 + <div class="canvas-placeholder">loading…</div> 271 + {/if} 272 + <canvas 273 + bind:this={canvas} 274 + class:hidden={!imgLoaded} 275 + onmousedown={startDraw} 276 + onmousemove={continueDraw} 277 + onmouseup={endDraw} 278 + onmouseleave={endDraw} 279 + ontouchstart={startDraw} 280 + ontouchmove={continueDraw} 281 + ontouchend={endDraw} 282 + ></canvas> 283 + <div class="canvas-hint"> 284 + {#if !imgLoaded} 285 + &nbsp; 286 + {:else if pathClosed} 287 + ✓ shape drawn 288 + {:else if points.length > 3} 289 + draw back to the dot to close, or click "close shape" 290 + {:else} 291 + click and drag to draw your sticker shape 292 + {/if} 293 + </div> 294 + </div> 295 + 296 + <!-- Controls --> 297 + <div class="controls"> 298 + <label class="field"> 299 + <span>shortname</span> 300 + <input type="text" bind:value={shortname} maxlength="50" placeholder="my cool sticker" /> 301 + </label> 302 + 303 + <label class="field checkbox-field"> 304 + <input type="checkbox" bind:checked={borderEnabled} /> 305 + <span>add border</span> 306 + </label> 307 + 308 + {#if borderEnabled} 309 + <label class="field"> 310 + <span>border color</span> 311 + <div class="color-row"> 312 + <input type="color" bind:value={borderColor} /> 313 + <span class="hex">{borderColor}</span> 314 + </div> 315 + </label> 316 + <label class="field"> 317 + <span>thickness: {borderThickness}px</span> 318 + <input type="range" min="1" max="30" bind:value={borderThickness} /> 319 + </label> 320 + {/if} 321 + 322 + <div class="path-actions"> 323 + {#if !pathClosed && points.length > 2} 324 + <button class="btn-outline" onclick={closePath}>close shape</button> 325 + {/if} 326 + {#if points.length > 0} 327 + <button class="btn-outline" onclick={resetPath}>redraw</button> 328 + {/if} 329 + </div> 330 + 331 + <div class="actions"> 332 + <button class="btn-cancel" onclick={onCancel}>cancel</button> 333 + <button 334 + class="btn-submit" 335 + onclick={handleSubmit} 336 + disabled={processing || !shortname.trim() || !pathClosed} 337 + > 338 + {processing ? 'processing…' : 'use this sticker →'} 339 + </button> 340 + </div> 341 + </div> 342 + </div> 343 + </div> 344 + 345 + <style> 346 + .editor { 347 + background: #fffef8; 348 + border: 2px solid #e0d4c0; 349 + border-radius: 2px; 350 + padding: 1.5rem; 351 + box-shadow: 3px 4px 12px rgba(0, 0, 0, 0.12); 352 + max-width: 900px; 353 + margin: 0 auto; 354 + } 355 + 356 + h2 { 357 + font-family: 'Caveat', cursive; 358 + font-size: 2rem; 359 + margin: 0 0 1.2rem; 360 + color: #5a4a6a; 361 + transform: rotate(-0.5deg); 362 + } 363 + 364 + .editor-body { 365 + display: grid; 366 + grid-template-columns: 1fr auto; 367 + gap: 1.5rem; 368 + align-items: start; 369 + } 370 + 371 + @media (max-width: 680px) { 372 + .editor-body { 373 + grid-template-columns: 1fr; 374 + } 375 + } 376 + 377 + .canvas-wrap { 378 + display: flex; 379 + flex-direction: column; 380 + gap: 0.4rem; 381 + } 382 + 383 + canvas { 384 + display: block; 385 + cursor: crosshair; 386 + border: 1.5px solid #d0c8b8; 387 + border-radius: 2px; 388 + background: #1a1a1a; 389 + max-width: 100%; 390 + touch-action: none; 391 + } 392 + 393 + canvas.hidden { 394 + display: none; 395 + } 396 + 397 + .canvas-placeholder { 398 + width: 300px; 399 + height: 200px; 400 + background: #1a1a1a; 401 + border: 1.5px solid #d0c8b8; 402 + border-radius: 2px; 403 + display: flex; 404 + align-items: center; 405 + justify-content: center; 406 + font-family: 'Caveat', cursive; 407 + color: #666; 408 + } 409 + 410 + .canvas-hint { 411 + font-family: 'Caveat', cursive; 412 + font-size: 0.9rem; 413 + color: #999; 414 + min-height: 1.2em; 415 + } 416 + 417 + .controls { 418 + display: flex; 419 + flex-direction: column; 420 + gap: 0.8rem; 421 + min-width: 200px; 422 + } 423 + 424 + .field { 425 + display: flex; 426 + flex-direction: column; 427 + gap: 0.3rem; 428 + font-family: 'Caveat', cursive; 429 + font-size: 1rem; 430 + color: #555; 431 + } 432 + 433 + .checkbox-field { 434 + flex-direction: row; 435 + align-items: center; 436 + gap: 0.5rem; 437 + } 438 + 439 + .field input[type='text'], 440 + .field input[type='range'] { 441 + font-family: 'Caveat', cursive; 442 + font-size: 1rem; 443 + padding: 0.3rem 0.5rem; 444 + border: 1.5px solid #c8c0b0; 445 + border-radius: 2px; 446 + background: white; 447 + outline: none; 448 + } 449 + 450 + .field input[type='text']:focus { 451 + border-color: #a88ad8; 452 + } 453 + 454 + .color-row { 455 + display: flex; 456 + align-items: center; 457 + gap: 0.5rem; 458 + } 459 + 460 + .hex { 461 + font-family: monospace; 462 + font-size: 0.85rem; 463 + color: #888; 464 + } 465 + 466 + .path-actions { 467 + display: flex; 468 + gap: 0.4rem; 469 + flex-wrap: wrap; 470 + } 471 + 472 + .btn-outline { 473 + font-family: 'Caveat', cursive; 474 + font-size: 0.95rem; 475 + padding: 0.3rem 0.7rem; 476 + background: transparent; 477 + border: 1.5px solid #c4a8e8; 478 + border-radius: 2px; 479 + cursor: pointer; 480 + color: #6a4a8a; 481 + } 482 + 483 + .btn-outline:hover { 484 + background: #f0e8ff; 485 + } 486 + 487 + .actions { 488 + display: flex; 489 + gap: 0.6rem; 490 + margin-top: 0.5rem; 491 + } 492 + 493 + .btn-cancel { 494 + font-family: 'Caveat', cursive; 495 + font-size: 1rem; 496 + padding: 0.4rem 0.9rem; 497 + background: transparent; 498 + border: 1.5px solid #ccc; 499 + border-radius: 2px; 500 + cursor: pointer; 501 + color: #888; 502 + } 503 + 504 + .btn-submit { 505 + font-family: 'Caveat', cursive; 506 + font-size: 1rem; 507 + padding: 0.4rem 1rem; 508 + background: #d0e8c0; 509 + border: 2px solid #a8c890; 510 + border-radius: 2px; 511 + cursor: pointer; 512 + color: #3a5a2a; 513 + flex: 1; 514 + } 515 + 516 + .btn-submit:hover:not(:disabled) { 517 + background: #bcd8ac; 518 + } 519 + 520 + .btn-submit:disabled { 521 + opacity: 0.5; 522 + cursor: not-allowed; 523 + } 524 + </style>
+293
src/lib/components/StickerWall.svelte
··· 1 + <script lang="ts"> 2 + import { onMount, tick } from 'svelte'; 3 + import { goto } from '$app/navigation'; 4 + import Sticker from './Sticker.svelte'; 5 + import type { StickerWithMeta } from '$lib/types.js'; 6 + import type { Draggable } from 'animejs/draggable'; 7 + 8 + interface Props { 9 + stickers: StickerWithMeta[]; 10 + canDelete?: boolean; 11 + onDeleteSticker?: (uri: string) => void; 12 + } 13 + 14 + let { stickers, canDelete = false, onDeleteSticker }: Props = $props(); 15 + 16 + const PAGE_SIZE = 50; 17 + const CELL_W = 210; // target grid cell width in px 18 + const CELL_H = 250; // target grid cell height in px 19 + 20 + let wallEl: HTMLElement; 21 + let dropZoneEl: HTMLElement; 22 + let containerW = $state(0); 23 + let shownCount = $state(0); 24 + let positions = $state<Array<{ top: number; left: number; rotate: number }>>([]); 25 + let isDragging = $state(false); 26 + 27 + const visibleStickers = $derived(stickers.slice(0, shownCount)); 28 + const hasMore = $derived(stickers.length > shownCount); 29 + 30 + // Wall height: tall enough to contain the last row of stickers 31 + const wallHeight = $derived( 32 + positions.length > 0 33 + ? Math.max( 34 + 600, 35 + Math.max(...positions.slice(0, shownCount).map((p) => p.top)) + 260 36 + ) 37 + : 600 38 + ); 39 + 40 + function computeGridPositions(count: number, startIdx: number) { 41 + const cols = Math.max(2, Math.floor(containerW / CELL_W)); 42 + const cellW = containerW / cols; 43 + return Array.from({ length: count }, (_, k) => { 44 + const i = startIdx + k; 45 + const col = i % cols; 46 + const row = Math.floor(i / cols); 47 + // Center of cell + jitter (±25% of cell size) + offset so sticker centers in cell 48 + const jx = (Math.random() - 0.5) * cellW * 0.3; 49 + const jy = (Math.random() - 0.5) * CELL_H * 0.25; 50 + return { 51 + left: Math.max(4, col * cellW + cellW / 2 + jx - 80), 52 + top: Math.max(20, row * CELL_H + CELL_H / 2 + jy - 80 + 30), 53 + rotate: (Math.random() - 0.5) * 8 54 + }; 55 + }); 56 + } 57 + 58 + // animejs modules, loaded once 59 + let anim: { createDraggable: typeof import('animejs/draggable').createDraggable; animate: Function; spring: Function } | null = null; 60 + // Track which elements already have a draggable so we only init once per element 61 + const initializedEls = new WeakSet<HTMLElement>(); 62 + // Ever-increasing z-index counter so the last-grabbed sticker stays on top permanently 63 + let topZ = 10; // start above the drop zone's z-index 64 + // Track raw pointer Y so we can detect drops on the fixed zone regardless of container bounds 65 + let pointerY = 0; 66 + 67 + function setupNewDraggables() { 68 + if (!wallEl || !anim) return; 69 + const { createDraggable, animate, spring } = anim; 70 + wallEl.querySelectorAll<HTMLElement>('.wall-sticker').forEach((el) => { 71 + if (initializedEls.has(el)) return; 72 + initializedEls.add(el); 73 + createDraggable(el, { 74 + container: document.documentElement, 75 + releaseContainerFriction: 1, 76 + velocityMultiplier: 0, 77 + onGrab(self: Draggable) { 78 + // Raise above all other stickers and keep it there after drop 79 + self.$target.style.zIndex = String(++topZ); 80 + const wrap = self.$target.querySelector<HTMLElement>('.sticker-wrap'); 81 + const img = self.$target.querySelector<HTMLElement>('.sticker-svg, .sticker img'); 82 + if (img) img.style.filter = 'drop-shadow(4px 14px 20px rgba(0,0,0,0.32))'; 83 + if (wrap) animate(wrap, { scale: 1.1, translateY: -12, duration: 200, ease: 'out(3)' }); 84 + isDragging = true; 85 + }, 86 + onRelease(self: Draggable) { 87 + const wrap = self.$target.querySelector<HTMLElement>('.sticker-wrap'); 88 + const img = self.$target.querySelector<HTMLElement>('.sticker-svg, .sticker img'); 89 + if (img) img.style.filter = ''; 90 + if (wrap) animate(wrap, { scale: 1, translateY: 0, ease: spring({ stiffness: 300, damping: 25 }) }); 91 + isDragging = false; 92 + 93 + // Use raw pointer Y to detect drops on the fixed zone at the bottom of the viewport, 94 + // since the draggable container may not extend that far. 95 + const zoneRect = dropZoneEl?.getBoundingClientRect(); 96 + if (zoneRect && pointerY >= zoneRect.top) { 97 + const did = self.$target.dataset.did ?? ''; 98 + const sn = self.$target.dataset.shortname ?? ''; 99 + if (did && sn) goto(`/sticker/${encodeURIComponent(did)}/${encodeURIComponent(sn)}`); 100 + } 101 + } 102 + }); 103 + }); 104 + } 105 + 106 + // Generate initial positions and shownCount once containerW and stickers are both ready. 107 + // Uses a plain flag (not $state) so the effect doesn't re-trigger itself. 108 + let positionsSeeded = false; 109 + $effect(() => { 110 + if (containerW === 0 || stickers.length === 0 || positionsSeeded) return; 111 + positionsSeeded = true; 112 + const count = Math.min(stickers.length, PAGE_SIZE); 113 + positions = computeGridPositions(count, 0); 114 + shownCount = count; 115 + tick().then(setupNewDraggables); 116 + }); 117 + 118 + onMount(async () => { 119 + containerW = wallEl.clientWidth; 120 + 121 + const trackPointer = (e: PointerEvent) => { pointerY = e.clientY; }; 122 + window.addEventListener('pointermove', trackPointer); 123 + 124 + const [{ createDraggable }, { animate, spring }] = await Promise.all([ 125 + import('animejs/draggable'), 126 + import('animejs') 127 + ]); 128 + anim = { createDraggable, animate, spring }; 129 + 130 + // In case stickers arrived before onMount (unlikely but possible) 131 + tick().then(setupNewDraggables); 132 + 133 + return () => window.removeEventListener('pointermove', trackPointer); 134 + }); 135 + 136 + async function loadMore() { 137 + const prev = shownCount; 138 + const next = Math.min(stickers.length, shownCount + PAGE_SIZE); 139 + positions = [...positions, ...computeGridPositions(next - prev, prev)]; 140 + shownCount = next; 141 + await tick(); 142 + setupNewDraggables(); 143 + } 144 + </script> 145 + 146 + <div class="wall" bind:this={wallEl} style="min-height: {wallHeight}px"> 147 + {#if stickers.length === 0} 148 + <div class="empty-state"> 149 + <p>no stickers yet…</p> 150 + <p class="sub">be the first to add one!</p> 151 + </div> 152 + {:else} 153 + {#each visibleStickers as sticker, i} 154 + {#if positions[i]} 155 + <div 156 + class="wall-sticker" 157 + data-did={sticker.did} 158 + data-shortname={sticker.value.shortname} 159 + style=" 160 + position: absolute; 161 + top: {positions[i].top}px; 162 + left: {positions[i].left}px; 163 + transform: rotate({positions[i].rotate}deg); 164 + transform-origin: center top; 165 + " 166 + > 167 + <Sticker 168 + shortname={sticker.value.shortname} 169 + imageCid={sticker.value.image.ref.$link} 170 + did={sticker.did} 171 + pds={sticker.pds} 172 + maskPath={sticker.value.maskPath} 173 + imageWidth={sticker.value.imageWidth} 174 + imageHeight={sticker.value.imageHeight} 175 + borderColor={sticker.value.borderColor} 176 + borderThickness={sticker.value.borderThickness} 177 + showDelete={canDelete} 178 + ondelete={() => onDeleteSticker?.(sticker.uri)} 179 + /> 180 + </div> 181 + {/if} 182 + {/each} 183 + {/if} 184 + </div> 185 + 186 + {#if hasMore} 187 + <div class="pagination-bar"> 188 + <button class="load-more-btn" onclick={loadMore}> 189 + show {Math.min(PAGE_SIZE, stickers.length - shownCount)} more 190 + <span class="count">({shownCount} of {stickers.length})</span> 191 + </button> 192 + </div> 193 + {/if} 194 + 195 + <div 196 + class="drop-zone" 197 + class:visible={isDragging} 198 + bind:this={dropZoneEl} 199 + > 200 + <span class="drop-zone-label">drop here to preview / edit</span> 201 + </div> 202 + 203 + <style> 204 + .wall { 205 + position: relative; 206 + width: 100%; 207 + overflow: visible; 208 + } 209 + 210 + .empty-state { 211 + position: absolute; 212 + top: 50%; 213 + left: 50%; 214 + transform: translate(-50%, -50%); 215 + text-align: center; 216 + font-family: 'Caveat', cursive; 217 + color: #aaa; 218 + } 219 + 220 + .empty-state p { 221 + font-size: 1.8rem; 222 + margin: 0.2rem 0; 223 + } 224 + 225 + .empty-state .sub { 226 + font-size: 1.2rem; 227 + } 228 + 229 + .pagination-bar { 230 + display: flex; 231 + justify-content: center; 232 + padding: 2rem 1rem 3rem; 233 + } 234 + 235 + .load-more-btn { 236 + font-family: 'Caveat', cursive; 237 + font-size: 1.1rem; 238 + padding: 0.5rem 1.8rem; 239 + background: rgba(255, 254, 248, 0.9); 240 + border: 2px solid #c8c0b0; 241 + border-radius: 2px; 242 + color: #6a5a7a; 243 + cursor: pointer; 244 + transform: rotate(-0.5deg); 245 + box-shadow: 2px 3px 8px rgba(0, 0, 0, 0.08); 246 + transition: background 0.15s, box-shadow 0.15s; 247 + } 248 + 249 + .load-more-btn:hover { 250 + background: #f0e8ff; 251 + border-color: #b8a0e0; 252 + box-shadow: 2px 4px 12px rgba(0, 0, 0, 0.12); 253 + } 254 + 255 + .count { 256 + font-size: 0.85rem; 257 + opacity: 0.6; 258 + margin-left: 0.4rem; 259 + } 260 + 261 + .drop-zone { 262 + position: fixed; 263 + bottom: 0; 264 + left: 0; 265 + right: 0; 266 + height: 20vh; 267 + background: rgba(240, 232, 255, 0.92); 268 + border-top: 2px dashed #b8a0e0; 269 + backdrop-filter: blur(4px); 270 + display: flex; 271 + align-items: center; 272 + justify-content: center; 273 + z-index: 5; 274 + transform: translateY(100%); 275 + transition: transform 0.22s cubic-bezier(0.34, 1.56, 0.64, 1), opacity 0.22s; 276 + opacity: 0; 277 + pointer-events: none; 278 + } 279 + 280 + .drop-zone.visible { 281 + transform: translateY(0); 282 + opacity: 1; 283 + pointer-events: auto; 284 + } 285 + 286 + .drop-zone-label { 287 + font-family: 'Caveat', cursive; 288 + font-size: 1.4rem; 289 + color: #7a5a9a; 290 + transform: rotate(-0.5deg); 291 + display: inline-block; 292 + } 293 + </style>
+50
src/lib/oauth-setup.ts
··· 1 + import { configureOAuth } from '@atcute/oauth-browser-client'; 2 + import { 3 + LocalActorResolver, 4 + CompositeDidDocumentResolver, 5 + PlcDidDocumentResolver, 6 + XrpcDidDocumentResolver, 7 + CompositeHandleResolver, 8 + WellKnownHandleResolver, 9 + DohJsonHandleResolver 10 + } from '@atcute/identity-resolver'; 11 + 12 + let configured = false; 13 + 14 + export function ensureOAuthConfigured() { 15 + if (configured) return; 16 + configured = true; 17 + 18 + const didDocumentResolver = new CompositeDidDocumentResolver({ 19 + methods: { 20 + plc: new PlcDidDocumentResolver(), 21 + web: new XrpcDidDocumentResolver({ serviceUrl: 'https://bsky.social' }) 22 + } 23 + }); 24 + 25 + const handleResolver = new CompositeHandleResolver({ 26 + methods: { 27 + http: new WellKnownHandleResolver(), 28 + dns: new DohJsonHandleResolver({ dohUrl: 'https://cloudflare-dns.com/dns-query' }) 29 + } 30 + }); 31 + 32 + const identityResolver = new LocalActorResolver({ handleResolver, didDocumentResolver }); 33 + 34 + const isLocalhost = 35 + window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1'; 36 + const port = window.location.port || '5173'; 37 + 38 + configureOAuth({ 39 + metadata: isLocalhost 40 + ? { 41 + client_id: `http://localhost?redirect_uri=${encodeURIComponent('http://127.0.0.1/oauth/callback')}&scope=${encodeURIComponent('atproto transition:generic')}`, 42 + redirect_uri: `http://127.0.0.1:${port}/oauth/callback` 43 + } 44 + : { 45 + client_id: 'https://stickies.sky.boo/client-metadata.json', 46 + redirect_uri: 'https://stickies.sky.boo/oauth/callback' 47 + }, 48 + identityResolver 49 + }); 50 + }
+34
src/lib/types.ts
··· 1 + export interface StickerRecord { 2 + uri: string; 3 + cid: string; 4 + value: { 5 + shortname: string; 6 + image: { 7 + ref: { $link: string }; 8 + mimeType: string; 9 + size?: number; 10 + }; 11 + maskPath?: string; 12 + imageWidth?: number; 13 + imageHeight?: number; 14 + borderColor?: string; 15 + borderThickness?: number; 16 + createdAt: string; 17 + }; 18 + } 19 + 20 + export interface StickerWithMeta extends StickerRecord { 21 + did: string; 22 + pds: string; 23 + blobUrl: string; 24 + } 25 + 26 + export function getBlobUrl(pds: string, did: string, cid: string): string { 27 + return `${pds}/xrpc/com.atproto.sync.getBlob?did=${encodeURIComponent(did)}&cid=${encodeURIComponent(cid)}`; 28 + } 29 + 30 + export function parseDid(uri: string): string { 31 + // at://did:plc:xxx/boo.sky.sticker/tid → did:plc:xxx 32 + const match = uri.match(/^at:\/\/(did:[^/]+)\//); 33 + return match ? match[1] : ''; 34 + }
+26
src/lib/ufo.ts
··· 1 + import type { StickerRecord } from './types.js'; 2 + 3 + const UFO_API = 'https://ufos-api.microcosm.blue'; 4 + 5 + export interface UFORecord { 6 + did: string; 7 + collection: string; 8 + rkey: string; 9 + record: StickerRecord['value']; 10 + time_us: number; 11 + } 12 + 13 + /** 14 + * Fetch community stickers from the UFOs API. 15 + * Returns raw UFO records — caller is responsible for resolving PDS per DID. 16 + */ 17 + export async function fetchCommunityStickers(): Promise<UFORecord[]> { 18 + try { 19 + const res = await fetch(`${UFO_API}/records?collection=boo.sky.sticker`); 20 + if (!res.ok) throw new Error(`UFOs API error: ${res.status}`); 21 + return (await res.json()) as UFORecord[]; 22 + } catch (e) { 23 + console.warn('UFOs API unavailable:', e); 24 + return []; 25 + } 26 + }
+2
src/routes/+layout.js
··· 1 + export const ssr = false; 2 + export const prerender = false;
+141
src/routes/+layout.svelte
··· 1 + <script lang="ts"> 2 + import { onMount } from 'svelte'; 3 + import { ensureOAuthConfigured } from '$lib/oauth-setup.js'; 4 + import { auth } from '$lib/auth.svelte.js'; 5 + import LoginButton from '$lib/components/LoginButton.svelte'; 6 + 7 + let { children } = $props(); 8 + 9 + // Call eagerly at script init so `database` is set before any onMount runs 10 + ensureOAuthConfigured(); 11 + 12 + onMount(() => { 13 + auth.tryRestoreSession(); 14 + }); 15 + </script> 16 + 17 + <svelte:head> 18 + <title>@stickies</title> 19 + <link rel="preconnect" href="https://fonts.googleapis.com" /> 20 + <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin="anonymous" /> 21 + <link href="https://fonts.googleapis.com/css2?family=Caveat:wght@400;500;600;700&display=swap" rel="stylesheet" /> 22 + </svelte:head> 23 + 24 + <div class="app"> 25 + <header class="site-header"> 26 + <div class="header-inner"> 27 + <a href="/" class="site-title">@stickies</a> 28 + <nav class="site-nav"> 29 + <a href="/" class="nav-link">wall</a> 30 + {#if auth.isLoggedIn} 31 + <a href="/upload" class="nav-link">+ add sticker</a> 32 + <a href="/profile/{auth.handle ?? auth.did}" class="nav-link">my stickers</a> 33 + {/if} 34 + </nav> 35 + <div class="auth-area"> 36 + <LoginButton /> 37 + </div> 38 + </div> 39 + </header> 40 + 41 + <main> 42 + {@render children()} 43 + </main> 44 + </div> 45 + 46 + <style> 47 + :global(*) { 48 + box-sizing: border-box; 49 + } 50 + 51 + :global(body) { 52 + margin: 0; 53 + padding: 0; 54 + font-family: 'Caveat', cursive; 55 + /* Grid paper background */ 56 + background-color: #f8f8f6; 57 + background-image: 58 + repeating-linear-gradient( 59 + 0deg, 60 + transparent, 61 + transparent 27px, 62 + rgba(150, 180, 220, 0.18) 28px 63 + ), 64 + repeating-linear-gradient( 65 + 90deg, 66 + transparent, 67 + transparent 27px, 68 + rgba(150, 180, 220, 0.18) 28px 69 + ); 70 + background-size: 28px 28px; 71 + min-height: 100vh; 72 + } 73 + 74 + .app { 75 + min-height: 100vh; 76 + } 77 + 78 + .site-header { 79 + background: rgba(255, 254, 248, 0.9); 80 + border-bottom: 2px solid rgba(200, 190, 170, 0.6); 81 + backdrop-filter: blur(4px); 82 + position: sticky; 83 + top: 0; 84 + z-index: 50; 85 + box-shadow: 0 2px 8px rgba(0,0,0,0.07); 86 + } 87 + 88 + .header-inner { 89 + max-width: 1400px; 90 + margin: 0 auto; 91 + padding: 0.6rem 1.5rem; 92 + display: flex; 93 + align-items: center; 94 + gap: 1.5rem; 95 + } 96 + 97 + .site-title { 98 + font-family: 'Caveat', cursive; 99 + font-size: 1.8rem; 100 + font-weight: 700; 101 + color: #5a4a6a; 102 + text-decoration: none; 103 + transform: rotate(-1deg); 104 + display: inline-block; 105 + letter-spacing: -0.5px; 106 + } 107 + 108 + .site-title:hover { 109 + color: #7a6a9a; 110 + } 111 + 112 + .site-nav { 113 + display: flex; 114 + gap: 1rem; 115 + flex: 1; 116 + } 117 + 118 + .nav-link { 119 + font-family: 'Caveat', cursive; 120 + font-size: 1.1rem; 121 + color: #6a5a7a; 122 + text-decoration: none; 123 + padding: 0.2rem 0.5rem; 124 + border-radius: 2px; 125 + transform: rotate(-0.5deg); 126 + display: inline-block; 127 + transition: background 0.15s; 128 + } 129 + 130 + .nav-link:hover { 131 + background: rgba(200, 180, 240, 0.2); 132 + } 133 + 134 + .auth-area { 135 + margin-left: auto; 136 + } 137 + 138 + main { 139 + position: relative; 140 + } 141 + </style>
+83
src/routes/+page.svelte
··· 1 + <script lang="ts"> 2 + import { onMount } from 'svelte'; 3 + import StickerWall from '$lib/components/StickerWall.svelte'; 4 + import { fetchCommunityStickers } from '$lib/ufo.js'; 5 + import { getPdsFromDid } from '$lib/atproto.js'; 6 + import type { StickerWithMeta } from '$lib/types.js'; 7 + 8 + let stickers = $state<StickerWithMeta[]>([]); 9 + let loading = $state(true); 10 + let error = $state(''); 11 + 12 + onMount(async () => { 13 + try { 14 + const records = await fetchCommunityStickers(); 15 + 16 + // Batch-resolve unique DIDs → PDS to avoid redundant requests 17 + const uniqueDids = [...new Set(records.map((r) => r.did))]; 18 + const pdsMap = new Map<string, string>(); 19 + await Promise.all( 20 + uniqueDids.map(async (did) => { 21 + try { 22 + pdsMap.set(did, await getPdsFromDid(did)); 23 + } catch { 24 + pdsMap.set(did, 'https://bsky.social'); 25 + } 26 + }) 27 + ); 28 + 29 + stickers = records.map((r) => ({ 30 + uri: `at://${r.did}/${r.collection}/${r.rkey}`, 31 + cid: '', 32 + value: r.record, 33 + did: r.did, 34 + pds: pdsMap.get(r.did) ?? 'https://bsky.social', 35 + blobUrl: '' 36 + } satisfies StickerWithMeta)); 37 + } catch (e) { 38 + error = e instanceof Error ? e.message : 'Failed to load stickers'; 39 + } finally { 40 + loading = false; 41 + } 42 + }); 43 + </script> 44 + 45 + <svelte:head> 46 + <title>@stickies — community wall</title> 47 + </svelte:head> 48 + 49 + {#if loading} 50 + <div class="loading"> 51 + <p>loading stickers…</p> 52 + </div> 53 + {:else if error} 54 + <div class="error-msg"> 55 + <p>couldn't load stickers 😕</p> 56 + <p class="sub">{error}</p> 57 + </div> 58 + {:else} 59 + <StickerWall {stickers} /> 60 + {/if} 61 + 62 + <style> 63 + .loading, .error-msg { 64 + display: flex; 65 + flex-direction: column; 66 + align-items: center; 67 + justify-content: center; 68 + min-height: 60vh; 69 + font-family: 'Caveat', cursive; 70 + color: #aaa; 71 + text-align: center; 72 + } 73 + 74 + .loading p, .error-msg p { 75 + font-size: 1.8rem; 76 + margin: 0.2rem 0; 77 + } 78 + 79 + .error-msg .sub { 80 + font-size: 1rem; 81 + opacity: 0.6; 82 + } 83 + </style>
+142
src/routes/oauth/callback/+page.svelte
··· 1 + <script lang="ts"> 2 + import { onMount } from 'svelte'; 3 + import { goto } from '$app/navigation'; 4 + import { finalizeAuthorization } from '@atcute/oauth-browser-client'; 5 + import { auth } from '$lib/auth.svelte.js'; 6 + import { ensureOAuthConfigured } from '$lib/oauth-setup.js'; 7 + 8 + // Ensure database is initialised before finalizeAuthorization runs, 9 + // regardless of layout onMount ordering 10 + ensureOAuthConfigured(); 11 + 12 + let status = $state<'loading' | 'success' | 'error'>('loading'); 13 + let errorMsg = $state(''); 14 + 15 + onMount(async () => { 16 + try { 17 + // Auth servers may return params as query string (?code=...) or hash fragment (#code=...) 18 + const raw = window.location.search || window.location.hash.replace(/^#/, '?'); 19 + const params = new URLSearchParams(raw); 20 + const { session } = await finalizeAuthorization(params); 21 + auth.setFromSession(session); 22 + 23 + // Try to resolve handle for display 24 + try { 25 + const did = session.info.sub; 26 + const pds = session.info.aud; 27 + const res = await fetch(`${pds}/xrpc/com.atproto.identity.resolveHandle?handle=${did}`); 28 + // Actually, let's use the DID to get the profile 29 + const profileRes = await fetch( 30 + `https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(did)}` 31 + ); 32 + if (profileRes.ok) { 33 + const profile = await profileRes.json(); 34 + if (profile.handle) auth.setHandle(profile.handle); 35 + } 36 + } catch (_) { 37 + // Handle resolution is best-effort 38 + } 39 + 40 + status = 'success'; 41 + // Small delay so user sees success state 42 + setTimeout(() => goto('/'), 500); 43 + } catch (e) { 44 + errorMsg = e instanceof Error ? e.message : 'Authorization failed'; 45 + status = 'error'; 46 + } 47 + }); 48 + </script> 49 + 50 + <svelte:head> 51 + <title>@stickies — signing in…</title> 52 + </svelte:head> 53 + 54 + <div class="callback-page"> 55 + {#if status === 'loading'} 56 + <div class="status-card"> 57 + <div class="spinner">✦</div> 58 + <p>finishing sign-in…</p> 59 + </div> 60 + {:else if status === 'success'} 61 + <div class="status-card success"> 62 + <p class="big">✓</p> 63 + <p>signed in! redirecting…</p> 64 + </div> 65 + {:else} 66 + <div class="status-card error"> 67 + <p class="big">✗</p> 68 + <p>sign-in failed</p> 69 + <p class="sub">{errorMsg}</p> 70 + <a href="/" class="btn">go home</a> 71 + </div> 72 + {/if} 73 + </div> 74 + 75 + <style> 76 + .callback-page { 77 + min-height: 100vh; 78 + display: flex; 79 + align-items: center; 80 + justify-content: center; 81 + } 82 + 83 + .status-card { 84 + background: #fffef8; 85 + border: 2px solid #e0d4c0; 86 + border-radius: 2px; 87 + padding: 2.5rem 3rem; 88 + text-align: center; 89 + box-shadow: 3px 4px 12px rgba(0,0,0,0.12); 90 + transform: rotate(-0.5deg); 91 + font-family: 'Caveat', cursive; 92 + } 93 + 94 + .status-card p { 95 + font-size: 1.4rem; 96 + color: #666; 97 + margin: 0.3rem 0; 98 + } 99 + 100 + .big { 101 + font-size: 3rem !important; 102 + margin-bottom: 0.5rem; 103 + } 104 + 105 + .success .big { 106 + color: #5a9a5a; 107 + } 108 + 109 + .error .big { 110 + color: #c44; 111 + } 112 + 113 + .sub { 114 + font-size: 0.9rem !important; 115 + opacity: 0.6; 116 + } 117 + 118 + .spinner { 119 + font-size: 2.5rem; 120 + animation: spin 2s linear infinite; 121 + display: inline-block; 122 + margin-bottom: 0.5rem; 123 + } 124 + 125 + @keyframes spin { 126 + from { transform: rotate(0deg); } 127 + to { transform: rotate(360deg); } 128 + } 129 + 130 + .btn { 131 + display: inline-block; 132 + margin-top: 1rem; 133 + font-family: 'Caveat', cursive; 134 + font-size: 1rem; 135 + padding: 0.4rem 1rem; 136 + background: #f0e8ff; 137 + border: 2px solid #c4a8e8; 138 + border-radius: 2px; 139 + text-decoration: none; 140 + color: #5a4a6a; 141 + } 142 + </style>
+205
src/routes/profile/[handle]/+page.svelte
··· 1 + <script lang="ts"> 2 + import { page } from '$app/stores'; 3 + import StickerWall from '$lib/components/StickerWall.svelte'; 4 + import { resolveHandle, listStickers, getPdsFromDid, getHandleFromDid, deleteStickerRecord } from '$lib/atproto.js'; 5 + import { auth } from '$lib/auth.svelte.js'; 6 + import type { StickerWithMeta } from '$lib/types.js'; 7 + 8 + let handle = $derived($page.params.handle ?? ''); 9 + 10 + let stickers = $state<StickerWithMeta[]>([]); 11 + let did = $state(''); 12 + let pds = $state(''); 13 + let displayHandle = $state(''); 14 + let loading = $state(true); 15 + let error = $state(''); 16 + 17 + let isOwnProfile = $derived(!!auth.did && !!did && auth.did === did); 18 + 19 + async function loadProfile(h: string) { 20 + loading = true; 21 + error = ''; 22 + stickers = []; 23 + 24 + try { 25 + let resolvedDid: string; 26 + 27 + // Handle "me" shortcut 28 + if (h === 'me') { 29 + if (!auth.did) { 30 + error = 'Not signed in'; 31 + loading = false; 32 + return; 33 + } 34 + resolvedDid = auth.did; 35 + } else if (h.startsWith('did:')) { 36 + resolvedDid = h; 37 + } else { 38 + resolvedDid = await resolveHandle(h); 39 + } 40 + 41 + did = resolvedDid; 42 + // If the URL contained a raw DID, resolve it to a readable handle 43 + if (h.startsWith('did:') || h === 'me') { 44 + displayHandle = (await getHandleFromDid(resolvedDid)) ?? resolvedDid; 45 + } else { 46 + displayHandle = h; 47 + } 48 + 49 + const resolvedPds = await getPdsFromDid(resolvedDid); 50 + pds = resolvedPds; 51 + 52 + const { records } = await listStickers(resolvedPds, resolvedDid); 53 + stickers = records.map((r) => ({ 54 + ...r, 55 + did: resolvedDid, 56 + pds: resolvedPds, 57 + blobUrl: '' 58 + })); 59 + } catch (e) { 60 + error = e instanceof Error ? e.message : 'Failed to load profile'; 61 + } finally { 62 + loading = false; 63 + } 64 + } 65 + 66 + $effect(() => { 67 + if (handle) loadProfile(handle); 68 + }); 69 + 70 + async function handleDeleteSticker(uri: string) { 71 + if (!auth.agent || !auth.did) return; 72 + const rkey = uri.split('/').pop() ?? ''; 73 + try { 74 + await deleteStickerRecord(auth.agent, auth.did, rkey); 75 + stickers = stickers.filter((s) => s.uri !== uri); 76 + } catch (e) { 77 + alert('Failed to delete sticker: ' + (e instanceof Error ? e.message : e)); 78 + } 79 + } 80 + </script> 81 + 82 + <svelte:head> 83 + <title>@stickies — {displayHandle || handle}</title> 84 + </svelte:head> 85 + 86 + <div class="profile-page"> 87 + <div class="profile-header"> 88 + <h1> 89 + {#if displayHandle} 90 + @{displayHandle} 91 + {:else} 92 + @{handle} 93 + {/if} 94 + {#if isOwnProfile} 95 + <span class="you-badge">you</span> 96 + {/if} 97 + </h1> 98 + {#if isOwnProfile} 99 + <a href="/upload" class="add-btn">+ add sticker</a> 100 + {/if} 101 + </div> 102 + 103 + {#if loading} 104 + <div class="status"> 105 + <p>loading stickers…</p> 106 + </div> 107 + {:else if error} 108 + <div class="status error"> 109 + <p>couldn't load profile 😕</p> 110 + <p class="sub">{error}</p> 111 + </div> 112 + {:else} 113 + <div class="count-bar"> 114 + <span>{stickers.length} sticker{stickers.length !== 1 ? 's' : ''}</span> 115 + </div> 116 + <StickerWall 117 + {stickers} 118 + canDelete={isOwnProfile} 119 + onDeleteSticker={handleDeleteSticker} 120 + /> 121 + {/if} 122 + </div> 123 + 124 + <style> 125 + .profile-page { 126 + font-family: 'Caveat', cursive; 127 + } 128 + 129 + .profile-header { 130 + padding: 1.5rem 2rem 0.5rem; 131 + display: flex; 132 + align-items: baseline; 133 + gap: 1rem; 134 + flex-wrap: wrap; 135 + } 136 + 137 + h1 { 138 + font-size: 2.2rem; 139 + color: #5a4a6a; 140 + margin: 0; 141 + transform: rotate(-0.5deg); 142 + display: inline-flex; 143 + align-items: center; 144 + gap: 0.5rem; 145 + } 146 + 147 + .you-badge { 148 + font-size: 0.8rem; 149 + background: #d0e8c0; 150 + border: 1.5px solid #a8c890; 151 + border-radius: 2px; 152 + padding: 0.1rem 0.4rem; 153 + color: #3a6a2a; 154 + transform: rotate(1deg); 155 + display: inline-block; 156 + vertical-align: middle; 157 + } 158 + 159 + .add-btn { 160 + font-family: 'Caveat', cursive; 161 + font-size: 1rem; 162 + padding: 0.3rem 0.9rem; 163 + background: #d0e8c0; 164 + border: 2px solid #a8c890; 165 + border-radius: 2px; 166 + text-decoration: none; 167 + color: #3a6a2a; 168 + transform: rotate(-0.5deg); 169 + display: inline-block; 170 + } 171 + 172 + .add-btn:hover { 173 + background: #bcd8ac; 174 + } 175 + 176 + .count-bar { 177 + padding: 0.3rem 2rem 0.8rem; 178 + font-size: 1rem; 179 + color: #aaa; 180 + } 181 + 182 + .status { 183 + display: flex; 184 + flex-direction: column; 185 + align-items: center; 186 + justify-content: center; 187 + min-height: 60vh; 188 + text-align: center; 189 + } 190 + 191 + .status p { 192 + font-size: 1.8rem; 193 + color: #aaa; 194 + margin: 0.2rem 0; 195 + } 196 + 197 + .status.error p { 198 + color: #c44; 199 + } 200 + 201 + .sub { 202 + font-size: 1rem !important; 203 + opacity: 0.6; 204 + } 205 + </style>
+111
src/routes/sticker/+page.svelte
··· 1 + <script lang="ts"> 2 + import { goto } from '$app/navigation'; 3 + 4 + let query = $state(''); 5 + let error = $state(''); 6 + 7 + function handleSubmit(e: Event) { 8 + e.preventDefault(); 9 + const raw = query.trim(); 10 + const slash = raw.indexOf('/'); 11 + if (slash === -1 || slash === 0 || slash === raw.length - 1) { 12 + error = 'use the format handle/shortname or did:plc:…/shortname'; 13 + return; 14 + } 15 + const actor = raw.slice(0, slash); 16 + const shortname = raw.slice(slash + 1); 17 + error = ''; 18 + goto(`/sticker/${actor}/${encodeURIComponent(shortname)}`); 19 + } 20 + </script> 21 + 22 + <svelte:head> 23 + <title>@stickies — preview sticker</title> 24 + </svelte:head> 25 + 26 + <div class="page"> 27 + <h1>preview sticker</h1> 28 + <p class="hint">look up any sticker by handle or DID, and its shortname</p> 29 + 30 + <form class="search-form" onsubmit={handleSubmit}> 31 + <input 32 + class="search-input" 33 + type="text" 34 + bind:value={query} 35 + placeholder="goose.art/blosh or did:plc:…/blosh" 36 + spellcheck="false" 37 + autocomplete="off" 38 + /> 39 + <button class="search-btn" type="submit">look up →</button> 40 + </form> 41 + 42 + {#if error} 43 + <p class="error">{error}</p> 44 + {/if} 45 + </div> 46 + 47 + <style> 48 + .page { 49 + max-width: 640px; 50 + margin: 0 auto; 51 + padding: 3rem 1.5rem; 52 + font-family: 'Caveat', cursive; 53 + } 54 + 55 + h1 { 56 + font-size: 2.4rem; 57 + color: #5a4a6a; 58 + margin: 0 0 0.4rem; 59 + transform: rotate(-0.5deg); 60 + display: inline-block; 61 + } 62 + 63 + .hint { 64 + color: #aaa; 65 + font-size: 1.1rem; 66 + margin: 0 0 1.5rem; 67 + } 68 + 69 + .search-form { 70 + display: flex; 71 + gap: 0.6rem; 72 + flex-wrap: wrap; 73 + } 74 + 75 + .search-input { 76 + font-family: 'Caveat', cursive; 77 + font-size: 1.1rem; 78 + padding: 0.45rem 0.8rem; 79 + border: 2px solid #c8c0b0; 80 + border-radius: 2px; 81 + background: white; 82 + outline: none; 83 + flex: 1; 84 + min-width: 240px; 85 + } 86 + 87 + .search-input:focus { 88 + border-color: #a88ad8; 89 + } 90 + 91 + .search-btn { 92 + font-family: 'Caveat', cursive; 93 + font-size: 1.1rem; 94 + padding: 0.45rem 1.2rem; 95 + background: #f0e8ff; 96 + border: 2px solid #c4a8e8; 97 + border-radius: 2px; 98 + cursor: pointer; 99 + color: #5a4a6a; 100 + } 101 + 102 + .search-btn:hover { 103 + background: #e0d0ff; 104 + } 105 + 106 + .error { 107 + color: #c44; 108 + font-size: 1rem; 109 + margin-top: 0.8rem; 110 + } 111 + </style>
+446
src/routes/sticker/[actor]/[shortname]/+page.svelte
··· 1 + <script lang="ts"> 2 + import { page } from '$app/stores'; 3 + import { goto } from '$app/navigation'; 4 + import { onMount, tick } from 'svelte'; 5 + import { resolveHandle, getPdsFromDid, listStickers, getHandleFromDid, deleteStickerRecord } from '$lib/atproto.js'; 6 + import { auth } from '$lib/auth.svelte.js'; 7 + import { getBlobUrl } from '$lib/types.js'; 8 + import type { StickerRecord } from '$lib/types.js'; 9 + 10 + let actor = $derived($page.params.actor); 11 + let shortname = $derived($page.params.shortname); 12 + 13 + let did = $state(''); 14 + let pds = $state(''); 15 + let handle = $state(''); 16 + let sticker = $state<StickerRecord | null>(null); 17 + let loading = $state(true); 18 + let error = $state(''); 19 + let deleting = $state(false); 20 + 21 + let rotate = $state(0); 22 + let scale = $state(100); 23 + 24 + const isOwnSticker = $derived(!!auth.did && !!did && auth.did === did); 25 + 26 + const MAX_DISPLAY = 220; 27 + const svgW = $derived( 28 + sticker?.value.imageWidth && sticker?.value.imageHeight 29 + ? Math.round( 30 + sticker.value.imageWidth * 31 + Math.min(MAX_DISPLAY / sticker.value.imageWidth, MAX_DISPLAY / sticker.value.imageHeight, 1) 32 + ) 33 + : 0 34 + ); 35 + const svgH = $derived( 36 + sticker?.value.imageWidth && sticker?.value.imageHeight 37 + ? Math.round( 38 + sticker.value.imageHeight * 39 + Math.min(MAX_DISPLAY / sticker.value.imageWidth, MAX_DISPLAY / sticker.value.imageHeight, 1) 40 + ) 41 + : 0 42 + ); 43 + 44 + const clipId = $derived( 45 + sticker ? `preview-clip-${sticker.cid.replace(/[^a-zA-Z0-9]/g, '')}` : 'preview-clip' 46 + ); 47 + const blobUrl = $derived( 48 + sticker && did && pds ? getBlobUrl(pds, did, sticker.value.image.ref.$link) : '' 49 + ); 50 + 51 + async function loadSticker(actorParam: string, shortnameParam: string) { 52 + loading = true; 53 + error = ''; 54 + sticker = null; 55 + did = ''; 56 + pds = ''; 57 + handle = ''; 58 + draggableInited = false; 59 + 60 + try { 61 + let resolvedDid: string; 62 + if (actorParam.startsWith('did:')) { 63 + resolvedDid = actorParam; 64 + } else { 65 + resolvedDid = await resolveHandle(actorParam); 66 + } 67 + did = resolvedDid; 68 + 69 + const [resolvedPds, resolvedHandle] = await Promise.all([ 70 + getPdsFromDid(resolvedDid), 71 + getHandleFromDid(resolvedDid) 72 + ]); 73 + pds = resolvedPds; 74 + handle = resolvedHandle ?? resolvedDid; 75 + 76 + const { records } = await listStickers(resolvedPds, resolvedDid); 77 + const found = records.find((r) => r.value.shortname === shortnameParam); 78 + if (!found) { 79 + error = `sticker "${shortnameParam}" not found for @${handle}`; 80 + } else { 81 + sticker = found; 82 + } 83 + } catch (e) { 84 + error = e instanceof Error ? e.message : 'failed to load sticker'; 85 + } finally { 86 + loading = false; 87 + } 88 + } 89 + 90 + $effect(() => { 91 + if (actor && shortname) loadSticker(actor, shortname); 92 + }); 93 + 94 + let positionerEl: HTMLElement; 95 + let stageEl: HTMLElement; 96 + let animLib: { createDraggable: Function } | null = null; 97 + let draggableInited = false; 98 + 99 + onMount(async () => { 100 + const { createDraggable } = await import('animejs/draggable'); 101 + animLib = { createDraggable }; 102 + }); 103 + 104 + $effect(() => { 105 + if (!sticker || !animLib || draggableInited || !positionerEl || !stageEl) return; 106 + draggableInited = true; 107 + tick().then(() => { 108 + if (!animLib || !positionerEl || !stageEl) return; 109 + // Center positioner within stage 110 + const sw = stageEl.clientWidth; 111 + const sh = stageEl.clientHeight; 112 + positionerEl.style.left = `${Math.round(sw / 2 - (svgW || 110) / 2)}px`; 113 + positionerEl.style.top = `${Math.round(sh / 2 - (svgH || 110) / 2)}px`; 114 + animLib.createDraggable(positionerEl, { 115 + container: document.documentElement, 116 + releaseContainerFriction: 1, 117 + velocityMultiplier: 0 118 + }); 119 + }); 120 + }); 121 + 122 + let imgLoaded = $state(false); 123 + 124 + async function handleDelete() { 125 + if (!auth.agent || !auth.did || !sticker) return; 126 + if (!confirm(`delete sticker "${shortname}"?`)) return; 127 + deleting = true; 128 + try { 129 + const rkey = sticker.uri.split('/').pop() ?? ''; 130 + await deleteStickerRecord(auth.agent, auth.did, rkey); 131 + goto(`/profile/${auth.handle ?? auth.did}`); 132 + } catch (e) { 133 + alert('failed to delete: ' + (e instanceof Error ? e.message : e)); 134 + deleting = false; 135 + } 136 + } 137 + </script> 138 + 139 + <svelte:head> 140 + <title>@stickies — {shortname}{handle ? ` by @${handle}` : ''}</title> 141 + </svelte:head> 142 + 143 + <div class="preview-page"> 144 + <div class="back-bar"> 145 + <a href="/sticker" class="back-link">← look up another</a> 146 + {#if handle} 147 + <a href="/profile/{handle}" class="profile-link">@{handle}'s stickers →</a> 148 + {/if} 149 + </div> 150 + 151 + {#if loading} 152 + <div class="status"><p>loading…</p></div> 153 + {:else if error} 154 + <div class="status error"> 155 + <p>couldn't find sticker 😕</p> 156 + <p class="sub">{error}</p> 157 + </div> 158 + {:else if sticker} 159 + <div class="content"> 160 + <div class="sticker-stage" bind:this={stageEl}> 161 + <div class="sticker-positioner" bind:this={positionerEl}> 162 + <div class="sticker-inner" style="transform: rotate({rotate}deg) scale({scale / 100});"> 163 + {#if sticker.value.maskPath && sticker.value.imageWidth && sticker.value.imageHeight} 164 + <svg 165 + class="preview-svg" 166 + class:loaded={imgLoaded} 167 + width={svgW} 168 + height={svgH} 169 + viewBox="0 0 {sticker.value.imageWidth} {sticker.value.imageHeight}" 170 + style="overflow: visible; display: block;" 171 + > 172 + <defs> 173 + <clipPath id={clipId}> 174 + <path d={sticker.value.maskPath} /> 175 + </clipPath> 176 + </defs> 177 + {#if sticker.value.borderColor && sticker.value.borderThickness} 178 + <path 179 + d={sticker.value.maskPath} 180 + fill="none" 181 + stroke={sticker.value.borderColor} 182 + stroke-width={sticker.value.borderThickness * 2} 183 + stroke-linejoin="round" 184 + stroke-linecap="round" 185 + /> 186 + {/if} 187 + <image 188 + href={blobUrl} 189 + x="0" 190 + y="0" 191 + width={sticker.value.imageWidth} 192 + height={sticker.value.imageHeight} 193 + clip-path="url(#{clipId})" 194 + preserveAspectRatio="none" 195 + onload={() => (imgLoaded = true)} 196 + /> 197 + </svg> 198 + {:else} 199 + <img 200 + class="preview-img" 201 + class:loaded={imgLoaded} 202 + src={blobUrl} 203 + alt={shortname} 204 + onload={() => (imgLoaded = true)} 205 + draggable="false" 206 + /> 207 + {/if} 208 + </div> 209 + </div> 210 + </div> 211 + 212 + <div class="controls"> 213 + <h1 class="sticker-name">{shortname}</h1> 214 + {#if handle} 215 + <p class="by-line">by <a href="/profile/{handle}">@{handle}</a></p> 216 + {/if} 217 + 218 + <div class="slider-group"> 219 + <label class="slider-label"> 220 + rotate <span class="slider-value">{rotate}°</span> 221 + </label> 222 + <input type="range" min="-180" max="180" bind:value={rotate} class="slider" /> 223 + </div> 224 + 225 + <div class="slider-group"> 226 + <label class="slider-label"> 227 + scale <span class="slider-value">{scale}%</span> 228 + </label> 229 + <input type="range" min="25" max="300" bind:value={scale} class="slider" /> 230 + </div> 231 + 232 + <button class="reset-btn" onclick={() => { rotate = 0; scale = 100; }}>reset</button> 233 + 234 + {#if isOwnSticker} 235 + <div class="own-actions"> 236 + <button class="delete-btn" onclick={handleDelete} disabled={deleting}> 237 + {deleting ? 'deleting…' : 'delete sticker'} 238 + </button> 239 + </div> 240 + {/if} 241 + </div> 242 + </div> 243 + {/if} 244 + </div> 245 + 246 + <style> 247 + .preview-page { 248 + max-width: 900px; 249 + margin: 0 auto; 250 + padding: 2rem 1.5rem; 251 + font-family: 'Caveat', cursive; 252 + } 253 + 254 + .back-bar { 255 + display: flex; 256 + justify-content: space-between; 257 + align-items: center; 258 + margin-bottom: 2rem; 259 + flex-wrap: wrap; 260 + gap: 0.5rem; 261 + } 262 + 263 + .back-link, 264 + .profile-link { 265 + font-size: 1rem; 266 + color: #8a7a9a; 267 + text-decoration: none; 268 + } 269 + 270 + .back-link:hover, 271 + .profile-link:hover { color: #5a4a6a; } 272 + 273 + .status { 274 + display: flex; 275 + flex-direction: column; 276 + align-items: center; 277 + justify-content: center; 278 + min-height: 40vh; 279 + text-align: center; 280 + } 281 + 282 + .status p { 283 + font-size: 1.8rem; 284 + color: #aaa; 285 + margin: 0.2rem 0; 286 + } 287 + 288 + .status.error p { color: #c44; } 289 + 290 + .sub { 291 + font-size: 1rem !important; 292 + opacity: 0.7; 293 + } 294 + 295 + .content { 296 + display: flex; 297 + gap: 3rem; 298 + align-items: flex-start; 299 + flex-wrap: wrap; 300 + } 301 + 302 + .sticker-stage { 303 + flex: 0 0 auto; 304 + width: 360px; 305 + height: 360px; 306 + background: rgba(255, 254, 248, 0.8); 307 + border: 2px solid #e0d4c0; 308 + border-radius: 2px; 309 + position: relative; 310 + overflow: visible; 311 + box-shadow: inset 0 1px 4px rgba(0, 0, 0, 0.06); 312 + } 313 + 314 + .sticker-positioner { 315 + position: absolute; 316 + cursor: grab; 317 + user-select: none; 318 + } 319 + 320 + .sticker-positioner:active { cursor: grabbing; } 321 + 322 + .sticker-inner { 323 + transform-origin: center center; 324 + } 325 + 326 + .preview-svg { 327 + opacity: 0; 328 + filter: drop-shadow(4px 8px 20px rgba(0, 0, 0, 0.25)); 329 + transition: opacity 0.2s ease; 330 + display: block; 331 + } 332 + 333 + .preview-svg.loaded { opacity: 1; } 334 + 335 + .preview-img { 336 + max-width: 220px; 337 + max-height: 220px; 338 + object-fit: contain; 339 + opacity: 0; 340 + filter: drop-shadow(4px 8px 20px rgba(0, 0, 0, 0.25)); 341 + transition: opacity 0.2s ease; 342 + display: block; 343 + } 344 + 345 + .preview-img.loaded { opacity: 1; } 346 + 347 + .controls { 348 + flex: 1; 349 + min-width: 200px; 350 + padding-top: 0.5rem; 351 + } 352 + 353 + .sticker-name { 354 + font-size: 2.4rem; 355 + color: #5a4a6a; 356 + margin: 0 0 0.2rem; 357 + transform: rotate(-0.5deg); 358 + display: inline-block; 359 + } 360 + 361 + .by-line { 362 + font-size: 1rem; 363 + color: #aaa; 364 + margin: 0 0 1.5rem; 365 + } 366 + 367 + .by-line a { 368 + color: #8a7a9a; 369 + text-decoration: none; 370 + } 371 + 372 + .by-line a:hover { color: #5a4a6a; } 373 + 374 + .slider-group { margin-bottom: 1.2rem; } 375 + 376 + .slider-label { 377 + display: flex; 378 + justify-content: space-between; 379 + font-size: 1.1rem; 380 + color: #6a5a7a; 381 + margin-bottom: 0.3rem; 382 + } 383 + 384 + .slider-value { 385 + color: #aaa; 386 + font-size: 1rem; 387 + } 388 + 389 + .slider { 390 + width: 100%; 391 + accent-color: #a88ad8; 392 + cursor: pointer; 393 + } 394 + 395 + .reset-btn { 396 + font-family: 'Caveat', cursive; 397 + font-size: 1rem; 398 + padding: 0.3rem 0.9rem; 399 + background: transparent; 400 + border: 1.5px solid #ccc; 401 + border-radius: 2px; 402 + color: #999; 403 + cursor: pointer; 404 + margin-top: 0.3rem; 405 + } 406 + 407 + .reset-btn:hover { 408 + border-color: #b0a0c0; 409 + color: #6a5a7a; 410 + } 411 + 412 + .own-actions { 413 + margin-top: 2rem; 414 + padding-top: 1.5rem; 415 + border-top: 1.5px dashed #e0d4c0; 416 + } 417 + 418 + .delete-btn { 419 + font-family: 'Caveat', cursive; 420 + font-size: 1rem; 421 + padding: 0.4rem 1rem; 422 + background: transparent; 423 + border: 1.5px solid #e09090; 424 + border-radius: 2px; 425 + color: #c44; 426 + cursor: pointer; 427 + } 428 + 429 + .delete-btn:hover { background: #fff0f0; } 430 + 431 + .delete-btn:disabled { 432 + opacity: 0.5; 433 + cursor: not-allowed; 434 + } 435 + 436 + @media (max-width: 600px) { 437 + .sticker-stage { 438 + width: 100%; 439 + height: 300px; 440 + } 441 + 442 + .controls { 443 + padding-top: 0; 444 + } 445 + } 446 + </style>
+359
src/routes/upload/+page.svelte
··· 1 + <script lang="ts"> 2 + import { goto } from '$app/navigation'; 3 + import { auth } from '$lib/auth.svelte.js'; 4 + import StickerEditor from '$lib/components/StickerEditor.svelte'; 5 + import { uploadBlob, createStickerRecord } from '$lib/atproto.js'; 6 + import type { StickerWithMeta } from '$lib/types.js'; 7 + 8 + let step = $state<'pick' | 'edit' | 'uploading' | 'done'>('pick'); 9 + let selectedFile = $state<File | null>(null); 10 + let uploadError = $state(''); 11 + let createdSticker = $state<{ uri: string; cid: string } | null>(null); 12 + 13 + function handleFileInput(e: Event) { 14 + const input = e.target as HTMLInputElement; 15 + const file = input.files?.[0]; 16 + if (!file) return; 17 + if (!file.type.startsWith('image/')) { 18 + uploadError = 'Please select an image file'; 19 + return; 20 + } 21 + selectedFile = file; 22 + uploadError = ''; 23 + step = 'edit'; 24 + } 25 + 26 + function handleDrop(e: DragEvent) { 27 + e.preventDefault(); 28 + const file = e.dataTransfer?.files[0]; 29 + if (!file) return; 30 + if (!file.type.startsWith('image/')) { 31 + uploadError = 'Please drop an image file'; 32 + return; 33 + } 34 + selectedFile = file; 35 + uploadError = ''; 36 + step = 'edit'; 37 + } 38 + 39 + function handleDragOver(e: DragEvent) { 40 + e.preventDefault(); 41 + } 42 + 43 + async function handleEditorDone(result: { 44 + imageBlob: Blob; 45 + maskPath: string; 46 + imageWidth: number; 47 + imageHeight: number; 48 + borderColor: string; 49 + borderThickness: number; 50 + shortname: string; 51 + }) { 52 + if (!auth.agent || !auth.did) { 53 + uploadError = 'Not signed in'; 54 + return; 55 + } 56 + 57 + step = 'uploading'; 58 + uploadError = ''; 59 + 60 + try { 61 + const pds = auth.pds || 'https://bsky.social'; 62 + 63 + // 1. Upload blob (unmasked JPEG — mask is stored as SVG path in the record) 64 + const blobRef = await uploadBlob( 65 + auth.agent, 66 + pds, 67 + result.imageBlob, 68 + 'image/png' 69 + ); 70 + 71 + // 2. Create record 72 + const record: Parameters<typeof createStickerRecord>[3] = { 73 + shortname: result.shortname, 74 + image: blobRef, 75 + maskPath: result.maskPath, 76 + imageWidth: result.imageWidth, 77 + imageHeight: result.imageHeight 78 + }; 79 + if (result.borderColor) record.borderColor = result.borderColor; 80 + if (result.borderThickness > 0) record.borderThickness = result.borderThickness; 81 + 82 + const created = await createStickerRecord( 83 + auth.agent, 84 + pds, 85 + auth.did, 86 + record 87 + ); 88 + 89 + createdSticker = created; 90 + step = 'done'; 91 + } catch (e) { 92 + uploadError = e instanceof Error ? e.message : 'Upload failed'; 93 + step = 'edit'; 94 + } 95 + } 96 + 97 + function handleEditorCancel() { 98 + selectedFile = null; 99 + step = 'pick'; 100 + } 101 + 102 + let dropzone = $state<HTMLElement>(); 103 + </script> 104 + 105 + <svelte:head> 106 + <title>@stickies — add sticker</title> 107 + </svelte:head> 108 + 109 + <div class="upload-page"> 110 + {#if !auth.isLoggedIn && !auth.loading} 111 + <div class="auth-gate"> 112 + <div class="card"> 113 + <h2>sign in to add stickers</h2> 114 + <p>you need a Bluesky account to upload stickers</p> 115 + <a href="/" class="btn">← back to wall</a> 116 + </div> 117 + </div> 118 + {:else if step === 'pick'} 119 + <div class="pick-step"> 120 + <h1>add a sticker</h1> 121 + <div 122 + class="dropzone" 123 + bind:this={dropzone} 124 + ondrop={handleDrop} 125 + ondragover={handleDragOver} 126 + role="button" 127 + tabindex="0" 128 + > 129 + <div class="dropzone-inner"> 130 + <p class="drop-icon">🖼</p> 131 + <p class="drop-text">drop an image here</p> 132 + <p class="drop-sub">or</p> 133 + <label class="file-btn"> 134 + pick a file 135 + <input type="file" accept="image/*" onchange={handleFileInput} /> 136 + </label> 137 + <p class="drop-hint">PNG, JPG, GIF, WebP…</p> 138 + </div> 139 + </div> 140 + {#if uploadError} 141 + <p class="error">{uploadError}</p> 142 + {/if} 143 + </div> 144 + {:else if step === 'edit' && selectedFile} 145 + <div class="edit-step"> 146 + <StickerEditor 147 + file={selectedFile} 148 + onDone={handleEditorDone} 149 + onCancel={handleEditorCancel} 150 + /> 151 + {#if uploadError} 152 + <p class="error">{uploadError}</p> 153 + {/if} 154 + </div> 155 + {:else if step === 'uploading'} 156 + <div class="uploading"> 157 + <div class="card"> 158 + <p class="spinner">✦</p> 159 + <p>uploading sticker…</p> 160 + </div> 161 + </div> 162 + {:else if step === 'done'} 163 + <div class="done-step"> 164 + <div class="card success"> 165 + <p class="big">✓</p> 166 + <h2>sticker added!</h2> 167 + <div class="done-actions"> 168 + <a href="/profile/{auth.handle ?? auth.did}" class="btn">see my stickers</a> 169 + <button class="btn-secondary" onclick={() => { step = 'pick'; selectedFile = null; }}> 170 + add another 171 + </button> 172 + </div> 173 + </div> 174 + </div> 175 + {/if} 176 + </div> 177 + 178 + <style> 179 + .upload-page { 180 + max-width: 900px; 181 + margin: 0 auto; 182 + padding: 2rem 1.5rem; 183 + font-family: 'Caveat', cursive; 184 + } 185 + 186 + h1 { 187 + font-size: 2.5rem; 188 + color: #5a4a6a; 189 + margin: 0 0 1.5rem; 190 + transform: rotate(-0.5deg); 191 + display: inline-block; 192 + } 193 + 194 + .auth-gate, .uploading, .done-step { 195 + min-height: 70vh; 196 + display: flex; 197 + align-items: center; 198 + justify-content: center; 199 + } 200 + 201 + .card { 202 + background: #fffef8; 203 + border: 2px solid #e0d4c0; 204 + border-radius: 2px; 205 + padding: 2.5rem 3rem; 206 + text-align: center; 207 + box-shadow: 3px 4px 12px rgba(0,0,0,0.1); 208 + transform: rotate(-0.5deg); 209 + } 210 + 211 + .card h2 { 212 + font-size: 1.8rem; 213 + color: #5a4a6a; 214 + margin: 0 0 0.5rem; 215 + } 216 + 217 + .card p { 218 + color: #888; 219 + margin: 0.3rem 0; 220 + } 221 + 222 + .big { 223 + font-size: 3rem !important; 224 + color: #5a9a5a !important; 225 + display: block; 226 + margin-bottom: 0.5rem; 227 + } 228 + 229 + .success { 230 + border-color: #a8c890; 231 + background: #f8fff4; 232 + } 233 + 234 + .dropzone { 235 + border: 3px dashed #c8c0b0; 236 + border-radius: 3px; 237 + background: rgba(255, 254, 248, 0.8); 238 + cursor: pointer; 239 + transition: border-color 0.2s, background 0.2s; 240 + min-height: 280px; 241 + display: flex; 242 + align-items: center; 243 + justify-content: center; 244 + } 245 + 246 + .dropzone:hover, .dropzone:focus { 247 + border-color: #a88ad8; 248 + background: rgba(240, 232, 255, 0.4); 249 + outline: none; 250 + } 251 + 252 + .dropzone-inner { 253 + text-align: center; 254 + padding: 2rem; 255 + } 256 + 257 + .drop-icon { 258 + font-size: 3rem; 259 + margin: 0; 260 + } 261 + 262 + .drop-text { 263 + font-size: 1.5rem; 264 + color: #888; 265 + margin: 0.3rem 0; 266 + } 267 + 268 + .drop-sub { 269 + color: #bbb; 270 + font-size: 1rem; 271 + margin: 0.4rem 0; 272 + } 273 + 274 + .drop-hint { 275 + font-size: 0.85rem; 276 + color: #bbb; 277 + margin-top: 0.5rem; 278 + } 279 + 280 + .file-btn { 281 + display: inline-block; 282 + padding: 0.5rem 1.2rem; 283 + background: #f0e8ff; 284 + border: 2px solid #c4a8e8; 285 + border-radius: 2px; 286 + cursor: pointer; 287 + font-family: 'Caveat', cursive; 288 + font-size: 1rem; 289 + color: #5a4a6a; 290 + transform: rotate(-0.5deg); 291 + } 292 + 293 + .file-btn:hover { 294 + background: #e0d0ff; 295 + } 296 + 297 + .file-btn input { 298 + display: none; 299 + } 300 + 301 + .error { 302 + color: #c44; 303 + font-size: 1rem; 304 + margin-top: 0.8rem; 305 + text-align: center; 306 + } 307 + 308 + .spinner { 309 + font-size: 2.5rem; 310 + animation: spin 2s linear infinite; 311 + display: block; 312 + margin-bottom: 0.5rem; 313 + } 314 + 315 + @keyframes spin { 316 + from { transform: rotate(0deg); } 317 + to { transform: rotate(360deg); } 318 + } 319 + 320 + .done-actions { 321 + display: flex; 322 + gap: 0.8rem; 323 + justify-content: center; 324 + margin-top: 1.2rem; 325 + flex-wrap: wrap; 326 + } 327 + 328 + .btn { 329 + display: inline-block; 330 + font-family: 'Caveat', cursive; 331 + font-size: 1rem; 332 + padding: 0.5rem 1.2rem; 333 + background: #f0e8ff; 334 + border: 2px solid #c4a8e8; 335 + border-radius: 2px; 336 + text-decoration: none; 337 + color: #5a4a6a; 338 + cursor: pointer; 339 + } 340 + 341 + .btn:hover { 342 + background: #e0d0ff; 343 + } 344 + 345 + .btn-secondary { 346 + font-family: 'Caveat', cursive; 347 + font-size: 1rem; 348 + padding: 0.5rem 1.2rem; 349 + background: transparent; 350 + border: 1.5px solid #ccc; 351 + border-radius: 2px; 352 + cursor: pointer; 353 + color: #888; 354 + } 355 + 356 + .edit-step { 357 + padding: 1rem 0; 358 + } 359 + </style>
+14
svelte.config.js
··· 1 + import adapter from '@sveltejs/adapter-static'; 2 + import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'; 3 + 4 + /** @type {import('@sveltejs/kit').Config} */ 5 + const config = { 6 + preprocess: vitePreprocess(), 7 + kit: { 8 + adapter: adapter({ 9 + fallback: 'index.html' 10 + }) 11 + } 12 + }; 13 + 14 + export default config;
+14
tsconfig.json
··· 1 + { 2 + "extends": "./.svelte-kit/tsconfig.json", 3 + "compilerOptions": { 4 + "allowJs": true, 5 + "checkJs": true, 6 + "esModuleInterop": true, 7 + "forceConsistentCasingInFileNames": true, 8 + "resolveJsonModule": true, 9 + "skipLibCheck": true, 10 + "sourceMap": true, 11 + "strict": true, 12 + "moduleResolution": "bundler" 13 + } 14 + }
+12
vite.config.ts
··· 1 + import { sveltekit } from '@sveltejs/kit/vite'; 2 + import { defineConfig } from 'vite'; 3 + 4 + export default defineConfig({ 5 + plugins: [sveltekit()], 6 + server: { 7 + host: '127.0.0.1' 8 + }, 9 + optimizeDeps: { 10 + exclude: ['animejs'] 11 + } 12 + });