diff --git a/apps/web/src/lib/components/CreateEventModal.svelte b/apps/web/src/lib/components/CreateEventModal.svelte new file mode 100644 index 0000000..e049613 --- /dev/null +++ b/apps/web/src/lib/components/CreateEventModal.svelte @@ -0,0 +1,248 @@ + + + + + createEventModalState.open, handleOpenChange}> +
+ {#if step === 'choose'} +
+

Create event

+

+ Start from scratch or pull details from another platform. +

+
+ +
+ + + +
+ {:else} +
+
+

Import event

+

+ We'll try to autofill the title, date, location and description. +

+
+ +
+ + +
+ + + + {#if importError} +

{importError}

+ {/if} + +
+ + +
+
+ {/if} +
+
diff --git a/apps/web/src/lib/import-event.ts b/apps/web/src/lib/import-event.ts new file mode 100644 index 0000000..b189821 --- /dev/null +++ b/apps/web/src/lib/import-event.ts @@ -0,0 +1,28 @@ +/** + * Shape returned by /api/import-event. Mirrors a subset of EventEditor inputs + * so the page can prefill its state without round-tripping through the + * lexicon-shaped EventData (which would flip the editor into "edit" mode). + */ +export type EventImportPrefill = { + source: string; + name?: string; + description?: string; + /** ISO 8601 with offset */ + startsAt?: string; + /** ISO 8601 with offset */ + endsAt?: string; + timezone?: string; + mode?: 'inperson' | 'virtual' | 'hybrid'; + location?: { + street?: string; + locality?: string; + region?: string; + country?: string; + }; + links?: Array<{ uri: string; name: string }>; + imageUrl?: string; + /** Base64 data URL of the cover image fetched server-side (no CORS hassles). */ + imageDataUrl?: string; + /** Controls whether atmo should accept its own RSVPs or punt to the source. */ + rsvpMode?: 'external_only' | 'atmo_too'; +}; diff --git a/apps/web/src/routes/(app)/+layout.svelte b/apps/web/src/routes/(app)/+layout.svelte index d9e6672..26e47b9 100644 --- a/apps/web/src/routes/(app)/+layout.svelte +++ b/apps/web/src/routes/(app)/+layout.svelte @@ -6,6 +6,9 @@ import { page } from '$app/state'; import { ModeWatcher } from 'mode-watcher'; import LoginModal from '$lib/components/LoginModal.svelte'; + import CreateEventModal, { + createEventModalState + } from '$lib/components/CreateEventModal.svelte'; let { children } = $props(); @@ -48,8 +51,10 @@ {#if user.isLoggedIn} {#if !page.url.pathname.startsWith('/create')} - - + {/if} @@ -106,6 +112,7 @@ + { + try { + const res = await fetch(url, { headers: FETCH_HEADERS, redirect: 'follow' }); + if (!res.ok) return undefined; + const contentType = (res.headers.get('content-type') || 'image/jpeg').split(';')[0].trim(); + if (!contentType.startsWith('image/')) return undefined; + + const reader = res.body?.getReader(); + if (!reader) { + const buf = new Uint8Array(await res.arrayBuffer()); + if (buf.byteLength > MAX_IMAGE_BYTES) return undefined; + return `data:${contentType};base64,${bytesToBase64(buf)}`; + } + const chunks: Uint8Array[] = []; + let total = 0; + while (true) { + const { done, value } = await reader.read(); + if (done) break; + total += value.byteLength; + if (total > MAX_IMAGE_BYTES) { + try { + await reader.cancel(); + } catch { + /* ignore */ + } + return undefined; + } + chunks.push(value); + } + const merged = new Uint8Array(total); + let off = 0; + for (const c of chunks) { + merged.set(c, off); + off += c.byteLength; + } + return `data:${contentType};base64,${bytesToBase64(merged)}`; + } catch (err) { + console.error('fetchImageAsDataUrl failed:', url, err); + return undefined; + } +} + +function bytesToBase64(bytes: Uint8Array): string { + // btoa expects a binary string; build in chunks to avoid hitting argument + // limits with String.fromCharCode on multi-MB buffers. + let s = ''; + const chunk = 0x8000; + for (let i = 0; i < bytes.length; i += chunk) { + s += String.fromCharCode.apply( + null, + Array.from(bytes.subarray(i, i + chunk)) as unknown as number[] + ); + } + return btoa(s); +} + +async function importFromUrl(url: string): Promise { + const res = await fetch(url, { headers: FETCH_HEADERS, redirect: 'follow' }); + if (!res.ok) throw new Error(`upstream ${res.status}`); + + const contentType = (res.headers.get('content-type') || '').toLowerCase(); + const text = await readLimited(res, MAX_BYTES); + + const finalUrl = res.url || url; + + if (contentType.includes('text/calendar') || /^BEGIN:VCALENDAR/m.test(text.slice(0, 200))) { + const ical = parseIcal(text); + if (ical) return { source: finalUrl, ...ical }; + } + + if (contentType.includes('text/html') || / | null = null; + if (icalLink) { + try { + const icalRes = await fetch(icalLink, { headers: FETCH_HEADERS, redirect: 'follow' }); + if (icalRes.ok) { + const icalText = await readLimited(icalRes, MAX_BYTES); + ical = parseIcal(icalText); + } + } catch { + /* ignore — JSON-LD or OG will cover for it */ + } + } + + if (jsonLd) { + const mapped = mapJsonLdEvent(jsonLd, finalUrl); + if (!mapped.timezone && ical?.timezone) mapped.timezone = ical.timezone; + if (!mapped.timezone && mapped.startsAt) { + mapped.timezone = guessIanaZoneFromIsoOffset(mapped.startsAt); + } + return { source: finalUrl, ...mapped }; + } + + if (ical) return { source: finalUrl, ...ical }; + + const og = extractOpenGraph(text); + if (og.title || og.description) { + return { source: finalUrl, name: og.title, description: og.description, imageUrl: og.image }; + } + } + + return null; +} + +async function readLimited(res: Response, max: number): Promise { + const reader = res.body?.getReader(); + if (!reader) return await res.text(); + const decoder = new TextDecoder(); + let received = 0; + let out = ''; + while (true) { + const { done, value } = await reader.read(); + if (done) break; + received += value.byteLength; + if (received > max) { + out += decoder.decode(value.subarray(0, Math.max(0, max - (received - value.byteLength)))); + try { + await reader.cancel(); + } catch { + /* ignore */ + } + break; + } + out += decoder.decode(value, { stream: true }); + } + out += decoder.decode(); + return out; +} + +/* ---------------- JSON-LD ---------------- */ + +type JsonLdEvent = Record; + +function extractJsonLdEvent(html: string): JsonLdEvent | null { + const re = /]*type=["']application\/ld\+json["'][^>]*>([\s\S]*?)<\/script>/gi; + for (const match of html.matchAll(re)) { + const raw = match[1].trim(); + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + continue; + } + const event = findEventNode(parsed); + if (event) return event; + } + return null; +} + +function findEventNode(node: unknown): JsonLdEvent | null { + if (!node) return null; + if (Array.isArray(node)) { + for (const item of node) { + const found = findEventNode(item); + if (found) return found; + } + return null; + } + if (typeof node !== 'object') return null; + const obj = node as Record; + + const graph = obj['@graph']; + if (Array.isArray(graph)) { + const found = findEventNode(graph); + if (found) return found; + } + + const type = obj['@type']; + const types = Array.isArray(type) ? type : type ? [type] : []; + if (types.some((t) => typeof t === 'string' && /Event$/i.test(t))) { + return obj; + } + return null; +} + +function mapJsonLdEvent(ev: JsonLdEvent, sourceUrl: string): Omit { + const out: Omit = {}; + + const name = asString(ev.name); + if (name) out.name = name; + + const description = stripHtml(asString(ev.description)); + if (description) out.description = description; + + const startDate = asString(ev.startDate); + if (startDate) { + out.startsAt = normalizeIso(startDate); + const tz = tzFromIso(startDate); + if (tz) out.timezone = tz; + } + const endDate = asString(ev.endDate); + if (endDate) out.endsAt = normalizeIso(endDate); + + const mode = inferModeFromJsonLd(ev); + if (mode) out.mode = mode; + + const location = extractJsonLdLocation(ev.location); + if (location.address) out.location = location.address; + + const image = pickFirstImage(ev.image); + if (image) out.imageUrl = image; + + const links: Array<{ uri: string; name: string }> = []; + const eventUrl = asString(ev.url); + if (eventUrl && eventUrl !== sourceUrl) { + links.push({ uri: eventUrl, name: 'Event page' }); + } + if (location.virtualUrl) { + links.push({ uri: location.virtualUrl, name: 'Join link' }); + } + if (links.length > 0) out.links = links; + + return out; +} + +function inferModeFromJsonLd(ev: JsonLdEvent): EventImportPrefill['mode'] | undefined { + const raw = asString(ev.eventAttendanceMode); + if (!raw) return undefined; + const lower = raw.toLowerCase(); + if (lower.includes('mixed')) return 'hybrid'; + if (lower.includes('online')) return 'virtual'; + if (lower.includes('offline')) return 'inperson'; + return undefined; +} + +function extractJsonLdLocation(loc: unknown): { + address?: EventImportPrefill['location']; + virtualUrl?: string; +} { + if (!loc) return {}; + if (Array.isArray(loc)) { + const merged: ReturnType = {}; + for (const item of loc) { + const part = extractJsonLdLocation(item); + merged.address ??= part.address; + merged.virtualUrl ??= part.virtualUrl; + } + return merged; + } + if (typeof loc !== 'object') return {}; + const obj = loc as Record; + const type = asString(obj['@type']); + + if (type && /VirtualLocation/i.test(type)) { + return { virtualUrl: asString(obj.url) }; + } + + const address = obj.address; + if (typeof address === 'string') { + return { address: { street: address } }; + } + if (address && typeof address === 'object') { + const a = address as Record; + const out: NonNullable = {}; + const street = asString(a.streetAddress); + const locality = asString(a.addressLocality); + const region = asString(a.addressRegion); + const country = asString(a.addressCountry); + if (street) out.street = street; + if (locality) out.locality = locality; + if (region) out.region = region; + if (country) out.country = country; + const placeName = asString(obj.name); + if (placeName && !out.street) out.street = placeName; + else if (placeName && out.street && !out.street.includes(placeName)) { + out.street = `${placeName}, ${out.street}`; + } + return { address: Object.keys(out).length > 0 ? out : undefined }; + } + + const placeName = asString(obj.name); + if (placeName) return { address: { street: placeName } }; + return {}; +} + +function pickFirstImage(img: unknown): string | undefined { + if (!img) return undefined; + if (typeof img === 'string') return img; + if (Array.isArray(img)) { + for (const item of img) { + const v = pickFirstImage(item); + if (v) return v; + } + return undefined; + } + if (typeof img === 'object') { + const obj = img as Record; + return asString(obj.url) ?? asString(obj.contentUrl); + } + return undefined; +} + +/* ---------------- iCal ---------------- */ + +type IcalEvent = Omit; + +function parseIcal(text: string): IcalEvent | null { + const unfolded = text.replace(/\r?\n[ \t]/g, ''); + const lines = unfolded.split(/\r?\n/); + + let inEvent = false; + const fields: Record; value: string }> = {}; + + for (const raw of lines) { + if (raw === 'BEGIN:VEVENT') { + inEvent = true; + continue; + } + if (raw === 'END:VEVENT') break; + if (!inEvent) continue; + + const colonIdx = raw.indexOf(':'); + if (colonIdx < 0) continue; + const left = raw.slice(0, colonIdx); + const value = raw.slice(colonIdx + 1); + const parts = left.split(';'); + const name = parts[0].toUpperCase(); + const params: Record = {}; + for (let i = 1; i < parts.length; i++) { + const eq = parts[i].indexOf('='); + if (eq > 0) { + params[parts[i].slice(0, eq).toUpperCase()] = parts[i].slice(eq + 1); + } + } + fields[name] = { params, value }; + } + + if (Object.keys(fields).length === 0) return null; + + const out: IcalEvent = {}; + const summary = fields.SUMMARY?.value; + if (summary) out.name = unescapeIcal(summary); + const description = fields.DESCRIPTION?.value; + if (description) out.description = unescapeIcal(description); + const url = fields.URL?.value; + if (url) out.links = [{ uri: url, name: 'Event page' }]; + + const start = fields.DTSTART; + if (start) { + const iso = icalDateToIso(start.value, start.params.TZID); + if (iso) out.startsAt = iso; + if (start.params.TZID) out.timezone = start.params.TZID; + } + const end = fields.DTEND; + if (end) { + const iso = icalDateToIso(end.value, end.params.TZID); + if (iso) out.endsAt = iso; + } + + const location = fields.LOCATION?.value; + if (location) { + out.location = { street: unescapeIcal(location) }; + } + + return out; +} + +function unescapeIcal(s: string): string { + return s.replace(/\\n/gi, '\n').replace(/\\,/g, ',').replace(/\\;/g, ';').replace(/\\\\/g, '\\'); +} + +function icalDateToIso(value: string, tzid?: string): string | undefined { + // All-day: YYYYMMDD + const dateOnly = value.match(/^(\d{4})(\d{2})(\d{2})$/); + if (dateOnly) { + return `${dateOnly[1]}-${dateOnly[2]}-${dateOnly[3]}T00:00:00`; + } + // UTC: YYYYMMDDTHHMMSSZ + const utc = value.match(/^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})Z$/); + if (utc) { + return `${utc[1]}-${utc[2]}-${utc[3]}T${utc[4]}:${utc[5]}:${utc[6]}Z`; + } + // Local: YYYYMMDDTHHMMSS (interpret in TZID if present) + const local = value.match(/^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})$/); + if (local) { + const base = `${local[1]}-${local[2]}-${local[3]}T${local[4]}:${local[5]}:${local[6]}`; + if (!tzid) return base; + // Compute offset for that wall time in tzid. + const offset = offsetForZone(tzid, base); + return offset ? `${base}${offset}` : base; + } + return undefined; +} + +function offsetForZone(tz: string, isoNoOffset: string): string | undefined { + try { + const ms = Date.parse(isoNoOffset + 'Z'); + if (Number.isNaN(ms)) return undefined; + const dtf = new Intl.DateTimeFormat('en-US', { + timeZone: tz, + timeZoneName: 'shortOffset', + hour: '2-digit' + }); + const parts = dtf.formatToParts(new Date(ms)); + const name = parts.find((p) => p.type === 'timeZoneName')?.value; + const m = name?.match(/GMT([+-]\d{1,2})(?::?(\d{2}))?/); + if (!m) return undefined; + const hours = parseInt(m[1], 10); + const mins = m[2] ? parseInt(m[2], 10) : 0; + const sign = hours >= 0 ? '+' : '-'; + const hh = String(Math.abs(hours)).padStart(2, '0'); + const mm = String(mins).padStart(2, '0'); + return `${sign}${hh}:${mm}`; + } catch { + return undefined; + } +} + +function findAlternateIcalLink(html: string, baseUrl: string): string | null { + // Prefer the canonical tag. + const linkRe = /]+)>/gi; + for (const m of html.matchAll(linkRe)) { + const attrs = m[1]; + if (!/type=["']text\/calendar["']/i.test(attrs)) continue; + const href = attrs.match(/href=["']([^"']+)["']/i)?.[1]; + if (!href) continue; + try { + return new URL(href, baseUrl).toString(); + } catch { + continue; + } + } + + // Fall back to any anchor pointing at a .ics file or a calendar-export + // endpoint. Partiful, for instance, only exposes its calendar feed this way. + const anchorRe = /href=["']([^"']+\.ics(?:\?[^"']*)?)["']/gi; + for (const m of html.matchAll(anchorRe)) { + try { + return new URL(m[1], baseUrl).toString(); + } catch { + continue; + } + } + const calendarRe = /href=["']([^"']*(?:add[-_]?to[-_]?calendar|\/calendar|\/ics|export\.ics)[^"']*)["']/gi; + for (const m of html.matchAll(calendarRe)) { + try { + return new URL(m[1], baseUrl).toString(); + } catch { + continue; + } + } + return null; +} + +/* ---------------- OpenGraph ---------------- */ + +function extractOpenGraph(html: string): { title?: string; description?: string; image?: string } { + const grab = (prop: string) => { + const re = new RegExp( + `]*?(?:property|name)=["']${prop}["'][^>]*?content=["']([^"']*)["']`, + 'i' + ); + const alt = new RegExp( + `]*?content=["']([^"']*)["'][^>]*?(?:property|name)=["']${prop}["']`, + 'i' + ); + return html.match(re)?.[1] ?? html.match(alt)?.[1]; + }; + return { + title: grab('og:title') ?? grab('twitter:title'), + description: grab('og:description') ?? grab('twitter:description') ?? grab('description'), + image: grab('og:image') ?? grab('twitter:image') + }; +} + +/* ---------------- helpers ---------------- */ + +function asString(v: unknown): string | undefined { + return typeof v === 'string' && v.length > 0 ? v : undefined; +} + +function stripHtml(s: string | undefined): string | undefined { + if (!s) return undefined; + return s + .replace(//gi, '\n') + .replace(/<\/p>/gi, '\n\n') + .replace(/<[^>]+>/g, '') + .replace(/ /g, ' ') + .replace(/&/g, '&') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/\n{3,}/g, '\n\n') + .trim(); +} + +function normalizeIso(s: string): string { + // Date.parse handles most ISO variants; re-emit canonical form when we can. + const ms = Date.parse(s); + if (Number.isNaN(ms)) return s; + // Preserve the original offset when present (Date.parse → toISOString gives UTC). + if (/[+-]\d{2}:?\d{2}$|Z$/.test(s)) return s; + return new Date(ms).toISOString(); +} + +function tzFromIso(s: string): string | undefined { + // IANA tz isn't carried in ISO strings; only an offset is. Picking a zone + // from offset alone is ambiguous, so we defer guessing until we've assembled + // the whole prefill (see guessIanaZoneFromIsoOffset). + void s; + return undefined; +} + +/** + * Curated list of representative IANA zones we'll consider when guessing from + * an ISO offset. Ordering matters — earlier entries win ties, so the most + * commonly-meant zone for each offset bucket should come first. + */ +const ZONE_CANDIDATES = [ + 'Pacific/Honolulu', + 'America/Anchorage', + 'America/Los_Angeles', + 'America/Denver', + 'America/Phoenix', + 'America/Chicago', + 'America/Mexico_City', + 'America/New_York', + 'America/Toronto', + 'America/Halifax', + 'America/Sao_Paulo', + 'America/Argentina/Buenos_Aires', + 'Atlantic/Azores', + 'UTC', + 'Europe/London', + 'Europe/Berlin', + 'Europe/Paris', + 'Europe/Madrid', + 'Africa/Cairo', + 'Europe/Athens', + 'Europe/Moscow', + 'Asia/Dubai', + 'Asia/Karachi', + 'Asia/Kolkata', + 'Asia/Dhaka', + 'Asia/Bangkok', + 'Asia/Singapore', + 'Asia/Shanghai', + 'Asia/Tokyo', + 'Australia/Sydney', + 'Pacific/Auckland' +]; + +function guessIanaZoneFromIsoOffset(iso: string): string | undefined { + const m = iso.match(/([+-]\d{2}):?(\d{2})$|Z$/); + if (!m) return undefined; + const offsetMinutes = iso.endsWith('Z') + ? 0 + : (m[1].startsWith('-') ? -1 : 1) * (parseInt(m[1].slice(1), 10) * 60 + parseInt(m[2], 10)); + + // Offset 0 is almost always a platform serializing as UTC because it didn't + // track the authoring tz (Partiful does this). Returning "UTC" here would be + // wrong far more often than right — leave it unset so the editor falls back + // to the viewer's browser zone and they can correct it. + if (offsetMinutes === 0) return undefined; + + const ms = Date.parse(iso); + if (Number.isNaN(ms)) return undefined; + + for (const zone of ZONE_CANDIDATES) { + if (zoneOffsetAt(zone, ms) === offsetMinutes) return zone; + } + return undefined; +} + +function zoneOffsetAt(zone: string, ms: number): number | undefined { + try { + // Format the same instant as wall-clock parts in `zone`, reconstruct it + // as if it were UTC, and the difference is the zone's offset at `ms`. + const dtf = new Intl.DateTimeFormat('en-US', { + timeZone: zone, + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hour12: false + }); + const parts = Object.fromEntries( + dtf.formatToParts(new Date(ms)).map((p) => [p.type, p.value]) + ) as Record; + const asUtcMs = Date.UTC( + Number(parts.year), + Number(parts.month) - 1, + Number(parts.day), + Number(parts.hour) === 24 ? 0 : Number(parts.hour), + Number(parts.minute), + Number(parts.second) + ); + return Math.round((asUtcMs - ms) / 60000); + } catch { + return undefined; + } +} diff --git a/apps/web/src/routes/(app)/create/+page.svelte b/apps/web/src/routes/(app)/create/+page.svelte index e862c35..9cb25e0 100644 --- a/apps/web/src/routes/(app)/create/+page.svelte +++ b/apps/web/src/routes/(app)/create/+page.svelte @@ -1,8 +1,10 @@ Create Event - +{#if ready} + +{/if} diff --git a/packages/ui/src/EventEditor.svelte b/packages/ui/src/EventEditor.svelte index 38d0f69..6a7cd10 100644 --- a/packages/ui/src/EventEditor.svelte +++ b/packages/ui/src/EventEditor.svelte @@ -29,6 +29,7 @@ import RecurringModal from './editor/RecurringModal.svelte'; import { stripModePrefix, + type EventEditorPrefill, type EventLocation, type EventMode, type Visibility @@ -44,7 +45,8 @@ privateMode = false, adapter, viewer, - initialTheme + initialTheme, + prefill = null }: { eventData: FlatEventRecord | null; actorDid: string; @@ -55,19 +57,41 @@ viewer: EditorViewer; /** Override default theme for new events (e.g. inherit embedder's palette). */ initialTheme?: Partial; + /** Autofill payload for new events (e.g. imported from Luma/Meetup). */ + prefill?: EventEditorPrefill | null; } = $props(); let isNew = $derived(eventData === null); - let thumbnailChanged = $state(false); + // svelte-ignore state_referenced_locally + let thumbnailChanged = $state( + eventData === null && (prefill?.thumbnailFile ?? null) !== null + ); + + // Initial values: prefer prefill (only honored for brand-new events) so the + // title editor and date inputs see the imported values on their first render + // — TipTap reads `content` only once at mount. + // svelte-ignore state_referenced_locally + const initialPrefill = eventData === null ? prefill : null; + const initialTimezone = + initialPrefill?.timezone ?? Intl.DateTimeFormat().resolvedOptions().timeZone; // svelte-ignore state_referenced_locally - let name = $state(eventData?.name || ''); - let description = $state(''); - let startsAt = $state(''); - let endsAt = $state(''); - let timezone = $state(Intl.DateTimeFormat().resolvedOptions().timeZone); - let mode: EventMode = $state('inperson'); + let name = $state(initialPrefill?.name ?? eventData?.name ?? ''); + // svelte-ignore state_referenced_locally + let description = $state(initialPrefill?.description ?? ''); + // svelte-ignore state_referenced_locally + let startsAt = $state( + initialPrefill?.startsAt ? isoToDatetimeLocalInTz(initialPrefill.startsAt, initialTimezone) : '' + ); + // svelte-ignore state_referenced_locally + let endsAt = $state( + initialPrefill?.endsAt ? isoToDatetimeLocalInTz(initialPrefill.endsAt, initialTimezone) : '' + ); + // svelte-ignore state_referenced_locally + let timezone = $state(initialTimezone); + // svelte-ignore state_referenced_locally + let mode: EventMode = $state(initialPrefill?.mode ?? 'inperson'); // svelte-ignore state_referenced_locally let visibility: Visibility = $state(privateMode && dev ? 'private' : 'public'); // svelte-ignore state_referenced_locally @@ -80,17 +104,30 @@ } : { ...defaultTheme } ); - let thumbnailFile: File | null = $state(null); - let thumbnailPreview: string | null = $state(null); - let selectedPreset: string | null = $state(eventData === null ? DEFAULT_PRESET : null); + const initialThumbnailFile = initialPrefill?.thumbnailFile ?? null; + // svelte-ignore state_referenced_locally + let thumbnailFile: File | null = $state(initialThumbnailFile); + // svelte-ignore state_referenced_locally + let thumbnailPreview: string | null = $state( + initialThumbnailFile ? URL.createObjectURL(initialThumbnailFile) : null + ); + // svelte-ignore state_referenced_locally + let selectedPreset: string | null = $state( + initialThumbnailFile ? null : eventData === null ? DEFAULT_PRESET : null + ); let submitting = $state(false); let error: string | null = $state(null); let titleEditor: Readable | undefined = $state(undefined); - let location: EventLocation | null = $state(null); - let locationChanged = $state(false); + // svelte-ignore state_referenced_locally + let location: EventLocation | null = $state(initialPrefill?.location ? { ...initialPrefill.location } : null); + // svelte-ignore state_referenced_locally + let locationChanged = $state(initialPrefill?.location != null); - let links: Array<{ uri: string; name: string }> = $state([]); + // svelte-ignore state_referenced_locally + let links: Array<{ uri: string; name: string }> = $state( + initialPrefill?.links ? initialPrefill.links.map((l) => ({ ...l })) : [] + ); let showRecurringModal = $state(false); @@ -243,6 +280,11 @@ resolveHandle: (handle) => adapter.resolveHandle(handle) }); + if (isNew && prefill?.additionalData) { + const existing = (record.additionalData ?? {}) as Record; + record.additionalData = { ...existing, ...prefill.additionalData }; + } + if (visibility === 'private') { if (!adapter.createPrivateEvent) { error = 'Private events are not supported here.'; diff --git a/packages/ui/src/editor/types.ts b/packages/ui/src/editor/types.ts index c032d43..39c0e91 100644 --- a/packages/ui/src/editor/types.ts +++ b/packages/ui/src/editor/types.ts @@ -8,6 +8,33 @@ export interface EventLocation { country?: string; } +/** + * Optional autofill payload for a brand-new event. EventEditor populates its + * fields from this on mount (only when there is no `eventData`), while leaving + * `isNew` true so the save path still treats the result as a creation. Use + * `additionalData` to carry atmo-specific extras (e.g. an external source link + * + rsvp mode) into the saved record. + */ +export interface EventEditorPrefill { + name?: string; + description?: string; + /** ISO 8601 string. */ + startsAt?: string; + /** ISO 8601 string. */ + endsAt?: string; + timezone?: string; + mode?: EventMode; + location?: EventLocation; + links?: Array<{ uri: string; name: string }>; + additionalData?: Record; + /** + * Pre-supplied cover image. When set, the editor uses this instead of + * auto-generating a preset thumbnail and the file is uploaded as a blob on + * save. + */ + thumbnailFile?: File; +} + export function stripModePrefix(modeStr: string): EventMode { const stripped = modeStr.replace('community.lexicon.calendar.event#', ''); if (stripped === 'virtual' || stripped === 'hybrid' || stripped === 'inperson') return stripped; diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index 1a76e99..d8ab307 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -15,6 +15,9 @@ export type { EditorViewer } from './editor/adapter.js'; +// Editor inputs +export type { EventEditorPrefill } from './editor/types.js'; + // Domain types export type { FlatEventRecord,