src
modules
shared
webapp
api-client
app
components
···
1
1
-
import {
2
2
-
pgTable,
3
3
-
text,
4
4
-
timestamp,
5
5
-
jsonb,
6
6
-
uuid,
7
7
-
} from 'drizzle-orm/pg-core';
1
1
+
import { pgTable, text, timestamp, jsonb, uuid } from 'drizzle-orm/pg-core';
8
2
9
3
export const feedActivities = pgTable('feed_activities', {
10
4
id: uuid('id').primaryKey(),
···
62
62
63
63
it('should add and retrieve a card collected activity', async () => {
64
64
// Create a card collected activity
65
65
-
const activityResult = FeedActivity.createCardCollected(
66
66
-
curatorId,
67
67
-
cardId,
68
68
-
[collectionId],
69
69
-
);
65
65
+
const activityResult = FeedActivity.createCardCollected(curatorId, cardId, [
66
66
+
collectionId,
67
67
+
]);
70
68
71
69
expect(activityResult.isOk()).toBe(true);
72
70
const activity = activityResult.unwrap();
···
94
92
95
93
it('should add a card collected activity without collections', async () => {
96
94
// Create a card collected activity without collections
97
97
-
const activityResult = FeedActivity.createCardCollected(
98
98
-
curatorId,
99
99
-
cardId,
100
100
-
);
95
95
+
const activityResult = FeedActivity.createCardCollected(curatorId, cardId);
101
96
102
97
expect(activityResult.isOk()).toBe(true);
103
98
const activity = activityResult.unwrap();
···
278
273
const feed = feedResult.unwrap();
279
274
expect(feed.activities).toHaveLength(2);
280
275
281
281
-
const actorIds = feed.activities.map(a => a.actorId.value);
276
276
+
const actorIds = feed.activities.map((a) => a.actorId.value);
282
277
expect(actorIds).toContain(curatorId.value);
283
278
expect(actorIds).toContain(anotherCuratorId.value);
284
279
});
···
14
14
try {
15
15
this.activities.push(activity);
16
16
// Sort by creation time descending
17
17
-
this.activities.sort((a, b) =>
18
18
-
b.createdAt.getTime() - a.createdAt.getTime()
17
17
+
this.activities.sort(
18
18
+
(a, b) => b.createdAt.getTime() - a.createdAt.getTime(),
19
19
);
20
20
return ok(undefined);
21
21
} catch (error) {
···
32
32
33
33
// Filter by cursor if provided
34
34
if (beforeActivityId) {
35
35
-
const beforeIndex = filteredActivities.findIndex(
36
36
-
(activity) => activity.activityId.equals(beforeActivityId)
35
35
+
const beforeIndex = filteredActivities.findIndex((activity) =>
36
36
+
activity.activityId.equals(beforeActivityId),
37
37
);
38
38
if (beforeIndex >= 0) {
39
39
filteredActivities = filteredActivities.slice(beforeIndex + 1);
···
42
42
43
43
// Paginate
44
44
const offset = (page - 1) * limit;
45
45
-
const paginatedActivities = filteredActivities.slice(offset, offset + limit);
46
46
-
45
45
+
const paginatedActivities = filteredActivities.slice(
46
46
+
offset,
47
47
+
offset + limit,
48
48
+
);
49
49
+
47
50
const totalCount = this.activities.length;
48
51
const hasMore = offset + paginatedActivities.length < totalCount;
49
49
-
52
52
+
50
53
let nextCursor: ActivityId | undefined;
51
54
if (hasMore && paginatedActivities.length > 0) {
52
52
-
nextCursor = paginatedActivities[paginatedActivities.length - 1]!.activityId;
55
55
+
nextCursor =
56
56
+
paginatedActivities[paginatedActivities.length - 1]!.activityId;
53
57
}
54
58
55
59
return ok({
···
65
69
66
70
async findById(activityId: ActivityId): Promise<Result<FeedActivity | null>> {
67
71
try {
68
68
-
const activity = this.activities.find((a) => a.activityId.equals(activityId));
72
72
+
const activity = this.activities.find((a) =>
73
73
+
a.activityId.equals(activityId),
74
74
+
);
69
75
return ok(activity || null);
70
76
} catch (error) {
71
77
return err(error as Error);
···
117
117
"name": "cards_parent_card_id_cards_id_fk",
118
118
"tableFrom": "cards",
119
119
"tableTo": "cards",
120
120
-
"columnsFrom": [
121
121
-
"parent_card_id"
122
122
-
],
123
123
-
"columnsTo": [
124
124
-
"id"
125
125
-
],
120
120
+
"columnsFrom": ["parent_card_id"],
121
121
+
"columnsTo": ["id"],
126
122
"onDelete": "no action",
127
123
"onUpdate": "no action"
128
124
},
···
130
126
"name": "cards_original_published_record_id_published_records_id_fk",
131
127
"tableFrom": "cards",
132
128
"tableTo": "published_records",
133
133
-
"columnsFrom": [
134
134
-
"original_published_record_id"
135
135
-
],
136
136
-
"columnsTo": [
137
137
-
"id"
138
138
-
],
129
129
+
"columnsFrom": ["original_published_record_id"],
130
130
+
"columnsTo": ["id"],
139
131
"onDelete": "no action",
140
132
"onUpdate": "no action"
141
133
}
···
194
186
"name": "collection_cards_collection_id_collections_id_fk",
195
187
"tableFrom": "collection_cards",
196
188
"tableTo": "collections",
197
197
-
"columnsFrom": [
198
198
-
"collection_id"
199
199
-
],
200
200
-
"columnsTo": [
201
201
-
"id"
202
202
-
],
189
189
+
"columnsFrom": ["collection_id"],
190
190
+
"columnsTo": ["id"],
203
191
"onDelete": "cascade",
204
192
"onUpdate": "no action"
205
193
},
···
207
195
"name": "collection_cards_card_id_cards_id_fk",
208
196
"tableFrom": "collection_cards",
209
197
"tableTo": "cards",
210
210
-
"columnsFrom": [
211
211
-
"card_id"
212
212
-
],
213
213
-
"columnsTo": [
214
214
-
"id"
215
215
-
],
198
198
+
"columnsFrom": ["card_id"],
199
199
+
"columnsTo": ["id"],
216
200
"onDelete": "cascade",
217
201
"onUpdate": "no action"
218
202
},
···
220
204
"name": "collection_cards_published_record_id_published_records_id_fk",
221
205
"tableFrom": "collection_cards",
222
206
"tableTo": "published_records",
223
223
-
"columnsFrom": [
224
224
-
"published_record_id"
225
225
-
],
226
226
-
"columnsTo": [
227
227
-
"id"
228
228
-
],
207
207
+
"columnsFrom": ["published_record_id"],
208
208
+
"columnsTo": ["id"],
229
209
"onDelete": "no action",
230
210
"onUpdate": "no action"
231
211
}
···
265
245
"name": "collection_collaborators_collection_id_collections_id_fk",
266
246
"tableFrom": "collection_collaborators",
267
247
"tableTo": "collections",
268
268
-
"columnsFrom": [
269
269
-
"collection_id"
270
270
-
],
271
271
-
"columnsTo": [
272
272
-
"id"
273
273
-
],
248
248
+
"columnsFrom": ["collection_id"],
249
249
+
"columnsTo": ["id"],
274
250
"onDelete": "cascade",
275
251
"onUpdate": "no action"
276
252
}
···
349
325
"name": "collections_published_record_id_published_records_id_fk",
350
326
"tableFrom": "collections",
351
327
"tableTo": "published_records",
352
352
-
"columnsFrom": [
353
353
-
"published_record_id"
354
354
-
],
355
355
-
"columnsTo": [
356
356
-
"id"
357
357
-
],
328
328
+
"columnsFrom": ["published_record_id"],
329
329
+
"columnsTo": ["id"],
358
330
"onDelete": "no action",
359
331
"onUpdate": "no action"
360
332
}
···
432
404
"name": "library_memberships_card_id_cards_id_fk",
433
405
"tableFrom": "library_memberships",
434
406
"tableTo": "cards",
435
435
-
"columnsFrom": [
436
436
-
"card_id"
437
437
-
],
438
438
-
"columnsTo": [
439
439
-
"id"
440
440
-
],
407
407
+
"columnsFrom": ["card_id"],
408
408
+
"columnsTo": ["id"],
441
409
"onDelete": "cascade",
442
410
"onUpdate": "no action"
443
411
},
···
445
413
"name": "library_memberships_published_record_id_published_records_id_fk",
446
414
"tableFrom": "library_memberships",
447
415
"tableTo": "published_records",
448
448
-
"columnsFrom": [
449
449
-
"published_record_id"
450
450
-
],
451
451
-
"columnsTo": [
452
452
-
"id"
453
453
-
],
416
416
+
"columnsFrom": ["published_record_id"],
417
417
+
"columnsTo": ["id"],
454
418
"onDelete": "no action",
455
419
"onUpdate": "no action"
456
420
}
···
458
422
"compositePrimaryKeys": {
459
423
"library_memberships_card_id_user_id_pk": {
460
424
"name": "library_memberships_card_id_user_id_pk",
461
461
-
"columns": [
462
462
-
"card_id",
463
463
-
"user_id"
464
464
-
]
425
425
+
"columns": ["card_id", "user_id"]
465
426
}
466
427
},
467
428
"uniqueConstraints": {},
···
678
639
"name": "auth_refresh_tokens_user_did_users_id_fk",
679
640
"tableFrom": "auth_refresh_tokens",
680
641
"tableTo": "users",
681
681
-
"columnsFrom": [
682
682
-
"user_did"
683
683
-
],
684
684
-
"columnsTo": [
685
685
-
"id"
686
686
-
],
642
642
+
"columnsFrom": ["user_did"],
643
643
+
"columnsTo": ["id"],
687
644
"onDelete": "no action",
688
645
"onUpdate": "no action"
689
646
}
···
743
700
"schemas": {},
744
701
"tables": {}
745
702
}
746
746
-
}
703
703
+
}
···
192
192
// Create saga for worker
193
193
const cardCollectionSaga = new CardCollectionSaga(
194
194
// We'll need to create this use case in the worker context
195
195
-
null as any // Will be set properly in worker
195
195
+
null as any, // Will be set properly in worker
196
196
);
197
197
198
198
return {
···
1
1
import { BaseClient } from './BaseClient';
2
2
-
import {
3
3
-
GetGlobalFeedParams,
4
4
-
GetGlobalFeedResponse,
5
5
-
} from '../types';
2
2
+
import { GetGlobalFeedParams, GetGlobalFeedResponse } from '../types';
6
3
7
4
export class FeedClient extends BaseClient {
8
5
async getGlobalFeed(
···
11
8
const searchParams = new URLSearchParams();
12
9
if (params?.page) searchParams.set('page', params.page.toString());
13
10
if (params?.limit) searchParams.set('limit', params.limit.toString());
14
14
-
if (params?.beforeActivityId) searchParams.set('beforeActivityId', params.beforeActivityId);
11
11
+
if (params?.beforeActivityId)
12
12
+
searchParams.set('beforeActivityId', params.beforeActivityId);
15
13
16
14
const queryString = searchParams.toString();
17
15
const endpoint = queryString
···
16
16
} from '@mantine/core';
17
17
18
18
export default function ExplorePage() {
19
19
-
const [feedItems, setFeedItems] = useState<GetGlobalFeedResponse['activities']>([]);
19
19
+
const [feedItems, setFeedItems] = useState<
20
20
+
GetGlobalFeedResponse['activities']
21
21
+
>([]);
20
22
const [loading, setLoading] = useState(true);
21
23
const [loadingMore, setLoadingMore] = useState(false);
22
24
const [hasMore, setHasMore] = useState(true);
···
33
35
);
34
36
35
37
// Fetch initial feed data
36
36
-
const fetchFeed = useCallback(async (reset = false) => {
37
37
-
try {
38
38
-
if (reset) {
39
39
-
setLoading(true);
40
40
-
setError(null);
41
41
-
} else {
42
42
-
setLoadingMore(true);
43
43
-
}
38
38
+
const fetchFeed = useCallback(
39
39
+
async (reset = false) => {
40
40
+
try {
41
41
+
if (reset) {
42
42
+
setLoading(true);
43
43
+
setError(null);
44
44
+
} else {
45
45
+
setLoadingMore(true);
46
46
+
}
47
47
+
48
48
+
const response = await apiClient.getGlobalFeed({
49
49
+
limit: 20,
50
50
+
beforeActivityId: reset
51
51
+
? undefined
52
52
+
: feedItems[feedItems.length - 1]?.id,
53
53
+
});
44
54
45
45
-
const response = await apiClient.getGlobalFeed({
46
46
-
limit: 20,
47
47
-
beforeActivityId: reset ? undefined : feedItems[feedItems.length - 1]?.id,
48
48
-
});
55
55
+
if (reset) {
56
56
+
setFeedItems(response.activities);
57
57
+
} else {
58
58
+
setFeedItems((prev) => [...prev, ...response.activities]);
59
59
+
}
49
60
50
50
-
if (reset) {
51
51
-
setFeedItems(response.activities);
52
52
-
} else {
53
53
-
setFeedItems(prev => [...prev, ...response.activities]);
61
61
+
setHasMore(response.pagination.hasMore);
62
62
+
} catch (error: any) {
63
63
+
console.error('Error fetching feed:', error);
64
64
+
setError(error.message || 'Failed to load feed');
65
65
+
} finally {
66
66
+
setLoading(false);
67
67
+
setLoadingMore(false);
54
68
}
55
55
-
56
56
-
setHasMore(response.pagination.hasMore);
57
57
-
} catch (error: any) {
58
58
-
console.error('Error fetching feed:', error);
59
59
-
setError(error.message || 'Failed to load feed');
60
60
-
} finally {
61
61
-
setLoading(false);
62
62
-
setLoadingMore(false);
63
63
-
}
64
64
-
}, [apiClient, feedItems]);
69
69
+
},
70
70
+
[apiClient, feedItems],
71
71
+
);
65
72
66
73
useEffect(() => {
67
74
fetchFeed(true);
···
101
108
return (
102
109
<Stack>
103
110
<Title order={2}>Explore</Title>
104
104
-
111
111
+
105
112
{feedItems.length === 0 ? (
106
113
<Center h={200}>
107
114
<Text c="dimmed">No activity to show yet</Text>
···
111
118
{feedItems.map((item) => (
112
119
<FeedItem key={item.id} item={item} />
113
120
))}
114
114
-
121
121
+
115
122
{hasMore && (
116
123
<Center>
117
124
<Button
···
1
1
'use client';
2
2
3
3
import { useParams } from 'next/navigation';
4
4
-
import {
5
5
-
Stack,
6
6
-
Title,
7
7
-
Text,
8
8
-
Card,
9
9
-
Center,
10
10
-
} from '@mantine/core';
4
4
+
import { Stack, Title, Text, Card, Center } from '@mantine/core';
11
5
12
6
export default function ProfilePage() {
13
7
const params = useParams();
···
18
12
<Card withBorder p="xl">
19
13
<Stack align="center">
20
14
<Title order={2}>Profile Page</Title>
21
21
-
<Text c="dimmed">
22
22
-
Profile for @{handle} - Coming Soon!
23
23
-
</Text>
15
15
+
<Text c="dimmed">Profile for @{handle} - Coming Soon!</Text>
24
16
<Text size="sm" c="dimmed">
25
25
-
This page will show user profile information and their public activity.
17
17
+
This page will show user profile information and their public
18
18
+
activity.
26
19
</Text>
27
20
</Stack>
28
21
</Card>
···
3
3
import { useRouter } from 'next/navigation';
4
4
import { UrlCard } from './UrlCard';
5
5
import type { FeedItem as FeedItemType } from '@/api-client/types';
6
6
-
import {
7
7
-
Stack,
8
8
-
Text,
9
9
-
Group,
10
10
-
Anchor,
11
11
-
Box,
12
12
-
} from '@mantine/core';
6
6
+
import { Stack, Text, Group, Anchor, Box } from '@mantine/core';
13
7
14
8
interface FeedItemProps {
15
9
item: FeedItemType;
···
28
22
29
23
const renderActivityText = () => {
30
24
const { user, collections } = item;
31
31
-
25
25
+
32
26
if (collections.length === 0) {
33
27
return (
34
28
<Text size="sm" c="dimmed">
···
38
32
c="blue"
39
33
>
40
34
@{user.handle}
41
41
-
</Anchor>
42
42
-
{' '}added to library
35
35
+
</Anchor>{' '}
36
36
+
added to library
43
37
</Text>
44
38
);
45
39
}
···
53
47
c="blue"
54
48
>
55
49
@{user.handle}
56
56
-
</Anchor>
57
57
-
{' '}added to{' '}
50
50
+
</Anchor>{' '}
51
51
+
added to{' '}
58
52
<Anchor
59
53
component="button"
60
54
onClick={() => handleCollectionClick(collections[0].id)}
···
74
68
c="blue"
75
69
>
76
70
@{user.handle}
77
77
-
</Anchor>
78
78
-
{' '}added to{' '}
71
71
+
</Anchor>{' '}
72
72
+
added to{' '}
79
73
{collections.map((collection, index) => (
80
74
<span key={collection.id}>
81
75
<Anchor