atmo.rsvp
3
fork

Configure Feed

Select the types of activity you want to include in your feed.

commit

Florian 7b315795 03cfc491

+2356 -1560
+5 -1
lexicons-generated/rsvp/atmo/event/getRecord.json
··· 23 23 "spaceUri": { 24 24 "type": "string", 25 25 "format": "at-uri", 26 - "description": "If set, fetch from this permissioned space (requires service-auth JWT)." 26 + "description": "If set, fetch from this permissioned space (requires service-auth JWT or a read-grant invite token)." 27 + }, 28 + "inviteToken": { 29 + "type": "string", 30 + "description": "Read-grant invite token for anonymous bearer access. Replaces JWT auth when supplied." 27 31 }, 28 32 "hydrateRsvps": { 29 33 "type": "integer",
+10 -1
lexicons-generated/rsvp/atmo/event/listRecords.json
··· 29 29 "spaceUri": { 30 30 "type": "string", 31 31 "format": "at-uri", 32 - "description": "If set, query records inside this permissioned space (requires service-auth JWT)." 32 + "description": "If set, query records inside this permissioned space (requires service-auth JWT or a read-grant invite token)." 33 33 }, 34 34 "byUser": { 35 35 "type": "string", 36 36 "format": "did", 37 37 "description": "Only used with spaceUri — filter to records authored by this DID." 38 + }, 39 + "inviteToken": { 40 + "type": "string", 41 + "description": "Read-grant invite token for anonymous bearer access. Replaces JWT auth when supplied." 38 42 }, 39 43 "search": { 40 44 "type": "string", ··· 80 84 "type": "string", 81 85 "description": "Filter by description" 82 86 }, 87 + "preferencesShowInDiscovery": { 88 + "type": "string", 89 + "description": "Filter by preferences.showInDiscovery" 90 + }, 83 91 "rsvpsCountMin": { 84 92 "type": "integer", 85 93 "description": "Minimum total rsvps count" ··· 112 120 "startsAt", 113 121 "createdAt", 114 122 "description", 123 + "preferencesShowInDiscovery", 115 124 "rsvpsCount", 116 125 "rsvpsInterestedCount", 117 126 "rsvpsGoingCount",
+5 -1
lexicons-generated/rsvp/atmo/rsvp/getRecord.json
··· 23 23 "spaceUri": { 24 24 "type": "string", 25 25 "format": "at-uri", 26 - "description": "If set, fetch from this permissioned space (requires service-auth JWT)." 26 + "description": "If set, fetch from this permissioned space (requires service-auth JWT or a read-grant invite token)." 27 + }, 28 + "inviteToken": { 29 + "type": "string", 30 + "description": "Read-grant invite token for anonymous bearer access. Replaces JWT auth when supplied." 27 31 }, 28 32 "hydrateEvent": { 29 33 "type": "boolean",
+5 -1
lexicons-generated/rsvp/atmo/rsvp/listRecords.json
··· 29 29 "spaceUri": { 30 30 "type": "string", 31 31 "format": "at-uri", 32 - "description": "If set, query records inside this permissioned space (requires service-auth JWT)." 32 + "description": "If set, query records inside this permissioned space (requires service-auth JWT or a read-grant invite token)." 33 33 }, 34 34 "byUser": { 35 35 "type": "string", 36 36 "format": "did", 37 37 "description": "Only used with spaceUri — filter to records authored by this DID." 38 + }, 39 + "inviteToken": { 40 + "type": "string", 41 + "description": "Read-grant invite token for anonymous bearer access. Replaces JWT auth when supplied." 38 42 }, 39 43 "status": { 40 44 "type": "string",
+9
lexicons-generated/rsvp/atmo/space/defs.json
··· 145 145 "required": [ 146 146 "tokenHash", 147 147 "spaceUri", 148 + "kind", 148 149 "perms", 149 150 "usedCount", 150 151 "createdBy", ··· 157 158 "spaceUri": { 158 159 "type": "string", 159 160 "format": "at-uri" 161 + }, 162 + "kind": { 163 + "type": "string", 164 + "knownValues": [ 165 + "join", 166 + "read", 167 + "read-join" 168 + ] 160 169 }, 161 170 "perms": { 162 171 "type": "string",
+4
lexicons-generated/rsvp/atmo/space/getRecord.json
··· 28 28 }, 29 29 "rkey": { 30 30 "type": "string" 31 + }, 32 + "inviteToken": { 33 + "type": "string", 34 + "description": "Read-grant invite token. When supplied, replaces JWT auth for this read." 31 35 } 32 36 } 33 37 },
+5 -1
lexicons-generated/rsvp/atmo/space/getSpace.json
··· 4 4 "defs": { 5 5 "main": { 6 6 "type": "query", 7 - "description": "Get metadata for a single space. Caller must be a member or the owner.", 7 + "description": "Get metadata for a single space. Caller must be a member, the owner, or hold a read-grant invite token.", 8 8 "parameters": { 9 9 "type": "params", 10 10 "required": [ ··· 14 14 "uri": { 15 15 "type": "string", 16 16 "format": "at-uri" 17 + }, 18 + "inviteToken": { 19 + "type": "string", 20 + "description": "Read-grant invite token. When supplied, replaces JWT auth for this read." 17 21 } 18 22 } 19 23 },
+11 -1
lexicons-generated/rsvp/atmo/space/invite/create.json
··· 17 17 "type": "string", 18 18 "format": "at-uri" 19 19 }, 20 + "kind": { 21 + "type": "string", 22 + "knownValues": [ 23 + "join", 24 + "read", 25 + "read-join" 26 + ], 27 + "default": "join", 28 + "description": "join: redeem to become a member. read: bearer-only read access, no membership. read-join: anonymous read + signed-in redeem to join." 29 + }, 20 30 "perms": { 21 31 "type": "string", 22 32 "knownValues": [ ··· 32 42 "maxUses": { 33 43 "type": "integer", 34 44 "minimum": 1, 35 - "description": "Omit for unlimited uses." 45 + "description": "Caps join redemptions only — read-token reads are unlimited. Omit for unlimited joins." 36 46 }, 37 47 "note": { 38 48 "type": "string",
+4
lexicons-generated/rsvp/atmo/space/listRecords.json
··· 33 33 "minimum": 1, 34 34 "maximum": 200, 35 35 "default": 50 36 + }, 37 + "inviteToken": { 38 + "type": "string", 39 + "description": "Read-grant invite token. When supplied, replaces JWT auth for this read." 36 40 } 37 41 } 38 42 },
+1 -7
package.json
··· 21 21 "env:setup-dev": "npx tsx src/lib/atproto/scripts/setup-dev.ts", 22 22 "tunnel": "npx tsx src/lib/atproto/scripts/tunnel.ts", 23 23 "lexicons": "lex-cli pull && lex-cli generate", 24 - "vods": "tsx scripts/vod-processing/pipeline.ts", 25 - "vods:fetch": "tsx scripts/vod-processing/fetch-events.ts", 26 - "vods:download": "tsx scripts/vod-processing/download-audio.ts", 27 - "vods:transcribe": "tsx scripts/vod-processing/transcribe.ts", 28 - "vods:summarize": "tsx scripts/vod-processing/summarize.ts", 29 - "vods:vtt": "tsx scripts/vod-processing/generate-vtt.ts", 30 24 "publish-lexicons": "tsx --env-file=.env scripts/publish-lexicons.ts" 31 25 }, 32 26 "devDependencies": { ··· 67 61 "dependencies": { 68 62 "@atcute/bluesky-richtext-parser": "^2.1.1", 69 63 "@atcute/jetstream": "^1.1.2", 70 - "@atmo-dev/contrail": "link:../contrail", 64 + "@atmo-dev/contrail": "^0.1.0", 71 65 "@ethercorps/sveltekit-og": "^4.2.1", 72 66 "@foxui/colors": "^0.8.4", 73 67 "@foxui/core": "^0.9.0",
+44 -2
pnpm-lock.yaml
··· 15 15 specifier: ^1.1.2 16 16 version: 1.1.2 17 17 '@atmo-dev/contrail': 18 - specifier: link:../contrail 19 - version: link:../contrail 18 + specifier: ^0.1.0 19 + version: 0.1.0 20 20 '@ethercorps/sveltekit-og': 21 21 specifier: ^4.2.1 22 22 version: 4.2.1(@sveltejs/kit@2.55.0(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.0)(vite@8.0.3(@types/node@25.0.10)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)))(svelte@5.55.0)(typescript@6.0.2)(vite@8.0.3(@types/node@25.0.10)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0))) ··· 266 266 267 267 '@atcute/varint@2.0.0': 268 268 resolution: {integrity: sha512-CEY/oVK/nVpL4e5y3sdenLETDL6/Xu5xsE/0TupK+f0Yv8jcD60t2gD8SHROWSvUwYLdkjczLCSA7YrtnjCzWw==} 269 + 270 + '@atcute/xrpc-server@0.1.12': 271 + resolution: {integrity: sha512-70KIerQlljp5+s6t0u6YNN9klEboQUZa2hhoi/hmXIO1cIKEORettTMctnyjfcCJaSfAuj42dxPu51GTZBlm8w==} 272 + 273 + '@atmo-dev/contrail@0.1.0': 274 + resolution: {integrity: sha512-YTOZbdReiVYCE+F1b71PUgDJSRDZD38ZjZweNa14o38uQt17XBEherQ+2z8IYnfB+CGAFJ1ln6O0R7sAIo0vIA==} 275 + peerDependencies: 276 + pg: ^8.0.0 277 + peerDependenciesMeta: 278 + pg: 279 + optional: true 269 280 270 281 '@badrap/valita@0.4.6': 271 282 resolution: {integrity: sha512-4kdqcjyxo/8RQ8ayjms47HCWZIF5981oE5nIenbfThKDxWXtEHKipAOWlflpPJzZx9y/JWYQkp18Awr7VuepFg==} ··· 1983 1994 hls.js@1.6.15: 1984 1995 resolution: {integrity: sha512-E3a5VwgXimGHwpRGV+WxRTKeSp2DW5DI5MWv34ulL3t5UNmyJWCQ1KmLEHbYzcfThfXG8amBL+fCYPneGHC4VA==} 1985 1996 1997 + hono@4.12.14: 1998 + resolution: {integrity: sha512-am5zfg3yu6sqn5yjKBNqhnTX7Cv+m00ox+7jbaKkrLMRJ4rAdldd1xPd/JzbBWspqaQv6RSTrgFN95EsfhC+7w==} 1999 + engines: {node: '>=16.9.0'} 2000 + 1986 2001 htmlparser2@10.1.0: 1987 2002 resolution: {integrity: sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==} 1988 2003 ··· 3100 3115 3101 3116 '@atcute/varint@2.0.0': {} 3102 3117 3118 + '@atcute/xrpc-server@0.1.12': 3119 + dependencies: 3120 + '@atcute/cbor': 2.3.2 3121 + '@atcute/crypto': 2.4.1 3122 + '@atcute/identity': 1.1.4 3123 + '@atcute/identity-resolver': 1.2.2(@atcute/identity@1.1.4) 3124 + '@atcute/lexicons': 1.2.9 3125 + '@atcute/multibase': 1.2.0 3126 + '@atcute/uint8array': 1.1.1 3127 + '@badrap/valita': 0.4.6 3128 + nanoid: 5.1.7 3129 + 3130 + '@atmo-dev/contrail@0.1.0': 3131 + dependencies: 3132 + '@atcute/atproto': 3.1.10 3133 + '@atcute/client': 4.2.1 3134 + '@atcute/identity': 1.1.4 3135 + '@atcute/identity-resolver': 1.2.2(@atcute/identity@1.1.4) 3136 + '@atcute/jetstream': 1.1.2 3137 + '@atcute/lexicons': 1.2.9 3138 + '@atcute/xrpc-server': 0.1.12 3139 + hono: 4.12.14 3140 + transitivePeerDependencies: 3141 + - react 3142 + 3103 3143 '@badrap/valita@0.4.6': {} 3104 3144 3105 3145 '@cloudflare/kv-asset-handler@0.4.2': {} ··· 4712 4752 highlight.js@11.11.1: {} 4713 4753 4714 4754 hls.js@1.6.15: {} 4755 + 4756 + hono@4.12.14: {} 4715 4757 4716 4758 htmlparser2@10.1.0: 4717 4759 dependencies:
+5 -1
src/lexicon-types/types/rsvp/atmo/event/getRecord.ts
··· 127 127 ]), 128 128 ), 129 129 /** 130 + * Read-grant invite token for anonymous bearer access. Replaces JWT auth when supplied. 131 + */ 132 + inviteToken: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()), 133 + /** 130 134 * Include profile + identity info keyed by DID 131 135 */ 132 136 profiles: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.boolean()), 133 137 /** 134 - * If set, fetch from this permissioned space (requires service-auth JWT). 138 + * If set, fetch from this permissioned space (requires service-auth JWT or a read-grant invite token). 135 139 */ 136 140 spaceUri: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.resourceUriString()), 137 141 /**
+12 -1
src/lexicon-types/types/rsvp/atmo/event/listRecords.ts
··· 156 156 ]), 157 157 ), 158 158 /** 159 + * Read-grant invite token for anonymous bearer access. Replaces JWT auth when supplied. 160 + */ 161 + inviteToken: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()), 162 + /** 159 163 * @minimum 1 160 164 * @maximum 200 161 165 * @default 50 ··· 181 185 /*#__PURE__*/ v.string<"asc" | "desc" | (string & {})>(), 182 186 ), 183 187 /** 188 + * Filter by preferences.showInDiscovery 189 + */ 190 + preferencesShowInDiscovery: /*#__PURE__*/ v.optional( 191 + /*#__PURE__*/ v.string(), 192 + ), 193 + /** 184 194 * Include profile + identity info keyed by DID 185 195 */ 186 196 profiles: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.boolean()), ··· 216 226 | "endsAt" 217 227 | "mode" 218 228 | "name" 229 + | "preferencesShowInDiscovery" 219 230 | "rsvpsCount" 220 231 | "rsvpsGoingCount" 221 232 | "rsvpsInterestedCount" ··· 226 237 >(), 227 238 ), 228 239 /** 229 - * If set, query records inside this permissioned space (requires service-auth JWT). 240 + * If set, query records inside this permissioned space (requires service-auth JWT or a read-grant invite token). 230 241 */ 231 242 spaceUri: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.resourceUriString()), 232 243 /**
+5 -1
src/lexicon-types/types/rsvp/atmo/rsvp/getRecord.ts
··· 78 78 */ 79 79 hydrateEvent: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.boolean()), 80 80 /** 81 + * Read-grant invite token for anonymous bearer access. Replaces JWT auth when supplied. 82 + */ 83 + inviteToken: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()), 84 + /** 81 85 * Include profile + identity info keyed by DID 82 86 */ 83 87 profiles: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.boolean()), 84 88 /** 85 - * If set, fetch from this permissioned space (requires service-auth JWT). 89 + * If set, fetch from this permissioned space (requires service-auth JWT or a read-grant invite token). 86 90 */ 87 91 spaceUri: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.resourceUriString()), 88 92 /**
+5 -1
src/lexicon-types/types/rsvp/atmo/rsvp/listRecords.ts
··· 87 87 */ 88 88 hydrateEvent: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.boolean()), 89 89 /** 90 + * Read-grant invite token for anonymous bearer access. Replaces JWT auth when supplied. 91 + */ 92 + inviteToken: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()), 93 + /** 90 94 * @minimum 1 91 95 * @maximum 200 92 96 * @default 50 ··· 114 118 /*#__PURE__*/ v.string<"status" | "subjectUri" | (string & {})>(), 115 119 ), 116 120 /** 117 - * If set, query records inside this permissioned space (requires service-auth JWT). 121 + * If set, query records inside this permissioned space (requires service-auth JWT or a read-grant invite token). 118 122 */ 119 123 spaceUri: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.resourceUriString()), 120 124 /**
+1
src/lexicon-types/types/rsvp/atmo/space/defs.ts
··· 18 18 createdAt: /*#__PURE__*/ v.integer(), 19 19 createdBy: /*#__PURE__*/ v.didString(), 20 20 expiresAt: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.integer()), 21 + kind: /*#__PURE__*/ v.string<"join" | "read" | "read-join" | (string & {})>(), 21 22 maxUses: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.integer()), 22 23 note: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()), 23 24 perms: /*#__PURE__*/ v.string<"read" | "write" | (string & {})>(),
+4
src/lexicon-types/types/rsvp/atmo/space/getRecord.ts
··· 7 7 params: /*#__PURE__*/ v.object({ 8 8 author: /*#__PURE__*/ v.didString(), 9 9 collection: /*#__PURE__*/ v.nsidString(), 10 + /** 11 + * Read-grant invite token. When supplied, replaces JWT auth for this read. 12 + */ 13 + inviteToken: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()), 10 14 rkey: /*#__PURE__*/ v.string(), 11 15 spaceUri: /*#__PURE__*/ v.resourceUriString(), 12 16 }),
+4
src/lexicon-types/types/rsvp/atmo/space/getSpace.ts
··· 5 5 6 6 const _mainSchema = /*#__PURE__*/ v.query("rsvp.atmo.space.getSpace", { 7 7 params: /*#__PURE__*/ v.object({ 8 + /** 9 + * Read-grant invite token. When supplied, replaces JWT auth for this read. 10 + */ 11 + inviteToken: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()), 8 12 uri: /*#__PURE__*/ v.resourceUriString(), 9 13 }), 10 14 output: {
+9 -1
src/lexicon-types/types/rsvp/atmo/space/invite/create.ts
··· 13 13 */ 14 14 expiresAt: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.integer()), 15 15 /** 16 - * Omit for unlimited uses. 16 + * join: redeem to become a member. read: bearer-only read access, no membership. read-join: anonymous read + signed-in redeem to join. 17 + * @default "join" 18 + */ 19 + kind: /*#__PURE__*/ v.optional( 20 + /*#__PURE__*/ v.string<"join" | "read" | "read-join" | (string & {})>(), 21 + "join", 22 + ), 23 + /** 24 + * Caps join redemptions only — read-token reads are unlimited. Omit for unlimited joins. 17 25 * @minimum 1 18 26 */ 19 27 maxUses: /*#__PURE__*/ v.optional(
+4
src/lexicon-types/types/rsvp/atmo/space/listRecords.ts
··· 12 12 collection: /*#__PURE__*/ v.nsidString(), 13 13 cursor: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()), 14 14 /** 15 + * Read-grant invite token. When supplied, replaces JWT auth for this read. 16 + */ 17 + inviteToken: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()), 18 + /** 15 19 * @minimum 1 16 20 * @maximum 200 17 21 * @default 50
+1 -1
src/lib/components/EventCard.svelte
··· 99 99 {/if} 100 100 </p> 101 101 <h3 102 - class="text-base-900 dark:text-base-50 group-hover:text-base-700 dark:group-hover:text-base-200 mt-0.5 line-clamp-2 flex items-start gap-1.5 text-sm leading-snug font-semibold transition-colors sm:text-base" 102 + class="text-base-900 dark:text-base-50 group-hover:text-base-700 dark:group-hover:text-base-200 mt-0.5 line-clamp-2 flex items-center gap-1.5 text-sm leading-snug font-semibold transition-colors sm:text-base" 103 103 > 104 104 {#if event.space} 105 105 <svg
+175 -1085
src/lib/components/EventEditor.svelte
··· 1 1 <script lang="ts"> 2 2 import { user } from '$lib/atproto/auth.svelte'; 3 3 import { atProtoLoginModalState } from '$lib/components/LoginModal.svelte'; 4 - import { uploadBlob, putRecord, deleteRecord, resolveHandle } from '$lib/atproto/methods'; 4 + import { putRecord, deleteRecord } from '$lib/atproto/methods'; 5 5 import { getCDNImageBlobUrl } from '$lib/atproto'; 6 6 import { notifyContrailOfUpdate } from '$lib/contrail'; 7 - import { compressImage } from '$lib/atproto/image-helper'; 8 - import { validateLink } from '$lib/cal/helper'; 9 - import * as TID from '@atcute/tid'; 10 7 import { 11 8 Avatar as FoxAvatar, 12 9 Button, 13 - PopoverRoot, 14 - PopoverTrigger, 15 - PopoverContent, 16 10 ToggleGroup, 17 - ToggleGroupItem, 18 - Input, 19 - Checkbox 11 + ToggleGroupItem 20 12 } from '@foxui/core'; 21 13 import { goto } from '$app/navigation'; 22 - import { tokenize, type Token } from '@atcute/bluesky-richtext-parser'; 23 - import type { Handle } from '@atcute/lexicons'; 24 14 import { onMount } from 'svelte'; 25 - import { browser } from '$app/environment'; 26 - import { putImage, getImage, deleteImage } from '$lib/components/image-store'; 27 - import { Modal } from '@foxui/core'; 15 + import { browser, dev } from '$app/environment'; 16 + import { getImage, deleteImage } from '$lib/components/image-store'; 28 17 import { PlainTextEditor } from '@foxui/text'; 29 - import Avatar from 'svelte-boring-avatars'; 30 18 import DateTimePicker from '$lib/components/DateTimePicker.svelte'; 31 19 import TimezonePicker from '$lib/components/TimezonePicker.svelte'; 32 20 import { parseDateTime } from '@internationalized/date'; 33 - import { datetimeLocalToISO, isoToDatetimeLocalInTz } from '$lib/date-format'; 34 - import ThumbnailPresets from '$lib/components/ThumbnailPresets.svelte'; 35 - import { designs } from '$lib/components/thumbnails/designs'; 21 + import { isoToDatetimeLocalInTz } from '$lib/date-format'; 36 22 import type { FlatEventRecord } from '$lib/contrail'; 37 - import ThemePicker from '$lib/components/ThemePicker.svelte'; 38 23 import ThemeApply from '$lib/components/ThemeApply.svelte'; 39 24 import ThemeBackground from '$lib/components/ThemeBackground.svelte'; 40 - import { defaultTheme, themeBackgrounds, type EventTheme } from '$lib/theme'; 25 + import { defaultTheme, type EventTheme } from '$lib/theme'; 26 + 27 + import type { Readable } from 'svelte/store'; 28 + import { get } from 'svelte/store'; 29 + import type { Editor } from '@tiptap/core'; 30 + 31 + import ThumbnailSection from './editor/ThumbnailSection.svelte'; 32 + import LocationSection from './editor/LocationSection.svelte'; 33 + import LinksSection from './editor/LinksSection.svelte'; 34 + import ThemeSection from './editor/ThemeSection.svelte'; 35 + import RecurringModal from './editor/RecurringModal.svelte'; 36 + import { 37 + stripModePrefix, 38 + type EventDraft, 39 + type EventLocation, 40 + type EventMode, 41 + type Visibility 42 + } from './editor/types'; 43 + import { clearDraft, migrateLegacyDraft, readDraft, writeDraft } from './editor/draft'; 44 + import { buildEventRecord, buildThumbnailMedia, renderPresetThumbnail } from './editor/save'; 41 45 42 46 let { 43 47 eventData = null, ··· 53 57 } = $props(); 54 58 55 59 let isNew = $derived(eventData === null); 56 - let DRAFT_KEY = $derived(`blento-event-edit-${rkey}`); 57 - 58 - type EventMode = 'inperson' | 'virtual' | 'hybrid'; 59 - 60 - interface EventLocation { 61 - street?: string; 62 - locality?: string; 63 - region?: string; 64 - country?: string; 65 - } 66 - 67 - interface EventDraft { 68 - name: string; 69 - description: string; 70 - startsAt: string; 71 - endsAt: string; 72 - timezone?: string; 73 - theme?: EventTheme; 74 - links: Array<{ uri: string; name: string }>; 75 - mode?: EventMode; 76 - thumbnailKey?: string; 77 - thumbnailChanged?: boolean; 78 - location?: EventLocation | null; 79 - locationChanged?: boolean; 80 - } 81 60 82 61 let thumbnailKey: string | null = $state(null); 83 62 let thumbnailChanged = $state(false); ··· 89 68 let endsAt = $state(''); 90 69 let timezone = $state(Intl.DateTimeFormat().resolvedOptions().timeZone); 91 70 let mode: EventMode = $state('inperson'); 71 + // svelte-ignore state_referenced_locally 72 + let visibility: Visibility = $state(privateMode && dev ? 'private' : 'public'); 92 73 let eventTheme: EventTheme = $state({ ...defaultTheme }); 93 - let showThemeModal = $state(false); 94 74 let thumbnailFile: File | null = $state(null); 95 75 let thumbnailPreview: string | null = $state(null); 96 76 let selectedPreset: { design: string; seed: number } | null = $state(null); 97 - let presetPreviewCanvas: HTMLCanvasElement | undefined = $state(undefined); 98 - let showThumbnailModal = $state(false); 99 77 let submitting = $state(false); 100 78 let error: string | null = $state(null); 101 - import type { Readable } from 'svelte/store'; 102 - import { get } from 'svelte/store'; 103 - import type { Editor } from '@tiptap/core'; 104 79 let titleEditor: Readable<Editor> | undefined = $state(undefined); 105 80 106 81 let location: EventLocation | null = $state(null); 107 82 let locationChanged = $state(false); 108 - let showLocationModal = $state(false); 109 - let locationSearch = $state(''); 110 - let locationSearching = $state(false); 111 - let locationError = $state(''); 112 - let locationResult: { displayName: string; location: EventLocation } | null = $state(null); 113 83 114 84 let links: Array<{ uri: string; name: string }> = $state([]); 115 - let showLinkPopup = $state(false); 116 - let newLinkUri = $state(''); 117 - let newLinkName = $state(''); 118 - let linkError = $state(''); 119 85 120 86 let draftLoaded = $state(false); 121 87 122 88 let showRecurringModal = $state(false); 123 - let recurringInterval = $state(1); 124 - let recurringUnit: 'days' | 'weeks' | 'months' | 'years' = $state('weeks'); 125 - let recurringCount = $state(4); 126 - let recurringNumberInTitle = $state(false); 127 - let recurringCreating = $state(false); 128 - let recurringError: string | null = $state(null); 129 - let recurringCreated = $state(0); 130 - 131 - let titleNumberMatch = $derived(name.match(/#?(\d+)\s*$/)); 132 - let detectedStartNumber = $derived(titleNumberMatch ? parseInt(titleNumberMatch[1]) : null); 133 - 134 - $effect(() => { 135 - if (detectedStartNumber !== null) { 136 - recurringNumberInTitle = true; 137 - } 138 - }); 139 - 140 - function stripModePrefix(modeStr: string): EventMode { 141 - const stripped = modeStr.replace('community.lexicon.calendar.event#', ''); 142 - if (stripped === 'virtual' || stripped === 'hybrid' || stripped === 'inperson') return stripped; 143 - return 'inperson'; 144 - } 145 89 146 90 function populateLocationFromEventData() { 147 91 if (!eventData) return; ··· 189 133 startsAt = eventData.startsAt ? isoToDatetimeLocalInTz(eventData.startsAt, timezone) : ''; 190 134 endsAt = eventData.endsAt ? isoToDatetimeLocalInTz(eventData.endsAt, timezone) : ''; 191 135 mode = eventData.mode ? stripModePrefix(eventData.mode) : 'inperson'; 136 + const prefs = (eventData as unknown as { preferences?: { showInDiscovery?: boolean } }) 137 + .preferences; 138 + if (privateMode && dev) visibility = 'private'; 139 + else if (prefs && prefs.showInDiscovery === false) visibility = 'unlisted'; 140 + else visibility = 'public'; 192 141 links = eventData.uris ? eventData.uris.map((l) => ({ uri: l.uri, name: l.name || '' })) : []; 193 142 if (eventData.theme) eventTheme = { ...eventData.theme }; 194 143 populateLocationFromEventData(); ··· 196 145 } 197 146 198 147 onMount(async () => { 199 - // Migrate old creation draft if this is a new event 200 - if (isNew) { 201 - const oldDraft = localStorage.getItem('blento-event-draft'); 202 - if (oldDraft && !localStorage.getItem(DRAFT_KEY)) { 203 - localStorage.setItem(DRAFT_KEY, oldDraft); 204 - localStorage.removeItem('blento-event-draft'); 148 + if (isNew) migrateLegacyDraft(rkey); 149 + 150 + const draft = readDraft(rkey); 151 + if (draft) { 152 + name = draft.name || ''; 153 + description = draft.description || ''; 154 + startsAt = draft.startsAt || ''; 155 + endsAt = draft.endsAt || ''; 156 + if (draft.timezone) timezone = draft.timezone; 157 + if (draft.theme) eventTheme = draft.theme; 158 + links = draft.links || []; 159 + mode = draft.mode || 'inperson'; 160 + if (draft.visibility && (draft.visibility !== 'private' || dev)) 161 + visibility = draft.visibility; 162 + else if (privateMode && dev) visibility = 'private'; 163 + locationChanged = draft.locationChanged || false; 164 + if (draft.locationChanged) { 165 + location = draft.location || null; 166 + } else if (!isNew) { 167 + populateLocationFromEventData(); 205 168 } 206 - } 169 + thumbnailChanged = draft.thumbnailChanged || false; 207 170 208 - const saved = localStorage.getItem(DRAFT_KEY); 209 - if (saved) { 210 - try { 211 - const draft: EventDraft = JSON.parse(saved); 212 - name = draft.name || ''; 213 - description = draft.description || ''; 214 - startsAt = draft.startsAt || ''; 215 - endsAt = draft.endsAt || ''; 216 - if (draft.timezone) timezone = draft.timezone; 217 - if (draft.theme) eventTheme = draft.theme; 218 - links = draft.links || []; 219 - mode = draft.mode || 'inperson'; 220 - locationChanged = draft.locationChanged || false; 221 - if (draft.locationChanged) { 222 - location = draft.location || null; 223 - } else if (!isNew) { 224 - // For edits without location changes, load from event data 225 - populateLocationFromEventData(); 171 + if (draft.thumbnailKey) { 172 + const img = await getImage(draft.thumbnailKey); 173 + if (img) { 174 + thumbnailKey = draft.thumbnailKey; 175 + thumbnailFile = new File([img.blob], img.name, { type: img.blob.type }); 176 + thumbnailPreview = URL.createObjectURL(img.blob); 177 + thumbnailChanged = true; 226 178 } 227 - thumbnailChanged = draft.thumbnailChanged || false; 228 - 229 - if (draft.thumbnailKey) { 230 - const img = await getImage(draft.thumbnailKey); 231 - if (img) { 232 - thumbnailKey = draft.thumbnailKey; 233 - thumbnailFile = new File([img.blob], img.name, { type: img.blob.type }); 234 - thumbnailPreview = URL.createObjectURL(img.blob); 235 - thumbnailChanged = true; 236 - } 237 - } else if (!thumbnailChanged && !isNew) { 238 - // No new thumbnail in draft, show existing one from event data 239 - populateThumbnailFromEventData(); 240 - } 241 - } catch { 242 - localStorage.removeItem(DRAFT_KEY); 243 - if (!isNew) populateFromEventData(); 179 + } else if (!thumbnailChanged && !isNew) { 180 + populateThumbnailFromEventData(); 244 181 } 245 182 } else if (!isNew) { 246 183 populateFromEventData(); ··· 264 201 theme: eventTheme, 265 202 links, 266 203 mode, 204 + visibility, 267 205 thumbnailChanged, 268 206 locationChanged 269 207 }; 270 208 if (locationChanged) draft.location = location; 271 209 if (thumbnailKey) draft.thumbnailKey = thumbnailKey; 272 - localStorage.setItem(DRAFT_KEY, JSON.stringify(draft)); 210 + writeDraft(rkey, draft); 273 211 }, 500); 274 212 } 275 213 ··· 283 221 timezone, 284 222 JSON.stringify(eventTheme), 285 223 mode, 224 + visibility, 286 225 JSON.stringify(links), 287 - JSON.stringify(location) 226 + JSON.stringify(location), 227 + thumbnailKey, 228 + thumbnailChanged, 229 + locationChanged 288 230 ]; 289 231 saveDraft(); 290 232 }); 291 233 292 - async function searchLocation() { 293 - const q = locationSearch.trim(); 294 - if (!q) return; 295 - locationError = ''; 296 - locationSearching = true; 297 - locationResult = null; 298 - 299 - try { 300 - const response = await fetch('/api/geocoding?q=' + encodeURIComponent(q)); 301 - if (!response.ok) throw new Error('response not ok'); 302 - const data: Record<string, unknown> = await response.json(); 303 - if (!data || data.error) throw new Error('no results'); 304 - 305 - const addr = (data.address || {}) as Record<string, string>; 306 - const road = addr.road || ''; 307 - const houseNumber = addr.house_number || ''; 308 - const street = road ? (houseNumber ? `${road} ${houseNumber}` : road) : ''; 309 - const locality = 310 - addr.city || addr.town || addr.village || addr.municipality || addr.hamlet || ''; 311 - const region = addr.state || addr.county || ''; 312 - const country = addr.country || ''; 313 - 314 - locationResult = { 315 - displayName: (data.display_name as string) || q, 316 - location: { 317 - ...(street && { street }), 318 - ...(locality && { locality }), 319 - ...(region && { region }), 320 - ...(country && { country }) 321 - } 322 - }; 323 - } catch { 324 - locationError = "Couldn't find that location."; 325 - } finally { 326 - locationSearching = false; 327 - } 328 - } 329 - 330 - function confirmLocation() { 331 - if (locationResult) { 332 - location = locationResult.location; 333 - locationChanged = true; 334 - } 335 - showLocationModal = false; 336 - locationSearch = ''; 337 - locationResult = null; 338 - locationError = ''; 339 - } 340 - 341 - function removeLocation() { 342 - location = null; 343 - locationChanged = true; 344 - } 345 - 346 - function getLocationDisplayString(loc: EventLocation): string { 347 - return [loc.street, loc.locality, loc.region, loc.country].filter(Boolean).join(', '); 348 - } 349 - 350 - function addLink() { 351 - const raw = newLinkUri.trim(); 352 - if (!raw) return; 353 - const uri = validateLink(raw); 354 - if (!uri) { 355 - linkError = 'Please enter a valid URL'; 356 - return; 357 - } 358 - links.push({ uri, name: newLinkName.trim() }); 359 - newLinkUri = ''; 360 - newLinkName = ''; 361 - linkError = ''; 362 - showLinkPopup = false; 363 - } 364 - 365 - function removeLink(index: number) { 366 - links.splice(index, 1); 367 - } 368 - 369 - let fileInput: HTMLInputElement | undefined = $state(); 370 - 371 234 let hostName = $derived(user.profile?.displayName || user.profile?.handle || user.did || ''); 372 235 373 - async function setThumbnail(file: File) { 374 - thumbnailFile = file; 375 - thumbnailChanged = true; 376 - selectedPreset = null; 377 - if (thumbnailPreview) URL.revokeObjectURL(thumbnailPreview); 378 - thumbnailPreview = URL.createObjectURL(file); 379 - 380 - if (thumbnailKey) await deleteImage(thumbnailKey); 381 - thumbnailKey = crypto.randomUUID(); 382 - await putImage(thumbnailKey, file, file.name); 383 - saveDraft(); 384 - } 385 - 386 - async function onFileChange(e: Event) { 387 - const input = e.target as HTMLInputElement; 388 - const file = input.files?.[0]; 389 - if (!file) return; 390 - setThumbnail(file); 391 - } 392 - 393 - let isDragOver = $state(false); 394 - 395 - function onDragOver(e: DragEvent) { 396 - e.preventDefault(); 397 - isDragOver = true; 398 - } 399 - 400 - function onDragLeave(e: DragEvent) { 401 - e.preventDefault(); 402 - isDragOver = false; 403 - } 404 - 405 - function onDrop(e: DragEvent) { 406 - e.preventDefault(); 407 - isDragOver = false; 408 - const file = e.dataTransfer?.files?.[0]; 409 - if (file?.type.startsWith('image/')) { 410 - setThumbnail(file); 411 - } 412 - } 413 - 414 - function removeThumbnail() { 415 - thumbnailFile = null; 416 - thumbnailChanged = true; 417 - selectedPreset = null; 418 - if (thumbnailPreview) { 419 - URL.revokeObjectURL(thumbnailPreview); 420 - thumbnailPreview = null; 421 - } 422 - if (thumbnailKey) { 423 - deleteImage(thumbnailKey); 424 - thumbnailKey = null; 425 - } 426 - if (fileInput) fileInput.value = ''; 427 - saveDraft(); 428 - } 429 - 430 236 let thumbnailDateStr = $derived.by(() => { 431 237 if (!startsAt) return ''; 432 238 const d = new Date(startsAt); ··· 434 240 return d.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric' }); 435 241 }); 436 242 437 - // Render preset preview canvas 438 - $effect(() => { 439 - if (selectedPreset && presetPreviewCanvas && designs[selectedPreset.design]) { 440 - const ctx = presetPreviewCanvas.getContext('2d'); 441 - if (!ctx) return; 442 - presetPreviewCanvas.width = 800; 443 - presetPreviewCanvas.height = 800; 444 - designs[selectedPreset.design](ctx, 800, 800, name || 'Event', thumbnailDateStr, selectedPreset.seed); 445 - } 446 - }); 447 - 448 243 // Trim a CalendarDateTime.toString() ("YYYY-MM-DDTHH:mm:ss[.sss]") down to 449 244 // the "YYYY-MM-DDTHH:mm" shape that <input type="datetime-local"> expects. 450 245 function cdtToDatetimeLocal(s: string): string { ··· 469 264 } 470 265 }); 471 266 472 - async function tokensToFacets(tokens: Token[]): Promise<Record<string, unknown>[]> { 473 - const encoder = new TextEncoder(); 474 - const facets: Record<string, unknown>[] = []; 475 - let byteOffset = 0; 476 - 477 - for (const token of tokens) { 478 - const tokenBytes = encoder.encode(token.raw); 479 - const byteStart = byteOffset; 480 - const byteEnd = byteOffset + tokenBytes.length; 481 - 482 - if (token.type === 'mention') { 483 - try { 484 - const did = await resolveHandle({ handle: token.handle as Handle }); 485 - if (did) { 486 - facets.push({ 487 - index: { byteStart, byteEnd }, 488 - features: [{ $type: 'app.bsky.richtext.facet#mention', did }] 489 - }); 490 - } 491 - } catch { 492 - // skip unresolvable mentions 493 - } 494 - } else if (token.type === 'autolink') { 495 - facets.push({ 496 - index: { byteStart, byteEnd }, 497 - features: [{ $type: 'app.bsky.richtext.facet#link', uri: token.url }] 498 - }); 499 - } else if (token.type === 'topic') { 500 - facets.push({ 501 - index: { byteStart, byteEnd }, 502 - features: [{ $type: 'app.bsky.richtext.facet#tag', tag: token.name }] 503 - }); 504 - } 505 - 506 - byteOffset = byteEnd; 507 - } 508 - 509 - return facets; 510 - } 511 - 512 267 async function handleSubmit() { 513 268 error = null; 514 269 515 - if (!name.trim()) { 516 - error = 'Name is required.'; 517 - return; 518 - } 519 - if (!startsAt) { 520 - error = 'Start date is required.'; 521 - return; 522 - } 523 - if (!endsAt) { 524 - error = 'End date is required.'; 525 - return; 526 - } 527 - if (!user.isLoggedIn || !user.did) { 528 - error = 'You must be logged in.'; 529 - return; 530 - } 270 + if (!name.trim()) return void (error = 'Name is required.'); 271 + if (!startsAt) return void (error = 'Start date is required.'); 272 + if (!endsAt) return void (error = 'End date is required.'); 273 + if (!user.isLoggedIn || !user.did) return void (error = 'You must be logged in.'); 531 274 532 275 submitting = true; 533 276 534 277 try { 535 278 // Generate thumbnail from preset if selected and no custom upload 536 - if (selectedPreset && !thumbnailFile && designs[selectedPreset.design]) { 537 - const canvas = document.createElement('canvas'); 538 - canvas.width = 800; 539 - canvas.height = 800; 540 - const ctx = canvas.getContext('2d')!; 541 - designs[selectedPreset.design](ctx, 800, 800, name.trim() || 'Event', thumbnailDateStr, selectedPreset.seed); 542 - const blob = await new Promise<Blob | null>((r) => canvas.toBlob(r, 'image/png')); 543 - if (blob) { 544 - thumbnailFile = new File([blob], 'thumbnail.png', { type: 'image/png' }); 279 + if (selectedPreset && !thumbnailFile) { 280 + const rendered = await renderPresetThumbnail({ 281 + design: selectedPreset.design, 282 + seed: selectedPreset.seed, 283 + name, 284 + dateStr: thumbnailDateStr 285 + }); 286 + if (rendered) { 287 + thumbnailFile = rendered; 545 288 thumbnailChanged = true; 546 289 } 547 290 } 548 291 549 - let media: Array<Record<string, unknown>> | undefined; 550 - 551 - // Start with existing media, excluding thumbnail role 552 292 const existingMedia = (eventData?.media ?? []) as Array<Record<string, unknown>>; 293 + const media = await buildThumbnailMedia({ 294 + isNew, 295 + thumbnailChanged, 296 + thumbnailFile, 297 + existingMedia 298 + }); 553 299 554 - if (isNew || thumbnailChanged) { 555 - if (thumbnailFile) { 556 - const compressed = await compressImage(thumbnailFile); 557 - const result = await uploadBlob({ blob: compressed.blob }); 558 - if (result) { 559 - const { aspectRatio: _ar, ...blobRef } = result as Record<string, unknown> & { aspectRatio?: unknown }; 560 - // Keep all non-thumbnail media, add new thumbnail 561 - media = [ 562 - ...existingMedia.filter((m) => m.role !== 'thumbnail'), 563 - { 564 - role: 'thumbnail', 565 - content: blobRef, 566 - aspect_ratio: { 567 - width: compressed.aspectRatio.width, 568 - height: compressed.aspectRatio.height 569 - } 570 - } 571 - ]; 572 - } 573 - } else { 574 - // Thumbnail removed — keep all non-thumbnail media 575 - const remaining = existingMedia.filter((m) => m.role !== 'thumbnail'); 576 - if (remaining.length > 0) media = remaining; 577 - } 578 - } else { 579 - // Thumbnail not changed — keep all original media 580 - if (existingMedia.length > 0) { 581 - media = existingMedia; 582 - } 583 - } 584 - 585 - const createdAt = isNew 586 - ? new Date().toISOString() 587 - : eventData?.createdAt || new Date().toISOString(); 588 - 589 - // Spread original record to preserve unspecced fields (e.g. additionalData) 590 - const record: Record<string, unknown> = { 591 - ...(eventData ? { ...eventData } : {}), 592 - $type: 'community.lexicon.calendar.event', 593 - createdWith: 'https://atmo.rsvp', 594 - name: name.trim(), 595 - mode: `community.lexicon.calendar.event#${mode}`, 596 - status: 'community.lexicon.calendar.event#scheduled', 597 - startsAt: datetimeLocalToISO(startsAt, timezone), 300 + const record = await buildEventRecord({ 301 + eventData, 302 + isNew, 303 + name, 304 + description, 305 + startsAt, 306 + endsAt, 598 307 timezone, 599 - createdAt, 600 - theme: eventTheme 601 - }; 602 - // Remove flattened fields that aren't part of the actual record 603 - delete record.cid; 604 - delete record.did; 605 - delete record.rkey; 606 - delete record.uri; 607 - delete record.rsvps; 608 - delete record.rsvpsCount; 609 - delete record.rsvpsGoingCount; 610 - delete record.rsvpsInterestedCount; 611 - delete record.rsvpsNotgoingCount; 308 + mode, 309 + visibility, 310 + theme: eventTheme, 311 + links, 312 + location, 313 + locationChanged, 314 + media 315 + }); 612 316 613 - const trimmedDescription = description.trim(); 614 - if (trimmedDescription) { 615 - record.description = trimmedDescription; 616 - const tokens = tokenize(trimmedDescription); 617 - const facets = await tokensToFacets(tokens); 618 - if (facets.length > 0) { 619 - record.facets = facets; 620 - } 621 - } 622 - if (endsAt) { 623 - record.endsAt = datetimeLocalToISO(endsAt, timezone); 624 - } 625 - if (media) { 626 - record.media = media; 627 - } 628 - if (links.length > 0) { 629 - record.uris = links; 630 - } 631 - if (isNew || locationChanged) { 632 - if (location) { 633 - record.locations = [ 634 - { 635 - $type: 'community.lexicon.location.address', 636 - ...location 637 - } 638 - ]; 639 - } 640 - // If changed/new but no location, locations stays undefined (removed/absent) 641 - } else if (eventData?.locations && eventData.locations.length > 0) { 642 - record.locations = eventData.locations; 643 - } 644 - 645 - if (privateMode) { 317 + if (visibility === 'private') { 646 318 const { createPrivateEvent } = await import('$lib/spaces/server/spaces.remote'); 647 319 const { spaceUri, rkey: eventRkey } = await createPrivateEvent({ key: rkey, record }); 648 - localStorage.removeItem(DRAFT_KEY); 320 + clearDraft(rkey); 649 321 if (thumbnailKey) deleteImage(thumbnailKey); 650 322 const spaceKey = spaceUri.split('/').pop(); 651 323 const handle = ··· 665 337 if (response.ok) { 666 338 const eventUri = `at://${user.did}/community.lexicon.calendar.event/${rkey}`; 667 339 await notifyContrailOfUpdate(eventUri); 668 - localStorage.removeItem(DRAFT_KEY); 340 + clearDraft(rkey); 669 341 if (thumbnailKey) deleteImage(thumbnailKey); 670 342 const handle = 671 343 user.profile?.handle && user.profile.handle !== 'handle.invalid' ··· 695 367 }); 696 368 const eventUri = `at://${user.did}/community.lexicon.calendar.event/${rkey}`; 697 369 await notifyContrailOfUpdate(eventUri); 698 - localStorage.removeItem(DRAFT_KEY); 370 + clearDraft(rkey); 699 371 if (thumbnailKey) deleteImage(thumbnailKey); 700 372 const handle = 701 373 user.profile?.handle && user.profile.handle !== 'handle.invalid' ··· 710 382 showDeleteConfirm = false; 711 383 } 712 384 } 713 - 714 - async function handleCreateRecurring() { 715 - if (!name.trim() || !startsAt || !user.isLoggedIn || !user.did) return; 716 - 717 - recurringCreating = true; 718 - recurringError = null; 719 - recurringCreated = 0; 720 - 721 - try { 722 - // Recurring instances advance by wall-clock duration (e.g. "every week 723 - // at 10am"), so operate on CalendarDateTime — not absolute instants — 724 - // to preserve the wall time across DST transitions. 725 - const baseStart = parseDateTime(startsAt); 726 - const baseEnd = endsAt ? parseDateTime(endsAt) : null; 727 - const durationMs = baseEnd 728 - ? baseEnd.toDate(timezone).getTime() - baseStart.toDate(timezone).getTime() 729 - : 0; 730 - const baseName = recurringNumberInTitle && titleNumberMatch 731 - ? name.replace(/#?\d+\s*$/, '').trimEnd() 732 - : name.trim(); 733 - const startNum = detectedStartNumber ?? 1; 734 - const hasHash = titleNumberMatch ? titleNumberMatch[0].includes('#') : false; 735 - 736 - // Generate thumbnail from preset if selected and no custom upload 737 - if (selectedPreset && !thumbnailFile && designs[selectedPreset.design]) { 738 - const canvas = document.createElement('canvas'); 739 - canvas.width = 800; 740 - canvas.height = 800; 741 - const ctx = canvas.getContext('2d')!; 742 - designs[selectedPreset.design](ctx, 800, 800, name.trim() || 'Event', thumbnailDateStr, selectedPreset.seed); 743 - const blob = await new Promise<Blob | null>((r) => canvas.toBlob(r, 'image/png')); 744 - if (blob) { 745 - thumbnailFile = new File([blob], 'thumbnail.png', { type: 'image/png' }); 746 - thumbnailChanged = true; 747 - } 748 - } 749 - 750 - // Build the same record shape as handleSubmit 751 - let media: Array<Record<string, unknown>> | undefined; 752 - const existingMedia = (eventData?.media ?? []) as Array<Record<string, unknown>>; 753 - 754 - if (isNew || thumbnailChanged) { 755 - if (thumbnailFile) { 756 - const compressed = await compressImage(thumbnailFile); 757 - const result = await uploadBlob({ blob: compressed.blob }); 758 - if (result) { 759 - const { aspectRatio: _ar, ...blobRef } = result as Record<string, unknown> & { aspectRatio?: unknown }; 760 - media = [ 761 - ...existingMedia.filter((m) => m.role !== 'thumbnail'), 762 - { 763 - role: 'thumbnail', 764 - content: blobRef, 765 - aspect_ratio: { width: compressed.aspectRatio.width, height: compressed.aspectRatio.height } 766 - } 767 - ]; 768 - } 769 - } else { 770 - const remaining = existingMedia.filter((m) => m.role !== 'thumbnail'); 771 - if (remaining.length > 0) media = remaining; 772 - } 773 - } else if (existingMedia.length > 0) { 774 - media = existingMedia; 775 - } 776 - 777 - const parentUri = `at://${user.did}/community.lexicon.calendar.event/${rkey}`; 778 - 779 - for (let i = 0; i < recurringCount; i++) { 780 - const offset = i + 1; 781 - const step = offset * recurringInterval; 782 - const eventStart = 783 - recurringUnit === 'days' 784 - ? baseStart.add({ days: step }) 785 - : recurringUnit === 'weeks' 786 - ? baseStart.add({ weeks: step }) 787 - : recurringUnit === 'months' 788 - ? baseStart.add({ months: step }) 789 - : baseStart.add({ years: step }); 790 - 791 - const eventStartIso = eventStart.toDate(timezone).toISOString(); 792 - // Preserve the original absolute duration (handles events that 793 - // span midnight or odd wall-clock lengths correctly). 794 - const eventEndIso = durationMs 795 - ? new Date(eventStart.toDate(timezone).getTime() + durationMs).toISOString() 796 - : null; 797 - 798 - let eventName = baseName; 799 - if (recurringNumberInTitle) { 800 - const num = startNum + (i + 1); 801 - eventName = hasHash ? `${baseName} #${num}` : `${baseName} ${num}`; 802 - } 803 - 804 - const newRkey = TID.now(); 805 - const record: Record<string, unknown> = { 806 - $type: 'community.lexicon.calendar.event', 807 - createdWith: 'https://atmo.rsvp', 808 - name: eventName, 809 - mode: `community.lexicon.calendar.event#${mode}`, 810 - status: 'community.lexicon.calendar.event#scheduled', 811 - startsAt: eventStartIso, 812 - timezone, 813 - createdAt: new Date().toISOString(), 814 - recurringEventOf: parentUri 815 - }; 816 - 817 - const trimmedDescription = description.trim(); 818 - if (trimmedDescription) { 819 - record.description = trimmedDescription; 820 - } 821 - if (eventEndIso) { 822 - record.endsAt = eventEndIso; 823 - } 824 - if (media) { 825 - record.media = media; 826 - } 827 - if (links.length > 0) { 828 - record.uris = links; 829 - } 830 - if (location) { 831 - record.locations = [{ 832 - $type: 'community.lexicon.location.address', 833 - ...location 834 - }]; 835 - } 836 - 837 - const response = await putRecord({ 838 - collection: 'community.lexicon.calendar.event', 839 - rkey: newRkey, 840 - record 841 - }); 842 - 843 - if (response.ok) { 844 - const eventUri = `at://${user.did}/community.lexicon.calendar.event/${newRkey}`; 845 - await notifyContrailOfUpdate(eventUri); 846 - recurringCreated = i + 1; 847 - } else { 848 - recurringError = `Failed to create event ${i + 1}. Stopping.`; 849 - return; 850 - } 851 - } 852 - 853 - showRecurringModal = false; 854 - } catch (e) { 855 - console.error('Failed to create recurring events:', e); 856 - recurringError = 'Failed to create recurring events. Please try again.'; 857 - } finally { 858 - recurringCreating = false; 859 - } 860 - } 861 385 </script> 862 386 863 387 <ThemeApply accentColor={eventTheme.accentColor} baseColor={eventTheme.baseColor} /> ··· 885 409 <div 886 410 class="grid grid-cols-1 gap-8 md:grid-cols-[14rem_1fr] md:gap-x-10 md:gap-y-6 lg:grid-cols-[16rem_1fr]" 887 411 > 888 - <!-- Thumbnail (left column) --> 889 - <!-- svelte-ignore a11y_no_static_element_interactions --> 890 - <div 891 - class="order-1 max-w-sm md:order-0 md:col-start-1 md:max-w-none" 892 - ondragover={onDragOver} 893 - ondragleave={onDragLeave} 894 - ondrop={onDrop} 895 - > 896 - <input 897 - bind:this={fileInput} 898 - type="file" 899 - accept="image/*" 900 - onchange={(e) => { onFileChange(e); showThumbnailModal = false; }} 901 - class="hidden" 902 - /> 903 - <div class="group relative"> 904 - {#if thumbnailPreview} 905 - <img 906 - src={thumbnailPreview} 907 - alt="Thumbnail preview" 908 - class="border-base-200 dark:border-base-800 aspect-square w-full rounded-2xl border object-cover" 909 - /> 910 - {:else if selectedPreset && designs[selectedPreset.design]} 911 - <div class="border-base-200 dark:border-base-800 aspect-square w-full overflow-hidden rounded-2xl border"> 912 - <canvas bind:this={presetPreviewCanvas} class="h-full w-full"></canvas> 913 - </div> 914 - {:else} 915 - <div 916 - class="bg-base-100 dark:bg-base-900 aspect-square w-full overflow-hidden rounded-2xl [&>svg]:h-full [&>svg]:w-full" 917 - > 918 - <Avatar 919 - size={400} 920 - name={rkey} 921 - variant="marble" 922 - colors={['#92A1C6', '#146A7C', '#F0AB3D', '#C271B4', '#C20D90']} 923 - square 924 - /> 925 - </div> 926 - {/if} 927 - <button 928 - type="button" 929 - onclick={() => (showThumbnailModal = true)} 930 - class="absolute inset-0 flex cursor-pointer flex-col items-center justify-center gap-1.5 rounded-2xl bg-black/0 text-white/0 transition-colors group-hover:bg-black/40 group-hover:text-white/90 {isDragOver 931 - ? 'bg-black/40 text-white/90' 932 - : ''}" 933 - > 934 - <svg 935 - xmlns="http://www.w3.org/2000/svg" 936 - fill="none" 937 - viewBox="0 0 24 24" 938 - stroke-width="1.5" 939 - stroke="currentColor" 940 - class="size-6" 941 - > 942 - <path 943 - stroke-linecap="round" 944 - stroke-linejoin="round" 945 - 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.909M3.75 21h16.5A2.25 2.25 0 0 0 22.5 18.75V5.25A2.25 2.25 0 0 0 20.25 3H3.75A2.25 2.25 0 0 0 1.5 5.25v13.5A2.25 2.25 0 0 0 3.75 21Z" 946 - /> 947 - </svg> 948 - <span class="text-sm font-medium">Change thumbnail</span> 949 - </button> 950 - {#if thumbnailPreview || selectedPreset} 951 - <Button 952 - variant="ghost" 953 - size="iconSm" 954 - onclick={removeThumbnail} 955 - class="bg-base-900/70 absolute top-2 right-2 text-white opacity-0 transition-opacity group-hover:opacity-100 hover:bg-red-600" 956 - > 957 - <svg 958 - xmlns="http://www.w3.org/2000/svg" 959 - viewBox="0 0 20 20" 960 - fill="currentColor" 961 - class="size-3.5" 962 - > 963 - <path 964 - d="M6.28 5.22a.75.75 0 0 0-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 1 0 1.06 1.06L10 11.06l3.72 3.72a.75.75 0 1 0 1.06-1.06L11.06 10l3.72-3.72a.75.75 0 0 0-1.06-1.06L10 8.94 6.28 5.22Z" 965 - /> 966 - </svg> 967 - </Button> 968 - {/if} 969 - </div> 970 - </div> 971 - <Button 972 - variant="secondary" 973 - class="mt-3 w-full" 974 - onclick={() => (showThemeModal = true)} 975 - > 976 - <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-4"> 977 - <path stroke-linecap="round" stroke-linejoin="round" d="M4.098 19.902a3.75 3.75 0 0 0 5.304 0l6.401-6.402M6.75 21A3.75 3.75 0 0 1 3 17.25V4.125C3 3.504 3.504 3 4.125 3h5.25c.621 0 1.125.504 1.125 1.125v4.072M6.75 21a3.75 3.75 0 0 0 3.75-3.75V8.197M6.75 21h13.125c.621 0 1.125-.504 1.125-1.125v-5.25c0-.621-.504-1.125-1.125-1.125h-4.072M10.5 8.197l2.88-2.88c.438-.439 1.15-.439 1.59 0l3.712 3.713c.44.44.44 1.152 0 1.59l-2.879 2.88M6.75 17.25h.008v.008H6.75v-.008Z" /> 978 - </svg> 979 - Theme: {themeBackgrounds[eventTheme.name] || eventTheme.name} 980 - </Button> 981 - <Button 982 - type="submit" 983 - class="mt-3 w-full" 984 - disabled={submitting || !name.trim() || !startsAt || !endsAt} 985 - > 986 - {submitting 987 - ? isNew 988 - ? 'Publishing...' 989 - : 'Saving...' 990 - : isNew 991 - ? 'Publish Event' 992 - : 'Save Event'} 993 - </Button> 994 - <!-- Right column: event details --> 412 + <ThumbnailSection 413 + {rkey} 414 + {name} 415 + dateStr={thumbnailDateStr} 416 + bind:thumbnailFile 417 + bind:thumbnailPreview 418 + bind:thumbnailKey 419 + bind:thumbnailChanged 420 + bind:selectedPreset 421 + /> 422 + 423 + <!-- Right column: event details --> 995 424 <div class="order-2 min-w-0 md:order-0 md:col-start-2 md:row-span-5 md:row-start-1"> 996 425 <!-- Name --> 997 426 <div class="mb-2 min-h-14"> ··· 1016 445 </div> 1017 446 1018 447 <!-- Mode toggle --> 1019 - <div class="mb-8"> 448 + <div class="mb-3"> 1020 449 <ToggleGroup 1021 450 type="single" 1022 451 bind:value={ 1023 - () => { 1024 - return mode; 1025 - }, 452 + () => mode, 1026 453 (val) => { 1027 454 if (val) mode = val; 1028 455 } ··· 1036 463 </ToggleGroup> 1037 464 </div> 1038 465 466 + <!-- Visibility toggle --> 467 + <div class="mb-8"> 468 + <ToggleGroup 469 + type="single" 470 + bind:value={ 471 + () => visibility, 472 + (val) => { 473 + if (val) visibility = val as Visibility; 474 + } 475 + } 476 + class="w-fit" 477 + size="xs" 478 + disabled={!isNew && visibility === 'private'} 479 + > 480 + <ToggleGroupItem value="public">Public</ToggleGroupItem> 481 + {#if dev} 482 + <ToggleGroupItem value="private">Private</ToggleGroupItem> 483 + {/if} 484 + <ToggleGroupItem value="unlisted">Unlisted</ToggleGroupItem> 485 + </ToggleGroup> 486 + <div class="text-base-500 dark:text-base-400 mt-1.5 text-xs"> 487 + {#if visibility === 'public'} 488 + Anyone can view and it appears in discovery. 489 + {:else if visibility === 'private'} 490 + Only people you add (or who redeem an invite link) can see it. 491 + {:else} 492 + Public to anyone with the link, but hidden from discovery. 493 + {/if} 494 + </div> 495 + </div> 496 + 1039 497 <!-- Date row --> 1040 498 <div class="mb-4 flex items-stretch gap-3"> 1041 499 <div class="flex flex-col gap-2"> ··· 1053 511 </div> 1054 512 </div> 1055 513 1056 - <!-- Location row --> 1057 - {#if location} 1058 - <div class="mb-6 flex items-center gap-4"> 1059 - <div 1060 - class="border-base-200 dark:border-base-700 bg-base-100 dark:bg-base-950/30 flex size-12 shrink-0 items-center justify-center rounded-xl border" 1061 - > 1062 - <svg 1063 - xmlns="http://www.w3.org/2000/svg" 1064 - fill="none" 1065 - viewBox="0 0 24 24" 1066 - stroke-width="1.5" 1067 - stroke="currentColor" 1068 - class="text-base-900 dark:text-base-200 size-5" 1069 - > 1070 - <path 1071 - stroke-linecap="round" 1072 - stroke-linejoin="round" 1073 - d="M15 10.5a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" 1074 - /> 1075 - <path 1076 - stroke-linecap="round" 1077 - stroke-linejoin="round" 1078 - 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" 1079 - /> 1080 - </svg> 1081 - </div> 1082 - <p class="text-base-900 dark:text-base-50 flex-1 font-semibold"> 1083 - {getLocationDisplayString(location)} 1084 - </p> 1085 - <Button variant="ghost" size="iconSm" onclick={removeLocation} class="shrink-0"> 1086 - <svg 1087 - xmlns="http://www.w3.org/2000/svg" 1088 - viewBox="0 0 20 20" 1089 - fill="currentColor" 1090 - class="size-3.5" 1091 - > 1092 - <path 1093 - d="M6.28 5.22a.75.75 0 0 0-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 1 0 1.06 1.06L10 11.06l3.72 3.72a.75.75 0 1 0 1.06-1.06L11.06 10l3.72-3.72a.75.75 0 0 0-1.06-1.06L10 8.94 6.28 5.22Z" 1094 - /> 1095 - </svg> 1096 - </Button> 1097 - </div> 1098 - {:else} 1099 - <div class="mb-6"> 1100 - <Button variant="secondary" onclick={() => (showLocationModal = true)}> 1101 - <svg 1102 - xmlns="http://www.w3.org/2000/svg" 1103 - fill="none" 1104 - viewBox="0 0 24 24" 1105 - stroke-width="1.5" 1106 - stroke="currentColor" 1107 - class="size-4" 1108 - > 1109 - <path 1110 - stroke-linecap="round" 1111 - stroke-linejoin="round" 1112 - d="M15 10.5a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" 1113 - /> 1114 - <path 1115 - stroke-linecap="round" 1116 - stroke-linejoin="round" 1117 - 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" 1118 - /> 1119 - </svg> 1120 - Add location 1121 - </Button> 1122 - </div> 1123 - {/if} 514 + <LocationSection bind:location bind:locationChanged /> 1124 515 1125 516 <!-- About Event --> 1126 517 <div class="mt-8 mb-8"> ··· 1156 547 type="button" 1157 548 variant="secondary" 1158 549 disabled={submitting || !name.trim() || !startsAt || !endsAt} 1159 - onclick={() => { 1160 - recurringError = null; 1161 - recurringCreated = 0; 1162 - showRecurringModal = true; 1163 - }} 550 + onclick={() => (showRecurringModal = true)} 1164 551 > 1165 552 Add recurring events 1166 553 </Button> ··· 1182 569 </div> 1183 570 </div> 1184 571 1185 - <!-- Links --> 1186 - <div class="order-4 md:order-0 md:col-start-1"> 1187 - <p 1188 - class="text-base-500 dark:text-base-400 mb-4 text-xs font-semibold tracking-wider uppercase" 1189 - > 1190 - Links 1191 - </p> 1192 - <div class="space-y-3"> 1193 - {#each links as link, i (i)} 1194 - <div class="group flex items-center gap-1.5"> 1195 - <svg 1196 - xmlns="http://www.w3.org/2000/svg" 1197 - fill="none" 1198 - viewBox="0 0 24 24" 1199 - stroke-width="1.5" 1200 - stroke="currentColor" 1201 - class="text-base-700 dark:text-base-300 size-3.5 shrink-0" 1202 - > 1203 - <path 1204 - stroke-linecap="round" 1205 - stroke-linejoin="round" 1206 - 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" 1207 - /> 1208 - </svg> 1209 - <span class="text-base-700 dark:text-base-300 truncate text-sm"> 1210 - {link.name || link.uri.replace(/^https?:\/\//, '')} 1211 - </span> 1212 - <Button 1213 - variant="ghost" 1214 - size="iconSm" 1215 - onclick={() => removeLink(i)} 1216 - class="ml-auto shrink-0 opacity-0 transition-opacity group-hover:opacity-100" 1217 - > 1218 - <svg 1219 - xmlns="http://www.w3.org/2000/svg" 1220 - viewBox="0 0 20 20" 1221 - fill="currentColor" 1222 - class="size-3.5" 1223 - > 1224 - <path 1225 - d="M6.28 5.22a.75.75 0 0 0-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 1 0 1.06 1.06L10 11.06l3.72 3.72a.75.75 0 1 0 1.06-1.06L11.06 10l3.72-3.72a.75.75 0 0 0-1.06-1.06L10 8.94 6.28 5.22Z" 1226 - /> 1227 - </svg> 1228 - </Button> 1229 - </div> 1230 - {/each} 1231 - </div> 1232 - 1233 - <div class="mt-3"> 1234 - <PopoverRoot bind:open={showLinkPopup}> 1235 - <PopoverTrigger> 1236 - <Button size="sm"> 1237 - <svg 1238 - xmlns="http://www.w3.org/2000/svg" 1239 - fill="none" 1240 - viewBox="0 0 24 24" 1241 - stroke-width="1.5" 1242 - stroke="currentColor" 1243 - class="size-4" 1244 - > 1245 - <path 1246 - stroke-linecap="round" 1247 - stroke-linejoin="round" 1248 - d="M12 4.5v15m7.5-7.5h-15" 1249 - /> 1250 - </svg> 1251 - 1252 - Add link 1253 - </Button> 1254 - </PopoverTrigger> 1255 - <PopoverContent side="bottom" sideOffset={8} class="w-64 p-3"> 1256 - <Input 1257 - type="url" 1258 - bind:value={newLinkUri} 1259 - placeholder="https://..." 1260 - variant="secondary" 1261 - class="mb-2" 1262 - onkeydown={(e) => { 1263 - if (e.key === 'Enter') { 1264 - e.preventDefault(); 1265 - addLink(); 1266 - } 1267 - }} 1268 - /> 1269 - <Input 1270 - type="text" 1271 - bind:value={newLinkName} 1272 - placeholder="Label (optional)" 1273 - variant="secondary" 1274 - class="mb-2" 1275 - onkeydown={(e) => { 1276 - if (e.key === 'Enter') { 1277 - e.preventDefault(); 1278 - addLink(); 1279 - } 1280 - }} 1281 - /> 1282 - {#if linkError} 1283 - <p class="mb-2 text-xs text-red-500">{linkError}</p> 1284 - {/if} 1285 - <div class="flex justify-end gap-2"> 1286 - <Button 1287 - variant="ghost" 1288 - size="sm" 1289 - onclick={() => { 1290 - showLinkPopup = false; 1291 - linkError = ''; 1292 - newLinkUri = ''; 1293 - newLinkName = ''; 1294 - }} 1295 - > 1296 - Cancel 1297 - </Button> 1298 - <Button onclick={addLink} size="sm" disabled={!newLinkUri.trim()}>Add</Button> 1299 - </div> 1300 - </PopoverContent> 1301 - </PopoverRoot> 1302 - </div> 572 + <div class="order-4 space-y-6 md:order-0 md:col-start-1"> 573 + <LinksSection bind:links /> 574 + <ThemeSection bind:theme={eventTheme} /> 1303 575 </div> 1304 576 </div> 1305 577 ··· 1332 604 </div> 1333 605 </div> 1334 606 1335 - <!-- Theme modal --> 1336 - <Modal bind:open={showThemeModal}> 1337 - <p class="text-base-900 dark:text-base-50 text-lg font-semibold">Event theme</p> 1338 - <div class="mt-4"> 1339 - <ThemePicker bind:theme={eventTheme} /> 1340 - </div> 1341 - </Modal> 1342 - 1343 - <!-- Thumbnail modal --> 1344 - <Modal bind:open={showThumbnailModal}> 1345 - <p class="text-base-900 dark:text-base-50 text-lg font-semibold">Choose thumbnail</p> 1346 - <div class="mt-4 flex max-h-[70vh] flex-col gap-6 overflow-y-auto"> 1347 - <Button 1348 - variant="secondary" 1349 - class="w-full" 1350 - onclick={() => fileInput?.click()} 1351 - > 1352 - <svg 1353 - xmlns="http://www.w3.org/2000/svg" 1354 - fill="none" 1355 - viewBox="0 0 24 24" 1356 - stroke-width="1.5" 1357 - stroke="currentColor" 1358 - class="size-4" 1359 - > 1360 - <path 1361 - stroke-linecap="round" 1362 - stroke-linejoin="round" 1363 - d="M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5m-13.5-9L12 3m0 0 4.5 4.5M12 3v13.5" 1364 - /> 1365 - </svg> 1366 - Upload own thumbnail 1367 - </Button> 1368 - <ThumbnailPresets 1369 - name={name} 1370 - dateStr={thumbnailDateStr} 1371 - bind:selected={selectedPreset} 1372 - onselect={() => { showThumbnailModal = false; thumbnailPreview = null; thumbnailFile = null; thumbnailChanged = true; }} 1373 - /> 1374 - </div> 1375 - </Modal> 1376 - 1377 - <!-- Location modal --> 1378 - <Modal bind:open={showLocationModal}> 1379 - <p class="text-base-900 dark:text-base-50 text-lg font-semibold">Add location</p> 1380 - <form 1381 - onsubmit={(e) => { 1382 - e.preventDefault(); 1383 - searchLocation(); 1384 - }} 1385 - class="mt-2" 1386 - > 1387 - <div class="flex gap-2"> 1388 - <Input type="text" class="flex-1" bind:value={locationSearch} /> 1389 - <Button type="submit" disabled={locationSearching || !locationSearch.trim()}> 1390 - {locationSearching ? 'Searching...' : 'Search'} 1391 - </Button> 1392 - </div> 1393 - </form> 1394 - 1395 - {#if locationError} 1396 - <p class="mt-3 text-sm text-red-600 dark:text-red-400">{locationError}</p> 1397 - {/if} 1398 - 1399 - {#if locationResult} 1400 - <div 1401 - class="border-base-200 dark:border-base-700 bg-base-50 dark:bg-base-900 mt-4 overflow-hidden rounded-xl border p-4" 1402 - > 1403 - <div class="flex items-start gap-3"> 1404 - <svg 1405 - xmlns="http://www.w3.org/2000/svg" 1406 - fill="none" 1407 - viewBox="0 0 24 24" 1408 - stroke-width="1.5" 1409 - stroke="currentColor" 1410 - class="text-base-500 mt-0.5 size-5 shrink-0" 1411 - > 1412 - <path 1413 - stroke-linecap="round" 1414 - stroke-linejoin="round" 1415 - d="M15 10.5a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" 1416 - /> 1417 - <path 1418 - stroke-linecap="round" 1419 - stroke-linejoin="round" 1420 - 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" 1421 - /> 1422 - </svg> 1423 - <div class="min-w-0 flex-1"> 1424 - <p class="text-base-900 dark:text-base-50 font-medium"> 1425 - {getLocationDisplayString(locationResult.location)} 1426 - </p> 1427 - <p class="text-base-500 dark:text-base-400 mt-0.5 truncate text-xs"> 1428 - {locationResult.displayName} 1429 - </p> 1430 - </div> 1431 - </div> 1432 - <div class="mt-4 flex justify-end"> 1433 - <Button onclick={confirmLocation}>Use this location</Button> 1434 - </div> 1435 - </div> 1436 - {/if} 1437 - 1438 - <p class="text-base-400 dark:text-base-500 mt-4 text-xs"> 1439 - Geocoding by <a 1440 - href="https://nominatim.openstreetmap.org/" 1441 - class="hover:text-base-600 dark:hover:text-base-400 underline" 1442 - target="_blank">Nominatim</a 1443 - > 1444 - / &copy; 1445 - <a 1446 - href="https://www.openstreetmap.org/copyright" 1447 - class="hover:text-base-600 dark:hover:text-base-400 underline" 1448 - target="_blank">OpenStreetMap contributors</a 1449 - > 1450 - </p> 1451 - </Modal> 1452 - 1453 - <Modal bind:open={showRecurringModal}> 1454 - <p class="text-base-900 dark:text-base-50 text-lg font-semibold">Add recurring events</p> 1455 - <p class="text-base-500 dark:text-base-400 mt-1 text-sm"> 1456 - Create multiple copies of this event at regular intervals. 1457 - </p> 1458 - 1459 - <div class="mt-4 space-y-4"> 1460 - <div> 1461 - <!-- svelte-ignore a11y_label_has_associated_control --> 1462 - <label class="text-base-700 dark:text-base-300 mb-1 block text-sm font-medium"> 1463 - Number of events to create 1464 - </label> 1465 - <Input type="number" bind:value={recurringCount} min={1} max={52} class="w-24" /> 1466 - </div> 1467 - 1468 - <div> 1469 - <!-- svelte-ignore a11y_label_has_associated_control --> 1470 - <label class="text-base-700 dark:text-base-300 mb-1 block text-sm font-medium"> 1471 - Repeat every 1472 - </label> 1473 - <div class="flex items-center gap-2"> 1474 - <Input type="number" bind:value={recurringInterval} min={1} max={99} class="w-20" /> 1475 - <ToggleGroup type="single" bind:value={recurringUnit}> 1476 - <ToggleGroupItem value="days">days</ToggleGroupItem> 1477 - <ToggleGroupItem value="weeks">weeks</ToggleGroupItem> 1478 - <ToggleGroupItem value="months">months</ToggleGroupItem> 1479 - <ToggleGroupItem value="years">years</ToggleGroupItem> 1480 - </ToggleGroup> 1481 - </div> 1482 - </div> 1483 - 1484 - <div> 1485 - <div class="flex items-center gap-2"> 1486 - <Checkbox bind:checked={recurringNumberInTitle} sizeVariant="sm" /> 1487 - <span class="text-base-700 dark:text-base-300 text-sm font-medium">Number in title</span> 1488 - </div> 1489 - <p class="text-base-500 dark:text-base-400 mt-1 text-xs"> 1490 - {#if recurringNumberInTitle && detectedStartNumber !== null} 1491 - Titles will count up from #{detectedStartNumber + 1} 1492 - {:else if recurringNumberInTitle} 1493 - A number will be appended to each title 1494 - {:else} 1495 - Append a number to each event title 1496 - {/if} 1497 - </p> 1498 - </div> 1499 - </div> 1500 - 1501 - {#if recurringError} 1502 - <p class="mt-4 text-sm text-red-600 dark:text-red-400">{recurringError}</p> 1503 - {/if} 1504 - 1505 - {#if recurringCreating && recurringCreated > 0} 1506 - <p class="text-base-500 dark:text-base-400 mt-4 text-sm"> 1507 - Created {recurringCreated} of {recurringCount} events... 1508 - </p> 1509 - {/if} 1510 - 1511 - {#if recurringCreated > 0 && !recurringCreating} 1512 - <p class="mt-4 text-sm text-green-600 dark:text-green-400"> 1513 - Successfully created {recurringCreated} recurring events! 1514 - </p> 1515 - {/if} 1516 - 1517 - <div class="mt-4 flex justify-end gap-2"> 1518 - <Button 1519 - variant="secondary" 1520 - onclick={() => (showRecurringModal = false)} 1521 - disabled={recurringCreating} 1522 - > 1523 - {recurringCreated > 0 && !recurringCreating ? 'Close' : 'Cancel'} 1524 - </Button> 1525 - {#if !recurringCreated || recurringCreating} 1526 - <Button 1527 - onclick={handleCreateRecurring} 1528 - disabled={recurringCreating || recurringCount < 1} 1529 - > 1530 - {recurringCreating ? `Creating...` : `Create ${recurringCount} event${recurringCount === 1 ? '' : 's'}`} 1531 - </Button> 1532 - {/if} 1533 - </div> 1534 - </Modal> 607 + <RecurringModal 608 + bind:open={showRecurringModal} 609 + {rkey} 610 + {eventData} 611 + {isNew} 612 + {name} 613 + {startsAt} 614 + {endsAt} 615 + {mode} 616 + {timezone} 617 + {description} 618 + {links} 619 + {location} 620 + {thumbnailDateStr} 621 + {thumbnailFile} 622 + {thumbnailChanged} 623 + {selectedPreset} 624 + />
+32 -421
src/lib/components/EventView.svelte
··· 2 2 import { eventUrl, isEventOngoing, type FlatEventRecord } from '$lib/contrail'; 3 3 import { getCDNImageBlobUrl } from '$lib/atproto'; 4 4 import { user } from '$lib/atproto/auth.svelte'; 5 - import { Avatar as FoxAvatar, Badge, Button } from '@foxui/core'; 6 - import Map from '$lib/components/Map.svelte'; 5 + import { Avatar as FoxAvatar, Button } from '@foxui/core'; 7 6 import ShareModal from '$lib/components/ShareModal.svelte'; 8 7 import Avatar from 'svelte-boring-avatars'; 9 8 import EventRsvp from '$lib/components/EventRsvp.svelte'; 10 9 import EventCard from '$lib/components/EventCard.svelte'; 11 10 import EventAttendees from './EventAttendees.svelte'; 12 11 import VodPlayer, { type VodPlayerApi } from '$lib/components/VodPlayer.svelte'; 13 - import VodTranscript from '$lib/components/VodTranscript.svelte'; 14 12 import { page } from '$app/state'; 15 - import { marked } from 'marked'; 16 - import { sanitize } from '$lib/cal/sanitize'; 17 - import { generateICalEvent } from '$lib/cal/ical'; 18 13 import { launchConfetti } from '@foxui/visual'; 19 14 import ThemeBackground from '$lib/components/ThemeBackground.svelte'; 20 15 import ThemeApply from '$lib/components/ThemeApply.svelte'; 21 16 import { defaultTheme, type EventTheme } from '$lib/theme'; 17 + import { onMount } from 'svelte'; 18 + 19 + import EventBadges from './event-view/EventBadges.svelte'; 20 + import EventDateBlock from './event-view/EventDateBlock.svelte'; 21 + import EventLocationBlock from './event-view/EventLocationBlock.svelte'; 22 + import EventLocationMap from './event-view/EventLocationMap.svelte'; 23 + import EventHostedBy from './event-view/EventHostedBy.svelte'; 24 + import EventLinksList from './event-view/EventLinksList.svelte'; 25 + import AddToCalendarButton from './event-view/AddToCalendarButton.svelte'; 26 + import InviteShareFlow from './event-view/InviteShareFlow.svelte'; 27 + import { buildDescriptionHtml, getLocationData, resolveGeoLocation } from './event-view/format'; 22 28 23 29 let { data } = $props(); 24 30 ··· 29 35 let attendees = $derived(data.attendees); 30 36 31 37 let theme: EventTheme = $derived(eventData.theme ?? defaultTheme); 32 - 33 38 34 39 let hostUrl = $derived(`/p/${hostProfile?.handle || did}`); 35 40 let eventPath = $derived(eventUrl(eventData, hostProfile?.handle || did)); ··· 43 48 let startDate = $derived(new Date(eventData.startsAt)); 44 49 let endDate = $derived(eventData.endsAt ? new Date(eventData.endsAt) : null); 45 50 46 - function formatMonth(date: Date): string { 47 - return date.toLocaleDateString('en-US', { month: 'short' }).toUpperCase(); 48 - } 49 - 50 - function formatDay(date: Date): number { 51 - return date.getDate(); 52 - } 53 - 54 - function formatWeekday(date: Date): string { 55 - return date.toLocaleDateString('en-US', { weekday: 'long' }); 56 - } 57 - 58 - function formatFullDate(date: Date): string { 59 - const options: Intl.DateTimeFormatOptions = { month: 'long', day: 'numeric' }; 60 - if (date.getFullYear() !== new Date().getFullYear()) { 61 - options.year = 'numeric'; 62 - } 63 - return date.toLocaleDateString('en-US', options); 64 - } 65 - 66 - function formatTime(date: Date): string { 67 - return date.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' }); 68 - } 69 - 70 - function getModeLabel(mode: string): string { 71 - if (mode.includes('virtual')) return 'Virtual'; 72 - if (mode.includes('hybrid')) return 'Hybrid'; 73 - if (mode.includes('inperson')) return 'In-Person'; 74 - return 'Event'; 75 - } 76 - 77 - function getModeColor(mode: string): 'cyan' | 'purple' | 'amber' | 'secondary' { 78 - if (mode.includes('virtual')) return 'cyan'; 79 - if (mode.includes('hybrid')) return 'purple'; 80 - if (mode.includes('inperson')) return 'amber'; 81 - return 'secondary'; 82 - } 83 - 84 - function getLocationData(locations: FlatEventRecord['locations']) { 85 - if (!locations || locations.length === 0) return null; 86 - 87 - const loc = locations.find((v) => v.$type === 'community.lexicon.location.address') as 88 - | { name?: string; street?: string; locality?: string; region?: string; country?: string } 89 - | undefined; 90 - if (!loc) return null; 91 - 92 - const shortParts = [loc.street, loc.locality].filter(Boolean); 93 - const fullParts = [loc.street, loc.locality, loc.region, loc.country].filter(Boolean); 94 - if (fullParts.length === 0) return null; 95 - 96 - const shortAddress = shortParts.join(', '); 97 - const fullAddress = fullParts.join(', '); 98 - const displayName = loc.name || undefined; 99 - const fullString = displayName ? `${displayName}, ${fullAddress}` : fullAddress; 100 - const mapsUrl = `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(fullString)}`; 101 - 102 - return { name: displayName, shortAddress, fullAddress, fullString, mapsUrl }; 103 - } 104 - 105 51 let locationData = $derived(getLocationData(eventData.locations)); 106 - let location = $derived(locationData?.fullString); 107 - 108 52 let geoLocation: { lat: number; lng: number } | null = $state(null); 109 53 110 - function initGeoLocation() { 111 - if (!eventData.locations || eventData.locations.length === 0) return; 112 - 113 - // Check for explicit geo coordinates first 114 - const geo = eventData.locations.find((v) => v.$type === 'community.lexicon.location.geo') as 115 - | { latitude?: string; longitude?: string } 116 - | undefined; 117 - if (geo?.latitude && geo?.longitude) { 118 - const lat = parseFloat(geo.latitude); 119 - const lng = parseFloat(geo.longitude); 120 - if (!isNaN(lat) && !isNaN(lng)) { 121 - geoLocation = { lat, lng }; 122 - return; 123 - } 124 - } 125 - 126 - // Geocode from address if available 127 - const addressQuery = locationData?.fullAddress; 128 - if (addressQuery) { 129 - fetch(`/api/geocoding?q=${encodeURIComponent(addressQuery)}`) 130 - .then((r) => (r.ok ? r.json() : null)) 131 - .then((data: unknown) => { 132 - const d = data as Record<string, unknown> | null; 133 - if (!d) return; 134 - if (d.lat && d.lon) { 135 - geoLocation = { lat: parseFloat(d.lat as string), lng: parseFloat(d.lon as string) }; 136 - } 137 - }) 138 - .catch(() => {}); 139 - } 140 - } 141 - 142 54 let showShareModal = $state(false); 143 55 let shareModalTitle = $state('Event created!'); 144 56 let shareModalText: string | undefined = $state(undefined); 145 57 146 - import { onMount } from 'svelte'; 147 - onMount(() => { 148 - initGeoLocation(); 58 + onMount(async () => { 59 + geoLocation = await resolveGeoLocation(eventData.locations, locationData); 149 60 150 61 const url = new URL(window.location.href); 151 62 if (url.searchParams.has('created')) { ··· 180 91 let displayImage = $derived(thumbnailImage ?? bannerImage); 181 92 let isBannerOnly = $derived(!thumbnailImage && !!bannerImage); 182 93 183 - let isSameDay = $derived( 184 - endDate && 185 - startDate.getFullYear() === endDate.getFullYear() && 186 - startDate.getMonth() === endDate.getMonth() && 187 - startDate.getDate() === endDate.getDate() 188 - ); 189 - 190 94 let isOngoing = $derived(isEventOngoing(eventData.startsAt, eventData.endsAt)); 191 95 let isPast = $derived(endDate ? endDate < new Date() : false); 192 96 193 - const renderer = new marked.Renderer(); 194 - renderer.link = ({ href, text }) => 195 - `<a target="_blank" rel="noopener noreferrer nofollow" href="${href}" class="text-accent-600 dark:text-accent-400 hover:underline">${text}</a>`; 196 - 197 - function renderDescription( 198 - text: string, 199 - facets?: { 200 - index: { byteStart: number; byteEnd: number }; 201 - features: { $type: string; did?: string; uri?: string; tag?: string }[]; 202 - }[] 203 - ): string { 204 - let result = text; 205 - 206 - if (facets && facets.length > 0) { 207 - const encoder = new TextEncoder(); 208 - const encoded = encoder.encode(text); 209 - const decoder = new TextDecoder(); 210 - 211 - // Sort facets in reverse order by byteStart so replacements don't shift positions 212 - const sorted = [...facets].sort((a, b) => b.index.byteStart - a.index.byteStart); 213 - 214 - for (const facet of sorted) { 215 - const feature = facet.features?.[0]; 216 - if (!feature) continue; 217 - 218 - const segmentBytes = encoded.slice(facet.index.byteStart, facet.index.byteEnd); 219 - const segmentText = decoder.decode(segmentBytes); 220 - 221 - let mdLink: string | null = null; 222 - switch (feature.$type) { 223 - case 'app.bsky.richtext.facet#mention': 224 - mdLink = `[${segmentText}](/${feature.did})`; 225 - break; 226 - case 'app.bsky.richtext.facet#link': 227 - mdLink = `[${segmentText}](${feature.uri})`; 228 - break; 229 - case 'app.bsky.richtext.facet#tag': 230 - mdLink = `[${segmentText}](https://bsky.app/hashtag/${feature.tag})`; 231 - break; 232 - } 233 - 234 - if (mdLink) { 235 - // Convert byte offsets to character offsets for string replacement 236 - const before = decoder.decode(encoded.slice(0, facet.index.byteStart)); 237 - const after = decoder.decode(encoded.slice(facet.index.byteEnd)); 238 - result = before + mdLink + after; 239 - } 240 - } 241 - } 242 - 243 - return marked.parse(result, { renderer }) as string; 244 - } 245 - 246 97 let descriptionHtml = $derived( 247 - eventData.description 248 - ? sanitize( 249 - renderDescription( 250 - eventData.description, 251 - eventData.facets as 252 - | { 253 - index: { byteStart: number; byteEnd: number }; 254 - features: { $type: string; did?: string; uri?: string; tag?: string }[]; 255 - }[] 256 - | undefined 257 - ), 258 - { ADD_ATTR: ['target'] } 259 - ) 260 - : null 98 + buildDescriptionHtml(eventData.description, eventData.facets) 261 99 ); 262 100 263 101 let eventUri = $derived(`at://${did}/community.lexicon.calendar.event/${rkey}`); ··· 293 131 if (!user.did) return; 294 132 attendeesRef?.removeAttendee(user.did); 295 133 } 296 - 297 - function downloadIcs() { 298 - const ical = generateICalEvent(eventData, eventUri, page.url.href); 299 - const blob = new Blob([ical], { type: 'text/calendar;charset=utf-8' }); 300 - const url = URL.createObjectURL(blob); 301 - const a = document.createElement('a'); 302 - a.href = url; 303 - a.download = `${eventData.name.replace(/[^a-zA-Z0-9]/g, '-')}.ics`; 304 - a.click(); 305 - URL.revokeObjectURL(url); 306 - } 307 134 </script> 308 135 309 136 <svelte:head> ··· 359 186 {/if} 360 187 {#if isOwner} 361 188 <Button href="./{rkey}/edit" class="mt-9 w-full">Edit Event</Button> 189 + {#if data.spaceUri} 190 + <InviteShareFlow 191 + spaceUri={data.spaceUri} 192 + spaceKey={data.spaceKey} 193 + {did} 194 + {rkey} 195 + eventName={eventData.name} 196 + {hostProfile} 197 + /> 198 + {/if} 362 199 {/if} 363 200 </div> 364 201 {/if} ··· 371 208 </h1> 372 209 </div> 373 210 374 - <!-- Badges --> 375 - {#if eventData.mode || isOngoing} 376 - <div class="mb-8 flex items-center gap-2"> 377 - {#if isOngoing} 378 - <Badge size="md" variant="primary"> 379 - <span class="bg-accent-500 mr-1 inline-block size-1.5 animate-pulse rounded-full" 380 - ></span> 381 - Live 382 - </Badge> 383 - {/if} 384 - {#if eventData.mode} 385 - <Badge size="md" variant={getModeColor(eventData.mode)} 386 - >{getModeLabel(eventData.mode)}</Badge 387 - > 388 - {/if} 389 - </div> 390 - {/if} 211 + <EventBadges mode={eventData.mode} {isOngoing} /> 391 212 392 - <!-- Date row --> 393 - <div class="mb-4 flex items-center gap-4"> 394 - <div 395 - class="border-base-200 dark:border-base-700 bg-base-100 dark:bg-base-950/30 flex size-12 shrink-0 flex-col items-center justify-center overflow-hidden rounded-xl border" 396 - > 397 - <span class="text-base-500 dark:text-base-400 text-[9px] leading-none font-semibold"> 398 - {formatMonth(startDate)} 399 - </span> 400 - <span class="text-base-900 dark:text-base-50 text-lg leading-tight font-bold"> 401 - {formatDay(startDate)} 402 - </span> 403 - </div> 404 - <div> 405 - <p class="text-base-900 dark:text-base-50 font-semibold"> 406 - {formatWeekday(startDate)}, {formatFullDate(startDate)} 407 - {#if endDate && !isSameDay} 408 - - {formatWeekday(endDate)}, {formatFullDate(endDate)} 409 - {/if} 410 - </p> 411 - <p class="text-base-500 dark:text-base-400 text-sm"> 412 - {formatTime(startDate)} 413 - {#if endDate && isSameDay} 414 - - {formatTime(endDate)} 415 - {/if} 416 - </p> 417 - </div> 418 - </div> 213 + <EventDateBlock {startDate} {endDate} /> 419 214 420 - <!-- Location row --> 421 - {#if locationData} 422 - <a 423 - href={locationData.mapsUrl} 424 - target="_blank" 425 - rel="noopener noreferrer" 426 - class="mb-6 flex items-center gap-4 transition-opacity hover:opacity-80" 427 - > 428 - <div 429 - class="border-base-200 dark:border-base-700 bg-base-100 dark:bg-base-950/30 flex size-12 shrink-0 items-center justify-center rounded-xl border" 430 - > 431 - <svg 432 - xmlns="http://www.w3.org/2000/svg" 433 - fill="none" 434 - viewBox="0 0 24 24" 435 - stroke-width="1.5" 436 - stroke="currentColor" 437 - class="text-base-900 dark:text-base-200 size-5" 438 - > 439 - <path 440 - stroke-linecap="round" 441 - stroke-linejoin="round" 442 - d="M15 10.5a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" 443 - /> 444 - <path 445 - stroke-linecap="round" 446 - stroke-linejoin="round" 447 - 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" 448 - /> 449 - </svg> 450 - </div> 451 - <div> 452 - {#if locationData.name} 453 - <p class="text-base-900 dark:text-base-50 font-semibold">{locationData.name}</p> 454 - <p class="text-base-500 dark:text-base-400 text-sm">{locationData.shortAddress}</p> 455 - {:else} 456 - <p class="text-base-900 dark:text-base-50 font-semibold"> 457 - {locationData.shortAddress} 458 - </p> 459 - {/if} 460 - </div> 461 - </a> 462 - {/if} 215 + <EventLocationBlock {locationData} /> 463 216 464 217 <!-- Part of --> 465 218 {#if data.parentEvent} ··· 528 281 bind:api={vodApi} 529 282 /> 530 283 </div> 531 - 532 - <!-- <div class="mt-4 mb-8"> 533 - <p 534 - class="text-base-500 dark:text-base-400 mb-3 text-xs font-semibold tracking-wider uppercase" 535 - > 536 - Transcript 537 - </p> 538 - <VodTranscript 539 - transcriptUrl="/vods/{rkey}.json" 540 - currentTime={vodCurrentTime} 541 - onseek={(time) => vodApi?.seek(time)} 542 - /> 543 - </div> --> 544 284 {/if} 545 285 546 - <!-- Map --> 547 - {#if geoLocation && locationData} 548 - <div class="mt-8 mb-8"> 549 - <p 550 - class="text-base-500 dark:text-base-400 mb-3 text-xs font-semibold tracking-wider uppercase" 551 - > 552 - Location 553 - </p> 554 - <a 555 - href={locationData.mapsUrl} 556 - target="_blank" 557 - rel="noopener noreferrer" 558 - class="block transition-opacity hover:opacity-80" 559 - > 560 - <div class="h-64 w-full overflow-hidden rounded-xl"> 561 - <Map lat={geoLocation.lat} lng={geoLocation.lng} /> 562 - </div> 563 - <p class="text-base-500 dark:text-base-400 mt-2 text-sm"> 564 - {locationData.fullString} 565 - </p> 566 - </a> 567 - </div> 568 - {/if} 286 + <EventLocationMap {locationData} {geoLocation} /> 569 287 </div> 570 288 571 289 <!-- Left column: sidebar info --> 572 290 <div class="order-3 space-y-6 md:order-0 md:col-start-1"> 573 - <!-- Hosted By --> 574 - <div> 575 - <p 576 - class="text-base-500 dark:text-base-400 mb-3 text-xs font-semibold tracking-wider uppercase" 577 - > 578 - Hosted By 579 - </p> 580 - <a 581 - href={hostUrl} 582 - class="text-base-900 dark:text-base-100 flex items-center gap-2.5 font-medium transition-opacity hover:opacity-80" 583 - > 584 - <FoxAvatar 585 - src={hostProfile?.avatar} 586 - alt={hostProfile?.displayName || hostProfile?.handle || did} 587 - class="size-8 shrink-0" 588 - /> 589 - <span class="truncate text-sm"> 590 - {hostProfile?.displayName || hostProfile?.handle || did} 591 - </span> 592 - </a> 593 - </div> 291 + <EventHostedBy {hostProfile} {hostUrl} {did} {speakers} /> 594 292 595 - <!-- Speakers --> 596 - {#if speakers.length > 0} 597 - <div> 598 - <p 599 - class="text-base-500 dark:text-base-400 mb-3 text-xs font-semibold tracking-wider uppercase" 600 - > 601 - Speakers 602 - </p> 603 - <div class="space-y-2"> 604 - {#each speakers as speaker, i (speaker.id || i)} 605 - {#if speaker.handle} 606 - <a 607 - href="/p/{speaker.handle}" 608 - class="text-base-900 dark:text-base-100 flex items-center gap-2.5 font-medium transition-opacity hover:opacity-80" 609 - > 610 - <FoxAvatar src={speaker.avatar} alt={speaker.name} class="size-8 shrink-0" /> 611 - <span class="truncate text-sm">{speaker.name}</span> 612 - </a> 613 - {:else} 614 - <div 615 - class="text-base-900 dark:text-base-100 flex items-center gap-2.5 font-medium" 616 - > 617 - <FoxAvatar alt={speaker.name} class="size-8 shrink-0" /> 618 - <span class="truncate text-sm">{speaker.name}</span> 619 - </div> 620 - {/if} 621 - {/each} 622 - </div> 623 - </div> 624 - {/if} 293 + <EventLinksList uris={eventData.uris} /> 625 294 626 - {#if eventData.uris && eventData.uris.length > 0} 627 - <!-- Links --> 628 - <div> 629 - <p 630 - class="text-base-500 dark:text-base-400 mb-4 text-xs font-semibold tracking-wider uppercase" 631 - > 632 - Links 633 - </p> 634 - <div class="space-y-3"> 635 - {#each eventData.uris as link (link.name + link.uri)} 636 - <a 637 - href={link.uri} 638 - target="_blank" 639 - rel="noopener noreferrer" 640 - class="text-base-700 dark:text-base-300 hover:text-base-900 dark:hover:text-base-100 flex items-center gap-1.5 text-sm transition-colors" 641 - > 642 - <svg 643 - xmlns="http://www.w3.org/2000/svg" 644 - fill="none" 645 - viewBox="0 0 24 24" 646 - stroke-width="1.5" 647 - stroke="currentColor" 648 - class="size-3.5 shrink-0" 649 - > 650 - <path 651 - stroke-linecap="round" 652 - stroke-linejoin="round" 653 - 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" 654 - /> 655 - </svg> 656 - <span class="truncate">{link.name || link.uri.replace(/^https?:\/\//, '')}</span> 657 - </a> 658 - {/each} 659 - </div> 660 - </div> 661 - {/if} 295 + <AddToCalendarButton {eventData} {eventUri} pageHref={page.url.href} /> 662 296 663 - <!-- Add to Calendar --> 664 - <button 665 - onclick={downloadIcs} 666 - class="text-base-700 dark:text-base-300 hover:text-base-900 dark:hover:text-base-100 flex cursor-pointer items-center gap-2 text-sm font-medium transition-colors" 667 - > 668 - <svg 669 - xmlns="http://www.w3.org/2000/svg" 670 - fill="none" 671 - viewBox="0 0 24 24" 672 - stroke-width="1.5" 673 - stroke="currentColor" 674 - class="size-4" 675 - > 676 - <path 677 - stroke-linecap="round" 678 - stroke-linejoin="round" 679 - 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" 680 - /> 681 - </svg> 682 - Add to Calendar 683 - </button> 684 - 685 - <!-- Attendees --> 686 297 <EventAttendees 687 298 bind:this={attendeesRef} 688 299 going={attendees.going}
+144
src/lib/components/editor/LinksSection.svelte
··· 1 + <script lang="ts"> 2 + import { Button, Input, PopoverContent, PopoverRoot, PopoverTrigger } from '@foxui/core'; 3 + import { validateLink } from '$lib/cal/helper'; 4 + 5 + type Link = { uri: string; name: string }; 6 + 7 + let { links = $bindable() }: { links: Link[] } = $props(); 8 + 9 + let showPopup = $state(false); 10 + let newUri = $state(''); 11 + let newName = $state(''); 12 + let error = $state(''); 13 + 14 + function addLink() { 15 + const raw = newUri.trim(); 16 + if (!raw) return; 17 + const uri = validateLink(raw); 18 + if (!uri) { 19 + error = 'Please enter a valid URL'; 20 + return; 21 + } 22 + links.push({ uri, name: newName.trim() }); 23 + newUri = ''; 24 + newName = ''; 25 + error = ''; 26 + showPopup = false; 27 + } 28 + 29 + function removeLink(index: number) { 30 + links.splice(index, 1); 31 + } 32 + 33 + function cancel() { 34 + showPopup = false; 35 + error = ''; 36 + newUri = ''; 37 + newName = ''; 38 + } 39 + </script> 40 + 41 + <div> 42 + <p class="text-base-500 dark:text-base-400 mb-4 text-xs font-semibold tracking-wider uppercase"> 43 + Links 44 + </p> 45 + <div class="space-y-3"> 46 + {#each links as link, i (i)} 47 + <div class="group flex items-center gap-1.5"> 48 + <svg 49 + xmlns="http://www.w3.org/2000/svg" 50 + fill="none" 51 + viewBox="0 0 24 24" 52 + stroke-width="1.5" 53 + stroke="currentColor" 54 + class="text-base-700 dark:text-base-300 size-3.5 shrink-0" 55 + > 56 + <path 57 + stroke-linecap="round" 58 + stroke-linejoin="round" 59 + 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" 60 + /> 61 + </svg> 62 + <span class="text-base-700 dark:text-base-300 truncate text-sm"> 63 + {link.name || link.uri.replace(/^https?:\/\//, '')} 64 + </span> 65 + <Button 66 + variant="ghost" 67 + size="iconSm" 68 + onclick={() => removeLink(i)} 69 + class="ml-auto shrink-0 opacity-0 transition-opacity group-hover:opacity-100" 70 + > 71 + <svg 72 + xmlns="http://www.w3.org/2000/svg" 73 + viewBox="0 0 20 20" 74 + fill="currentColor" 75 + class="size-3.5" 76 + > 77 + <path 78 + d="M6.28 5.22a.75.75 0 0 0-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 1 0 1.06 1.06L10 11.06l3.72 3.72a.75.75 0 1 0 1.06-1.06L11.06 10l3.72-3.72a.75.75 0 0 0-1.06-1.06L10 8.94 6.28 5.22Z" 79 + /> 80 + </svg> 81 + </Button> 82 + </div> 83 + {/each} 84 + </div> 85 + 86 + <div class="mt-3"> 87 + <PopoverRoot bind:open={showPopup}> 88 + <PopoverTrigger> 89 + <Button size="sm"> 90 + <svg 91 + xmlns="http://www.w3.org/2000/svg" 92 + fill="none" 93 + viewBox="0 0 24 24" 94 + stroke-width="1.5" 95 + stroke="currentColor" 96 + class="size-4" 97 + > 98 + <path 99 + stroke-linecap="round" 100 + stroke-linejoin="round" 101 + d="M12 4.5v15m7.5-7.5h-15" 102 + /> 103 + </svg> 104 + Add link 105 + </Button> 106 + </PopoverTrigger> 107 + <PopoverContent side="bottom" sideOffset={8} class="w-64 p-3"> 108 + <Input 109 + type="url" 110 + bind:value={newUri} 111 + placeholder="https://..." 112 + variant="secondary" 113 + class="mb-2" 114 + onkeydown={(e) => { 115 + if (e.key === 'Enter') { 116 + e.preventDefault(); 117 + addLink(); 118 + } 119 + }} 120 + /> 121 + <Input 122 + type="text" 123 + bind:value={newName} 124 + placeholder="Label (optional)" 125 + variant="secondary" 126 + class="mb-2" 127 + onkeydown={(e) => { 128 + if (e.key === 'Enter') { 129 + e.preventDefault(); 130 + addLink(); 131 + } 132 + }} 133 + /> 134 + {#if error} 135 + <p class="mb-2 text-xs text-red-500">{error}</p> 136 + {/if} 137 + <div class="flex justify-end gap-2"> 138 + <Button variant="ghost" size="sm" onclick={cancel}>Cancel</Button> 139 + <Button onclick={addLink} size="sm" disabled={!newUri.trim()}>Add</Button> 140 + </div> 141 + </PopoverContent> 142 + </PopoverRoot> 143 + </div> 144 + </div>
+215
src/lib/components/editor/LocationSection.svelte
··· 1 + <script lang="ts"> 2 + import { Button, Input, Modal } from '@foxui/core'; 3 + import { getLocationDisplayString, type EventLocation } from './types'; 4 + 5 + let { 6 + location = $bindable(), 7 + locationChanged = $bindable() 8 + }: { 9 + location: EventLocation | null; 10 + locationChanged: boolean; 11 + } = $props(); 12 + 13 + let showModal = $state(false); 14 + let searchText = $state(''); 15 + let searching = $state(false); 16 + let error = $state(''); 17 + let result: { displayName: string; location: EventLocation } | null = $state(null); 18 + 19 + async function search() { 20 + const q = searchText.trim(); 21 + if (!q) return; 22 + error = ''; 23 + searching = true; 24 + result = null; 25 + 26 + try { 27 + const response = await fetch('/api/geocoding?q=' + encodeURIComponent(q)); 28 + if (!response.ok) throw new Error('response not ok'); 29 + const data: Record<string, unknown> = await response.json(); 30 + if (!data || data.error) throw new Error('no results'); 31 + 32 + const addr = (data.address || {}) as Record<string, string>; 33 + const road = addr.road || ''; 34 + const houseNumber = addr.house_number || ''; 35 + const street = road ? (houseNumber ? `${road} ${houseNumber}` : road) : ''; 36 + const locality = 37 + addr.city || addr.town || addr.village || addr.municipality || addr.hamlet || ''; 38 + const region = addr.state || addr.county || ''; 39 + const country = addr.country || ''; 40 + 41 + result = { 42 + displayName: (data.display_name as string) || q, 43 + location: { 44 + ...(street && { street }), 45 + ...(locality && { locality }), 46 + ...(region && { region }), 47 + ...(country && { country }) 48 + } 49 + }; 50 + } catch { 51 + error = "Couldn't find that location."; 52 + } finally { 53 + searching = false; 54 + } 55 + } 56 + 57 + function confirm() { 58 + if (result) { 59 + location = result.location; 60 + locationChanged = true; 61 + } 62 + showModal = false; 63 + searchText = ''; 64 + result = null; 65 + error = ''; 66 + } 67 + 68 + function remove() { 69 + location = null; 70 + locationChanged = true; 71 + } 72 + </script> 73 + 74 + {#if location} 75 + <div class="mb-6 flex items-center gap-4"> 76 + <div 77 + class="border-base-200 dark:border-base-700 bg-base-100 dark:bg-base-950/30 flex size-12 shrink-0 items-center justify-center rounded-xl border" 78 + > 79 + <svg 80 + xmlns="http://www.w3.org/2000/svg" 81 + fill="none" 82 + viewBox="0 0 24 24" 83 + stroke-width="1.5" 84 + stroke="currentColor" 85 + class="text-base-900 dark:text-base-200 size-5" 86 + > 87 + <path 88 + stroke-linecap="round" 89 + stroke-linejoin="round" 90 + d="M15 10.5a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" 91 + /> 92 + <path 93 + stroke-linecap="round" 94 + stroke-linejoin="round" 95 + 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" 96 + /> 97 + </svg> 98 + </div> 99 + <p class="text-base-900 dark:text-base-50 flex-1 font-semibold"> 100 + {getLocationDisplayString(location)} 101 + </p> 102 + <Button variant="ghost" size="iconSm" onclick={remove} class="shrink-0"> 103 + <svg 104 + xmlns="http://www.w3.org/2000/svg" 105 + viewBox="0 0 20 20" 106 + fill="currentColor" 107 + class="size-3.5" 108 + > 109 + <path 110 + d="M6.28 5.22a.75.75 0 0 0-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 1 0 1.06 1.06L10 11.06l3.72 3.72a.75.75 0 1 0 1.06-1.06L11.06 10l3.72-3.72a.75.75 0 0 0-1.06-1.06L10 8.94 6.28 5.22Z" 111 + /> 112 + </svg> 113 + </Button> 114 + </div> 115 + {:else} 116 + <div class="mb-6"> 117 + <Button variant="secondary" onclick={() => (showModal = true)}> 118 + <svg 119 + xmlns="http://www.w3.org/2000/svg" 120 + fill="none" 121 + viewBox="0 0 24 24" 122 + stroke-width="1.5" 123 + stroke="currentColor" 124 + class="size-4" 125 + > 126 + <path 127 + stroke-linecap="round" 128 + stroke-linejoin="round" 129 + d="M15 10.5a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" 130 + /> 131 + <path 132 + stroke-linecap="round" 133 + stroke-linejoin="round" 134 + 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" 135 + /> 136 + </svg> 137 + Add location 138 + </Button> 139 + </div> 140 + {/if} 141 + 142 + <Modal bind:open={showModal}> 143 + <p class="text-base-900 dark:text-base-50 text-lg font-semibold">Add location</p> 144 + <form 145 + onsubmit={(e) => { 146 + e.preventDefault(); 147 + search(); 148 + }} 149 + class="mt-2" 150 + > 151 + <div class="flex gap-2"> 152 + <Input type="text" class="flex-1" bind:value={searchText} /> 153 + <Button type="submit" disabled={searching || !searchText.trim()}> 154 + {searching ? 'Searching...' : 'Search'} 155 + </Button> 156 + </div> 157 + </form> 158 + 159 + {#if error} 160 + <p class="mt-3 text-sm text-red-600 dark:text-red-400">{error}</p> 161 + {/if} 162 + 163 + {#if result} 164 + <div 165 + class="border-base-200 dark:border-base-700 bg-base-50 dark:bg-base-900 mt-4 overflow-hidden rounded-xl border p-4" 166 + > 167 + <div class="flex items-start gap-3"> 168 + <svg 169 + xmlns="http://www.w3.org/2000/svg" 170 + fill="none" 171 + viewBox="0 0 24 24" 172 + stroke-width="1.5" 173 + stroke="currentColor" 174 + class="text-base-500 mt-0.5 size-5 shrink-0" 175 + > 176 + <path 177 + stroke-linecap="round" 178 + stroke-linejoin="round" 179 + d="M15 10.5a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" 180 + /> 181 + <path 182 + stroke-linecap="round" 183 + stroke-linejoin="round" 184 + 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" 185 + /> 186 + </svg> 187 + <div class="min-w-0 flex-1"> 188 + <p class="text-base-900 dark:text-base-50 font-medium"> 189 + {getLocationDisplayString(result.location)} 190 + </p> 191 + <p class="text-base-500 dark:text-base-400 mt-0.5 truncate text-xs"> 192 + {result.displayName} 193 + </p> 194 + </div> 195 + </div> 196 + <div class="mt-4 flex justify-end"> 197 + <Button onclick={confirm}>Use this location</Button> 198 + </div> 199 + </div> 200 + {/if} 201 + 202 + <p class="text-base-400 dark:text-base-500 mt-4 text-xs"> 203 + Geocoding by <a 204 + href="https://nominatim.openstreetmap.org/" 205 + class="hover:text-base-600 dark:hover:text-base-400 underline" 206 + target="_blank">Nominatim</a 207 + > 208 + / &copy; 209 + <a 210 + href="https://www.openstreetmap.org/copyright" 211 + class="hover:text-base-600 dark:hover:text-base-400 underline" 212 + target="_blank">OpenStreetMap contributors</a 213 + > 214 + </p> 215 + </Modal>
+264
src/lib/components/editor/RecurringModal.svelte
··· 1 + <script lang="ts"> 2 + import { Button, Checkbox, Input, Modal, ToggleGroup, ToggleGroupItem } from '@foxui/core'; 3 + import { parseDateTime } from '@internationalized/date'; 4 + import * as TID from '@atcute/tid'; 5 + import { putRecord } from '$lib/atproto/methods'; 6 + import { notifyContrailOfUpdate } from '$lib/contrail'; 7 + import { user } from '$lib/atproto/auth.svelte'; 8 + import type { FlatEventRecord } from '$lib/contrail'; 9 + import type { EventLocation, EventMode } from './types'; 10 + import { buildThumbnailMedia, renderPresetThumbnail } from './save'; 11 + 12 + let { 13 + open = $bindable(), 14 + rkey, 15 + eventData, 16 + isNew, 17 + name, 18 + startsAt, 19 + endsAt, 20 + mode, 21 + timezone, 22 + description, 23 + links, 24 + location, 25 + thumbnailDateStr, 26 + thumbnailFile, 27 + thumbnailChanged, 28 + selectedPreset 29 + }: { 30 + open: boolean; 31 + rkey: string; 32 + eventData: FlatEventRecord | null; 33 + isNew: boolean; 34 + name: string; 35 + startsAt: string; 36 + endsAt: string; 37 + mode: EventMode; 38 + timezone: string; 39 + description: string; 40 + links: Array<{ uri: string; name: string }>; 41 + location: EventLocation | null; 42 + thumbnailDateStr: string; 43 + thumbnailFile: File | null; 44 + thumbnailChanged: boolean; 45 + selectedPreset: { design: string; seed: number } | null; 46 + } = $props(); 47 + 48 + let interval = $state(1); 49 + let unit: 'days' | 'weeks' | 'months' | 'years' = $state('weeks'); 50 + let count = $state(4); 51 + let numberInTitle = $state(false); 52 + let creating = $state(false); 53 + let errorMsg: string | null = $state(null); 54 + let created = $state(0); 55 + 56 + let titleNumberMatch = $derived(name.match(/#?(\d+)\s*$/)); 57 + let detectedStartNumber = $derived(titleNumberMatch ? parseInt(titleNumberMatch[1]) : null); 58 + 59 + $effect(() => { 60 + if (detectedStartNumber !== null) numberInTitle = true; 61 + }); 62 + 63 + async function handleCreate() { 64 + if (!name.trim() || !startsAt || !user.isLoggedIn || !user.did) return; 65 + 66 + creating = true; 67 + errorMsg = null; 68 + created = 0; 69 + 70 + try { 71 + // Recurring instances advance by wall-clock duration (e.g. "every week 72 + // at 10am"), so operate on CalendarDateTime — not absolute instants — 73 + // to preserve the wall time across DST transitions. 74 + const baseStart = parseDateTime(startsAt); 75 + const baseEnd = endsAt ? parseDateTime(endsAt) : null; 76 + const durationMs = baseEnd 77 + ? baseEnd.toDate(timezone).getTime() - baseStart.toDate(timezone).getTime() 78 + : 0; 79 + const baseName = 80 + numberInTitle && titleNumberMatch 81 + ? name.replace(/#?\d+\s*$/, '').trimEnd() 82 + : name.trim(); 83 + const startNum = detectedStartNumber ?? 1; 84 + const hasHash = titleNumberMatch ? titleNumberMatch[0].includes('#') : false; 85 + 86 + // Generate thumbnail from preset if selected and no custom upload. 87 + let fileForUpload = thumbnailFile; 88 + let hasNewThumbnail = thumbnailChanged; 89 + if (selectedPreset && !fileForUpload) { 90 + const rendered = await renderPresetThumbnail({ 91 + design: selectedPreset.design, 92 + seed: selectedPreset.seed, 93 + name, 94 + dateStr: thumbnailDateStr 95 + }); 96 + if (rendered) { 97 + fileForUpload = rendered; 98 + hasNewThumbnail = true; 99 + } 100 + } 101 + 102 + const existingMedia = (eventData?.media ?? []) as Array<Record<string, unknown>>; 103 + const media = await buildThumbnailMedia({ 104 + isNew, 105 + thumbnailChanged: hasNewThumbnail, 106 + thumbnailFile: fileForUpload, 107 + existingMedia 108 + }); 109 + 110 + const parentUri = `at://${user.did}/community.lexicon.calendar.event/${rkey}`; 111 + 112 + for (let i = 0; i < count; i++) { 113 + const offset = i + 1; 114 + const step = offset * interval; 115 + const eventStart = 116 + unit === 'days' 117 + ? baseStart.add({ days: step }) 118 + : unit === 'weeks' 119 + ? baseStart.add({ weeks: step }) 120 + : unit === 'months' 121 + ? baseStart.add({ months: step }) 122 + : baseStart.add({ years: step }); 123 + 124 + const eventStartIso = eventStart.toDate(timezone).toISOString(); 125 + // Preserve the original absolute duration (handles events that 126 + // span midnight or odd wall-clock lengths correctly). 127 + const eventEndIso = durationMs 128 + ? new Date(eventStart.toDate(timezone).getTime() + durationMs).toISOString() 129 + : null; 130 + 131 + let eventName = baseName; 132 + if (numberInTitle) { 133 + const num = startNum + (i + 1); 134 + eventName = hasHash ? `${baseName} #${num}` : `${baseName} ${num}`; 135 + } 136 + 137 + const newRkey = TID.now(); 138 + const record: Record<string, unknown> = { 139 + $type: 'community.lexicon.calendar.event', 140 + createdWith: 'https://atmo.rsvp', 141 + name: eventName, 142 + mode: `community.lexicon.calendar.event#${mode}`, 143 + status: 'community.lexicon.calendar.event#scheduled', 144 + startsAt: eventStartIso, 145 + timezone, 146 + createdAt: new Date().toISOString(), 147 + recurringEventOf: parentUri 148 + }; 149 + 150 + const trimmedDescription = description.trim(); 151 + if (trimmedDescription) record.description = trimmedDescription; 152 + if (eventEndIso) record.endsAt = eventEndIso; 153 + if (media) record.media = media; 154 + if (links.length > 0) record.uris = links; 155 + if (location) { 156 + record.locations = [ 157 + { 158 + $type: 'community.lexicon.location.address', 159 + ...location 160 + } 161 + ]; 162 + } 163 + 164 + const response = await putRecord({ 165 + collection: 'community.lexicon.calendar.event', 166 + rkey: newRkey, 167 + record 168 + }); 169 + 170 + if (response.ok) { 171 + const eventUri = `at://${user.did}/community.lexicon.calendar.event/${newRkey}`; 172 + await notifyContrailOfUpdate(eventUri); 173 + created = i + 1; 174 + } else { 175 + errorMsg = `Failed to create event ${i + 1}. Stopping.`; 176 + return; 177 + } 178 + } 179 + 180 + open = false; 181 + } catch (e) { 182 + console.error('Failed to create recurring events:', e); 183 + errorMsg = 'Failed to create recurring events. Please try again.'; 184 + } finally { 185 + creating = false; 186 + } 187 + } 188 + </script> 189 + 190 + <Modal bind:open> 191 + <p class="text-base-900 dark:text-base-50 text-lg font-semibold">Add recurring events</p> 192 + <p class="text-base-500 dark:text-base-400 mt-1 text-sm"> 193 + Create multiple copies of this event at regular intervals. 194 + </p> 195 + 196 + <div class="mt-4 space-y-4"> 197 + <div> 198 + <!-- svelte-ignore a11y_label_has_associated_control --> 199 + <label class="text-base-700 dark:text-base-300 mb-1 block text-sm font-medium"> 200 + Number of events to create 201 + </label> 202 + <Input type="number" bind:value={count} min={1} max={52} class="w-24" /> 203 + </div> 204 + 205 + <div> 206 + <!-- svelte-ignore a11y_label_has_associated_control --> 207 + <label class="text-base-700 dark:text-base-300 mb-1 block text-sm font-medium"> 208 + Repeat every 209 + </label> 210 + <div class="flex items-center gap-2"> 211 + <Input type="number" bind:value={interval} min={1} max={99} class="w-20" /> 212 + <ToggleGroup type="single" bind:value={unit}> 213 + <ToggleGroupItem value="days">days</ToggleGroupItem> 214 + <ToggleGroupItem value="weeks">weeks</ToggleGroupItem> 215 + <ToggleGroupItem value="months">months</ToggleGroupItem> 216 + <ToggleGroupItem value="years">years</ToggleGroupItem> 217 + </ToggleGroup> 218 + </div> 219 + </div> 220 + 221 + <div> 222 + <div class="flex items-center gap-2"> 223 + <Checkbox bind:checked={numberInTitle} sizeVariant="sm" /> 224 + <span class="text-base-700 dark:text-base-300 text-sm font-medium">Number in title</span> 225 + </div> 226 + <p class="text-base-500 dark:text-base-400 mt-1 text-xs"> 227 + {#if numberInTitle && detectedStartNumber !== null} 228 + Titles will count up from #{detectedStartNumber + 1} 229 + {:else if numberInTitle} 230 + A number will be appended to each title 231 + {:else} 232 + Append a number to each event title 233 + {/if} 234 + </p> 235 + </div> 236 + </div> 237 + 238 + {#if errorMsg} 239 + <p class="mt-4 text-sm text-red-600 dark:text-red-400">{errorMsg}</p> 240 + {/if} 241 + 242 + {#if creating && created > 0} 243 + <p class="text-base-500 dark:text-base-400 mt-4 text-sm"> 244 + Created {created} of {count} events... 245 + </p> 246 + {/if} 247 + 248 + {#if created > 0 && !creating} 249 + <p class="mt-4 text-sm text-green-600 dark:text-green-400"> 250 + Successfully created {created} recurring events! 251 + </p> 252 + {/if} 253 + 254 + <div class="mt-4 flex justify-end gap-2"> 255 + <Button variant="secondary" onclick={() => (open = false)} disabled={creating}> 256 + {created > 0 && !creating ? 'Close' : 'Cancel'} 257 + </Button> 258 + {#if !created || creating} 259 + <Button onclick={handleCreate} disabled={creating || count < 1}> 260 + {creating ? `Creating...` : `Create ${count} event${count === 1 ? '' : 's'}`} 261 + </Button> 262 + {/if} 263 + </div> 264 + </Modal>
+39
src/lib/components/editor/ThemeSection.svelte
··· 1 + <script lang="ts"> 2 + import { Button, Modal } from '@foxui/core'; 3 + import ThemePicker from '$lib/components/ThemePicker.svelte'; 4 + import { themeBackgrounds, type EventTheme } from '$lib/theme'; 5 + 6 + let { theme = $bindable() }: { theme: EventTheme } = $props(); 7 + 8 + let showModal = $state(false); 9 + </script> 10 + 11 + <div> 12 + <p class="text-base-500 dark:text-base-400 mb-3 text-xs font-semibold tracking-wider uppercase"> 13 + Theme 14 + </p> 15 + <Button variant="secondary" size="sm" onclick={() => (showModal = true)}> 16 + <svg 17 + xmlns="http://www.w3.org/2000/svg" 18 + fill="none" 19 + viewBox="0 0 24 24" 20 + stroke-width="1.5" 21 + stroke="currentColor" 22 + class="size-4" 23 + > 24 + <path 25 + stroke-linecap="round" 26 + stroke-linejoin="round" 27 + d="M4.098 19.902a3.75 3.75 0 0 0 5.304 0l6.401-6.402M6.75 21A3.75 3.75 0 0 1 3 17.25V4.125C3 3.504 3.504 3 4.125 3h5.25c.621 0 1.125.504 1.125 1.125v4.072M6.75 21a3.75 3.75 0 0 0 3.75-3.75V8.197M6.75 21h13.125c.621 0 1.125-.504 1.125-1.125v-5.25c0-.621-.504-1.125-1.125-1.125h-4.072M10.5 8.197l2.88-2.88c.438-.439 1.15-.439 1.59 0l3.712 3.713c.44.44.44 1.152 0 1.59l-2.879 2.88M6.75 17.25h.008v.008H6.75v-.008Z" 28 + /> 29 + </svg> 30 + {themeBackgrounds[theme.name] || theme.name} 31 + </Button> 32 + </div> 33 + 34 + <Modal bind:open={showModal}> 35 + <p class="text-base-900 dark:text-base-50 text-lg font-semibold">Event theme</p> 36 + <div class="mt-4"> 37 + <ThemePicker bind:theme /> 38 + </div> 39 + </Modal>
+223
src/lib/components/editor/ThumbnailSection.svelte
··· 1 + <script lang="ts"> 2 + import { Button, Modal } from '@foxui/core'; 3 + import Avatar from 'svelte-boring-avatars'; 4 + import ThumbnailPresets from '$lib/components/ThumbnailPresets.svelte'; 5 + import { designs } from '$lib/components/thumbnails/designs'; 6 + import { deleteImage, putImage } from '$lib/components/image-store'; 7 + 8 + let { 9 + rkey, 10 + name, 11 + dateStr, 12 + thumbnailFile = $bindable(), 13 + thumbnailPreview = $bindable(), 14 + thumbnailKey = $bindable(), 15 + thumbnailChanged = $bindable(), 16 + selectedPreset = $bindable() 17 + }: { 18 + rkey: string; 19 + name: string; 20 + dateStr: string; 21 + thumbnailFile: File | null; 22 + thumbnailPreview: string | null; 23 + thumbnailKey: string | null; 24 + thumbnailChanged: boolean; 25 + selectedPreset: { design: string; seed: number } | null; 26 + } = $props(); 27 + 28 + let fileInput: HTMLInputElement | undefined = $state(); 29 + let presetPreviewCanvas: HTMLCanvasElement | undefined = $state(); 30 + let showModal = $state(false); 31 + let isDragOver = $state(false); 32 + 33 + async function setThumbnail(file: File) { 34 + thumbnailFile = file; 35 + thumbnailChanged = true; 36 + selectedPreset = null; 37 + if (thumbnailPreview) URL.revokeObjectURL(thumbnailPreview); 38 + thumbnailPreview = URL.createObjectURL(file); 39 + 40 + if (thumbnailKey) await deleteImage(thumbnailKey); 41 + thumbnailKey = crypto.randomUUID(); 42 + await putImage(thumbnailKey, file, file.name); 43 + } 44 + 45 + function onFileChange(e: Event) { 46 + const input = e.target as HTMLInputElement; 47 + const file = input.files?.[0]; 48 + if (!file) return; 49 + setThumbnail(file); 50 + showModal = false; 51 + } 52 + 53 + function onDragOver(e: DragEvent) { 54 + e.preventDefault(); 55 + isDragOver = true; 56 + } 57 + 58 + function onDragLeave(e: DragEvent) { 59 + e.preventDefault(); 60 + isDragOver = false; 61 + } 62 + 63 + function onDrop(e: DragEvent) { 64 + e.preventDefault(); 65 + isDragOver = false; 66 + const file = e.dataTransfer?.files?.[0]; 67 + if (file?.type.startsWith('image/')) { 68 + setThumbnail(file); 69 + } 70 + } 71 + 72 + function removeThumbnail() { 73 + thumbnailFile = null; 74 + thumbnailChanged = true; 75 + selectedPreset = null; 76 + if (thumbnailPreview) { 77 + URL.revokeObjectURL(thumbnailPreview); 78 + thumbnailPreview = null; 79 + } 80 + if (thumbnailKey) { 81 + deleteImage(thumbnailKey); 82 + thumbnailKey = null; 83 + } 84 + if (fileInput) fileInput.value = ''; 85 + } 86 + 87 + // Render preset preview canvas whenever the selection, name, or date changes. 88 + $effect(() => { 89 + if (selectedPreset && presetPreviewCanvas && designs[selectedPreset.design]) { 90 + const ctx = presetPreviewCanvas.getContext('2d'); 91 + if (!ctx) return; 92 + presetPreviewCanvas.width = 800; 93 + presetPreviewCanvas.height = 800; 94 + designs[selectedPreset.design]( 95 + ctx, 96 + 800, 97 + 800, 98 + name || 'Event', 99 + dateStr, 100 + selectedPreset.seed 101 + ); 102 + } 103 + }); 104 + </script> 105 + 106 + <!-- svelte-ignore a11y_no_static_element_interactions --> 107 + <div 108 + class="order-1 max-w-sm md:order-0 md:col-start-1 md:max-w-none" 109 + ondragover={onDragOver} 110 + ondragleave={onDragLeave} 111 + ondrop={onDrop} 112 + > 113 + <input 114 + bind:this={fileInput} 115 + type="file" 116 + accept="image/*" 117 + onchange={onFileChange} 118 + class="hidden" 119 + /> 120 + <div class="group relative"> 121 + {#if thumbnailPreview} 122 + <img 123 + src={thumbnailPreview} 124 + alt="Thumbnail preview" 125 + class="border-base-200 dark:border-base-800 aspect-square w-full rounded-2xl border object-cover" 126 + /> 127 + {:else if selectedPreset && designs[selectedPreset.design]} 128 + <div 129 + class="border-base-200 dark:border-base-800 aspect-square w-full overflow-hidden rounded-2xl border" 130 + > 131 + <canvas bind:this={presetPreviewCanvas} class="h-full w-full"></canvas> 132 + </div> 133 + {:else} 134 + <div 135 + class="bg-base-100 dark:bg-base-900 aspect-square w-full overflow-hidden rounded-2xl [&>svg]:h-full [&>svg]:w-full" 136 + > 137 + <Avatar 138 + size={400} 139 + name={rkey} 140 + variant="marble" 141 + colors={['#92A1C6', '#146A7C', '#F0AB3D', '#C271B4', '#C20D90']} 142 + square 143 + /> 144 + </div> 145 + {/if} 146 + <button 147 + type="button" 148 + onclick={() => (showModal = true)} 149 + class="absolute inset-0 flex cursor-pointer flex-col items-center justify-center gap-1.5 rounded-2xl bg-black/0 text-white/0 transition-colors group-hover:bg-black/40 group-hover:text-white/90 {isDragOver 150 + ? 'bg-black/40 text-white/90' 151 + : ''}" 152 + > 153 + <svg 154 + xmlns="http://www.w3.org/2000/svg" 155 + fill="none" 156 + viewBox="0 0 24 24" 157 + stroke-width="1.5" 158 + stroke="currentColor" 159 + class="size-6" 160 + > 161 + <path 162 + stroke-linecap="round" 163 + stroke-linejoin="round" 164 + 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.909M3.75 21h16.5A2.25 2.25 0 0 0 22.5 18.75V5.25A2.25 2.25 0 0 0 20.25 3H3.75A2.25 2.25 0 0 0 1.5 5.25v13.5A2.25 2.25 0 0 0 3.75 21Z" 165 + /> 166 + </svg> 167 + <span class="text-sm font-medium">Change thumbnail</span> 168 + </button> 169 + {#if thumbnailPreview || selectedPreset} 170 + <Button 171 + variant="ghost" 172 + size="iconSm" 173 + onclick={removeThumbnail} 174 + class="bg-base-900/70 absolute top-2 right-2 text-white opacity-0 transition-opacity group-hover:opacity-100 hover:bg-red-600" 175 + > 176 + <svg 177 + xmlns="http://www.w3.org/2000/svg" 178 + viewBox="0 0 20 20" 179 + fill="currentColor" 180 + class="size-3.5" 181 + > 182 + <path 183 + d="M6.28 5.22a.75.75 0 0 0-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 1 0 1.06 1.06L10 11.06l3.72 3.72a.75.75 0 1 0 1.06-1.06L11.06 10l3.72-3.72a.75.75 0 0 0-1.06-1.06L10 8.94 6.28 5.22Z" 184 + /> 185 + </svg> 186 + </Button> 187 + {/if} 188 + </div> 189 + </div> 190 + 191 + <Modal bind:open={showModal}> 192 + <p class="text-base-900 dark:text-base-50 text-lg font-semibold">Choose thumbnail</p> 193 + <div class="mt-4 flex max-h-[70vh] flex-col gap-6 overflow-y-auto"> 194 + <Button variant="secondary" class="w-full" onclick={() => fileInput?.click()}> 195 + <svg 196 + xmlns="http://www.w3.org/2000/svg" 197 + fill="none" 198 + viewBox="0 0 24 24" 199 + stroke-width="1.5" 200 + stroke="currentColor" 201 + class="size-4" 202 + > 203 + <path 204 + stroke-linecap="round" 205 + stroke-linejoin="round" 206 + d="M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5m-13.5-9L12 3m0 0 4.5 4.5M12 3v13.5" 207 + /> 208 + </svg> 209 + Upload own thumbnail 210 + </Button> 211 + <ThumbnailPresets 212 + {name} 213 + {dateStr} 214 + bind:selected={selectedPreset} 215 + onselect={() => { 216 + showModal = false; 217 + thumbnailPreview = null; 218 + thumbnailFile = null; 219 + thumbnailChanged = true; 220 + }} 221 + /> 222 + </div> 223 + </Modal>
+36
src/lib/components/editor/draft.ts
··· 1 + import type { EventDraft } from './types'; 2 + 3 + const OLD_DRAFT_KEY = 'blento-event-draft'; 4 + 5 + export function draftKeyFor(rkey: string): string { 6 + return `blento-event-edit-${rkey}`; 7 + } 8 + 9 + /** Promote any pre-existing shared "new event" draft into a per-rkey draft. */ 10 + export function migrateLegacyDraft(rkey: string): void { 11 + const key = draftKeyFor(rkey); 12 + const old = localStorage.getItem(OLD_DRAFT_KEY); 13 + if (old && !localStorage.getItem(key)) { 14 + localStorage.setItem(key, old); 15 + localStorage.removeItem(OLD_DRAFT_KEY); 16 + } 17 + } 18 + 19 + export function readDraft(rkey: string): EventDraft | null { 20 + const saved = localStorage.getItem(draftKeyFor(rkey)); 21 + if (!saved) return null; 22 + try { 23 + return JSON.parse(saved) as EventDraft; 24 + } catch { 25 + localStorage.removeItem(draftKeyFor(rkey)); 26 + return null; 27 + } 28 + } 29 + 30 + export function writeDraft(rkey: string, draft: EventDraft): void { 31 + localStorage.setItem(draftKeyFor(rkey), JSON.stringify(draft)); 32 + } 33 + 34 + export function clearDraft(rkey: string): void { 35 + localStorage.removeItem(draftKeyFor(rkey)); 36 + }
+209
src/lib/components/editor/save.ts
··· 1 + import { uploadBlob, resolveHandle } from '$lib/atproto/methods'; 2 + import { compressImage } from '$lib/atproto/image-helper'; 3 + import { tokenize, type Token } from '@atcute/bluesky-richtext-parser'; 4 + import type { Handle } from '@atcute/lexicons'; 5 + import { datetimeLocalToISO } from '$lib/date-format'; 6 + import { designs } from '$lib/components/thumbnails/designs'; 7 + import type { FlatEventRecord } from '$lib/contrail'; 8 + import type { EventTheme } from '$lib/theme'; 9 + import type { EventLocation, EventMode, Visibility } from './types'; 10 + 11 + export async function tokensToFacets(tokens: Token[]): Promise<Record<string, unknown>[]> { 12 + const encoder = new TextEncoder(); 13 + const facets: Record<string, unknown>[] = []; 14 + let byteOffset = 0; 15 + 16 + for (const token of tokens) { 17 + const tokenBytes = encoder.encode(token.raw); 18 + const byteStart = byteOffset; 19 + const byteEnd = byteOffset + tokenBytes.length; 20 + 21 + if (token.type === 'mention') { 22 + try { 23 + const did = await resolveHandle({ handle: token.handle as Handle }); 24 + if (did) { 25 + facets.push({ 26 + index: { byteStart, byteEnd }, 27 + features: [{ $type: 'app.bsky.richtext.facet#mention', did }] 28 + }); 29 + } 30 + } catch { 31 + // skip unresolvable mentions 32 + } 33 + } else if (token.type === 'autolink') { 34 + facets.push({ 35 + index: { byteStart, byteEnd }, 36 + features: [{ $type: 'app.bsky.richtext.facet#link', uri: token.url }] 37 + }); 38 + } else if (token.type === 'topic') { 39 + facets.push({ 40 + index: { byteStart, byteEnd }, 41 + features: [{ $type: 'app.bsky.richtext.facet#tag', tag: token.name }] 42 + }); 43 + } 44 + 45 + byteOffset = byteEnd; 46 + } 47 + 48 + return facets; 49 + } 50 + 51 + /** Render a selected thumbnail preset to a PNG File so it can be uploaded 52 + * as a blob like a user-provided image. Returns null if the preset design 53 + * is missing or the canvas fails to produce a blob. */ 54 + export async function renderPresetThumbnail(args: { 55 + design: string; 56 + seed: number; 57 + name: string; 58 + dateStr: string; 59 + }): Promise<File | null> { 60 + const drawer = designs[args.design]; 61 + if (!drawer) return null; 62 + const canvas = document.createElement('canvas'); 63 + canvas.width = 800; 64 + canvas.height = 800; 65 + const ctx = canvas.getContext('2d'); 66 + if (!ctx) return null; 67 + drawer(ctx, 800, 800, args.name.trim() || 'Event', args.dateStr, args.seed); 68 + const blob = await new Promise<Blob | null>((r) => canvas.toBlob(r, 'image/png')); 69 + if (!blob) return null; 70 + return new File([blob], 'thumbnail.png', { type: 'image/png' }); 71 + } 72 + 73 + export async function buildThumbnailMedia(args: { 74 + isNew: boolean; 75 + thumbnailChanged: boolean; 76 + thumbnailFile: File | null; 77 + existingMedia: Array<Record<string, unknown>>; 78 + }): Promise<Array<Record<string, unknown>> | undefined> { 79 + const { isNew, thumbnailChanged, thumbnailFile, existingMedia } = args; 80 + 81 + if (!isNew && !thumbnailChanged) { 82 + return existingMedia.length > 0 ? existingMedia : undefined; 83 + } 84 + 85 + if (!thumbnailFile) { 86 + const remaining = existingMedia.filter((m) => m.role !== 'thumbnail'); 87 + return remaining.length > 0 ? remaining : undefined; 88 + } 89 + 90 + const compressed = await compressImage(thumbnailFile); 91 + const result = await uploadBlob({ blob: compressed.blob }); 92 + if (!result) return existingMedia.length > 0 ? existingMedia : undefined; 93 + 94 + const { aspectRatio: _ar, ...blobRef } = result as Record<string, unknown> & { 95 + aspectRatio?: unknown; 96 + }; 97 + return [ 98 + ...existingMedia.filter((m) => m.role !== 'thumbnail'), 99 + { 100 + role: 'thumbnail', 101 + content: blobRef, 102 + aspect_ratio: { 103 + width: compressed.aspectRatio.width, 104 + height: compressed.aspectRatio.height 105 + } 106 + } 107 + ]; 108 + } 109 + 110 + export async function buildEventRecord(args: { 111 + eventData: FlatEventRecord | null; 112 + isNew: boolean; 113 + name: string; 114 + description: string; 115 + startsAt: string; 116 + endsAt: string; 117 + timezone: string; 118 + mode: EventMode; 119 + visibility: Visibility; 120 + theme: EventTheme; 121 + links: Array<{ uri: string; name: string }>; 122 + location: EventLocation | null; 123 + locationChanged: boolean; 124 + media: Array<Record<string, unknown>> | undefined; 125 + }): Promise<Record<string, unknown>> { 126 + const { 127 + eventData, 128 + isNew, 129 + name, 130 + description, 131 + startsAt, 132 + endsAt, 133 + timezone, 134 + mode, 135 + visibility, 136 + theme, 137 + links, 138 + location, 139 + locationChanged, 140 + media 141 + } = args; 142 + 143 + const createdAt = isNew 144 + ? new Date().toISOString() 145 + : eventData?.createdAt || new Date().toISOString(); 146 + 147 + // Spread original record to preserve unspecced fields (e.g. additionalData) 148 + const record: Record<string, unknown> = { 149 + ...(eventData ? { ...eventData } : {}), 150 + $type: 'community.lexicon.calendar.event', 151 + createdWith: 'https://atmo.rsvp', 152 + name: name.trim(), 153 + mode: `community.lexicon.calendar.event#${mode}`, 154 + status: 'community.lexicon.calendar.event#scheduled', 155 + startsAt: datetimeLocalToISO(startsAt, timezone), 156 + timezone, 157 + createdAt, 158 + theme 159 + }; 160 + // Remove flattened fields that aren't part of the actual record 161 + for (const k of [ 162 + 'cid', 163 + 'did', 164 + 'rkey', 165 + 'uri', 166 + 'rsvps', 167 + 'rsvpsCount', 168 + 'rsvpsGoingCount', 169 + 'rsvpsInterestedCount', 170 + 'rsvpsNotgoingCount' 171 + ]) { 172 + delete record[k]; 173 + } 174 + 175 + const trimmedDescription = description.trim(); 176 + if (trimmedDescription) { 177 + record.description = trimmedDescription; 178 + const tokens = tokenize(trimmedDescription); 179 + const facets = await tokensToFacets(tokens); 180 + if (facets.length > 0) record.facets = facets; 181 + } 182 + 183 + if (endsAt) record.endsAt = datetimeLocalToISO(endsAt, timezone); 184 + if (media) record.media = media; 185 + if (links.length > 0) record.uris = links; 186 + 187 + if (isNew || locationChanged) { 188 + if (location) { 189 + record.locations = [ 190 + { 191 + $type: 'community.lexicon.location.address', 192 + ...location 193 + } 194 + ]; 195 + } 196 + // If changed/new but no location, locations stays undefined (removed/absent) 197 + } else if (eventData?.locations && eventData.locations.length > 0) { 198 + record.locations = eventData.locations; 199 + } 200 + 201 + const existingPrefs = ((record.preferences as Record<string, unknown> | undefined) ?? 202 + {}) as Record<string, unknown>; 203 + record.preferences = { 204 + ...existingPrefs, 205 + showInDiscovery: visibility !== 'unlisted' 206 + }; 207 + 208 + return record; 209 + }
+37
src/lib/components/editor/types.ts
··· 1 + import type { EventTheme } from '$lib/theme'; 2 + 3 + export type EventMode = 'inperson' | 'virtual' | 'hybrid'; 4 + export type Visibility = 'public' | 'private' | 'unlisted'; 5 + 6 + export interface EventLocation { 7 + street?: string; 8 + locality?: string; 9 + region?: string; 10 + country?: string; 11 + } 12 + 13 + export interface EventDraft { 14 + name: string; 15 + description: string; 16 + startsAt: string; 17 + endsAt: string; 18 + timezone?: string; 19 + theme?: EventTheme; 20 + links: Array<{ uri: string; name: string }>; 21 + mode?: EventMode; 22 + visibility?: Visibility; 23 + thumbnailKey?: string; 24 + thumbnailChanged?: boolean; 25 + location?: EventLocation | null; 26 + locationChanged?: boolean; 27 + } 28 + 29 + export function stripModePrefix(modeStr: string): EventMode { 30 + const stripped = modeStr.replace('community.lexicon.calendar.event#', ''); 31 + if (stripped === 'virtual' || stripped === 'hybrid' || stripped === 'inperson') return stripped; 32 + return 'inperson'; 33 + } 34 + 35 + export function getLocationDisplayString(loc: EventLocation): string { 36 + return [loc.street, loc.locality, loc.region, loc.country].filter(Boolean).join(', '); 37 + }
+42
src/lib/components/event-view/AddToCalendarButton.svelte
··· 1 + <script lang="ts"> 2 + import { generateICalEvent } from '$lib/cal/ical'; 3 + import type { FlatEventRecord } from '$lib/contrail'; 4 + 5 + let { 6 + eventData, 7 + eventUri, 8 + pageHref 9 + }: { eventData: FlatEventRecord; eventUri: string; pageHref: string } = $props(); 10 + 11 + function downloadIcs() { 12 + const ical = generateICalEvent(eventData, eventUri, pageHref); 13 + const blob = new Blob([ical], { type: 'text/calendar;charset=utf-8' }); 14 + const url = URL.createObjectURL(blob); 15 + const a = document.createElement('a'); 16 + a.href = url; 17 + a.download = `${eventData.name.replace(/[^a-zA-Z0-9]/g, '-')}.ics`; 18 + a.click(); 19 + URL.revokeObjectURL(url); 20 + } 21 + </script> 22 + 23 + <button 24 + onclick={downloadIcs} 25 + class="text-base-700 dark:text-base-300 hover:text-base-900 dark:hover:text-base-100 flex cursor-pointer items-center gap-2 text-sm font-medium transition-colors" 26 + > 27 + <svg 28 + xmlns="http://www.w3.org/2000/svg" 29 + fill="none" 30 + viewBox="0 0 24 24" 31 + stroke-width="1.5" 32 + stroke="currentColor" 33 + class="size-4" 34 + > 35 + <path 36 + stroke-linecap="round" 37 + stroke-linejoin="round" 38 + 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" 39 + /> 40 + </svg> 41 + Add to Calendar 42 + </button>
+20
src/lib/components/event-view/EventBadges.svelte
··· 1 + <script lang="ts"> 2 + import { Badge } from '@foxui/core'; 3 + import { getModeColor, getModeLabel } from './format'; 4 + 5 + let { mode, isOngoing }: { mode?: string; isOngoing: boolean } = $props(); 6 + </script> 7 + 8 + {#if mode || isOngoing} 9 + <div class="mb-8 flex items-center gap-2"> 10 + {#if isOngoing} 11 + <Badge size="md" variant="primary"> 12 + <span class="bg-accent-500 mr-1 inline-block size-1.5 animate-pulse rounded-full"></span> 13 + Live 14 + </Badge> 15 + {/if} 16 + {#if mode} 17 + <Badge size="md" variant={getModeColor(mode)}>{getModeLabel(mode)}</Badge> 18 + {/if} 19 + </div> 20 + {/if}
+39
src/lib/components/event-view/EventDateBlock.svelte
··· 1 + <script lang="ts"> 2 + import { formatDay, formatFullDate, formatMonth, formatTime, formatWeekday } from './format'; 3 + 4 + let { startDate, endDate }: { startDate: Date; endDate: Date | null } = $props(); 5 + 6 + let isSameDay = $derived( 7 + endDate && 8 + startDate.getFullYear() === endDate.getFullYear() && 9 + startDate.getMonth() === endDate.getMonth() && 10 + startDate.getDate() === endDate.getDate() 11 + ); 12 + </script> 13 + 14 + <div class="mb-4 flex items-center gap-4"> 15 + <div 16 + class="border-base-200 dark:border-base-700 bg-base-100 dark:bg-base-950/30 flex size-12 shrink-0 flex-col items-center justify-center overflow-hidden rounded-xl border" 17 + > 18 + <span class="text-base-500 dark:text-base-400 text-[9px] leading-none font-semibold"> 19 + {formatMonth(startDate)} 20 + </span> 21 + <span class="text-base-900 dark:text-base-50 text-lg leading-tight font-bold"> 22 + {formatDay(startDate)} 23 + </span> 24 + </div> 25 + <div> 26 + <p class="text-base-900 dark:text-base-50 font-semibold"> 27 + {formatWeekday(startDate)}, {formatFullDate(startDate)} 28 + {#if endDate && !isSameDay} 29 + - {formatWeekday(endDate)}, {formatFullDate(endDate)} 30 + {/if} 31 + </p> 32 + <p class="text-base-500 dark:text-base-400 text-sm"> 33 + {formatTime(startDate)} 34 + {#if endDate && isSameDay} 35 + - {formatTime(endDate)} 36 + {/if} 37 + </p> 38 + </div> 39 + </div>
+63
src/lib/components/event-view/EventHostedBy.svelte
··· 1 + <script lang="ts"> 2 + import { Avatar as FoxAvatar } from '@foxui/core'; 3 + import type { HostProfile } from '$lib/contrail'; 4 + 5 + type Speaker = { id?: string; handle?: string; name: string; avatar?: string }; 6 + 7 + let { 8 + hostProfile, 9 + hostUrl, 10 + did, 11 + speakers = [] 12 + }: { 13 + hostProfile: HostProfile | null | undefined; 14 + hostUrl: string; 15 + did: string; 16 + speakers?: Speaker[]; 17 + } = $props(); 18 + </script> 19 + 20 + <div> 21 + <p class="text-base-500 dark:text-base-400 mb-3 text-xs font-semibold tracking-wider uppercase"> 22 + Hosted By 23 + </p> 24 + <a 25 + href={hostUrl} 26 + class="text-base-900 dark:text-base-100 flex items-center gap-2.5 font-medium transition-opacity hover:opacity-80" 27 + > 28 + <FoxAvatar 29 + src={hostProfile?.avatar} 30 + alt={hostProfile?.displayName || hostProfile?.handle || did} 31 + class="size-8 shrink-0" 32 + /> 33 + <span class="truncate text-sm"> 34 + {hostProfile?.displayName || hostProfile?.handle || did} 35 + </span> 36 + </a> 37 + </div> 38 + 39 + {#if speakers.length > 0} 40 + <div> 41 + <p class="text-base-500 dark:text-base-400 mb-3 text-xs font-semibold tracking-wider uppercase"> 42 + Speakers 43 + </p> 44 + <div class="space-y-2"> 45 + {#each speakers as speaker, i (speaker.id || i)} 46 + {#if speaker.handle} 47 + <a 48 + href="/p/{speaker.handle}" 49 + class="text-base-900 dark:text-base-100 flex items-center gap-2.5 font-medium transition-opacity hover:opacity-80" 50 + > 51 + <FoxAvatar src={speaker.avatar} alt={speaker.name} class="size-8 shrink-0" /> 52 + <span class="truncate text-sm">{speaker.name}</span> 53 + </a> 54 + {:else} 55 + <div class="text-base-900 dark:text-base-100 flex items-center gap-2.5 font-medium"> 56 + <FoxAvatar alt={speaker.name} class="size-8 shrink-0" /> 57 + <span class="truncate text-sm">{speaker.name}</span> 58 + </div> 59 + {/if} 60 + {/each} 61 + </div> 62 + </div> 63 + {/if}
+37
src/lib/components/event-view/EventLinksList.svelte
··· 1 + <script lang="ts"> 2 + let { uris = [] }: { uris?: Array<{ uri: string; name?: string }> } = $props(); 3 + </script> 4 + 5 + {#if uris.length > 0} 6 + <div> 7 + <p class="text-base-500 dark:text-base-400 mb-4 text-xs font-semibold tracking-wider uppercase"> 8 + Links 9 + </p> 10 + <div class="space-y-3"> 11 + {#each uris as link (link.name + link.uri)} 12 + <a 13 + href={link.uri} 14 + target="_blank" 15 + rel="noopener noreferrer" 16 + class="text-base-700 dark:text-base-300 hover:text-base-900 dark:hover:text-base-100 flex items-center gap-1.5 text-sm transition-colors" 17 + > 18 + <svg 19 + xmlns="http://www.w3.org/2000/svg" 20 + fill="none" 21 + viewBox="0 0 24 24" 22 + stroke-width="1.5" 23 + stroke="currentColor" 24 + class="size-3.5 shrink-0" 25 + > 26 + <path 27 + stroke-linecap="round" 28 + stroke-linejoin="round" 29 + 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" 30 + /> 31 + </svg> 32 + <span class="truncate">{link.name || link.uri.replace(/^https?:\/\//, '')}</span> 33 + </a> 34 + {/each} 35 + </div> 36 + </div> 37 + {/if}
+48
src/lib/components/event-view/EventLocationBlock.svelte
··· 1 + <script lang="ts"> 2 + import type { LocationData } from './format'; 3 + 4 + let { locationData }: { locationData: LocationData | null } = $props(); 5 + </script> 6 + 7 + {#if locationData} 8 + <a 9 + href={locationData.mapsUrl} 10 + target="_blank" 11 + rel="noopener noreferrer" 12 + class="mb-6 flex items-center gap-4 transition-opacity hover:opacity-80" 13 + > 14 + <div 15 + class="border-base-200 dark:border-base-700 bg-base-100 dark:bg-base-950/30 flex size-12 shrink-0 items-center justify-center rounded-xl border" 16 + > 17 + <svg 18 + xmlns="http://www.w3.org/2000/svg" 19 + fill="none" 20 + viewBox="0 0 24 24" 21 + stroke-width="1.5" 22 + stroke="currentColor" 23 + class="text-base-900 dark:text-base-200 size-5" 24 + > 25 + <path 26 + stroke-linecap="round" 27 + stroke-linejoin="round" 28 + d="M15 10.5a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" 29 + /> 30 + <path 31 + stroke-linecap="round" 32 + stroke-linejoin="round" 33 + 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" 34 + /> 35 + </svg> 36 + </div> 37 + <div> 38 + {#if locationData.name} 39 + <p class="text-base-900 dark:text-base-50 font-semibold">{locationData.name}</p> 40 + <p class="text-base-500 dark:text-base-400 text-sm">{locationData.shortAddress}</p> 41 + {:else} 42 + <p class="text-base-900 dark:text-base-50 font-semibold"> 43 + {locationData.shortAddress} 44 + </p> 45 + {/if} 46 + </div> 47 + </a> 48 + {/if}
+35
src/lib/components/event-view/EventLocationMap.svelte
··· 1 + <script lang="ts"> 2 + import Map from '$lib/components/Map.svelte'; 3 + import type { LocationData } from './format'; 4 + 5 + let { 6 + locationData, 7 + geoLocation 8 + }: { 9 + locationData: LocationData | null; 10 + geoLocation: { lat: number; lng: number } | null; 11 + } = $props(); 12 + </script> 13 + 14 + {#if geoLocation && locationData} 15 + <div class="mt-8 mb-8"> 16 + <p 17 + class="text-base-500 dark:text-base-400 mb-3 text-xs font-semibold tracking-wider uppercase" 18 + > 19 + Location 20 + </p> 21 + <a 22 + href={locationData.mapsUrl} 23 + target="_blank" 24 + rel="noopener noreferrer" 25 + class="block transition-opacity hover:opacity-80" 26 + > 27 + <div class="h-64 w-full overflow-hidden rounded-xl"> 28 + <Map lat={geoLocation.lat} lng={geoLocation.lng} /> 29 + </div> 30 + <p class="text-base-500 dark:text-base-400 mt-2 text-sm"> 31 + {locationData.fullString} 32 + </p> 33 + </a> 34 + </div> 35 + {/if}
+168
src/lib/components/event-view/InviteShareFlow.svelte
··· 1 + <script lang="ts"> 2 + import { Button, Checkbox, Input, Label, Modal } from '@foxui/core'; 3 + import DateTimePicker from '$lib/components/DateTimePicker.svelte'; 4 + import ShareModal from '$lib/components/ShareModal.svelte'; 5 + import { datetimeLocalToISO } from '$lib/date-format'; 6 + import type { HostProfile } from '$lib/contrail'; 7 + 8 + let { 9 + spaceUri, 10 + spaceKey, 11 + did, 12 + rkey, 13 + eventName, 14 + hostProfile 15 + }: { 16 + spaceUri: string; 17 + spaceKey: string; 18 + did: string; 19 + rkey: string; 20 + eventName: string; 21 + hostProfile: HostProfile | null | undefined; 22 + } = $props(); 23 + 24 + let inviteUrl: string | null = $state(null); 25 + let inviteBusy = $state(false); 26 + let inviteError: string | null = $state(null); 27 + let showInviteModal = $state(false); 28 + 29 + // Invite-options dialog (shown before the share modal) — owner picks 30 + // whether to allow anonymous reads, max uses, and expiry. 31 + let showInviteForm = $state(false); 32 + let inviteAllowAnonRead = $state(true); 33 + let inviteMaxUsesText = $state(''); 34 + let inviteHasExpiry = $state(false); 35 + let inviteExpiresAt = $state(''); 36 + const inviteTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone; 37 + 38 + function openInviteForm() { 39 + inviteError = null; 40 + inviteAllowAnonRead = true; 41 + inviteMaxUsesText = ''; 42 + inviteHasExpiry = false; 43 + showInviteForm = true; 44 + } 45 + 46 + async function submitInviteForm() { 47 + if (inviteBusy) return; 48 + inviteBusy = true; 49 + inviteError = null; 50 + try { 51 + let maxUses: number | undefined; 52 + if (inviteMaxUsesText.trim()) { 53 + const n = Number(inviteMaxUsesText); 54 + if (!Number.isInteger(n) || n < 1) { 55 + throw new Error('Max uses must be a positive integer.'); 56 + } 57 + maxUses = n; 58 + } 59 + 60 + let expiresAt: number | undefined; 61 + if (inviteHasExpiry && inviteExpiresAt.trim()) { 62 + const iso = datetimeLocalToISO(inviteExpiresAt, inviteTimezone); 63 + const ts = new Date(iso).getTime(); 64 + if (!Number.isFinite(ts)) throw new Error('Invalid expiry date.'); 65 + if (ts <= Date.now()) throw new Error('Expiry must be in the future.'); 66 + expiresAt = ts; 67 + } 68 + 69 + const { createInvite } = await import('$lib/spaces/server/spaces.remote'); 70 + const result = await createInvite({ 71 + spaceUri, 72 + kind: inviteAllowAnonRead ? 'read-join' : 'join', 73 + ...(maxUses != null ? { maxUses } : {}), 74 + ...(expiresAt != null ? { expiresAt } : {}) 75 + }); 76 + inviteUrl = `${window.location.origin}/p/${hostProfile?.handle || did}/e/${rkey}/s/${spaceKey}?invite=${result.token}`; 77 + showInviteForm = false; 78 + showInviteModal = true; 79 + } catch (e) { 80 + inviteError = e instanceof Error ? e.message : String(e); 81 + } finally { 82 + inviteBusy = false; 83 + } 84 + } 85 + </script> 86 + 87 + <Button variant="secondary" class="mt-3 w-full" onclick={openInviteForm}> 88 + Share invite link 89 + </Button> 90 + 91 + <Modal bind:open={showInviteForm} interactOutsideBehavior={inviteBusy ? 'ignore' : 'close'}> 92 + <h2 class="mb-4 text-lg font-semibold">Create invite link</h2> 93 + 94 + <form 95 + class="space-y-4" 96 + onsubmit={(e) => { 97 + e.preventDefault(); 98 + submitInviteForm(); 99 + }} 100 + > 101 + <div class="flex items-start gap-2"> 102 + <Checkbox id="invite-allow-anon" bind:checked={inviteAllowAnonRead} disabled={inviteBusy} /> 103 + <div> 104 + <Label for="invite-allow-anon">Allow viewing event without being logged in</Label> 105 + <p class="text-base-500 dark:text-base-400 mt-0.5 text-xs"> 106 + Anyone with this link can read the event details. Signed-in users can still join with the 107 + same link. 108 + </p> 109 + </div> 110 + </div> 111 + 112 + <div> 113 + <Label for="invite-max-uses">Max uses</Label> 114 + <Input 115 + id="invite-max-uses" 116 + type="number" 117 + min="1" 118 + bind:value={inviteMaxUsesText} 119 + placeholder="Unlimited" 120 + disabled={inviteBusy} 121 + /> 122 + <p class="text-base-500 dark:text-base-400 mt-1 text-xs"> 123 + Caps how many people can join — anonymous reads are always unlimited. Leave empty for no 124 + limit. 125 + </p> 126 + </div> 127 + 128 + <div> 129 + <div class="mb-1 flex items-center gap-2"> 130 + <Checkbox id="invite-has-expiry" bind:checked={inviteHasExpiry} disabled={inviteBusy} /> 131 + <Label for="invite-has-expiry">Set an expiry</Label> 132 + </div> 133 + {#if inviteHasExpiry} 134 + <DateTimePicker bind:value={inviteExpiresAt} /> 135 + {:else} 136 + <p class="text-base-500 dark:text-base-400 text-xs">Link never expires.</p> 137 + {/if} 138 + </div> 139 + 140 + {#if inviteError} 141 + <p class="text-sm text-red-600 dark:text-red-400">{inviteError}</p> 142 + {/if} 143 + 144 + <div class="flex justify-end gap-2 pt-2"> 145 + <Button 146 + type="button" 147 + variant="secondary" 148 + onclick={() => (showInviteForm = false)} 149 + disabled={inviteBusy} 150 + > 151 + Cancel 152 + </Button> 153 + <Button type="submit" disabled={inviteBusy}> 154 + {inviteBusy ? 'Creating…' : 'Create invite'} 155 + </Button> 156 + </div> 157 + </form> 158 + </Modal> 159 + 160 + {#if inviteUrl} 161 + <ShareModal 162 + bind:open={showInviteModal} 163 + url={inviteUrl} 164 + title="Invite link" 165 + shareText={`You're invited to "${eventName}".\n\n${inviteUrl}`} 166 + {eventName} 167 + /> 168 + {/if}
+159
src/lib/components/event-view/format.ts
··· 1 + import { marked } from 'marked'; 2 + import { sanitize } from '$lib/cal/sanitize'; 3 + import type { FlatEventRecord } from '$lib/contrail'; 4 + 5 + export function formatMonth(date: Date): string { 6 + return date.toLocaleDateString('en-US', { month: 'short' }).toUpperCase(); 7 + } 8 + 9 + export function formatDay(date: Date): number { 10 + return date.getDate(); 11 + } 12 + 13 + export function formatWeekday(date: Date): string { 14 + return date.toLocaleDateString('en-US', { weekday: 'long' }); 15 + } 16 + 17 + export function formatFullDate(date: Date): string { 18 + const options: Intl.DateTimeFormatOptions = { month: 'long', day: 'numeric' }; 19 + if (date.getFullYear() !== new Date().getFullYear()) { 20 + options.year = 'numeric'; 21 + } 22 + return date.toLocaleDateString('en-US', options); 23 + } 24 + 25 + export function formatTime(date: Date): string { 26 + return date.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' }); 27 + } 28 + 29 + export function getModeLabel(mode: string): string { 30 + if (mode.includes('virtual')) return 'Virtual'; 31 + if (mode.includes('hybrid')) return 'Hybrid'; 32 + if (mode.includes('inperson')) return 'In-Person'; 33 + return 'Event'; 34 + } 35 + 36 + export function getModeColor(mode: string): 'cyan' | 'purple' | 'amber' | 'secondary' { 37 + if (mode.includes('virtual')) return 'cyan'; 38 + if (mode.includes('hybrid')) return 'purple'; 39 + if (mode.includes('inperson')) return 'amber'; 40 + return 'secondary'; 41 + } 42 + 43 + export type LocationData = { 44 + name?: string; 45 + shortAddress: string; 46 + fullAddress: string; 47 + fullString: string; 48 + mapsUrl: string; 49 + }; 50 + 51 + export function getLocationData(locations: FlatEventRecord['locations']): LocationData | null { 52 + if (!locations || locations.length === 0) return null; 53 + 54 + const loc = locations.find((v) => v.$type === 'community.lexicon.location.address') as 55 + | { name?: string; street?: string; locality?: string; region?: string; country?: string } 56 + | undefined; 57 + if (!loc) return null; 58 + 59 + const shortParts = [loc.street, loc.locality].filter(Boolean); 60 + const fullParts = [loc.street, loc.locality, loc.region, loc.country].filter(Boolean); 61 + if (fullParts.length === 0) return null; 62 + 63 + const shortAddress = shortParts.join(', '); 64 + const fullAddress = fullParts.join(', '); 65 + const displayName = loc.name || undefined; 66 + const fullString = displayName ? `${displayName}, ${fullAddress}` : fullAddress; 67 + const mapsUrl = `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(fullString)}`; 68 + 69 + return { name: displayName, shortAddress, fullAddress, fullString, mapsUrl }; 70 + } 71 + 72 + export async function resolveGeoLocation( 73 + locations: FlatEventRecord['locations'], 74 + locationData: LocationData | null 75 + ): Promise<{ lat: number; lng: number } | null> { 76 + if (!locations || locations.length === 0) return null; 77 + 78 + const geo = locations.find((v) => v.$type === 'community.lexicon.location.geo') as 79 + | { latitude?: string; longitude?: string } 80 + | undefined; 81 + if (geo?.latitude && geo?.longitude) { 82 + const lat = parseFloat(geo.latitude); 83 + const lng = parseFloat(geo.longitude); 84 + if (!isNaN(lat) && !isNaN(lng)) return { lat, lng }; 85 + } 86 + 87 + const addressQuery = locationData?.fullAddress; 88 + if (!addressQuery) return null; 89 + 90 + try { 91 + const r = await fetch(`/api/geocoding?q=${encodeURIComponent(addressQuery)}`); 92 + if (!r.ok) return null; 93 + const data = (await r.json()) as Record<string, unknown> | null; 94 + if (!data || !data.lat || !data.lon) return null; 95 + return { lat: parseFloat(data.lat as string), lng: parseFloat(data.lon as string) }; 96 + } catch { 97 + return null; 98 + } 99 + } 100 + 101 + const renderer = new marked.Renderer(); 102 + renderer.link = ({ href, text }) => 103 + `<a target="_blank" rel="noopener noreferrer nofollow" href="${href}" class="text-accent-600 dark:text-accent-400 hover:underline">${text}</a>`; 104 + 105 + type Facet = { 106 + index: { byteStart: number; byteEnd: number }; 107 + features: { $type: string; did?: string; uri?: string; tag?: string }[]; 108 + }; 109 + 110 + function renderDescription(text: string, facets?: Facet[]): string { 111 + let result = text; 112 + 113 + if (facets && facets.length > 0) { 114 + const encoder = new TextEncoder(); 115 + const encoded = encoder.encode(text); 116 + const decoder = new TextDecoder(); 117 + 118 + const sorted = [...facets].sort((a, b) => b.index.byteStart - a.index.byteStart); 119 + 120 + for (const facet of sorted) { 121 + const feature = facet.features?.[0]; 122 + if (!feature) continue; 123 + 124 + const segmentBytes = encoded.slice(facet.index.byteStart, facet.index.byteEnd); 125 + const segmentText = decoder.decode(segmentBytes); 126 + 127 + let mdLink: string | null = null; 128 + switch (feature.$type) { 129 + case 'app.bsky.richtext.facet#mention': 130 + mdLink = `[${segmentText}](/${feature.did})`; 131 + break; 132 + case 'app.bsky.richtext.facet#link': 133 + mdLink = `[${segmentText}](${feature.uri})`; 134 + break; 135 + case 'app.bsky.richtext.facet#tag': 136 + mdLink = `[${segmentText}](https://bsky.app/hashtag/${feature.tag})`; 137 + break; 138 + } 139 + 140 + if (mdLink) { 141 + const before = decoder.decode(encoded.slice(0, facet.index.byteStart)); 142 + const after = decoder.decode(encoded.slice(facet.index.byteEnd)); 143 + result = before + mdLink + after; 144 + } 145 + } 146 + } 147 + 148 + return marked.parse(result, { renderer }) as string; 149 + } 150 + 151 + export function buildDescriptionHtml( 152 + description: string | undefined, 153 + facets: unknown 154 + ): string | null { 155 + if (!description) return null; 156 + return sanitize(renderDescription(description, facets as Facet[] | undefined), { 157 + ADD_ATTR: ['target'] 158 + }); 159 + }
+20
src/lib/contrail.ts
··· 75 75 rsvpsGoingCountMin?: number; 76 76 hydrateRsvps?: number; 77 77 profiles?: boolean; 78 + preferencesShowInDiscovery?: string; 78 79 sort?: string; 79 80 order?: 'asc' | 'desc'; 80 81 limit?: number; ··· 248 249 const response = await client.get('rsvp.atmo.event.listRecords', { 249 250 params 250 251 }); 252 + 253 + if (!response.ok) return null; 254 + return response.data; 255 + } 256 + 257 + /** 258 + * Hits the `listDiscoverable` pipelineQuery, which reuses the listRecords 259 + * pipeline but adds a WHERE condition excluding events where 260 + * `preferences.showInDiscovery === false`. Missing field is treated as true. 261 + * Response shape is identical to listRecords. 262 + */ 263 + export async function listDiscoverableEventsFromContrail( 264 + client: Client, 265 + params: Omit<ListEventsParams, 'preferencesShowInDiscovery'> 266 + ): Promise<EventListOutput | null> { 267 + const response = await client.get( 268 + 'rsvp.atmo.event.listDiscoverable' as 'rsvp.atmo.event.listRecords', 269 + { params } 270 + ); 251 271 252 272 if (!response.ok) return null; 253 273 return response.data;
+17
src/lib/contrail/config.ts
··· 3 3 4 4 export const config: ContrailConfig = { 5 5 namespace: 'rsvp.atmo', 6 + // Enable the rsvp.atmo.notifyOfUpdate endpoint. The client calls it after 7 + // writing records to the PDS so contrail re-fetches and indexes them 8 + // immediately instead of waiting for the jetstream. 9 + notify: true, 6 10 // `spaces` is declared statically so `pnpm generate` emits the `rsvp.atmo.space.*` 7 11 // lexicons. The real serviceDid is injected at runtime in `$lib/contrail/index.ts` 8 12 // via `getSpacesConfig()` — generate doesn't serialize it. ··· 23 27 name: {}, 24 28 status: {}, 25 29 description: {}, 30 + 'preferences.showInDiscovery': {}, 26 31 startsAt: { type: 'range' }, 27 32 endsAt: { type: 'range' }, 28 33 createdAt: { type: 'range' } ··· 38 43 notgoing: 'community.lexicon.calendar.rsvp#notgoing' 39 44 } 40 45 } 46 + }, 47 + pipelineQueries: { 48 + // Endpoint: rsvp.atmo.event.listDiscoverable 49 + // Same shape as listRecords, but filters out unlisted events 50 + // (preferences.showInDiscovery === false). Missing field defaults 51 + // to true, so pre-existing records without `preferences` are included. 52 + listDiscoverable: async () => ({ 53 + conditions: [ 54 + `(json_extract(r.record, '$.preferences.showInDiscovery') IS NULL 55 + OR json_extract(r.record, '$.preferences.showInDiscovery') != 0)` 56 + ] 57 + }) 41 58 } 42 59 }, 43 60 rsvp: {
+3
src/lib/spaces/server/spaces.remote.ts
··· 1 1 import { error } from '@sveltejs/kit'; 2 2 import { command, query, getRequestEvent } from '$app/server'; 3 + import { dev } from '$app/environment'; 3 4 import * as v from 'valibot'; 4 5 import '../../../lexicon-types/index.js'; 5 6 import { getSpacesClient } from './client'; ··· 26 27 record: v.record(v.string(), v.unknown()) 27 28 }), 28 29 async (input) => { 30 + if (!dev) error(403, 'Private events are not available yet'); 29 31 const { client } = getClient(); 30 32 31 33 const createRes = await client.post('rsvp.atmo.space.admin.createSpace', { ··· 149 151 export const createInvite = command( 150 152 v.object({ 151 153 spaceUri: atUriSchema, 154 + kind: v.optional(v.picklist(['join', 'read', 'read-join'])), 152 155 perms: v.optional(v.string()), 153 156 expiresAt: v.optional(v.number()), 154 157 maxUses: v.optional(v.pipe(v.number(), v.integer(), v.minValue(1))),
+2 -2
src/lib/spaces/tunnel-service.generated.ts
··· 2 2 * When the tunnel is running, this file is rewritten with the tunnel's 3 3 * service DID + URL; when the tunnel stops, it is reset to null values. */ 4 4 5 - export const SERVICE_DID: string | null = "did:web:kid-retention-reservation-proc.trycloudflare.com"; 6 - export const SERVICE_URL: string | null = "https://kid-retention-reservation-proc.trycloudflare.com"; 5 + export const SERVICE_DID: string | null = "did:web:salary-lists-seas-species.trycloudflare.com"; 6 + export const SERVICE_URL: string | null = "https://salary-lists-seas-species.trycloudflare.com";
+6 -2
src/routes/(app)/+page.server.ts
··· 1 - import { flattenEventRecords, getServerClient, listEventRecordsFromContrail } from '$lib/contrail'; 1 + import { 2 + flattenEventRecords, 3 + getServerClient, 4 + listDiscoverableEventsFromContrail 5 + } from '$lib/contrail'; 2 6 import type { PageServerLoad } from './$types'; 3 7 4 8 export const load: PageServerLoad = async ({ platform }) => { 5 9 const client = getServerClient(platform!.env.DB); 6 10 const now = new Date().toISOString(); 7 11 8 - const response = await listEventRecordsFromContrail(client, { 12 + const response = await listDiscoverableEventsFromContrail(client, { 9 13 startsAtMin: now, 10 14 rsvpsGoingCountMin: 2, 11 15 hydrateRsvps: 5,
+1 -21
src/routes/(app)/create/+page.svelte
··· 1 1 <script lang="ts"> 2 2 import EventEditor from '$lib/components/EventEditor.svelte'; 3 3 import { page } from '$app/state'; 4 - import { goto } from '$app/navigation'; 5 4 6 5 let { data } = $props(); 7 6 let privateMode = $derived(page.url.searchParams.get('private') === '1'); 8 - 9 - function toggle() { 10 - const u = new URL(page.url); 11 - if (privateMode) u.searchParams.delete('private'); 12 - else u.searchParams.set('private', '1'); 13 - goto(u.pathname + u.search, { replaceState: true }); 14 - } 15 7 </script> 16 8 17 9 <svelte:head> 18 - <title>{privateMode ? 'Create Private Event' : 'Create Event'}</title> 10 + <title>Create Event</title> 19 11 </svelte:head> 20 - 21 - <div class="mx-auto max-w-3xl px-4 pt-4"> 22 - <label class="flex cursor-pointer items-center gap-3 rounded-md border border-dashed border-base-300 bg-base-50 p-3 text-sm dark:border-base-700 dark:bg-base-900"> 23 - <input type="checkbox" checked={privateMode} onchange={toggle} class="size-4" /> 24 - <div> 25 - <div class="font-medium">Private event</div> 26 - <div class="text-xs text-base-500 dark:text-base-400"> 27 - Only people you add (or who redeem an invite link) can see the event. Not published to your public profile. 28 - </div> 29 - </div> 30 - </label> 31 - </div> 32 12 33 13 <EventEditor eventData={null} actorDid={data.actorDid} rkey={data.rkey} {privateMode} />
+6 -2
src/routes/(app)/events/+page.server.ts
··· 1 - import { flattenEventRecords, getServerClient, listEventRecordsFromContrail } from '$lib/contrail'; 1 + import { 2 + flattenEventRecords, 3 + getServerClient, 4 + listDiscoverableEventsFromContrail 5 + } from '$lib/contrail'; 2 6 import type { PageServerLoad } from './$types'; 3 7 4 8 const PAGE_SIZE = 20; ··· 8 12 const now = new Date().toISOString(); 9 13 const cursor = url.searchParams.get('cursor') ?? undefined; 10 14 11 - const response = await listEventRecordsFromContrail(client, { 15 + const response = await listDiscoverableEventsFromContrail(client, { 12 16 startsAtMin: now, 13 17 profiles: true, 14 18 sort: 'startsAt',
+153 -3
src/routes/(app)/p/[actor]/e/[rkey]/s/[skey]/+page.server.ts
··· 18 18 return { authState: 'not-found' as const }; 19 19 } 20 20 21 + const spaceUri = `at://${ownerDid}/${SPACE_TYPE}/${params.skey}`; 22 + const inviteToken = url.searchParams.get('invite') ?? undefined; 23 + const hasInvite = inviteToken != null; 24 + 25 + // Anonymous viewer with a `?invite=...` token: try to read the space using 26 + // the bearer-token path (works only for `read` / `read-join` invites). On 27 + // success we render the event in read-only mode; on failure we drop back to 28 + // the standard auth flow (anon prompt or pending-invite redeem). 21 29 if (!locals.did) { 30 + if (!hasInvite) return { authState: 'anon' as const }; 31 + const anon = await loadByInviteToken( 32 + platform!.env.DB, 33 + spaceUri, 34 + inviteToken!, 35 + ownerDid, 36 + params.actor, 37 + params.rkey 38 + ); 39 + if (anon) return anon; 22 40 return { authState: 'anon' as const }; 23 41 } 24 - 25 - const spaceUri = `at://${ownerDid}/${SPACE_TYPE}/${params.skey}`; 26 - const hasInvite = url.searchParams.has('invite'); 27 42 28 43 const spaceResult = await getPrivateSpace({ spaceUri }).catch((e) => { 29 44 console.error('[private-event-load] getPrivateSpace threw unexpectedly:', e); ··· 151 166 ogImage: undefined as string | undefined 152 167 }; 153 168 }; 169 + 170 + /** Anonymous read using a `read` / `read-join` invite token. Hits the spaces 171 + * XRPCs through the in-process server client with `inviteToken=...` instead 172 + * of a service-auth JWT. Returns the page data on success, or null if the 173 + * token is invalid/expired/revoked or the event isn't in the space. */ 174 + async function loadByInviteToken( 175 + db: D1Database, 176 + spaceUri: string, 177 + inviteToken: string, 178 + ownerDid: string, 179 + _actor: string, 180 + eventRkey: string 181 + ) { 182 + const client = getServerClient(db); 183 + const spaceUriParam = spaceUri as unknown as import('@atcute/lexicons').ResourceUri; 184 + 185 + const [spaceRes, eventsRes, rsvpsRes] = await Promise.all([ 186 + client.get('rsvp.atmo.space.getSpace', { 187 + params: { uri: spaceUriParam, inviteToken } 188 + }), 189 + client.get('rsvp.atmo.space.listRecords', { 190 + params: { 191 + spaceUri: spaceUriParam, 192 + collection: 'community.lexicon.calendar.event' as `${string}.${string}.${string}`, 193 + inviteToken 194 + } 195 + }), 196 + client.get('rsvp.atmo.space.listRecords', { 197 + params: { 198 + spaceUri: spaceUriParam, 199 + collection: 'community.lexicon.calendar.rsvp' as `${string}.${string}.${string}`, 200 + inviteToken 201 + } 202 + }) 203 + ]); 204 + 205 + if (!spaceRes.ok || !eventsRes.ok) return null; 206 + 207 + const events = (eventsRes.data.records ?? []) as Array<{ 208 + authorDid: string; 209 + rkey: string; 210 + cid?: string | null; 211 + collection: string; 212 + record: Record<string, unknown>; 213 + }>; 214 + const stored = events.find((e) => e.rkey === eventRkey); 215 + if (!stored) return null; 216 + 217 + const eventData = flattenEventRecord({ 218 + record: stored.record, 219 + cid: stored.cid ?? null, 220 + did: stored.authorDid, 221 + rkey: stored.rkey, 222 + uri: `at://${stored.authorDid}/${stored.collection}/${stored.rkey}`, 223 + space: spaceUri 224 + } as never) as FlatEventRecord | null; 225 + if (!eventData) return null; 226 + 227 + const rsvps = (rsvpsRes.ok ? rsvpsRes.data.records ?? [] : []) as Array<{ 228 + authorDid: string; 229 + rkey: string; 230 + record: { status?: string; createdAt?: string }; 231 + }>; 232 + 233 + // Resolve profiles for host + RSVP authors via the public profile endpoint 234 + // (no auth needed). Without this, attendees show as bare DIDs. 235 + const profileDids = Array.from(new Set([ownerDid, ...rsvps.map((r) => r.authorDid)])); 236 + const profileMap = new Map<string, HostProfile>(); 237 + await Promise.all( 238 + profileDids.map(async (d) => { 239 + try { 240 + const p = await getProfileFromContrail(client, d as ActorIdentifier); 241 + if (p) { 242 + profileMap.set(d, { 243 + did: p.did, 244 + handle: p.handle && p.handle !== 'handle.invalid' ? p.handle : d, 245 + displayName: p.record?.displayName, 246 + avatar: p.record?.avatar ? getProfileBlobUrl(p.did, p.record.avatar) : undefined 247 + }); 248 + } 249 + } catch { 250 + /* best-effort */ 251 + } 252 + }) 253 + ); 254 + 255 + const hostProfile = profileMap.get(ownerDid) ?? null; 256 + 257 + const shapeAttendee = (a: { did: string; rkey: string; createdAt?: string }) => { 258 + const p = profileMap.get(a.did); 259 + return { 260 + did: a.did, 261 + rkey: a.rkey, 262 + handle: p?.handle ?? a.did, 263 + displayName: p?.displayName, 264 + avatar: p?.avatar, 265 + createdAt: a.createdAt 266 + }; 267 + }; 268 + 269 + const going: Array<{ did: string; rkey: string; createdAt?: string }> = []; 270 + const interested: Array<{ did: string; rkey: string; createdAt?: string }> = []; 271 + for (const r of rsvps) { 272 + const short = r.record?.status?.split('#')[1]; 273 + if (short === 'going') 274 + going.push({ did: r.authorDid, rkey: r.rkey, createdAt: r.record?.createdAt }); 275 + else if (short === 'interested') 276 + interested.push({ did: r.authorDid, rkey: r.rkey, createdAt: r.record?.createdAt }); 277 + } 278 + 279 + return { 280 + authState: 'member' as const, 281 + ownerDid: ownerDid as Did, 282 + spaceUri, 283 + spaceKey: spaceUri.split('/').pop() ?? '', 284 + isOwner: false, 285 + eventData, 286 + actorDid: ownerDid, 287 + rkey: eventRkey, 288 + hostProfile, 289 + attendees: { 290 + going: going.map(shapeAttendee), 291 + interested: interested.map(shapeAttendee), 292 + goingCount: going.length, 293 + interestedCount: interested.length 294 + }, 295 + viewerRsvpStatus: null as 'going' | 'interested' | 'notgoing' | null, 296 + viewerRsvpRkey: null as string | null, 297 + parentEvent: null, 298 + vod: null, 299 + speakerProfiles: [] as Array<{ id?: string; name: string; avatar?: string; handle?: string }>, 300 + ogImage: undefined as string | undefined, 301 + viaInviteToken: true 302 + }; 303 + }
+3
src/routes/(app)/p/[actor]/e/[rkey]/s/[skey]/+page.svelte
··· 15 15 const token = page.url.searchParams.get('invite'); 16 16 if (!token) return; 17 17 if (data.authState === 'anon') return; 18 + // Anonymous viewer who got in via the read-token bearer path — no 19 + // redemption to do (they're not logged in, the link is just for reading). 20 + if ('viaInviteToken' in data && data.viaInviteToken) return; 18 21 19 22 inviteBusy = true; 20 23 try {
+6 -2
src/routes/(app)/search/+page.server.ts
··· 1 - import { flattenEventRecords, getServerClient, listEventRecordsFromContrail } from '$lib/contrail'; 1 + import { 2 + flattenEventRecords, 3 + getServerClient, 4 + listDiscoverableEventsFromContrail 5 + } from '$lib/contrail'; 2 6 import type { PageServerLoad } from './$types'; 3 7 4 8 const PAGE_SIZE = 20; ··· 10 14 11 15 if (!q) return { events: [], handles: {}, cursor: null, query: '' }; 12 16 13 - const response = await listEventRecordsFromContrail(client, { 17 + const response = await listDiscoverableEventsFromContrail(client, { 14 18 search: q, 15 19 profiles: true, 16 20 sort: 'startsAt',
+1 -1
vite.config.ts
··· 9 9 server: { 10 10 host: '127.0.0.1', 11 11 port: DEV_PORT, 12 - allowedHosts: ['kid-retention-reservation-proc.trycloudflare.com'] 12 + allowedHosts: ['salary-lists-seas-species.trycloudflare.com'] 13 13 } 14 14 });