Write on the margins of the internet. Powered by the AT Protocol. margin.at
extension web atproto comments

Merge pull request #47 from hacdias/feat/44

feat: add 'load more' on remaining pages

authored by

Scan and committed by
GitHub
40c34f31 b38753c2

+192 -414
+1 -1
web/src/api/client.ts
··· 114 114 return response; 115 115 } 116 116 117 - interface GetFeedParams { 117 + export interface GetFeedParams { 118 118 source?: string; 119 119 type?: string; 120 120 limit?: number;
+158
web/src/components/feed/FeedItems.tsx
··· 1 + import { Clock, Loader2 } from "lucide-react"; 2 + import { useCallback, useEffect, useState } from "react"; 3 + import { type GetFeedParams, getFeed } from "../../api/client"; 4 + import Card from "../../components/common/Card"; 5 + import { EmptyState } from "../../components/ui"; 6 + import type { AnnotationItem } from "../../types"; 7 + 8 + const LIMIT = 50; 9 + 10 + export interface FeedItemsProps extends Omit< 11 + GetFeedParams, 12 + "limit" | "offset" 13 + > { 14 + layout: "list" | "mosaic"; 15 + emptyMessage: string; 16 + } 17 + 18 + export default function FeedItems({ 19 + creator, 20 + source, 21 + tag, 22 + type, 23 + motivation, 24 + emptyMessage, 25 + layout, 26 + }: FeedItemsProps) { 27 + const [items, setItems] = useState<AnnotationItem[]>([]); 28 + const [loading, setLoading] = useState(true); 29 + const [loadingMore, setLoadingMore] = useState(false); 30 + const [hasMore, setHasMore] = useState(false); 31 + const [offset, setOffset] = useState(0); 32 + 33 + useEffect(() => { 34 + let cancelled = false; 35 + 36 + getFeed({ type, motivation, tag, creator, source, limit: LIMIT, offset: 0 }) 37 + .then((data) => { 38 + if (cancelled) return; 39 + const fetched = data.items; 40 + setItems(fetched); 41 + setHasMore(data.hasMore); 42 + setOffset(data.fetchedCount); 43 + setLoading(false); 44 + }) 45 + .catch((e) => { 46 + if (cancelled) return; 47 + console.error(e); 48 + setItems([]); 49 + setHasMore(false); 50 + setLoading(false); 51 + }); 52 + 53 + return () => { 54 + cancelled = true; 55 + }; 56 + }, [type, motivation, tag, creator, source]); 57 + 58 + const loadMore = useCallback(async () => { 59 + setLoadingMore(true); 60 + try { 61 + const data = await getFeed({ 62 + type, 63 + motivation, 64 + tag, 65 + creator, 66 + source, 67 + limit: LIMIT, 68 + offset, 69 + }); 70 + const fetched = data?.items || []; 71 + setItems((prev) => [...prev, ...fetched]); 72 + setHasMore(data.hasMore); 73 + setOffset((prev) => prev + data.fetchedCount); 74 + } catch (e) { 75 + console.error(e); 76 + } finally { 77 + setLoadingMore(false); 78 + } 79 + }, [type, motivation, tag, creator, source, offset]); 80 + 81 + const handleDelete = (uri: string) => { 82 + setItems((prev) => prev.filter((i) => i.uri !== uri)); 83 + }; 84 + 85 + if (loading) { 86 + return ( 87 + <div className="flex flex-col items-center justify-center py-20 gap-3"> 88 + <Loader2 89 + className="animate-spin text-primary-600 dark:text-primary-400" 90 + size={32} 91 + /> 92 + <p className="text-sm text-surface-400 dark:text-surface-500"> 93 + Loading... 94 + </p> 95 + </div> 96 + ); 97 + } 98 + 99 + if (items.length === 0) { 100 + return ( 101 + <EmptyState 102 + icon={<Clock size={48} />} 103 + title="Nothing here yet" 104 + message={emptyMessage} 105 + /> 106 + ); 107 + } 108 + 109 + const loadMoreButton = hasMore && ( 110 + <div className="flex justify-center py-6"> 111 + <button 112 + type="button" 113 + onClick={loadMore} 114 + disabled={loadingMore} 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 + > 117 + {loadingMore ? ( 118 + <> 119 + <Loader2 size={16} className="animate-spin" /> 120 + Loading... 121 + </> 122 + ) : ( 123 + "Load more" 124 + )} 125 + </button> 126 + </div> 127 + ); 128 + 129 + if (layout === "mosaic") { 130 + return ( 131 + <> 132 + <div className="columns-1 sm:columns-2 xl:columns-3 2xl:columns-4 gap-4 animate-fade-in"> 133 + {items.map((item) => ( 134 + <div key={item.uri || item.cid} className="break-inside-avoid mb-4"> 135 + <Card item={item} onDelete={handleDelete} layout="mosaic" /> 136 + </div> 137 + ))} 138 + </div> 139 + {loadMoreButton} 140 + </> 141 + ); 142 + } 143 + 144 + return ( 145 + <> 146 + <div className="space-y-3 animate-fade-in"> 147 + {items.map((item) => ( 148 + <Card 149 + key={item.uri || item.cid} 150 + item={item} 151 + onDelete={handleDelete} 152 + /> 153 + ))} 154 + </div> 155 + {loadMoreButton} 156 + </> 157 + ); 158 + }
+14 -110
web/src/components/feed/MasonryFeed.tsx
··· 1 1 import { useStore as useNanoStore, useStore } from "@nanostores/react"; 2 - import { Loader2 } from "lucide-react"; 3 - import React, { useEffect, useState } from "react"; 4 - import { getFeed } from "../../api/client"; 2 + import { useState } from "react"; 5 3 import { $user } from "../../store/auth"; 6 4 import { $feedLayout } from "../../store/feedLayout"; 7 - import type { AnnotationItem } from "../../types"; 8 - import Card from "../common/Card"; 9 - import { EmptyState, Tabs } from "../ui"; 5 + import { Tabs } from "../ui"; 10 6 import LayoutToggle from "../ui/LayoutToggle"; 7 + import FeedItems from "./FeedItems"; 11 8 12 9 interface MasonryFeedProps { 13 10 motivation?: string; ··· 16 13 title?: string; 17 14 } 18 15 19 - function MasonryContent({ 20 - tab, 21 - motivation, 22 - emptyMessage, 23 - userDid, 24 - layout, 25 - }: { 26 - tab: string; 27 - motivation?: string; 28 - emptyMessage: string; 29 - userDid?: string; 30 - layout: "list" | "mosaic"; 31 - }) { 32 - const [items, setItems] = useState<AnnotationItem[]>([]); 33 - const [loading, setLoading] = useState(true); 34 - 35 - useEffect(() => { 36 - let cancelled = false; 37 - 38 - const params: { type?: string; motivation?: string; creator?: string } = { 39 - motivation, 40 - }; 41 - 42 - if (tab === "my" && userDid) { 43 - params.creator = userDid; 44 - params.type = "my-feed"; 45 - } else { 46 - params.type = "all"; 47 - } 48 - 49 - getFeed(params) 50 - .then((data) => { 51 - if (cancelled) return; 52 - setItems(data.items); 53 - setLoading(false); 54 - }) 55 - .catch((e) => { 56 - if (cancelled) return; 57 - console.error(e); 58 - setItems([]); 59 - setLoading(false); 60 - }); 61 - 62 - return () => { 63 - cancelled = true; 64 - }; 65 - }, [tab, motivation, userDid]); 66 - 67 - const handleDelete = (uri: string) => { 68 - setItems((prev) => prev.filter((i) => i.uri !== uri)); 69 - }; 70 - 71 - if (loading) { 72 - return ( 73 - <div className="flex justify-center py-20"> 74 - <Loader2 75 - className="animate-spin text-primary-600 dark:text-primary-400" 76 - size={32} 77 - /> 78 - </div> 79 - ); 80 - } 81 - 82 - if (items.length === 0) { 83 - return ( 84 - <EmptyState 85 - message={ 86 - tab === "my" 87 - ? emptyMessage 88 - : `No ${motivation === "bookmarking" ? "bookmarks" : "highlights"} from the community yet.` 89 - } 90 - /> 91 - ); 92 - } 93 - 94 - if (layout === "list") { 95 - return ( 96 - <div className="space-y-3 animate-fade-in"> 97 - {items.map((item) => ( 98 - <Card 99 - key={item.uri || item.cid} 100 - item={item} 101 - onDelete={handleDelete} 102 - /> 103 - ))} 104 - </div> 105 - ); 106 - } 107 - 108 - return ( 109 - <div className="columns-1 sm:columns-2 md:columns-3 xl:columns-4 gap-4 animate-fade-in"> 110 - {items.map((item) => ( 111 - <div key={item.uri || item.cid} className="break-inside-avoid mb-4"> 112 - <Card item={item} onDelete={handleDelete} layout="mosaic" /> 113 - </div> 114 - ))} 115 - </div> 116 - ); 117 - } 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 + const creator = activeTab === "my" ? user?.did : undefined; 40 + const type = activeTab === "my" ? "my-feed" : "all"; 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 - <MasonryContent 71 + <FeedItems 172 72 key={activeTab} 173 - tab={activeTab} 73 + type={type} 174 74 motivation={motivation} 175 - emptyMessage={emptyMessage} 176 - userDid={user?.did} 75 + emptyMessage={ 76 + activeTab === "my" 77 + ? emptyMessage 78 + : `No ${motivation === "bookmarking" ? "bookmarks" : "highlights"} from the community yet.` 79 + } 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 - import { 4 - Bookmark, 5 - Clock, 6 - Highlighter, 7 - Loader2, 8 - MessageSquareText, 9 - } from "lucide-react"; 10 - import React, { useCallback, useEffect, useState } from "react"; 3 + import { Bookmark, Highlighter, MessageSquareText } from "lucide-react"; 4 + import { useState } from "react"; 11 5 import { useSearchParams } from "react-router-dom"; 12 - import { getFeed } from "../../api/client"; 13 - import Card from "../../components/common/Card"; 14 - import { Button, EmptyState, Tabs } from "../../components/ui"; 6 + import FeedItems from "../../components/feed/FeedItems"; 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 - import type { AnnotationItem } from "../../types"; 19 11 20 12 interface FeedProps { 21 13 initialType?: string; ··· 24 16 emptyMessage?: string; 25 17 } 26 18 27 - function FeedContent({ 28 - type, 29 - motivation, 30 - emptyMessage, 31 - layout, 32 - tag, 33 - }: { 34 - type: string; 35 - motivation?: string; 36 - emptyMessage: string; 37 - layout: "list" | "mosaic"; 38 - tag?: string; 39 - }) { 40 - const [items, setItems] = useState<AnnotationItem[]>([]); 41 - const [loading, setLoading] = useState(true); 42 - const [loadingMore, setLoadingMore] = useState(false); 43 - const [hasMore, setHasMore] = useState(false); 44 - const [offset, setOffset] = useState(0); 45 - 46 - const LIMIT = 50; 47 - 48 - useEffect(() => { 49 - let cancelled = false; 50 - 51 - getFeed({ type, motivation, tag, limit: LIMIT, offset: 0 }) 52 - .then((data) => { 53 - if (cancelled) return; 54 - const fetched = data.items; 55 - setItems(fetched); 56 - setHasMore(data.hasMore); 57 - setOffset(data.fetchedCount); 58 - setLoading(false); 59 - }) 60 - .catch((e) => { 61 - if (cancelled) return; 62 - console.error(e); 63 - setItems([]); 64 - setHasMore(false); 65 - setLoading(false); 66 - }); 67 - 68 - return () => { 69 - cancelled = true; 70 - }; 71 - }, [type, motivation, tag]); 72 - 73 - const loadMore = useCallback(async () => { 74 - setLoadingMore(true); 75 - try { 76 - const data = await getFeed({ 77 - type, 78 - motivation, 79 - tag, 80 - limit: LIMIT, 81 - offset, 82 - }); 83 - const fetched = data?.items || []; 84 - setItems((prev) => [...prev, ...fetched]); 85 - setHasMore(data.hasMore); 86 - setOffset((prev) => prev + data.fetchedCount); 87 - } catch (e) { 88 - console.error(e); 89 - } finally { 90 - setLoadingMore(false); 91 - } 92 - }, [type, motivation, tag, offset]); 93 - 94 - const handleDelete = (uri: string) => { 95 - setItems((prev) => prev.filter((i) => i.uri !== uri)); 96 - }; 97 - 98 - if (loading) { 99 - return ( 100 - <div className="flex flex-col items-center justify-center py-20 gap-3"> 101 - <Loader2 102 - className="animate-spin text-primary-600 dark:text-primary-400" 103 - size={32} 104 - /> 105 - <p className="text-sm text-surface-400 dark:text-surface-500"> 106 - Loading feed... 107 - </p> 108 - </div> 109 - ); 110 - } 111 - 112 - if (items.length === 0) { 113 - return ( 114 - <EmptyState 115 - icon={<Clock size={48} />} 116 - title="Nothing here yet" 117 - message={emptyMessage} 118 - /> 119 - ); 120 - } 121 - 122 - const loadMoreButton = hasMore && ( 123 - <div className="flex justify-center py-6"> 124 - <button 125 - onClick={loadMore} 126 - disabled={loadingMore} 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 - > 129 - {loadingMore ? ( 130 - <> 131 - <Loader2 size={16} className="animate-spin" /> 132 - Loading... 133 - </> 134 - ) : ( 135 - "Load more" 136 - )} 137 - </button> 138 - </div> 139 - ); 140 - 141 - if (layout === "mosaic") { 142 - return ( 143 - <> 144 - <div className="columns-1 sm:columns-2 xl:columns-3 2xl:columns-4 gap-4 animate-fade-in"> 145 - {items.map((item) => ( 146 - <div key={item.uri || item.cid} className="break-inside-avoid mb-4"> 147 - <Card item={item} onDelete={handleDelete} layout="mosaic" /> 148 - </div> 149 - ))} 150 - </div> 151 - {loadMoreButton} 152 - </> 153 - ); 154 - } 155 - 156 - return ( 157 - <> 158 - <div className="space-y-3 animate-fade-in"> 159 - {items.map((item) => ( 160 - <Card 161 - key={item.uri || item.cid} 162 - item={item} 163 - onDelete={handleDelete} 164 - /> 165 - ))} 166 - </div> 167 - {loadMoreButton} 168 - </> 169 - ); 170 - } 171 - 172 19 export default function Feed({ 173 20 initialType = "all", 174 21 motivation, ··· 306 153 </div> 307 154 )} 308 155 309 - <FeedContent 156 + <FeedItems 310 157 key={`${activeTab}-${activeFilter || "all"}-${tag || ""}`} 311 158 type={activeTab} 312 159 motivation={activeFilter} 313 - tag={tag} 314 160 emptyMessage={emptyMessage} 315 161 layout={layout} 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 - Bookmark, 5 4 Edit2, 6 5 Eye, 7 6 EyeOff, ··· 11 10 Link2, 12 11 Linkedin, 13 12 Loader2, 14 - MessageSquare, 15 - PenTool, 16 13 ShieldBan, 17 14 ShieldOff, 18 15 Volume2, 19 16 VolumeX, 20 17 } from "lucide-react"; 21 - import type React from "react"; 22 - import { useCallback, useEffect, useRef, useState } from "react"; 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 - getFeed, 28 23 getModerationRelationship, 29 24 getProfile, 30 25 muteUser, 31 26 unblockUser, 32 27 unmuteUser, 33 28 } from "../../api/client"; 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 + 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 - AnnotationItem, 54 48 Collection, 55 49 ContentLabel, 56 50 ModerationRelationship, ··· 71 65 collections: undefined, 72 66 }; 73 67 74 - const LIMIT = 50; 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 - const [items, setItems] = useState<{ 85 - all: AnnotationItem[]; 86 - annotations: AnnotationItem[]; 87 - highlights: AnnotationItem[]; 88 - bookmarks: AnnotationItem[]; 89 - }>({ 90 - all: [], 91 - annotations: [], 92 - highlights: [], 93 - bookmarks: [], 94 - }); 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 - const [loadingMore, setLoadingMore] = useState(false); 102 - const [loadMoreError, setLoadMoreError] = useState<string | null>(null); 103 - const [pagination, setPagination] = useState< 104 - Record<string, { hasMore: boolean; offset: number }> 105 - >({ 106 - all: { hasMore: false, offset: 0 }, 107 - annotations: { hasMore: false, offset: 0 }, 108 - highlights: { hasMore: false, offset: 0 }, 109 - bookmarks: { hasMore: false, offset: 0 }, 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 - setItems({ 150 - all: [], 151 - annotations: [], 152 - highlights: [], 153 - bookmarks: [], 154 - }); 155 119 setCollections([]); 156 120 setActiveTab("all"); 157 121 setLoading(true); ··· 218 182 }; 219 183 }, []); 220 184 185 + const isHandle = !did.startsWith("did:"); 186 + const resolvedDid = isHandle ? profile?.did : did; 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 - setPagination((prev) => ({ 230 - ...prev, 231 - [activeTab]: { hasMore: false, offset: 0 }, 232 - })); 233 196 try { 234 - if ( 235 - ["all", "annotations", "highlights", "bookmarks"].includes(activeTab) 236 - ) { 237 - const res = await getFeed({ 238 - creator: resolvedDid, 239 - limit: LIMIT, 240 - motivation: motivationMap[activeTab], 241 - }); 242 - setItems((prev) => ({ 243 - ...prev, 244 - [activeTab]: res.items, 245 - })); 246 - setPagination((prev) => ({ 247 - ...prev, 248 - [activeTab]: { 249 - hasMore: res.hasMore, 250 - offset: res.fetchedCount, 251 - }, 252 - })); 253 - } else if (activeTab === "collections") { 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 - const loadMore = useCallback(async () => { 267 - if (activeTab === "collections") return; 268 - 269 - const isHandle = !did.startsWith("did:"); 270 - const resolvedDid = isHandle ? profile?.did : did; 271 - if (!resolvedDid) return; 272 - 273 - const tabPagination = pagination[activeTab]; 274 - if (!tabPagination) return; 275 - 276 - const capturedTab = activeTab; 277 - setLoadingMore(true); 278 - setLoadMoreError(null); 279 - 280 - try { 281 - const res = await getFeed({ 282 - creator: resolvedDid, 283 - motivation: motivationMap[capturedTab], 284 - limit: LIMIT, 285 - offset: tabPagination.offset, 286 - }); 287 - setItems((prev) => ({ 288 - ...prev, 289 - [capturedTab]: [...prev[capturedTab], ...res.items], 290 - })); 291 - setPagination((prev) => ({ 292 - ...prev, 293 - [capturedTab]: { 294 - hasMore: res.hasMore, 295 - offset: prev[capturedTab].offset + res.fetchedCount, 296 - }, 297 - })); 298 - } catch (e) { 299 - console.error(e); 300 - const msg = e instanceof Error ? e.message : "Something went wrong"; 301 - setLoadMoreError(msg); 302 - if (loadMoreTimerRef.current) clearTimeout(loadMoreTimerRef.current); 303 - loadMoreTimerRef.current = setTimeout(() => setLoadMoreError(null), 5000); 304 - } finally { 305 - setLoadingMore(false); 306 - } 307 - }, [did, profile?.did, activeTab, pagination]); 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 - 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 - ) : currentItems.length > 0 ? ( 732 - <div className="space-y-3"> 733 - {currentItems.map((item) => ( 734 - <Card key={item.uri || item.cid} item={item} /> 735 - ))} 736 - </div> 737 630 ) : ( 738 - <EmptyState 739 - icon={ 740 - activeTab === "annotations" ? ( 741 - <MessageSquare size={40} /> 742 - ) : activeTab === "highlights" ? ( 743 - <PenTool size={40} /> 744 - ) : ( 745 - <Bookmark size={40} /> 746 - ) 747 - } 748 - message={ 631 + <FeedItems 632 + key={activeTab} 633 + type="all" 634 + motivation={motivationMap[activeTab]} 635 + creator={resolvedDid} 636 + layout="list" 637 + emptyMessage={ 749 638 isOwner 750 639 ? `You haven't added any ${activeTab} yet.` 751 640 : `No ${activeTab}` 752 641 } 753 642 /> 754 - )} 755 - 756 - {activeTab !== "collections" && pagination[activeTab]?.hasMore && ( 757 - <div className="flex flex-col items-center gap-2 py-6"> 758 - {loadMoreError && ( 759 - <p className="text-sm text-red-500 dark:text-red-400"> 760 - Failed to load more: {loadMoreError} 761 - </p> 762 - )} 763 - <Button 764 - variant="secondary" 765 - size="sm" 766 - onClick={loadMore} 767 - loading={loadingMore} 768 - disabled={loadingMore} 769 - className="rounded-xl" 770 - > 771 - Load more 772 - </Button> 773 - </div> 774 643 )} 775 644 </div> 776 645