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
add og images
Florian
4 weeks ago
fb4d93d0
dcb98beb
+416
-9
8 changed files
expand all
collapse all
unified
split
docs
Autofill.md
src
lib
atproto
methods.ts
cards
social
EventCard
index.ts
routes
[[actor=actor]]
e
+page.server.ts
+page.svelte
[rkey]
+page.svelte
og.png
+server.ts
EventOgImage.svelte
+1
-1
docs/Autofill.md
···
11
11
- blue.flashes.actor.portfolio
12
12
- social.grain.gallery
13
13
- xyz.statusphere.status
14
14
-
- site.standard.publication
14
14
+
- site.standard.publication
15
15
- fm.teal.alpha.feed.play
16
16
- dev.npmx.feed.like
17
17
- add bluesky profile card
+4
-2
src/lib/atproto/methods.ts
···
395
395
*/
396
396
export function getCDNImageBlobUrl({
397
397
did,
398
398
-
blob
398
398
+
blob,
399
399
+
type = 'webp'
399
400
}: {
400
401
did?: string;
401
402
blob: {
···
404
405
$link: string;
405
406
};
406
407
};
408
408
+
type?: 'webp' | 'jpeg';
407
409
}) {
408
410
if (!blob || !did) return;
409
411
did ??= user.did;
410
412
411
411
-
return `https://cdn.bsky.app/img/feed_thumbnail/plain/${did}/${blob.ref.$link}@webp`;
413
413
+
return `https://cdn.bsky.app/img/feed_thumbnail/plain/${did}/${blob.ref.$link}@${type}`;
412
414
}
413
415
414
416
/**
+2
-1
src/lib/cards/social/EventCard/index.ts
···
24
24
alt?: string;
25
25
role?: string;
26
26
content?: {
27
27
-
ref?: {
27
27
+
$type: 'blob';
28
28
+
ref: {
28
29
$link: string;
29
30
};
30
31
mimeType?: string;
+77
src/routes/[[actor=actor]]/e/+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 { ActorIdentifier, Did } from '@atcute/lexicons';
7
7
+
import { env as publicEnv } from '$env/dynamic/public';
8
8
+
9
9
+
export async function load({ params, platform, request }) {
10
10
+
const cache = createCache(platform);
11
11
+
12
12
+
const customDomain = request.headers.get('X-Custom-Domain')?.toLowerCase();
13
13
+
14
14
+
let actor: ActorIdentifier | undefined = params.actor;
15
15
+
16
16
+
if (!actor) {
17
17
+
const kv = platform?.env?.CUSTOM_DOMAINS;
18
18
+
19
19
+
if (kv && customDomain) {
20
20
+
try {
21
21
+
const did = await kv.get(customDomain);
22
22
+
23
23
+
if (did) actor = did as ActorIdentifier;
24
24
+
} catch (error) {
25
25
+
console.error('failed to get custom domain kv', error);
26
26
+
}
27
27
+
} else {
28
28
+
actor = publicEnv.PUBLIC_HANDLE as ActorIdentifier;
29
29
+
}
30
30
+
} else if (customDomain && params.actor) {
31
31
+
actor = undefined;
32
32
+
}
33
33
+
34
34
+
const did = isHandle(actor) ? await resolveHandle({ handle: actor }) : actor;
35
35
+
36
36
+
if (!did) {
37
37
+
throw error(404, 'Events not found');
38
38
+
}
39
39
+
40
40
+
try {
41
41
+
const [eventsResponse, hostProfile] = await Promise.all([
42
42
+
fetch(
43
43
+
`https://smokesignal.events/xrpc/community.lexicon.calendar.searchEvents?repository=${encodeURIComponent(did)}&query=upcoming`
44
44
+
),
45
45
+
cache
46
46
+
? cache.getProfile(did as Did).catch(() => null)
47
47
+
: getBlentoOrBskyProfile({ did: did as Did })
48
48
+
.then(
49
49
+
(p): CachedProfile => ({
50
50
+
did: p.did as string,
51
51
+
handle: p.handle as string,
52
52
+
displayName: p.displayName as string | undefined,
53
53
+
avatar: p.avatar as string | undefined,
54
54
+
hasBlento: p.hasBlento,
55
55
+
url: p.url
56
56
+
})
57
57
+
)
58
58
+
.catch(() => null)
59
59
+
]);
60
60
+
61
61
+
if (!eventsResponse.ok) {
62
62
+
throw error(404, 'Events not found');
63
63
+
}
64
64
+
65
65
+
const data: { results: EventData[] } = await eventsResponse.json();
66
66
+
const events = data.results;
67
67
+
68
68
+
return {
69
69
+
events,
70
70
+
did,
71
71
+
hostProfile: hostProfile ?? null
72
72
+
};
73
73
+
} catch (e) {
74
74
+
if (e && typeof e === 'object' && 'status' in e) throw e;
75
75
+
throw error(404, 'Events not found');
76
76
+
}
77
77
+
}
+170
src/routes/[[actor=actor]]/e/+page.svelte
···
1
1
+
<script lang="ts">
2
2
+
import type { EventData } from '$lib/cards/social/EventCard';
3
3
+
import { getCDNImageBlobUrl } from '$lib/atproto';
4
4
+
import { Avatar as FoxAvatar, Badge } from '@foxui/core';
5
5
+
import Avatar from 'svelte-boring-avatars';
6
6
+
7
7
+
let { data } = $props();
8
8
+
9
9
+
let events: EventData[] = $derived(data.events);
10
10
+
let did: string = $derived(data.did);
11
11
+
let hostProfile = $derived(data.hostProfile);
12
12
+
13
13
+
let hostName = $derived(hostProfile?.displayName || hostProfile?.handle || did);
14
14
+
15
15
+
function formatDate(dateStr: string): string {
16
16
+
const date = new Date(dateStr);
17
17
+
const options: Intl.DateTimeFormatOptions = {
18
18
+
weekday: 'short',
19
19
+
month: 'short',
20
20
+
day: 'numeric'
21
21
+
};
22
22
+
if (date.getFullYear() !== new Date().getFullYear()) {
23
23
+
options.year = 'numeric';
24
24
+
}
25
25
+
return date.toLocaleDateString('en-US', options);
26
26
+
}
27
27
+
28
28
+
function formatTime(dateStr: string): string {
29
29
+
return new Date(dateStr).toLocaleTimeString('en-US', {
30
30
+
hour: 'numeric',
31
31
+
minute: '2-digit'
32
32
+
});
33
33
+
}
34
34
+
35
35
+
function getModeLabel(mode: string): string {
36
36
+
if (mode.includes('virtual')) return 'Virtual';
37
37
+
if (mode.includes('hybrid')) return 'Hybrid';
38
38
+
if (mode.includes('inperson')) return 'In-Person';
39
39
+
return 'Event';
40
40
+
}
41
41
+
42
42
+
function getModeColor(mode: string): 'cyan' | 'purple' | 'amber' | 'secondary' {
43
43
+
if (mode.includes('virtual')) return 'cyan';
44
44
+
if (mode.includes('hybrid')) return 'purple';
45
45
+
if (mode.includes('inperson')) return 'amber';
46
46
+
return 'secondary';
47
47
+
}
48
48
+
49
49
+
function getLocationString(locations: EventData['locations']): string | undefined {
50
50
+
if (!locations || locations.length === 0) return undefined;
51
51
+
52
52
+
const loc = locations.find((v) => v.$type === 'community.lexicon.location.address');
53
53
+
if (!loc) return undefined;
54
54
+
55
55
+
const flat = loc as Record<string, unknown>;
56
56
+
const nested = loc.address;
57
57
+
58
58
+
const locality = (flat.locality as string) || nested?.locality;
59
59
+
const region = (flat.region as string) || nested?.region;
60
60
+
61
61
+
const parts = [locality, region].filter(Boolean);
62
62
+
return parts.length > 0 ? parts.join(', ') : undefined;
63
63
+
}
64
64
+
65
65
+
function getThumbnail(event: EventData): { url: string; alt: string } | null {
66
66
+
if (!event.media || event.media.length === 0) return null;
67
67
+
const media = event.media.find((m) => m.role === 'thumbnail');
68
68
+
if (!media?.content) return null;
69
69
+
const url = getCDNImageBlobUrl({ did, blob: media.content, type: 'jpeg' });
70
70
+
if (!url) return null;
71
71
+
return { url, alt: media.alt || event.name };
72
72
+
}
73
73
+
74
74
+
function getRkey(event: EventData): string {
75
75
+
return event.url.split('/').pop() || '';
76
76
+
}
77
77
+
78
78
+
let actorPrefix = $derived(data.hostProfile?.handle ? `/${data.hostProfile.handle}` : `/${did}`);
79
79
+
</script>
80
80
+
81
81
+
<svelte:head>
82
82
+
<title>{hostName} - Events</title>
83
83
+
<meta name="description" content="Events hosted by {hostName}" />
84
84
+
<meta property="og:title" content="{hostName} - Events" />
85
85
+
<meta property="og:description" content="Events hosted by {hostName}" />
86
86
+
<meta name="twitter:card" content="summary" />
87
87
+
<meta name="twitter:title" content="{hostName} - Events" />
88
88
+
<meta name="twitter:description" content="Events hosted by {hostName}" />
89
89
+
</svelte:head>
90
90
+
91
91
+
<div class="bg-base-50 dark:bg-base-950 min-h-screen px-8 py-8 sm:py-12">
92
92
+
<div class="mx-auto max-w-4xl">
93
93
+
<!-- Header -->
94
94
+
<div class="mb-8 flex items-center gap-3">
95
95
+
<FoxAvatar src={hostProfile?.avatar} alt={hostName} class="size-10 shrink-0" />
96
96
+
<div>
97
97
+
<h1 class="text-base-900 dark:text-base-50 text-2xl font-bold sm:text-3xl">Events</h1>
98
98
+
<p class="text-base-500 dark:text-base-400 text-sm">Hosted by {hostName}</p>
99
99
+
</div>
100
100
+
</div>
101
101
+
102
102
+
{#if events.length === 0}
103
103
+
<p class="text-base-500 dark:text-base-400 py-12 text-center">No events found.</p>
104
104
+
{:else}
105
105
+
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
106
106
+
{#each events as event (event.url)}
107
107
+
{@const thumbnail = getThumbnail(event)}
108
108
+
{@const location = getLocationString(event.locations)}
109
109
+
{@const rkey = getRkey(event)}
110
110
+
<a
111
111
+
href="{actorPrefix}/e/{rkey}"
112
112
+
class="border-base-200 dark:border-base-800 hover:border-base-300 dark:hover:border-base-700 group block overflow-hidden rounded-xl border transition-colors"
113
113
+
>
114
114
+
<!-- Thumbnail -->
115
115
+
{#if thumbnail}
116
116
+
<img
117
117
+
src={thumbnail.url}
118
118
+
alt={thumbnail.alt}
119
119
+
class="aspect-[3/2] w-full object-cover"
120
120
+
/>
121
121
+
{:else}
122
122
+
<div
123
123
+
class="bg-base-100 dark:bg-base-900 aspect-[3/2] w-full [&>svg]:h-full [&>svg]:w-full"
124
124
+
>
125
125
+
<Avatar
126
126
+
size={400}
127
127
+
name={rkey}
128
128
+
variant="marble"
129
129
+
colors={['#92A1C6', '#146A7C', '#F0AB3D', '#C271B4', '#C20D90']}
130
130
+
square
131
131
+
/>
132
132
+
</div>
133
133
+
{/if}
134
134
+
135
135
+
<!-- Content -->
136
136
+
<div class="p-4">
137
137
+
<h2
138
138
+
class="text-base-900 dark:text-base-50 group-hover:text-base-700 dark:group-hover:text-base-200 mb-1 leading-snug font-semibold"
139
139
+
>
140
140
+
{event.name}
141
141
+
</h2>
142
142
+
143
143
+
<p class="text-base-500 dark:text-base-400 mb-2 text-sm">
144
144
+
{formatDate(event.startsAt)} · {formatTime(event.startsAt)}
145
145
+
</p>
146
146
+
147
147
+
<div class="flex flex-wrap items-center gap-2">
148
148
+
{#if event.mode}
149
149
+
<Badge size="sm" variant={getModeColor(event.mode)}
150
150
+
>{getModeLabel(event.mode)}</Badge
151
151
+
>
152
152
+
{/if}
153
153
+
154
154
+
{#if location}
155
155
+
<span class="text-base-500 dark:text-base-400 truncate text-xs">{location}</span>
156
156
+
{/if}
157
157
+
</div>
158
158
+
159
159
+
{#if event.countGoing && event.countGoing > 0}
160
160
+
<p class="text-base-500 dark:text-base-400 mt-2 text-xs">
161
161
+
{event.countGoing} going
162
162
+
</p>
163
163
+
{/if}
164
164
+
</div>
165
165
+
</a>
166
166
+
{/each}
167
167
+
</div>
168
168
+
{/if}
169
169
+
</div>
170
170
+
</div>
+15
-5
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 { getCDNImageBlobUrl } from '$lib/atproto';
3
4
import { Avatar as FoxAvatar, Badge } from '@foxui/core';
4
5
import Avatar from 'svelte-boring-avatars';
5
6
import EventRsvp from './EventRsvp.svelte';
7
7
+
import { page } from '$app/state';
6
8
7
9
let { data } = $props();
8
10
···
80
82
let headerImage = $derived.by(() => {
81
83
if (!eventData.media || eventData.media.length === 0) return null;
82
84
const media = eventData.media.find((m) => m.role === 'thumbnail');
83
83
-
if (!media?.content?.ref?.$link) return null;
84
84
-
return {
85
85
-
url: `https://cdn.bsky.app/img/feed_thumbnail/plain/${did}/${media.content.ref.$link}@jpeg`,
86
86
-
alt: media.alt || eventData.name
87
87
-
};
85
85
+
if (!media?.content) return null;
86
86
+
const url = getCDNImageBlobUrl({ did, blob: media.content, type: 'jpeg' });
87
87
+
if (!url) return null;
88
88
+
return { url, alt: media.alt || eventData.name };
88
89
});
89
90
90
91
let eventUrl = $derived(eventData.url || `https://smokesignal.events/${did}/${rkey}`);
91
92
let eventUri = $derived(`at://${did}/community.lexicon.calendar.event/${rkey}`);
93
93
+
94
94
+
let ogImageUrl = $derived(`${page.url.origin}${page.url.pathname}/og.png`);
92
95
</script>
93
96
94
97
<svelte:head>
95
98
<title>{eventData.name}</title>
96
99
<meta name="description" content={eventData.description || `Event: ${eventData.name}`} />
100
100
+
<meta property="og:title" content={eventData.name} />
101
101
+
<meta property="og:description" content={eventData.description || `Event: ${eventData.name}`} />
102
102
+
<meta property="og:image" content={ogImageUrl} />
103
103
+
<meta name="twitter:card" content="summary_large_image" />
104
104
+
<meta name="twitter:title" content={eventData.name} />
105
105
+
<meta name="twitter:description" content={eventData.description || `Event: ${eventData.name}`} />
106
106
+
<meta name="twitter:image" content={ogImageUrl} />
97
107
</svelte:head>
98
108
99
109
<div class="bg-base-50 dark:bg-base-950 min-h-screen px-8 py-8 sm:py-12">
+84
src/routes/[[actor=actor]]/e/[rkey]/og.png/+server.ts
···
1
1
+
import { getCDNImageBlobUrl, resolveHandle } from '$lib/atproto/methods.js';
2
2
+
import { env as publicEnv } from '$env/dynamic/public';
3
3
+
4
4
+
import type { ActorIdentifier } from '@atcute/lexicons';
5
5
+
import { isHandle } from '@atcute/lexicons/syntax';
6
6
+
import type { EventData } from '$lib/cards/social/EventCard';
7
7
+
import { ImageResponse } from '@ethercorps/sveltekit-og';
8
8
+
import { error } from '@sveltejs/kit';
9
9
+
import EventOgImage from './EventOgImage.svelte';
10
10
+
11
11
+
function formatDate(dateStr: string): string {
12
12
+
const date = new Date(dateStr);
13
13
+
const weekday = date.toLocaleDateString('en-US', { weekday: 'long' });
14
14
+
const month = date.toLocaleDateString('en-US', { month: 'long' });
15
15
+
const day = date.getDate();
16
16
+
return `${weekday}, ${month} ${day}`;
17
17
+
}
18
18
+
19
19
+
export async function GET({ params, platform, request }) {
20
20
+
const { rkey } = params;
21
21
+
22
22
+
const customDomain = request.headers.get('X-Custom-Domain')?.toLowerCase();
23
23
+
24
24
+
let actor: ActorIdentifier | undefined = params.actor;
25
25
+
26
26
+
if (!actor) {
27
27
+
const kv = platform?.env?.CUSTOM_DOMAINS;
28
28
+
29
29
+
if (kv && customDomain) {
30
30
+
try {
31
31
+
const did = await kv.get(customDomain);
32
32
+
if (did) actor = did as ActorIdentifier;
33
33
+
} catch (error) {
34
34
+
console.error('failed to get custom domain kv', error);
35
35
+
}
36
36
+
} else {
37
37
+
actor = publicEnv.PUBLIC_HANDLE as ActorIdentifier;
38
38
+
}
39
39
+
}
40
40
+
41
41
+
const did = isHandle(actor) ? await resolveHandle({ handle: actor }) : actor;
42
42
+
43
43
+
if (!did || !rkey) {
44
44
+
throw error(404, 'Event not found');
45
45
+
}
46
46
+
47
47
+
let eventData: EventData;
48
48
+
49
49
+
try {
50
50
+
const eventResponse = await fetch(
51
51
+
`https://smokesignal.events/xrpc/community.lexicon.calendar.GetEvent?repository=${encodeURIComponent(did)}&record_key=${encodeURIComponent(rkey)}`
52
52
+
);
53
53
+
54
54
+
if (!eventResponse.ok) {
55
55
+
throw error(404, 'Event not found');
56
56
+
}
57
57
+
58
58
+
eventData = await eventResponse.json();
59
59
+
} catch (e) {
60
60
+
if (e && typeof e === 'object' && 'status' in e) throw e;
61
61
+
throw error(404, 'Event not found');
62
62
+
}
63
63
+
64
64
+
const dateStr = formatDate(eventData.startsAt);
65
65
+
66
66
+
let thumbnailUrl: string | null = null;
67
67
+
if (eventData.media && eventData.media.length > 0) {
68
68
+
const media = eventData.media.find((m) => m.role === 'thumbnail');
69
69
+
if (media?.content) {
70
70
+
thumbnailUrl = getCDNImageBlobUrl({ did, blob: media.content, type: 'jpeg' }) ?? null;
71
71
+
}
72
72
+
}
73
73
+
74
74
+
return new ImageResponse(
75
75
+
EventOgImage,
76
76
+
{ width: 1200, height: 630, debug: false },
77
77
+
{
78
78
+
name: eventData.name,
79
79
+
dateStr,
80
80
+
thumbnailUrl,
81
81
+
rkey
82
82
+
}
83
83
+
);
84
84
+
}
+63
src/routes/[[actor=actor]]/e/[rkey]/og.png/EventOgImage.svelte
···
1
1
+
<script lang="ts">
2
2
+
import Avatar from 'svelte-boring-avatars';
3
3
+
4
4
+
let {
5
5
+
name,
6
6
+
dateStr,
7
7
+
thumbnailUrl,
8
8
+
rkey
9
9
+
}: {
10
10
+
name: string;
11
11
+
dateStr: string;
12
12
+
thumbnailUrl: string | null;
13
13
+
rkey: string;
14
14
+
} = $props();
15
15
+
</script>
16
16
+
17
17
+
<div class="flex h-full w-full bg-neutral-900 p-0">
18
18
+
<div class="flex h-full shrink-0 items-center px-8">
19
19
+
<div class="flex overflow-hidden rounded-3xl">
20
20
+
{#if thumbnailUrl}
21
21
+
<img src={thumbnailUrl} alt={name} width="420" height="420" style="object-fit: cover;" />
22
22
+
{:else}
23
23
+
<Avatar
24
24
+
size={420}
25
25
+
name={rkey}
26
26
+
variant="marble"
27
27
+
colors={['#92A1C6', '#146A7C', '#F0AB3D', '#C271B4', '#C20D90']}
28
28
+
square
29
29
+
/>
30
30
+
{/if}
31
31
+
</div>
32
32
+
</div>
33
33
+
34
34
+
<div class="flex min-w-0 flex-1 flex-col justify-center p-12">
35
35
+
<h1
36
36
+
class="text-7xl leading-tight font-bold text-neutral-50"
37
37
+
style="display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; word-break: break-word;"
38
38
+
>
39
39
+
{name}
40
40
+
</h1>
41
41
+
42
42
+
<div class="mt-8 flex items-center">
43
43
+
<svg
44
44
+
width="28"
45
45
+
height="28"
46
46
+
viewBox="0 0 24 24"
47
47
+
fill="none"
48
48
+
xmlns="http://www.w3.org/2000/svg"
49
49
+
>
50
50
+
<path
51
51
+
d="M8 2v3M16 2v3M3.5 9.09h17M21 8.5v8c0 3-1.5 5-5 5H8c-3.5 0-5-2-5-5v-8c0-3 1.5-5 5-5h8c3.5 0 5 2 5 5Z"
52
52
+
stroke="#a3a3a3"
53
53
+
stroke-width="1.5"
54
54
+
stroke-miterlimit="10"
55
55
+
stroke-linecap="round"
56
56
+
stroke-linejoin="round"
57
57
+
/>
58
58
+
</svg>
59
59
+
<span class="ml-3 text-2xl text-neutral-300">{dateStr}</span>
60
60
+
</div>
61
61
+
62
62
+
</div>
63
63
+
</div>