diff --git a/src/routes/[[actor=actor]]/blog/+page.svelte b/src/routes/[[actor=actor]]/blog/+page.svelte index d8f8835..1e57832 100644 --- a/src/routes/[[actor=actor]]/blog/+page.svelte +++ b/src/routes/[[actor=actor]]/blog/+page.svelte @@ -45,8 +45,8 @@ -
-
+
+
diff --git a/src/routes/[[actor=actor]]/blog/[rkey]/+page.svelte b/src/routes/[[actor=actor]]/blog/[rkey]/+page.svelte index ab92ee6..ecadc83 100644 --- a/src/routes/[[actor=actor]]/blog/[rkey]/+page.svelte +++ b/src/routes/[[actor=actor]]/blog/[rkey]/+page.svelte @@ -109,8 +109,8 @@ {/if} -
-
+
+
{#if coverUrl} {title} diff --git a/src/routes/[[actor=actor]]/blog/new/+page.svelte b/src/routes/[[actor=actor]]/blog/new/+page.svelte index 51d1f99..fe31d30 100644 --- a/src/routes/[[actor=actor]]/blog/new/+page.svelte +++ b/src/routes/[[actor=actor]]/blog/new/+page.svelte @@ -372,8 +372,8 @@ Create Blog Post -
-
+
+
{#if user.isInitializing || !draftRestored}
diff --git a/src/routes/[[actor=actor]]/events/+page.svelte b/src/routes/[[actor=actor]]/events/+page.svelte index 647160d..07da258 100644 --- a/src/routes/[[actor=actor]]/events/+page.svelte +++ b/src/routes/[[actor=actor]]/events/+page.svelte @@ -89,8 +89,8 @@ -
-
+
+
@@ -125,7 +125,7 @@ {@const rkey = event.rkey}
diff --git a/src/routes/[[actor=actor]]/events/[rkey]/+page.svelte b/src/routes/[[actor=actor]]/events/[rkey]/+page.svelte index c450bc6..b010f8d 100644 --- a/src/routes/[[actor=actor]]/events/[rkey]/+page.svelte +++ b/src/routes/[[actor=actor]]/events/[rkey]/+page.svelte @@ -171,8 +171,8 @@ -
-
+
+
{#if isBannerOnly && displayImage}
{#if user.isInitializing || rsvpLoading}
diff --git a/src/routes/[[actor=actor]]/events/new/+page.svelte b/src/routes/[[actor=actor]]/events/new/+page.svelte index 7827407..dc1af3d 100644 --- a/src/routes/[[actor=actor]]/events/new/+page.svelte +++ b/src/routes/[[actor=actor]]/events/new/+page.svelte @@ -359,8 +359,8 @@ Create Event -
-
+
+
{#if user.isInitializing}
-- 2.51.2 From 428ce852cbb6f5f7909f9cb022a490ffe025fca6 Mon Sep 17 00:00:00 2001 From: Florian <45694132+flo-bit@users.noreply.github.com> Date: Thu, 19 Feb 2026 01:26:49 +0100 Subject: [PATCH 2/2] fixes, design, start attendees --- .../[[actor=actor]]/events/+page.svelte | 5 +- .../events/[rkey]/+page.svelte | 32 +- .../events/[rkey]/EventAttendees.svelte | 134 +++ .../events/[rkey]/api.remote.ts | 47 + .../events/[rkey]/edit/+page.server.ts | 59 + .../events/[rkey]/edit/+page.svelte | 1064 +++++++++++++++++ .../[[actor=actor]]/events/new/+page.svelte | 256 +++- src/routes/api/geocoding/+server.ts | 3 +- 8 files changed, 1588 insertions(+), 12 deletions(-) create mode 100644 src/routes/[[actor=actor]]/events/[rkey]/EventAttendees.svelte create mode 100644 src/routes/[[actor=actor]]/events/[rkey]/api.remote.ts create mode 100644 src/routes/[[actor=actor]]/events/[rkey]/edit/+page.server.ts create mode 100644 src/routes/[[actor=actor]]/events/[rkey]/edit/+page.svelte diff --git a/src/routes/[[actor=actor]]/events/+page.svelte b/src/routes/[[actor=actor]]/events/+page.svelte index 07da258..e487b93 100644 --- a/src/routes/[[actor=actor]]/events/+page.svelte +++ b/src/routes/[[actor=actor]]/events/+page.svelte @@ -75,7 +75,6 @@ return { url, alt: media.alt || event.name }; } - let actorPrefix = $derived(data.hostProfile?.handle ? `/${data.hostProfile.handle}` : `/${did}`); let isOwner = $derived(user.isLoggedIn && user.did === did); @@ -111,7 +110,7 @@
{#if isOwner} - + {/if}
@@ -124,7 +123,7 @@ {@const location = getLocationString(event.locations)} {@const rkey = event.rkey} diff --git a/src/routes/[[actor=actor]]/events/[rkey]/+page.svelte b/src/routes/[[actor=actor]]/events/[rkey]/+page.svelte index b010f8d..c4fb162 100644 --- a/src/routes/[[actor=actor]]/events/[rkey]/+page.svelte +++ b/src/routes/[[actor=actor]]/events/[rkey]/+page.svelte @@ -1,9 +1,11 @@ @@ -213,11 +217,16 @@
-

- {eventData.name} -

+
+

+ {eventData.name} +

+ {#if isOwner} + + {/if} +
{#if eventData.mode} @@ -288,6 +297,12 @@ + {#if isOwner} +
+ +
+ {/if} + {#if descriptionHtml}
@@ -364,6 +379,11 @@
{/if} + + +
+ import { Avatar as FoxAvatar } from '@foxui/core'; + import { onMount } from 'svelte'; + import { fetchEventBacklinks } from './api.remote'; + + let { eventUri, did }: { eventUri: string; did: string } = $props(); + + let goingCount = $state(0); + let interestedCount = $state(0); + let goingAvatars: Array<{ did: string; avatar?: string; name: string }> = $state([]); + let interestedAvatars: Array<{ did: string; avatar?: string; name: string }> = $state([]); + let loading = $state(true); + + onMount(() => { + fetchEventBacklinks(eventUri) + .then((records) => { + console.log(records); + if (!records) return; + let going = 0; + let interested = 0; + const goingAvatarList: Array<{ did: string; avatar?: string; name: string }> = []; + const interestedAvatarList: Array<{ did: string; avatar?: string; name: string }> = []; + + for (const raw of records) { + const record = raw as { + did: string; + value?: { status?: string }; + author?: { avatar?: string; displayName?: string; handle?: string }; + }; + const status = record.value?.status || ''; + const author = record.author; + const avatarInfo = { + did: record.did, + avatar: author?.avatar, + name: author?.displayName || author?.handle || record.did + }; + + if (status.includes('#going')) { + going++; + goingAvatarList.push(avatarInfo); + } else if (status.includes('#interested')) { + interested++; + interestedAvatarList.push(avatarInfo); + } + } + + goingCount = going; + interestedCount = interested; + goingAvatars = goingAvatarList; + interestedAvatars = interestedAvatarList; + }) + .catch((err) => { + console.error('Failed to fetch event attendees:', err); + }) + .finally(() => { + loading = false; + }); + }); + + let totalCount = $derived(goingCount + interestedCount); + let allAvatars = $derived([...goingAvatars, ...interestedAvatars]); + let displayAvatars = $derived(allAvatars.slice(0, 8)); + let overflowCount = $derived(allAvatars.length - displayAvatars.length); + + +{#if loading} +
+
+
+{:else if totalCount > 0} +
+

+ Attendees +

+ + + {#if displayAvatars.length > 0} +
+
+ {#each displayAvatars as person (person.did)} + + {/each} +
+ {#if overflowCount > 0} + + +{overflowCount} + + {/if} +
+ {/if} + + +
+ {#if goingCount > 0} + + + + + {goingCount} going + + {/if} + {#if interestedCount > 0} + + + + + {interestedCount} interested + + {/if} +
+
+{/if} diff --git a/src/routes/[[actor=actor]]/events/[rkey]/api.remote.ts b/src/routes/[[actor=actor]]/events/[rkey]/api.remote.ts new file mode 100644 index 0000000..293a48b --- /dev/null +++ b/src/routes/[[actor=actor]]/events/[rkey]/api.remote.ts @@ -0,0 +1,47 @@ +import { query } from '$app/server'; + +export const fetchEventBacklinks = query('unchecked', async (eventUri: string) => { + const allRecords: Record[] = []; + let cursor: string | undefined; + + do { + const params: Record = { + subject: eventUri, + source: 'community.lexicon.calendar.rsvp:subject.uri' + }; + if (cursor) params.cursor = cursor; + + const res = await fetch( + 'https://slingshot.microcosm.blue/xrpc/com.bad-example.proxy.hydrateQueryResponse', + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + atproto_proxy: 'did:web:constellation.microcosm.blue#constellation', + hydration_sources: [ + { + path: 'records[]', + shape: 'at-uri-parts' + } + ], + params, + xrpc: 'blue.microcosm.links.getBacklinks' + }) + } + ); + + if (!res.ok) break; + + const data = await res.json(); + const output = data.output; + if (!output) break; + + if (output.records && Array.isArray(output.records)) { + allRecords.push(...output.records); + } + + cursor = output.cursor || undefined; + } while (cursor); + + return allRecords; +}); diff --git a/src/routes/[[actor=actor]]/events/[rkey]/edit/+page.server.ts b/src/routes/[[actor=actor]]/events/[rkey]/edit/+page.server.ts new file mode 100644 index 0000000..463a15e --- /dev/null +++ b/src/routes/[[actor=actor]]/events/[rkey]/edit/+page.server.ts @@ -0,0 +1,59 @@ +import { error } from '@sveltejs/kit'; +import type { EventData } from '$lib/cards/social/EventCard'; +import { getBlentoOrBskyProfile, getRecord } from '$lib/atproto/methods.js'; +import { createCache, type CachedProfile } from '$lib/cache'; +import type { Did } from '@atcute/lexicons'; +import { getActor } from '$lib/actor'; + +export async function load({ params, platform, request }) { + const { rkey } = params; + + const cache = createCache(platform); + + const did = await getActor({ request, paramActor: params.actor, platform }); + + if (!did || !rkey) { + throw error(404, 'Event not found'); + } + + try { + const [eventRecord, hostProfile] = await Promise.all([ + getRecord({ + did: did as Did, + collection: 'community.lexicon.calendar.event', + rkey + }), + cache + ? cache.getProfile(did as Did).catch(() => null) + : getBlentoOrBskyProfile({ did: did as Did }) + .then( + (p): CachedProfile => ({ + did: p.did as string, + handle: p.handle as string, + displayName: p.displayName as string | undefined, + avatar: p.avatar as string | undefined, + hasBlento: p.hasBlento, + url: p.url + }) + ) + .catch(() => null) + ]); + + if (!eventRecord?.value) { + throw error(404, 'Event not found'); + } + + const eventData: EventData = eventRecord.value as EventData; + + return { + eventData, + did, + rkey, + hostProfile: hostProfile ?? null, + eventCid: (eventRecord.cid as string) ?? null + }; + } catch (e) { + if (e && typeof e === 'object' && 'status' in e) throw e; + throw error(404, 'Event not found'); + } +} diff --git a/src/routes/[[actor=actor]]/events/[rkey]/edit/+page.svelte b/src/routes/[[actor=actor]]/events/[rkey]/edit/+page.svelte new file mode 100644 index 0000000..4f7e67a --- /dev/null +++ b/src/routes/[[actor=actor]]/events/[rkey]/edit/+page.svelte @@ -0,0 +1,1064 @@ + + + + Edit Event + + +
+
+ {#if user.isInitializing} +
+
+
+
+ {:else if !user.isLoggedIn} +
+

Log in to edit this event.

+ +
+ {:else} +
+ Local edit + {#if hasDraft} + + {/if} +
+ +
{ + e.preventDefault(); + handleSubmit(); + }} + > + +
+ + +
+ + {#if thumbnailPreview} +
+ + +
+ {:else} + + {/if} +
+ + +
+ + + + +
+ { + return mode; + }, + (val) => { + if (val) mode = val; + } + } + class="w-fit" + > + In Person + Virtual + Hybrid + +
+ + +
+
+ {#if startDate} + + {formatMonth(startDate)} + + + {formatDay(startDate)} + + {:else} + + + + {/if} +
+
+ {#if startDate} +

+ {formatWeekday(startDate)}, {formatFullDate(startDate)} + {#if endDate && !isSameDay} + - {formatWeekday(endDate)}, {formatFullDate(endDate)} + {/if} +

+

+ {formatTime(startDate)} + {#if endDate && isSameDay} + - {formatTime(endDate)} + {/if} +

+ {/if} +
+ + +
+
+
+ + + {#if location} +
+
+ + + + +
+

+ {getLocationDisplayString(location)} +

+ +
+ {:else} + + {/if} + + +
+

+ About +

+ +
+ + {#if error} +

{error}

+ {/if} + + +
+ + +
+

+ Hosted By +

+
+ + + {hostName} + +
+
+ + +
+

+ Links +

+
+ {#each links as link, i (i)} +
+ + + + + {link.name || link.uri.replace(/^https?:\/\//, '')} + + +
+ {/each} +
+ +
+ + + {#if showLinkPopup} +
+ + +
+ + +
+
+ {/if} +
+
+
+
+ {/if} +
+
+ + + +

Add location

+
{ + e.preventDefault(); + searchLocation(); + }} + class="mt-2" + > +
+ + +
+
+ + {#if locationError} +

{locationError}

+ {/if} + + {#if locationResult} +
+
+ + + + +
+

+ {getLocationDisplayString(locationResult.location)} +

+

+ {locationResult.displayName} +

+
+
+
+ +
+
+ {/if} + +

+ Geocoding by Nominatim + / © + OpenStreetMap contributors +

+ diff --git a/src/routes/[[actor=actor]]/events/new/+page.svelte b/src/routes/[[actor=actor]]/events/new/+page.svelte index dc1af3d..97bfe2a 100644 --- a/src/routes/[[actor=actor]]/events/new/+page.svelte +++ b/src/routes/[[actor=actor]]/events/new/+page.svelte @@ -10,11 +10,19 @@ import { onMount } from 'svelte'; import { browser } from '$app/environment'; import { putImage, getImage, deleteImage } from '$lib/components/image-store'; + import Modal from '$lib/components/modal/Modal.svelte'; const DRAFT_KEY = 'blento-event-draft'; type EventMode = 'inperson' | 'virtual' | 'hybrid'; + interface EventLocation { + street?: string; + locality?: string; + region?: string; + country?: string; + } + interface EventDraft { name: string; description: string; @@ -23,6 +31,7 @@ links: Array<{ uri: string; name: string }>; mode?: EventMode; thumbnailKey?: string; + location?: EventLocation; } let thumbnailKey: string | null = $state(null); @@ -37,6 +46,13 @@ let submitting = $state(false); let error: string | null = $state(null); + let location: EventLocation | null = $state(null); + let showLocationModal = $state(false); + let locationSearch = $state(''); + let locationSearching = $state(false); + let locationError = $state(''); + let locationResult: { displayName: string; location: EventLocation } | null = $state(null); + let links: Array<{ uri: string; name: string }> = $state([]); let showLinkPopup = $state(false); let newLinkUri = $state(''); @@ -56,6 +72,7 @@ endsAt = draft.endsAt || ''; links = draft.links || []; mode = draft.mode || 'inperson'; + location = draft.location || null; if (draft.thumbnailKey) { const img = await getImage(draft.thumbnailKey); @@ -82,7 +99,8 @@ saveDraftTimeout = setTimeout(() => { const draft: EventDraft = { name, description, startsAt, endsAt, links, mode }; if (thumbnailKey) draft.thumbnailKey = thumbnailKey; - const hasContent = name || description || startsAt || endsAt || links.length > 0; + if (location) draft.location = location; + const hasContent = name || description || startsAt || endsAt || links.length > 0 || location; if (hasContent) { localStorage.setItem(DRAFT_KEY, JSON.stringify(draft)); hasDraft = true; @@ -95,7 +113,15 @@ $effect(() => { // track all draft fields by reading them - void [name, description, startsAt, endsAt, mode, JSON.stringify(links)]; + void [ + name, + description, + startsAt, + endsAt, + mode, + JSON.stringify(links), + JSON.stringify(location) + ]; saveDraft(); }); @@ -108,6 +134,7 @@ endsAt = ''; links = []; mode = 'inperson'; + location = null; thumbnailFile = null; if (thumbnailPreview) URL.revokeObjectURL(thumbnailPreview); thumbnailPreview = null; @@ -115,6 +142,62 @@ hasDraft = false; } + async function searchLocation() { + const q = locationSearch.trim(); + if (!q) return; + locationError = ''; + locationSearching = true; + locationResult = null; + + try { + const response = await fetch('/api/geocoding?q=' + encodeURIComponent(q)); + if (!response.ok) throw new Error('response not ok'); + const data = await response.json(); + if (!data || data.error) throw new Error('no results'); + + const addr = data.address || {}; + const road = addr.road || ''; + const houseNumber = addr.house_number || ''; + const street = road ? (houseNumber ? `${road} ${houseNumber}` : road) : ''; + const locality = + addr.city || addr.town || addr.village || addr.municipality || addr.hamlet || ''; + const region = addr.state || addr.county || ''; + const country = addr.country || ''; + + locationResult = { + displayName: data.display_name || q, + location: { + ...(street && { street }), + ...(locality && { locality }), + ...(region && { region }), + ...(country && { country }) + } + }; + } catch { + locationError = "Couldn't find that location."; + } finally { + locationSearching = false; + } + } + + function confirmLocation() { + if (locationResult) { + location = locationResult.location; + } + showLocationModal = false; + locationSearch = ''; + locationResult = null; + locationError = ''; + } + + function removeLocation() { + location = null; + } + + function getLocationDisplayString(loc: EventLocation): string { + return [loc.street, loc.locality, loc.region, loc.country].filter(Boolean).join(', '); + } + function addLink() { const uri = newLinkUri.trim(); if (!uri) return; @@ -324,6 +407,14 @@ if (links.length > 0) { record.uris = links; } + if (location) { + record.locations = [ + { + $type: 'community.lexicon.location.address', + ...location + } + ]; + } const response = await user.client.post('com.atproto.repo.createRecord', { input: { @@ -565,6 +656,86 @@
+ + {#if location} +
+
+ + + + +
+

+ {getLocationDisplayString(location)} +

+ +
+ {:else} + + {/if} +

+ + + +

Add location

+
{ + e.preventDefault(); + searchLocation(); + }} + class="mt-2" + > +
+ + +
+
+ + {#if locationError} +

{locationError}

+ {/if} + + {#if locationResult} +
+
+ + + + +
+

+ {getLocationDisplayString(locationResult.location)} +

+

+ {locationResult.displayName} +

+
+
+
+ +
+
+ {/if} + +

+ Geocoding by Nominatim + / © + OpenStreetMap contributors +

+
diff --git a/src/routes/api/geocoding/+server.ts b/src/routes/api/geocoding/+server.ts index 6b6e2e4..78a0e49 100644 --- a/src/routes/api/geocoding/+server.ts +++ b/src/routes/api/geocoding/+server.ts @@ -8,7 +8,8 @@ export async function GET({ url }) { } const nomUrl = - 'https://nominatim.openstreetmap.org/search?format=json&q=' + encodeURIComponent(q); + 'https://nominatim.openstreetmap.org/search?format=json&addressdetails=1&q=' + + encodeURIComponent(q); try { const data = await fetch(nomUrl, {