diff --git a/src/lib/atproto/UI/Button.svelte b/src/lib/atproto/UI/Button.svelte new file mode 100644 index 0000000..27844dd --- /dev/null +++ b/src/lib/atproto/UI/Button.svelte @@ -0,0 +1,22 @@ + + + diff --git a/src/lib/atproto/UI/HandleInput.svelte b/src/lib/atproto/UI/HandleInput.svelte new file mode 100644 index 0000000..e8b85a4 --- /dev/null +++ b/src/lib/atproto/UI/HandleInput.svelte @@ -0,0 +1,88 @@ + + + { + if (!o) results = []; + }} + bind:value={ + () => { + return value; + }, + (val) => { + const profile = results.find((v) => v.handle === val); + if (profile) onselected?.(profile); + + value = val; + } + } + bind:open={ + () => { + return open && results.length > 0; + }, + (val) => { + open = val; + } + } +> + { + value = e.currentTarget.value; + search(e.currentTarget.value); + }} + class="focus-within:outline-accent-600 dark:focus-within:outline-accent-500 dark:placeholder:text-base-400 w-full touch-none rounded-full border-0 bg-white ring-0 outline-1 -outline-offset-1 outline-gray-300 focus-within:outline-2 focus-within:-outline-offset-2 dark:bg-white/5 dark:outline-white/10" + placeholder="handle" + id="" + aria-label="enter your handle" + /> + + + {#each results as actor (actor.did)} + + + {actor.handle} + + {/each} + + + diff --git a/src/lib/atproto/UI/LoginModal.svelte b/src/lib/atproto/UI/LoginModal.svelte new file mode 100644 index 0000000..2ad5939 --- /dev/null +++ b/src/lib/atproto/UI/LoginModal.svelte @@ -0,0 +1,265 @@ + + + + +{#if loginModalState.visible} + +{/if} diff --git a/src/lib/atproto/UI/SecondaryButton.svelte b/src/lib/atproto/UI/SecondaryButton.svelte new file mode 100644 index 0000000..b0ba4c5 --- /dev/null +++ b/src/lib/atproto/UI/SecondaryButton.svelte @@ -0,0 +1,20 @@ + + + diff --git a/src/lib/atproto/auth.svelte.ts b/src/lib/atproto/auth.svelte.ts index 11c4053..46cde8d 100644 --- a/src/lib/atproto/auth.svelte.ts +++ b/src/lib/atproto/auth.svelte.ts @@ -17,7 +17,6 @@ import { WellKnownHandleResolver } from '@atcute/identity-resolver'; import { Client } from '@atcute/client'; -import type { ActorIdentifier, Did } from '@atcute/lexicons'; import { dev } from '$app/environment'; import { replaceState } from '$app/navigation'; @@ -27,6 +26,8 @@ import { getDetailedProfile } from './methods'; import { signUpPDS } from './settings'; import { SvelteURLSearchParams } from 'svelte/reactivity'; +import type { ActorIdentifier, Did } from '@atcute/lexicons'; + export const user = $state({ agent: null as OAuthUserAgent | null, client: null as Client | null, @@ -151,7 +152,7 @@ export async function logout() { } } -async function finalizeLogin(params: URLSearchParams, did?: Did) { +async function finalizeLogin(params: SvelteURLSearchParams, did?: Did) { try { const { session } = await finalizeAuthorization(params); replaceState(location.pathname + location.search, {}); diff --git a/src/lib/atproto/image-helper.ts b/src/lib/atproto/image-helper.ts new file mode 100644 index 0000000..8a53704 --- /dev/null +++ b/src/lib/atproto/image-helper.ts @@ -0,0 +1,152 @@ +import { getCDNImageBlobUrl, uploadBlob } from './methods'; + +export function compressImage( + file: File | Blob, + maxSize: number = 900 * 1024, + maxDimension: number = 2048 +): Promise<{ + blob: Blob; + aspectRatio: { + width: number; + height: number; + }; +}> { + return new Promise((resolve, reject) => { + const img = new Image(); + const reader = new FileReader(); + + reader.onload = (e) => { + if (!e.target?.result) { + return reject(new Error('Failed to read file.')); + } + img.src = e.target.result as string; + }; + + reader.onerror = (err) => reject(err); + reader.readAsDataURL(file); + + img.onload = () => { + let width = img.width; + let height = img.height; + + // If image is already small enough, return original + if (file.size <= maxSize) { + console.log('skipping compression+resizing, already small enough'); + return resolve({ + blob: file, + aspectRatio: { + width, + height + } + }); + } + + if (width > maxDimension || height > maxDimension) { + if (width > height) { + height = Math.round((maxDimension / width) * height); + width = maxDimension; + } else { + width = Math.round((maxDimension / height) * width); + height = maxDimension; + } + } + + // Create a canvas to draw the image + const canvas = document.createElement('canvas'); + canvas.width = width; + canvas.height = height; + const ctx = canvas.getContext('2d'); + if (!ctx) return reject(new Error('Failed to get canvas context.')); + ctx.drawImage(img, 0, 0, width, height); + + // Use WebP for both compression and transparency support + let quality = 0.9; + + function attemptCompression() { + canvas.toBlob( + (blob) => { + if (!blob) { + return reject(new Error('Compression failed.')); + } + if (blob.size <= maxSize || quality < 0.3) { + resolve({ + blob, + aspectRatio: { + width, + height + } + }); + } else { + quality -= 0.1; + attemptCompression(); + } + }, + 'image/webp', + quality + ); + } + + attemptCompression(); + }; + + img.onerror = (err) => reject(err); + }); +} + +export async function checkAndUploadImage( + recordWithImage: Record, + key: string = 'image', + // e.g. /api/image-proxy?url= + imageProxy?: string +) { + if (!recordWithImage[key]) return; + + // Already uploaded as blob + if (typeof recordWithImage[key] === 'object' && recordWithImage[key].$type === 'blob') { + return; + } + + if (typeof recordWithImage[key] === 'string' && imageProxy) { + const proxyUrl = imageProxy + encodeURIComponent(recordWithImage[key]); + const response = await fetch(proxyUrl); + if (!response.ok) { + throw Error('failed to get image from image proxy'); + } + + const blob = await response.blob(); + const compressedBlob = await compressImage(blob); + + recordWithImage[key] = await uploadBlob({ blob: compressedBlob.blob }); + + return; + } + + if (recordWithImage[key]?.blob) { + if (recordWithImage[key].objectUrl) { + URL.revokeObjectURL(recordWithImage[key].objectUrl); + } + const compressedBlob = await compressImage(recordWithImage[key].blob); + recordWithImage[key] = await uploadBlob({ blob: compressedBlob.blob }); + } +} + +export function getImageFromRecord( + recordWithImage: Record | undefined, + did: string, + key: string = 'image' +): string | undefined { + if (!recordWithImage?.[key]) return; + + if (typeof recordWithImage[key] === 'object' && recordWithImage[key].$type === 'blob') { + return getCDNImageBlobUrl({ did, blob: recordWithImage[key] }); + } + + if (recordWithImage[key].objectUrl) return recordWithImage[key].objectUrl; + + if (recordWithImage[key].blob) { + recordWithImage[key].objectUrl = URL.createObjectURL(recordWithImage[key].blob); + return recordWithImage[key].objectUrl; + } + + return recordWithImage[key]; +} diff --git a/src/lib/atproto/index.ts b/src/lib/atproto/index.ts index 37b9d42..3fc0742 100644 --- a/src/lib/atproto/index.ts +++ b/src/lib/atproto/index.ts @@ -14,6 +14,7 @@ export { uploadBlob, describeRepo, getBlobURL, - getImageBlobUrl, - searchActorsTypeahead + getCDNImageBlobUrl, + searchActorsTypeahead, + getAuthorFeed } from './methods'; diff --git a/src/lib/atproto/metadata.ts b/src/lib/atproto/metadata.ts index cc4d176..f8f1560 100644 --- a/src/lib/atproto/metadata.ts +++ b/src/lib/atproto/metadata.ts @@ -1,22 +1,23 @@ import { resolve } from '$app/paths'; -import { blobs, collections, rpcCalls, SITE } from './settings'; +import { permissions, SITE } from './settings'; function constructScope() { - const repos = collections.map((collection) => 'repo:' + collection).join(' '); + const repos = permissions.collections.map((collection) => 'repo:' + collection).join(' '); let rpcs = ''; - for (const [key, value] of Object.entries(rpcCalls)) { + for (const [key, value] of Object.entries(permissions.rpc ?? {})) { if (Array.isArray(value)) { rpcs += value.map((lxm) => 'rpc?lxm=' + lxm + '&aud=' + key).join(' '); } else { rpcs += 'rpc?lxm=' + value + '&aud=' + key; } } + let blobScope: string | undefined = undefined; - if (Array.isArray(blobs)) { - blobScope = 'blob?' + blobs.map((b) => 'accept=' + b).join('&'); - } else if (blobs) { - blobScope = 'blob:' + blobs; + if (Array.isArray(permissions.blobs)) { + blobScope = 'blob?' + permissions.blobs.map((b) => 'accept=' + b).join('&'); + } else if (permissions.blobs) { + blobScope = 'blob:' + permissions.blobs; } const scope = ['atproto', repos, rpcs, blobScope].filter((v) => v?.trim()).join(' '); diff --git a/src/lib/atproto/methods.ts b/src/lib/atproto/methods.ts index 560ce04..e4e77c0 100644 --- a/src/lib/atproto/methods.ts +++ b/src/lib/atproto/methods.ts @@ -1,5 +1,6 @@ -import type { Did, Handle } from '@atcute/lexicons'; +import { parseResourceUri, type Did, type Handle } from '@atcute/lexicons'; import { user } from './auth.svelte'; +import type { AllowedCollection } from './settings'; import { CompositeDidDocumentResolver, CompositeHandleResolver, @@ -9,20 +10,27 @@ import { WellKnownHandleResolver } from '@atcute/identity-resolver'; import { Client, simpleFetchHandler } from '@atcute/client'; -import type { AppBskyActorDefs } from '@atcute/bluesky'; -import { redirect } from '@sveltejs/kit'; +import { type AppBskyActorDefs } from '@atcute/bluesky'; export type Collection = `${string}.${string}.${string}`; +import * as TID from '@atcute/tid'; +/** + * Parses an AT Protocol URI into its components. + * @param uri - The AT URI to parse (e.g., "at://did:plc:xyz/app.bsky.feed.post/abc123") + * @returns An object containing the repo, collection, and rkey or undefined if not an AT uri + */ export function parseUri(uri: string) { - const [did, collection, rkey] = uri.replace('at://', '').split('/'); - return { did, collection, rkey } as { - collection: `${string}.${string}.${string}`; - rkey: string; - did: Did; - }; + const parts = parseResourceUri(uri); + if (!parts.ok) return; + return parts.value; } +/** + * Resolves a handle to a DID using DNS and HTTP methods. + * @param handle - The handle to resolve (e.g., "alice.bsky.social") + * @returns The DID associated with the handle + */ export async function resolveHandle({ handle }: { handle: Handle }) { const handleResolver = new CompositeHandleResolver({ methods: { @@ -31,12 +39,8 @@ export async function resolveHandle({ handle }: { handle: Handle }) { } }); - try { - const data = await handleResolver.resolve(handle); - return data; - } catch (error) { - redirect(307, '/?error=handle_not_found&handle=' + handle); - } + const data = await handleResolver.resolve(handle); + return data; } const didResolver = new CompositeDidDocumentResolver({ @@ -46,8 +50,14 @@ const didResolver = new CompositeDidDocumentResolver({ } }); +/** + * Gets the PDS (Personal Data Server) URL for a given DID. + * @param did - The DID to look up + * @returns The PDS service endpoint URL + * @throws If no PDS is found in the DID document + */ export async function getPDS(did: Did) { - const doc = await didResolver.resolve(did as `did:plc:${string}` | `did:web:${string}`); + const doc = await didResolver.resolve(did as Did<'plc'> | Did<'web'>); if (!doc.service) throw new Error('No PDS found'); for (const service of doc.service) { if (service.id === '#atproto_pds') { @@ -56,6 +66,13 @@ export async function getPDS(did: Did) { } } +/** + * Fetches a detailed Bluesky profile for a user. + * @param data - Optional object with did and client + * @param data.did - The DID to fetch the profile for (defaults to current user) + * @param data.client - The client to use (defaults to public Bluesky API) + * @returns The profile data or undefined if not found + */ export async function getDetailedProfile(data?: { did?: Did; client?: Client }) { data ??= {}; data.did ??= user.did; @@ -75,30 +92,12 @@ export async function getDetailedProfile(data?: { did?: Did; client?: Client }) return response.data; } -export async function getAuthorFeed(data?: { - did?: Did; - client?: Client; - filter?: string; - limit?: number; -}) { - data ??= {}; - data.did ??= user.did; - - if (!data.did) throw new Error('Error getting detailed profile: no did'); - - data.client ??= new Client({ - handler: simpleFetchHandler({ service: 'https://public.api.bsky.app' }) - }); - - const response = await data.client.get('app.bsky.feed.getAuthorFeed', { - params: { actor: data.did, filter: data.filter ?? 'posts_with_media', limit: data.limit || 100 } - }); - - if (!response.ok) return; - - return response.data; -} - +/** + * Creates an AT Protocol client for a user's PDS. + * @param did - The DID of the user + * @returns A client configured for the user's PDS + * @throws If the PDS cannot be found + */ export async function getClient({ did }: { did: Did }) { const pds = await getPDS(did); if (!pds) throw new Error('PDS not found'); @@ -110,11 +109,20 @@ export async function getClient({ did }: { did: Did }) { return client; } +/** + * Lists records from a repository collection with pagination support. + * @param did - The DID of the repository (defaults to current user) + * @param collection - The collection to list records from + * @param cursor - Pagination cursor for continuing from a previous request + * @param limit - Maximum number of records to return (default 100, set to 0 for all records) + * @param client - The client to use (defaults to user's PDS client) + * @returns An array of records from the collection + */ export async function listRecords({ did, collection, cursor, - limit = 0, + limit = 100, client }: { did?: Did; @@ -141,7 +149,7 @@ export async function listRecords({ params: { repo: did, collection, - limit: limit || 100, + limit: !limit || limit > 100 ? 100 : limit, cursor: currentCursor } }); @@ -157,10 +165,18 @@ export async function listRecords({ return allRecords; } +/** + * Fetches a single record from a repository. + * @param did - The DID of the repository (defaults to current user) + * @param collection - The collection the record belongs to + * @param rkey - The record key (defaults to "self") + * @param client - The client to use (defaults to user's PDS client) + * @returns The record data + */ export async function getRecord({ did, collection, - rkey, + rkey = 'self', client }: { did?: Did; @@ -169,7 +185,6 @@ export async function getRecord({ client?: Client; }) { did ??= user.did; - rkey ??= 'self'; if (!collection) { throw new Error('Missing parameters for getRecord'); @@ -188,18 +203,25 @@ export async function getRecord({ } }); - if (!record.ok) return; - return JSON.parse(JSON.stringify(record.data)); } +/** + * Creates or updates a record in the current user's repository. + * Only accepts collections that are configured in permissions. + * @param collection - The collection to write to (must be in permissions.collections) + * @param rkey - The record key (defaults to "self") + * @param record - The record data to write + * @returns The response from the PDS + * @throws If the user is not logged in + */ export async function putRecord({ collection, - rkey, + rkey = 'self', record }: { - collection: Collection; - rkey: string; + collection: AllowedCollection; + rkey?: string; record: Record; }) { if (!user.client || !user.did) throw new Error('No rpc or did'); @@ -218,7 +240,21 @@ export async function putRecord({ return response; } -export async function deleteRecord({ collection, rkey }: { collection: Collection; rkey: string }) { +/** + * Deletes a record from the current user's repository. + * Only accepts collections that are configured in permissions. + * @param collection - The collection the record belongs to (must be in permissions.collections) + * @param rkey - The record key (defaults to "self") + * @returns True if the deletion was successful + * @throws If the user is not logged in + */ +export async function deleteRecord({ + collection, + rkey = 'self' +}: { + collection: AllowedCollection; + rkey: string; +}) { if (!user.client || !user.did) throw new Error('No profile or rpc or did'); const response = await user.client.post('com.atproto.repo.deleteRecord', { @@ -232,19 +268,23 @@ export async function deleteRecord({ collection, rkey }: { collection: Collectio return response.ok; } +/** + * Uploads a blob to the current user's PDS. + * @param blob - The blob data to upload + * @returns The blob metadata including ref, mimeType, and size, or undefined on failure + * @throws If the user is not logged in + */ export async function uploadBlob({ blob }: { blob: Blob }) { if (!user.did || !user.client) throw new Error("Can't upload blob: Not logged in"); const blobResponse = await user.client.post('com.atproto.repo.uploadBlob', { - input: blob, - data: { + params: { repo: user.did - } + }, + input: blob }); - if (!blobResponse?.ok) { - return; - } + if (!blobResponse?.ok) return; const blobInfo = blobResponse?.data.blob as { $type: 'blob'; @@ -258,6 +298,12 @@ export async function uploadBlob({ blob }: { blob: Blob }) { return blobInfo; } +/** + * Gets metadata about a repository. + * @param client - The client to use + * @param did - The DID of the repository (defaults to current user) + * @returns Repository metadata or undefined on failure + */ export async function describeRepo({ client, did }: { client?: Client; did?: Did }) { did ??= user.did; if (!did) { @@ -275,6 +321,12 @@ export async function describeRepo({ client, did }: { client?: Client; did?: Did return repo.data; } +/** + * Constructs a URL to fetch a blob directly from a user's PDS. + * @param did - The DID of the user who owns the blob + * @param blob - The blob reference object + * @returns The URL to fetch the blob + */ export async function getBlobURL({ did, blob @@ -291,11 +343,17 @@ export async function getBlobURL({ return `${pds}/xrpc/com.atproto.sync.getBlob?did=${did}&cid=${blob.ref.$link}`; } -export function getImageBlobUrl({ +/** + * Constructs a Bluesky CDN URL for an image blob. + * @param did - The DID of the user who owns the blob (defaults to current user) + * @param blob - The blob reference object + * @returns The CDN URL for the image in webp format + */ +export function getCDNImageBlobUrl({ did, blob }: { - did: string; + did?: string; blob: { $type: 'blob'; ref: { @@ -303,10 +361,18 @@ export function getImageBlobUrl({ }; }; }) { - if (!did || !blob?.ref?.$link) return ''; + did ??= user.did; + return `https://cdn.bsky.app/img/feed_thumbnail/plain/${did}/${blob.ref.$link}@webp`; } +/** + * Searches for actors with typeahead/autocomplete functionality. + * @param q - The search query + * @param limit - Maximum number of results (default 10) + * @param host - The API host to use (defaults to public Bluesky API) + * @returns An object containing matching actors and the original query + */ export async function searchActorsTypeahead( q: string, limit: number = 10, @@ -329,3 +395,36 @@ export async function searchActorsTypeahead( return { actors: response.data.actors, q }; } + +/** + * Return a TID based on current time + * + * @returns TID for current time + */ +export function createTID() { + return TID.now(); +} + +export async function getAuthorFeed(data?: { + did?: Did; + client?: Client; + filter?: string; + limit?: number; +}) { + data ??= {}; + data.did ??= user.did; + + if (!data.did) throw new Error('Error getting detailed profile: no did'); + + data.client ??= new Client({ + handler: simpleFetchHandler({ service: 'https://public.api.bsky.app' }) + }); + + const response = await data.client.get('app.bsky.feed.getAuthorFeed', { + params: { actor: data.did, filter: data.filter ?? 'posts_with_media', limit: data.limit || 100 } + }); + + if (!response.ok) return; + + return response.data; +} diff --git a/src/lib/atproto/settings.ts b/src/lib/atproto/settings.ts index 5e92232..30e7c74 100644 --- a/src/lib/atproto/settings.ts +++ b/src/lib/atproto/settings.ts @@ -1,20 +1,47 @@ -export const SITE = 'https://blento.app'; - -export const collections: string[] = [ - 'app.blento.card', - 'app.blento.page', - 'app.blento.settings', - 'app.blento.comment', - 'app.blento.guestbook.entry', - 'site.standard.publication', - 'site.standard.document', - 'xyz.statusphere.status' -]; - -export const rpcCalls: Record = { - //'did:web:api.bsky.app#bsky_appview': ['app.bsky.actor.getProfile'] +export const SITE = 'https://flo-bit.dev'; + +type Permissions = { + collections: readonly string[]; + rpc: Record; + blobs: readonly string[]; }; -export const blobs = ['*/*'] as string | string[] | undefined; +export const permissions = { + // collections you can create/delete/update + + // example: only allow create and delete + // collections: ['xyz.statusphere.status?action=create&action=update'], + collections: [ + 'app.blento.card', + 'app.blento.page', + 'app.blento.settings', + 'app.blento.comment', + 'app.blento.guestbook.entry', + 'site.standard.publication', + 'site.standard.document', + 'xyz.statusphere.status' + ], + + // what types of authenticated proxied requests you can make to services + + // example: allow authenticated proxying to bsky appview to get a users liked posts + //rpc: {'did:web:api.bsky.app#bsky_appview': ['app.bsky.feed.getActorLikes']} + rpc: {}, + + // what types of blobs you can upload to a users PDS + + // example: allowing video and html uploads + // blobs: ['video/*', 'text/html'] + // example: allowing all blob types + // blobs: ['*/*'] + blobs: ['*/*'] +} as const satisfies Permissions; + +// Extract base collection name (before any query params) +type ExtractCollectionBase = T extends `${infer Base}?${string}` ? Base : T; + +export type AllowedCollection = ExtractCollectionBase<(typeof permissions.collections)[number]>; -export const signUpPDS = 'https://pds.rip/'; +// which PDS to use for signup +// ATTENTION: pds.rip is only for development, all accounts get deleted automatically after a week +export const signUpPDS = 'https://selfhosted.social/'; diff --git a/src/lib/cards/EventCard/EventCard.svelte b/src/lib/cards/EventCard/EventCard.svelte index 11c541f..5e1a160 100644 --- a/src/lib/cards/EventCard/EventCard.svelte +++ b/src/lib/cards/EventCard/EventCard.svelte @@ -8,6 +8,7 @@ import { parseUri } from '$lib/atproto'; import { browser } from '$app/environment'; import { qrOverlay } from '$lib/components/qr/qrOverlay.svelte'; + import type { Did } from '@atcute/lexicons'; let { item }: ContentComponentProps = $props(); @@ -27,9 +28,9 @@ let parsedUri = $derived(item.cardData?.uri ? parseUri(item.cardData.uri) : null); onMount(async () => { - if (!eventData && item.cardData?.uri) { + if (!eventData && item.cardData?.uri && parsedUri?.repo) { const loadedData = (await CardDefinitionsByType[item.cardType]?.loadData?.([item], { - did: parsedUri?.did ?? ('' as `did:${string}:${string}`), + did: parsedUri.repo as Did, handle: '' })) as Record | undefined; @@ -92,7 +93,7 @@ let eventUrl = $derived(() => { if (eventData?.url) return eventData.url; if (parsedUri) { - return `https://smokesignal.events/${parsedUri.did}/${parsedUri.rkey}`; + return `https://smokesignal.events/${parsedUri.repo}/${parsedUri.rkey}`; } return '#'; }); @@ -104,7 +105,7 @@ const header = eventData.media.find((m) => m.role === 'header'); if (!header?.content?.ref?.$link) return null; return { - url: `https://cdn.bsky.app/img/feed_thumbnail/plain/${parsedUri.did}/${header.content.ref.$link}@jpeg`, + url: `https://cdn.bsky.app/img/feed_thumbnail/plain/${parsedUri.repo}/${header.content.ref.$link}@jpeg`, alt: header.alt || eventData.name }; }); diff --git a/src/lib/cards/EventCard/index.ts b/src/lib/cards/EventCard/index.ts index 2b43824..5d03aca 100644 --- a/src/lib/cards/EventCard/index.ts +++ b/src/lib/cards/EventCard/index.ts @@ -58,11 +58,12 @@ export const EventCardDefinition = { const uri = item.cardData?.uri; if (!uri) continue; - const { did, rkey } = parseUri(uri); + const parsedUri = parseUri(uri); + if (!parsedUri || !parsedUri.rkey || !parsedUri.repo) continue; try { const response = await fetch( - `https://smokesignal.events/xrpc/community.lexicon.calendar.GetEvent?repository=${encodeURIComponent(did)}&record_key=${encodeURIComponent(rkey)}` + `https://smokesignal.events/xrpc/community.lexicon.calendar.GetEvent?repository=${encodeURIComponent(parsedUri.repo)}&record_key=${encodeURIComponent(parsedUri.rkey)}` ); if (response.ok) { diff --git a/src/lib/cards/LivestreamCard/index.ts b/src/lib/cards/LivestreamCard/index.ts index bc8238d..9fbd513 100644 --- a/src/lib/cards/LivestreamCard/index.ts +++ b/src/lib/cards/LivestreamCard/index.ts @@ -1,4 +1,4 @@ -import { user, listRecords, getImageBlobUrl } from '$lib/atproto'; +import { user, listRecords, getCDNImageBlobUrl } from '$lib/atproto'; import type { CardDefinition } from '../types'; import LivestreamCard from './LivestreamCard.svelte'; import LivestreamEmbedCard from './LivestreamEmbedCard.svelte'; @@ -35,7 +35,7 @@ export const LivestreamCardDefitition = { createdAt: latest.value.createdAt, title: latest.value?.title as string, thumb: latest.value?.thumb?.ref?.$link - ? getImageBlobUrl({ blob: latest.value.thumb, did }) + ? getCDNImageBlobUrl({ blob: latest.value.thumb, did }) : undefined, href: latest.value?.canonicalUrl || latest.value.url, online: undefined diff --git a/src/lib/cards/PhotoGalleryCard/PhotoGalleryCard.svelte b/src/lib/cards/PhotoGalleryCard/PhotoGalleryCard.svelte index 087919d..b97ebcb 100644 --- a/src/lib/cards/PhotoGalleryCard/PhotoGalleryCard.svelte +++ b/src/lib/cards/PhotoGalleryCard/PhotoGalleryCard.svelte @@ -8,7 +8,7 @@ getIsMobile } from '$lib/website/context'; import { CardDefinitionsByType } from '..'; - import { getImageBlobUrl, parseUri } from '$lib/atproto'; + import { getCDNImageBlobUrl, parseUri } from '$lib/atproto'; import { ImageMasonry } from '@foxui/visual'; @@ -54,9 +54,9 @@ return (a.value.position ?? 0) - (b.value.position ?? 0); }) .map((i: PhotoItem) => { - const { did: photoDid } = parseUri(i.uri); + const item = parseUri(i.uri); return { - src: getImageBlobUrl({ did: photoDid, blob: i.value.photo }), + src: getCDNImageBlobUrl({ did: item?.repo, blob: i.value.photo }), name: '', width: i.value.aspectRatio.width, height: i.value.aspectRatio.height, diff --git a/src/lib/cards/PhotoGalleryCard/index.ts b/src/lib/cards/PhotoGalleryCard/index.ts index 61bb600..b4a7e18 100644 --- a/src/lib/cards/PhotoGalleryCard/index.ts +++ b/src/lib/cards/PhotoGalleryCard/index.ts @@ -1,6 +1,7 @@ import type { CardDefinition } from '../types'; import { getRecord, listRecords, parseUri } from '$lib/atproto'; import PhotoGalleryCard from './PhotoGalleryCard.svelte'; +import type { Did } from '@atcute/lexicons'; interface GalleryItem { value: { @@ -33,14 +34,14 @@ export const PhotoGalleryCardDefinition = { for (const item of items) { if (!item.cardData.galleryUri) continue; - const { did, collection } = parseUri(item.cardData.galleryUri); + const parsedUri = parseUri(item.cardData.galleryUri); - if (collection === 'social.grain.gallery') { + if (parsedUri?.collection === 'social.grain.gallery') { const itemCollection = 'social.grain.gallery.item'; if (!galleryItems[itemCollection]) { galleryItems[itemCollection] = (await listRecords({ - did, + did: parsedUri.repo as Did, collection: itemCollection })) as unknown as GalleryItem[]; } @@ -52,7 +53,12 @@ export const PhotoGalleryCardDefinition = { .filter((i) => i.value.gallery === item.cardData.galleryUri) .map(async (i) => { const itemData = parseUri(i.value.item); - const record = await getRecord(itemData); + if (!itemData) return null; + const record = await getRecord({ + did: itemData.repo as Did, + collection: itemData.collection!, + rkey: itemData.rkey + }); return { ...record, value: { ...record.value, ...i.value } }; }); diff --git a/src/lib/cards/SpecialCards/UpdatedBlentos/UpdatedBlentosCard.svelte b/src/lib/cards/SpecialCards/UpdatedBlentos/UpdatedBlentosCard.svelte index f72b0c9..adf6475 100644 --- a/src/lib/cards/SpecialCards/UpdatedBlentos/UpdatedBlentosCard.svelte +++ b/src/lib/cards/SpecialCards/UpdatedBlentos/UpdatedBlentosCard.svelte @@ -2,6 +2,7 @@ import type { ContentComponentProps } from '$lib/cards/types'; import { getAdditionalUserData } from '$lib/website/context'; import type { AppBskyActorDefs } from '@atcute/bluesky'; + import { Avatar } from '@foxui/core'; let { item }: ContentComponentProps = $props(); @@ -19,12 +20,7 @@ class="bg-base-100 dark:bg-base-800 hover:bg-base-200 dark:hover:bg-base-700 accent:bg-accent-200/30 accent:hover:bg-accent-200/50 flex h-52 w-44 min-w-44 flex-col items-center justify-center gap-2 rounded-xl p-2 transition-colors duration-150" target="_blank" > - +
{profile.displayName || profile.handle}
diff --git a/src/lib/cards/StandardSiteDocumentListCard/index.ts b/src/lib/cards/StandardSiteDocumentListCard/index.ts index da67696..f0d7fc8 100644 --- a/src/lib/cards/StandardSiteDocumentListCard/index.ts +++ b/src/lib/cards/StandardSiteDocumentListCard/index.ts @@ -22,7 +22,13 @@ export const StandardSiteDocumentListCardDefinition = { if (!publications[site]) { const siteParts = parseUri(site); - const publicationRecord = await getRecord(siteParts); + if (!siteParts) continue; + + const publicationRecord = await getRecord({ + did: siteParts.repo as `did:${string}:${string}`, + collection: siteParts.collection!, + rkey: siteParts.rkey + }); publications[site] = publicationRecord.value.url as string; } diff --git a/src/lib/helper.ts b/src/lib/helper.ts index 8680f43..ab9a622 100644 --- a/src/lib/helper.ts +++ b/src/lib/helper.ts @@ -1,8 +1,7 @@ import type { Item, WebsiteData } from './types'; import { COLUMNS, margin, mobileMargin } from '$lib'; import { CardDefinitionsByType } from './cards'; -import { deleteRecord, getImageBlobUrl, putRecord, uploadBlob } from '$lib/atproto'; -import { toast } from '@foxui/core'; +import { deleteRecord, getCDNImageBlobUrl, putRecord, uploadBlob } from '$lib/atproto'; import * as TID from '@atcute/tid'; export function clamp(value: number, min: number, max: number): number { @@ -627,7 +626,7 @@ export function getImage( if (objectWithImage[key].objectUrl) return objectWithImage[key].objectUrl; if (typeof objectWithImage[key] === 'object' && objectWithImage[key].$type === 'blob') { - return getImageBlobUrl({ did, blob: objectWithImage[key] }); + return getCDNImageBlobUrl({ did, blob: objectWithImage[key] }); } return objectWithImage[key]; } diff --git a/src/lib/website/Account.svelte b/src/lib/website/Account.svelte index e2df916..c48150c 100644 --- a/src/lib/website/Account.svelte +++ b/src/lib/website/Account.svelte @@ -2,7 +2,7 @@ import { user, login, logout } from '$lib/atproto'; import type { WebsiteData } from '$lib/types'; import type { ActorIdentifier } from '@atcute/lexicons'; - import { Button, Popover } from '@foxui/core'; + import { Avatar, Button, Popover } from '@foxui/core'; let { data @@ -18,7 +18,7 @@ {#snippet child({ props })} {/snippet} diff --git a/src/lib/website/EditableProfile.svelte b/src/lib/website/EditableProfile.svelte index 24d277c..dfce27c 100644 --- a/src/lib/website/EditableProfile.svelte +++ b/src/lib/website/EditableProfile.svelte @@ -3,7 +3,7 @@ import { getImage, compressImage, getProfilePosition } from '$lib/helper'; import PlainTextEditor from '$lib/components/PlainTextEditor.svelte'; import MarkdownTextEditor from '$lib/components/MarkdownTextEditor.svelte'; - import { Button } from '@foxui/core'; + import { Avatar, Button } from '@foxui/core'; import { getIsMobile } from './context'; import type { Editor } from '@tiptap/core'; import MadeWithBlento from './MadeWithBlento.svelte'; @@ -149,15 +149,13 @@ onmouseleave={() => (isHoveringAvatar = false)} onclick={handleFileInputClick} > - {#if getAvatarUrl()} - - {:else} -
- {/if} +
- import { login, user } from '$lib/atproto'; import BaseCard from '$lib/cards/BaseCard/BaseCard.svelte'; import Card from '$lib/cards/Card/Card.svelte'; import type { Item, WebsiteData } from '$lib/types'; - import type { ActorIdentifier } from '@atcute/lexicons'; - import { Button } from '@foxui/core'; let { data }: { data: WebsiteData } = $props(); @@ -23,7 +20,7 @@ mobileW: 8, mobileH: 3, cardType: 'text', - color: 'red', + color: 'cyan', cardData: { text: `## No blento yet!`, textAlign: 'center', @@ -34,8 +31,8 @@ // Bluesky social icon items.push({ id: 'empty-bluesky', - x: 0, - y: 2, + x: 6, + y: 0, w: 2, h: 2, mobileX: 0, diff --git a/src/lib/website/FloatingEditButton.svelte b/src/lib/website/FloatingEditButton.svelte index 1814a09..4dac7c1 100644 --- a/src/lib/website/FloatingEditButton.svelte +++ b/src/lib/website/FloatingEditButton.svelte @@ -6,6 +6,7 @@ import type { WebsiteData } from '$lib/types'; import { page } from '$app/state'; import type { ActorIdentifier } from '@atcute/lexicons'; + import { loginModalState } from '$lib/atproto/UI/LoginModal.svelte'; let { data }: { data: WebsiteData } = $props(); @@ -63,12 +64,7 @@
{:else if showLoginOnBlento}
- { - await login(handle as ActorIdentifier); - return true; - }} - /> +
{:else if showEditBlentoButton}
diff --git a/src/lib/website/Profile.svelte b/src/lib/website/Profile.svelte index 004975e..51e136b 100644 --- a/src/lib/website/Profile.svelte +++ b/src/lib/website/Profile.svelte @@ -5,6 +5,7 @@ import { page } from '$app/state'; import { qrOverlay } from '$lib/components/qr/qrOverlay.svelte'; import MadeWithBlento from './MadeWithBlento.svelte'; + import { Avatar } from '@foxui/core'; let { data, @@ -46,23 +47,13 @@ } }} > - {#if data.publication?.icon || data.profile.avatar} - - {:else} -
- {/if} +
diff --git a/src/lib/website/Website.svelte b/src/lib/website/Website.svelte index 9939836..c3233ef 100644 --- a/src/lib/website/Website.svelte +++ b/src/lib/website/Website.svelte @@ -79,7 +79,7 @@ >
- {#if data.cards.length === 0} + {#if data.cards.length === 0 && data.page === 'blento.self'} {:else} {#each data.cards.toSorted(sortItems) as item (item.id)} diff --git a/src/lib/website/load.ts b/src/lib/website/load.ts index 469143a..e133696 100644 --- a/src/lib/website/load.ts +++ b/src/lib/website/load.ts @@ -31,7 +31,7 @@ export async function getCache(handle: string, page: string, cache?: UserCache) result.page = 'blento.' + page; result.publication = (result.publications as Awaited>).find( - (v) => parseUri(v.uri).rkey === result.page + (v) => parseUri(v.uri)?.rkey === result.page )?.value; result.publication ??= { name: result.profile?.displayName || result.profile?.handle, @@ -139,7 +139,7 @@ export async function loadData( parsedResult.publication = ( parsedResult.publications as Awaited> - ).find((v) => parseUri(v.uri).rkey === parsedResult.page)?.value; + ).find((v) => parseUri(v.uri)?.rkey === parsedResult.page)?.value; parsedResult.publication ??= { name: profile?.displayName || profile?.handle, description: profile?.description diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 4aab562..041f0c1 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -7,6 +7,7 @@ import YoutubeVideoPlayer, { videoPlayer } from '$lib/components/YoutubeVideoPlayer.svelte'; import { page } from '$app/state'; import { goto } from '$app/navigation'; + import LoginModal from '$lib/atproto/UI/LoginModal.svelte'; let { children } = $props(); @@ -34,3 +35,5 @@ {#if videoPlayer.id} {/if} + +