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