your personal website on atproto - mirror blento.app

Merge pull request #111 from flo-bit/card-command-bar

Card command bar

authored by Florian and committed by GitHub 0c45f933 25bcf503

+905 -98
+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 }
-42
docs/Beta.md
··· 1 - # Todo for beta version 2 - 3 - - site.standard 4 - - move description to markdownDescription and set description as text only 5 - 6 - - allow editing on mobile 7 - 8 - - get automatic layout for mobile if only edited on desktop (and vice versa) 9 - 10 - - add cards in middle of current position (both mobile and desktop version) 11 - 12 - - show nsfw warnings 13 - 14 - - card with big call to action button "create your blento" 15 - 16 - - ask to fill with some default cards on page creation 17 - 18 - - when adding images try to add them in a size that best fits aspect ratio 19 - 20 - - onboarding? 21 - 22 - - switch sidebar to a quick list of available cards with search function 23 - 24 - - test 25 - - selfhosting 26 - 27 - - guestbook card 28 - 29 - - onboarding? 30 - 31 - - switch sidebar to a quick list of available cards with search function 32 - 33 - - test 34 - - selfhosting 35 - 36 - - guestbook card 37 - 38 - - analytics? 39 - 40 - - refresh recently updated blentos (move to top of list, update profiles every 24 hours) 41 - 42 - - server side oauth?
+1
package.json
··· 85 85 "svelte-sonner": "^1.0.7", 86 86 "tailwind-merge": "^3.4.0", 87 87 "tailwind-variants": "^3.2.2", 88 + "tailwindcss-animate": "^1.0.7", 88 89 "three": "^0.176.0", 89 90 "turndown": "^7.2.2", 90 91 "wrangler": "^4.60.0"
+12
pnpm-lock.yaml
··· 146 146 tailwind-variants: 147 147 specifier: ^3.2.2 148 148 version: 3.2.2(tailwind-merge@3.4.0)(tailwindcss@4.1.18) 149 + tailwindcss-animate: 150 + specifier: ^1.0.7 151 + version: 1.0.7(tailwindcss@4.1.18) 149 152 three: 150 153 specifier: ^0.176.0 151 154 version: 0.176.0 ··· 2799 2802 peerDependenciesMeta: 2800 2803 tailwind-merge: 2801 2804 optional: true 2805 + 2806 + tailwindcss-animate@1.0.7: 2807 + resolution: {integrity: sha512-bl6mpH3T7I3UFxuvDEXLxy/VuFxBk5bbzplh7tXI68mwMokNYd1t9qPBHlnyTwfa4JGC4zP516I1hYYtQ/vspA==, tarball: https://registry.npmjs.org/tailwindcss-animate/-/tailwindcss-animate-1.0.7.tgz} 2808 + peerDependencies: 2809 + tailwindcss: '>=3.0.0 || insiders' 2802 2810 2803 2811 tailwindcss@4.1.18: 2804 2812 resolution: {integrity: sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==} ··· 5560 5568 tailwindcss: 4.1.18 5561 5569 optionalDependencies: 5562 5570 tailwind-merge: 3.4.0 5571 + 5572 + tailwindcss-animate@1.0.7(tailwindcss@4.1.18): 5573 + dependencies: 5574 + tailwindcss: 4.1.18 5563 5575 5564 5576 tailwindcss@4.1.18: {} 5565 5577
+4
src/app.css
··· 3 3 @plugin '@tailwindcss/forms'; 4 4 @plugin '@tailwindcss/typography'; 5 5 6 + 7 + @plugin "tailwindcss-animate"; 8 + 9 + 6 10 @source '../node_modules/@foxui'; 7 11 8 12 @custom-variant dark (&:where(.dark, .dark *):not(:where(.light, .light *)));
+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'
+5 -1
src/lib/cards/ATProtoCollectionsCard/index.ts
··· 19 19 item.w = 4; 20 20 item.mobileW = 8; 21 21 }, 22 - sidebarButtonText: 'Atmosphere Collections' 22 + sidebarButtonText: 'Atmosphere Collections', 23 + 24 + name: 'ATProto Collections', 25 + 26 + groups: ['Social'] 23 27 } as CardDefinition & { type: 'atprotocollections' };
+3 -1
src/lib/cards/BigSocialCard/index.ts
··· 51 51 return item; 52 52 }, 53 53 urlHandlerPriority: 1, 54 - canHaveLabel: true 54 + canHaveLabel: true, 55 + 56 + groups: ['Social'] 55 57 } as CardDefinition & { type: 'bigsocial' }; 56 58 57 59 import {
+5 -1
src/lib/cards/BlueskyMediaCard/index.ts
··· 8 8 createNew: () => {}, 9 9 creationModalComponent: CreateBlueskyMediaCardModal, 10 10 sidebarButtonText: 'Bluesky Media', 11 - canHaveLabel: true 11 + canHaveLabel: true, 12 + 13 + groups: ['Media'], 14 + 15 + name: 'Video/Image from Bluesky' 12 16 } as CardDefinition & { type: 'blueskyMedia' };
+3 -1
src/lib/cards/BlueskyPostCard/index.ts
··· 63 63 return postsMap; 64 64 }, 65 65 minW: 4, 66 - name: 'Bluesky Post' 66 + name: 'Bluesky Post', 67 + 68 + groups: ['Social'] 67 69 } as CardDefinition & { type: 'blueskyPost' };
+4 -1
src/lib/cards/ButtonCard/index.ts
··· 27 27 minW: 2, 28 28 minH: 1, 29 29 maxW: 8, 30 - maxH: 4 30 + maxH: 4, 31 + 32 + groups: ['Utilities'], 33 + name: 'Button' 31 34 };
+3 -1
src/lib/cards/DrawCard/index.ts
··· 23 23 strokeWidth: 1, 24 24 locked: true 25 25 }; 26 - } 26 + }, 27 + 28 + groups: ['Visual'] 27 29 } as CardDefinition & { type: 'draw' };
+1 -1
src/lib/cards/EmbedCard/index.ts
··· 19 19 // change: (item) => { 20 20 // return item; 21 21 // }, 22 - name: 'Embed Card' 22 + name: 'Embed' 23 23 } as CardDefinition & { type: 'embed' };
+3 -1
src/lib/cards/EventCard/index.ts
··· 112 112 113 113 urlHandlerPriority: 5, 114 114 115 - name: 'Event Card' 115 + name: 'Event', 116 + 117 + groups: ['Social'] 116 118 } as CardDefinition & { type: 'event' };
+4 -1
src/lib/cards/FluidTextCard/index.ts
··· 23 23 sidebarButtonText: 'Fluid Text', 24 24 defaultColor: 'transparent', 25 25 allowSetColor: true, 26 - minW: 2 26 + minW: 2, 27 + 28 + groups: ['Visual'], 29 + name: 'Fluid Text' 27 30 } as CardDefinition & { type: 'fluid-text' };
+3 -1
src/lib/cards/GIFCard/index.ts
··· 45 45 return null; 46 46 }, 47 47 urlHandlerPriority: 5, 48 - name: 'GIF' 48 + name: 'GIF', 49 + 50 + groups: ['Media'] 49 51 } as CardDefinition & { type: 'gif' };
+4 -1
src/lib/cards/GameCards/DinoGameCard/index.ts
··· 14 14 card.mobileH = 6; 15 15 card.cardData = {}; 16 16 }, 17 - canHaveLabel: true 17 + canHaveLabel: true, 18 + 19 + groups: ['Games'], 20 + name: 'Dino Game' 18 21 } as CardDefinition & { type: 'dino-game' };
+5 -1
src/lib/cards/GameCards/TetrisCard/index.ts
··· 19 19 card.cardData = {}; 20 20 }, 21 21 maxH: 10, 22 - canHaveLabel: true 22 + canHaveLabel: true, 23 + 24 + groups: ['Games'], 25 + 26 + name: 'Tetris' 23 27 } as CardDefinition & { type: 'tetris' };
+3 -1
src/lib/cards/GitHubProfileCard/index.ts
··· 50 50 51 51 return item; 52 52 }, 53 - name: 'Github Profile' 53 + name: 'Github Profile', 54 + 55 + groups: ['Social'] 54 56 } as CardDefinition & { type: 'githubProfile' }; 55 57 56 58 function getGitHubUsername(url: string | undefined): string | undefined {
+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>
+65
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 + groups: ['Social'] 65 + } as CardDefinition & { type: 'guestbook' };
+19 -2
src/lib/cards/ImageCard/index.ts
··· 42 42 }, 43 43 urlHandlerPriority: 3, 44 44 45 - name: 'Image Card', 45 + name: 'Image', 46 + 47 + canHaveLabel: true, 48 + 49 + groups: ['Core'], 46 50 47 - canHaveLabel: true 51 + icon: `<svg 52 + xmlns="http://www.w3.org/2000/svg" 53 + fill="none" 54 + viewBox="0 0 24 24" 55 + stroke-width="2" 56 + stroke="currentColor" 57 + class="size-4" 58 + > 59 + <path 60 + stroke-linecap="round" 61 + stroke-linejoin="round" 62 + d="m2.25 15.75 5.159-5.159a2.25 2.25 0 0 1 3.182 0l5.159 5.159m-1.5-1.5 1.409-1.409a2.25 2.25 0 0 1 3.182 0l2.909 2.909m-18 3.75h16.5a1.5 1.5 0 0 0 1.5-1.5V6a1.5 1.5 0 0 0-1.5-1.5H3.75A1.5 1.5 0 0 0 2.25 6v12a1.5 1.5 0 0 0 1.5 1.5Zm10.5-11.25h.008v.008h-.008V8.25Zm.375 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Z" 63 + /> 64 + </svg>` 48 65 } as CardDefinition & { type: 'image' };
+5 -1
src/lib/cards/LatestBlueskyPostCard/index.ts
··· 18 18 19 19 return JSON.parse(JSON.stringify(authorFeed)); 20 20 }, 21 - minW: 4 21 + minW: 4, 22 + 23 + name: 'Latest Bluesky Post', 24 + 25 + groups: ['Social'] 22 26 } as CardDefinition & { type: 'latestPost' };
+44
src/lib/cards/LinkCard/CreateLinkCardModal.svelte
··· 1 + <script lang="ts"> 2 + import { Button, Input, Modal, Subheading } from '@foxui/core'; 3 + import type { CreationModalComponentProps } from '../types'; 4 + import { validateLink } from '$lib/helper'; 5 + 6 + let { item = $bindable(), oncreate, oncancel }: CreationModalComponentProps = $props(); 7 + 8 + let isFetchingLocation = $state(false); 9 + 10 + let errorMessage = $state(''); 11 + </script> 12 + 13 + <Modal open={true} closeButton={false}> 14 + <form 15 + onsubmit={() => { 16 + if (!item.cardData.href.trim()) return; 17 + 18 + let link = validateLink(item.cardData.href); 19 + if (!link) { 20 + errorMessage = 'Invalid link'; 21 + return; 22 + } 23 + 24 + item.cardData.href = link; 25 + item.cardData.domain = new URL(link).hostname; 26 + item.cardData.hasFetched = false; 27 + 28 + oncreate?.(); 29 + }} 30 + class="flex flex-col gap-2" 31 + > 32 + <Subheading>Enter a link</Subheading> 33 + <Input bind:value={item.cardData.href} class="mt-4" /> 34 + 35 + {#if errorMessage} 36 + <p class="mt-2 text-sm text-red-600">{errorMessage}</p> 37 + {/if} 38 + 39 + <div class="mt-4 flex justify-end gap-2"> 40 + <Button onclick={oncancel} variant="ghost">Cancel</Button> 41 + <Button type="submit" disabled={isFetchingLocation}>Create</Button> 42 + </div> 43 + </form> 44 + </Modal>
+22 -2
src/lib/cards/LinkCard/index.ts
··· 1 1 import { checkAndUploadImage, validateLink } from '$lib/helper'; 2 2 import type { CardDefinition } from '../types'; 3 + import CreateLinkCardModal from './CreateLinkCardModal.svelte'; 3 4 import EditingLinkCard from './EditingLinkCard.svelte'; 4 5 import LinkCard from './LinkCard.svelte'; 5 6 import LinkCardSettings from './LinkCardSettings.svelte'; ··· 13 14 }, 14 15 settingsComponent: LinkCardSettings, 15 16 16 - name: 'Link Card', 17 + creationModalComponent: CreateLinkCardModal, 18 + 19 + name: 'Link', 17 20 canChange: (item) => Boolean(validateLink(item.cardData?.href)), 18 21 change: (item) => { 19 22 const href = validateLink(item.cardData?.href); ··· 36 39 await checkAndUploadImage(item.cardData, 'favicon'); 37 40 return item; 38 41 }, 39 - urlHandlerPriority: 0 42 + urlHandlerPriority: 0, 43 + 44 + groups: ['Core'], 45 + 46 + icon: `<svg 47 + xmlns="http://www.w3.org/2000/svg" 48 + fill="none" 49 + viewBox="-2 -2 28 28" 50 + stroke-width="2" 51 + stroke="currentColor" 52 + class="size-4" 53 + > 54 + <path 55 + stroke-linecap="round" 56 + stroke-linejoin="round" 57 + d="M13.19 8.688a4.5 4.5 0 0 1 1.242 7.244l-4.5 4.5a4.5 4.5 0 0 1-6.364-6.364l1.757-1.757m13.35-.622 1.757-1.757a4.5 4.5 0 0 0-6.364-6.364l-4.5 4.5a4.5 4.5 0 0 0 1.242 7.244" 58 + /> 59 + </svg>` 40 60 } as CardDefinition & { type: 'link' };
+2 -1
src/lib/cards/LivestreamCard/index.ts
··· 81 81 82 82 urlHandlerPriority: 5, 83 83 84 - name: 'stream.place Card' 84 + name: 'Latest Livestream (stream.place)', 85 + groups: ['Media'] 85 86 } as CardDefinition & { type: 'latestLivestream' }; 86 87 87 88 export const LivestreamEmbedCardDefitition = {
+10 -1
src/lib/cards/MapCard/index.ts
··· 17 17 creationModalComponent: CreateMapCardModal, 18 18 allowSetColor: false, 19 19 canHaveLabel: true, 20 - settingsComponent: MapCardSettings 20 + settingsComponent: MapCardSettings, 21 + 22 + groups: ['Core'], 23 + 24 + name: 'Map', 25 + 26 + icon: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="size-4"> 27 + <path stroke-linecap="round" stroke-linejoin="round" d="M9 6.75V15m6-6v8.25m.503 3.498 4.875-2.437c.381-.19.622-.58.622-1.006V4.82c0-.836-.88-1.38-1.628-1.006l-3.869 1.934c-.317.159-.69.159-1.006 0L9.503 3.252a1.125 1.125 0 0 0-1.006 0L3.622 5.689C3.24 5.88 3 6.27 3 6.695V19.18c0 .836.88 1.38 1.628 1.006l3.869-1.934c.317-.159.69-.159 1.006 0l4.994 2.497c.317.158.69.158 1.006 0Z" /> 28 + </svg> 29 + ` 21 30 } as CardDefinition & { type: 'mapLocation' }; 22 31 23 32 export function getZoomLevel(type: string | undefined): number {
+4 -1
src/lib/cards/PopfeedReviews/index.ts
··· 18 18 }, 19 19 minH: 3, 20 20 sidebarButtonText: 'Popfeed Reviews', 21 - canHaveLabel: true 21 + canHaveLabel: true, 22 + 23 + groups: ['Media'], 24 + name: 'Movie and TV Reviews' 22 25 } as CardDefinition & { type: 'recentPopfeedReviews' };
+16 -1
src/lib/cards/SectionCard/index.ts
··· 26 26 defaultColor: 'transparent', 27 27 maxH: 1, 28 28 canResize: false, 29 - settingsComponent: SectionCardSettings 29 + settingsComponent: SectionCardSettings, 30 + 31 + name: 'Heading', 32 + groups: ['Core'], 33 + 34 + icon: `<svg 35 + xmlns="http://www.w3.org/2000/svg" 36 + viewBox="0 0 24 24" 37 + fill="none" 38 + stroke="currentColor" 39 + stroke-width="2" 40 + stroke-linecap="round" 41 + stroke-linejoin="round" 42 + class="size-4" 43 + ><path d="M6 12h12" /><path d="M6 20V4" /><path d="M18 20V4" /></svg 44 + >` 30 45 } as CardDefinition & { type: 'section' }; 31 46 32 47 export const textAlignClasses: Record<string, string> = {
+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));
+3 -1
src/lib/cards/SpotifyCard/index.ts
··· 40 40 name: 'Spotify Embed', 41 41 canResize: true, 42 42 minW: 4, 43 - minH: 5 43 + minH: 5, 44 + 45 + groups: ['Media'] 44 46 } as CardDefinition & { type: typeof cardType }; 45 47 46 48 // Match Spotify album and playlist URLs
+5 -1
src/lib/cards/StandardSiteDocumentListCard/index.ts
··· 42 42 return records; 43 43 }, 44 44 45 - sidebarButtonText: 'site.standard.document list' 45 + sidebarButtonText: 'site.standard.document list', 46 + 47 + name: 'Blog Posts', 48 + 49 + groups: ['Content'] 46 50 } as CardDefinition & { type: 'site.standard.document list' };
+4 -1
src/lib/cards/StatusphereCard/index.ts
··· 47 47 item.cardData.label = item.cardData.title; 48 48 } 49 49 }, 50 - canHaveLabel: true 50 + canHaveLabel: true, 51 + 52 + name: 'Emoji', 53 + groups: ['Media'] 51 54 } as CardDefinition & { type: 'statusphere' }; 52 55 53 56 export function emojiToNotoAnimatedWebp(emoji: string | undefined): string | undefined {
+5 -1
src/lib/cards/TealFMPlaysCard/index.ts
··· 22 22 }, 23 23 minW: 4, 24 24 sidebarButtonText: 'teal.fm Plays', 25 - canHaveLabel: true 25 + canHaveLabel: true, 26 + 27 + name: 'Teal.fm Plays', 28 + 29 + groups: ['Media'] 26 30 } as CardDefinition & { type: 'recentTealFMPlays' };
+16 -1
src/lib/cards/TextCard/index.ts
··· 14 14 }; 15 15 }, 16 16 17 - settingsComponent: TextCardSettings 17 + settingsComponent: TextCardSettings, 18 + 19 + name: 'Text', 20 + 21 + groups: ['Core'], 22 + 23 + icon: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" class="size-4" 24 + ><path 25 + fill="none" 26 + stroke="currentColor" 27 + stroke-linecap="round" 28 + stroke-linejoin="round" 29 + stroke-width="2" 30 + d="m15 16l2.536-7.328a1.02 1.02 1 0 1 1.928 0L22 16m-6.303-2h5.606M2 16l4.039-9.69a.5.5 0 0 1 .923 0L11 16m-7.696-3h6.392" 31 + /></svg 32 + >` 18 33 } as CardDefinition & { type: 'text' }; 19 34 20 35 export const textAlignClasses: Record<string, string> = {
+2 -1
src/lib/cards/TimerCard/index.ts
··· 33 33 allowSetColor: true, 34 34 name: 'Timer Card', 35 35 minW: 4, 36 - canHaveLabel: true 36 + canHaveLabel: true, 37 + groups: ['Utilities'] 37 38 } as CardDefinition & { type: 'timer' };
+2 -1
src/lib/cards/VCardCard/index.ts
··· 122 122 123 123 sidebarButtonText: 'vCard', 124 124 allowSetColor: true, 125 - name: 'vCard Card' 125 + name: 'vCard Card', 126 + groups: ['Social'] 126 127 } as CardDefinition & { type: 'vcard' };
+10 -1
src/lib/cards/YoutubeVideoCard/index.ts
··· 51 51 52 52 return item; 53 53 }, 54 - name: 'Youtube Video' 54 + name: 'Youtube Video', 55 + 56 + groups: ['Media'], 57 + 58 + icon: `<svg xmlns="http://www.w3.org/2000/svg" class="h-3" viewBox="0 0 256 180" 59 + ><path 60 + fill="currentColor" 61 + d="M250.346 28.075A32.18 32.18 0 0 0 227.69 5.418C207.824 0 127.87 0 127.87 0S47.912.164 28.046 5.582A32.18 32.18 0 0 0 5.39 28.24c-6.009 35.298-8.34 89.084.165 122.97a32.18 32.18 0 0 0 22.656 22.657c19.866 5.418 99.822 5.418 99.822 5.418s79.955 0 99.82-5.418a32.18 32.18 0 0 0 22.657-22.657c6.338-35.348 8.291-89.1-.164-123.134" 62 + /><path fill="currentColor" class="invert" d="m102.421 128.06l66.328-38.418l-66.328-38.418z" /></svg 63 + >` 55 64 } as CardDefinition & { type: 'youtubeVideo' }; 56 65 57 66 // Thanks to eleventy-plugin-youtube-embed
+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,
+6
src/lib/cards/types.ts
··· 73 73 canHaveLabel?: boolean; 74 74 75 75 migrate?: (item: Item) => void; 76 + 77 + groups?: string[]; 78 + 79 + keywords?: string[]; 80 + 81 + icon?: string; 76 82 };
+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?.()}
+102
src/lib/components/card-command/CardCommand.svelte
··· 1 + <script lang="ts"> 2 + import { AllCardDefinitions } from '$lib/cards'; 3 + import type { CardDefinition } from '$lib/cards/types'; 4 + import { Command, Dialog } from 'bits-ui'; 5 + 6 + const CardDefGroups = [ 7 + 'Core', 8 + ...Array.from( 9 + new Set( 10 + AllCardDefinitions.map((cardDef) => cardDef.groups) 11 + .flat() 12 + .filter((g) => g) 13 + ) 14 + ) 15 + .sort() 16 + .filter((g) => g !== 'Core') 17 + ]; 18 + 19 + let { 20 + open = $bindable(false), 21 + onselect 22 + }: { open: boolean; onselect: (cardDef: CardDefinition) => void } = $props(); 23 + 24 + function handleKeydown(e: KeyboardEvent) { 25 + if (e.key === 'k' && (e.metaKey || e.ctrlKey)) { 26 + e.preventDefault(); 27 + open = true; 28 + } 29 + } 30 + </script> 31 + 32 + <svelte:document onkeydown={handleKeydown} /> 33 + 34 + <Dialog.Root bind:open> 35 + <Dialog.Portal> 36 + <Dialog.Overlay 37 + class="data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/80" 38 + /> 39 + <Dialog.Content 40 + class="data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-36 left-[50%] z-50 w-full max-w-[94%] translate-x-[-50%] outline-hidden sm:max-w-lg md:w-full" 41 + > 42 + <Dialog.Title class="sr-only">Command Menu</Dialog.Title> 43 + <Dialog.Description class="sr-only"> 44 + This is the command menu. Use the arrow keys to navigate and press ⌘K to open the search 45 + bar. 46 + </Dialog.Description> 47 + <Command.Root 48 + class="border-base-200 dark:border-base-800 mx-auto flex h-full w-full max-w-[90vw] flex-col overflow-hidden rounded-2xl border bg-white dark:bg-black" 49 + > 50 + <Command.Input 51 + class="focus-override placeholder:text-base-900/50 dark:placeholder:text-base-50/50 border-base-200 dark:border-base-800 bg-base-100 mx-1 mt-1 inline-flex truncate rounded-2xl rounded-tl-2xl px-4 text-sm transition-colors focus:ring-0 focus:outline-hidden dark:bg-black" 52 + placeholder="Search for a card..." 53 + /> 54 + <Command.List 55 + class="focus:outline-accent-500/50 max-h-[50vh] overflow-x-hidden overflow-y-auto rounded-br-2xl rounded-bl-2xl bg-white px-2 pb-2 focus:border-0 dark:bg-black" 56 + > 57 + <Command.Viewport> 58 + <Command.Empty 59 + class="text-base-900 dark:text-base-100 flex w-full items-center justify-center pt-8 pb-6 text-sm" 60 + > 61 + No results found. 62 + </Command.Empty> 63 + 64 + {#each CardDefGroups as group, index} 65 + {#if group && AllCardDefinitions.some((cardDef) => cardDef.groups?.includes(group))} 66 + <Command.Group> 67 + <Command.GroupHeading 68 + class="text-base-600 dark:text-base-400 px-3 pt-4 pb-2 text-xs" 69 + > 70 + {group} 71 + </Command.GroupHeading> 72 + <Command.GroupItems> 73 + {#each AllCardDefinitions.filter( (cardDef) => cardDef.groups?.includes(group) ) as cardDef} 74 + <Command.Item 75 + onSelect={() => { 76 + open = false; 77 + onselect(cardDef); 78 + }} 79 + class="rounded-button data-selected:bg-accent-500/10 flex h-10 cursor-pointer items-center gap-2 rounded-xl px-3 py-2.5 text-sm outline-hidden select-none" 80 + keywords={[group, cardDef.type, ...(cardDef.keywords || [])]} 81 + > 82 + {#if cardDef.icon} 83 + <div class="text-base-700 dark:text-base-300"> 84 + {@html cardDef.icon} 85 + </div> 86 + {/if} 87 + {cardDef.name} 88 + </Command.Item> 89 + {/each} 90 + </Command.GroupItems> 91 + </Command.Group> 92 + {#if index < CardDefGroups.length - 1} 93 + <Command.Separator class="bg-base-900/5 dark:bg-base-50/5 my-1 h-px w-full" /> 94 + {/if} 95 + {/if} 96 + {/each} 97 + </Command.Viewport> 98 + </Command.List> 99 + </Command.Root> 100 + </Dialog.Content> 101 + </Dialog.Portal> 102 + </Dialog.Root>
+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 >
+18
src/lib/website/EditBar.svelte
··· 22 22 handleImageInputChange, 23 23 handleVideoInputChange, 24 24 25 + showCardCommand 25 26 selectedCard = null, 26 27 isMobile = false, 27 28 isCoarse = false, ··· 44 45 handleImageInputChange: (evt: Event) => void; 45 46 handleVideoInputChange: (evt: Event) => void; 46 47 48 + showCardCommand: () => void; 47 49 selectedCard?: Item | null; 48 50 isMobile?: boolean; 49 51 isCoarse?: boolean; ··· 128 130 accept="image/*" 129 131 onchange={handleImageInputChange} 130 132 class="hidden" 133 + id="image-input" 131 134 multiple 132 135 bind:this={imageInputRef} 133 136 /> ··· 484 487 <path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" /> 485 488 </svg> 486 489 </Button> 490 + {/if} 491 + 492 + <Button size="iconLg" variant="ghost" class="backdrop-blur-none" onclick={showCardCommand}> 493 + <svg 494 + xmlns="http://www.w3.org/2000/svg" 495 + fill="none" 496 + viewBox="0 0 24 24" 497 + stroke-width="1.5" 498 + stroke="currentColor" 499 + > 500 + <path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" /> 501 + </svg> 502 + </Button> 503 + </div> 504 + <div class="flex items-center gap-2"> 487 505 </div> 488 506 {/if} 489 507 <div class={['flex items-center gap-2', showMobileEditControls ? 'hidden' : '']}>
+29 -1
src/lib/website/EditableWebsite.svelte
··· 24 24 import EditingCard from '../cards/Card/EditingCard.svelte'; 25 25 import { AllCardDefinitions, CardDefinitionsByType } from '../cards'; 26 26 import { tick, type Component } from 'svelte'; 27 - import type { CreationModalComponentProps } from '../cards/types'; 27 + import type { CardDefinition, CreationModalComponentProps } from '../cards/types'; 28 28 import { dev } from '$app/environment'; 29 29 import { setIsCoarse, setIsMobile, setSelectedCardId, setSelectCard } from './context'; 30 30 import BaseEditingCard from '../cards/BaseCard/BaseEditingCard.svelte'; ··· 38 38 import { user } from '$lib/atproto'; 39 39 import { launchConfetti } from '@foxui/visual'; 40 40 import Controls from './Controls.svelte'; 41 + import CardCommand from '$lib/components/card-command/CardCommand.svelte'; 41 42 42 43 let { 43 44 data ··· 802 803 } 803 804 804 805 // $inspect(items); 806 + 807 + let showCardCommand = $state(true); 805 808 </script> 806 809 807 810 <svelte:body ··· 833 836 <Account {data} /> 834 837 835 838 <Context {data}> 839 + {#if !dev} 840 + <div 841 + class="bg-base-200 dark:bg-base-800 fixed inset-0 z-50 inline-flex h-full w-full items-center justify-center p-4 text-center lg:hidden" 842 + > 843 + Editing on mobile is not supported yet. Please use a desktop browser. 844 + </div> 845 + {/if} 846 + 847 + <CardCommand 848 + bind:open={showCardCommand} 849 + onselect={(cardDef: CardDefinition) => { 850 + if (cardDef.type === 'image') { 851 + const input = document.getElementById('image-input') as HTMLInputElement; 852 + if (input) { 853 + input.click(); 854 + return; 855 + } 856 + } else { 857 + newCard(cardDef.type); 858 + } 859 + }} 860 + /> 861 + 836 862 <Controls bind:data /> 837 863 838 864 {#if showingMobileView} ··· 1068 1094 {save} 1069 1095 {handleImageInputChange} 1070 1096 {handleVideoInputChange} 1097 + showCardCommand={() => { 1098 + showCardCommand = true; 1071 1099 {selectedCard} 1072 1100 {isMobile} 1073 1101 {isCoarse}