your personal website on atproto - mirror blento.app

add event card, update sidebar stuff

Florian bb5ebaa0 890d1bf9

+512 -179
+1 -1
CLAUDE.md
··· 53 53 **Card System (`src/lib/cards/`):** 54 54 55 55 - `CardDefinition` type in `types.ts` defines the interface for card types 56 - - Each card type exports a definition with: `type`, `contentComponent`, optional `editingContentComponent`, `creationModalComponent`, `sidebarComponent`, `loadData`, `upload` (see more info and description in `src/lib/cards/types.ts`) 56 + - Each card type exports a definition with: `type`, `contentComponent`, optional `editingContentComponent`, `creationModalComponent`, `sidebarButtonText`, `loadData`, `upload` (see more info and description in `src/lib/cards/types.ts`) 57 57 - Card types include Text, Link, Image, Bluesky, Embed, Map, Livestream, ATProto collections, and special cards (see `src/lib/cards`). 58 58 - `AllCardDefinitions` and `CardDefinitionsByType` in `index.ts` aggregate all card types 59 59 - See e.g. `src/lib/cards/EmbedCard/` and `src/lib/cards/LivestreamCard/` for examples of implementation.
-29
src/lib/cards/ATProtoCollectionsCard/SidebarItemATProtoCollectionsCard.svelte
··· 1 - <script lang="ts"> 2 - import { Button } from '@foxui/core'; 3 - 4 - let { onclick }: { onclick: () => void } = $props(); 5 - </script> 6 - 7 - <Button {onclick} variant="ghost" class="w-full justify-start"> 8 - <svg 9 - xmlns="http://www.w3.org/2000/svg" 10 - viewBox="0 0 24 24" 11 - fill="currentColor" 12 - class="text-accent-600 dark:text-accent-400" 13 - > 14 - <path 15 - d="M21 6.375c0 2.692-4.03 4.875-9 4.875S3 9.067 3 6.375 7.03 1.5 12 1.5s9 2.183 9 4.875Z" 16 - /> 17 - <path 18 - d="M12 12.75c2.685 0 5.19-.586 7.078-1.609a8.283 8.283 0 0 0 1.897-1.384c.016.121.025.244.025.368C21 12.817 16.97 15 12 15s-9-2.183-9-4.875c0-.124.009-.247.025-.368a8.285 8.285 0 0 0 1.897 1.384C6.809 12.164 9.315 12.75 12 12.75Z" 19 - /> 20 - <path 21 - d="M12 16.5c2.685 0 5.19-.586 7.078-1.609a8.282 8.282 0 0 0 1.897-1.384c.016.121.025.244.025.368 0 2.692-4.03 4.875-9 4.875s-9-2.183-9-4.875c0-.124.009-.247.025-.368a8.284 8.284 0 0 0 1.897 1.384C6.809 15.914 9.315 16.5 12 16.5Z" 22 - /> 23 - <path 24 - d="M12 20.25c2.685 0 5.19-.586 7.078-1.609a8.282 8.282 0 0 0 1.897-1.384c.016.121.025.244.025.368 0 2.692-4.03 4.875-9 4.875s-9-2.183-9-4.875c0-.124.009-.247.025-.368a8.284 8.284 0 0 0 1.897 1.384C6.809 19.664 9.315 20.25 12 20.25Z" 25 - /> 26 - </svg> 27 - 28 - AT Proto Collections 29 - </Button>
+1 -2
src/lib/cards/ATProtoCollectionsCard/index.ts
··· 1 1 import { describeRepo } from '$lib/atproto'; 2 2 import type { CardDefinition } from '../types'; 3 3 import ATProtoCollectionsCard from './ATProtoCollectionsCard.svelte'; 4 - import SidebarItemATProtoCollectionsCard from './SidebarItemATProtoCollectionsCard.svelte'; 5 4 6 5 export const ATProtoCollectionsCardDefinition = { 7 6 type: 'atprotocollections', ··· 20 19 item.w = 4; 21 20 item.mobileW = 8; 22 21 }, 23 - sidebarComponent: SidebarItemATProtoCollectionsCard 22 + sidebarButtonText: 'Atmosphere Collections' 24 23 } as CardDefinition & { type: 'atprotocollections' };
-29
src/lib/cards/BlueskyMediaCard/SidebarItemBlueskyMediaCard.svelte
··· 1 - <script lang="ts"> 2 - import { Button } from '@foxui/core'; 3 - 4 - let { onclick }: { onclick: () => void } = $props(); 5 - </script> 6 - 7 - <Button {onclick} variant="ghost" class="w-full justify-start"> 8 - <svg 9 - xmlns="http://www.w3.org/2000/svg" 10 - class="text-accent-600 dark:text-accent-400" 11 - viewBox="0 0 24 24" 12 - fill="none" 13 - stroke="currentColor" 14 - stroke-width="2" 15 - stroke-linecap="round" 16 - stroke-linejoin="round" 17 - ><path d="m22 11-1.296-1.296a2.4 2.4 0 0 0-3.408 0L11 16" /><path 18 - d="M4 8a2 2 0 0 0-2 2v10a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2" 19 - /><circle cx="13" cy="7" r="1" fill="currentColor" /><rect 20 - x="8" 21 - y="2" 22 - width="14" 23 - height="14" 24 - rx="2" 25 - /></svg 26 - > 27 - 28 - Bluesky media 29 - </Button>
-2
src/lib/cards/BlueskyMediaCard/index.ts
··· 1 1 import type { CardDefinition } from '../types'; 2 2 import BlueskyMediaCard from './BlueskyMediaCard.svelte'; 3 3 import CreateBlueskyMediaCardModal from './CreateBlueskyMediaCardModal.svelte'; 4 - import SidebarItemBlueskyMediaCard from './SidebarItemBlueskyMediaCard.svelte'; 5 4 6 5 export const BlueskyMediaCardDefinition = { 7 6 type: 'blueskyMedia', ··· 9 8 createNew: () => {}, 10 9 creationModalComponent: CreateBlueskyMediaCardModal, 11 10 sidebarButtonText: 'Bluesky Media', 12 - sidebarComponent: SidebarItemBlueskyMediaCard, 13 11 canHaveLabel: true 14 12 } as CardDefinition & { type: 'blueskyMedia' };
+1 -1
src/lib/cards/BlueskyPostCard/index.ts
··· 13 13 card.h = 4; 14 14 card.mobileH = 8; 15 15 }, 16 - sidebarComponent: SidebarItemBlueskyPostCard, 16 + sidebarButtonText: 'Latest Bluesky Post', 17 17 loadData: async (items, { did }) => { 18 18 const authorFeed = await getAuthorFeed({ did, filter: 'posts_no_replies', limit: 2 }); 19 19
+98
src/lib/cards/EventCard/CreateEventCardModal.svelte
··· 1 + <script lang="ts"> 2 + import { Alert, Button, Input, Modal, Subheading } from '@foxui/core'; 3 + import type { CreationModalComponentProps } from '../types'; 4 + 5 + const EVENT_COLLECTION = 'community.lexicon.calendar.event'; 6 + 7 + let { item = $bindable(), oncreate, oncancel }: CreationModalComponentProps = $props(); 8 + 9 + let isValidating = $state(false); 10 + let errorMessage = $state(''); 11 + let eventUrl = $state(''); 12 + 13 + function parseEventUrl(url: string): { did: string; rkey: string } | null { 14 + // Match smokesignal.events URLs: https://smokesignal.events/{did}/{rkey} 15 + const smokesignalMatch = url.match(/^https?:\/\/smokesignal\.events\/(did:[^/]+)\/([^/?#]+)/); 16 + if (smokesignalMatch) { 17 + return { did: smokesignalMatch[1], rkey: smokesignalMatch[2] }; 18 + } 19 + 20 + // Match AT URIs: at://{did}/community.lexicon.calendar.event/{rkey} 21 + const atUriMatch = url.match(/^at:\/\/(did:[^/]+)\/([^/]+)\/([^/?#]+)/); 22 + if (atUriMatch && atUriMatch[2] === EVENT_COLLECTION) { 23 + return { did: atUriMatch[1], rkey: atUriMatch[3] }; 24 + } 25 + 26 + return null; 27 + } 28 + 29 + async function validateAndCreate() { 30 + errorMessage = ''; 31 + isValidating = true; 32 + 33 + try { 34 + const parsed = parseEventUrl(eventUrl.trim()); 35 + 36 + if (!parsed) { 37 + throw new Error('Invalid URL format'); 38 + } 39 + 40 + // Validate the event exists by fetching it 41 + const response = await fetch( 42 + `https://smokesignal.events/xrpc/community.lexicon.calendar.GetEvent?repository=${encodeURIComponent(parsed.did)}&record_key=${encodeURIComponent(parsed.rkey)}` 43 + ); 44 + 45 + if (!response.ok) { 46 + throw new Error('Event not found'); 47 + } 48 + 49 + // Store as AT URI 50 + item.cardData.uri = `at://${parsed.did}/${EVENT_COLLECTION}/${parsed.rkey}`; 51 + 52 + return true; 53 + } catch (err) { 54 + errorMessage = 55 + err instanceof Error && err.message === 'Event not found' 56 + ? "Couldn't find that event. Please check the URL and try again." 57 + : 'Invalid URL. Please enter a valid smokesignal.events URL or AT URI.'; 58 + return false; 59 + } finally { 60 + isValidating = false; 61 + } 62 + } 63 + </script> 64 + 65 + <Modal open={true} closeButton={false}> 66 + <form 67 + onsubmit={async () => { 68 + if (await validateAndCreate()) oncreate(); 69 + }} 70 + class="flex flex-col gap-2" 71 + > 72 + <Subheading>Enter a Smoke Signal event URL</Subheading> 73 + <Input 74 + bind:value={eventUrl} 75 + placeholder="https://smokesignal.events/did:.../..." 76 + class="mt-4" 77 + /> 78 + 79 + {#if errorMessage} 80 + <Alert type="error" title="Failed to create event card"><span>{errorMessage}</span></Alert> 81 + {/if} 82 + 83 + <p class="text-base-500 dark:text-base-400 mt-2 text-xs"> 84 + Paste a URL from <a 85 + href="https://smokesignal.events" 86 + class="text-accent-800 dark:text-accent-300" 87 + target="_blank">smokesignal.events</a 88 + > or an AT URI for a calendar event. 89 + </p> 90 + 91 + <div class="mt-4 flex justify-end gap-2"> 92 + <Button onclick={oncancel} variant="ghost">Cancel</Button> 93 + <Button type="submit" disabled={isValidating || !eventUrl.trim()} 94 + >{isValidating ? 'Creating...' : 'Create'}</Button 95 + > 96 + </div> 97 + </form> 98 + </Modal>
+283
src/lib/cards/EventCard/EventCard.svelte
··· 1 + <script lang="ts"> 2 + import { onMount } from 'svelte'; 3 + import { Badge, Button } from '@foxui/core'; 4 + import { getAdditionalUserData, getIsMobile } from '$lib/website/context'; 5 + import type { ContentComponentProps } from '../types'; 6 + import { CardDefinitionsByType } from '..'; 7 + import type { EventData } from '.'; 8 + import { parseUri } from '$lib/atproto'; 9 + import { browser } from '$app/environment'; 10 + 11 + let { item }: ContentComponentProps = $props(); 12 + 13 + let isMobile = getIsMobile(); 14 + let isLoaded = $state(false); 15 + let fetchedEventData = $state<EventData | undefined>(undefined); 16 + 17 + const data = getAdditionalUserData(); 18 + 19 + let eventData = $derived( 20 + fetchedEventData || 21 + ((data[item.cardType] as Record<string, EventData> | undefined)?.[item.id] as 22 + | EventData 23 + | undefined) 24 + ); 25 + 26 + let parsedUri = $derived(item.cardData?.uri ? parseUri(item.cardData.uri) : null); 27 + 28 + onMount(async () => { 29 + if (!eventData && item.cardData?.uri) { 30 + const loadedData = (await CardDefinitionsByType[item.cardType]?.loadData?.([item], { 31 + did: parsedUri?.did ?? ('' as `did:${string}:${string}`), 32 + handle: '' 33 + })) as Record<string, EventData> | undefined; 34 + 35 + if (loadedData?.[item.id]) { 36 + fetchedEventData = loadedData[item.id]; 37 + if (!data[item.cardType]) { 38 + data[item.cardType] = {}; 39 + } 40 + (data[item.cardType] as Record<string, EventData>)[item.id] = fetchedEventData; 41 + } 42 + } 43 + isLoaded = true; 44 + }); 45 + 46 + function formatDate(dateStr: string): string { 47 + const date = new Date(dateStr); 48 + return date.toLocaleDateString('en-US', { 49 + weekday: 'short', 50 + month: 'short', 51 + day: 'numeric', 52 + year: 'numeric' 53 + }); 54 + } 55 + 56 + function formatTime(dateStr: string): string { 57 + const date = new Date(dateStr); 58 + return date.toLocaleTimeString('en-US', { 59 + hour: 'numeric', 60 + minute: '2-digit' 61 + }); 62 + } 63 + 64 + function getModeLabel(mode: string): string { 65 + if (mode.includes('virtual')) return 'Virtual'; 66 + if (mode.includes('hybrid')) return 'Hybrid'; 67 + if (mode.includes('inperson')) return 'In-Person'; 68 + return 'Event'; 69 + } 70 + 71 + function getModeColor(mode: string): string { 72 + if (mode.includes('virtual')) return 'blue'; 73 + if (mode.includes('hybrid')) return 'purple'; 74 + if (mode.includes('inperson')) return 'green'; 75 + return 'gray'; 76 + } 77 + 78 + function getLocationString( 79 + locations: 80 + | Array<{ address?: { locality?: string; region?: string; country?: string } }> 81 + | undefined 82 + ): string | undefined { 83 + if (!locations || locations.length === 0) return undefined; 84 + const loc = locations[0]?.address; 85 + if (!loc) return undefined; 86 + 87 + const parts = [loc.locality, loc.region, loc.country].filter(Boolean); 88 + return parts.length > 0 ? parts.join(', ') : undefined; 89 + } 90 + 91 + let eventUrl = $derived(() => { 92 + if (eventData?.url) return eventData.url; 93 + if (parsedUri) { 94 + return `https://smokesignal.events/${parsedUri.did}/${parsedUri.rkey}`; 95 + } 96 + return '#'; 97 + }); 98 + 99 + let location = $derived(getLocationString(eventData?.locations)); 100 + 101 + let headerImage = $derived(() => { 102 + if (!eventData?.media || !parsedUri) return null; 103 + const header = eventData.media.find((m) => m.role === 'header'); 104 + if (!header?.content?.ref?.$link) return null; 105 + return { 106 + url: `https://cdn.bsky.app/img/feed_thumbnail/plain/${parsedUri.did}/${header.content.ref.$link}@jpeg`, 107 + alt: header.alt || eventData.name 108 + }; 109 + }); 110 + 111 + let showImage = $derived( 112 + browser && headerImage() && ((isMobile() && item.mobileH >= 8) || (!isMobile() && item.h >= 4)) 113 + ); 114 + </script> 115 + 116 + <div class="flex h-full flex-col justify-between overflow-hidden p-4"> 117 + {#if eventData} 118 + <div class="min-w-0 flex-1 overflow-hidden"> 119 + <div class="mb-2 flex items-center justify-between gap-2"> 120 + <div class="flex items-center gap-2"> 121 + <div 122 + class="bg-base-100 border-base-300 accent:bg-accent-100/50 accent:border-accent-200 dark:border-base-800 dark:bg-base-900 flex size-8 shrink-0 items-center justify-center rounded-xl border" 123 + > 124 + <svg 125 + xmlns="http://www.w3.org/2000/svg" 126 + fill="none" 127 + viewBox="0 0 24 24" 128 + stroke-width="1.5" 129 + stroke="currentColor" 130 + class="size-4" 131 + > 132 + <path 133 + stroke-linecap="round" 134 + stroke-linejoin="round" 135 + d="M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 0 1 2.25-2.25h13.5A2.25 2.25 0 0 1 21 7.5v11.25m-18 0A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75m-18 0v-7.5A2.25 2.25 0 0 1 5.25 9h13.5A2.25 2.25 0 0 1 21 11.25v7.5" 136 + /> 137 + </svg> 138 + </div> 139 + <Badge size="sm" color={getModeColor(eventData.mode)}> 140 + <span class="accent:text-base-900">{getModeLabel(eventData.mode)}</span> 141 + </Badge> 142 + </div> 143 + 144 + {#if isMobile() ? item.mobileW > 4 : item.w > 2} 145 + <Button href={eventUrl()} target="_blank" rel="noopener noreferrer" class="z-50" 146 + >View event</Button 147 + > 148 + {/if} 149 + </div> 150 + 151 + <h3 class="text-base-900 dark:text-base-50 mb-2 line-clamp-2 text-lg leading-tight font-bold"> 152 + {eventData.name} 153 + </h3> 154 + 155 + <div class="text-base-600 dark:text-base-400 accent:text-base-800 mb-2 text-sm"> 156 + <div class="flex items-center gap-1"> 157 + <svg 158 + xmlns="http://www.w3.org/2000/svg" 159 + fill="none" 160 + viewBox="0 0 24 24" 161 + stroke-width="1.5" 162 + stroke="currentColor" 163 + class="size-4 shrink-0" 164 + > 165 + <path 166 + stroke-linecap="round" 167 + stroke-linejoin="round" 168 + d="M12 6v6h4.5m4.5 0a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" 169 + /> 170 + </svg> 171 + <span class="truncate"> 172 + {formatDate(eventData.startsAt)} at {formatTime(eventData.startsAt)} 173 + {#if eventData.endsAt} 174 + - {formatDate(eventData.endsAt)} 175 + {/if} 176 + </span> 177 + </div> 178 + </div> 179 + 180 + {#if location} 181 + <div 182 + class="text-base-600 dark:text-base-400 accent:text-base-800 mb-2 flex items-center gap-1 text-sm" 183 + > 184 + <svg 185 + xmlns="http://www.w3.org/2000/svg" 186 + fill="none" 187 + viewBox="0 0 24 24" 188 + stroke-width="1.5" 189 + stroke="currentColor" 190 + class="size-4 shrink-0" 191 + > 192 + <path 193 + stroke-linecap="round" 194 + stroke-linejoin="round" 195 + d="M15 10.5a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" 196 + /> 197 + <path 198 + stroke-linecap="round" 199 + stroke-linejoin="round" 200 + d="M19.5 10.5c0 7.142-7.5 11.25-7.5 11.25S4.5 17.642 4.5 10.5a7.5 7.5 0 1 1 15 0Z" 201 + /> 202 + </svg> 203 + <span class="truncate">{location}</span> 204 + </div> 205 + {/if} 206 + 207 + {#if eventData.description && ((isMobile() && item.mobileH >= 5) || (!isMobile() && item.h >= 3))} 208 + <p class="text-base-500 dark:text-base-400 accent:text-base-700 mb-3 line-clamp-3 text-sm"> 209 + {eventData.description} 210 + </p> 211 + {/if} 212 + 213 + {#if (eventData.countGoing !== undefined || eventData.countInterested !== undefined) && ((isMobile() && item.mobileH >= 4) || (!isMobile() && item.h >= 3))} 214 + <div 215 + class="text-base-600 dark:text-base-400 accent:text-base-800 flex flex-wrap gap-3 text-xs" 216 + > 217 + {#if eventData.countGoing !== undefined} 218 + <div class="flex items-center gap-1"> 219 + <svg 220 + xmlns="http://www.w3.org/2000/svg" 221 + fill="none" 222 + viewBox="0 0 24 24" 223 + stroke-width="1.5" 224 + stroke="currentColor" 225 + class="size-4" 226 + > 227 + <path 228 + stroke-linecap="round" 229 + stroke-linejoin="round" 230 + d="M9 12.75 11.25 15 15 9.75M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" 231 + /> 232 + </svg> 233 + <span>{eventData.countGoing} going</span> 234 + </div> 235 + {/if} 236 + {#if eventData.countInterested !== undefined} 237 + <div class="flex items-center gap-1"> 238 + <svg 239 + xmlns="http://www.w3.org/2000/svg" 240 + fill="none" 241 + viewBox="0 0 24 24" 242 + stroke-width="1.5" 243 + stroke="currentColor" 244 + class="size-4" 245 + > 246 + <path 247 + stroke-linecap="round" 248 + stroke-linejoin="round" 249 + d="M11.48 3.499a.562.562 0 0 1 1.04 0l2.125 5.111a.563.563 0 0 0 .475.345l5.518.442c.499.04.701.663.321.988l-4.204 3.602a.563.563 0 0 0-.182.557l1.285 5.385a.562.562 0 0 1-.84.61l-4.725-2.885a.562.562 0 0 0-.586 0L6.982 20.54a.562.562 0 0 1-.84-.61l1.285-5.386a.562.562 0 0 0-.182-.557l-4.204-3.602a.562.562 0 0 1 .321-.988l5.518-.442a.563.563 0 0 0 .475-.345L11.48 3.5Z" 250 + /> 251 + </svg> 252 + <span>{eventData.countInterested} interested</span> 253 + </div> 254 + {/if} 255 + </div> 256 + {/if} 257 + </div> 258 + 259 + {#if showImage} 260 + {@const img = headerImage()} 261 + {#if img} 262 + <img src={img.url} alt={img.alt} class="mt-3 aspect-3/1 w-full rounded-xl object-cover" /> 263 + {/if} 264 + {/if} 265 + 266 + <a 267 + href={eventUrl()} 268 + class="absolute inset-0 h-full w-full" 269 + target="_blank" 270 + rel="noopener noreferrer" 271 + > 272 + <span class="sr-only">View event on smokesignal.events</span> 273 + </a> 274 + {:else if isLoaded} 275 + <div class="flex h-full w-full items-center justify-center"> 276 + <span class="text-base-500 dark:text-base-400">Event not found</span> 277 + </div> 278 + {:else} 279 + <div class="flex h-full w-full items-center justify-center"> 280 + <span class="text-base-500 dark:text-base-400">Loading event...</span> 281 + </div> 282 + {/if} 283 + </div>
+115
src/lib/cards/EventCard/index.ts
··· 1 + import { parseUri } from '$lib/atproto'; 2 + import type { CardDefinition } from '../types'; 3 + import CreateEventCardModal from './CreateEventCardModal.svelte'; 4 + import EventCard from './EventCard.svelte'; 5 + 6 + const EVENT_COLLECTION = 'community.lexicon.calendar.event'; 7 + 8 + export type EventData = { 9 + mode: string; 10 + name: string; 11 + status: string; 12 + startsAt: string; 13 + endsAt?: string; 14 + description?: string; 15 + locations?: Array<{ 16 + address?: { 17 + locality?: string; 18 + region?: string; 19 + country?: string; 20 + }; 21 + }>; 22 + media?: Array<{ 23 + alt?: string; 24 + role?: string; 25 + content?: { 26 + ref?: { 27 + $link: string; 28 + }; 29 + mimeType?: string; 30 + }; 31 + aspect_ratio?: { 32 + width: number; 33 + height: number; 34 + }; 35 + }>; 36 + countGoing?: number; 37 + countInterested?: number; 38 + url: string; 39 + }; 40 + 41 + export const EventCardDefinition = { 42 + type: 'event', 43 + contentComponent: EventCard, 44 + creationModalComponent: CreateEventCardModal, 45 + sidebarButtonText: 'Event', 46 + 47 + createNew: (card) => { 48 + card.w = 4; 49 + card.h = 4; 50 + card.mobileW = 8; 51 + card.mobileH = 6; 52 + }, 53 + 54 + loadData: async (items) => { 55 + const eventDataMap: Record<string, EventData> = {}; 56 + 57 + for (const item of items) { 58 + const uri = item.cardData?.uri; 59 + if (!uri) continue; 60 + 61 + const { did, rkey } = parseUri(uri); 62 + 63 + try { 64 + const response = await fetch( 65 + `https://smokesignal.events/xrpc/community.lexicon.calendar.GetEvent?repository=${encodeURIComponent(did)}&record_key=${encodeURIComponent(rkey)}` 66 + ); 67 + 68 + if (response.ok) { 69 + const data = await response.json(); 70 + eventDataMap[item.id] = data as EventData; 71 + } 72 + } catch (error) { 73 + console.error('Failed to fetch event data:', error); 74 + } 75 + } 76 + 77 + return eventDataMap; 78 + }, 79 + 80 + onUrlHandler: (url, item) => { 81 + // Match smokesignal.events URLs: https://smokesignal.events/{did}/{rkey} 82 + const smokesignalMatch = url.match(/^https?:\/\/smokesignal\.events\/(did:[^/]+)\/([^/?#]+)/); 83 + if (smokesignalMatch) { 84 + const [, did, rkey] = smokesignalMatch; 85 + item.w = 4; 86 + item.h = 4; 87 + item.mobileW = 8; 88 + item.mobileH = 6; 89 + item.cardType = 'event'; 90 + item.cardData.uri = `at://${did}/${EVENT_COLLECTION}/${rkey}`; 91 + return item; 92 + } 93 + 94 + // Match AT URIs: at://{did}/community.lexicon.calendar.event/{rkey} 95 + const atUriMatch = url.match(/^at:\/\/(did:[^/]+)\/([^/]+)\/([^/?#]+)/); 96 + if (atUriMatch) { 97 + const [, did, collection, rkey] = atUriMatch; 98 + if (collection === EVENT_COLLECTION) { 99 + item.w = 4; 100 + item.h = 4; 101 + item.mobileW = 8; 102 + item.mobileH = 6; 103 + item.cardType = 'event'; 104 + item.cardData.uri = `at://${did}/${collection}/${rkey}`; 105 + return item; 106 + } 107 + } 108 + 109 + return null; 110 + }, 111 + 112 + urlHandlerPriority: 5, 113 + 114 + name: 'Event Card' 115 + } as CardDefinition & { type: 'event' };
-21
src/lib/cards/GameCards/DinoGameCard/SidebarItemDinoGameCard.svelte
··· 1 - <script lang="ts"> 2 - import { Button } from '@foxui/core'; 3 - 4 - let { onclick }: { onclick: () => void } = $props(); 5 - </script> 6 - 7 - <Button {onclick} variant="ghost" class="w-full justify-start"> 8 - <svg 9 - xmlns="http://www.w3.org/2000/svg" 10 - viewBox="0 0 24 24" 11 - fill="currentColor" 12 - class="text-accent-600 dark:text-accent-400" 13 - > 14 - <!-- Dino silhouette --> 15 - <path 16 - d="M18 4h-2v2h-2v2h-2V6h-2V4H8v2H6v2H4v2H2v8h2v2h2v-2h2v-2h2v2h2v-2h2v2h2v-2h2V8h-2V6h-2V4zm-8 8H8v-2h2v2zm4 0h-2v-2h2v2z" 17 - /> 18 - </svg> 19 - 20 - Dino Game</Button 21 - >
+1 -2
src/lib/cards/GameCards/DinoGameCard/index.ts
··· 1 1 import type { CardDefinition, ContentComponentProps } from '$lib/cards/types'; 2 2 import type { Component } from 'svelte'; 3 3 import DinoGameCard from './DinoGameCard.svelte'; 4 - import SidebarItemDinoGameCard from './SidebarItemDinoGameCard.svelte'; 5 4 6 5 export const DinoGameCardDefinition = { 7 6 type: 'dino-game', 8 7 contentComponent: DinoGameCard as unknown as Component<ContentComponentProps>, 9 - sidebarComponent: SidebarItemDinoGameCard, 8 + sidebarButtonText: 'Dino Game', 10 9 allowSetColor: true, 11 10 createNew: (card) => { 12 11 card.w = 4;
-25
src/lib/cards/GameCards/TetrisCard/SidebarItemTetrisCard.svelte
··· 1 - <script lang="ts"> 2 - import { Button } from '@foxui/core'; 3 - 4 - let { onclick }: { onclick: () => void } = $props(); 5 - </script> 6 - 7 - <Button {onclick} variant="ghost" class="w-full justify-start"> 8 - <svg 9 - xmlns="http://www.w3.org/2000/svg" 10 - viewBox="0 0 24 24" 11 - fill="currentColor" 12 - class="text-accent-600 dark:text-accent-400" 13 - > 14 - <!-- Tetris blocks --> 15 - <rect x="4" y="4" width="5" height="5" /> 16 - <rect x="9" y="4" width="5" height="5" /> 17 - <rect x="9" y="9" width="5" height="5" /> 18 - <rect x="14" y="9" width="5" height="5" /> 19 - <rect x="4" y="14" width="5" height="5" /> 20 - <rect x="9" y="14" width="5" height="5" /> 21 - <rect x="14" y="14" width="5" height="5" /> 22 - </svg> 23 - 24 - Tetris</Button 25 - >
+1 -2
src/lib/cards/GameCards/TetrisCard/index.ts
··· 3 3 4 4 import type { CardDefinition, ContentComponentProps } from '../../types'; 5 5 import TetrisCard from './TetrisCard.svelte'; 6 - import SidebarItemTetrisCard from './SidebarItemTetrisCard.svelte'; 7 6 import type { Component } from 'svelte'; 8 7 9 8 export const TetrisCardDefinition = { 10 9 type: 'tetris', 11 10 contentComponent: TetrisCard as unknown as Component<ContentComponentProps>, 12 - sidebarComponent: SidebarItemTetrisCard, 11 + sidebarButtonText: 'Tetris', 13 12 allowSetColor: true, 14 13 defaultColor: 'accent', 15 14 createNew: (card) => {
-12
src/lib/cards/LivestreamCard/SidebarItemEmbedLivestreamCard.svelte
··· 1 - <script lang="ts"> 2 - import { Button } from '@foxui/core'; 3 - import Icon from './Icon.svelte'; 4 - 5 - let { onclick }: { onclick: () => void } = $props(); 6 - </script> 7 - 8 - <Button {onclick} variant="ghost" class="w-full justify-start"> 9 - <Icon class="size-4" /> 10 - 11 - Embed stream.place 12 - </Button>
-12
src/lib/cards/LivestreamCard/SidebarItemLivestreamCard.svelte
··· 1 - <script lang="ts"> 2 - import { Button } from '@foxui/core'; 3 - import Icon from './Icon.svelte'; 4 - 5 - let { onclick }: { onclick: () => void } = $props(); 6 - </script> 7 - 8 - <Button {onclick} variant="ghost" class="w-full justify-start"> 9 - <Icon class="size-4" /> 10 - 11 - Latest stream.place 12 - </Button>
+1 -2
src/lib/cards/LivestreamCard/index.ts
··· 2 2 import type { CardDefinition } from '../types'; 3 3 import LivestreamCard from './LivestreamCard.svelte'; 4 4 import LivestreamEmbedCard from './LivestreamEmbedCard.svelte'; 5 - import SidebarItemLivestreamCard from './SidebarItemLivestreamCard.svelte'; 6 5 7 6 export const LivestreamCardDefitition = { 8 7 type: 'latestLivestream', 9 8 contentComponent: LivestreamCard, 10 - sidebarComponent: SidebarItemLivestreamCard, 9 + sidebarButtonText: 'stream.place info', 11 10 createNew: (card) => { 12 11 card.w = 4; 13 12 card.h = 4;
-22
src/lib/cards/MapCard/SidebarItemMapCard.svelte
··· 1 - <script lang="ts"> 2 - import { Button } from '@foxui/core'; 3 - 4 - let { onclick }: { onclick: () => void } = $props(); 5 - </script> 6 - 7 - <Button {onclick} variant="ghost" class="w-full justify-start"> 8 - <svg 9 - xmlns="http://www.w3.org/2000/svg" 10 - viewBox="0 0 24 24" 11 - fill="currentColor" 12 - class="text-accent-600 dark:text-accent-400" 13 - > 14 - <path 15 - fill-rule="evenodd" 16 - d="M8.161 2.58a1.875 1.875 0 0 1 1.678 0l4.993 2.498c.106.052.23.052.336 0l3.869-1.935A1.875 1.875 0 0 1 21.75 4.82v12.485c0 .71-.401 1.36-1.037 1.677l-4.875 2.437a1.875 1.875 0 0 1-1.676 0l-4.994-2.497a.375.375 0 0 0-.336 0l-3.868 1.935A1.875 1.875 0 0 1 2.25 19.18V6.695c0-.71.401-1.36 1.036-1.677l4.875-2.437ZM9 6a.75.75 0 0 1 .75.75V15a.75.75 0 0 1-1.5 0V6.75A.75.75 0 0 1 9 6Zm6.75 3a.75.75 0 0 0-1.5 0v8.25a.75.75 0 0 0 1.5 0V9Z" 17 - clip-rule="evenodd" 18 - /> 19 - </svg> 20 - 21 - Map with Location 22 - </Button>
+1 -3
src/lib/cards/MapCard/index.ts
··· 2 2 import CreateMapCardModal from './CreateMapCardModal.svelte'; 3 3 import MapCard from './MapCard.svelte'; 4 4 import MapCardSettings from './MapCardSettings.svelte'; 5 - import SidebarItemMapCard from './SidebarItemMapCard.svelte'; 6 5 7 6 export const MapCardDefinition = { 8 7 type: 'mapLocation', 9 8 contentComponent: MapCard, 10 - sidebarButtonText: 'map', 9 + sidebarButtonText: 'Map', 11 10 createNew: (item) => { 12 11 item.w = 4; 13 12 item.h = 4; ··· 15 14 item.mobileW = 8; 16 15 }, 17 16 18 - sidebarComponent: SidebarItemMapCard, 19 17 creationModalComponent: CreateMapCardModal, 20 18 allowSetColor: false, 21 19 canHaveLabel: true,
+3 -1
src/lib/cards/index.ts
··· 25 25 import { PhotoGalleryCardDefinition } from './PhotoGalleryCard'; 26 26 import { StandardSiteDocumentListCardDefinition } from './StandardSiteDocumentListCard'; 27 27 import { StatusphereCardDefinition } from './StatusphereCard'; 28 + import { EventCardDefinition } from './EventCard'; 28 29 29 30 export const AllCardDefinitions = [ 30 31 ImageCardDefinition, ··· 52 53 TealFMPlaysCardDefinition, 53 54 PhotoGalleryCardDefinition, 54 55 StandardSiteDocumentListCardDefinition, 55 - StatusphereCardDefinition 56 + StatusphereCardDefinition, 57 + EventCardDefinition 56 58 ] as const; 57 59 58 60 export const CardDefinitionsByType = AllCardDefinitions.reduce(
+1 -2
src/lib/cards/types.ts
··· 33 33 34 34 upload?: (item: Item) => Promise<Item>; // optionally upload some other data needed for this card 35 35 36 - // one of those two has to be set for a card to appear in the sidebar 37 - sidebarComponent?: Component<SidebarComponentProps>; 36 + // has to be set for a card to appear in the sidebar 38 37 sidebarButtonText?: string; 39 38 40 39 // if this component exists, a settings button with a popover will be shown containing this component
+5 -11
src/lib/website/EditableWebsite.svelte
··· 142 142 } 143 143 } 144 144 145 - const sidebarItems = AllCardDefinitions.filter( 146 - (cardDef) => cardDef.sidebarComponent || cardDef.sidebarButtonText 147 - ); 145 + const sidebarItems = AllCardDefinitions.filter((cardDef) => cardDef.sidebarButtonText); 148 146 149 147 let showSettings = $state(false); 150 148 ··· 696 694 697 695 fixCollisions(items, item, isMobile); 698 696 }} 699 - ondragstart={(e) => { 697 + ondragstart={(e: DragEvent) => { 700 698 const target = e.currentTarget as HTMLDivElement; 701 699 activeDragElement.element = target; 702 700 activeDragElement.w = item.w; ··· 732 730 <Sidebar mobileOnly mobileClasses="lg:block p-4 gap-4"> 733 731 <div class="flex flex-col gap-2"> 734 732 {#each sidebarItems as cardDef (cardDef.type)} 735 - {#if cardDef.sidebarComponent} 736 - <cardDef.sidebarComponent onclick={() => newCard(cardDef.type)} /> 737 - {:else if cardDef.sidebarButtonText} 738 - <Button onclick={() => newCard(cardDef.type)} variant="ghost" class="w-full justify-start" 739 - >{cardDef.sidebarButtonText}</Button 740 - > 741 - {/if} 733 + <Button onclick={() => newCard(cardDef.type)} variant="ghost" class="w-full justify-start" 734 + >{cardDef.sidebarButtonText}</Button 735 + > 742 736 {/each} 743 737 </div> 744 738 </Sidebar>