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