(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 main 346 lines 13 kB view raw
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}