From 72837a4107453f25631052dfba0bd42e682e88e8 Mon Sep 17 00:00:00 2001 From: Florian <45694132+flo-bit@users.noreply.github.com> Date: Fri, 22 May 2026 01:55:15 +0200 Subject: [PATCH] import events --- .../lib/components/CreateEventModal.svelte | 248 +++++++ apps/web/src/lib/import-event.ts | 28 + apps/web/src/routes/(app)/+layout.svelte | 11 +- .../routes/(app)/api/import-event/+server.ts | 655 ++++++++++++++++++ apps/web/src/routes/(app)/create/+page.svelte | 59 +- packages/ui/src/EventEditor.svelte | 70 +- packages/ui/src/editor/types.ts | 27 + packages/ui/src/index.ts | 3 + 8 files changed, 1076 insertions(+), 25 deletions(-) create mode 100644 apps/web/src/lib/components/CreateEventModal.svelte create mode 100644 apps/web/src/lib/import-event.ts create mode 100644 apps/web/src/routes/(app)/api/import-event/+server.ts 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. + + + + + + Create new event + + Fill in the details yourself. + + + + + + Import event from somewhere else + + + Paste a Luma, Meetup, Eventbrite or Partiful link. + + + + {:else} + + + Import event + + We'll try to autofill the title, date, location and description. + + + + + + Event URL + + + + + + + + + Also accept RSVPs on atmo + + + If off, we'll just list the event and link out to the original page. + + + + + {#if importError} + {importError} + {/if} + + + (step = 'choose')} + disabled={importing} + > + Back + + + {importing ? 'Importing...' : 'Import'} + + + + {/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')} - Create Event - + createEventModalState.show()} class="hidden sm:inline-flex"> + Create Event + + createEventModalState.show()} size="icon" class="sm:hidden"> + create event {/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 = / 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, -- 2.51.2
+ Start from scratch or pull details from another platform. +
+ We'll try to autofill the title, date, location and description. +
{importError}