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