(READ ONLY) Margin is an open annotation layer for the internet. Powered by the AT Protocol.
margin.at
extension
web
atproto
comments
1import React, { useEffect, useState } from "react";
2import { getFeed } from "../../api/client";
3import Card from "../common/Card";
4import { Loader2 } from "lucide-react";
5import { useStore } from "@nanostores/react";
6import { $user } from "../../store/auth";
7import type { AnnotationItem } from "../../types";
8import { Tabs, EmptyState } from "../ui";
9import LayoutToggle from "../ui/LayoutToggle";
10import { useStore as useNanoStore } from "@nanostores/react";
11import { $feedLayout } from "../../store/feedLayout";
12
13interface MasonryFeedProps {
14 motivation?: string;
15 emptyMessage?: string;
16 showTabs?: boolean;
17 title?: string;
18}
19
20function MasonryContent({
21 tab,
22 motivation,
23 emptyMessage,
24 userDid,
25 layout,
26}: {
27 tab: string;
28 motivation?: string;
29 emptyMessage: string;
30 userDid?: string;
31 layout: "list" | "mosaic";
32}) {
33 const [items, setItems] = useState<AnnotationItem[]>([]);
34 const [loading, setLoading] = useState(true);
35
36 useEffect(() => {
37 let cancelled = false;
38
39 const params: { type?: string; motivation?: string; creator?: string } = {
40 motivation,
41 };
42
43 if (tab === "my" && userDid) {
44 params.creator = userDid;
45 params.type = "my-feed";
46 } else {
47 params.type = "all";
48 }
49
50 getFeed(params)
51 .then((data) => {
52 if (cancelled) return;
53 setItems(data?.items || []);
54 setLoading(false);
55 })
56 .catch((e) => {
57 if (cancelled) return;
58 console.error(e);
59 setItems([]);
60 setLoading(false);
61 });
62
63 return () => {
64 cancelled = true;
65 };
66 }, [tab, motivation, userDid]);
67
68 const handleDelete = (uri: string) => {
69 setItems((prev) => prev.filter((i) => i.uri !== uri));
70 };
71
72 if (loading) {
73 return (
74 <div className="flex justify-center py-20">
75 <Loader2
76 className="animate-spin text-primary-600 dark:text-primary-400"
77 size={32}
78 />
79 </div>
80 );
81 }
82
83 if (items.length === 0) {
84 return (
85 <EmptyState
86 message={
87 tab === "my"
88 ? emptyMessage
89 : `No ${motivation === "bookmarking" ? "bookmarks" : "highlights"} from the community yet.`
90 }
91 />
92 );
93 }
94
95 if (layout === "list") {
96 return (
97 <div className="space-y-3 animate-fade-in">
98 {items.map((item) => (
99 <Card
100 key={item.uri || item.cid}
101 item={item}
102 onDelete={handleDelete}
103 />
104 ))}
105 </div>
106 );
107 }
108
109 return (
110 <div className="columns-1 sm:columns-2 xl:columns-3 2xl:columns-4 gap-4 animate-fade-in">
111 {items.map((item) => (
112 <div key={item.uri || item.cid} className="break-inside-avoid mb-4">
113 <Card item={item} onDelete={handleDelete} />
114 </div>
115 ))}
116 </div>
117 );
118}
119
120export default function MasonryFeed({
121 motivation,
122 emptyMessage = "No items found.",
123 showTabs = false,
124 title,
125}: MasonryFeedProps) {
126 const user = useStore($user);
127 const layout = useNanoStore($feedLayout);
128 const [activeTab, setActiveTab] = useState(user ? "my" : "global");
129
130 const handleTabChange = (id: string) => {
131 if (id === activeTab) return;
132 setActiveTab(id);
133 window.scrollTo({ top: 0, behavior: "smooth" });
134 };
135
136 const tabs = user
137 ? [
138 { id: "my", label: "My" },
139 { id: "global", label: "Global" },
140 ]
141 : [{ id: "global", label: "Global" }];
142
143 return (
144 <div className="mx-auto max-w-2xl xl:max-w-none">
145 {title && (
146 <h1 className="text-3xl font-display font-bold text-surface-900 dark:text-white mb-6 text-center lg:text-left">
147 {title}
148 </h1>
149 )}
150
151 {showTabs && (
152 <div className="sticky top-0 z-10 bg-surface-50/95 dark:bg-surface-950/95 backdrop-blur-sm pb-4 mb-2 -mx-1 px-1 pt-1">
153 <div className="flex items-center gap-3">
154 <div className="flex-1">
155 <Tabs
156 tabs={tabs}
157 activeTab={activeTab}
158 onChange={handleTabChange}
159 />
160 </div>
161 <LayoutToggle />
162 </div>
163 </div>
164 )}
165
166 {!showTabs && (
167 <div className="flex justify-end mb-4">
168 <LayoutToggle />
169 </div>
170 )}
171
172 <MasonryContent
173 key={activeTab}
174 tab={activeTab}
175 motivation={motivation}
176 emptyMessage={emptyMessage}
177 userDid={user?.did}
178 layout={layout}
179 />
180 </div>
181 );
182}