tangled
alpha
login
or
join now
flo-bit.dev
/
blento
21
fork
atom
your personal website on atproto - mirror
blento.app
21
fork
atom
overview
issues
pulls
pipelines
first version
Florian
4 weeks ago
ea22373f
0fdc4eea
+415
-2
7 changed files
expand all
collapse all
unified
split
package.json
pnpm-lock.yaml
src
lib
atproto
methods.ts
cache.ts
cards
social
EventCard
index.ts
routes
[[actor=actor]]
e
[rkey]
+page.server.ts
+page.svelte
+1
package.json
···
84
84
"qr-code-styling": "^1.8.6",
85
85
"react-grid-layout": "^2.2.2",
86
86
"simple-icons": "^16.6.0",
87
87
+
"svelte-boring-avatars": "^1.2.6",
87
88
"svelte-sonner": "^1.0.7",
88
89
"tailwind-merge": "^3.4.0",
89
90
"tailwind-variants": "^3.2.2",
+8
pnpm-lock.yaml
···
140
140
simple-icons:
141
141
specifier: ^16.6.0
142
142
version: 16.6.0
143
143
+
svelte-boring-avatars:
144
144
+
specifier: ^1.2.6
145
145
+
version: 1.2.6
143
146
svelte-sonner:
144
147
specifier: ^1.0.7
145
148
version: 1.0.7(svelte@5.48.0)
···
2791
2794
supports-color@7.2.0:
2792
2795
resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==}
2793
2796
engines: {node: '>=8'}
2797
2797
+
2798
2798
+
svelte-boring-avatars@1.2.6:
2799
2799
+
resolution: {integrity: sha512-8+Z1DhsMUVI/V/5ik00Arw0PgbJcMdhTXq3YGqccBc5bYFeceCtMEMB0aWGhi8xFV+0aqZbWvS89Hcj16LlfHA==, tarball: https://registry.npmjs.org/svelte-boring-avatars/-/svelte-boring-avatars-1.2.6.tgz}
2794
2800
2795
2801
svelte-check@4.3.5:
2796
2802
resolution: {integrity: sha512-e4VWZETyXaKGhpkxOXP+B/d0Fp/zKViZoJmneZWe/05Y2aqSKj3YN2nLfYPJBQ87WEiY4BQCQ9hWGu9mPT1a1Q==}
···
5609
5615
supports-color@7.2.0:
5610
5616
dependencies:
5611
5617
has-flag: 4.0.0
5618
5618
+
5619
5619
+
svelte-boring-avatars@1.2.6: {}
5612
5620
5613
5621
svelte-check@4.3.5(picomatch@4.0.3)(svelte@5.48.0)(typescript@5.9.3):
5614
5622
dependencies:
+5
-2
src/lib/atproto/methods.ts
···
122
122
123
123
const response = await getDetailedProfile(data);
124
124
125
125
+
const avatar = blentoProfile?.value?.icon
126
126
+
? getCDNImageBlobUrl({ did: data?.did, blob: blentoProfile?.value?.icon })
127
127
+
: response?.avatar;
128
128
+
125
129
return {
126
130
did: data.did,
127
131
handle: response?.handle,
128
132
displayName: blentoProfile?.value?.name || response?.displayName || response?.handle,
129
129
-
avatar: (getCDNImageBlobUrl({ did: data?.did, blob: blentoProfile?.value?.icon }) ||
130
130
-
response?.avatar) as `${string}:${string}`,
133
133
+
avatar: avatar as `${string}:${string}`,
131
134
hasBlento: Boolean(blentoProfile.value),
132
135
url: blentoProfile?.value?.url as string | undefined
133
136
};
+30
src/lib/cache.ts
···
1
1
import type { ActorIdentifier, Did } from '@atcute/lexicons';
2
2
import { isDid } from '@atcute/lexicons/syntax';
3
3
import type { KVNamespace } from '@cloudflare/workers-types';
4
4
+
import { getBlentoOrBskyProfile } from '$lib/atproto/methods';
4
5
5
6
/** TTL in seconds for each cache namespace */
6
7
const NAMESPACE_TTL = {
···
10
11
'gh-contrib': 60 * 60 * 12, // 12 hours
11
12
lastfm: 60 * 60, // 1 hour (default, overridable per-put)
12
13
npmx: 60 * 60 * 12, // 12 hours
14
14
+
profile: 60 * 60 * 24, // 24 hours
13
15
meta: 0 // no auto-expiry
14
16
} as const;
15
17
···
89
91
async resolveHandle(did: Did): Promise<string | null> {
90
92
return this.get('identity', `d:${did}`);
91
93
}
94
94
+
95
95
+
// === Profile cache (did → profile data) ===
96
96
+
async getProfile(did: Did): Promise<CachedProfile> {
97
97
+
const cached = await this.getJSON<CachedProfile>('profile', did);
98
98
+
if (cached) return cached;
99
99
+
100
100
+
const profile = await getBlentoOrBskyProfile({ did });
101
101
+
const data: CachedProfile = {
102
102
+
did: profile.did as string,
103
103
+
handle: profile.handle as string,
104
104
+
displayName: profile.displayName as string | undefined,
105
105
+
avatar: profile.avatar as string | undefined,
106
106
+
hasBlento: profile.hasBlento,
107
107
+
url: profile.url
108
108
+
};
109
109
+
110
110
+
await this.putJSON('profile', did, data);
111
111
+
return data;
112
112
+
}
92
113
}
114
114
+
115
115
+
export type CachedProfile = {
116
116
+
did: string;
117
117
+
handle: string;
118
118
+
displayName?: string;
119
119
+
avatar?: string;
120
120
+
hasBlento: boolean;
121
121
+
url?: string;
122
122
+
};
93
123
94
124
export function createCache(platform?: App.Platform): CacheService | undefined {
95
125
const kv = platform?.env?.USER_DATA_CACHE;
+4
src/lib/cards/social/EventCard/index.ts
···
33
33
height: number;
34
34
};
35
35
}>;
36
36
+
uris?: Array<{
37
37
+
uri: string;
38
38
+
name?: string;
39
39
+
}>;
36
40
countGoing?: number;
37
41
countInterested?: number;
38
42
url: string;
+59
src/routes/[[actor=actor]]/e/[rkey]/+page.server.ts
···
1
1
+
import { error } from '@sveltejs/kit';
2
2
+
import type { EventData } from '$lib/cards/social/EventCard';
3
3
+
import { getBlentoOrBskyProfile, resolveHandle } from '$lib/atproto/methods.js';
4
4
+
import { isHandle } from '@atcute/lexicons/syntax';
5
5
+
import { createCache, type CachedProfile } from '$lib/cache';
6
6
+
import type { Did } from '@atcute/lexicons';
7
7
+
8
8
+
export async function load({ params, platform }) {
9
9
+
const { rkey } = params;
10
10
+
const did = isHandle(params.actor) ? await resolveHandle({ handle: params.actor }) : params.actor;
11
11
+
12
12
+
if (!did || !rkey) {
13
13
+
throw error(404, 'Event not found');
14
14
+
}
15
15
+
16
16
+
try {
17
17
+
const cache = createCache(platform);
18
18
+
19
19
+
console.log(
20
20
+
`https://smokesignal.events/xrpc/community.lexicon.calendar.GetEvent?repository=${encodeURIComponent(did)}&record_key=${encodeURIComponent(rkey)}`
21
21
+
);
22
22
+
23
23
+
const [eventResponse, hostProfile] = await Promise.all([
24
24
+
fetch(
25
25
+
`https://smokesignal.events/xrpc/community.lexicon.calendar.GetEvent?repository=${encodeURIComponent(did)}&record_key=${encodeURIComponent(rkey)}`
26
26
+
),
27
27
+
cache
28
28
+
? cache.getProfile(did as Did).catch(() => null)
29
29
+
: getBlentoOrBskyProfile({ did: did as Did })
30
30
+
.then(
31
31
+
(p): CachedProfile => ({
32
32
+
did: p.did as string,
33
33
+
handle: p.handle as string,
34
34
+
displayName: p.displayName as string | undefined,
35
35
+
avatar: p.avatar as string | undefined,
36
36
+
hasBlento: p.hasBlento,
37
37
+
url: p.url
38
38
+
})
39
39
+
)
40
40
+
.catch(() => null)
41
41
+
]);
42
42
+
43
43
+
if (!eventResponse.ok) {
44
44
+
throw error(404, 'Event not found');
45
45
+
}
46
46
+
47
47
+
const eventData: EventData = await eventResponse.json();
48
48
+
49
49
+
return {
50
50
+
eventData,
51
51
+
did,
52
52
+
rkey,
53
53
+
hostProfile: hostProfile ?? null
54
54
+
};
55
55
+
} catch (e) {
56
56
+
if (e && typeof e === 'object' && 'status' in e) throw e;
57
57
+
throw error(404, 'Event not found');
58
58
+
}
59
59
+
}
+308
src/routes/[[actor=actor]]/e/[rkey]/+page.svelte
···
1
1
+
<script lang="ts">
2
2
+
import type { EventData } from '$lib/cards/social/EventCard';
3
3
+
import { Avatar as FoxAvatar, Badge } from '@foxui/core';
4
4
+
import Avatar from 'svelte-boring-avatars';
5
5
+
6
6
+
let { data } = $props();
7
7
+
8
8
+
let eventData: EventData = $derived(data.eventData);
9
9
+
let did: string = $derived(data.did);
10
10
+
let rkey: string = $derived(data.rkey);
11
11
+
let hostProfile = $derived(data.hostProfile);
12
12
+
13
13
+
let hostUrl = $derived(
14
14
+
hostProfile?.hasBlento
15
15
+
? `/${hostProfile.handle}`
16
16
+
: `https://bsky.app/profile/${hostProfile?.handle || did}`
17
17
+
);
18
18
+
19
19
+
let startDate = $derived(new Date(eventData.startsAt));
20
20
+
let endDate = $derived(eventData.endsAt ? new Date(eventData.endsAt) : null);
21
21
+
22
22
+
function formatMonth(date: Date): string {
23
23
+
return date.toLocaleDateString('en-US', { month: 'short' }).toUpperCase();
24
24
+
}
25
25
+
26
26
+
function formatDay(date: Date): number {
27
27
+
return date.getDate();
28
28
+
}
29
29
+
30
30
+
function formatWeekday(date: Date): string {
31
31
+
return date.toLocaleDateString('en-US', { weekday: 'long' });
32
32
+
}
33
33
+
34
34
+
function formatFullDate(date: Date): string {
35
35
+
const options: Intl.DateTimeFormatOptions = { month: 'long', day: 'numeric' };
36
36
+
if (date.getFullYear() !== new Date().getFullYear()) {
37
37
+
options.year = 'numeric';
38
38
+
}
39
39
+
return date.toLocaleDateString('en-US', options);
40
40
+
}
41
41
+
42
42
+
function formatTime(date: Date): string {
43
43
+
return date.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' });
44
44
+
}
45
45
+
46
46
+
function getModeLabel(mode: string): string {
47
47
+
if (mode.includes('virtual')) return 'Virtual';
48
48
+
if (mode.includes('hybrid')) return 'Hybrid';
49
49
+
if (mode.includes('inperson')) return 'In-Person';
50
50
+
return 'Event';
51
51
+
}
52
52
+
53
53
+
function getModeColor(mode: string): string {
54
54
+
if (mode.includes('virtual')) return 'cyan';
55
55
+
if (mode.includes('hybrid')) return 'purple';
56
56
+
if (mode.includes('inperson')) return 'amber';
57
57
+
return 'gray';
58
58
+
}
59
59
+
60
60
+
function getLocationString(locations: EventData['locations']): string | undefined {
61
61
+
if (!locations || locations.length === 0) return undefined;
62
62
+
63
63
+
const loc = locations.find((v => v.$type === "community.lexicon.location.address"));
64
64
+
if (!loc) return undefined;
65
65
+
66
66
+
// Handle both flat location objects (name, street, locality, country)
67
67
+
// and nested address objects
68
68
+
const flat = loc as Record<string, unknown>;
69
69
+
const nested = loc.address;
70
70
+
71
71
+
const street = (flat.street as string) || undefined;
72
72
+
const locality = (flat.locality as string) || nested?.locality;
73
73
+
const region = (flat.region as string) || nested?.region;
74
74
+
75
75
+
const parts = [street, locality, region].filter(Boolean);
76
76
+
return parts.length > 0 ? parts.join(', ') : undefined;
77
77
+
}
78
78
+
79
79
+
let location = $derived(getLocationString(eventData.locations));
80
80
+
81
81
+
let headerImage = $derived.by(() => {
82
82
+
if (!eventData.media || eventData.media.length === 0) return null;
83
83
+
const media = eventData.media.find((m) => m.role === 'thumbnail');
84
84
+
if (!media?.content?.ref?.$link) return null;
85
85
+
return {
86
86
+
url: `https://cdn.bsky.app/img/feed_thumbnail/plain/${did}/${media.content.ref.$link}@jpeg`,
87
87
+
alt: media.alt || eventData.name
88
88
+
};
89
89
+
});
90
90
+
91
91
+
let eventUrl = $derived(eventData.url || `https://smokesignal.events/${did}/${rkey}`);
92
92
+
</script>
93
93
+
94
94
+
<svelte:head>
95
95
+
<title>{eventData.name}</title>
96
96
+
<meta name="description" content={eventData.description || `Event: ${eventData.name}`} />
97
97
+
</svelte:head>
98
98
+
99
99
+
<div class="bg-base-50 dark:bg-base-950 min-h-screen px-4 py-8 sm:py-12">
100
100
+
<div class="mx-auto max-w-4xl">
101
101
+
<!-- Two-column layout: image left, details right -->
102
102
+
<div class="flex flex-col gap-8 md:flex-row md:gap-10">
103
103
+
<!-- Left column: image -->
104
104
+
<div class="shrink-0 md:w-56 lg:w-64 max-w-sm mx-auto md:max-w-none">
105
105
+
{#if headerImage}
106
106
+
<img
107
107
+
src={headerImage.url}
108
108
+
alt={headerImage.alt}
109
109
+
class="border-base-200 dark:border-base-800 aspect-square w-full rounded-2xl border object-cover"
110
110
+
/>
111
111
+
{:else}
112
112
+
<div
113
113
+
class="border-base-200 dark:border-base-800 aspect-square w-full overflow-hidden rounded-2xl border [&>svg]:h-full [&>svg]:w-full"
114
114
+
>
115
115
+
<Avatar
116
116
+
size={256}
117
117
+
name={data.rkey}
118
118
+
variant="marble"
119
119
+
colors={['#92A1C6', '#146A7C', '#F0AB3D', '#C271B4', '#C20D90']}
120
120
+
square
121
121
+
/>
122
122
+
</div>
123
123
+
{/if}
124
124
+
125
125
+
<!-- Hosted By section (below image, like Luma) -->
126
126
+
<div class="mt-6">
127
127
+
<p
128
128
+
class="text-base-500 dark:text-base-400 mb-3 text-xs font-semibold tracking-wider uppercase"
129
129
+
>
130
130
+
Hosted By
131
131
+
</p>
132
132
+
<a
133
133
+
href={hostUrl}
134
134
+
target={hostProfile?.hasBlento ? undefined : '_blank'}
135
135
+
rel={hostProfile?.hasBlento ? undefined : 'noopener noreferrer'}
136
136
+
class="text-base-900 dark:text-base-100 flex items-center gap-2.5 font-medium hover:underline"
137
137
+
>
138
138
+
<FoxAvatar
139
139
+
src={hostProfile?.avatar}
140
140
+
alt={hostProfile?.displayName || hostProfile?.handle || did}
141
141
+
class="size-8 shrink-0"
142
142
+
/>
143
143
+
<span class="truncate text-sm">
144
144
+
{hostProfile?.displayName || hostProfile?.handle || did}
145
145
+
</span>
146
146
+
</a>
147
147
+
</div>
148
148
+
149
149
+
{#if (eventData.countGoing && eventData.countGoing > 0) || (eventData.countInterested && eventData.countInterested > 0)}
150
150
+
<div class="text-base-900 dark:text-base-100 mt-8 space-y-2.5 text-base font-medium">
151
151
+
{#if eventData.countGoing && eventData.countGoing > 0}
152
152
+
<p>{eventData.countGoing} Going</p>
153
153
+
{/if}
154
154
+
{#if eventData.countInterested && eventData.countInterested > 0}
155
155
+
<p>{eventData.countInterested} Interested</p>
156
156
+
{/if}
157
157
+
</div>
158
158
+
{/if}
159
159
+
160
160
+
{#if eventData.uris && eventData.uris.length > 0}
161
161
+
<div class="mt-8">
162
162
+
<p
163
163
+
class="text-base-500 dark:text-base-400 mb-2 text-xs font-semibold tracking-wider uppercase"
164
164
+
>
165
165
+
Links
166
166
+
</p>
167
167
+
<div class="space-y-1.5">
168
168
+
{#each eventData.uris as link}
169
169
+
<a
170
170
+
href={link.uri}
171
171
+
target="_blank"
172
172
+
rel="noopener noreferrer"
173
173
+
class="text-base-700 dark:text-base-300 hover:text-base-900 dark:hover:text-base-100 flex items-center gap-1.5 text-sm transition-colors"
174
174
+
>
175
175
+
<svg
176
176
+
xmlns="http://www.w3.org/2000/svg"
177
177
+
fill="none"
178
178
+
viewBox="0 0 24 24"
179
179
+
stroke-width="1.5"
180
180
+
stroke="currentColor"
181
181
+
class="size-3.5 shrink-0"
182
182
+
>
183
183
+
<path
184
184
+
stroke-linecap="round"
185
185
+
stroke-linejoin="round"
186
186
+
d="M13.19 8.688a4.5 4.5 0 0 1 1.242 7.244l-4.5 4.5a4.5 4.5 0 0 1-6.364-6.364l1.757-1.757m13.35-.622 1.757-1.757a4.5 4.5 0 0 0-6.364-6.364l-4.5 4.5a4.5 4.5 0 0 0 1.242 7.244"
187
187
+
/>
188
188
+
</svg>
189
189
+
<span class="truncate">{link.name || link.uri.replace(/^https?:\/\//, '')}</span>
190
190
+
</a>
191
191
+
{/each}
192
192
+
</div>
193
193
+
</div>
194
194
+
{/if}
195
195
+
</div>
196
196
+
197
197
+
<!-- Right column: event details -->
198
198
+
<div class="min-w-0 flex-1">
199
199
+
<h1
200
200
+
class="text-base-900 dark:text-base-50 mb-2 text-4xl leading-tight font-bold sm:text-5xl"
201
201
+
>
202
202
+
{eventData.name}
203
203
+
</h1>
204
204
+
205
205
+
<!-- Mode badge -->
206
206
+
{#if eventData.mode}
207
207
+
<div class="mb-8">
208
208
+
<Badge size="md" variant={getModeColor(eventData.mode)}
209
209
+
>{getModeLabel(eventData.mode)}</Badge
210
210
+
>
211
211
+
</div>
212
212
+
{/if}
213
213
+
214
214
+
<!-- Date row (Luma-style calendar icon) -->
215
215
+
<div class="mb-4 flex items-center gap-4">
216
216
+
<div
217
217
+
class="border-base-200 dark:border-base-700 flex size-12 shrink-0 flex-col items-center justify-center overflow-hidden rounded-xl border"
218
218
+
>
219
219
+
<span class="text-base-500 dark:text-base-400 text-[9px] leading-none font-semibold">
220
220
+
{formatMonth(startDate)}
221
221
+
</span>
222
222
+
<span class="text-base-900 dark:text-base-50 text-lg leading-tight font-bold">
223
223
+
{formatDay(startDate)}
224
224
+
</span>
225
225
+
</div>
226
226
+
<div>
227
227
+
<p class="text-base-900 dark:text-base-50 font-semibold">
228
228
+
{formatWeekday(startDate)}, {formatFullDate(startDate)}
229
229
+
</p>
230
230
+
<p class="text-base-500 dark:text-base-400 text-sm">
231
231
+
{formatTime(startDate)}
232
232
+
{#if endDate}
233
233
+
- {formatTime(endDate)}{/if}
234
234
+
</p>
235
235
+
</div>
236
236
+
</div>
237
237
+
238
238
+
<!-- Location row -->
239
239
+
{#if location}
240
240
+
<div class="mb-6 flex items-center gap-4">
241
241
+
<div
242
242
+
class="border-base-200 dark:border-base-700 flex size-12 shrink-0 items-center justify-center rounded-xl border"
243
243
+
>
244
244
+
<svg
245
245
+
xmlns="http://www.w3.org/2000/svg"
246
246
+
fill="none"
247
247
+
viewBox="0 0 24 24"
248
248
+
stroke-width="1.5"
249
249
+
stroke="currentColor"
250
250
+
class="text-base-900 dark:text-base-200 size-5"
251
251
+
>
252
252
+
<path
253
253
+
stroke-linecap="round"
254
254
+
stroke-linejoin="round"
255
255
+
d="M15 10.5a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z"
256
256
+
/>
257
257
+
<path
258
258
+
stroke-linecap="round"
259
259
+
stroke-linejoin="round"
260
260
+
d="M19.5 10.5c0 7.142-7.5 11.25-7.5 11.25S4.5 17.642 4.5 10.5a7.5 7.5 0 1 1 15 0Z"
261
261
+
/>
262
262
+
</svg>
263
263
+
</div>
264
264
+
<p class="text-base-900 dark:text-base-50 font-semibold">{location}</p>
265
265
+
</div>
266
266
+
{/if}
267
267
+
268
268
+
<!-- About Event -->
269
269
+
{#if eventData.description}
270
270
+
<div class="mt-8 mb-8">
271
271
+
<p
272
272
+
class="text-base-500 dark:text-base-400 mb-3 text-xs font-semibold tracking-wider uppercase"
273
273
+
>
274
274
+
About
275
275
+
</p>
276
276
+
<p class="text-base-700 dark:text-base-300 leading-relaxed whitespace-pre-wrap">
277
277
+
{eventData.description}
278
278
+
</p>
279
279
+
</div>
280
280
+
{/if}
281
281
+
282
282
+
<!-- View on Smoke Signal link -->
283
283
+
<a
284
284
+
href={eventUrl}
285
285
+
target="_blank"
286
286
+
rel="noopener noreferrer"
287
287
+
class="text-base-500 dark:text-base-400 hover:text-base-700 dark:hover:text-base-200 inline-flex items-center gap-1.5 text-sm transition-colors"
288
288
+
>
289
289
+
View on Smoke Signal
290
290
+
<svg
291
291
+
xmlns="http://www.w3.org/2000/svg"
292
292
+
fill="none"
293
293
+
viewBox="0 0 24 24"
294
294
+
stroke-width="2"
295
295
+
stroke="currentColor"
296
296
+
class="size-3.5"
297
297
+
>
298
298
+
<path
299
299
+
stroke-linecap="round"
300
300
+
stroke-linejoin="round"
301
301
+
d="m4.5 19.5 15-15m0 0H8.25m11.25 0v11.25"
302
302
+
/>
303
303
+
</svg>
304
304
+
</a>
305
305
+
</div>
306
306
+
</div>
307
307
+
</div>
308
308
+
</div>