(READ ONLY) Margin is an open annotation layer for the internet. Powered by the AT Protocol. margin.at
extension web atproto comments
99
fork

Configure Feed

Select the types of activity you want to include in your feed.

at frontend-rewrite 363 lines 14 kB view raw
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}