@atmo-dev/kit 0.4.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +119 -0
- package/dist/at-tags.d.ts +18 -0
- package/dist/at-tags.js +18 -0
- package/dist/components/AtTags.svelte +17 -0
- package/dist/components/AtTags.svelte.d.ts +7 -0
- package/dist/events/DatePicker.svelte +222 -0
- package/dist/events/DatePicker.svelte.d.ts +12 -0
- package/dist/events/DateTimePicker.svelte +112 -0
- package/dist/events/DateTimePicker.svelte.d.ts +10 -0
- package/dist/events/EventAttendees.svelte +199 -0
- package/dist/events/EventAttendees.svelte.d.ts +19 -0
- package/dist/events/EventCard.svelte +148 -0
- package/dist/events/EventCard.svelte.d.ts +8 -0
- package/dist/events/EventComments.svelte +98 -0
- package/dist/events/EventComments.svelte.d.ts +8 -0
- package/dist/events/EventEditor.svelte +540 -0
- package/dist/events/EventEditor.svelte.d.ts +18 -0
- package/dist/events/EventRsvp.svelte +178 -0
- package/dist/events/EventRsvp.svelte.d.ts +16 -0
- package/dist/events/EventView.svelte +488 -0
- package/dist/events/EventView.svelte.d.ts +23 -0
- package/dist/events/ImageDropper.svelte +66 -0
- package/dist/events/ImageDropper.svelte.d.ts +7 -0
- package/dist/events/Map.svelte +36 -0
- package/dist/events/Map.svelte.d.ts +8 -0
- package/dist/events/PostToBlueskyModal.svelte +246 -0
- package/dist/events/PostToBlueskyModal.svelte.d.ts +22 -0
- package/dist/events/ShareModal.svelte +174 -0
- package/dist/events/ShareModal.svelte.d.ts +23 -0
- package/dist/events/StarterPackButton.svelte +72 -0
- package/dist/events/StarterPackButton.svelte.d.ts +10 -0
- package/dist/events/ThemeApply.svelte +30 -0
- package/dist/events/ThemeApply.svelte.d.ts +7 -0
- package/dist/events/ThemeBackground.svelte +33 -0
- package/dist/events/ThemeBackground.svelte.d.ts +7 -0
- package/dist/events/ThemePicker.svelte +104 -0
- package/dist/events/ThemePicker.svelte.d.ts +7 -0
- package/dist/events/ThumbnailPresets.svelte +71 -0
- package/dist/events/ThumbnailPresets.svelte.d.ts +11 -0
- package/dist/events/TimePicker.svelte +117 -0
- package/dist/events/TimePicker.svelte.d.ts +10 -0
- package/dist/events/TimezonePicker.svelte +67 -0
- package/dist/events/TimezonePicker.svelte.d.ts +6 -0
- package/dist/events/VodPlayer.svelte +154 -0
- package/dist/events/VodPlayer.svelte.d.ts +14 -0
- package/dist/events/VodTranscript.svelte +75 -0
- package/dist/events/VodTranscript.svelte.d.ts +8 -0
- package/dist/events/atproto-helpers.d.ts +21 -0
- package/dist/events/atproto-helpers.js +61 -0
- package/dist/events/cal/helper.d.ts +1 -0
- package/dist/events/cal/helper.js +20 -0
- package/dist/events/cal/ical.d.ts +22 -0
- package/dist/events/cal/ical.js +188 -0
- package/dist/events/cal/sanitize.d.ts +3 -0
- package/dist/events/cal/sanitize.js +25 -0
- package/dist/events/conference.d.ts +65 -0
- package/dist/events/conference.js +61 -0
- package/dist/events/contrail.d.ts +49 -0
- package/dist/events/contrail.js +12 -0
- package/dist/events/date-format.d.ts +22 -0
- package/dist/events/date-format.js +43 -0
- package/dist/events/editor/LinkedRecordsPicker.svelte +104 -0
- package/dist/events/editor/LinkedRecordsPicker.svelte.d.ts +12 -0
- package/dist/events/editor/LinksSection.svelte +154 -0
- package/dist/events/editor/LinksSection.svelte.d.ts +13 -0
- package/dist/events/editor/LocationSection.svelte +163 -0
- package/dist/events/editor/LocationSection.svelte.d.ts +8 -0
- package/dist/events/editor/RecurringModal.svelte +313 -0
- package/dist/events/editor/RecurringModal.svelte.d.ts +30 -0
- package/dist/events/editor/ThemeSection.svelte +24 -0
- package/dist/events/editor/ThemeSection.svelte.d.ts +7 -0
- package/dist/events/editor/ThumbnailSection.svelte +181 -0
- package/dist/events/editor/ThumbnailSection.svelte.d.ts +13 -0
- package/dist/events/editor/adapter.d.ts +73 -0
- package/dist/events/editor/adapter.js +9 -0
- package/dist/events/editor/location.d.ts +16 -0
- package/dist/events/editor/location.js +50 -0
- package/dist/events/editor/save.d.ts +43 -0
- package/dist/events/editor/save.js +168 -0
- package/dist/events/editor/types.d.ts +39 -0
- package/dist/events/editor/types.js +9 -0
- package/dist/events/event-media.d.ts +4 -0
- package/dist/events/event-media.js +4 -0
- package/dist/events/event-types.d.ts +79 -0
- package/dist/events/event-types.js +11 -0
- package/dist/events/event-view/AddToCalendarButton.svelte +31 -0
- package/dist/events/event-view/AddToCalendarButton.svelte.d.ts +9 -0
- package/dist/events/event-view/EventBadges.svelte +20 -0
- package/dist/events/event-view/EventBadges.svelte.d.ts +7 -0
- package/dist/events/event-view/EventDateBlock.svelte +43 -0
- package/dist/events/event-view/EventDateBlock.svelte.d.ts +7 -0
- package/dist/events/event-view/EventHostedBy.svelte +61 -0
- package/dist/events/event-view/EventHostedBy.svelte.d.ts +16 -0
- package/dist/events/event-view/EventLinksList.svelte +24 -0
- package/dist/events/event-view/EventLinksList.svelte.d.ts +9 -0
- package/dist/events/event-view/EventLocationBlock.svelte +62 -0
- package/dist/events/event-view/EventLocationBlock.svelte.d.ts +7 -0
- package/dist/events/event-view/EventLocationMap.svelte +66 -0
- package/dist/events/event-view/EventLocationMap.svelte.d.ts +7 -0
- package/dist/events/event-view/ExternalRsvpNotice.svelte +34 -0
- package/dist/events/event-view/ExternalRsvpNotice.svelte.d.ts +6 -0
- package/dist/events/event-view/StreamPlacePlayer.svelte +209 -0
- package/dist/events/event-view/StreamPlacePlayer.svelte.d.ts +8 -0
- package/dist/events/event-view/format.d.ts +10 -0
- package/dist/events/event-view/format.js +112 -0
- package/dist/events/event-view/locations.d.ts +36 -0
- package/dist/events/event-view/locations.js +243 -0
- package/dist/events/event-view/plyr-icons.d.ts +3 -0
- package/dist/events/event-view/plyr-icons.js +46 -0
- package/dist/events/linked-records.d.ts +18 -0
- package/dist/events/linked-records.js +11 -0
- package/dist/events/profile-url.d.ts +3 -0
- package/dist/events/profile-url.js +11 -0
- package/dist/events/schedule/ConferenceTimetable.svelte +340 -0
- package/dist/events/schedule/ConferenceTimetable.svelte.d.ts +22 -0
- package/dist/events/schedule/DaySchedule.svelte +298 -0
- package/dist/events/schedule/DaySchedule.svelte.d.ts +26 -0
- package/dist/events/schedule/ScheduleEventCell.svelte +166 -0
- package/dist/events/schedule/ScheduleEventCell.svelte.d.ts +25 -0
- package/dist/events/schedule/schedule-utils.d.ts +44 -0
- package/dist/events/schedule/schedule-utils.js +166 -0
- package/dist/events/starter-pack-url.d.ts +3 -0
- package/dist/events/starter-pack-url.js +16 -0
- package/dist/events/theme.d.ts +9 -0
- package/dist/events/theme.js +36 -0
- package/dist/events/themes/Blobs.svelte +64 -0
- package/dist/events/themes/Blobs.svelte.d.ts +18 -0
- package/dist/events/themes/Butterflies.svelte +183 -0
- package/dist/events/themes/Butterflies.svelte.d.ts +3 -0
- package/dist/events/themes/Fireflies.svelte +134 -0
- package/dist/events/themes/Fireflies.svelte.d.ts +3 -0
- package/dist/events/themes/Kaleidoscope.svelte +183 -0
- package/dist/events/themes/Kaleidoscope.svelte.d.ts +3 -0
- package/dist/events/themes/Matrix.svelte +153 -0
- package/dist/events/themes/Matrix.svelte.d.ts +3 -0
- package/dist/events/themes/Stars.svelte +98 -0
- package/dist/events/themes/Stars.svelte.d.ts +3 -0
- package/dist/events/thumbnails/designs.d.ts +18 -0
- package/dist/events/thumbnails/designs.js +316 -0
- package/dist/events/view-data.d.ts +38 -0
- package/dist/events/view-data.js +1 -0
- package/dist/events-data.d.ts +15 -0
- package/dist/events-data.js +9 -0
- package/dist/events-editor.d.ts +2 -0
- package/dist/events-editor.js +2 -0
- package/dist/events.d.ts +19 -0
- package/dist/events.js +17 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +2 -0
- package/package.json +50 -4
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
<script lang="ts" module>
|
|
2
|
+
export interface AttendeeCounts {
|
|
3
|
+
goingCount: number;
|
|
4
|
+
interestedCount: number;
|
|
5
|
+
}
|
|
6
|
+
</script>
|
|
7
|
+
|
|
8
|
+
<script lang="ts">
|
|
9
|
+
import type { Snippet } from 'svelte';
|
|
10
|
+
import type { AttendeeInfo } from './contrail.js';
|
|
11
|
+
import { Avatar } from '../index.js';
|
|
12
|
+
import { scale } from 'svelte/transition';
|
|
13
|
+
import { flip } from 'svelte/animate';
|
|
14
|
+
import { Modal } from '../index.js';
|
|
15
|
+
|
|
16
|
+
let {
|
|
17
|
+
going = [],
|
|
18
|
+
interested = [],
|
|
19
|
+
goingCount: initialGoingCount = going.length,
|
|
20
|
+
interestedCount: initialInterestedCount = interested.length,
|
|
21
|
+
footer
|
|
22
|
+
}: {
|
|
23
|
+
going?: AttendeeInfo[];
|
|
24
|
+
interested?: AttendeeInfo[];
|
|
25
|
+
goingCount?: number;
|
|
26
|
+
interestedCount?: number;
|
|
27
|
+
footer?: Snippet<[AttendeeCounts]>;
|
|
28
|
+
} = $props();
|
|
29
|
+
|
|
30
|
+
let goingCountOverride: number | null = $state(null);
|
|
31
|
+
let interestedCountOverride: number | null = $state(null);
|
|
32
|
+
let goingAttendeesOverride: AttendeeInfo[] | null = $state(null);
|
|
33
|
+
let interestedAttendeesOverride: AttendeeInfo[] | null = $state(null);
|
|
34
|
+
|
|
35
|
+
let modalOpen = $state(false);
|
|
36
|
+
let modalGroup: 'going' | 'interested' = $state('going');
|
|
37
|
+
|
|
38
|
+
const MAX_AVATARS = 18;
|
|
39
|
+
|
|
40
|
+
let goingCount = $derived(goingCountOverride ?? initialGoingCount);
|
|
41
|
+
let interestedCount = $derived(interestedCountOverride ?? initialInterestedCount);
|
|
42
|
+
let goingAttendees = $derived(goingAttendeesOverride ?? going);
|
|
43
|
+
let interestedAttendees = $derived(interestedAttendeesOverride ?? interested);
|
|
44
|
+
|
|
45
|
+
let totalCount = $derived(goingCount + interestedCount);
|
|
46
|
+
|
|
47
|
+
let goingDisplay = $derived(goingAttendees.slice(0, MAX_AVATARS));
|
|
48
|
+
let goingOverflow = $derived(goingCount - goingDisplay.length);
|
|
49
|
+
|
|
50
|
+
let interestedDisplay = $derived(interestedAttendees.slice(0, MAX_AVATARS));
|
|
51
|
+
let interestedOverflow = $derived(interestedCount - interestedDisplay.length);
|
|
52
|
+
|
|
53
|
+
let modalAttendees = $derived(modalGroup === 'going' ? goingAttendees : interestedAttendees);
|
|
54
|
+
let modalTitle = $derived(modalGroup === 'going' ? 'Going' : 'Interested');
|
|
55
|
+
|
|
56
|
+
function openModal(group: 'going' | 'interested') {
|
|
57
|
+
modalGroup = group;
|
|
58
|
+
modalOpen = true;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function addAttendee(attendee: AttendeeInfo) {
|
|
62
|
+
const wasGoing = goingAttendees.some((a) => a.did === attendee.did);
|
|
63
|
+
const wasInterested = interestedAttendees.some((a) => a.did === attendee.did);
|
|
64
|
+
const nextGoing = goingAttendees.filter((a) => a.did !== attendee.did);
|
|
65
|
+
const nextInterested = interestedAttendees.filter((a) => a.did !== attendee.did);
|
|
66
|
+
|
|
67
|
+
// Remove from both lists first (in case of status change)
|
|
68
|
+
if (attendee.status === 'going') {
|
|
69
|
+
goingAttendeesOverride = [attendee, ...nextGoing];
|
|
70
|
+
interestedAttendeesOverride = nextInterested;
|
|
71
|
+
goingCountOverride = goingCount + (wasGoing ? 0 : 1);
|
|
72
|
+
interestedCountOverride = interestedCount - (wasInterested ? 1 : 0);
|
|
73
|
+
} else if (attendee.status === 'interested') {
|
|
74
|
+
goingAttendeesOverride = nextGoing;
|
|
75
|
+
interestedAttendeesOverride = [attendee, ...nextInterested];
|
|
76
|
+
goingCountOverride = goingCount - (wasGoing ? 1 : 0);
|
|
77
|
+
interestedCountOverride = interestedCount + (wasInterested ? 0 : 1);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function thumbnail(url: string | undefined) {
|
|
82
|
+
return url?.replace('/avatar/', '/avatar_thumbnail/');
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function removeAttendee(did: string) {
|
|
86
|
+
const wasGoing = goingAttendees.some((a) => a.did === did);
|
|
87
|
+
const wasInterested = interestedAttendees.some((a) => a.did === did);
|
|
88
|
+
goingAttendeesOverride = goingAttendees.filter((a) => a.did !== did);
|
|
89
|
+
interestedAttendeesOverride = interestedAttendees.filter((a) => a.did !== did);
|
|
90
|
+
if (wasGoing) goingCountOverride = goingCount - 1;
|
|
91
|
+
if (wasInterested) interestedCountOverride = interestedCount - 1;
|
|
92
|
+
}
|
|
93
|
+
</script>
|
|
94
|
+
|
|
95
|
+
{#if totalCount > 0}
|
|
96
|
+
<div class="mb-2">
|
|
97
|
+
{#if goingCount > 0}
|
|
98
|
+
<button
|
|
99
|
+
type="button"
|
|
100
|
+
class="-mx-2 block w-full cursor-pointer rounded-xl px-2 py-2 text-left transition-colors hover:bg-base-100 dark:hover:bg-base-800/50"
|
|
101
|
+
onclick={() => openModal('going')}
|
|
102
|
+
>
|
|
103
|
+
<p class="mb-2 text-sm text-base-900 dark:text-base-50">
|
|
104
|
+
<span class="font-bold">{goingCount}</span>
|
|
105
|
+
<span class="text-xs font-semibold tracking-wider text-muted uppercase">Going</span>
|
|
106
|
+
</p>
|
|
107
|
+
<div class="flex items-center">
|
|
108
|
+
<div class="flex flex-wrap -space-y-2 -space-x-4 pr-4">
|
|
109
|
+
{#each goingDisplay as person (person.did)}
|
|
110
|
+
<div
|
|
111
|
+
animate:flip={{ duration: 300 }}
|
|
112
|
+
in:scale={{ duration: 300, start: 0.5 }}
|
|
113
|
+
out:scale={{ duration: 200, start: 0.5 }}
|
|
114
|
+
>
|
|
115
|
+
<Avatar
|
|
116
|
+
src={thumbnail(person.avatar)}
|
|
117
|
+
name={person.name}
|
|
118
|
+
class="size-12 border-2"
|
|
119
|
+
/>
|
|
120
|
+
</div>
|
|
121
|
+
{/each}
|
|
122
|
+
{#if goingOverflow > 0}
|
|
123
|
+
<span
|
|
124
|
+
class="z-10 inline-flex size-12 items-center justify-center rounded-full border-2 border-base-100 bg-base-200 text-sm font-semibold text-base-950 dark:border-base-800 dark:bg-base-800 dark:text-base-100"
|
|
125
|
+
>
|
|
126
|
+
+{goingOverflow}
|
|
127
|
+
</span>
|
|
128
|
+
{/if}
|
|
129
|
+
</div>
|
|
130
|
+
</div>
|
|
131
|
+
</button>
|
|
132
|
+
{/if}
|
|
133
|
+
|
|
134
|
+
{#if interestedCount > 0}
|
|
135
|
+
<button
|
|
136
|
+
type="button"
|
|
137
|
+
class="-mx-2 mt-4 block w-full cursor-pointer rounded-xl px-2 py-2 text-left transition-colors hover:bg-base-100 dark:hover:bg-base-800/50"
|
|
138
|
+
onclick={() => openModal('interested')}
|
|
139
|
+
>
|
|
140
|
+
<p class="mb-2 text-sm text-base-900 dark:text-base-50">
|
|
141
|
+
<span class="font-bold">{interestedCount}</span>
|
|
142
|
+
<span class="text-xs font-semibold tracking-wider text-muted uppercase">Interested</span>
|
|
143
|
+
</p>
|
|
144
|
+
<div class="flex items-center">
|
|
145
|
+
<div class="flex flex-wrap -space-y-2 -space-x-4 pr-4">
|
|
146
|
+
{#each interestedDisplay as person (person.did)}
|
|
147
|
+
<div
|
|
148
|
+
animate:flip={{ duration: 300 }}
|
|
149
|
+
in:scale={{ duration: 300, start: 0.5 }}
|
|
150
|
+
out:scale={{ duration: 200, start: 0.5 }}
|
|
151
|
+
>
|
|
152
|
+
<Avatar
|
|
153
|
+
src={thumbnail(person.avatar)}
|
|
154
|
+
name={person.name}
|
|
155
|
+
class="size-12 border-2"
|
|
156
|
+
/>
|
|
157
|
+
</div>
|
|
158
|
+
{/each}
|
|
159
|
+
{#if interestedOverflow > 0}
|
|
160
|
+
<span
|
|
161
|
+
class="z-10 inline-flex size-12 items-center justify-center rounded-full border-2 border-base-100 bg-base-200 text-sm font-semibold text-base-950 dark:border-base-800 dark:bg-base-800 dark:text-base-100"
|
|
162
|
+
>
|
|
163
|
+
+{interestedOverflow}
|
|
164
|
+
</span>
|
|
165
|
+
{/if}
|
|
166
|
+
</div>
|
|
167
|
+
</div>
|
|
168
|
+
</button>
|
|
169
|
+
{/if}
|
|
170
|
+
{@render footer?.({ goingCount, interestedCount })}
|
|
171
|
+
</div>
|
|
172
|
+
{/if}
|
|
173
|
+
|
|
174
|
+
<Modal bind:open={modalOpen} title={`${modalTitle} (${modalAttendees.length})`}>
|
|
175
|
+
<div
|
|
176
|
+
class="mx-4 mb-4 max-h-80 space-y-1 overflow-y-auto rounded-xl bg-base-200/30 p-2 dark:bg-base-900/50"
|
|
177
|
+
>
|
|
178
|
+
{#each modalAttendees as person (person.did)}
|
|
179
|
+
<a
|
|
180
|
+
href={person.url}
|
|
181
|
+
target={person.url?.startsWith('/') ? undefined : '_blank'}
|
|
182
|
+
rel={person.url?.startsWith('/') ? undefined : 'noopener noreferrer'}
|
|
183
|
+
class="flex items-center gap-3 rounded-xl px-2 py-2 transition-colors hover:bg-base-200 dark:hover:bg-base-900"
|
|
184
|
+
>
|
|
185
|
+
<Avatar src={thumbnail(person.avatar)} name={person.name} class="size-10 shrink-0" />
|
|
186
|
+
<div class="min-w-0">
|
|
187
|
+
<p class="truncate text-sm font-medium text-base-900 dark:text-base-50">
|
|
188
|
+
{person.name}
|
|
189
|
+
</p>
|
|
190
|
+
{#if person.handle}
|
|
191
|
+
<p class="truncate text-xs text-muted">
|
|
192
|
+
@{person.handle}
|
|
193
|
+
</p>
|
|
194
|
+
{/if}
|
|
195
|
+
</div>
|
|
196
|
+
</a>
|
|
197
|
+
{/each}
|
|
198
|
+
</div>
|
|
199
|
+
</Modal>
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export interface AttendeeCounts {
|
|
2
|
+
goingCount: number;
|
|
3
|
+
interestedCount: number;
|
|
4
|
+
}
|
|
5
|
+
import type { Snippet } from 'svelte';
|
|
6
|
+
import type { AttendeeInfo } from './contrail.js';
|
|
7
|
+
type $$ComponentProps = {
|
|
8
|
+
going?: AttendeeInfo[];
|
|
9
|
+
interested?: AttendeeInfo[];
|
|
10
|
+
goingCount?: number;
|
|
11
|
+
interestedCount?: number;
|
|
12
|
+
footer?: Snippet<[AttendeeCounts]>;
|
|
13
|
+
};
|
|
14
|
+
declare const EventAttendees: import("svelte").Component<$$ComponentProps, {
|
|
15
|
+
addAttendee: (attendee: AttendeeInfo) => void;
|
|
16
|
+
removeAttendee: (did: string) => void;
|
|
17
|
+
}, "">;
|
|
18
|
+
type EventAttendees = ReturnType<typeof EventAttendees>;
|
|
19
|
+
export default EventAttendees;
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { getCDNImageBlobUrl } from './atproto-helpers.js';
|
|
3
|
+
import { getEventThumbnail } from './event-media.js';
|
|
4
|
+
import { eventUrl, isEventOngoing, type FlatEventRecord } from './contrail.js';
|
|
5
|
+
import Avatar from 'svelte-boring-avatars';
|
|
6
|
+
import { Badge, Icon } from '../index.js';
|
|
7
|
+
import { CalendarDays, MapPin } from '@sketchyicons/svelte';
|
|
8
|
+
import { getLocations, getLocationSummary } from './event-view/locations.js';
|
|
9
|
+
|
|
10
|
+
let {
|
|
11
|
+
event,
|
|
12
|
+
actor
|
|
13
|
+
}: {
|
|
14
|
+
event: FlatEventRecord;
|
|
15
|
+
actor?: string;
|
|
16
|
+
} = $props();
|
|
17
|
+
|
|
18
|
+
function formatDateTime(dateStr: string): string {
|
|
19
|
+
return new Date(dateStr).toLocaleDateString('en-US', {
|
|
20
|
+
weekday: 'short',
|
|
21
|
+
month: 'short',
|
|
22
|
+
day: 'numeric',
|
|
23
|
+
hour: 'numeric',
|
|
24
|
+
minute: '2-digit'
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* What a card says about an event ALREADY UNDER WAY: when it finishes, not when
|
|
30
|
+
* it began. A "Live" badge beside "Wed, Jul 1" reads as a bug, and in a list
|
|
31
|
+
* sorted by finishing time the start dates look shuffled. Day and weekday are
|
|
32
|
+
* dropped when it ends today, since the only thing a reader wants then is how
|
|
33
|
+
* long they have left.
|
|
34
|
+
*/
|
|
35
|
+
function formatEndsAt(endsAt: string): string {
|
|
36
|
+
const end = new Date(endsAt);
|
|
37
|
+
const now = new Date();
|
|
38
|
+
const endsToday = end.toDateString() === now.toDateString();
|
|
39
|
+
// Carry the YEAR when it is not this one. Long-running events are exactly
|
|
40
|
+
// what this band surfaces — a year-long journey ending 2027-07-04 rendered
|
|
41
|
+
// as "Ends Sun, Jul 4", which reads as a date three days ago.
|
|
42
|
+
const endsThisYear = end.getFullYear() === now.getFullYear();
|
|
43
|
+
return end.toLocaleTimeString('en-US', {
|
|
44
|
+
...(endsToday
|
|
45
|
+
? {}
|
|
46
|
+
: {
|
|
47
|
+
weekday: 'short',
|
|
48
|
+
month: 'short',
|
|
49
|
+
day: 'numeric',
|
|
50
|
+
...(endsThisYear ? {} : { year: 'numeric' })
|
|
51
|
+
}),
|
|
52
|
+
hour: 'numeric',
|
|
53
|
+
minute: '2-digit'
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function getModeLabel(mode: string | undefined): string | undefined {
|
|
58
|
+
if (!mode) return undefined;
|
|
59
|
+
if (mode.includes('virtual')) return 'Virtual';
|
|
60
|
+
if (mode.includes('hybrid')) return 'Hybrid';
|
|
61
|
+
if (mode.includes('inperson')) return 'In-Person';
|
|
62
|
+
return 'Event';
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function getThumbnail(event: FlatEventRecord): { url: string; alt: string } | null {
|
|
66
|
+
const media = getEventThumbnail(event.media);
|
|
67
|
+
if (media?.content) {
|
|
68
|
+
const url = getCDNImageBlobUrl({ did: event.did, blob: media.content });
|
|
69
|
+
if (url) return { url, alt: media.alt || event.name };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const banner = event.media?.find((m) => m.role === 'header');
|
|
73
|
+
if (banner?.content) {
|
|
74
|
+
const url = getCDNImageBlobUrl({ did: event.did, blob: banner.content });
|
|
75
|
+
if (url) return { url, alt: banner.alt || event.name };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
let thumbnail = $derived(getThumbnail(event));
|
|
82
|
+
let location = $derived(getLocationSummary(getLocations(event.locations)));
|
|
83
|
+
let mode = $derived(getModeLabel(event.mode));
|
|
84
|
+
let isOngoing = $derived(isEventOngoing(event.startsAt, event.endsAt));
|
|
85
|
+
</script>
|
|
86
|
+
|
|
87
|
+
<a
|
|
88
|
+
href={eventUrl(event, actor)}
|
|
89
|
+
class="group grid grid-cols-[5rem_minmax(0,1fr)] gap-3 rounded-ui outline-offset-4 transition-colors focus-visible:outline-2 focus-visible:outline-focus sm:grid-cols-[6rem_minmax(0,1fr)] sm:gap-3.5"
|
|
90
|
+
>
|
|
91
|
+
<div class="w-full">
|
|
92
|
+
{#if thumbnail}
|
|
93
|
+
<img
|
|
94
|
+
src={thumbnail.url}
|
|
95
|
+
alt={thumbnail.alt}
|
|
96
|
+
loading="lazy"
|
|
97
|
+
class="block aspect-square w-full rounded-[14px_12px_15px_13px] border border-line object-cover"
|
|
98
|
+
/>
|
|
99
|
+
{:else}
|
|
100
|
+
<div
|
|
101
|
+
class="aspect-square w-full overflow-hidden rounded-[14px_12px_15px_13px] border border-line [&>svg]:h-full [&>svg]:w-full"
|
|
102
|
+
>
|
|
103
|
+
<Avatar
|
|
104
|
+
size={96}
|
|
105
|
+
name={event.rkey}
|
|
106
|
+
variant="marble"
|
|
107
|
+
colors={['#92A1C6', '#146A7C', '#F0AB3D', '#C271B4', '#C20D90']}
|
|
108
|
+
square
|
|
109
|
+
/>
|
|
110
|
+
</div>
|
|
111
|
+
{/if}
|
|
112
|
+
</div>
|
|
113
|
+
|
|
114
|
+
<div class="min-w-0 self-center">
|
|
115
|
+
<p class="flex items-center gap-1.5 text-xs font-medium text-muted">
|
|
116
|
+
<Icon img={CalendarDays} size={14} class="shrink-0" />
|
|
117
|
+
{#if isOngoing && event.endsAt}
|
|
118
|
+
Ends {formatEndsAt(event.endsAt)}
|
|
119
|
+
{:else}
|
|
120
|
+
{formatDateTime(event.startsAt)}
|
|
121
|
+
{/if}
|
|
122
|
+
{#if isOngoing}
|
|
123
|
+
<Badge class="gap-1 px-1.5 py-0 text-[10px]">
|
|
124
|
+
<span class="size-1.5 animate-pulse rounded-full bg-accent-500"></span>
|
|
125
|
+
Live
|
|
126
|
+
</Badge>
|
|
127
|
+
{/if}
|
|
128
|
+
</p>
|
|
129
|
+
<h3
|
|
130
|
+
class="mt-0.5 flex items-start gap-1.5 text-sm leading-snug font-semibold text-base-900 transition-colors group-hover:text-base-700 sm:text-base dark:text-base-50 dark:group-hover:text-base-200"
|
|
131
|
+
>
|
|
132
|
+
<span class="line-clamp-2">{event.name}</span>
|
|
133
|
+
</h3>
|
|
134
|
+
{#if location || mode}
|
|
135
|
+
<p class="mt-1 flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-muted">
|
|
136
|
+
{#if location}
|
|
137
|
+
<span class="inline-flex max-w-full min-w-0 items-center gap-1.5" title={location}>
|
|
138
|
+
<Icon img={MapPin} size={14} class="shrink-0" />
|
|
139
|
+
<span class="truncate">{location}</span>
|
|
140
|
+
</span>
|
|
141
|
+
{/if}
|
|
142
|
+
{#if mode}
|
|
143
|
+
<Badge variant="outline" class="shrink-0 px-1.5 py-0 text-[10px]">{mode}</Badge>
|
|
144
|
+
{/if}
|
|
145
|
+
</p>
|
|
146
|
+
{/if}
|
|
147
|
+
</div>
|
|
148
|
+
</a>
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { type FlatEventRecord } from './contrail.js';
|
|
2
|
+
type $$ComponentProps = {
|
|
3
|
+
event: FlatEventRecord;
|
|
4
|
+
actor?: string;
|
|
5
|
+
};
|
|
6
|
+
declare const EventCard: import("svelte").Component<$$ComponentProps, {}, "">;
|
|
7
|
+
type EventCard = ReturnType<typeof EventCard>;
|
|
8
|
+
export default EventCard;
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { NestedComments, blueskyPostToPostData, type PostData } from '../index.js';
|
|
3
|
+
|
|
4
|
+
import { socialClientInfo, socialPostUrl, type PreferredSocialClient } from '../social.js';
|
|
5
|
+
|
|
6
|
+
let {
|
|
7
|
+
postUri,
|
|
8
|
+
socialClient
|
|
9
|
+
}: {
|
|
10
|
+
postUri: string;
|
|
11
|
+
socialClient?: PreferredSocialClient | null;
|
|
12
|
+
} = $props();
|
|
13
|
+
|
|
14
|
+
let comments = $state<PostData[]>([]);
|
|
15
|
+
let loading = $state(true);
|
|
16
|
+
let errorMessage = $state<string | null>(null);
|
|
17
|
+
|
|
18
|
+
function threadToComments(replies: unknown[]): PostData[] {
|
|
19
|
+
return (replies as Array<{ $type?: string; post?: unknown; replies?: unknown[] }>)
|
|
20
|
+
.filter((r) => r.$type === 'app.bsky.feed.defs#threadViewPost' && r.post)
|
|
21
|
+
.map((r) => {
|
|
22
|
+
const postData = blueskyPostToPostData(
|
|
23
|
+
r.post as Parameters<typeof blueskyPostToPostData>[0]
|
|
24
|
+
);
|
|
25
|
+
if (r.replies?.length) {
|
|
26
|
+
postData.replies = threadToComments(r.replies);
|
|
27
|
+
}
|
|
28
|
+
return postData;
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async function loadThread(uri: string, signal: AbortSignal) {
|
|
33
|
+
loading = true;
|
|
34
|
+
errorMessage = null;
|
|
35
|
+
try {
|
|
36
|
+
const res = await fetch(
|
|
37
|
+
`https://public.api.bsky.app/xrpc/app.bsky.feed.getPostThread?uri=${encodeURIComponent(uri)}&depth=6`,
|
|
38
|
+
{ signal }
|
|
39
|
+
);
|
|
40
|
+
if (!res.ok) throw new Error(`Failed to load thread (${res.status})`);
|
|
41
|
+
const data = (await res.json()) as { thread?: { replies?: unknown[] } };
|
|
42
|
+
const thread = data.thread;
|
|
43
|
+
if (!signal.aborted)
|
|
44
|
+
comments = thread?.replies?.length ? threadToComments(thread.replies) : [];
|
|
45
|
+
} catch (err) {
|
|
46
|
+
if (signal.aborted) return;
|
|
47
|
+
console.error('EventComments: load failed', err);
|
|
48
|
+
errorMessage = err instanceof Error ? err.message : 'Failed to load comments';
|
|
49
|
+
comments = [];
|
|
50
|
+
} finally {
|
|
51
|
+
if (!signal.aborted) loading = false;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Only a new thread URI should refetch; changing the viewer's client just changes links.
|
|
56
|
+
let threadUri = $derived(postUri);
|
|
57
|
+
$effect(() => {
|
|
58
|
+
const controller = new AbortController();
|
|
59
|
+
if (threadUri) loadThread(threadUri, controller.signal);
|
|
60
|
+
return () => controller.abort();
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
let replyUrl = $derived(socialPostUrl(postUri, socialClient));
|
|
64
|
+
let clientLabel = $derived(socialClientInfo(socialClient).label);
|
|
65
|
+
</script>
|
|
66
|
+
|
|
67
|
+
<div>
|
|
68
|
+
{#if replyUrl}
|
|
69
|
+
<div class="mb-4 text-sm">
|
|
70
|
+
<a
|
|
71
|
+
href={replyUrl}
|
|
72
|
+
target="_blank"
|
|
73
|
+
rel="noopener noreferrer"
|
|
74
|
+
class="font-medium text-accent-600 hover:text-accent-700 dark:text-accent-400 dark:hover:text-accent-300"
|
|
75
|
+
>
|
|
76
|
+
Add a comment on {clientLabel} →
|
|
77
|
+
</a>
|
|
78
|
+
</div>
|
|
79
|
+
{/if}
|
|
80
|
+
|
|
81
|
+
{#if loading}
|
|
82
|
+
<p class="text-sm text-muted">Loading comments…</p>
|
|
83
|
+
{:else if errorMessage}
|
|
84
|
+
<p class="text-sm text-muted">{errorMessage}</p>
|
|
85
|
+
{:else if comments.length === 0}
|
|
86
|
+
<p class="text-sm text-muted">No comments yet.</p>
|
|
87
|
+
{:else}
|
|
88
|
+
<div class="not-prose comments-no-divider">
|
|
89
|
+
<NestedComments {comments} {socialClient} />
|
|
90
|
+
</div>
|
|
91
|
+
{/if}
|
|
92
|
+
</div>
|
|
93
|
+
|
|
94
|
+
<style>
|
|
95
|
+
.comments-no-divider :global(> div) {
|
|
96
|
+
border-top-width: 0;
|
|
97
|
+
}
|
|
98
|
+
</style>
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { type PreferredSocialClient } from '../social.js';
|
|
2
|
+
type $$ComponentProps = {
|
|
3
|
+
postUri: string;
|
|
4
|
+
socialClient?: PreferredSocialClient | null;
|
|
5
|
+
};
|
|
6
|
+
declare const EventComments: import("svelte").Component<$$ComponentProps, {}, "">;
|
|
7
|
+
type EventComments = ReturnType<typeof EventComments>;
|
|
8
|
+
export default EventComments;
|