tangled
alpha
login
or
join now
margin.at
/
margin
90
fork
atom
Margin is an open annotation layer for the internet. Powered by the AT Protocol.
margin.at
extension
web
atproto
comments
90
fork
atom
overview
issues
4
pulls
1
pipelines
atmosphereconf 2026 event for margin
scanash.com
4 days ago
a43f552b
ce26ad9c
+179
-52
8 changed files
expand all
collapse all
unified
split
web
astro.config.mjs
src
components
feed
FeedItems.tsx
layouts
AppLayout.astro
views
collections
Collections.tsx
core
Feed.tsx
Notifications.tsx
Search.tsx
profile
Profile.tsx
+2
-2
web/astro.config.mjs
···
12
12
adapter: node({ mode: "standalone" }),
13
13
integrations: [react(), tailwind()],
14
14
prefetch: {
15
15
-
prefetchAll: false,
16
16
-
defaultStrategy: "hover",
15
15
+
prefetchAll: true,
16
16
+
defaultStrategy: "viewport",
17
17
},
18
18
security: {
19
19
checkOrigin: true,
+38
-7
web/src/components/feed/FeedItems.tsx
···
51
51
setOffset(cached.offset);
52
52
setLoading(false);
53
53
54
54
-
getFeed({ type, motivation, tag, creator, source, limit: LIMIT, offset: 0 })
54
54
+
getFeed({
55
55
+
type,
56
56
+
motivation,
57
57
+
tag,
58
58
+
creator,
59
59
+
source,
60
60
+
limit: LIMIT,
61
61
+
offset: 0,
62
62
+
})
55
63
.then((data) => {
56
64
if (cancelled) return;
57
65
const fetched = data.items;
58
66
setItems(fetched);
59
67
setHasMore(data.hasMore);
60
68
setOffset(data.fetchedCount);
61
61
-
feedCache.set(cacheKey, { items: fetched, hasMore: data.hasMore, offset: data.fetchedCount, timestamp: Date.now() });
69
69
+
feedCache.set(cacheKey, {
70
70
+
items: fetched,
71
71
+
hasMore: data.hasMore,
72
72
+
offset: data.fetchedCount,
73
73
+
timestamp: Date.now(),
74
74
+
});
62
75
})
63
76
.catch(console.error);
64
64
-
65
65
-
return () => { cancelled = true; };
77
77
+
78
78
+
return () => {
79
79
+
cancelled = true;
80
80
+
};
66
81
}
67
82
68
83
setLoading(true);
···
74
89
setHasMore(data.hasMore);
75
90
setOffset(data.fetchedCount);
76
91
setLoading(false);
77
77
-
feedCache.set(cacheKey, { items: fetched, hasMore: data.hasMore, offset: data.fetchedCount, timestamp: Date.now() });
92
92
+
feedCache.set(cacheKey, {
93
93
+
items: fetched,
94
94
+
hasMore: data.hasMore,
95
95
+
offset: data.fetchedCount,
96
96
+
timestamp: Date.now(),
97
97
+
});
78
98
})
79
99
.catch((e) => {
80
100
if (cancelled) return;
···
92
112
const loadMore = useCallback(async () => {
93
113
setLoadingMore(true);
94
114
try {
95
95
-
const cacheKey = JSON.stringify({ type, motivation, tag, creator, source });
115
115
+
const cacheKey = JSON.stringify({
116
116
+
type,
117
117
+
motivation,
118
118
+
tag,
119
119
+
creator,
120
120
+
source,
121
121
+
});
96
122
const data = await getFeed({
97
123
type,
98
124
motivation,
···
108
134
setHasMore(data.hasMore);
109
135
const newOffset = offset + data.fetchedCount;
110
136
setOffset(newOffset);
111
111
-
feedCache.set(cacheKey, { items: newItems, hasMore: data.hasMore, offset: newOffset, timestamp: Date.now() });
137
137
+
feedCache.set(cacheKey, {
138
138
+
items: newItems,
139
139
+
hasMore: data.hasMore,
140
140
+
offset: newOffset,
141
141
+
timestamp: Date.now(),
142
142
+
});
112
143
} catch (e) {
113
144
console.error(e);
114
145
} finally {
+50
-8
web/src/layouts/AppLayout.astro
···
1
1
---
2
2
-
import BaseLayout from './BaseLayout.astro';
3
3
-
import Sidebar from '../components/navigation/Sidebar';
4
4
-
import RightSidebar from '../components/navigation/RightSidebar';
5
5
-
import MobileNav from '../components/navigation/MobileNav';
6
6
-
import type { UserProfile } from '../types';
2
2
+
import BaseLayout from "./BaseLayout.astro";
3
3
+
import Sidebar from "../components/navigation/Sidebar";
4
4
+
import RightSidebar from "../components/navigation/RightSidebar";
5
5
+
import MobileNav from "../components/navigation/MobileNav";
6
6
+
import { BlueskyIcon } from "../components/common/Icons";
7
7
+
import type { UserProfile } from "../types";
7
8
8
9
interface Props {
9
10
title?: string;
···
16
17
---
17
18
18
19
<BaseLayout title={title} description={description} image={image}>
20
20
+
<div
21
21
+
class="bg-blue-600 dark:bg-blue-500 text-white font-medium text-sm flex items-center justify-center gap-x-3 gap-y-1 flex-wrap py-2 px-4 w-full z-50"
22
22
+
>
23
23
+
<div
24
24
+
class="w-12 h-9 overflow-hidden rounded flex items-start justify-center -my-1"
25
25
+
>
26
26
+
<img
27
27
+
src="https://atmosphereconf.org/_image?href=%2F_astro%2Fgoodstuff-goose.DKPXDrcQ.png&w=792&h=990&f=webp"
28
28
+
alt="Atmosphere Goose"
29
29
+
class="w-12 h-12 object-cover object-top drop-shadow-md"
30
30
+
/>
31
31
+
</div>
32
32
+
<span
33
33
+
>Welcome to <a
34
34
+
href="https://atmosphereconf.org/"
35
35
+
target="_blank"
36
36
+
rel="noopener noreferrer"
37
37
+
class="font-bold underline hover:text-blue-200 transition-colors"
38
38
+
>ATmosphereConf 2026</a
39
39
+
>!</span
40
40
+
>
41
41
+
<a
42
42
+
href="https://bsky.app/profile/atmosphereconf.org/feed/atmosphereconf"
43
43
+
target="_blank"
44
44
+
rel="noopener noreferrer"
45
45
+
class="hover:text-blue-200 transition-colors flex items-center gap-1.5 ml-1 bg-blue-700/50 hover:bg-blue-700 px-2 py-0.5 rounded-full"
46
46
+
>
47
47
+
<BlueskyIcon size={14} color="currentColor" />
48
48
+
<span>View feed</span>
49
49
+
</a>
50
50
+
</div>
19
51
<div class="min-h-screen bg-surface-100 dark:bg-surface-900 flex">
20
52
<div transition:persist="sidebar">
21
21
-
<Sidebar client:load initialUser={user} currentPath={Astro.url.pathname} />
53
53
+
<Sidebar
54
54
+
client:idle
55
55
+
initialUser={user}
56
56
+
currentPath={Astro.url.pathname}
57
57
+
/>
22
58
</div>
23
59
24
60
<div class="flex-1 min-w-0 transition-all duration-200">
25
61
<div class="flex w-full max-w-[1800px] mx-auto">
26
62
<main class="flex-1 w-full min-w-0 py-2 md:py-3">
27
27
-
<div class="bg-white dark:bg-surface-800 rounded-2xl min-h-[calc(100vh-16px)] md:min-h-[calc(100vh-24px)] py-6 px-4 md:px-6 lg:px-8 pb-20 md:pb-6">
63
63
+
<div
64
64
+
class="bg-white dark:bg-surface-800 rounded-2xl min-h-[calc(100vh-16px)] md:min-h-[calc(100vh-24px)] py-6 px-4 md:px-6 lg:px-8 pb-20 md:pb-6"
65
65
+
>
28
66
<slot />
29
67
</div>
30
68
</main>
···
36
74
</div>
37
75
38
76
<div transition:persist="mobile-nav">
39
39
-
<MobileNav client:load initialUser={user} currentPath={Astro.url.pathname} />
77
77
+
<MobileNav
78
78
+
client:media="(max-width: 768px)"
79
79
+
initialUser={user}
80
80
+
currentPath={Astro.url.pathname}
81
81
+
/>
40
82
</div>
41
83
</div>
42
84
</BaseLayout>
+12
-7
web/src/views/collections/Collections.tsx
···
34
34
const [creating, setCreating] = useState(false);
35
35
36
36
const fetchCollections = async () => {
37
37
-
if (collectionsCache.data && Date.now() - collectionsCache.timestamp < 5 * 60 * 1000) {
37
37
+
if (
38
38
+
collectionsCache.data &&
39
39
+
Date.now() - collectionsCache.timestamp < 5 * 60 * 1000
40
40
+
) {
38
41
setCollections(collectionsCache.data);
39
42
setLoading(false);
40
40
-
41
41
-
getCollections().then(data => {
42
42
-
setCollections(data);
43
43
-
collectionsCache.data = data;
44
44
-
collectionsCache.timestamp = Date.now();
45
45
-
}).catch(console.error);
43
43
+
44
44
+
getCollections()
45
45
+
.then((data) => {
46
46
+
setCollections(data);
47
47
+
collectionsCache.data = data;
48
48
+
collectionsCache.timestamp = Date.now();
49
49
+
})
50
50
+
.catch(console.error);
46
51
return;
47
52
}
48
53
+8
-2
web/src/views/core/Feed.tsx
···
65
65
const tabs = [
66
66
{ id: "all", label: "Recent" },
67
67
{ id: "popular", label: "Popular" },
68
68
+
{ id: "atmosphereconf", label: "ATmosphereConf" },
68
69
{ id: "shelved", label: "Shelved" },
69
70
{ id: "margin", label: "Margin" },
70
71
{ id: "semble", label: "Semble" },
···
157
158
158
159
<FeedItems
159
160
key={`${activeTab}-${activeFilter || "all"}-${tag || ""}`}
160
160
-
type={activeTab}
161
161
+
type={activeTab === "atmosphereconf" ? "all" : activeTab}
161
162
motivation={activeFilter}
162
163
emptyMessage={emptyMessage}
163
164
layout={layout}
164
164
-
tag={tag}
165
165
+
tag={
166
166
+
activeTab === "atmosphereconf" ||
167
167
+
tag?.toLowerCase() === "atmosphereconf"
168
168
+
? "ATmosphereConf"
169
169
+
: tag
170
170
+
}
165
171
/>
166
172
</div>
167
173
);
+13
-8
web/src/views/core/Notifications.tsx
···
228
228
229
229
useEffect(() => {
230
230
const load = async () => {
231
231
-
if (notificationsCache.data && Date.now() - notificationsCache.timestamp < 5 * 60 * 1000) {
231
231
+
if (
232
232
+
notificationsCache.data &&
233
233
+
Date.now() - notificationsCache.timestamp < 5 * 60 * 1000
234
234
+
) {
232
235
setNotifications(notificationsCache.data);
233
236
setLoading(false);
234
234
-
235
235
-
getNotifications().then(data => {
236
236
-
setNotifications(data);
237
237
-
notificationsCache.data = data;
238
238
-
notificationsCache.timestamp = Date.now();
239
239
-
}).catch(console.error);
240
240
-
237
237
+
238
238
+
getNotifications()
239
239
+
.then((data) => {
240
240
+
setNotifications(data);
241
241
+
notificationsCache.data = data;
242
242
+
notificationsCache.timestamp = Date.now();
243
243
+
})
244
244
+
.catch(console.error);
245
245
+
241
246
markNotificationsRead();
242
247
return;
243
248
}
+34
-14
web/src/views/core/Search.tsx
···
66
66
setResults([]);
67
67
return;
68
68
}
69
69
-
70
70
-
const cacheKey = JSON.stringify({ q: q.trim(), myItemsOnly: myItemsRef.current });
71
71
-
69
69
+
70
70
+
const cacheKey = JSON.stringify({
71
71
+
q: q.trim(),
72
72
+
myItemsOnly: myItemsRef.current,
73
73
+
});
74
74
+
72
75
if (!append && newOffset === 0) {
73
76
const cached = searchCache.get(cacheKey);
74
77
if (cached && Date.now() - cached.timestamp < 5 * 60 * 1000) {
···
76
79
setHasMore(cached.hasMore);
77
80
setOffset(cached.offset);
78
81
setLoading(false);
79
79
-
82
82
+
80
83
const id = ++fetchIdRef.current;
81
84
searchItems(q.trim(), {
82
85
creator: myItemsRef.current && user ? user.did : undefined,
83
86
limit: 30,
84
87
offset: newOffset,
85
85
-
}).then(data => {
86
86
-
if (id !== fetchIdRef.current) return;
87
87
-
setResults(data.items);
88
88
-
setHasMore(data.hasMore);
89
89
-
setOffset(newOffset + data.items.length);
90
90
-
searchCache.set(cacheKey, { results: data.items, hasMore: data.hasMore, offset: newOffset + data.items.length, timestamp: Date.now() });
91
91
-
}).catch(console.error);
92
92
-
88
88
+
})
89
89
+
.then((data) => {
90
90
+
if (id !== fetchIdRef.current) return;
91
91
+
setResults(data.items);
92
92
+
setHasMore(data.hasMore);
93
93
+
setOffset(newOffset + data.items.length);
94
94
+
searchCache.set(cacheKey, {
95
95
+
results: data.items,
96
96
+
hasMore: data.hasMore,
97
97
+
offset: newOffset + data.items.length,
98
98
+
timestamp: Date.now(),
99
99
+
});
100
100
+
})
101
101
+
.catch(console.error);
102
102
+
93
103
return;
94
104
}
95
105
}
···
105
115
if (append) {
106
116
setResults((prev) => {
107
117
const newResults = [...prev, ...data.items];
108
108
-
searchCache.set(cacheKey, { results: newResults, hasMore: data.hasMore, offset: newOffset + data.items.length, timestamp: Date.now() });
118
118
+
searchCache.set(cacheKey, {
119
119
+
results: newResults,
120
120
+
hasMore: data.hasMore,
121
121
+
offset: newOffset + data.items.length,
122
122
+
timestamp: Date.now(),
123
123
+
});
109
124
return newResults;
110
125
});
111
126
} else {
112
127
setResults(data.items);
113
113
-
searchCache.set(cacheKey, { results: data.items, hasMore: data.hasMore, offset: newOffset + data.items.length, timestamp: Date.now() });
128
128
+
searchCache.set(cacheKey, {
129
129
+
results: data.items,
130
130
+
hasMore: data.hasMore,
131
131
+
offset: newOffset + data.items.length,
132
132
+
timestamp: Date.now(),
133
133
+
});
114
134
}
115
135
setHasMore(data.hasMore);
116
136
setOffset(newOffset + data.items.length);
+22
-4
web/src/views/profile/Profile.tsx
···
186
186
try {
187
187
const rel = await getModerationRelationship(did);
188
188
setModRelation(rel);
189
189
-
profileCache.set(did, { profile: merged, labels: marginData?.labels || [], relation: rel, timestamp: Date.now() });
189
189
+
profileCache.set(did, {
190
190
+
profile: merged,
191
191
+
labels: marginData?.labels || [],
192
192
+
relation: rel,
193
193
+
timestamp: Date.now(),
194
194
+
});
190
195
} catch {
191
191
-
profileCache.set(did, { profile: merged, labels: marginData?.labels || [], relation: modRelation, timestamp: Date.now() });
196
196
+
profileCache.set(did, {
197
197
+
profile: merged,
198
198
+
labels: marginData?.labels || [],
199
199
+
relation: modRelation,
200
200
+
timestamp: Date.now(),
201
201
+
});
192
202
}
193
203
} else {
194
194
-
profileCache.set(did, { profile: merged, labels: marginData?.labels || [], relation: modRelation, timestamp: Date.now() });
204
204
+
profileCache.set(did, {
205
205
+
profile: merged,
206
206
+
labels: marginData?.labels || [],
207
207
+
relation: modRelation,
208
208
+
timestamp: Date.now(),
209
209
+
});
195
210
}
196
211
} catch (e) {
197
212
console.error("Profile load failed", e);
···
233
248
}
234
249
const res = await getCollections(resolvedDid);
235
250
setCollections(res);
236
236
-
profileCollectionsCache.set(resolvedDid, { collections: res, timestamp: Date.now() });
251
251
+
profileCollectionsCache.set(resolvedDid, {
252
252
+
collections: res,
253
253
+
timestamp: Date.now(),
254
254
+
});
237
255
}
238
256
} catch (e) {
239
257
console.error(e);