alpha
Login
or
Join now
flo-bit.dev
/
atmo-garden
Star
0
Fork
0
Atom
Configure Feed
Issues
Pull Requests
Commits
Tags
Feed URL
Select the types of activity you want to include in your feed.
[READ-ONLY] Mirror of https://github.com/flo-bit/atmo-garden.
atmo.garden
Star
0
Fork
0
Atom
Configure Feed
Issues
Pull Requests
Commits
Tags
Feed URL
Select the types of activity you want to include in your feed.
Overview
Issues
Pulls
Pipelines
add first atmo.rsvp version
author
Florian
date
4 months ago
(Mar 26, 2026, 5:14 AM +0100)
commit
0f54b149
0f54b1496c4c865414f5b82567f0869dd3b547fc
parent
453271fc
453271fc6a5c7d42b9ed8b9d9f50018e08f3fef6
+211
4 changed files
Expand all
Collapse all
Unified
Split
src
lib
atproto
server
feed.remote.ts
components
embed
EventEmbed.svelte
special
AtmoRsvpEmbed.svelte
index.ts
+54
src/lib/atproto/server/feed.remote.ts
View file
Reviewed
···
108
108
}
109
109
);
110
110
111
111
+
export const createRsvp = command(
112
112
+
v.object({
113
113
+
eventUri: v.string(),
114
114
+
eventCid: v.string(),
115
115
+
status: v.optional(v.string())
116
116
+
}),
117
117
+
async (input) => {
118
118
+
const { locals } = getRequestEvent();
119
119
+
if (!locals.client || !locals.did) error(401, 'Not authenticated');
120
120
+
121
121
+
const rkey = TID.now();
122
122
+
const res = await locals.client.post('com.atproto.repo.createRecord', {
123
123
+
input: {
124
124
+
repo: locals.did,
125
125
+
collection: 'community.lexicon.calendar.rsvp',
126
126
+
rkey,
127
127
+
record: {
128
128
+
$type: 'community.lexicon.calendar.rsvp',
129
129
+
status: input.status ?? 'community.lexicon.calendar.rsvp#going',
130
130
+
subject: { uri: input.eventUri, cid: input.eventCid },
131
131
+
createdAt: new Date().toISOString()
132
132
+
}
133
133
+
}
134
134
+
});
135
135
+
136
136
+
if (!res.ok) error(res.status, 'Failed to RSVP');
137
137
+
return { uri: res.data.uri };
138
138
+
}
139
139
+
);
140
140
+
141
141
+
export const deleteRsvp = command(
142
142
+
v.object({
143
143
+
rsvpUri: v.string()
144
144
+
}),
145
145
+
async (input) => {
146
146
+
const { locals } = getRequestEvent();
147
147
+
if (!locals.client || !locals.did) error(401, 'Not authenticated');
148
148
+
149
149
+
const parts = input.rsvpUri.split('/');
150
150
+
const rkey = parts[parts.length - 1];
151
151
+
152
152
+
const res = await locals.client.post('com.atproto.repo.deleteRecord', {
153
153
+
input: {
154
154
+
repo: locals.did,
155
155
+
collection: 'community.lexicon.calendar.rsvp',
156
156
+
rkey
157
157
+
}
158
158
+
});
159
159
+
160
160
+
if (!res.ok) error(res.status, 'Failed to cancel RSVP');
161
161
+
return { ok: true };
162
162
+
}
163
163
+
);
164
164
+
111
165
export const getPostThread = command(
112
166
v.object({
113
167
uri: v.string(),
+139
src/lib/components/embed/EventEmbed.svelte
View file
Reviewed
···
1
1
+
<script lang="ts">
2
2
+
import { onMount } from 'svelte';
3
3
+
import { Button } from '@foxui/core';
4
4
+
import { CalendarCheck, CalendarX } from '@lucide/svelte';
5
5
+
import { user } from '$lib/atproto/auth.svelte';
6
6
+
import { createRsvp, deleteRsvp } from '$lib/atproto/server/feed.remote';
7
7
+
8
8
+
const { url }: { url: string } = $props();
9
9
+
10
10
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
11
11
+
let event = $state<any>(null);
12
12
+
let loading = $state(true);
13
13
+
let rsvpLoading = $state(false);
14
14
+
15
15
+
// Current user's RSVP state
16
16
+
let myRsvpUri = $state<string | null>(null);
17
17
+
let isRsvpd = $derived(myRsvpUri !== null);
18
18
+
19
19
+
// Local count adjustments
20
20
+
let goingDelta = $state(0);
21
21
+
22
22
+
onMount(async () => {
23
23
+
try {
24
24
+
const res = await fetch(`${url}/data.json`);
25
25
+
if (res.ok) {
26
26
+
event = await res.json();
27
27
+
findMyRsvp();
28
28
+
}
29
29
+
} catch {
30
30
+
// silent
31
31
+
} finally {
32
32
+
loading = false;
33
33
+
}
34
34
+
});
35
35
+
36
36
+
function findMyRsvp() {
37
37
+
if (!user.did || !event?.rsvps) return;
38
38
+
// Check all RSVP status categories
39
39
+
for (const statusKey of Object.keys(event.rsvps)) {
40
40
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
41
41
+
const rsvp = event.rsvps[statusKey]?.find((r: any) => r.did === user.did);
42
42
+
if (rsvp) {
43
43
+
myRsvpUri = rsvp.uri;
44
44
+
return;
45
45
+
}
46
46
+
}
47
47
+
}
48
48
+
49
49
+
async function toggleRsvp() {
50
50
+
if (!event?.record || rsvpLoading) return;
51
51
+
rsvpLoading = true;
52
52
+
try {
53
53
+
if (isRsvpd) {
54
54
+
await deleteRsvp({ rsvpUri: myRsvpUri! });
55
55
+
myRsvpUri = null;
56
56
+
goingDelta--;
57
57
+
} else {
58
58
+
const result = await createRsvp({
59
59
+
eventUri: event.uri,
60
60
+
eventCid: event.cid
61
61
+
});
62
62
+
myRsvpUri = result.uri;
63
63
+
goingDelta++;
64
64
+
}
65
65
+
} catch (e) {
66
66
+
console.error('Failed to toggle RSVP:', e);
67
67
+
} finally {
68
68
+
rsvpLoading = false;
69
69
+
}
70
70
+
}
71
71
+
72
72
+
function formatDate(dateStr: string): string {
73
73
+
const d = new Date(dateStr);
74
74
+
return d.toLocaleDateString([], { weekday: 'short', month: 'short', day: 'numeric' });
75
75
+
}
76
76
+
77
77
+
function formatTime(dateStr: string): string {
78
78
+
return new Date(dateStr).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
79
79
+
}
80
80
+
81
81
+
function getThumbnail(): string | null {
82
82
+
if (!event?.record?.media) return null;
83
83
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
84
84
+
const thumb = event.record.media.find((m: any) => m.role === 'thumbnail');
85
85
+
if (!thumb?.content?.ref?.$link) return null;
86
86
+
return `https://cdn.bsky.app/img/avatar/plain/${event.did}/${thumb.content.ref.$link}@jpeg`;
87
87
+
}
88
88
+
</script>
89
89
+
90
90
+
{#if loading}
91
91
+
<div class="border-base-300 dark:border-base-600/30 bg-base-200/50 dark:bg-base-800/50 flex h-24 items-center justify-center rounded-2xl border">
92
92
+
<div class="text-base-400 animate-pulse text-sm">Loading event...</div>
93
93
+
</div>
94
94
+
{:else if event?.record}
95
95
+
<div class="border-base-300 dark:border-base-600/30 bg-base-200/50 dark:bg-base-800/50 overflow-hidden rounded-2xl border">
96
96
+
<a
97
97
+
href={url}
98
98
+
target="_blank"
99
99
+
rel="noopener noreferrer"
100
100
+
class="hover:bg-base-200 dark:hover:bg-base-800 flex items-center gap-4 p-4 transition-colors"
101
101
+
>
102
102
+
{#if getThumbnail()}
103
103
+
<img src={getThumbnail()} alt="" class="size-18 shrink-0 rounded-xl object-cover" />
104
104
+
{/if}
105
105
+
<div class="min-w-0 flex-1">
106
106
+
<h3 class="text-base-900 dark:text-base-100 line-clamp-2 text-lg font-bold leading-tight">
107
107
+
{event.record.name}
108
108
+
</h3>
109
109
+
<div class="text-base-500 dark:text-base-400 mt-1.5 flex flex-wrap items-center gap-x-3 gap-y-0.5 text-xs">
110
110
+
{#if event.record.startsAt}
111
111
+
<span>{formatDate(event.record.startsAt)}, {formatTime(event.record.startsAt)}</span>
112
112
+
{/if}
113
113
+
{#if (event.rsvpsGoingCount + goingDelta) > 0 || event.rsvpsInterestedCount > 0}
114
114
+
<span>{event.rsvpsGoingCount + goingDelta} going, {event.rsvpsInterestedCount} interested</span>
115
115
+
{/if}
116
116
+
</div>
117
117
+
</div>
118
118
+
</a>
119
119
+
{#if user.did}
120
120
+
<div class="border-base-300/50 dark:border-base-600/20 border-t px-4 py-2.5">
121
121
+
<Button
122
122
+
variant={isRsvpd ? 'outline' : 'default'}
123
123
+
size="sm"
124
124
+
onclick={toggleRsvp}
125
125
+
disabled={rsvpLoading}
126
126
+
class="w-full gap-1.5"
127
127
+
>
128
128
+
{#if isRsvpd}
129
129
+
<CalendarX size={14} />
130
130
+
Cancel RSVP
131
131
+
{:else}
132
132
+
<CalendarCheck size={14} />
133
133
+
RSVP Going
134
134
+
{/if}
135
135
+
</Button>
136
136
+
</div>
137
137
+
{/if}
138
138
+
</div>
139
139
+
{/if}
+11
src/lib/components/embed/special/AtmoRsvpEmbed.svelte
View file
Reviewed
···
1
1
+
<script lang="ts">
2
2
+
import type { EmbedExternalData } from '../types';
3
3
+
import EventEmbed from '../EventEmbed.svelte';
4
4
+
5
5
+
const { data }: { data: EmbedExternalData } = $props();
6
6
+
7
7
+
// Normalize URL: strip trailing slash
8
8
+
const url = data.external.href.replace(/\/$/, '');
9
9
+
</script>
10
10
+
11
11
+
<EventEmbed {url} />
+7
src/lib/components/embed/special/index.ts
View file
Reviewed
···
2
2
import type { EmbedExternalData } from '../types';
3
3
import YouTubeEmbed from './YouTubeEmbed.svelte';
4
4
import TenorEmbed from './TenorEmbed.svelte';
5
5
+
import AtmoRsvpEmbed from './AtmoRsvpEmbed.svelte';
5
6
6
7
export type SpecialEmbed = {
7
8
match: (data: EmbedExternalData) => boolean;
···
21
22
return /^https?:\/\/(www\.|media\.)?tenor\.com\//.test(data.external.href);
22
23
},
23
24
component: TenorEmbed
25
25
+
},
26
26
+
{
27
27
+
match: (data) => {
28
28
+
return /^https?:\/\/(www\.)?atmo\.rsvp\/p\//.test(data.external.href);
29
29
+
},
30
30
+
component: AtmoRsvpEmbed
24
31
}
25
32
];
26
33