tangled
alpha
login
or
join now
margin.at
/
margin
86
fork
atom
Write on the margins of the internet. Powered by the AT Protocol.
margin.at
extension
web
atproto
comments
86
fork
atom
overview
issues
4
pulls
1
pipelines
feat: add 'load more' on remaining pages
Henrique Dias
4 weeks ago
9e6cc7a3
df3d263b
+192
-414
5 changed files
expand all
collapse all
unified
split
web
src
api
client.ts
components
feed
FeedItems.tsx
MasonryFeed.tsx
views
core
Feed.tsx
profile
Profile.tsx
+1
-1
web/src/api/client.ts
···
114
114
return response;
115
115
}
116
116
117
117
-
interface GetFeedParams {
117
117
+
export interface GetFeedParams {
118
118
source?: string;
119
119
type?: string;
120
120
limit?: number;
+158
web/src/components/feed/FeedItems.tsx
···
1
1
+
import { Clock, Loader2 } from "lucide-react";
2
2
+
import { useCallback, useEffect, useState } from "react";
3
3
+
import { type GetFeedParams, getFeed } from "../../api/client";
4
4
+
import Card from "../../components/common/Card";
5
5
+
import { EmptyState } from "../../components/ui";
6
6
+
import type { AnnotationItem } from "../../types";
7
7
+
8
8
+
const LIMIT = 50;
9
9
+
10
10
+
export interface FeedItemsProps extends Omit<
11
11
+
GetFeedParams,
12
12
+
"limit" | "offset"
13
13
+
> {
14
14
+
layout: "list" | "mosaic";
15
15
+
emptyMessage: string;
16
16
+
}
17
17
+
18
18
+
export default function FeedItems({
19
19
+
creator,
20
20
+
source,
21
21
+
tag,
22
22
+
type,
23
23
+
motivation,
24
24
+
emptyMessage,
25
25
+
layout,
26
26
+
}: FeedItemsProps) {
27
27
+
const [items, setItems] = useState<AnnotationItem[]>([]);
28
28
+
const [loading, setLoading] = useState(true);
29
29
+
const [loadingMore, setLoadingMore] = useState(false);
30
30
+
const [hasMore, setHasMore] = useState(false);
31
31
+
const [offset, setOffset] = useState(0);
32
32
+
33
33
+
useEffect(() => {
34
34
+
let cancelled = false;
35
35
+
36
36
+
getFeed({ type, motivation, tag, creator, source, limit: LIMIT, offset: 0 })
37
37
+
.then((data) => {
38
38
+
if (cancelled) return;
39
39
+
const fetched = data.items;
40
40
+
setItems(fetched);
41
41
+
setHasMore(data.hasMore);
42
42
+
setOffset(data.fetchedCount);
43
43
+
setLoading(false);
44
44
+
})
45
45
+
.catch((e) => {
46
46
+
if (cancelled) return;
47
47
+
console.error(e);
48
48
+
setItems([]);
49
49
+
setHasMore(false);
50
50
+
setLoading(false);
51
51
+
});
52
52
+
53
53
+
return () => {
54
54
+
cancelled = true;
55
55
+
};
56
56
+
}, [type, motivation, tag, creator, source]);
57
57
+
58
58
+
const loadMore = useCallback(async () => {
59
59
+
setLoadingMore(true);
60
60
+
try {
61
61
+
const data = await getFeed({
62
62
+
type,
63
63
+
motivation,
64
64
+
tag,
65
65
+
creator,
66
66
+
source,
67
67
+
limit: LIMIT,
68
68
+
offset,
69
69
+
});
70
70
+
const fetched = data?.items || [];
71
71
+
setItems((prev) => [...prev, ...fetched]);
72
72
+
setHasMore(data.hasMore);
73
73
+
setOffset((prev) => prev + data.fetchedCount);
74
74
+
} catch (e) {
75
75
+
console.error(e);
76
76
+
} finally {
77
77
+
setLoadingMore(false);
78
78
+
}
79
79
+
}, [type, motivation, tag, creator, source, offset]);
80
80
+
81
81
+
const handleDelete = (uri: string) => {
82
82
+
setItems((prev) => prev.filter((i) => i.uri !== uri));
83
83
+
};
84
84
+
85
85
+
if (loading) {
86
86
+
return (
87
87
+
<div className="flex flex-col items-center justify-center py-20 gap-3">
88
88
+
<Loader2
89
89
+
className="animate-spin text-primary-600 dark:text-primary-400"
90
90
+
size={32}
91
91
+
/>
92
92
+
<p className="text-sm text-surface-400 dark:text-surface-500">
93
93
+
Loading...
94
94
+
</p>
95
95
+
</div>
96
96
+
);
97
97
+
}
98
98
+
99
99
+
if (items.length === 0) {
100
100
+
return (
101
101
+
<EmptyState
102
102
+
icon={<Clock size={48} />}
103
103
+
title="Nothing here yet"
104
104
+
message={emptyMessage}
105
105
+
/>
106
106
+
);
107
107
+
}
108
108
+
109
109
+
const loadMoreButton = hasMore && (
110
110
+
<div className="flex justify-center py-6">
111
111
+
<button
112
112
+
type="button"
113
113
+
onClick={loadMore}
114
114
+
disabled={loadingMore}
115
115
+
className="inline-flex items-center gap-2 px-5 py-2.5 text-sm font-medium rounded-xl bg-surface-100 dark:bg-surface-800 text-surface-600 dark:text-surface-300 hover:bg-surface-200 dark:hover:bg-surface-700 transition-colors disabled:opacity-50"
116
116
+
>
117
117
+
{loadingMore ? (
118
118
+
<>
119
119
+
<Loader2 size={16} className="animate-spin" />
120
120
+
Loading...
121
121
+
</>
122
122
+
) : (
123
123
+
"Load more"
124
124
+
)}
125
125
+
</button>
126
126
+
</div>
127
127
+
);
128
128
+
129
129
+
if (layout === "mosaic") {
130
130
+
return (
131
131
+
<>
132
132
+
<div className="columns-1 sm:columns-2 xl:columns-3 2xl:columns-4 gap-4 animate-fade-in">
133
133
+
{items.map((item) => (
134
134
+
<div key={item.uri || item.cid} className="break-inside-avoid mb-4">
135
135
+
<Card item={item} onDelete={handleDelete} layout="mosaic" />
136
136
+
</div>
137
137
+
))}
138
138
+
</div>
139
139
+
{loadMoreButton}
140
140
+
</>
141
141
+
);
142
142
+
}
143
143
+
144
144
+
return (
145
145
+
<>
146
146
+
<div className="space-y-3 animate-fade-in">
147
147
+
{items.map((item) => (
148
148
+
<Card
149
149
+
key={item.uri || item.cid}
150
150
+
item={item}
151
151
+
onDelete={handleDelete}
152
152
+
/>
153
153
+
))}
154
154
+
</div>
155
155
+
{loadMoreButton}
156
156
+
</>
157
157
+
);
158
158
+
}
+14
-110
web/src/components/feed/MasonryFeed.tsx
···
1
1
import { useStore as useNanoStore, useStore } from "@nanostores/react";
2
2
-
import { Loader2 } from "lucide-react";
3
3
-
import React, { useEffect, useState } from "react";
4
4
-
import { getFeed } from "../../api/client";
2
2
+
import { useState } from "react";
5
3
import { $user } from "../../store/auth";
6
4
import { $feedLayout } from "../../store/feedLayout";
7
7
-
import type { AnnotationItem } from "../../types";
8
8
-
import Card from "../common/Card";
9
9
-
import { EmptyState, Tabs } from "../ui";
5
5
+
import { Tabs } from "../ui";
10
6
import LayoutToggle from "../ui/LayoutToggle";
7
7
+
import FeedItems from "./FeedItems";
11
8
12
9
interface MasonryFeedProps {
13
10
motivation?: string;
···
16
13
title?: string;
17
14
}
18
15
19
19
-
function MasonryContent({
20
20
-
tab,
21
21
-
motivation,
22
22
-
emptyMessage,
23
23
-
userDid,
24
24
-
layout,
25
25
-
}: {
26
26
-
tab: string;
27
27
-
motivation?: string;
28
28
-
emptyMessage: string;
29
29
-
userDid?: string;
30
30
-
layout: "list" | "mosaic";
31
31
-
}) {
32
32
-
const [items, setItems] = useState<AnnotationItem[]>([]);
33
33
-
const [loading, setLoading] = useState(true);
34
34
-
35
35
-
useEffect(() => {
36
36
-
let cancelled = false;
37
37
-
38
38
-
const params: { type?: string; motivation?: string; creator?: string } = {
39
39
-
motivation,
40
40
-
};
41
41
-
42
42
-
if (tab === "my" && userDid) {
43
43
-
params.creator = userDid;
44
44
-
params.type = "my-feed";
45
45
-
} else {
46
46
-
params.type = "all";
47
47
-
}
48
48
-
49
49
-
getFeed(params)
50
50
-
.then((data) => {
51
51
-
if (cancelled) return;
52
52
-
setItems(data.items);
53
53
-
setLoading(false);
54
54
-
})
55
55
-
.catch((e) => {
56
56
-
if (cancelled) return;
57
57
-
console.error(e);
58
58
-
setItems([]);
59
59
-
setLoading(false);
60
60
-
});
61
61
-
62
62
-
return () => {
63
63
-
cancelled = true;
64
64
-
};
65
65
-
}, [tab, motivation, userDid]);
66
66
-
67
67
-
const handleDelete = (uri: string) => {
68
68
-
setItems((prev) => prev.filter((i) => i.uri !== uri));
69
69
-
};
70
70
-
71
71
-
if (loading) {
72
72
-
return (
73
73
-
<div className="flex justify-center py-20">
74
74
-
<Loader2
75
75
-
className="animate-spin text-primary-600 dark:text-primary-400"
76
76
-
size={32}
77
77
-
/>
78
78
-
</div>
79
79
-
);
80
80
-
}
81
81
-
82
82
-
if (items.length === 0) {
83
83
-
return (
84
84
-
<EmptyState
85
85
-
message={
86
86
-
tab === "my"
87
87
-
? emptyMessage
88
88
-
: `No ${motivation === "bookmarking" ? "bookmarks" : "highlights"} from the community yet.`
89
89
-
}
90
90
-
/>
91
91
-
);
92
92
-
}
93
93
-
94
94
-
if (layout === "list") {
95
95
-
return (
96
96
-
<div className="space-y-3 animate-fade-in">
97
97
-
{items.map((item) => (
98
98
-
<Card
99
99
-
key={item.uri || item.cid}
100
100
-
item={item}
101
101
-
onDelete={handleDelete}
102
102
-
/>
103
103
-
))}
104
104
-
</div>
105
105
-
);
106
106
-
}
107
107
-
108
108
-
return (
109
109
-
<div className="columns-1 sm:columns-2 md:columns-3 xl:columns-4 gap-4 animate-fade-in">
110
110
-
{items.map((item) => (
111
111
-
<div key={item.uri || item.cid} className="break-inside-avoid mb-4">
112
112
-
<Card item={item} onDelete={handleDelete} layout="mosaic" />
113
113
-
</div>
114
114
-
))}
115
115
-
</div>
116
116
-
);
117
117
-
}
118
118
-
119
16
export default function MasonryFeed({
120
17
motivation,
121
18
emptyMessage = "No items found.",
···
139
36
]
140
37
: [{ id: "global", label: "Global" }];
141
38
39
39
+
const creator = activeTab === "my" ? user?.did : undefined;
40
40
+
const type = activeTab === "my" ? "my-feed" : "all";
41
41
+
142
42
return (
143
43
<div className="mx-auto max-w-2xl xl:max-w-none">
144
44
{title && (
···
168
68
</div>
169
69
)}
170
70
171
171
-
<MasonryContent
71
71
+
<FeedItems
172
72
key={activeTab}
173
173
-
tab={activeTab}
73
73
+
type={type}
174
74
motivation={motivation}
175
175
-
emptyMessage={emptyMessage}
176
176
-
userDid={user?.did}
75
75
+
emptyMessage={
76
76
+
activeTab === "my"
77
77
+
? emptyMessage
78
78
+
: `No ${motivation === "bookmarking" ? "bookmarks" : "highlights"} from the community yet.`
79
79
+
}
80
80
+
creator={creator}
177
81
layout={layout}
178
82
/>
179
83
</div>
+6
-159
web/src/views/core/Feed.tsx
···
1
1
import { useStore } from "@nanostores/react";
2
2
import { clsx } from "clsx";
3
3
-
import {
4
4
-
Bookmark,
5
5
-
Clock,
6
6
-
Highlighter,
7
7
-
Loader2,
8
8
-
MessageSquareText,
9
9
-
} from "lucide-react";
10
10
-
import React, { useCallback, useEffect, useState } from "react";
3
3
+
import { Bookmark, Highlighter, MessageSquareText } from "lucide-react";
4
4
+
import { useState } from "react";
11
5
import { useSearchParams } from "react-router-dom";
12
12
-
import { getFeed } from "../../api/client";
13
13
-
import Card from "../../components/common/Card";
14
14
-
import { Button, EmptyState, Tabs } from "../../components/ui";
6
6
+
import FeedItems from "../../components/feed/FeedItems";
7
7
+
import { Button, Tabs } from "../../components/ui";
15
8
import LayoutToggle from "../../components/ui/LayoutToggle";
16
9
import { $user } from "../../store/auth";
17
10
import { $feedLayout } from "../../store/feedLayout";
18
18
-
import type { AnnotationItem } from "../../types";
19
11
20
12
interface FeedProps {
21
13
initialType?: string;
···
24
16
emptyMessage?: string;
25
17
}
26
18
27
27
-
function FeedContent({
28
28
-
type,
29
29
-
motivation,
30
30
-
emptyMessage,
31
31
-
layout,
32
32
-
tag,
33
33
-
}: {
34
34
-
type: string;
35
35
-
motivation?: string;
36
36
-
emptyMessage: string;
37
37
-
layout: "list" | "mosaic";
38
38
-
tag?: string;
39
39
-
}) {
40
40
-
const [items, setItems] = useState<AnnotationItem[]>([]);
41
41
-
const [loading, setLoading] = useState(true);
42
42
-
const [loadingMore, setLoadingMore] = useState(false);
43
43
-
const [hasMore, setHasMore] = useState(false);
44
44
-
const [offset, setOffset] = useState(0);
45
45
-
46
46
-
const LIMIT = 50;
47
47
-
48
48
-
useEffect(() => {
49
49
-
let cancelled = false;
50
50
-
51
51
-
getFeed({ type, motivation, tag, limit: LIMIT, offset: 0 })
52
52
-
.then((data) => {
53
53
-
if (cancelled) return;
54
54
-
const fetched = data.items;
55
55
-
setItems(fetched);
56
56
-
setHasMore(data.hasMore);
57
57
-
setOffset(data.fetchedCount);
58
58
-
setLoading(false);
59
59
-
})
60
60
-
.catch((e) => {
61
61
-
if (cancelled) return;
62
62
-
console.error(e);
63
63
-
setItems([]);
64
64
-
setHasMore(false);
65
65
-
setLoading(false);
66
66
-
});
67
67
-
68
68
-
return () => {
69
69
-
cancelled = true;
70
70
-
};
71
71
-
}, [type, motivation, tag]);
72
72
-
73
73
-
const loadMore = useCallback(async () => {
74
74
-
setLoadingMore(true);
75
75
-
try {
76
76
-
const data = await getFeed({
77
77
-
type,
78
78
-
motivation,
79
79
-
tag,
80
80
-
limit: LIMIT,
81
81
-
offset,
82
82
-
});
83
83
-
const fetched = data?.items || [];
84
84
-
setItems((prev) => [...prev, ...fetched]);
85
85
-
setHasMore(data.hasMore);
86
86
-
setOffset((prev) => prev + data.fetchedCount);
87
87
-
} catch (e) {
88
88
-
console.error(e);
89
89
-
} finally {
90
90
-
setLoadingMore(false);
91
91
-
}
92
92
-
}, [type, motivation, tag, offset]);
93
93
-
94
94
-
const handleDelete = (uri: string) => {
95
95
-
setItems((prev) => prev.filter((i) => i.uri !== uri));
96
96
-
};
97
97
-
98
98
-
if (loading) {
99
99
-
return (
100
100
-
<div className="flex flex-col items-center justify-center py-20 gap-3">
101
101
-
<Loader2
102
102
-
className="animate-spin text-primary-600 dark:text-primary-400"
103
103
-
size={32}
104
104
-
/>
105
105
-
<p className="text-sm text-surface-400 dark:text-surface-500">
106
106
-
Loading feed...
107
107
-
</p>
108
108
-
</div>
109
109
-
);
110
110
-
}
111
111
-
112
112
-
if (items.length === 0) {
113
113
-
return (
114
114
-
<EmptyState
115
115
-
icon={<Clock size={48} />}
116
116
-
title="Nothing here yet"
117
117
-
message={emptyMessage}
118
118
-
/>
119
119
-
);
120
120
-
}
121
121
-
122
122
-
const loadMoreButton = hasMore && (
123
123
-
<div className="flex justify-center py-6">
124
124
-
<button
125
125
-
onClick={loadMore}
126
126
-
disabled={loadingMore}
127
127
-
className="inline-flex items-center gap-2 px-5 py-2.5 text-sm font-medium rounded-xl bg-surface-100 dark:bg-surface-800 text-surface-600 dark:text-surface-300 hover:bg-surface-200 dark:hover:bg-surface-700 transition-colors disabled:opacity-50"
128
128
-
>
129
129
-
{loadingMore ? (
130
130
-
<>
131
131
-
<Loader2 size={16} className="animate-spin" />
132
132
-
Loading...
133
133
-
</>
134
134
-
) : (
135
135
-
"Load more"
136
136
-
)}
137
137
-
</button>
138
138
-
</div>
139
139
-
);
140
140
-
141
141
-
if (layout === "mosaic") {
142
142
-
return (
143
143
-
<>
144
144
-
<div className="columns-1 sm:columns-2 xl:columns-3 2xl:columns-4 gap-4 animate-fade-in">
145
145
-
{items.map((item) => (
146
146
-
<div key={item.uri || item.cid} className="break-inside-avoid mb-4">
147
147
-
<Card item={item} onDelete={handleDelete} layout="mosaic" />
148
148
-
</div>
149
149
-
))}
150
150
-
</div>
151
151
-
{loadMoreButton}
152
152
-
</>
153
153
-
);
154
154
-
}
155
155
-
156
156
-
return (
157
157
-
<>
158
158
-
<div className="space-y-3 animate-fade-in">
159
159
-
{items.map((item) => (
160
160
-
<Card
161
161
-
key={item.uri || item.cid}
162
162
-
item={item}
163
163
-
onDelete={handleDelete}
164
164
-
/>
165
165
-
))}
166
166
-
</div>
167
167
-
{loadMoreButton}
168
168
-
</>
169
169
-
);
170
170
-
}
171
171
-
172
19
export default function Feed({
173
20
initialType = "all",
174
21
motivation,
···
306
153
</div>
307
154
)}
308
155
309
309
-
<FeedContent
156
156
+
<FeedItems
310
157
key={`${activeTab}-${activeFilter || "all"}-${tag || ""}`}
311
158
type={activeTab}
312
159
motivation={activeFilter}
313
313
-
tag={tag}
314
160
emptyMessage={emptyMessage}
315
161
layout={layout}
162
162
+
tag={tag}
316
163
/>
317
164
</div>
318
165
);
+13
-144
web/src/views/profile/Profile.tsx
···
1
1
import { useStore } from "@nanostores/react";
2
2
import { clsx } from "clsx";
3
3
import {
4
4
-
Bookmark,
5
4
Edit2,
6
5
Eye,
7
6
EyeOff,
···
11
10
Link2,
12
11
Linkedin,
13
12
Loader2,
14
14
-
MessageSquare,
15
15
-
PenTool,
16
13
ShieldBan,
17
14
ShieldOff,
18
15
Volume2,
19
16
VolumeX,
20
17
} from "lucide-react";
21
21
-
import type React from "react";
22
22
-
import { useCallback, useEffect, useRef, useState } from "react";
18
18
+
import { useEffect, useRef, useState } from "react";
23
19
import { Link } from "react-router-dom";
24
20
import {
25
21
blockUser,
26
22
getCollections,
27
27
-
getFeed,
28
23
getModerationRelationship,
29
24
getProfile,
30
25
muteUser,
31
26
unblockUser,
32
27
unmuteUser,
33
28
} from "../../api/client";
34
34
-
import Card from "../../components/common/Card";
35
29
import CollectionIcon from "../../components/common/CollectionIcon";
36
30
import { BlueskyIcon, TangledIcon } from "../../components/common/Icons";
37
31
import type { MoreMenuItem } from "../../components/common/MoreMenu";
38
32
import MoreMenu from "../../components/common/MoreMenu";
39
33
import RichText from "../../components/common/RichText";
34
34
+
import FeedItems from "../../components/feed/FeedItems";
40
35
import EditProfileModal from "../../components/modals/EditProfileModal";
41
36
import ExternalLinkModal from "../../components/modals/ExternalLinkModal";
42
37
import ReportModal from "../../components/modals/ReportModal";
···
50
45
import { $user } from "../../store/auth";
51
46
import { $preferences, loadPreferences } from "../../store/preferences";
52
47
import type {
53
53
-
AnnotationItem,
54
48
Collection,
55
49
ContentLabel,
56
50
ModerationRelationship,
···
71
65
collections: undefined,
72
66
};
73
67
74
74
-
const LIMIT = 50;
75
75
-
76
68
export default function Profile({ did }: ProfileProps) {
77
69
const [profile, setProfile] = useState<UserProfile | null>(null);
78
70
const [loading, setLoading] = useState(true);
···
81
73
const [collections, setCollections] = useState<Collection[]>([]);
82
74
const [dataLoading, setDataLoading] = useState(false);
83
75
84
84
-
const [items, setItems] = useState<{
85
85
-
all: AnnotationItem[];
86
86
-
annotations: AnnotationItem[];
87
87
-
highlights: AnnotationItem[];
88
88
-
bookmarks: AnnotationItem[];
89
89
-
}>({
90
90
-
all: [],
91
91
-
annotations: [],
92
92
-
highlights: [],
93
93
-
bookmarks: [],
94
94
-
});
95
95
-
96
76
const user = useStore($user);
97
77
const isOwner = user?.did === did;
98
78
const [showEdit, setShowEdit] = useState(false);
99
79
const [externalLink, setExternalLink] = useState<string | null>(null);
100
80
const [showReportModal, setShowReportModal] = useState(false);
101
101
-
const [loadingMore, setLoadingMore] = useState(false);
102
102
-
const [loadMoreError, setLoadMoreError] = useState<string | null>(null);
103
103
-
const [pagination, setPagination] = useState<
104
104
-
Record<string, { hasMore: boolean; offset: number }>
105
105
-
>({
106
106
-
all: { hasMore: false, offset: 0 },
107
107
-
annotations: { hasMore: false, offset: 0 },
108
108
-
highlights: { hasMore: false, offset: 0 },
109
109
-
bookmarks: { hasMore: false, offset: 0 },
110
110
-
});
111
81
const loadMoreTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
112
82
const [modRelation, setModRelation] = useState<ModerationRelationship>({
113
83
blocking: false,
···
146
116
147
117
useEffect(() => {
148
118
setProfile(null);
149
149
-
setItems({
150
150
-
all: [],
151
151
-
annotations: [],
152
152
-
highlights: [],
153
153
-
bookmarks: [],
154
154
-
});
155
119
setCollections([]);
156
120
setActiveTab("all");
157
121
setLoading(true);
···
218
182
};
219
183
}, []);
220
184
185
185
+
const isHandle = !did.startsWith("did:");
186
186
+
const resolvedDid = isHandle ? profile?.did : did;
187
187
+
221
188
useEffect(() => {
222
189
const loadTabContent = async () => {
223
190
const isHandle = !did.startsWith("did:");
···
226
193
if (!resolvedDid) return;
227
194
228
195
setDataLoading(true);
229
229
-
setPagination((prev) => ({
230
230
-
...prev,
231
231
-
[activeTab]: { hasMore: false, offset: 0 },
232
232
-
}));
233
196
try {
234
234
-
if (
235
235
-
["all", "annotations", "highlights", "bookmarks"].includes(activeTab)
236
236
-
) {
237
237
-
const res = await getFeed({
238
238
-
creator: resolvedDid,
239
239
-
limit: LIMIT,
240
240
-
motivation: motivationMap[activeTab],
241
241
-
});
242
242
-
setItems((prev) => ({
243
243
-
...prev,
244
244
-
[activeTab]: res.items,
245
245
-
}));
246
246
-
setPagination((prev) => ({
247
247
-
...prev,
248
248
-
[activeTab]: {
249
249
-
hasMore: res.hasMore,
250
250
-
offset: res.fetchedCount,
251
251
-
},
252
252
-
}));
253
253
-
} else if (activeTab === "collections") {
197
197
+
if (activeTab === "collections") {
254
198
const res = await getCollections(resolvedDid);
255
199
setCollections(res);
256
200
}
···
263
207
loadTabContent();
264
208
}, [profile?.did, did, activeTab]);
265
209
266
266
-
const loadMore = useCallback(async () => {
267
267
-
if (activeTab === "collections") return;
268
268
-
269
269
-
const isHandle = !did.startsWith("did:");
270
270
-
const resolvedDid = isHandle ? profile?.did : did;
271
271
-
if (!resolvedDid) return;
272
272
-
273
273
-
const tabPagination = pagination[activeTab];
274
274
-
if (!tabPagination) return;
275
275
-
276
276
-
const capturedTab = activeTab;
277
277
-
setLoadingMore(true);
278
278
-
setLoadMoreError(null);
279
279
-
280
280
-
try {
281
281
-
const res = await getFeed({
282
282
-
creator: resolvedDid,
283
283
-
motivation: motivationMap[capturedTab],
284
284
-
limit: LIMIT,
285
285
-
offset: tabPagination.offset,
286
286
-
});
287
287
-
setItems((prev) => ({
288
288
-
...prev,
289
289
-
[capturedTab]: [...prev[capturedTab], ...res.items],
290
290
-
}));
291
291
-
setPagination((prev) => ({
292
292
-
...prev,
293
293
-
[capturedTab]: {
294
294
-
hasMore: res.hasMore,
295
295
-
offset: prev[capturedTab].offset + res.fetchedCount,
296
296
-
},
297
297
-
}));
298
298
-
} catch (e) {
299
299
-
console.error(e);
300
300
-
const msg = e instanceof Error ? e.message : "Something went wrong";
301
301
-
setLoadMoreError(msg);
302
302
-
if (loadMoreTimerRef.current) clearTimeout(loadMoreTimerRef.current);
303
303
-
loadMoreTimerRef.current = setTimeout(() => setLoadMoreError(null), 5000);
304
304
-
} finally {
305
305
-
setLoadingMore(false);
306
306
-
}
307
307
-
}, [did, profile?.did, activeTab, pagination]);
308
308
-
309
210
if (loading) {
310
211
return (
311
212
<div className="max-w-2xl mx-auto animate-fade-in">
···
344
245
{ id: "bookmarks", label: "Bookmarks" },
345
246
{ id: "collections", label: "Collections" },
346
247
];
347
347
-
348
348
-
const currentItems = activeTab !== "collections" ? items[activeTab] : [];
349
248
350
249
const LABEL_DESCRIPTIONS: Record<string, string> = {
351
250
sexual: "Sexual Content",
···
728
627
))}
729
628
</div>
730
629
)
731
731
-
) : currentItems.length > 0 ? (
732
732
-
<div className="space-y-3">
733
733
-
{currentItems.map((item) => (
734
734
-
<Card key={item.uri || item.cid} item={item} />
735
735
-
))}
736
736
-
</div>
737
630
) : (
738
738
-
<EmptyState
739
739
-
icon={
740
740
-
activeTab === "annotations" ? (
741
741
-
<MessageSquare size={40} />
742
742
-
) : activeTab === "highlights" ? (
743
743
-
<PenTool size={40} />
744
744
-
) : (
745
745
-
<Bookmark size={40} />
746
746
-
)
747
747
-
}
748
748
-
message={
631
631
+
<FeedItems
632
632
+
key={activeTab}
633
633
+
type="all"
634
634
+
motivation={motivationMap[activeTab]}
635
635
+
creator={resolvedDid}
636
636
+
layout="list"
637
637
+
emptyMessage={
749
638
isOwner
750
639
? `You haven't added any ${activeTab} yet.`
751
640
: `No ${activeTab}`
752
641
}
753
642
/>
754
754
-
)}
755
755
-
756
756
-
{activeTab !== "collections" && pagination[activeTab]?.hasMore && (
757
757
-
<div className="flex flex-col items-center gap-2 py-6">
758
758
-
{loadMoreError && (
759
759
-
<p className="text-sm text-red-500 dark:text-red-400">
760
760
-
Failed to load more: {loadMoreError}
761
761
-
</p>
762
762
-
)}
763
763
-
<Button
764
764
-
variant="secondary"
765
765
-
size="sm"
766
766
-
onClick={loadMore}
767
767
-
loading={loadingMore}
768
768
-
disabled={loadingMore}
769
769
-
className="rounded-xl"
770
770
-
>
771
771
-
Load more
772
772
-
</Button>
773
773
-
</div>
774
643
)}
775
644
</div>
776
645