your personal website on atproto - mirror blento.app

Merge branch 'flo-bit:main' into main

authored by

polijn and committed by
GitHub
b935c215 93fd9f28

+704 -48
+2 -1
.claude/settings.local.json
··· 24 24 "Bash(pnpm dev)", 25 25 "Bash(pnpm exec svelte-kit:*)", 26 26 "Bash(pnpm build:*)", 27 - "Bash(pnpm remove:*)" 27 + "Bash(pnpm remove:*)", 28 + "Bash(grep:*)" 28 29 ] 29 30 } 30 31 }
+2 -2
docs/Selfhosting.md
··· 24 24 ] 25 25 ``` 26 26 27 - 5. (maybe necessary? will improve performance at least) create your own kv store by running `npx wrangler kv namespace create USER_DATA_CACHE` and when asked add it to the `wrangler.jsonc` 27 + 5. optionally to improve performance: create your own kv store by running `npx wrangler kv namespace create USER_DATA_CACHE` and when asked add it to the `wrangler.jsonc` 28 28 29 29 DONE :) your blento should be live after a minute or two at `your-cloudflare-worker-or-custom-domain.com` and you can edit it by signing in with your bluesky account at `your-cloudflare-worker-or-custom-domain.com/edit` 30 30 31 31 6. some cards need their own additional env keys, if you have these cards in your profile, create your keys and add them to your cloudflare worker 32 32 33 - - github profile: GITHUB_TOKEN 33 + - github profile: GITHUB_TOKEN (token with public_repo access) 34 34 - map: PUBLIC_MAPBOX_TOKEN
+3 -1
src/lib/atproto/index.ts
··· 16 16 getBlobURL, 17 17 getCDNImageBlobUrl, 18 18 searchActorsTypeahead, 19 - getAuthorFeed 19 + getAuthorFeed, 20 + getPostThread, 21 + createPost 20 22 } from './methods';
+100
src/lib/atproto/methods.ts
··· 101 101 return response.data; 102 102 } 103 103 104 + export async function getBlentoOrBskyProfile(data: { did: Did; client?: Client }): Promise< 105 + Awaited<ReturnType<typeof getDetailedProfile>> & { 106 + hasBlento: boolean; 107 + } 108 + > { 109 + let blentoProfile; 110 + try { 111 + // try getting blento profile first 112 + blentoProfile = await getRecord({ 113 + collection: 'site.standard.publication', 114 + did: data?.did, 115 + rkey: 'blento.self', 116 + client: data?.client 117 + }); 118 + } catch { 119 + console.error('error getting blento profile, falling back to bsky profile'); 120 + } 121 + 122 + const response = await getDetailedProfile(data); 123 + 124 + return { 125 + did: data.did, 126 + handle: response?.handle, 127 + displayName: blentoProfile?.value?.name || response?.displayName || response?.handle, 128 + avatar: (getCDNImageBlobUrl({ did: data?.did, blob: blentoProfile?.value?.icon }) || 129 + response?.avatar) as `${string}:${string}`, 130 + hasBlento: Boolean(blentoProfile.value) 131 + }; 132 + } 133 + 104 134 /** 105 135 * Creates an AT Protocol client for a user's PDS. 106 136 * @param did - The DID of the user ··· 370 400 }; 371 401 }; 372 402 }) { 403 + if (!blob || !did) return; 373 404 did ??= user.did; 374 405 375 406 return `https://cdn.bsky.app/img/feed_thumbnail/plain/${did}/${blob.ref.$link}@webp`; ··· 465 496 return profile.did; 466 497 } 467 498 } 499 + 500 + /** 501 + * Fetches a post's thread including replies. 502 + * @param uri - The AT URI of the post 503 + * @param depth - How many levels of replies to fetch (default 1) 504 + * @param client - The client to use (defaults to public Bluesky API) 505 + * @returns The thread data or undefined on failure 506 + */ 507 + export async function getPostThread({ 508 + uri, 509 + depth = 1, 510 + client 511 + }: { 512 + uri: string; 513 + depth?: number; 514 + client?: Client; 515 + }) { 516 + client ??= new Client({ 517 + handler: simpleFetchHandler({ service: 'https://public.api.bsky.app' }) 518 + }); 519 + 520 + const response = await client.get('app.bsky.feed.getPostThread', { 521 + params: { uri: uri as ResourceUri, depth } 522 + }); 523 + 524 + if (!response.ok) return; 525 + 526 + return response.data.thread; 527 + } 528 + 529 + /** 530 + * Creates a Bluesky post on the authenticated user's account. 531 + * @param text - The post text 532 + * @param facets - Optional rich text facets (links, mentions, etc.) 533 + * @returns The response containing the post's URI and CID 534 + * @throws If the user is not logged in 535 + */ 536 + export async function createPost({ 537 + text, 538 + facets 539 + }: { 540 + text: string; 541 + facets?: Array<{ 542 + index: { byteStart: number; byteEnd: number }; 543 + features: Array<{ $type: string; uri?: string; did?: string; tag?: string }>; 544 + }>; 545 + }) { 546 + if (!user.client || !user.did) throw new Error('No client or did'); 547 + 548 + const record: Record<string, unknown> = { 549 + $type: 'app.bsky.feed.post', 550 + text, 551 + createdAt: new Date().toISOString() 552 + }; 553 + 554 + if (facets) { 555 + record.facets = facets; 556 + } 557 + 558 + const response = await user.client.post('com.atproto.repo.createRecord', { 559 + input: { 560 + collection: 'app.bsky.feed.post', 561 + repo: user.did, 562 + record 563 + } 564 + }); 565 + 566 + return response; 567 + }
+1
src/lib/atproto/settings.ts
··· 20 20 'app.blento.settings', 21 21 'app.blento.comment', 22 22 'app.blento.guestbook.entry', 23 + 'app.bsky.feed.post?action=create', 23 24 'site.standard.publication', 24 25 'site.standard.document', 25 26 'xyz.statusphere.status'
+2 -1
src/lib/cards/GameCards/DinoGameCard/index.ts
··· 13 13 card.mobileW = 8; 14 14 card.mobileH = 6; 15 15 card.cardData = {}; 16 - } 16 + }, 17 + canHaveLabel: true 17 18 } as CardDefinition & { type: 'dino-game' };
+2 -1
src/lib/cards/GameCards/TetrisCard/index.ts
··· 18 18 card.mobileH = 12; 19 19 card.cardData = {}; 20 20 }, 21 - maxH: 10 21 + maxH: 10, 22 + canHaveLabel: true 22 23 } as CardDefinition & { type: 'tetris' };
+166
src/lib/cards/GuestbookCard/CreateGuestbookCardModal.svelte
··· 1 + <script lang="ts"> 2 + import { Alert, Button, Input, Modal, Subheading } from '@foxui/core'; 3 + import type { CreationModalComponentProps } from '../types'; 4 + import { createPost } from '$lib/atproto/methods'; 5 + import { user } from '$lib/atproto/auth.svelte'; 6 + import { parseBlueskyPostUrl } from '../BlueskyPostCard/utils'; 7 + 8 + let { item = $bindable(), oncreate, oncancel }: CreationModalComponentProps = $props(); 9 + 10 + let mode = $state<'create' | 'existing'>('create'); 11 + 12 + const profileUrl = `https://blento.app/${user.profile?.handle ?? ''}`; 13 + let postText = $state(`Comment on this post to appear on my Blento! ${profileUrl}`); 14 + let postUrl = $state(''); 15 + let isPosting = $state(false); 16 + let errorMessage = $state(''); 17 + 18 + function buildFacets(text: string, url: string) { 19 + const encoder = new TextEncoder(); 20 + const encoded = encoder.encode(text); 21 + const urlBytes = encoder.encode(url); 22 + 23 + let byteStart = -1; 24 + for (let i = 0; i <= encoded.length - urlBytes.length; i++) { 25 + let match = true; 26 + for (let j = 0; j < urlBytes.length; j++) { 27 + if (encoded[i + j] !== urlBytes[j]) { 28 + match = false; 29 + break; 30 + } 31 + } 32 + if (match) { 33 + byteStart = i; 34 + break; 35 + } 36 + } 37 + 38 + if (byteStart === -1) return undefined; 39 + 40 + return [ 41 + { 42 + index: { byteStart, byteEnd: byteStart + urlBytes.length }, 43 + features: [{ $type: 'app.bsky.richtext.facet#link', uri: url }] 44 + } 45 + ]; 46 + } 47 + 48 + async function handleCreateNew() { 49 + if (!postText.trim()) { 50 + errorMessage = 'Post text cannot be empty.'; 51 + return; 52 + } 53 + 54 + isPosting = true; 55 + errorMessage = ''; 56 + 57 + try { 58 + const facets = buildFacets(postText, profileUrl); 59 + const response = await createPost({ text: postText, facets }); 60 + 61 + if (!response.ok) { 62 + throw new Error('Failed to create post'); 63 + } 64 + 65 + item.cardData.uri = response.data.uri; 66 + 67 + const rkey = response.data.uri.split('/').pop(); 68 + item.cardData.href = `https://bsky.app/profile/${user.profile?.handle}/post/${rkey}`; 69 + 70 + oncreate(); 71 + } catch (err) { 72 + errorMessage = 73 + err instanceof Error ? err.message : 'Failed to create post. Please try again.'; 74 + } finally { 75 + isPosting = false; 76 + } 77 + } 78 + 79 + function handleExisting() { 80 + errorMessage = ''; 81 + const parsed = parseBlueskyPostUrl(postUrl.trim()); 82 + 83 + if (!parsed) { 84 + errorMessage = 85 + 'Invalid URL. Please enter a valid Bluesky post URL (e.g., https://bsky.app/profile/handle/post/...)'; 86 + return; 87 + } 88 + 89 + item.cardData.uri = `at://${parsed.handle}/app.bsky.feed.post/${parsed.rkey}`; 90 + item.cardData.href = postUrl.trim(); 91 + 92 + oncreate(); 93 + } 94 + 95 + async function handleSubmit() { 96 + if (mode === 'create') { 97 + await handleCreateNew(); 98 + } else { 99 + handleExisting(); 100 + } 101 + } 102 + </script> 103 + 104 + <Modal open={true} closeButton={false}> 105 + <form 106 + onsubmit={(e) => { 107 + e.preventDefault(); 108 + handleSubmit(); 109 + }} 110 + class="flex flex-col gap-2" 111 + > 112 + <Subheading>Guestbook</Subheading> 113 + 114 + <div class="flex gap-2"> 115 + <Button 116 + size="sm" 117 + variant="ghost" 118 + class={mode === 'create' ? 'bg-base-200 dark:bg-base-700' : ''} 119 + onclick={() => (mode = 'create')} 120 + > 121 + Create new post 122 + </Button> 123 + <Button 124 + size="sm" 125 + variant="ghost" 126 + class={mode === 'existing' ? 'bg-base-200 dark:bg-base-700' : ''} 127 + onclick={() => (mode = 'existing')} 128 + > 129 + Use existing post 130 + </Button> 131 + </div> 132 + 133 + {#if mode === 'create'} 134 + <p class="text-base-500 dark:text-base-400 text-sm"> 135 + This will create a post on your Bluesky account. Replies to that post will appear on your 136 + guestbook card. 137 + </p> 138 + <textarea 139 + bind:value={postText} 140 + rows="4" 141 + class="bg-base-100 dark:bg-base-800 border-base-300 dark:border-base-600 mt-2 w-full rounded-lg border p-3 text-sm focus:outline-none" 142 + ></textarea> 143 + {:else} 144 + <p class="text-base-500 dark:text-base-400 text-sm"> 145 + Paste a Bluesky post URL to use as your guestbook. Replies to that post will appear on your 146 + card. 147 + </p> 148 + <Input bind:value={postUrl} placeholder="https://bsky.app/profile/handle/post/..." /> 149 + {/if} 150 + 151 + {#if errorMessage} 152 + <Alert type="error" title="Error"><span>{errorMessage}</span></Alert> 153 + {/if} 154 + 155 + <div class="mt-4 flex justify-end gap-2"> 156 + <Button onclick={oncancel} variant="ghost">Cancel</Button> 157 + {#if mode === 'create'} 158 + <Button type="submit" disabled={isPosting || !postText.trim()}> 159 + {isPosting ? 'Posting...' : 'Post to Bluesky & Create'} 160 + </Button> 161 + {:else} 162 + <Button type="submit" disabled={!postUrl.trim()}>Create</Button> 163 + {/if} 164 + </div> 165 + </form> 166 + </Modal>
+126
src/lib/cards/GuestbookCard/GuestbookCard.svelte
··· 1 + <script lang="ts"> 2 + import { onMount } from 'svelte'; 3 + import { getAdditionalUserData, getDidContext, getHandleContext } from '$lib/website/context'; 4 + import { CardDefinitionsByType } from '..'; 5 + import type { ContentComponentProps } from '../types'; 6 + import { Button } from '@foxui/core'; 7 + import { BlueskyPost } from '$lib/components/bluesky-post'; 8 + import type { PostView } from '@atcute/bluesky/types/app/feed/defs'; 9 + 10 + let { item }: ContentComponentProps = $props(); 11 + 12 + const data = getAdditionalUserData(); 13 + const did = getDidContext(); 14 + const handle = getHandleContext(); 15 + 16 + type Reply = { 17 + $type: string; 18 + post: PostView; 19 + }; 20 + 21 + let isLoaded = $state(false); 22 + 23 + let cardUri = $derived(item.cardData.uri as string); 24 + 25 + // svelte-ignore state_referenced_locally 26 + let replies = $state<Reply[]>( 27 + ((data['guestbook'] as Record<string, Reply[]>)?.[item.cardData.uri as string] ?? []) as Reply[] 28 + ); 29 + 30 + onMount(async () => { 31 + if (!cardUri) { 32 + isLoaded = true; 33 + return; 34 + } 35 + 36 + try { 37 + const loaded = await CardDefinitionsByType[item.cardType]?.loadData?.([item], { 38 + did, 39 + handle 40 + }); 41 + const result = loaded as Record<string, Reply[]> | undefined; 42 + const freshReplies = result?.[cardUri] ?? []; 43 + 44 + if (freshReplies.length > 0) { 45 + replies = freshReplies; 46 + } 47 + 48 + if (!data['guestbook']) { 49 + data['guestbook'] = {}; 50 + } 51 + (data['guestbook'] as Record<string, Reply[]>)[cardUri] = replies; 52 + } catch (e) { 53 + console.error('Failed to load guestbook replies', e); 54 + } 55 + 56 + isLoaded = true; 57 + }); 58 + </script> 59 + 60 + <div class="flex h-full flex-col overflow-hidden p-4"> 61 + {#if item.cardData.href} 62 + <div class="mb-2 flex justify-end"> 63 + <a href={item.cardData.href} target="_blank" rel="noopener noreferrer"> 64 + <Button size="sm">Add a comment on Bluesky</Button> 65 + </a> 66 + </div> 67 + {/if} 68 + 69 + <div class="flex-1 overflow-y-auto"> 70 + {#if replies.length > 0} 71 + <div class="replies"> 72 + {#each replies as reply (reply.post.uri)} 73 + <div class="reply"> 74 + <BlueskyPost feedViewPost={reply.post} showAvatar compact showLogo={false} /> 75 + </div> 76 + {/each} 77 + </div> 78 + {:else if isLoaded} 79 + <div 80 + class="text-base-500 dark:text-base-400 accent:text-white/60 flex h-full items-center justify-center text-center text-sm" 81 + > 82 + No comments yet — share your Bluesky post to get started! 83 + </div> 84 + {:else} 85 + <div 86 + class="text-base-500 dark:text-base-400 accent:text-white/60 flex h-full items-center justify-center text-center text-sm" 87 + > 88 + Loading comments... 89 + </div> 90 + {/if} 91 + </div> 92 + </div> 93 + 94 + <style> 95 + .reply { 96 + padding-bottom: 1rem; 97 + margin-bottom: 1rem; 98 + border-bottom: 1px solid oklch(0.5 0 0 / 0.1); 99 + } 100 + 101 + .reply:last-child { 102 + border-bottom: none; 103 + margin-bottom: 0; 104 + padding-bottom: 0; 105 + } 106 + 107 + .reply :global(img:not([class*='rounded-full'])) { 108 + max-height: 10rem; 109 + } 110 + 111 + .reply :global(article) { 112 + max-height: 10rem; 113 + } 114 + 115 + @container card (width >= 30rem) { 116 + .replies { 117 + columns: 2; 118 + column-gap: 1.5rem; 119 + column-rule: 1px solid oklch(0.5 0 0 / 0.15); 120 + } 121 + 122 + .reply { 123 + break-inside: avoid; 124 + } 125 + } 126 + </style>
+64
src/lib/cards/GuestbookCard/index.ts
··· 1 + import { getPostThread } from '$lib/atproto/methods'; 2 + import type { CardDefinition } from '../types'; 3 + import GuestbookCard from './GuestbookCard.svelte'; 4 + import CreateGuestbookCardModal from './CreateGuestbookCardModal.svelte'; 5 + 6 + export const GuestbookCardDefinition = { 7 + type: 'guestbook', 8 + contentComponent: GuestbookCard, 9 + creationModalComponent: CreateGuestbookCardModal, 10 + sidebarButtonText: 'Guestbook', 11 + createNew: (card) => { 12 + card.w = 4; 13 + card.h = 6; 14 + card.mobileW = 8; 15 + card.mobileH = 12; 16 + card.cardData.label = 'Guestbook'; 17 + }, 18 + minW: 4, 19 + minH: 4, 20 + defaultColor: 'base', 21 + canHaveLabel: true, 22 + loadData: async (items) => { 23 + const uris = items 24 + .filter((item) => item.cardData?.uri) 25 + .map((item) => item.cardData.uri as string); 26 + 27 + if (uris.length === 0) return {}; 28 + 29 + const results: Record<string, unknown[]> = {}; 30 + 31 + await Promise.all( 32 + uris.map(async (uri) => { 33 + try { 34 + const thread = await getPostThread({ uri, depth: 1 }); 35 + if (thread && '$type' in thread && thread.$type === 'app.bsky.feed.defs#threadViewPost') { 36 + const typedThread = thread as { replies?: unknown[] }; 37 + results[uri] = (typedThread.replies ?? []) 38 + .filter( 39 + (r: unknown) => 40 + r != null && 41 + typeof r === 'object' && 42 + '$type' in r && 43 + (r as { $type: string }).$type === 'app.bsky.feed.defs#threadViewPost' 44 + ) 45 + .sort((a: unknown, b: unknown) => { 46 + const timeA = new Date( 47 + ((a as any).post?.record?.createdAt as string) ?? 0 48 + ).getTime(); 49 + const timeB = new Date( 50 + ((b as any).post?.record?.createdAt as string) ?? 0 51 + ).getTime(); 52 + return timeB - timeA; 53 + }); 54 + } 55 + } catch (e) { 56 + console.error('Failed to load guestbook thread for', uri, e); 57 + } 58 + }) 59 + ); 60 + 61 + return results; 62 + }, 63 + name: 'Guestbook' 64 + } as CardDefinition & { type: 'guestbook' };
+20 -18
src/lib/cards/SpecialCards/UpdatedBlentos/index.ts
··· 1 - import { getDetailedProfile } from '$lib/atproto'; 2 1 import type { CardDefinition } from '../../types'; 3 2 import UpdatedBlentosCard from './UpdatedBlentosCard.svelte'; 4 3 import type { Did } from '@atcute/lexicons'; 5 - import type { AppBskyActorDefs } from '@atcute/bluesky'; 4 + import { getBlentoOrBskyProfile } from '$lib/atproto/methods'; 5 + 6 + type ProfileWithBlentoFlag = Awaited<ReturnType<typeof getBlentoOrBskyProfile>>; 6 7 7 8 export const UpdatedBlentosCardDefitition = { 8 9 type: 'updatedBlentos', ··· 14 15 ); 15 16 const recentRecords = await response.json(); 16 17 const existingUsers = await cache?.get('updatedBlentos'); 17 - const existingUsersArray: AppBskyActorDefs.ProfileViewDetailed[] = existingUsers 18 + const existingUsersArray: ProfileWithBlentoFlag[] = existingUsers 18 19 ? JSON.parse(existingUsers) 19 20 : []; 20 21 21 - const existingUsersSet = new Set(existingUsersArray.map((v) => v.did)); 22 + const uniqueDids = new Set<Did>(recentRecords.map((v: { did: string }) => v.did as Did)); 22 23 23 - const uniqueDids = new Set<Did>(); 24 - for (const record of recentRecords as { did: string }[]) { 25 - if (!existingUsersSet.has(record.did as Did)) uniqueDids.add(record.did as Did); 26 - } 27 - 28 - const profiles: Promise<AppBskyActorDefs.ProfileViewDetailed | undefined>[] = []; 24 + const profiles: Promise<ProfileWithBlentoFlag | undefined>[] = []; 29 25 30 26 for (const did of Array.from(uniqueDids)) { 31 - const profile = getDetailedProfile({ did }); 32 - profiles.push(profile); 33 - if (profiles.length > 30) break; 27 + profiles.push(getBlentoOrBskyProfile({ did })); 34 28 } 35 29 36 30 for (let i = existingUsersArray.length - 1; i >= 0; i--) { 37 31 // if handle is handle.invalid, remove from existing users and add to profiles to refresh 38 - if (existingUsersArray[i].handle === 'handle.invalid') { 32 + if ( 33 + (existingUsersArray[i].handle === 'handle.invalid' || 34 + (!existingUsersArray[i].avatar && !existingUsersArray[i].hasBlento)) && 35 + !uniqueDids.has(existingUsersArray[i].did) 36 + ) { 39 37 const removed = existingUsersArray.splice(i, 1)[0]; 40 - profiles.push(getDetailedProfile({ did: removed.did })); 38 + profiles.push(getBlentoOrBskyProfile({ did: removed.did })); 39 + // if in unique dids, remove from older existing users and keep the newer one 40 + // so updated profiles go first 41 + } else if (uniqueDids.has(existingUsersArray[i].did)) { 42 + existingUsersArray.splice(i, 1); 41 43 } 42 44 } 43 45 44 - const result = [...(await Promise.all(profiles)), ...existingUsersArray].filter( 45 - (v) => v && v.handle !== 'handle.invalid' 46 - ); 46 + let result = [...(await Promise.all(profiles)), ...existingUsersArray]; 47 + 48 + result = result.filter((v) => v && v.handle !== 'handle.invalid'); 47 49 48 50 if (cache) { 49 51 await cache?.put('updatedBlentos', JSON.stringify(result));
+2
src/lib/cards/index.ts
··· 32 32 import { TimerCardDefinition } from './TimerCard'; 33 33 import { SpotifyCardDefinition } from './SpotifyCard'; 34 34 import { ButtonCardDefinition } from './ButtonCard'; 35 + import { GuestbookCardDefinition } from './GuestbookCard'; 35 36 // import { Model3DCardDefinition } from './Model3DCard'; 36 37 37 38 export const AllCardDefinitions = [ 39 + GuestbookCardDefinition, 38 40 ButtonCardDefinition, 39 41 ImageCardDefinition, 40 42 VideoCardDefinition,
+11 -1
src/lib/components/bluesky-post/BlueskyPost.svelte
··· 8 8 feedViewPost, 9 9 children, 10 10 showLogo = false, 11 + showAvatar = false, 12 + compact = false, 11 13 ...restProps 12 - }: { feedViewPost?: PostView; children?: Snippet; showLogo?: boolean } = $props(); 14 + }: { 15 + feedViewPost?: PostView; 16 + children?: Snippet; 17 + showLogo?: boolean; 18 + showAvatar?: boolean; 19 + compact?: boolean; 20 + } = $props(); 13 21 14 22 const postData = $derived(feedViewPost ? blueskyPostToPostData(feedViewPost) : undefined); 15 23 </script> ··· 37 45 likeHref={postData?.href} 38 46 showBookmark={false} 39 47 logo={showLogo ? logo : undefined} 48 + {showAvatar} 49 + {compact} 40 50 {...restProps} 41 51 > 42 52 {@render children?.()}
+22 -4
src/lib/components/post/Post.svelte
··· 36 36 37 37 children, 38 38 39 - logo 39 + logo, 40 + 41 + showAvatar = false, 42 + compact = false 40 43 }: WithElementRef<WithChildren<HTMLAttributes<HTMLDivElement>>> & { 41 44 data: PostData; 42 45 class?: string; ··· 61 64 customActions?: Snippet; 62 65 63 66 logo?: Snippet; 67 + 68 + showAvatar?: boolean; 69 + compact?: boolean; 64 70 } = $props(); 65 71 </script> 66 72 ··· 121 127 </div> 122 128 {/if} 123 129 <div class="flex gap-4"> 130 + {#if showAvatar && data.author.avatar} 131 + <a href={data.author.href} class="flex-shrink-0"> 132 + <img 133 + src={data.author.avatar} 134 + alt="" 135 + class={compact ? 'size-7 rounded-full object-cover' : 'size-10 rounded-full object-cover'} 136 + /> 137 + </a> 138 + {/if} 124 139 <div class="w-full"> 125 140 <div class="mb-1 flex items-start justify-between gap-2"> 126 141 <div class="flex items-start gap-4"> ··· 161 176 {/if} 162 177 163 178 <div 164 - class="text-base-600 dark:text-base-400 accent:text-accent-950 block text-sm no-underline" 179 + class={cn( 180 + 'text-base-600 dark:text-base-400 accent:text-accent-950 block no-underline', 181 + compact ? 'text-xs' : 'text-sm' 182 + )} 165 183 > 166 184 <RelativeTime date={new Date(data.createdAt)} locale="en" /> 167 185 </div> ··· 173 191 </div> 174 192 175 193 <Prose 176 - size="md" 194 + size={compact ? 'default' : 'md'} 177 195 class="accent:prose-a:text-accent-950 accent:text-base-900 accent:prose-p:text-base-900 accent:prose-a:underline" 178 196 > 179 197 {#if data.htmlContent} ··· 185 203 186 204 <PostEmbed {data} /> 187 205 188 - {#if showReply || showRepost || showLike || showBookmark || customActions} 206 + {#if !compact && (showReply || showRepost || showLike || showBookmark || customActions)} 189 207 <div 190 208 class="text-base-500 dark:text-base-400 accent:text-base-900 mt-4 flex justify-between gap-2" 191 209 >
+77 -1
src/lib/helper.ts
··· 240 240 ); 241 241 } 242 242 243 - export function setPositionOfNewItem(newItem: Item, items: Item[]) { 243 + export function setPositionOfNewItem( 244 + newItem: Item, 245 + items: Item[], 246 + viewportCenter?: { gridY: number; isMobile: boolean } 247 + ) { 248 + if (viewportCenter) { 249 + const { gridY, isMobile } = viewportCenter; 250 + 251 + if (isMobile) { 252 + // Place at viewport center Y 253 + newItem.mobileY = Math.max(0, Math.round(gridY - newItem.mobileH / 2)); 254 + newItem.mobileY = Math.floor(newItem.mobileY / 2) * 2; 255 + 256 + // Try to find a free X at this Y 257 + let found = false; 258 + for ( 259 + newItem.mobileX = 0; 260 + newItem.mobileX <= COLUMNS - newItem.mobileW; 261 + newItem.mobileX += 2 262 + ) { 263 + if (!items.some((item) => overlaps(newItem, item, true))) { 264 + found = true; 265 + break; 266 + } 267 + } 268 + if (!found) { 269 + newItem.mobileX = 0; 270 + } 271 + 272 + // Desktop: derive from mobile 273 + newItem.y = Math.max(0, Math.round(newItem.mobileY / 2)); 274 + found = false; 275 + for (newItem.x = 0; newItem.x <= COLUMNS - newItem.w; newItem.x += 2) { 276 + if (!items.some((item) => overlaps(newItem, item, false))) { 277 + found = true; 278 + break; 279 + } 280 + } 281 + if (!found) { 282 + newItem.x = 0; 283 + } 284 + } else { 285 + // Place at viewport center Y 286 + newItem.y = Math.max(0, Math.round(gridY - newItem.h / 2)); 287 + 288 + // Try to find a free X at this Y 289 + let found = false; 290 + for (newItem.x = 0; newItem.x <= COLUMNS - newItem.w; newItem.x += 2) { 291 + if (!items.some((item) => overlaps(newItem, item, false))) { 292 + found = true; 293 + break; 294 + } 295 + } 296 + if (!found) { 297 + newItem.x = 0; 298 + } 299 + 300 + // Mobile: derive from desktop 301 + newItem.mobileY = Math.max(0, Math.round(newItem.y * 2)); 302 + found = false; 303 + for ( 304 + newItem.mobileX = 0; 305 + newItem.mobileX <= COLUMNS - newItem.mobileW; 306 + newItem.mobileX += 2 307 + ) { 308 + if (!items.some((item) => overlaps(newItem, item, true))) { 309 + found = true; 310 + break; 311 + } 312 + } 313 + if (!found) { 314 + newItem.mobileX = 0; 315 + } 316 + } 317 + return; 318 + } 319 + 244 320 let foundPosition = false; 245 321 while (!foundPosition) { 246 322 for (newItem.x = 0; newItem.x <= COLUMNS - newItem.w; newItem.x++) {
+104 -18
src/lib/website/EditableWebsite.svelte
··· 129 129 130 130 let maxHeight = $derived(items.reduce((max, item) => Math.max(max, getY(item) + getH(item)), 0)); 131 131 132 + function getViewportCenterGridY(): { gridY: number; isMobile: boolean } | undefined { 133 + if (!container) return undefined; 134 + const rect = container.getBoundingClientRect(); 135 + const currentMargin = isMobile ? mobileMargin : margin; 136 + const cellSize = (rect.width - currentMargin * 2) / COLUMNS; 137 + const viewportCenterY = window.innerHeight / 2; 138 + const gridY = (viewportCenterY - rect.top - currentMargin) / cellSize; 139 + return { gridY, isMobile }; 140 + } 141 + 132 142 function newCard(type: string = 'link', cardData?: any) { 133 143 // close sidebar if open 134 144 const popover = document.getElementById('mobile-menu'); ··· 157 167 if (!newItem.item) return; 158 168 const item = newItem.item; 159 169 160 - setPositionOfNewItem(item, items); 170 + const viewportCenter = getViewportCenterGridY(); 171 + setPositionOfNewItem(item, items, viewportCenter); 161 172 162 173 items = [...items, item]; 174 + 175 + // Push overlapping items down, then compact to fill gaps 176 + fixCollisions(items, item, false, true); 177 + fixCollisions(items, item, true, true); 178 + compactItems(items, false); 179 + compactItems(items, true); 163 180 164 181 newItem = {}; 165 182 ··· 373 390 } 374 391 } 375 392 393 + function getImageDimensions(src: string): Promise<{ width: number; height: number }> { 394 + return new Promise((resolve) => { 395 + const img = new Image(); 396 + img.onload = () => resolve({ width: img.naturalWidth, height: img.naturalHeight }); 397 + img.onerror = () => resolve({ width: 1, height: 1 }); 398 + img.src = src; 399 + }); 400 + } 401 + 402 + function getBestGridSize( 403 + imageWidth: number, 404 + imageHeight: number, 405 + candidates: [number, number][] 406 + ): [number, number] { 407 + const imageRatio = imageWidth / imageHeight; 408 + let best: [number, number] = candidates[0]; 409 + let bestDiff = Infinity; 410 + 411 + for (const candidate of candidates) { 412 + const gridRatio = candidate[0] / candidate[1]; 413 + const diff = Math.abs(Math.log(imageRatio) - Math.log(gridRatio)); 414 + if (diff < bestDiff) { 415 + bestDiff = diff; 416 + best = candidate; 417 + } 418 + } 419 + 420 + return best; 421 + } 422 + 423 + const desktopSizeCandidates: [number, number][] = [ 424 + [2, 2], 425 + [2, 4], 426 + [4, 2], 427 + [4, 4], 428 + [4, 6], 429 + [6, 4] 430 + ]; 431 + const mobileSizeCandidates: [number, number][] = [ 432 + [4, 4], 433 + [4, 6], 434 + [4, 8], 435 + [6, 4], 436 + [8, 4], 437 + [8, 6] 438 + ]; 439 + 376 440 async function processImageFile(file: File, gridX?: number, gridY?: number) { 377 441 const isGif = file.type === 'image/gif'; 378 442 ··· 386 450 image: { blob: file, objectUrl } 387 451 }; 388 452 389 - // If grid position is provided 453 + // Size card based on image aspect ratio 454 + const { width, height } = await getImageDimensions(objectUrl); 455 + const [dw, dh] = getBestGridSize(width, height, desktopSizeCandidates); 456 + const [mw, mh] = getBestGridSize(width, height, mobileSizeCandidates); 457 + item.w = dw; 458 + item.h = dh; 459 + item.mobileW = mw; 460 + item.mobileH = mh; 461 + 462 + // If grid position is provided (image dropped on grid) 390 463 if (gridX !== undefined && gridY !== undefined) { 391 464 if (isMobile) { 392 465 item.mobileX = gridX; 393 466 item.mobileY = gridY; 394 - // Find valid desktop position 395 - findValidPosition(item, items, false); 467 + // Derive desktop Y from mobile 468 + item.x = Math.floor((COLUMNS - item.w) / 2); 469 + item.x = Math.floor(item.x / 2) * 2; 470 + item.y = Math.max(0, Math.round(gridY / 2)); 396 471 } else { 397 472 item.x = gridX; 398 473 item.y = gridY; 399 - // Find valid mobile position 400 - findValidPosition(item, items, true); 474 + // Derive mobile Y from desktop 475 + item.mobileX = Math.floor((COLUMNS - item.mobileW) / 2); 476 + item.mobileX = Math.floor(item.mobileX / 2) * 2; 477 + item.mobileY = Math.max(0, Math.round(gridY * 2)); 401 478 } 402 479 403 480 items = [...items, item]; 404 481 fixCollisions(items, item, isMobile); 482 + fixCollisions(items, item, !isMobile); 405 483 } else { 406 - setPositionOfNewItem(item, items); 484 + const viewportCenter = getViewportCenterGridY(); 485 + setPositionOfNewItem(item, items, viewportCenter); 407 486 items = [...items, item]; 487 + fixCollisions(items, item, false, true); 488 + fixCollisions(items, item, true, true); 489 + compactItems(items, false); 490 + compactItems(items, true); 408 491 } 409 492 410 493 await tick(); ··· 481 564 } 482 565 } 483 566 484 - for (const file of imageFiles) { 485 - await processImageFile(file, gridX, gridY); 486 - 487 - // Move to next cell position 488 - const cardW = isMobile ? 4 : 2; 489 - gridX += cardW; 490 - if (gridX + cardW > COLUMNS) { 491 - gridX = 0; 492 - gridY += isMobile ? 4 : 2; 567 + for (let i = 0; i < imageFiles.length; i++) { 568 + // First image gets the drop position, rest use normal placement 569 + if (i === 0) { 570 + await processImageFile(imageFiles[i], gridX, gridY); 571 + } else { 572 + await processImageFile(imageFiles[i]); 493 573 } 494 574 } 495 575 } ··· 537 617 objectUrl 538 618 }; 539 619 540 - setPositionOfNewItem(item, items); 620 + const viewportCenter = getViewportCenterGridY(); 621 + setPositionOfNewItem(item, items, viewportCenter); 541 622 items = [...items, item]; 623 + fixCollisions(items, item, false, true); 624 + fixCollisions(items, item, true, true); 625 + compactItems(items, false); 626 + compactItems(items, true); 542 627 543 628 await tick(); 544 629 ··· 758 843 bind:item={items[i]} 759 844 ondelete={() => { 760 845 items = items.filter((it) => it !== item); 761 - compactItems(items, isMobile); 846 + compactItems(items, false); 847 + compactItems(items, true); 762 848 }} 763 849 onsetsize={(newW: number, newH: number) => { 764 850 if (isMobile) {