Write on the margins of the internet. Powered by the AT Protocol.
margin.at
extension
web
atproto
comments
1import React, { useEffect, useState } from "react";
2import {
3 getCollections,
4 createCollection,
5 deleteCollection,
6} from "../../api/client";
7import { Plus, Folder, Trash2, X } from "lucide-react";
8import CollectionIcon from "../../components/common/CollectionIcon";
9import { ICON_MAP } from "../../components/common/iconMap";
10import { useStore } from "@nanostores/react";
11import { $user } from "../../store/auth";
12import EmojiPicker, { Theme } from "emoji-picker-react";
13import { $theme } from "../../store/theme";
14import type { Collection } from "../../types";
15import { formatDistanceToNow } from "date-fns";
16import { clsx } from "clsx";
17import { Button, Input, EmptyState, Skeleton } from "../../components/ui";
18
19export default function Collections() {
20 const user = useStore($user);
21 const theme = useStore($theme);
22 const [collections, setCollections] = useState<Collection[]>([]);
23 const [loading, setLoading] = useState(true);
24 const [showCreateModal, setShowCreateModal] = useState(false);
25 const [newItemName, setNewItemName] = useState("");
26 const [newItemDesc, setNewItemDesc] = useState("");
27 const [newItemIcon, setNewItemIcon] = useState("folder");
28 const [activeTab, setActiveTab] = useState<"icon" | "emoji">("icon");
29 const [creating, setCreating] = useState(false);
30
31 const fetchCollections = async () => {
32 try {
33 setLoading(true);
34 const data = await getCollections();
35 setCollections(data);
36 } catch (error) {
37 console.error("Failed to load collections:", error);
38 } finally {
39 setLoading(false);
40 }
41 };
42
43 useEffect(() => {
44 fetchCollections();
45 }, []);
46
47 const handleCreate = async (e: React.FormEvent) => {
48 e.preventDefault();
49 if (!newItemName.trim()) return;
50
51 setCreating(true);
52 const finalIcon = ICON_MAP[newItemIcon]
53 ? `icon:${newItemIcon}`
54 : newItemIcon;
55
56 const res = await createCollection(newItemName, newItemDesc, finalIcon);
57 if (res) {
58 setCollections([res, ...collections]);
59 setShowCreateModal(false);
60 setNewItemName("");
61 setNewItemDesc("");
62 setNewItemIcon("folder");
63 setActiveTab("icon");
64 fetchCollections();
65 }
66 setCreating(false);
67 };
68
69 const handleDelete = async (id: string, e: React.MouseEvent) => {
70 e.preventDefault();
71 if (window.confirm("Delete this collection?")) {
72 const success = await deleteCollection(id);
73 if (success) {
74 setCollections((prev) => prev.filter((c) => c.id !== id));
75 }
76 }
77 };
78
79 if (loading) {
80 return (
81 <div className="max-w-2xl mx-auto animate-fade-in">
82 <div className="flex items-center justify-between mb-6">
83 <div>
84 <Skeleton width="180px" className="h-8 mb-2" />
85 <Skeleton width="240px" className="h-4" />
86 </div>
87 <Skeleton width="90px" className="h-10 rounded-lg" />
88 </div>
89 <div className="space-y-2">
90 {[1, 2, 3].map((i) => (
91 <div key={i} className="card p-4 flex gap-3 items-center">
92 <Skeleton className="w-10 h-10 rounded-lg" />
93 <div className="flex-1 space-y-2">
94 <Skeleton width="50%" />
95 <Skeleton width="30%" className="h-3" />
96 </div>
97 </div>
98 ))}
99 </div>
100 </div>
101 );
102 }
103
104 return (
105 <div className="max-w-2xl mx-auto animate-slide-up">
106 <div className="flex items-center justify-between mb-6">
107 <div>
108 <h1 className="text-3xl font-display font-bold text-surface-900 dark:text-white">
109 Collections
110 </h1>
111 <p className="text-surface-500 dark:text-surface-400 mt-1">
112 Organize your annotations and highlights
113 </p>
114 </div>
115 <Button
116 onClick={() => setShowCreateModal(true)}
117 icon={<Plus size={16} />}
118 >
119 New
120 </Button>
121 </div>
122
123 {collections.length === 0 ? (
124 <EmptyState
125 icon={<Folder size={48} />}
126 title="No collections yet"
127 message="Create a collection to organize your highlights and annotations."
128 action={{
129 label: "Create collection",
130 onClick: () => setShowCreateModal(true),
131 }}
132 />
133 ) : (
134 <div className="space-y-2">
135 {collections
136 .filter((c) => c && c.id && c.name)
137 .map((collection) => (
138 <a
139 key={collection.id}
140 href={`/${collection.creator?.handle || user?.handle}/collection/${(collection.uri || "").split("/").pop()}`}
141 className="group card p-4 hover:ring-primary-300 dark:hover:ring-primary-600 transition-all flex items-center gap-4"
142 >
143 <div className="w-10 h-10 flex items-center justify-center shrink-0 bg-primary-50 dark:bg-primary-900/30 text-primary-600 dark:text-primary-400 rounded-xl">
144 <CollectionIcon icon={collection.icon} size={20} />
145 </div>
146 <div className="flex-1 min-w-0">
147 <h3 className="font-semibold text-surface-900 dark:text-white truncate group-hover:text-primary-600 dark:group-hover:text-primary-400 transition-colors">
148 {collection.name}
149 </h3>
150 <p className="text-sm text-surface-500 dark:text-surface-400">
151 {collection.itemCount}{" "}
152 {collection.itemCount === 1 ? "item" : "items"}
153 {collection.createdAt &&
154 ` · ${formatDistanceToNow(new Date(collection.createdAt), { addSuffix: true })}`}
155 </p>
156 </div>
157 {!collection.uri.includes("network.cosmik") && (
158 <button
159 onClick={(e) => handleDelete(collection.id, e)}
160 className="p-2 text-surface-400 dark:text-surface-500 hover:text-red-500 dark:hover:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-lg transition-all opacity-0 group-hover:opacity-100"
161 >
162 <Trash2 size={18} />
163 </button>
164 )}
165 </a>
166 ))}
167 </div>
168 )}
169
170 {showCreateModal && (
171 <div className="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm animate-fade-in">
172 <div className="bg-white dark:bg-surface-900 rounded-2xl shadow-2xl max-w-md w-full animate-scale-in ring-1 ring-black/5 dark:ring-white/10">
173 <div className="flex items-center justify-between p-5 border-b border-surface-100 dark:border-surface-800">
174 <h2 className="text-xl font-bold text-surface-900 dark:text-white">
175 New Collection
176 </h2>
177 <button
178 onClick={() => setShowCreateModal(false)}
179 className="p-2 text-surface-400 dark:text-surface-500 hover:bg-surface-100 dark:hover:bg-surface-800 rounded-lg transition-colors"
180 >
181 <X size={18} />
182 </button>
183 </div>
184 <form onSubmit={handleCreate} className="p-5">
185 <div className="mb-4">
186 <Input
187 label="Name"
188 value={newItemName}
189 onChange={(e) => setNewItemName(e.target.value)}
190 placeholder="e.g. Design Inspiration"
191 autoFocus
192 required
193 />
194 </div>
195 <div className="mb-4">
196 <label className="block text-sm font-medium text-surface-700 dark:text-surface-300 mb-2">
197 Icon
198 </label>
199
200 <div className="flex gap-2 mb-3 bg-surface-100 dark:bg-surface-800 p-1 rounded-xl">
201 <button
202 type="button"
203 onClick={() => setActiveTab("icon")}
204 className={`flex-1 py-1.5 text-sm font-medium rounded-lg transition-colors ${
205 activeTab === "icon"
206 ? "bg-white dark:bg-surface-700 text-surface-900 dark:text-white shadow-sm"
207 : "text-surface-600 dark:text-surface-400 hover:text-surface-900 dark:hover:text-surface-200"
208 }`}
209 >
210 Icons
211 </button>
212 <button
213 type="button"
214 onClick={() => setActiveTab("emoji")}
215 className={`flex-1 py-1.5 text-sm font-medium rounded-lg transition-colors ${
216 activeTab === "emoji"
217 ? "bg-white dark:bg-surface-700 text-surface-900 dark:text-white shadow-sm"
218 : "text-surface-600 dark:text-surface-400 hover:text-surface-900 dark:hover:text-surface-200"
219 }`}
220 >
221 Emojis
222 </button>
223 </div>
224
225 {activeTab === "icon" ? (
226 <div className="grid grid-cols-7 gap-1.5 p-3 bg-surface-50 dark:bg-surface-800 border border-surface-200 dark:border-surface-700 rounded-xl max-h-48 overflow-y-auto custom-scrollbar">
227 {Object.keys(ICON_MAP).map((key) => {
228 const Icon = ICON_MAP[key];
229 return (
230 <button
231 key={key}
232 type="button"
233 onClick={() => setNewItemIcon(key)}
234 className={clsx(
235 "p-2 rounded-lg flex items-center justify-center transition-all",
236 newItemIcon === key
237 ? "bg-primary-100 dark:bg-primary-900/50 text-primary-600 dark:text-primary-400 ring-2 ring-primary-500"
238 : "hover:bg-surface-100 dark:hover:bg-surface-700 text-surface-500 dark:text-surface-400",
239 )}
240 >
241 <Icon size={18} />
242 </button>
243 );
244 })}
245 </div>
246 ) : (
247 <div className="w-full bg-surface-50 dark:bg-surface-800 rounded-xl border border-surface-200 dark:border-surface-700 overflow-hidden">
248 <EmojiPicker
249 className="custom-emoji-picker"
250 onEmojiClick={(emojiData) =>
251 setNewItemIcon(emojiData.emoji)
252 }
253 autoFocusSearch={false}
254 width="100%"
255 height={300}
256 previewConfig={{ showPreview: false }}
257 skinTonesDisabled
258 lazyLoadEmojis
259 theme={
260 theme === "dark" ||
261 (theme === "system" &&
262 window.matchMedia("(prefers-color-scheme: dark)")
263 .matches)
264 ? (Theme.DARK as Theme)
265 : (Theme.LIGHT as Theme)
266 }
267 />
268 </div>
269 )}
270 </div>
271 <div className="mb-6">
272 <label className="block text-sm font-medium text-surface-700 dark:text-surface-300 mb-2">
273 Description
274 </label>
275 <textarea
276 value={newItemDesc}
277 onChange={(e) => setNewItemDesc(e.target.value)}
278 className="w-full px-3 py-2.5 bg-white dark:bg-surface-800 border border-surface-200 dark:border-surface-700 rounded-xl text-surface-900 dark:text-white placeholder:text-surface-400 dark:placeholder:text-surface-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20 focus:border-primary-500 dark:focus:border-primary-400 min-h-[80px] resize-none"
279 placeholder="What's this collection for?"
280 />
281 </div>
282 <div className="flex justify-end gap-2">
283 <Button
284 type="button"
285 variant="ghost"
286 onClick={() => setShowCreateModal(false)}
287 >
288 Cancel
289 </Button>
290 <Button type="submit" loading={creating}>
291 Create Collection
292 </Button>
293 </div>
294 </form>
295 </div>
296 </div>
297 )}
298 </div>
299 );
300}