From 04d8e832785d5ecdf60be959237b8e5d670e06eb Mon Sep 17 00:00:00 2001 From: Florian <45694132+flo-bit@users.noreply.github.com> Date: Thu, 4 Jun 2026 15:14:39 +0200 Subject: [PATCH] view stuff --- apps/web/package.json | 3 +- apps/web/scripts/publish-test-conference.ts | 275 ++++++++++++++++++ apps/web/src/lib/conference-import/parse.ts | 126 ++++++++ apps/web/src/lib/contrail.config.ts | 20 ++ apps/web/src/lib/contrail.ts | 51 ++++ .../lib/spaces/tunnel-service.generated.ts | 4 +- .../routes/(app)/p/[actor]/+page.server.ts | 11 +- .../(app)/p/[actor]/e/[rkey]/+page.server.ts | 105 ++++++- .../(app)/p/[actor]/hosting/+page.server.ts | 4 +- .../p/[actor]/past-events/+page.server.ts | 4 +- packages/ui/src/EventView.svelte | 45 ++- packages/ui/src/conference.ts | 105 +++++++ .../ui/src/event-view/EventHostedBy.svelte | 4 +- packages/ui/src/index.ts | 13 + .../src/schedule/ConferenceTimetable.svelte | 211 ++++++++++++++ packages/ui/src/schedule/DaySchedule.svelte | 249 ++++++++++++++++ .../ui/src/schedule/ScheduleEventCell.svelte | 185 ++++++++++++ packages/ui/src/schedule/schedule-utils.ts | 212 ++++++++++++++ 18 files changed, 1597 insertions(+), 30 deletions(-) create mode 100644 apps/web/scripts/publish-test-conference.ts create mode 100644 apps/web/src/lib/conference-import/parse.ts create mode 100644 packages/ui/src/conference.ts create mode 100644 packages/ui/src/schedule/ConferenceTimetable.svelte create mode 100644 packages/ui/src/schedule/DaySchedule.svelte create mode 100644 packages/ui/src/schedule/ScheduleEventCell.svelte create mode 100644 packages/ui/src/schedule/schedule-utils.ts diff --git a/apps/web/package.json b/apps/web/package.json index a5e8d73..66b00f0 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -21,7 +21,8 @@ "env:generate-secret": "npx tsx src/lib/atproto/scripts/generate-secret.ts", "env:setup-dev": "npx tsx src/lib/atproto/scripts/setup-dev.ts", "tunnel": "npx tsx src/lib/atproto/scripts/tunnel.ts", - "publish-lexicons": "contrail-lex publish" + "publish-lexicons": "contrail-lex publish", + "seed:conference": "bun run scripts/publish-test-conference.ts" }, "devDependencies": { "@atcute/atproto": "^3.1.10", diff --git a/apps/web/scripts/publish-test-conference.ts b/apps/web/scripts/publish-test-conference.ts new file mode 100644 index 0000000..931bd06 --- /dev/null +++ b/apps/web/scripts/publish-test-conference.ts @@ -0,0 +1,275 @@ +/** + * Publishes a throwaway test conference + a handful of talk events to your repo, + * so you can see the generalized conference timetable render on the event page. + * + * Run (from apps/web): + * bun run scripts/publish-test-conference.ts + * + * It authenticates via OAuth (loopback, no client_id needed), writes one + * `type: 'conference'` event plus several talks that point back at it via + * `additionalData.parentEvent`, then pings the app's notifyOfUpdate so Contrail + * indexes them immediately. Prints the conference URL at the end. + * + * Override the app it pings (for local dev) with ATMO_APP_URL, e.g. + * ATMO_APP_URL=http://localhost:5173 bun run scripts/publish-test-conference.ts alice.test + */ +import { Client, ok } from '@atcute/client'; +import { + CompositeDidDocumentResolver, + CompositeHandleResolver, + LocalActorResolver, + PlcDidDocumentResolver, + WebDidDocumentResolver, + WellKnownHandleResolver +} from '@atcute/identity-resolver'; +import { isActorIdentifier } from '@atcute/lexicons/syntax'; +import { + MemoryStore, + OAuthClient, + type StoredState, + scope +} from '@atcute/oauth-node-client'; +import { resolveTxt } from 'node:dns/promises'; + +const TEN_MINUTES_MS = 10 * 60_000; +const COLLECTION = 'community.lexicon.calendar.event'; +const APP_URL = (process.env.ATMO_APP_URL ?? 'https://atmo.rsvp').replace(/\/$/, ''); +const TZ = 'America/New_York'; + +const identifier = process.argv[2]?.trim(); +if (!identifier) { + console.error('usage: bun run scripts/publish-test-conference.ts '); + console.error(' example: bun run scripts/publish-test-conference.ts alice.bsky.social'); + process.exit(1); +} +if (!isActorIdentifier(identifier)) { + console.error(`error: invalid identifier "${identifier}" (expected a handle or did:...)`); + process.exit(1); +} + +// Minimal DNS handle resolver (the @atcute identity-resolver-node package isn't +// installed here) — looks up the `_atproto.` TXT record for `did=...`. +class DnsHandleResolver { + async resolve(handle: string): Promise<`did:${string}`> { + const records = await resolveTxt(`_atproto.${handle}`).catch(() => [] as string[][]); + for (const chunks of records) { + const txt = Array.isArray(chunks) ? chunks.join('') : String(chunks); + if (txt.startsWith('did=')) return txt.slice(4) as `did:${string}`; + } + throw new Error(`could not resolve handle "${handle}" via DNS`); + } +} + +// ---- OAuth (loopback) ------------------------------------------------------- + +const deferred = Promise.withResolvers(); + +using server = Bun.serve({ + port: 0, + fetch(req) { + const url = new URL(req.url); + if (url.pathname === '/callback') { + deferred.resolve(url.searchParams); + return new Response( + 'authenticated

authenticated!

You can close this window and return to the terminal.

', + { headers: { 'content-type': 'text/html' } } + ); + } + return new Response('not found', { status: 404 }); + } +}); +server.unref(); + +const redirectUri = `http://127.0.0.1:${server.port}/callback`; +const timeout = setTimeout(() => deferred.reject(new Error('OAuth callback timed out')), 5 * 60_000); + +const oauth = new OAuthClient({ + metadata: { + redirect_uris: [redirectUri], + // Write access to the calendar-event collection is all we need. + scope: [scope.repo({ collection: [COLLECTION] })] + }, + actorResolver: new LocalActorResolver({ + handleResolver: new CompositeHandleResolver({ + methods: { + dns: new DnsHandleResolver(), + http: new WellKnownHandleResolver() + } + }), + didDocumentResolver: new CompositeDidDocumentResolver({ + methods: { + plc: new PlcDidDocumentResolver(), + web: new WebDidDocumentResolver() + } + }) + }), + stores: { + sessions: new MemoryStore({ maxSize: 10 }), + states: new MemoryStore({ + maxSize: 10, + ttl: TEN_MINUTES_MS, + ttlAutopurge: true + }) + } +}); + +const { url } = await oauth.authorize({ + target: { type: 'account', identifier }, + redirectUri +}); + +console.log(`\nopen this URL in your browser to authorize:\n${url.href}\n`); + +const params = await deferred.promise; +clearTimeout(timeout); + +const { session } = await oauth.callback(params, { redirectUri }); +const rpc = new Client({ handler: session }); +const did = session.did; +console.log(`authenticated as: ${did}\n`); + +// ---- Build the conference + talks ------------------------------------------- + +const nowIso = new Date().toISOString(); + +// Base the schedule on tomorrow at 14:00 UTC (~10am ET), so the countdown shows. +const dayStart = new Date(Date.now() + 24 * 60 * 60_000); +dayStart.setUTCHours(14, 0, 0, 0); +const at = (offsetMin: number) => new Date(dayStart.getTime() + offsetMin * 60_000).toISOString(); + +const ROOM_MAIN = 'Main Hall'; +const ROOM_WORKSHOP = 'Workshop Room'; + +async function createEvent(record: Record): Promise<{ uri: string; rkey: string }> { + const data = (await ok( + rpc.post('com.atproto.repo.createRecord', { + input: { repo: did, collection: COLLECTION, record } + }) + )) as { uri: string; cid?: string }; + return { uri: data.uri, rkey: data.uri.split('/').pop()! }; +} + +console.log('publishing conference event…'); +const conference = await createEvent({ + $type: COLLECTION, + createdAt: nowIso, + name: 'Test Conference 2026', + description: 'A throwaway conference to preview the timetable feature.', + startsAt: at(0), + endsAt: at(8 * 60), + timezone: TZ, + additionalData: { type: 'conference', rooms: [ROOM_MAIN, ROOM_WORKSHOP] } +}); +console.log(` conference: ${conference.uri}`); + +type TalkSpec = { + name: string; + description: string; + type: string; + room?: string; + start: number; + end: number; + speakers?: { name: string }[]; +}; + +const talks: TalkSpec[] = [ + { + name: 'Opening Keynote', + description: 'Welcome and the year ahead.', + type: 'talk', + room: ROOM_MAIN, + start: 0, + end: 45, + speakers: [{ name: 'Ada Lovelace' }] + }, + { + name: 'Hands-on Workshop: Building on atproto', + description: 'Bring a laptop.', + type: 'workshop', + room: ROOM_WORKSHOP, + start: 60, + end: 150, + speakers: [{ name: 'Grace Hopper' }] + }, + { + name: 'Lightning Talks', + description: 'Five-minute talks, back to back.', + type: 'lightning-talk', + room: ROOM_MAIN, + start: 60, + end: 90, + speakers: [{ name: 'Various' }] + }, + { + name: 'Panel: The Open Social Web', + description: 'A wide-ranging discussion.', + type: 'panel', + room: ROOM_MAIN, + start: 100, + end: 150, + speakers: [{ name: 'Alan Turing' }, { name: 'Katherine Johnson' }] + }, + { + name: 'Lunch', + description: '', + type: 'info', + room: 'none', + start: 150, + end: 195 + }, + { + name: 'Closing Remarks', + description: 'See you next year.', + type: 'talk', + room: ROOM_MAIN, + start: 200, + end: 240, + speakers: [{ name: 'Ada Lovelace' }] + } +]; + +console.log(`publishing ${talks.length} talks…`); +const talkUris: string[] = []; +for (const t of talks) { + const { uri } = await createEvent({ + $type: COLLECTION, + createdAt: nowIso, + name: t.name, + description: t.description, + startsAt: at(t.start), + endsAt: at(t.end), + timezone: TZ, + additionalData: { + type: t.type, + parentEvent: { uri: conference.uri }, + ...(t.room ? { room: t.room } : {}), + ...(t.speakers ? { speakers: t.speakers } : {}) + } + }); + talkUris.push(uri); + console.log(` ${t.name} → ${uri}`); +} + +// ---- Ask the app to index everything now ------------------------------------ + +const allUris = [conference.uri, ...talkUris]; +try { + const res = await fetch(`${APP_URL}/xrpc/rsvp.atmo.notifyOfUpdate`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ uris: allUris }) + }); + console.log(`\nnotified ${APP_URL} to index ${allUris.length} records (status ${res.status})`); +} catch (err) { + console.log( + `\ncould not reach ${APP_URL} to trigger indexing (${(err as Error).message}). ` + + 'They should be picked up by the indexer shortly anyway.' + ); +} + +console.log('\n' + '='.repeat(60)); +console.log('done. view the conference timetable at:'); +console.log(` ${APP_URL}/p/${did}/e/${conference.rkey}`); +console.log('='.repeat(60)); + +process.exit(0); diff --git a/apps/web/src/lib/conference-import/parse.ts b/apps/web/src/lib/conference-import/parse.ts new file mode 100644 index 0000000..26a09c7 --- /dev/null +++ b/apps/web/src/lib/conference-import/parse.ts @@ -0,0 +1,126 @@ +/** + * Parse an uploaded talks file (CSV / TSV / JSON / YAML) into a flat list of + * rows plus the union of column names. The mapping UI then maps those columns + * onto talk-event fields. Kept format-agnostic on purpose so different + * conferences can bring whatever export they already have. + */ +import yaml from 'js-yaml'; + +export type ImportFormat = 'csv' | 'json' | 'yaml'; + +export interface ParsedFile { + format: ImportFormat; + columns: string[]; + rows: Record[]; +} + +export function detectFormat(filename: string, text: string): ImportFormat { + const ext = filename.toLowerCase().split('.').pop(); + if (ext === 'json') return 'json'; + if (ext === 'yaml' || ext === 'yml') return 'yaml'; + if (ext === 'csv' || ext === 'tsv') return 'csv'; + // Sniff: JSON/YAML docs usually start with a bracket/brace; otherwise CSV. + const head = text.trimStart(); + if (head.startsWith('[') || head.startsWith('{')) return 'json'; + return 'csv'; +} + +export function parseFile(filename: string, text: string): ParsedFile { + const format = detectFormat(filename, text); + let rows: Record[]; + if (format === 'json') { + rows = coerceRows(JSON.parse(text)); + } else if (format === 'yaml') { + rows = coerceRows(yaml.load(text)); + } else { + rows = parseCsv(text); + } + return { format, columns: unionKeys(rows), rows }; +} + +/** Accept an array of objects, or an object wrapping one (talks/events/…). */ +function coerceRows(data: unknown): Record[] { + if (Array.isArray(data)) { + return data.filter((r): r is Record => !!r && typeof r === 'object'); + } + if (data && typeof data === 'object') { + const obj = data as Record; + for (const key of ['talks', 'events', 'sessions', 'schedule', 'items', 'rows']) { + if (Array.isArray(obj[key])) { + return (obj[key] as unknown[]).filter( + (r): r is Record => !!r && typeof r === 'object' + ); + } + } + return [obj]; + } + return []; +} + +function unionKeys(rows: Record[]): string[] { + const seen = new Set(); + for (const row of rows) for (const key of Object.keys(row)) seen.add(key); + return [...seen]; +} + +/** Parse CSV/TSV into row objects keyed by the header row. */ +export function parseCsv(text: string): Record[] { + const records = parseDelimited(text); + if (records.length === 0) return []; + const header = records[0].map((h) => h.trim()); + const rows: Record[] = []; + for (let i = 1; i < records.length; i++) { + const rec = records[i]; + // Skip fully blank lines. + if (rec.length === 1 && rec[0].trim() === '') continue; + const row: Record = {}; + header.forEach((h, j) => { + row[h] = rec[j] ?? ''; + }); + rows.push(row); + } + return rows; +} + +/** Minimal RFC-4180-style parser: handles quoted fields, escaped quotes ("") + * and CRLF/LF. Auto-detects tab vs comma delimiter. */ +function parseDelimited(text: string): string[][] { + const delimiter = text.includes('\t') && !text.includes(',') ? '\t' : ','; + const rows: string[][] = []; + let field = ''; + let row: string[] = []; + let inQuotes = false; + + for (let i = 0; i < text.length; i++) { + const c = text[i]; + if (inQuotes) { + if (c === '"') { + if (text[i + 1] === '"') { + field += '"'; + i++; + } else { + inQuotes = false; + } + } else { + field += c; + } + } else if (c === '"') { + inQuotes = true; + } else if (c === delimiter) { + row.push(field); + field = ''; + } else if (c === '\n') { + row.push(field); + rows.push(row); + row = []; + field = ''; + } else if (c !== '\r') { + field += c; + } + } + if (field !== '' || row.length > 0) { + row.push(field); + rows.push(row); + } + return rows; +} diff --git a/apps/web/src/lib/contrail.config.ts b/apps/web/src/lib/contrail.config.ts index de3afed..2bad3e4 100644 --- a/apps/web/src/lib/contrail.config.ts +++ b/apps/web/src/lib/contrail.config.ts @@ -54,6 +54,26 @@ export const config: ContrailConfig = { `(json_extract(r.record, '$.preferences.showInDiscovery') IS NULL OR json_extract(r.record, '$.preferences.showInDiscovery') != 0)` ] + }), + // Endpoint: rsvp.atmo.event.listTalks?parentUri=at://… + // Returns the talk events belonging to a conference, i.e. those + // whose additionalData.parentEvent.uri matches `parentUri`. Composes + // with the standard listRecords params (actor, sort, limit, …), so + // pass actor= to scope to the organizer's own talks. + // json_extract runs at query time, so this needs no reindex; an + // empty/missing parentUri matches nothing (safe default). + listTalks: async (_db, params) => ({ + conditions: [`json_extract(r.record, '$.additionalData.parentEvent.uri') = ?`], + params: [params.get('parentUri') ?? ''] + }), + // Endpoint: rsvp.atmo.event.listAuthored + // Same shape as listRecords, but excludes conference talks (events + // that belong to a parent conference via additionalData.parentEvent), + // so a conference's talks don't flood the host's profile/listings — + // the conference event itself still appears. Filtered in SQL so + // pagination/cursors stay correct. + listAuthored: async () => ({ + conditions: [`json_extract(r.record, '$.additionalData.parentEvent.uri') IS NULL`] }) } }, diff --git a/apps/web/src/lib/contrail.ts b/apps/web/src/lib/contrail.ts index 1145746..d610a27 100644 --- a/apps/web/src/lib/contrail.ts +++ b/apps/web/src/lib/contrail.ts @@ -298,6 +298,57 @@ export async function listDiscoverableEventsFromContrail( return response.data; } +/** + * Hits the `listAuthored` pipelineQuery: same as listRecords but excludes + * conference talks (events with `additionalData.parentEvent`). Used on profile + * pages and the host's listings so a conference's talks don't flood the list — + * the conference event itself still appears. Response shape matches listRecords. + */ +export async function listAuthoredEventsFromContrail( + client: Client, + params: ListEventsParams +): Promise { + const response = await client.get( + 'rsvp.atmo.event.listAuthored' as 'rsvp.atmo.event.listRecords', + { params } + ); + + if (!response.ok) return null; + return response.data; +} + +/** + * Hits the `listTalks` pipelineQuery, returning the talk events that belong to + * a conference (their `additionalData.parentEvent.uri === parentUri`). Pass + * `actor` to scope to the conference organizer's own repo — for now talks are + * organizer-authored, so this keeps stray cross-author records out. Response + * shape is identical to listRecords. + */ +export async function listConferenceTalksFromContrail( + client: Client, + { + parentUri, + actor, + limit = 300 + }: { parentUri: string; actor?: ActorIdentifier; limit?: number } +): Promise { + const response = await client.get( + 'rsvp.atmo.event.listTalks' as 'rsvp.atmo.event.listRecords', + { + params: { + ...(actor ? { actor } : {}), + parentUri, + sort: 'startsAt', + order: 'asc', + limit + } as ListEventsParams + } + ); + + if (!response.ok) return null; + return response.data; +} + export async function getEventRecordFromContrail( client: Client, { diff --git a/apps/web/src/lib/spaces/tunnel-service.generated.ts b/apps/web/src/lib/spaces/tunnel-service.generated.ts index c17849c..6ab0a84 100644 --- a/apps/web/src/lib/spaces/tunnel-service.generated.ts +++ b/apps/web/src/lib/spaces/tunnel-service.generated.ts @@ -2,5 +2,5 @@ * When the tunnel is running, this file is rewritten with the tunnel's * service DID + URL; when the tunnel stops, it is reset to null values. */ -export const SERVICE_DID: string | null = "did:web:described-yamaha-fame-social.trycloudflare.com"; -export const SERVICE_URL: string | null = "https://described-yamaha-fame-social.trycloudflare.com"; +export const SERVICE_DID: string | null = null; +export const SERVICE_URL: string | null = null; diff --git a/apps/web/src/routes/(app)/p/[actor]/+page.server.ts b/apps/web/src/routes/(app)/p/[actor]/+page.server.ts index e783b75..d4f791b 100644 --- a/apps/web/src/routes/(app)/p/[actor]/+page.server.ts +++ b/apps/web/src/routes/(app)/p/[actor]/+page.server.ts @@ -4,7 +4,7 @@ import { getProfileFromContrail, getServerClient, listAttendingEventsFromContrail, - listEventRecordsFromContrail + listAuthoredEventsFromContrail } from '$lib/contrail'; import { getSpacesClient } from '$lib/spaces/server/client'; import { spacesAvailable } from '$lib/spaces/config'; @@ -22,6 +22,11 @@ export async function load({ params, platform, locals }) { locals.client && locals.did && spacesAvailable() ? getSpacesClient(locals.client, platform!.env.DB) : getServerClient(platform!.env.DB); + // `listAuthored` is a public pipelineQuery, not a declared lexicon method, so + // the service-auth spaces client can't mint a JWT for it (the PDS 403s). Route + // the public event listings through the in-process server client instead — + // matching the hosting/past-events pages, which already use it. + const publicClient = getServerClient(platform!.env.DB); if (!isActorIdentifier(params.actor)) return; const actor = params.actor; @@ -33,7 +38,7 @@ export async function load({ params, platform, locals }) { const [profile, upcomingResponse, pastResponse, attendingEvents] = await Promise.all([ getProfileFromContrail(client, actor), - listEventRecordsFromContrail(client, { + listAuthoredEventsFromContrail(publicClient, { hydrateRsvps: 5, profiles: true, sort: 'startsAt', @@ -42,7 +47,7 @@ export async function load({ params, platform, locals }) { actor, limit: PREVIEW_LIMIT + 1 }), - listEventRecordsFromContrail(client, { + listAuthoredEventsFromContrail(publicClient, { hydrateRsvps: 5, profiles: true, sort: 'startsAt', diff --git a/apps/web/src/routes/(app)/p/[actor]/e/[rkey]/+page.server.ts b/apps/web/src/routes/(app)/p/[actor]/e/[rkey]/+page.server.ts index cf522e6..d4ab9e6 100644 --- a/apps/web/src/routes/(app)/p/[actor]/e/[rkey]/+page.server.ts +++ b/apps/web/src/routes/(app)/p/[actor]/e/[rkey]/+page.server.ts @@ -3,6 +3,7 @@ import type { ActorIdentifier, Did } from '@atcute/lexicons'; import { actorToDid } from '$lib/atproto/methods'; import { flattenEventRecord, + flattenEventRecords, getEventRecordFromContrail, getHostProfile, getProfileBlobUrl, @@ -10,12 +11,14 @@ import { getRsvpStatus, getServerClient, getViewerRsvpFromContrail, + listConferenceTalksFromContrail, listEventAttendeesFromContrail, withD1Retry, RSVP_HYDRATE_LIMIT } from '$lib/contrail'; import type { Client } from '@atcute/client'; import { vodFromAtUri } from '$lib/vods'; +import { isConferenceEvent, getParentEventRef, parseEventUri } from '@atmo-dev/events-ui/conference'; type EventRecord = Awaited>; @@ -98,8 +101,22 @@ export async function load({ params, locals, url, platform }) { } const fullEventRecord = eventRecord!; - const isAtmosphereconf = !!(eventData.additionalData as Record | undefined) + const eventUri = fullEventRecord.uri; + + // A conference is just an event with type=conference; its talks are events + // pointing back at it via additionalData.parentEvent. + const isConference = isConferenceEvent(eventData); + const parentRef = getParentEventRef(eventData); + // Back-compat: atmosphereconf talks predate `parentEvent` (they carry an + // `isAtmosphereconf` flag instead). Point them at the conference event until + // the migration backfills `additionalData.parentEvent`. Remove after that. + const legacyAtmosphereconf = !!(eventData.additionalData as Record | undefined) ?.isAtmosphereconf; + const parentParts = parentRef + ? parseEventUri(parentRef.uri) + : legacyAtmosphereconf + ? { did: 'did:plc:lehcqqkwzcwvjvw66uthu5oq', rkey: '3lte3c7x43l2e' } + : null; const speakers = ((eventData.additionalData as Record | undefined)?.speakers as @@ -112,9 +129,17 @@ export async function load({ params, locals, url, platform }) { const vod = vodAtUri ? vodFromAtUri(vodAtUri) : null; // Secondary hydration is best-effort: the event already loaded, so a hiccup - // fetching attendees/rsvp/speakers must not take down (or 404) the page. - const [attendees, viewerRsvpRecord, parentEvent, ...speakerProfiles] = await Promise.all([ - listEventAttendeesFromContrail(client, fullEventRecord.uri).catch(() => ({ + // fetching attendees/rsvp/parent/talks/speakers must not take down (or 404) + // the page. + const [ + attendees, + viewerRsvpRecord, + parentRecord, + conferenceTalksResp, + conferenceRsvpResp, + ...speakerProfiles + ] = await Promise.all([ + listEventAttendeesFromContrail(client, eventUri).catch(() => ({ going: [], interested: [], goingCount: 0, @@ -122,17 +147,31 @@ export async function load({ params, locals, url, platform }) { })), locals.did ? getViewerRsvpFromContrail(client, { - eventUri: fullEventRecord.uri, + eventUri, actor: locals.did }).catch(() => null) : null, - isAtmosphereconf + // Resolve the parent conference (for the "Part of" card on a talk page). + parentParts ? getEventRecordFromContrail(client, { - did: 'did:plc:lehcqqkwzcwvjvw66uthu5oq', - rkey: '3lte3c7x43l2e', + did: parentParts.did, + rkey: parentParts.rkey, profiles: true - }) - .then((r) => (r ? flattenEventRecord(r) : null)) + }).catch(() => null) + : null, + // On a conference event, fetch its talks (organizer-authored for now). + isConference + ? listConferenceTalksFromContrail(client, { + parentUri: eventUri, + actor: did as ActorIdentifier + }).catch(() => null) + : null, + // The viewer's RSVPs, so the timetable can show per-talk going/interested. + isConference && locals.did + ? client + .get('rsvp.atmo.rsvp.listRecords', { + params: { actor: locals.did as ActorIdentifier, limit: 200 } + }) .catch(() => null) : null, ...speakers.map((s) => @@ -149,6 +188,43 @@ export async function load({ params, locals, url, platform }) { ) ]); + const parentEvent = parentRecord ? flattenEventRecord(parentRecord) : null; + const parentEventActor = parentParts + ? (getHostProfile(parentParts.did, parentRecord?.profiles)?.handle ?? parentParts.did) + : null; + // Legacy atmosphereconf talks link to the dedicated schedule route rather + // than the conference event page (which only renders a timetable once the + // event is migrated to type=conference). Drop after migration. + const parentScheduleUrl = !parentRef && legacyAtmosphereconf ? '/p/atmosphereconf.org' : null; + + // Conference talks → schedule maps (RSVP status/rkey + VODs keyed by event URI). + const conferenceTalks = conferenceTalksResp + ? flattenEventRecords(conferenceTalksResp.records) + : []; + const conferenceRsvpStatuses: Record = {}; + const conferenceRsvpRkeys: Record = {}; + if (conferenceRsvpResp?.ok) { + for (const r of conferenceRsvpResp.data.records ?? []) { + const status = r.value?.status; + const subjectUri = r.value?.subject?.uri; + if (status && subjectUri) { + conferenceRsvpStatuses[subjectUri] = status.split('#').pop()!; + if (r.rkey) conferenceRsvpRkeys[subjectUri] = r.rkey; + } + } + } + const conferenceVods: Record = {}; + for (const talk of conferenceTalks) { + const talkVod = (talk.additionalData as Record | undefined)?.vodAtUri as + | string + | undefined; + if (talkVod) + conferenceVods[talk.uri] = { + ...vodFromAtUri(talkVod), + subtitlesUrl: `/vods/${talk.rkey}-karaoke.vtt` + }; + } + return { ogImage: `${url.origin}${url.pathname}/og.png`, eventData, @@ -159,7 +235,16 @@ export async function load({ params, locals, url, platform }) { viewerRsvpStatus: getRsvpStatus(viewerRsvpRecord?.value?.status), viewerRsvpRkey: viewerRsvpRecord?.rkey ?? null, parentEvent, + parentEventActor, + parentScheduleUrl, vod, + isConference, + conferenceTalks, + conferenceTimezone: eventData.timezone ?? 'UTC', + conferenceRsvpStatuses, + conferenceRsvpRkeys, + conferenceVods, + loggedIn: !!locals.did, speakerProfiles: speakerProfiles as Array<{ id?: string; name: string; diff --git a/apps/web/src/routes/(app)/p/[actor]/hosting/+page.server.ts b/apps/web/src/routes/(app)/p/[actor]/hosting/+page.server.ts index b106bbf..22c63ed 100644 --- a/apps/web/src/routes/(app)/p/[actor]/hosting/+page.server.ts +++ b/apps/web/src/routes/(app)/p/[actor]/hosting/+page.server.ts @@ -3,7 +3,7 @@ import { flattenEventRecords, getProfileFromContrail, getServerClient, - listEventRecordsFromContrail + listAuthoredEventsFromContrail } from '$lib/contrail'; import { isActorIdentifier } from '@atcute/lexicons/syntax'; import { error } from '@sveltejs/kit'; @@ -24,7 +24,7 @@ export async function load({ params, url, platform }) { const [profile, response] = await Promise.all([ getProfileFromContrail(client, actor), - listEventRecordsFromContrail(client, { + listAuthoredEventsFromContrail(client, { profiles: true, sort: 'startsAt', order: 'asc', diff --git a/apps/web/src/routes/(app)/p/[actor]/past-events/+page.server.ts b/apps/web/src/routes/(app)/p/[actor]/past-events/+page.server.ts index 8aaf057..9e08c64 100644 --- a/apps/web/src/routes/(app)/p/[actor]/past-events/+page.server.ts +++ b/apps/web/src/routes/(app)/p/[actor]/past-events/+page.server.ts @@ -3,7 +3,7 @@ import { flattenEventRecords, getProfileFromContrail, getServerClient, - listEventRecordsFromContrail + listAuthoredEventsFromContrail } from '$lib/contrail'; import { isActorIdentifier } from '@atcute/lexicons/syntax'; import { error } from '@sveltejs/kit'; @@ -24,7 +24,7 @@ export async function load({ params, url, platform }) { const [profile, response] = await Promise.all([ getProfileFromContrail(client, actor), - listEventRecordsFromContrail(client, { + listAuthoredEventsFromContrail(client, { profiles: true, sort: 'startsAt', order: 'desc', diff --git a/packages/ui/src/EventView.svelte b/packages/ui/src/EventView.svelte index 5803b80..f049860 100644 --- a/packages/ui/src/EventView.svelte +++ b/packages/ui/src/EventView.svelte @@ -16,6 +16,8 @@ import { defaultTheme, type EventTheme } from './theme.js'; import { onMount } from 'svelte'; import type { EditorAdapter, EditorViewer } from './editor/adapter.js'; + import ConferenceTimetable from './schedule/ConferenceTimetable.svelte'; + import { isConferenceEvent, toScheduleEvents, getConferenceRooms } from './conference.js'; import EventBadges from './event-view/EventBadges.svelte'; import EventDateBlock from './event-view/EventDateBlock.svelte'; @@ -151,6 +153,14 @@ let speakers = $derived(data.speakerProfiles ?? []); + // Conference: when this event is a conference, render its talks as a timetable. + let isConference = $derived(isConferenceEvent(eventData)); + let eventActor = $derived(hostProfile?.handle || did); + let conferenceRooms = $derived(getConferenceRooms(eventData)); + let scheduleEvents = $derived( + isConference ? toScheduleEvents(data.conferenceTalks ?? [], eventUri) : [] + ); + // Imported events can opt out of atmo's own RSVPs (rsvpMode === 'external_only'). // In that case we hide the RSVP controls and link out to the original event page. let externalSource = $derived( @@ -275,8 +285,9 @@ - + {#if data.parentEvent} + {@const parentActor = data.parentEventActor ?? data.parentEvent.did}
@@ -285,19 +296,17 @@ > Part of

- -
{/if} - {#if did === 'did:plc:lehcqqkwzcwvjvw66uthu5oq' && rkey === '3lte3c7x43l2e'} - - {/if} - {#if !isPast} {#if rsvpExternalOnly && externalSource?.url} @@ -416,6 +425,24 @@ /> + + + {#if isConference && scheduleEvents.length > 0} +
+ +
+ {/if} diff --git a/packages/ui/src/conference.ts b/packages/ui/src/conference.ts new file mode 100644 index 0000000..9c7456c --- /dev/null +++ b/packages/ui/src/conference.ts @@ -0,0 +1,105 @@ +/** + * Conference / multi-event grouping. + * + * A "conference" is an ordinary calendar event whose `additionalData.type` is + * `'conference'`. Individual talks are ordinary calendar events too, each + * carrying `additionalData.parentEvent` pointing back at the conference. This + * keeps everything inside the shared `community.lexicon.calendar.event` lexicon + * (no fork) while giving every talk its own page, OG image and RSVP for free. + * + * All conference-specific metadata lives under `additionalData`: + * conference: { type: 'conference', rooms?: string[] } + * talk: { type, parentEvent: { uri, cid? }, room?, speakers? } + */ +import type { FlatEventRecord } from './contrail.js'; + +export interface ParentEventRef { + uri: string; + cid?: string; +} + +/** A talk normalized for the schedule grid. */ +export interface ScheduleEvent { + rkey: string; + title: string; + type: string; + speakers?: Array<{ id?: string; name: string }>; + start: string; + end?: string; + room?: string; + description?: string; + did: string; + uri: string; + cid?: string | null; +} + +/** Talk types that link through to their own event page (vs. static info rows). */ +export const linkableTalkTypes = new Set([ + 'talk', + 'presentation', + 'workshop', + 'lightning-talk', + 'panel' +]); + +function additional(event: { additionalData?: unknown }): Record { + return (event.additionalData ?? {}) as Record; +} + +export function getEventType(event: { additionalData?: unknown }): string | undefined { + return additional(event).type as string | undefined; +} + +export function isConferenceEvent(event: { additionalData?: unknown }): boolean { + return getEventType(event) === 'conference'; +} + +export function getParentEventRef(event: { additionalData?: unknown }): ParentEventRef | undefined { + const p = additional(event).parentEvent as ParentEventRef | undefined; + return p?.uri ? p : undefined; +} + +/** Explicit room ordering declared on the conference event, if any. */ +export function getConferenceRooms(event: { additionalData?: unknown }): string[] | undefined { + const r = additional(event).rooms; + return Array.isArray(r) && r.every((x) => typeof x === 'string') ? (r as string[]) : undefined; +} + +/** + * Parse an `at://did/community.lexicon.calendar.event/rkey` URI into its parts. + * Returns null for anything that isn't a calendar-event AT-URI. + */ +export function parseEventUri(uri: string): { did: string; rkey: string } | null { + const m = /^at:\/\/([^/]+)\/community\.lexicon\.calendar\.event\/([^/]+)$/.exec(uri); + return m ? { did: m[1], rkey: m[2] } : null; +} + +/** + * Normalize the talk records belonging to `conferenceUri` into ScheduleEvents, + * sorted by start time. Records whose `parentEvent.uri` doesn't match are + * dropped, so it's safe to pass an over-broad list. + */ +export function toScheduleEvents( + events: FlatEventRecord[], + conferenceUri: string +): ScheduleEvent[] { + return events + .filter((e) => getParentEventRef(e)?.uri === conferenceUri) + .map((e) => { + const ad = additional(e); + return { + rkey: e.rkey, + title: e.name, + type: (ad.type as string) ?? 'talk', + speakers: ad.speakers as Array<{ id?: string; name: string }> | undefined, + start: e.startsAt, + end: e.endsAt, + room: ad.room as string | undefined, + description: e.description, + did: e.did, + uri: e.uri, + cid: e.cid + } satisfies ScheduleEvent; + }) + .sort((a, b) => a.start.localeCompare(b.start)); +} diff --git a/packages/ui/src/event-view/EventHostedBy.svelte b/packages/ui/src/event-view/EventHostedBy.svelte index 1d5b1b7..4def20b 100644 --- a/packages/ui/src/event-view/EventHostedBy.svelte +++ b/packages/ui/src/event-view/EventHostedBy.svelte @@ -45,7 +45,9 @@ {#each speakers as speaker, i (speaker.id || i)} {#if speaker.handle} diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index d8ab307..2c6ff1f 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -7,6 +7,19 @@ export { default as EventComments } from './EventComments.svelte'; export { default as EventAttendees } from './EventAttendees.svelte'; export { default as VodPlayer } from './VodPlayer.svelte'; export { default as VodTranscript } from './VodTranscript.svelte'; +export { default as ConferenceTimetable } from './schedule/ConferenceTimetable.svelte'; + +// Conference / multi-event grouping +export { + isConferenceEvent, + getEventType, + getParentEventRef, + getConferenceRooms, + parseEventUri, + toScheduleEvents, + linkableTalkTypes +} from './conference.js'; +export type { ScheduleEvent, ParentEventRef } from './conference.js'; // Adapter contract export type { diff --git a/packages/ui/src/schedule/ConferenceTimetable.svelte b/packages/ui/src/schedule/ConferenceTimetable.svelte new file mode 100644 index 0000000..6e7fed5 --- /dev/null +++ b/packages/ui/src/schedule/ConferenceTimetable.svelte @@ -0,0 +1,211 @@ + + +{#if scheduleEvents.length > 0} +
+

Schedule

+ {#if isDuringConference} + + {/if} +
+ +

All times shown in {tzLabel}.

+ + {#if dayGroups.length > 1 || loggedIn} +
+ {#if dayGroups.length > 1} +
+ Days + selectedDay, (v) => { if (v) selectedDay = v; }} + class="w-fit" + > + All + {#each dayGroups as day (day.key)} + {day.shortLabel} + {/each} + +
+ {/if} + {#if loggedIn} +
+ Events + filterMode, (v) => { if (v) filterMode = v; }} + class="w-fit" + > + All + Attending + +
+ {/if} +
+ {/if} + + {#each filteredDayGroups as day, dayIndex (day.key)} + {@const dayRooms = getRooms(day.events, explicitRooms)} + {@const grid = buildGrid(day.events, dayRooms, tz)} + +
+

{day.label}

+ activeRooms[dayIndex] ?? 0, (v) => (activeRooms[dayIndex] = v)} + {nowKey} + {nowMinutes} + {rsvpStatuses} + {rsvpRkeys} + {eventVods} + dimUnattended={filterMode === 'attending'} + onrsvpchange={handleRsvpChange} + /> +
+ {/each} +{/if} diff --git a/packages/ui/src/schedule/DaySchedule.svelte b/packages/ui/src/schedule/DaySchedule.svelte new file mode 100644 index 0000000..87809eb --- /dev/null +++ b/packages/ui/src/schedule/DaySchedule.svelte @@ -0,0 +1,249 @@ + + +
+ +
+ {#each rooms as room, i} + + {/each} +
+ + + {#each rooms as room, roomIndex} + {@const roomEvents = grid.gridEvents.filter((e) => e.type === 'info' || e.room === room)} +
+
+
+
+
+
+
+ {#each Array(grid.totalSlots) as _, i} +
+ {#if (grid.minTime + i * SLOT) % 60 === 0} +
+ {formatHour(grid.minTime + i * SLOT)} +
+ {/if} +
+ {/each} +
+
    + {#each roomEvents as event} +
  1. + +
  2. + {/each} + {#if nowRow} +
  3. +
    +
    +
    +
    +
  4. + {/if} +
+
+
+
+ {/each} + + + +
diff --git a/packages/ui/src/schedule/ScheduleEventCell.svelte b/packages/ui/src/schedule/ScheduleEventCell.svelte new file mode 100644 index 0000000..34cf396 --- /dev/null +++ b/packages/ui/src/schedule/ScheduleEventCell.svelte @@ -0,0 +1,185 @@ + + +{#if linkableTypes.has(event.type) && event.rkey} + + + +
+

+ {event.title} +

+ + {#if event.start} +

+ {formatTime(event.start, tz)}{event.end ? ` – ${formatTime(event.end, tz)}` : ''} +

+ {/if} + + {#if event.speakers?.length} +

+ {#each event.speakers as s, i (s.id ?? s.name)}{#if s.id}{s.name}{:else}{s.name}{/if}{#if i < event.speakers.length - 1}, {/if}{/each} +

+ {/if} + + {#if event.description} +

+ {event.description} +

+ {/if} + + {#if !isPast} + (modalOpen = false)} + onrsvp={(status, key) => { + onrsvpchange?.(event.uri, status, key); + modalOpen = false; + }} + oncancel={() => { + onrsvpchange?.(event.uri, null); + }} + /> + {/if} + + {#if vodPlaylistUrl} +
+ +
+ {/if} + +
+ +{:else} +
+

+ {event.title} +

+ {#if event.start} +

+ {formatTime(event.start, tz)}{event.end ? ` – ${formatTime(event.end, tz)}` : ''} +

+ {/if} +
+{/if} diff --git a/packages/ui/src/schedule/schedule-utils.ts b/packages/ui/src/schedule/schedule-utils.ts new file mode 100644 index 0000000..9aa1cc8 --- /dev/null +++ b/packages/ui/src/schedule/schedule-utils.ts @@ -0,0 +1,212 @@ +import type { ScheduleEvent } from '../conference.js'; + +export type { ScheduleEvent }; + +export interface GridEvent extends ScheduleEvent { + startMin: number; + endMin: number; + startRow: number; + spanRows: number; + colStart: number; + colSpan: number; + zIndex: number; +} + +export interface GridData { + gridEvents: GridEvent[]; + minTime: number; + totalSlots: number; + timeGridRows: string; +} + +export const SLOT = 5; +export const SLOT_HEIGHT = '0.6rem'; + +export const linkableTypes = new Set([ + 'talk', + 'presentation', + 'workshop', + 'lightning-talk', + 'panel' +]); + +export function isLightning(type: string): boolean { + return type === 'lightning-talk'; +} + +export function isCompact(durationMin: number, type: string): boolean { + return type === 'lightning-talk' || durationMin <= 15; +} + +/** + * Wall-clock components of an instant as seen in `tz`, returned as a Date built + * from those local parts (so getHours()/getMinutes() read the tz time, not the + * runtime's). Used to lay the grid out in the conference's own timezone. + */ +export function toTzDate(iso: string, tz: string): Date { + const date = new Date(iso); + const parts = new Intl.DateTimeFormat('en-US', { + timeZone: tz, + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + hour12: false + }).formatToParts(date); + const get = (type: string) => parseInt(parts.find((p) => p.type === type)?.value ?? '0'); + return new Date(get('year'), get('month') - 1, get('day'), get('hour'), get('minute')); +} + +export function isoToMinutes(iso: string, tz: string): number { + const d = toTzDate(iso, tz); + return d.getHours() * 60 + d.getMinutes(); +} + +export function formatTime(iso: string, tz: string): string { + return new Date(iso).toLocaleTimeString('en-US', { + hour: 'numeric', + minute: '2-digit', + hour12: true, + timeZone: tz + }); +} + +/** Hour label for a "minutes since midnight" value (0–1439). Timezone-agnostic. */ +export function formatHour(minutes: number): string { + const h = Math.floor(minutes / 60) % 24; + const p = h >= 12 ? 'PM' : 'AM'; + const display = h % 12 === 0 ? 12 : h % 12; + return `${display}${p}`; +} + +export function getDayKey(iso: string, tz: string): string { + const d = toTzDate(iso, tz); + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; +} + +export function getDayLabel(iso: string, tz: string): string { + return new Date(iso).toLocaleDateString('en-US', { + timeZone: tz, + weekday: 'long', + month: 'long', + day: 'numeric' + }); +} + +export function getDayShortLabel(iso: string, tz: string): string { + return new Date(iso).toLocaleDateString('en-US', { weekday: 'short', timeZone: tz }); +} + +export function getEventColor(type: string): string { + switch (type) { + case 'info': + return 'bg-base-200 dark:bg-base-800 text-base-700 dark:text-base-300'; + case 'workshop': + return 'bg-cyan-100 dark:bg-cyan-950 text-cyan-900 dark:text-cyan-200'; + case 'lightning-talk': + return 'bg-sky-100 dark:bg-sky-950 text-sky-900 dark:text-sky-200'; + case 'panel': + return 'bg-violet-100 dark:bg-violet-950 text-violet-900 dark:text-violet-200'; + case 'activity': + return 'bg-emerald-100 dark:bg-emerald-950 text-emerald-900 dark:text-emerald-200'; + default: + return 'bg-teal-100 dark:bg-teal-950 text-teal-900 dark:text-teal-200'; + } +} + +export function durationMinutes(start: string, end: string | undefined, tz: string): number { + if (!end) return 30; + return isoToMinutes(end, tz) - isoToMinutes(start, tz); +} + +/** Rooms present in a set of events, in display order. */ +export function getRooms(events: ScheduleEvent[], explicitOrder?: string[]): string[] { + const present = new Set(); + for (const e of events) { + if (e.room && e.room !== 'none' && e.type !== 'info') present.add(e.room); + } + if (explicitOrder?.length) { + // Honour the declared order, then append any rooms not in the list. + const ordered = explicitOrder.filter((r) => present.has(r)); + const extra = [...present].filter((r) => !explicitOrder.includes(r)).sort(); + return [...ordered, ...extra]; + } + return [...present].sort(); +} + +export function buildGrid(events: ScheduleEvent[], rooms: string[], tz: string): GridData { + const parsed = events + .filter((e) => e.start && e.title?.trim()) + .map((e) => { + const startMin = isoToMinutes(e.start, tz); + const endMin = e.end ? isoToMinutes(e.end, tz) : startMin + 30; + return { ...e, startMin, endMin }; + }); + + const gridRelevant = parsed.filter((e) => e.type === 'info' || rooms.includes(e.room || '')); + + if (gridRelevant.length === 0) + return { gridEvents: [], minTime: 0, totalSlots: 0, timeGridRows: '' }; + + const minTime = Math.floor(Math.min(...gridRelevant.map((e) => e.startMin)) / SLOT) * SLOT; + const maxTime = Math.ceil(Math.max(...gridRelevant.map((e) => e.endMin)) / SLOT) * SLOT; + const totalSlots = (maxTime - minTime) / SLOT; + + // Collapse duplicate "doors open"-style info rows that share a start time + // with another info event, so they don't stack on top of each other. + const infoStartTimes = new Map(); + for (const e of gridRelevant) { + if (e.type === 'info') infoStartTimes.set(e.startMin, (infoStartTimes.get(e.startMin) ?? 0) + 1); + } + const filteredRelevant = gridRelevant.filter((e) => { + if ( + e.type === 'info' && + /doors\s*open/i.test(e.title) && + (infoStartTimes.get(e.startMin) ?? 0) > 1 + ) + return false; + return true; + }); + + // Trim each info banner's end so consecutive ones don't overlap. + const infoEvents = filteredRelevant + .filter((e) => e.type === 'info') + .sort((a, b) => a.startMin - b.startMin); + for (let i = 0; i < infoEvents.length - 1; i++) { + const next = infoEvents[i + 1]; + if (infoEvents[i].endMin > next.startMin) { + infoEvents[i].endMin = next.startMin; + } + } + + const gridEvents = filteredRelevant.map((e) => { + const fullWidth = e.type === 'info'; + return { + ...e, + startRow: Math.round((e.startMin - minTime) / SLOT) + 2, + spanRows: Math.max(1, Math.round((e.endMin - e.startMin) / SLOT)), + colStart: fullWidth ? 1 : rooms.indexOf(e.room!) + 1, + colSpan: fullWidth ? rooms.length : 1, + zIndex: fullWidth ? 0 : e.startMin + }; + }); + + const timeGridRows = `grid-template-rows: 0.5rem repeat(${totalSlots}, ${SLOT_HEIGHT})`; + + return { gridEvents, minTime, totalSlots, timeGridRows }; +} + +export function getNowGridRow( + grid: GridData, + dayKey: string, + nowKey: string, + nowMinutes: number +): { row: number; offsetPercent: number } | null { + if (grid.totalSlots === 0 || nowKey !== dayKey) return null; + const exactSlot = (nowMinutes - grid.minTime) / SLOT; + const row = Math.floor(exactSlot) + 2; + const offsetPercent = (exactSlot - Math.floor(exactSlot)) * 100; + if (row < 2 || row > grid.totalSlots + 2) return null; + return { row, offsetPercent }; +} -- 2.51.2