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