(READ ONLY) Margin is an open annotation layer for the internet. Powered by the AT Protocol.
margin.at
extension
web
atproto
comments
1import React, { useState } from "react";
2import { X, ShieldAlert } from "lucide-react";
3import {
4 updateAnnotation,
5 updateHighlight,
6 updateBookmark,
7} from "../../api/client";
8import type { AnnotationItem, ContentLabelValue } from "../../types";
9
10const SELF_LABEL_OPTIONS: { value: ContentLabelValue; label: string }[] = [
11 { value: "sexual", label: "Sexual" },
12 { value: "nudity", label: "Nudity" },
13 { value: "violence", label: "Violence" },
14 { value: "gore", label: "Gore" },
15 { value: "spam", label: "Spam" },
16 { value: "misleading", label: "Misleading" },
17];
18
19const HIGHLIGHT_COLORS = [
20 { value: "yellow", bg: "bg-yellow-400", ring: "ring-yellow-500" },
21 { value: "green", bg: "bg-green-400", ring: "ring-green-500" },
22 { value: "blue", bg: "bg-blue-400", ring: "ring-blue-500" },
23 { value: "red", bg: "bg-red-400", ring: "ring-red-500" },
24];
25
26interface EditItemModalProps {
27 isOpen: boolean;
28 onClose: () => void;
29 item: AnnotationItem;
30 type: "annotation" | "highlight" | "bookmark";
31 onSaved?: (item: AnnotationItem) => void;
32}
33
34export default function EditItemModal({
35 isOpen,
36 onClose,
37 item,
38 type,
39 onSaved,
40}: EditItemModalProps) {
41 if (!isOpen) return null;
42 return (
43 <EditItemModalContent
44 key={item.uri || item.id || JSON.stringify(item)}
45 item={item}
46 type={type}
47 onClose={onClose}
48 onSaved={onSaved}
49 />
50 );
51}
52
53function EditItemModalContent({
54 item,
55 type,
56 onClose,
57 onSaved,
58}: Omit<EditItemModalProps, "isOpen">) {
59 const [text, setText] = useState(item.body?.value || "");
60 const [tags, setTags] = useState<string[]>(item.tags || []);
61 const [tagInput, setTagInput] = useState("");
62 const [color, setColor] = useState(item.color || "yellow");
63 const [title, setTitle] = useState(item.title || item.target?.title || "");
64 const [description, setDescription] = useState(item.description || "");
65 const existingLabels = (item.labels || [])
66 .filter((l) => l.src === item.author?.did)
67 .map((l) => l.val as ContentLabelValue);
68 const [selfLabels, setSelfLabels] =
69 useState<ContentLabelValue[]>(existingLabels);
70 const [showLabelPicker, setShowLabelPicker] = useState(
71 existingLabels.length > 0,
72 );
73 const [saving, setSaving] = useState(false);
74 const [error, setError] = useState<string | null>(null);
75
76 const addTag = () => {
77 const t = tagInput.trim().toLowerCase();
78 if (t && !tags.includes(t)) {
79 setTags([...tags, t]);
80 }
81 setTagInput("");
82 };
83
84 const removeTag = (tag: string) => {
85 setTags(tags.filter((t) => t !== tag));
86 };
87
88 const toggleLabel = (val: ContentLabelValue) => {
89 setSelfLabels((prev) =>
90 prev.includes(val) ? prev.filter((l) => l !== val) : [...prev, val],
91 );
92 };
93
94 const handleSave = async () => {
95 setSaving(true);
96 setError(null);
97 let success = false;
98 const labels = selfLabels.length > 0 ? selfLabels : [];
99
100 try {
101 if (type === "annotation") {
102 success = await updateAnnotation(
103 item.uri,
104 text,
105 tags.length > 0 ? tags : undefined,
106 labels,
107 );
108 } else if (type === "highlight") {
109 success = await updateHighlight(
110 item.uri,
111 color,
112 tags.length > 0 ? tags : undefined,
113 labels,
114 );
115 } else if (type === "bookmark") {
116 success = await updateBookmark(
117 item.uri,
118 title || undefined,
119 description || undefined,
120 tags.length > 0 ? tags : undefined,
121 labels,
122 );
123 }
124 } catch (e) {
125 console.error("Edit save error:", e);
126 setError(e instanceof Error ? e.message : "Failed to save");
127 setSaving(false);
128 return;
129 }
130
131 setSaving(false);
132 if (!success) {
133 setError("Failed to save changes. Please try again.");
134 return;
135 }
136 const updated = { ...item };
137 if (type === "annotation") {
138 updated.body = { type: "TextualBody", value: text, format: "text/plain" };
139 } else if (type === "highlight") {
140 updated.color = color;
141 } else if (type === "bookmark") {
142 updated.title = title;
143 updated.description = description;
144 }
145 updated.tags = tags;
146 const otherLabels = (item.labels || []).filter(
147 (l) => l.src !== item.author?.did,
148 );
149 const newSelfLabels = selfLabels.map((val) => ({
150 val,
151 src: item.author?.did || "",
152 scope: "content" as const,
153 }));
154 updated.labels = [...otherLabels, ...newSelfLabels];
155 onSaved?.(updated);
156 onClose();
157 };
158
159 return (
160 <div
161 className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm"
162 onClick={onClose}
163 >
164 <div
165 className="bg-white dark:bg-surface-900 rounded-2xl shadow-xl w-full max-w-lg mx-4 max-h-[80vh] overflow-y-auto"
166 onClick={(e) => e.stopPropagation()}
167 >
168 <div className="flex items-center justify-between px-5 py-4 border-b border-surface-200 dark:border-surface-700">
169 <h3 className="text-lg font-semibold text-surface-900 dark:text-surface-100">
170 Edit{" "}
171 {type === "annotation"
172 ? "Annotation"
173 : type === "highlight"
174 ? "Highlight"
175 : "Bookmark"}
176 </h3>
177 <button
178 onClick={onClose}
179 className="p-1.5 rounded-lg text-surface-400 hover:text-surface-600 dark:hover:text-surface-300 hover:bg-surface-100 dark:hover:bg-surface-800 transition-colors"
180 >
181 <X size={18} />
182 </button>
183 </div>
184
185 <div className="px-5 py-4 space-y-4">
186 {type === "annotation" && (
187 <div>
188 <label className="block text-sm font-medium text-surface-700 dark:text-surface-300 mb-1.5">
189 Text
190 </label>
191 <textarea
192 value={text}
193 onChange={(e) => setText(e.target.value)}
194 rows={4}
195 maxLength={3000}
196 className="w-full px-3 py-2 rounded-xl border border-surface-200 dark:border-surface-700 bg-surface-50 dark:bg-surface-800 text-surface-900 dark:text-surface-100 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500 resize-none"
197 placeholder="Write your annotation..."
198 />
199 <p className="text-xs text-surface-400 mt-1">
200 {text.length}/3000
201 </p>
202 </div>
203 )}
204
205 {type === "highlight" && (
206 <div>
207 <label className="block text-sm font-medium text-surface-700 dark:text-surface-300 mb-2">
208 Color
209 </label>
210 <div className="flex gap-2">
211 {HIGHLIGHT_COLORS.map((c) => (
212 <button
213 key={c.value}
214 onClick={() => setColor(c.value)}
215 className={`w-8 h-8 rounded-full ${c.bg} transition-all ${
216 color === c.value
217 ? `ring-2 ${c.ring} ring-offset-2 dark:ring-offset-surface-900 scale-110`
218 : "opacity-60 hover:opacity-100"
219 }`}
220 title={c.value}
221 />
222 ))}
223 </div>
224 {item.target?.selector?.exact && (
225 <blockquote className="mt-3 pl-3 py-2 border-l-2 border-surface-300 dark:border-surface-600 text-sm italic text-surface-500 dark:text-surface-400">
226 {item.target.selector.exact}
227 </blockquote>
228 )}
229 </div>
230 )}
231
232 {type === "bookmark" && (
233 <>
234 <div>
235 <label className="block text-sm font-medium text-surface-700 dark:text-surface-300 mb-1.5">
236 Title
237 </label>
238 <input
239 type="text"
240 value={title}
241 onChange={(e) => setTitle(e.target.value)}
242 className="w-full px-3 py-2 rounded-xl border border-surface-200 dark:border-surface-700 bg-surface-50 dark:bg-surface-800 text-surface-900 dark:text-surface-100 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500"
243 placeholder="Bookmark title"
244 />
245 </div>
246 <div>
247 <label className="block text-sm font-medium text-surface-700 dark:text-surface-300 mb-1.5">
248 Description
249 </label>
250 <textarea
251 value={description}
252 onChange={(e) => setDescription(e.target.value)}
253 rows={3}
254 className="w-full px-3 py-2 rounded-xl border border-surface-200 dark:border-surface-700 bg-surface-50 dark:bg-surface-800 text-surface-900 dark:text-surface-100 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500 resize-none"
255 placeholder="Optional description..."
256 />
257 </div>
258 </>
259 )}
260
261 <div>
262 <label className="block text-sm font-medium text-surface-700 dark:text-surface-300 mb-1.5">
263 Tags
264 </label>
265 <div className="flex flex-wrap gap-1.5 mb-2">
266 {tags.map((tag) => (
267 <span
268 key={tag}
269 className="inline-flex items-center gap-1 px-2.5 py-1 rounded-full bg-primary-50 dark:bg-primary-900/30 text-primary-700 dark:text-primary-300 text-xs font-medium"
270 >
271 #{tag}
272 <button
273 onClick={() => removeTag(tag)}
274 className="hover:text-red-500 transition-colors"
275 >
276 <X size={12} />
277 </button>
278 </span>
279 ))}
280 </div>
281 <div className="flex gap-2">
282 <input
283 type="text"
284 value={tagInput}
285 onChange={(e) => setTagInput(e.target.value)}
286 onKeyDown={(e) => {
287 if (e.key === "Enter") {
288 e.preventDefault();
289 addTag();
290 }
291 }}
292 className="flex-1 px-3 py-1.5 rounded-lg border border-surface-200 dark:border-surface-700 bg-surface-50 dark:bg-surface-800 text-surface-900 dark:text-surface-100 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500"
293 placeholder="Add a tag..."
294 />
295 <button
296 onClick={addTag}
297 disabled={!tagInput.trim()}
298 className="px-3 py-1.5 rounded-lg bg-primary-500 text-white text-sm font-medium hover:bg-primary-600 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
299 >
300 Add
301 </button>
302 </div>
303 </div>
304
305 <div>
306 <button
307 onClick={() => setShowLabelPicker(!showLabelPicker)}
308 className={`flex items-center gap-2 text-sm font-medium transition-colors ${
309 showLabelPicker || selfLabels.length > 0
310 ? "text-amber-600 dark:text-amber-400"
311 : "text-surface-500 dark:text-surface-400 hover:text-surface-700 dark:hover:text-surface-200"
312 }`}
313 >
314 <ShieldAlert size={16} />
315 Content Warning
316 {selfLabels.length > 0 && (
317 <span className="text-xs bg-amber-100 dark:bg-amber-900/40 text-amber-700 dark:text-amber-300 px-1.5 py-0.5 rounded-full">
318 {selfLabels.length}
319 </span>
320 )}
321 </button>
322 {showLabelPicker && (
323 <div className="flex flex-wrap gap-1.5 mt-2">
324 {SELF_LABEL_OPTIONS.map((opt) => (
325 <button
326 key={opt.value}
327 onClick={() => toggleLabel(opt.value)}
328 className={`px-3 py-1 rounded-full text-xs font-medium border transition-all ${
329 selfLabels.includes(opt.value)
330 ? "bg-amber-100 dark:bg-amber-900/40 border-amber-300 dark:border-amber-700 text-amber-800 dark:text-amber-200"
331 : "bg-surface-50 dark:bg-surface-800 border-surface-200 dark:border-surface-700 text-surface-600 dark:text-surface-400 hover:border-amber-300 dark:hover:border-amber-700"
332 }`}
333 >
334 {opt.label}
335 </button>
336 ))}
337 </div>
338 )}
339 </div>
340 </div>
341
342 <div className="px-5 py-4 border-t border-surface-200 dark:border-surface-700">
343 {error && <p className="text-sm text-red-500 mb-3">{error}</p>}
344 <div className="flex items-center justify-end gap-2">
345 <button
346 onClick={onClose}
347 className="px-4 py-2 rounded-xl text-sm font-medium text-surface-600 dark:text-surface-400 hover:bg-surface-100 dark:hover:bg-surface-800 transition-colors"
348 >
349 Cancel
350 </button>
351 <button
352 onClick={handleSave}
353 disabled={saving || (type === "annotation" && !text.trim())}
354 className="px-4 py-2 rounded-xl bg-primary-500 text-white text-sm font-medium hover:bg-primary-600 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
355 >
356 {saving ? "Saving..." : "Save"}
357 </button>
358 </div>
359 </div>
360 </div>
361 </div>
362 );
363}