@atmo-dev/kit 0.3.0 → 0.5.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 +94 -1
- package/dist/components/ExternalEmbed.svelte +174 -0
- package/dist/components/ExternalEmbed.svelte.d.ts +8 -0
- package/dist/components/GrainAlbumPicker.svelte +218 -0
- package/dist/components/GrainAlbumPicker.svelte.d.ts +14 -0
- package/dist/components/GrainAlbumView.svelte +94 -0
- package/dist/components/GrainAlbumView.svelte.d.ts +10 -0
- package/dist/components/NestedComments.svelte +20 -10
- package/dist/components/NestedComments.svelte.d.ts +2 -0
- package/dist/components/PhotoGrid.svelte +53 -0
- package/dist/components/PhotoGrid.svelte.d.ts +9 -0
- package/dist/components/PhotoLightbox.svelte +332 -0
- package/dist/components/PhotoLightbox.svelte.d.ts +12 -0
- package/dist/components/PostContent.svelte +78 -20
- package/dist/components/PostContent.svelte.d.ts +5 -0
- package/dist/components/StreamplacePicker.svelte +94 -0
- package/dist/components/StreamplacePicker.svelte.d.ts +12 -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/grain/types.d.ts +41 -0
- package/dist/grain/types.js +1 -0
- package/dist/grain/urls.d.ts +7 -0
- package/dist/grain/urls.js +74 -0
- package/dist/grain/xrpc.d.ts +3 -0
- package/dist/grain/xrpc.js +129 -0
- package/dist/grain.d.ts +3 -0
- package/dist/grain.js +2 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.js +9 -0
- package/dist/media.d.ts +10 -0
- package/dist/media.js +1 -0
- package/dist/social/external-embeds.d.ts +39 -0
- package/dist/social/external-embeds.js +107 -0
- package/dist/social/posts.d.ts +59 -17
- package/dist/social/posts.js +99 -12
- package/dist/social.d.ts +2 -0
- package/dist/social.js +1 -0
- package/dist/streamplace.d.ts +6 -0
- package/dist/streamplace.js +1 -0
- package/package.json +58 -4
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
function text(record, key) {
|
|
2
|
+
const value = record[key];
|
|
3
|
+
return typeof value === 'string' ? value.trim() || undefined : undefined;
|
|
4
|
+
}
|
|
5
|
+
function coordinates(record) {
|
|
6
|
+
const latitude = text(record, 'latitude');
|
|
7
|
+
const longitude = text(record, 'longitude');
|
|
8
|
+
const decimal = /^[+-]?(?:\d+(?:\.\d*)?|\.\d+)$/;
|
|
9
|
+
if (!latitude || !longitude || !decimal.test(latitude) || !decimal.test(longitude))
|
|
10
|
+
return;
|
|
11
|
+
const lat = Number(latitude);
|
|
12
|
+
const lng = Number(longitude);
|
|
13
|
+
if (!Number.isFinite(lat) || !Number.isFinite(lng) || Math.abs(lat) > 90 || Math.abs(lng) > 180)
|
|
14
|
+
return;
|
|
15
|
+
return { lat, lng };
|
|
16
|
+
}
|
|
17
|
+
function coordinateLabel({ lat, lng }) {
|
|
18
|
+
return `${lat.toFixed(5)}, ${lng.toFixed(5)}`;
|
|
19
|
+
}
|
|
20
|
+
function mapsUrl(query) {
|
|
21
|
+
return `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(query)}`;
|
|
22
|
+
}
|
|
23
|
+
function webUrl(uri) {
|
|
24
|
+
try {
|
|
25
|
+
const url = new URL(uri);
|
|
26
|
+
if (url.protocol === 'https:' || url.protocol === 'http:')
|
|
27
|
+
return url.href;
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
// Invalid URLs render as non-interactive location text.
|
|
31
|
+
}
|
|
32
|
+
return undefined;
|
|
33
|
+
}
|
|
34
|
+
function formatLocation(value) {
|
|
35
|
+
if (!value || typeof value !== 'object')
|
|
36
|
+
return null;
|
|
37
|
+
const record = value;
|
|
38
|
+
const name = text(record, 'name');
|
|
39
|
+
switch (record.$type) {
|
|
40
|
+
case 'community.lexicon.location.address': {
|
|
41
|
+
const locality = text(record, 'locality');
|
|
42
|
+
const street = text(record, 'street');
|
|
43
|
+
const address = [
|
|
44
|
+
street,
|
|
45
|
+
locality,
|
|
46
|
+
text(record, 'region'),
|
|
47
|
+
text(record, 'postalCode'),
|
|
48
|
+
text(record, 'country')
|
|
49
|
+
]
|
|
50
|
+
.filter(Boolean)
|
|
51
|
+
.join(', ');
|
|
52
|
+
const label = name ?? address;
|
|
53
|
+
if (!label)
|
|
54
|
+
return null;
|
|
55
|
+
const fullString = name && address && name !== address ? `${name}, ${address}` : label;
|
|
56
|
+
return {
|
|
57
|
+
kind: 'address',
|
|
58
|
+
name,
|
|
59
|
+
label,
|
|
60
|
+
detail: name && address !== name ? address || undefined : undefined,
|
|
61
|
+
href: mapsUrl(fullString),
|
|
62
|
+
summary: locality ?? name ?? street,
|
|
63
|
+
geocodeQuery: fullString
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
case 'community.lexicon.location.geo': {
|
|
67
|
+
const point = coordinates(record);
|
|
68
|
+
if (!point)
|
|
69
|
+
return null;
|
|
70
|
+
const coords = coordinateLabel(point);
|
|
71
|
+
return {
|
|
72
|
+
kind: 'geo',
|
|
73
|
+
name,
|
|
74
|
+
label: name ?? coords,
|
|
75
|
+
detail: name ? coords : undefined,
|
|
76
|
+
href: mapsUrl(`${point.lat},${point.lng}`),
|
|
77
|
+
summary: name ?? coords,
|
|
78
|
+
coordinates: point
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
case 'community.lexicon.location.fsq': {
|
|
82
|
+
const id = text(record, 'fsq_place_id');
|
|
83
|
+
if (!id)
|
|
84
|
+
return null;
|
|
85
|
+
return {
|
|
86
|
+
kind: 'fsq',
|
|
87
|
+
name,
|
|
88
|
+
label: name ?? `Foursquare: ${id}`,
|
|
89
|
+
href: `https://foursquare.com/v/${encodeURIComponent(id)}`,
|
|
90
|
+
summary: name ?? `Foursquare: ${id}`,
|
|
91
|
+
coordinates: coordinates(record)
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
case 'community.lexicon.location.hthree': {
|
|
95
|
+
const cell = text(record, 'value');
|
|
96
|
+
if (!cell)
|
|
97
|
+
return null;
|
|
98
|
+
return {
|
|
99
|
+
kind: 'hthree',
|
|
100
|
+
name,
|
|
101
|
+
label: name ?? `H3: ${cell}`,
|
|
102
|
+
detail: name ? `H3: ${cell}` : undefined,
|
|
103
|
+
summary: name ?? `H3: ${cell}`,
|
|
104
|
+
h3Cell: cell
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
case 'community.lexicon.calendar.event#uri': {
|
|
108
|
+
const uri = text(record, 'uri');
|
|
109
|
+
if (!uri)
|
|
110
|
+
return null;
|
|
111
|
+
// AT references describe a place, not a user-facing link. Without a
|
|
112
|
+
// supplied name there is nothing useful to show until it is resolved.
|
|
113
|
+
if (uri.startsWith('at://')) {
|
|
114
|
+
return name ? { kind: 'place', name, label: name, summary: name } : null;
|
|
115
|
+
}
|
|
116
|
+
return {
|
|
117
|
+
kind: 'uri',
|
|
118
|
+
label: name ?? uri,
|
|
119
|
+
detail: name && name !== uri ? uri : undefined,
|
|
120
|
+
href: webUrl(uri),
|
|
121
|
+
summary: name ?? uri
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
default:
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
/** Preserve record order and supported variants, keeping only the first entry for each URI. */
|
|
129
|
+
export function getLocations(locations) {
|
|
130
|
+
if (!Array.isArray(locations))
|
|
131
|
+
return [];
|
|
132
|
+
const seenUris = new Set();
|
|
133
|
+
return locations.map(formatLocation).filter((location) => {
|
|
134
|
+
if (!location)
|
|
135
|
+
return false;
|
|
136
|
+
if (location.kind !== 'uri')
|
|
137
|
+
return true;
|
|
138
|
+
const uri = location.href ?? location.detail ?? location.label;
|
|
139
|
+
if (seenUris.has(uri))
|
|
140
|
+
return false;
|
|
141
|
+
seenUris.add(uri);
|
|
142
|
+
return true;
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
/** One pin for the place: venue names above addresses, coordinates only as a fallback. */
|
|
146
|
+
export function getPhysicalLocationLines(locations) {
|
|
147
|
+
const physical = locations.filter((location) => location.kind !== 'uri');
|
|
148
|
+
const names = physical.filter((location) => location.name);
|
|
149
|
+
const mapHref = physical.find((location) => location.kind === 'address')?.href ??
|
|
150
|
+
physical.find((location) => location.coordinates)?.href;
|
|
151
|
+
const lines = names.map((location) => ({
|
|
152
|
+
label: location.name,
|
|
153
|
+
href: location.href ?? (location.kind === 'place' ? mapHref : undefined),
|
|
154
|
+
muted: false
|
|
155
|
+
}));
|
|
156
|
+
const hasDescription = names.length > 0 ||
|
|
157
|
+
locations.some((location) => location.kind !== 'geo' && location.kind !== 'hthree');
|
|
158
|
+
const hasAddress = physical.some((location) => location.kind === 'address');
|
|
159
|
+
for (const location of physical) {
|
|
160
|
+
if (location.kind === 'address') {
|
|
161
|
+
const label = location.name ? location.detail : location.label;
|
|
162
|
+
if (label)
|
|
163
|
+
lines.push({ label, href: location.href, muted: names.length > 0 });
|
|
164
|
+
}
|
|
165
|
+
else if (location.kind === 'fsq' && !location.name && !hasAddress) {
|
|
166
|
+
lines.push({ label: location.label, href: location.href, muted: false });
|
|
167
|
+
}
|
|
168
|
+
else if (!hasDescription && (location.kind === 'geo' || location.kind === 'hthree')) {
|
|
169
|
+
lines.push({ label: location.label, href: location.href, muted: false });
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
return lines.filter((line, index) => lines.findIndex((other) => other.label === line.label) === index);
|
|
173
|
+
}
|
|
174
|
+
/** Cards stay compact: prefer cities, falling back to the other physical variants. */
|
|
175
|
+
export function getLocationSummary(locations) {
|
|
176
|
+
const physical = locations.filter((location) => location.kind !== 'uri' && location.summary);
|
|
177
|
+
const cities = physical.filter((location) => location.kind === 'address');
|
|
178
|
+
const links = locations.filter((location) => location.kind === 'uri');
|
|
179
|
+
const described = physical.filter((location) => location.name || location.kind === 'fsq');
|
|
180
|
+
const preferred = cities.length
|
|
181
|
+
? cities
|
|
182
|
+
: described.length
|
|
183
|
+
? described.slice(0, 1)
|
|
184
|
+
: links.length
|
|
185
|
+
? []
|
|
186
|
+
: physical.slice(0, 1);
|
|
187
|
+
const labels = [...preferred, ...links].map((location) => location.summary);
|
|
188
|
+
return [...new Set(labels)].filter(Boolean).join(' · ') || undefined;
|
|
189
|
+
}
|
|
190
|
+
function geoLocation(point, label, osmType, osmId) {
|
|
191
|
+
const { lat, lng } = point;
|
|
192
|
+
return {
|
|
193
|
+
...point,
|
|
194
|
+
label,
|
|
195
|
+
googleMapsUrl: mapsUrl(`${lat},${lng}`),
|
|
196
|
+
osmUrl: (osmType === 'node' || osmType === 'way' || osmType === 'relation') && osmId
|
|
197
|
+
? `https://www.openstreetmap.org/${osmType}/${osmId}`
|
|
198
|
+
: `https://www.openstreetmap.org/?mlat=${lat}&mlon=${lng}#map=17/${lat}/${lng}`
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
/** A single overview map: prefer explicit coordinates; otherwise geocode the first address. */
|
|
202
|
+
export async function resolveGeoLocation(locations) {
|
|
203
|
+
const explicit = locations.find((location) => location.kind === 'geo' && location.coordinates) ??
|
|
204
|
+
locations.find((location) => location.coordinates);
|
|
205
|
+
if (explicit?.coordinates)
|
|
206
|
+
return geoLocation(explicit.coordinates, explicit.label);
|
|
207
|
+
const cells = locations.filter((location) => location.h3Cell);
|
|
208
|
+
if (cells.length) {
|
|
209
|
+
// H3 decoding is only loaded for events that actually use it, never for cards.
|
|
210
|
+
try {
|
|
211
|
+
const { isValidCell, cellToLatLng } = await import('h3-js');
|
|
212
|
+
for (const location of cells) {
|
|
213
|
+
if (!isValidCell(location.h3Cell))
|
|
214
|
+
continue;
|
|
215
|
+
const [lat, lng] = cellToLatLng(location.h3Cell);
|
|
216
|
+
return geoLocation({ lat, lng }, `${location.label} · Approximate area`);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
catch {
|
|
220
|
+
// Fall back to a geocodable address when H3 decoding is unavailable.
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
const address = locations.find((location) => location.geocodeQuery);
|
|
224
|
+
if (!address?.geocodeQuery)
|
|
225
|
+
return null;
|
|
226
|
+
try {
|
|
227
|
+
const response = await fetch(`/api/geocoding?q=${encodeURIComponent(address.geocodeQuery)}`);
|
|
228
|
+
if (!response.ok)
|
|
229
|
+
return null;
|
|
230
|
+
const data = (await response.json());
|
|
231
|
+
if (typeof data?.lat !== 'number' ||
|
|
232
|
+
typeof data.lng !== 'number' ||
|
|
233
|
+
!Number.isFinite(data.lat) ||
|
|
234
|
+
!Number.isFinite(data.lng) ||
|
|
235
|
+
Math.abs(data.lat) > 90 ||
|
|
236
|
+
Math.abs(data.lng) > 180)
|
|
237
|
+
return null;
|
|
238
|
+
return geoLocation({ lat: data.lat, lng: data.lng }, address.geocodeQuery, data.osmType, data.osmId);
|
|
239
|
+
}
|
|
240
|
+
catch {
|
|
241
|
+
return null;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { Captions, CaptionsOff, Maximize, Minimize, Pause, Play, Settings, Volume2, VolumeX } from '@sketchyicons/svelte';
|
|
2
|
+
const prefix = 'atmo-sketchy-plyr';
|
|
3
|
+
const icons = {
|
|
4
|
+
play: Play,
|
|
5
|
+
pause: Pause,
|
|
6
|
+
volume: Volume2,
|
|
7
|
+
muted: VolumeX,
|
|
8
|
+
'captions-on': Captions,
|
|
9
|
+
'captions-off': CaptionsOff,
|
|
10
|
+
'enter-fullscreen': Maximize,
|
|
11
|
+
'exit-fullscreen': Minimize,
|
|
12
|
+
settings: Settings
|
|
13
|
+
};
|
|
14
|
+
/** Called in the browser before constructing either video player. */
|
|
15
|
+
export function sketchyPlyrIcons() {
|
|
16
|
+
// One local sprite serves every player, without fetching Plyr's stock CDN icons.
|
|
17
|
+
if (!document.getElementById(prefix)) {
|
|
18
|
+
const ns = 'http://www.w3.org/2000/svg';
|
|
19
|
+
const sprite = document.createElementNS(ns, 'svg');
|
|
20
|
+
sprite.id = prefix;
|
|
21
|
+
sprite.setAttribute('width', '0');
|
|
22
|
+
sprite.setAttribute('height', '0');
|
|
23
|
+
sprite.setAttribute('aria-hidden', 'true');
|
|
24
|
+
sprite.setAttribute('focusable', 'false');
|
|
25
|
+
sprite.style.position = 'absolute';
|
|
26
|
+
for (const [name, nodes] of Object.entries(icons)) {
|
|
27
|
+
const symbol = document.createElementNS(ns, 'symbol');
|
|
28
|
+
symbol.id = `${prefix}-${name}`;
|
|
29
|
+
symbol.setAttribute('viewBox', '0 0 24 24');
|
|
30
|
+
symbol.setAttribute('stroke', 'currentColor');
|
|
31
|
+
symbol.setAttribute('stroke-width', '1.75');
|
|
32
|
+
symbol.setAttribute('stroke-linecap', 'round');
|
|
33
|
+
symbol.setAttribute('stroke-linejoin', 'round');
|
|
34
|
+
for (const [, attributes] of nodes) {
|
|
35
|
+
const path = document.createElementNS(ns, 'path');
|
|
36
|
+
path.setAttribute('d', String(attributes.d));
|
|
37
|
+
path.setAttribute('fill', String(attributes.fill ?? 'none'));
|
|
38
|
+
symbol.appendChild(path);
|
|
39
|
+
}
|
|
40
|
+
sprite.appendChild(symbol);
|
|
41
|
+
}
|
|
42
|
+
document.body.appendChild(sprite);
|
|
43
|
+
}
|
|
44
|
+
// An empty iconUrl makes Plyr reference the local symbols by fragment only.
|
|
45
|
+
return { iconPrefix: prefix, iconUrl: '', loadSprite: false };
|
|
46
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { type GrainClient } from '../grain.js';
|
|
2
|
+
export { parseGrainLink, isLinkedActor } from '../grain.js';
|
|
3
|
+
export { parseStreamplaceLink } from '../streamplace.js';
|
|
4
|
+
export type { GrainReference, GrainAlbum, GrainPhoto, GrainGallery, GrainAlbumPage } from '../grain.js';
|
|
5
|
+
export type EventLink = {
|
|
6
|
+
uri: string;
|
|
7
|
+
name?: string;
|
|
8
|
+
};
|
|
9
|
+
export type LinkedRecordsClient = {
|
|
10
|
+
getGrainGallery: GrainClient['getGallery'];
|
|
11
|
+
listGrainAlbums: GrainClient['listAlbums'];
|
|
12
|
+
};
|
|
13
|
+
/** Recognize event stream URLs, not arbitrary bare handles in `uris`. */
|
|
14
|
+
export declare function eventStreamLink(uri: string): {
|
|
15
|
+
actor: string;
|
|
16
|
+
url: string;
|
|
17
|
+
} | null;
|
|
18
|
+
export declare function isLinkedRecord(link: EventLink): boolean;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { parseGrainLink } from '../grain.js';
|
|
2
|
+
import { parseStreamplaceLink } from '../streamplace.js';
|
|
3
|
+
export { parseGrainLink, isLinkedActor } from '../grain.js';
|
|
4
|
+
export { parseStreamplaceLink } from '../streamplace.js';
|
|
5
|
+
/** Recognize event stream URLs, not arbitrary bare handles in `uris`. */
|
|
6
|
+
export function eventStreamLink(uri) {
|
|
7
|
+
return /^https?:\/\//i.test(uri) ? parseStreamplaceLink(uri) : null;
|
|
8
|
+
}
|
|
9
|
+
export function isLinkedRecord(link) {
|
|
10
|
+
return !!parseGrainLink(link.uri) || !!eventStreamLink(link.uri);
|
|
11
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/** Profile routes shared by the RSVP app and its event components. */
|
|
2
|
+
export function getLocalProfileUrl(handleOrDid) {
|
|
3
|
+
return `/p/${encodeURIComponent(handleOrDid)}`;
|
|
4
|
+
}
|
|
5
|
+
export function getProfileUrl(handleOrDid) {
|
|
6
|
+
const lower = handleOrDid.toLowerCase();
|
|
7
|
+
if (lower.endsWith('.blacksky.team') || lower.endsWith('.blacksky.app')) {
|
|
8
|
+
return `https://blacksky.community/profile/${handleOrDid}`;
|
|
9
|
+
}
|
|
10
|
+
return `https://bsky.app/profile/${handleOrDid}`;
|
|
11
|
+
}
|
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { ArrowDown, Check, Star } from '@sketchyicons/svelte';
|
|
3
|
+
import { Button, Icon, ToggleGroup, ToggleGroupItem } from '../../index.js';
|
|
4
|
+
import { onMount } from 'svelte';
|
|
5
|
+
import DaySchedule from './DaySchedule.svelte';
|
|
6
|
+
import ScheduleEventCell from './ScheduleEventCell.svelte';
|
|
7
|
+
import {
|
|
8
|
+
type ScheduleEvent,
|
|
9
|
+
type GridEvent,
|
|
10
|
+
getDayKey,
|
|
11
|
+
getDayLabel,
|
|
12
|
+
getDayShortLabel,
|
|
13
|
+
getRooms,
|
|
14
|
+
buildGrid,
|
|
15
|
+
isoToMinutes,
|
|
16
|
+
formatTime
|
|
17
|
+
} from './schedule-utils.js';
|
|
18
|
+
import type { EditorAdapter, EditorViewer } from '../editor/adapter.js';
|
|
19
|
+
|
|
20
|
+
type VodInfo = { playlistUrl: string; subtitlesUrl?: string };
|
|
21
|
+
|
|
22
|
+
let {
|
|
23
|
+
scheduleEvents,
|
|
24
|
+
tz,
|
|
25
|
+
eventActor,
|
|
26
|
+
adapter,
|
|
27
|
+
viewer,
|
|
28
|
+
rooms: explicitRooms,
|
|
29
|
+
rsvpStatuses: initialRsvpStatuses = {},
|
|
30
|
+
rsvpRkeys: initialRsvpRkeys = {},
|
|
31
|
+
eventVods = {},
|
|
32
|
+
loggedIn = false
|
|
33
|
+
}: {
|
|
34
|
+
scheduleEvents: ScheduleEvent[];
|
|
35
|
+
tz: string;
|
|
36
|
+
eventActor: string;
|
|
37
|
+
adapter: EditorAdapter;
|
|
38
|
+
viewer: EditorViewer;
|
|
39
|
+
/** Explicit room ordering from the conference event, if declared. */
|
|
40
|
+
rooms?: string[];
|
|
41
|
+
rsvpStatuses?: Record<string, string>;
|
|
42
|
+
rsvpRkeys?: Record<string, string>;
|
|
43
|
+
eventVods?: Record<string, VodInfo>;
|
|
44
|
+
loggedIn?: boolean;
|
|
45
|
+
} = $props();
|
|
46
|
+
|
|
47
|
+
// Writable so RSVPs made from the grid reflect immediately; resets when the props change.
|
|
48
|
+
let rsvpStatuses = $derived({ ...initialRsvpStatuses });
|
|
49
|
+
let rsvpRkeys = $derived({ ...initialRsvpRkeys });
|
|
50
|
+
|
|
51
|
+
let filterMode = $state('all');
|
|
52
|
+
let selectedDay = $state('all');
|
|
53
|
+
let activeRooms: Record<number, number> = $state({});
|
|
54
|
+
|
|
55
|
+
let now = $state(new Date());
|
|
56
|
+
onMount(() => {
|
|
57
|
+
const interval = setInterval(() => {
|
|
58
|
+
now = new Date();
|
|
59
|
+
}, 60_000);
|
|
60
|
+
return () => clearInterval(interval);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
let nowIso = $derived(now.toISOString());
|
|
64
|
+
let nowKey = $derived(getDayKey(nowIso, tz));
|
|
65
|
+
let nowMinutes = $derived(isoToMinutes(nowIso, tz));
|
|
66
|
+
|
|
67
|
+
let dayGroups = $derived.by(() => {
|
|
68
|
+
// Temporary accumulator; the derived value returns a new array.
|
|
69
|
+
// eslint-disable-next-line svelte/prefer-svelte-reactivity
|
|
70
|
+
const groups = new Map<
|
|
71
|
+
string,
|
|
72
|
+
{ key: string; label: string; shortLabel: string; events: ScheduleEvent[] }
|
|
73
|
+
>();
|
|
74
|
+
for (const event of scheduleEvents) {
|
|
75
|
+
const key = getDayKey(event.start, tz);
|
|
76
|
+
if (!groups.has(key)) {
|
|
77
|
+
groups.set(key, {
|
|
78
|
+
key,
|
|
79
|
+
label: getDayLabel(event.start, tz),
|
|
80
|
+
shortLabel: getDayShortLabel(event.start, tz),
|
|
81
|
+
events: []
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
groups.get(key)!.events.push(event);
|
|
85
|
+
}
|
|
86
|
+
return [...groups.values()];
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
let filteredDayGroups = $derived(
|
|
90
|
+
selectedDay === 'all' ? dayGroups : dayGroups.filter((d) => d.key === selectedDay)
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
let isDuringConference = $derived.by(() => {
|
|
94
|
+
if (scheduleEvents.length === 0) return false;
|
|
95
|
+
const first = scheduleEvents[0].start;
|
|
96
|
+
const last = scheduleEvents[scheduleEvents.length - 1];
|
|
97
|
+
const lastEnd = last.end || last.start;
|
|
98
|
+
return now >= new Date(first) && now <= new Date(lastEnd);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
// The viewer's RSVP'd talks (going/interested) that haven't ended yet, shown
|
|
102
|
+
// a few at a time via "Show more".
|
|
103
|
+
let visibleCount = $state(3);
|
|
104
|
+
let upNextAll = $derived.by(() => {
|
|
105
|
+
if (!loggedIn) return [];
|
|
106
|
+
const nowMs = now.getTime();
|
|
107
|
+
return scheduleEvents
|
|
108
|
+
.filter((e) => {
|
|
109
|
+
const status = rsvpStatuses[e.uri];
|
|
110
|
+
if (status !== 'going' && status !== 'interested') return false;
|
|
111
|
+
const endMs = new Date(e.end ?? e.start).getTime();
|
|
112
|
+
return endMs >= nowMs;
|
|
113
|
+
})
|
|
114
|
+
.sort((a, b) => a.start.localeCompare(b.start));
|
|
115
|
+
});
|
|
116
|
+
let upNext = $derived(upNextAll.slice(0, visibleCount));
|
|
117
|
+
|
|
118
|
+
function whenLabel(ev: ScheduleEvent): { text: string; soon: boolean } {
|
|
119
|
+
const start = new Date(ev.start);
|
|
120
|
+
const end = new Date(ev.end ?? ev.start);
|
|
121
|
+
if (now >= start && now <= end) return { text: 'Now', soon: true };
|
|
122
|
+
const diffMin = Math.round((start.getTime() - now.getTime()) / 60_000);
|
|
123
|
+
if (diffMin >= 0 && diffMin < 60) return { text: `in ${diffMin} min`, soon: true };
|
|
124
|
+
const time = formatTime(ev.start, tz);
|
|
125
|
+
const sameDay = getDayKey(ev.start, tz) === nowKey;
|
|
126
|
+
return { text: sameDay ? time : `${getDayShortLabel(ev.start, tz)} ${time}`, soon: false };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function subtitle(ev: ScheduleEvent): string {
|
|
130
|
+
return [
|
|
131
|
+
ev.room && ev.room !== 'none' ? ev.room : null,
|
|
132
|
+
ev.speakers?.length ? ev.speakers.map((s) => s.name).join(', ') : null
|
|
133
|
+
]
|
|
134
|
+
.filter(Boolean)
|
|
135
|
+
.join(' · ');
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Wrap a schedule event as a GridEvent so ScheduleEventCell can own its modal
|
|
139
|
+
// (the layout fields are unused; only the start/end-minute span matters).
|
|
140
|
+
function toGridEvent(ev: ScheduleEvent): GridEvent {
|
|
141
|
+
const durMin = ev.end
|
|
142
|
+
? Math.max(
|
|
143
|
+
0,
|
|
144
|
+
Math.round((new Date(ev.end).getTime() - new Date(ev.start).getTime()) / 60_000)
|
|
145
|
+
)
|
|
146
|
+
: 30;
|
|
147
|
+
return {
|
|
148
|
+
...ev,
|
|
149
|
+
startMin: 0,
|
|
150
|
+
endMin: durMin,
|
|
151
|
+
startRow: 1,
|
|
152
|
+
spanRows: 1,
|
|
153
|
+
colStart: 1,
|
|
154
|
+
colSpan: 1,
|
|
155
|
+
zIndex: 0
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
let tzLabel = $derived.by(() => {
|
|
160
|
+
try {
|
|
161
|
+
const parts = new Intl.DateTimeFormat('en-US', {
|
|
162
|
+
timeZone: tz,
|
|
163
|
+
timeZoneName: 'short'
|
|
164
|
+
}).formatToParts(now);
|
|
165
|
+
return parts.find((p) => p.type === 'timeZoneName')?.value ?? tz;
|
|
166
|
+
} catch {
|
|
167
|
+
return tz;
|
|
168
|
+
}
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
function handleRsvpChange(uri: string, status: string | null, rkey?: string) {
|
|
172
|
+
if (status) {
|
|
173
|
+
rsvpStatuses = { ...rsvpStatuses, [uri]: status };
|
|
174
|
+
if (rkey) rsvpRkeys = { ...rsvpRkeys, [uri]: rkey };
|
|
175
|
+
} else {
|
|
176
|
+
const nextStatuses = { ...rsvpStatuses };
|
|
177
|
+
delete nextStatuses[uri];
|
|
178
|
+
rsvpStatuses = nextStatuses;
|
|
179
|
+
const nextKeys = { ...rsvpRkeys };
|
|
180
|
+
delete nextKeys[uri];
|
|
181
|
+
rsvpRkeys = nextKeys;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function scrollToNow() {
|
|
186
|
+
const els = document.querySelectorAll('[data-now-line]');
|
|
187
|
+
for (const el of els) {
|
|
188
|
+
if (el instanceof HTMLElement && el.offsetParent !== null) {
|
|
189
|
+
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
els[0]?.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
194
|
+
}
|
|
195
|
+
</script>
|
|
196
|
+
|
|
197
|
+
{#if scheduleEvents.length > 0}
|
|
198
|
+
<div class="mt-8 mb-6 flex items-center justify-between">
|
|
199
|
+
<h2 class="text-2xl font-bold text-base-900 dark:text-base-50">Schedule</h2>
|
|
200
|
+
{#if isDuringConference}
|
|
201
|
+
<Button onclick={scrollToNow} size="sm">
|
|
202
|
+
<Icon img={ArrowDown} size={16} />
|
|
203
|
+
Jump to now
|
|
204
|
+
</Button>
|
|
205
|
+
{/if}
|
|
206
|
+
</div>
|
|
207
|
+
|
|
208
|
+
<p class="mb-4 text-sm text-muted">All times shown in {tzLabel}.</p>
|
|
209
|
+
|
|
210
|
+
{#if upNextAll.length > 0}
|
|
211
|
+
<div class="mb-8">
|
|
212
|
+
<h2 class="mb-3 text-xl font-bold text-base-900 dark:text-base-50">Up next</h2>
|
|
213
|
+
<div class="space-y-2">
|
|
214
|
+
{#each upNext as ev (ev.uri)}
|
|
215
|
+
{@const w = whenLabel(ev)}
|
|
216
|
+
<ScheduleEventCell
|
|
217
|
+
event={toGridEvent(ev)}
|
|
218
|
+
{tz}
|
|
219
|
+
{eventActor}
|
|
220
|
+
{adapter}
|
|
221
|
+
{viewer}
|
|
222
|
+
{rsvpStatuses}
|
|
223
|
+
{rsvpRkeys}
|
|
224
|
+
vodPlaylistUrl={eventVods[ev.uri]?.playlistUrl}
|
|
225
|
+
vodSubtitlesUrl={eventVods[ev.uri]?.subtitlesUrl}
|
|
226
|
+
onrsvpchange={handleRsvpChange}
|
|
227
|
+
>
|
|
228
|
+
{#snippet trigger({ open })}
|
|
229
|
+
<button
|
|
230
|
+
onclick={open}
|
|
231
|
+
class="flex w-full cursor-pointer items-center gap-3 rounded-xl border border-base-200 bg-base-100 px-4 py-3 text-left leading-tight transition-colors hover:border-base-300 dark:border-base-800 dark:bg-base-950/50 dark:hover:border-base-700"
|
|
232
|
+
>
|
|
233
|
+
<div class="min-w-0 flex-1">
|
|
234
|
+
<p class="truncate text-sm font-semibold text-base-900 dark:text-base-100">
|
|
235
|
+
{ev.title}
|
|
236
|
+
</p>
|
|
237
|
+
{#if subtitle(ev)}
|
|
238
|
+
<p class="mt-0.5 truncate text-xs text-muted">
|
|
239
|
+
{subtitle(ev)}
|
|
240
|
+
</p>
|
|
241
|
+
{/if}
|
|
242
|
+
</div>
|
|
243
|
+
<span
|
|
244
|
+
class="flex shrink-0 items-center gap-1.5 text-xs whitespace-nowrap {w.soon
|
|
245
|
+
? 'font-semibold text-accent-600 dark:text-accent-400'
|
|
246
|
+
: 'text-muted'}"
|
|
247
|
+
>
|
|
248
|
+
{#if rsvpStatuses[ev.uri] === 'going'}
|
|
249
|
+
<Icon img={Check} size={14} class="text-action" />
|
|
250
|
+
{:else}
|
|
251
|
+
<Icon img={Star} size={14} class="text-action" />
|
|
252
|
+
{/if}
|
|
253
|
+
{w.text}
|
|
254
|
+
</span>
|
|
255
|
+
</button>
|
|
256
|
+
{/snippet}
|
|
257
|
+
</ScheduleEventCell>
|
|
258
|
+
{/each}
|
|
259
|
+
</div>
|
|
260
|
+
{#if upNextAll.length > upNext.length}
|
|
261
|
+
<button
|
|
262
|
+
type="button"
|
|
263
|
+
onclick={() => (visibleCount += 5)}
|
|
264
|
+
class="mt-2 text-sm font-medium text-accent-600 hover:underline dark:text-accent-400"
|
|
265
|
+
>
|
|
266
|
+
Show more
|
|
267
|
+
</button>
|
|
268
|
+
{/if}
|
|
269
|
+
</div>
|
|
270
|
+
{/if}
|
|
271
|
+
|
|
272
|
+
{#if dayGroups.length > 1 || loggedIn}
|
|
273
|
+
<div class="mb-6 space-y-3">
|
|
274
|
+
{#if dayGroups.length > 1}
|
|
275
|
+
<div class="flex items-center gap-3">
|
|
276
|
+
<span class="w-14 text-xs text-muted">Days</span>
|
|
277
|
+
<ToggleGroup
|
|
278
|
+
type="single"
|
|
279
|
+
bind:value={
|
|
280
|
+
() => selectedDay,
|
|
281
|
+
(v) => {
|
|
282
|
+
if (v) selectedDay = v;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
class="w-fit"
|
|
286
|
+
>
|
|
287
|
+
<ToggleGroupItem value="all" size="sm">All</ToggleGroupItem>
|
|
288
|
+
{#each dayGroups as day (day.key)}
|
|
289
|
+
<ToggleGroupItem value={day.key} size="sm">{day.shortLabel}</ToggleGroupItem>
|
|
290
|
+
{/each}
|
|
291
|
+
</ToggleGroup>
|
|
292
|
+
</div>
|
|
293
|
+
{/if}
|
|
294
|
+
{#if loggedIn}
|
|
295
|
+
<div class="flex items-center gap-3">
|
|
296
|
+
<span class="w-14 text-xs text-muted">Events</span>
|
|
297
|
+
<ToggleGroup
|
|
298
|
+
type="single"
|
|
299
|
+
bind:value={
|
|
300
|
+
() => filterMode,
|
|
301
|
+
(v) => {
|
|
302
|
+
if (v) filterMode = v;
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
class="w-fit"
|
|
306
|
+
>
|
|
307
|
+
<ToggleGroupItem value="all" size="sm">All</ToggleGroupItem>
|
|
308
|
+
<ToggleGroupItem value="attending" size="sm">Attending</ToggleGroupItem>
|
|
309
|
+
</ToggleGroup>
|
|
310
|
+
</div>
|
|
311
|
+
{/if}
|
|
312
|
+
</div>
|
|
313
|
+
{/if}
|
|
314
|
+
|
|
315
|
+
{#each filteredDayGroups as day, dayIndex (day.key)}
|
|
316
|
+
{@const dayRooms = getRooms(day.events, explicitRooms)}
|
|
317
|
+
{@const grid = buildGrid(day.events, dayRooms, tz)}
|
|
318
|
+
|
|
319
|
+
<section class="isolate mb-12">
|
|
320
|
+
<h3 class="mb-4 text-lg font-bold text-base-900 dark:text-base-50">{day.label}</h3>
|
|
321
|
+
<DaySchedule
|
|
322
|
+
{grid}
|
|
323
|
+
rooms={dayRooms}
|
|
324
|
+
dayEvents={day.events}
|
|
325
|
+
{tz}
|
|
326
|
+
{eventActor}
|
|
327
|
+
{adapter}
|
|
328
|
+
{viewer}
|
|
329
|
+
bind:activeRoom={() => activeRooms[dayIndex] ?? 0, (v) => (activeRooms[dayIndex] = v)}
|
|
330
|
+
{nowKey}
|
|
331
|
+
{nowMinutes}
|
|
332
|
+
{rsvpStatuses}
|
|
333
|
+
{rsvpRkeys}
|
|
334
|
+
{eventVods}
|
|
335
|
+
dimUnattended={filterMode === 'attending'}
|
|
336
|
+
onrsvpchange={handleRsvpChange}
|
|
337
|
+
/>
|
|
338
|
+
</section>
|
|
339
|
+
{/each}
|
|
340
|
+
{/if}
|