diff --git a/src/lib/atproto/methods.ts b/src/lib/atproto/methods.ts index ade4f1a..c5ee379 100644 --- a/src/lib/atproto/methods.ts +++ b/src/lib/atproto/methods.ts @@ -228,12 +228,16 @@ export async function deleteRecord({ /** * Uploads a blob via remote function. + * Converts the Blob to a byte array for serialization across the remote boundary. */ export async function uploadBlob({ blob }: { blob: Blob }) { if (!user.did) throw new Error("Can't upload blob: Not logged in"); + const arrayBuffer = await blob.arrayBuffer(); + const bytes = Array.from(new Uint8Array(arrayBuffer)); + const { uploadBlob: uploadBlobRemote } = await import('./server/repo.remote'); - return await uploadBlobRemote({ blob }); + return await uploadBlobRemote({ bytes, mimeType: blob.type || 'application/octet-stream' }); } /** diff --git a/src/lib/atproto/server/repo.remote.ts b/src/lib/atproto/server/repo.remote.ts index c3bff03..9f2df6b 100644 --- a/src/lib/atproto/server/repo.remote.ts +++ b/src/lib/atproto/server/repo.remote.ts @@ -59,15 +59,18 @@ export const deleteRecord = command( export const uploadBlob = command( v.object({ - blob: v.instance(Blob) + bytes: v.array(v.number()), + mimeType: v.string() }), async (input) => { const { locals } = getRequestEvent(); if (!locals.client || !locals.did) error(401, 'Not authenticated'); + const blob = new Blob([new Uint8Array(input.bytes)], { type: input.mimeType }); + const response = await locals.client.post('com.atproto.repo.uploadBlob', { params: { repo: locals.did }, - input: input.blob + input: blob }); if (!response.ok) error(500, 'Upload failed'); diff --git a/src/lib/atproto/settings.ts b/src/lib/atproto/settings.ts index e760afe..b187107 100644 --- a/src/lib/atproto/settings.ts +++ b/src/lib/atproto/settings.ts @@ -2,12 +2,16 @@ import { dev } from '$app/environment'; import { scope } from '@atcute/oauth-node-client'; // writable collections -export const collections = ['xyz.statusphere.status'] as const; +export const collections = ['xyz.statusphere.status', 'social.atmo.test.blob'] as const; export type AllowedCollection = (typeof collections)[number]; // OAuth scope — add scope.blob(), scope.rpc(), etc. as needed -export const scopes = ['atproto', scope.repo({ collection: [...collections] })]; +export const scopes = [ + 'atproto', + scope.repo({ collection: [...collections] }), + scope.blob({ accept: ['image/*'] }) +]; // set to false to disable signup export const ALLOW_SIGNUP = true; diff --git a/src/routes/+page.server.ts b/src/routes/+page.server.ts index 0f23e1a..2a024a8 100644 --- a/src/routes/+page.server.ts +++ b/src/routes/+page.server.ts @@ -1,27 +1,56 @@ import type { PageServerLoad } from './$types'; export const load: PageServerLoad = async ({ locals }) => { - if (!locals.client || !locals.did) return { statuses: [] }; + if (!locals.client || !locals.did) + return { + statuses: [] as { rkey: string; status: string; createdAt: string }[], + blobs: [] as { rkey: string; blob: { $type: 'blob'; ref: { $link: string }; mimeType: string; size: number }; createdAt: string }[] + }; try { - const response = await locals.client.get('com.atproto.repo.listRecords', { - params: { - repo: locals.did, - collection: 'xyz.statusphere.status', - limit: 10 - } - }); + const [statusResponse, blobResponse] = await Promise.all([ + locals.client.get('com.atproto.repo.listRecords', { + params: { + repo: locals.did, + collection: 'xyz.statusphere.status', + limit: 10 + } + }), + locals.client + .get('com.atproto.repo.listRecords', { + params: { + repo: locals.did, + collection: 'social.atmo.test.blob', + limit: 20 + } + }) + .catch(() => null) + ]); - if (!response.ok) return { statuses: [] }; + const statuses = statusResponse.ok + ? statusResponse.data.records.map((r) => ({ + rkey: r.uri.split('/').pop()!, + status: (r.value as { status: string }).status, + createdAt: (r.value as { createdAt: string }).createdAt + })) + : []; - return { - statuses: response.data.records.map((r) => ({ - rkey: r.uri.split('/').pop()!, - status: (r.value as { status: string }).status, - createdAt: (r.value as { createdAt: string }).createdAt - })) - }; + const blobs = blobResponse?.ok + ? blobResponse.data.records.map((r) => { + const value = r.value as { + blob: { $type: 'blob'; ref: { $link: string }; mimeType: string; size: number }; + createdAt: string; + }; + return { + rkey: r.uri.split('/').pop()!, + blob: value.blob, + createdAt: value.createdAt + }; + }) + : []; + + return { statuses, blobs }; } catch { - return { statuses: [] }; + return { statuses: [], blobs: [] }; } }; diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 344b81f..43e0e0d 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -2,15 +2,42 @@ import { invalidateAll } from '$app/navigation'; import { flip } from 'svelte/animate'; import { scale } from 'svelte/transition'; - import { user, logout } from '$lib/atproto'; + import { user, logout, uploadBlob } from '$lib/atproto'; import { Button, Avatar } from '@foxui/core'; import { atProtoLoginModalState, EmojiPicker } from '@foxui/social'; import { RelativeTime } from '@foxui/time'; - import { createTID } from '$lib/atproto/methods'; + import { createTID, getCDNImageBlobUrl } from '$lib/atproto/methods'; import { putRecord } from '$lib/atproto/server/repo.remote'; let { data } = $props(); + + let uploading = $state(false); + let fileInput: HTMLInputElement; + + async function handleImageUpload() { + const file = fileInput?.files?.[0]; + if (!file) return; + + uploading = true; + try { + const blobRef = await uploadBlob({ blob: file }); + await putRecord({ + rkey: createTID(), + collection: 'social.atmo.test.blob', + record: { + blob: blobRef, + createdAt: new Date().toISOString() + } + }); + fileInput.value = ''; + await invalidateAll(); + } catch (e) { + console.error('Upload failed:', e); + } finally { + uploading = false; + } + }