Write on the margins of the internet. Powered by the AT Protocol. margin.at
extension web atproto comments

Enable viewing item edit history for items and editing existing collections.

+523 -9
+1 -1
backend/cmd/server/main.go
··· 183 183 r.Get("/*", func(w http.ResponseWriter, req *http.Request) { 184 184 path := req.URL.Path 185 185 186 - if strings.HasPrefix(path, "/api/") || strings.HasPrefix(path, "/auth/") { 186 + if strings.HasPrefix(path, "/api/") || strings.HasPrefix(path, "/auth/") || strings.HasPrefix(path, "/.well-known/") { 187 187 http.NotFound(w, req) 188 188 return 189 189 }
+8 -1
backend/internal/api/annotations.go
··· 328 328 329 329 if annotation.BodyValue != nil { 330 330 previousContent := *annotation.BodyValue 331 - s.db.SaveEditHistory(uri, "annotation", previousContent, annotation.CID) 331 + log.Printf("[DEBUG] Saving edit history for %s. Previous content: %s", uri, previousContent) 332 + if err := s.db.SaveEditHistory(uri, "annotation", previousContent, annotation.CID); err != nil { 333 + log.Printf("Failed to save edit history for %s: %v", uri, err) 334 + } else { 335 + log.Printf("[DEBUG] Successfully saved edit history for %s", uri) 336 + } 337 + } else { 338 + log.Printf("[DEBUG] Annotation BodyValue is nil for %s", uri) 332 339 } 333 340 334 341 var result *xrpc.PutRecordOutput
+17
backend/internal/api/hydration.go
··· 83 83 ReplyCount int `json:"replyCount"` 84 84 ViewerHasLiked bool `json:"viewerHasLiked"` 85 85 Labels []APILabel `json:"labels,omitempty"` 86 + EditedAt *time.Time `json:"editedAt,omitempty"` 86 87 } 87 88 88 89 type APIHighlight struct { ··· 99 100 ReplyCount int `json:"replyCount"` 100 101 ViewerHasLiked bool `json:"viewerHasLiked"` 101 102 Labels []APILabel `json:"labels,omitempty"` 103 + EditedAt *time.Time `json:"editedAt,omitempty"` 102 104 } 103 105 104 106 type APIBookmark struct { ··· 116 118 ReplyCount int `json:"replyCount"` 117 119 ViewerHasLiked bool `json:"viewerHasLiked"` 118 120 Labels []APILabel `json:"labels,omitempty"` 121 + EditedAt *time.Time `json:"editedAt,omitempty"` 119 122 } 120 123 121 124 type APIReply struct { ··· 222 225 labelerDIDs := appendUnique(subscribedLabelers, authorDIDs) 223 226 uriLabels, _ := database.GetContentLabelsForURIs(uris, labelerDIDs) 224 227 didLabels, _ := database.GetContentLabelsForDIDs(authorDIDs, labelerDIDs) 228 + editTimes, _ := database.GetLatestEditTimes(uris) 225 229 226 230 result := make([]APIAnnotation, len(annotations)) 227 231 for i, a := range annotations { ··· 283 287 Labels: mergeLabels(uriLabels[a.URI], didLabels[a.AuthorDID]), 284 288 } 285 289 290 + if t, ok := editTimes[a.URI]; ok { 291 + result[i].EditedAt = &t 292 + } 293 + 286 294 result[i].LikeCount = likeCounts[a.URI] 287 295 result[i].ReplyCount = replyCounts[a.URI] 288 296 if viewerLikes != nil && viewerLikes[a.URI] { ··· 314 322 labelerDIDs := appendUnique(subscribedLabelers, authorDIDs) 315 323 uriLabels, _ := database.GetContentLabelsForURIs(uris, labelerDIDs) 316 324 didLabels, _ := database.GetContentLabelsForDIDs(authorDIDs, labelerDIDs) 325 + editTimes, _ := database.GetLatestEditTimes(uris) 317 326 318 327 result := make([]APIHighlight, len(highlights)) 319 328 for i, h := range highlights { ··· 360 369 Labels: mergeLabels(uriLabels[h.URI], didLabels[h.AuthorDID]), 361 370 } 362 371 372 + if t, ok := editTimes[h.URI]; ok { 373 + result[i].EditedAt = &t 374 + } 375 + 363 376 result[i].LikeCount = likeCounts[h.URI] 364 377 result[i].ReplyCount = replyCounts[h.URI] 365 378 if viewerLikes != nil && viewerLikes[h.URI] { ··· 391 404 labelerDIDs := appendUnique(subscribedLabelers, authorDIDs) 392 405 uriLabels, _ := database.GetContentLabelsForURIs(uris, labelerDIDs) 393 406 didLabels, _ := database.GetContentLabelsForDIDs(authorDIDs, labelerDIDs) 407 + editTimes, _ := database.GetLatestEditTimes(uris) 394 408 395 409 result := make([]APIBookmark, len(bookmarks)) 396 410 for i, b := range bookmarks { ··· 426 440 CreatedAt: b.CreatedAt, 427 441 CID: cid, 428 442 Labels: mergeLabels(uriLabels[b.URI], didLabels[b.AuthorDID]), 443 + } 444 + if t, ok := editTimes[b.URI]; ok { 445 + result[i].EditedAt = &t 429 446 } 430 447 result[i].LikeCount = likeCounts[b.URI] 431 448 result[i].ReplyCount = replyCounts[b.URI]
+109 -1
backend/internal/db/queries_history.go
··· 1 1 package db 2 2 3 3 import ( 4 + "fmt" 5 + "strings" 4 6 "time" 5 7 ) 6 8 ··· 27 29 var history []EditHistory 28 30 for rows.Next() { 29 31 var h EditHistory 30 - if err := rows.Scan(&h.ID, &h.URI, &h.RecordType, &h.PreviousContent, &h.PreviousCID, &h.EditedAt); err != nil { 32 + var editedAt interface{} 33 + if err := rows.Scan(&h.ID, &h.URI, &h.RecordType, &h.PreviousContent, &h.PreviousCID, &editedAt); err != nil { 31 34 return nil, err 32 35 } 36 + 37 + switch v := editedAt.(type) { 38 + case time.Time: 39 + h.EditedAt = v 40 + case []byte: 41 + parsed, err := parseTime(string(v)) 42 + if err != nil { 43 + return nil, err 44 + } 45 + h.EditedAt = parsed 46 + case string: 47 + parsed, err := parseTime(v) 48 + if err != nil { 49 + return nil, err 50 + } 51 + h.EditedAt = parsed 52 + } 53 + 33 54 history = append(history, h) 34 55 } 35 56 return history, nil 36 57 } 58 + 59 + func (db *DB) GetLatestEditTimes(uris []string) (map[string]time.Time, error) { 60 + if len(uris) == 0 { 61 + return nil, nil 62 + } 63 + 64 + query := ` 65 + SELECT uri, MAX(edited_at) as edited_at 66 + FROM edit_history 67 + WHERE uri IN (` 68 + args := make([]interface{}, len(uris)) 69 + placeholders := make([]string, len(uris)) 70 + 71 + for i, uri := range uris { 72 + placeholders[i] = fmt.Sprintf("$%d", i+1) 73 + args[i] = uri 74 + } 75 + 76 + query += strings.Join(placeholders, ",") + ") GROUP BY uri" 77 + 78 + if db.driver == "sqlite3" { 79 + query = strings.ReplaceAll(query, "$", "?") 80 + placeholders = make([]string, len(uris)) 81 + for i := range uris { 82 + placeholders[i] = "?" 83 + } 84 + query = ` 85 + SELECT uri, MAX(edited_at) as edited_at 86 + FROM edit_history 87 + WHERE uri IN (` + strings.Join(placeholders, ",") + ") GROUP BY uri" 88 + } 89 + 90 + rows, err := db.Query(db.Rebind(query), args...) 91 + if err != nil { 92 + return nil, err 93 + } 94 + defer rows.Close() 95 + 96 + result := make(map[string]time.Time) 97 + for rows.Next() { 98 + var uri string 99 + var editedAt interface{} 100 + if err := rows.Scan(&uri, &editedAt); err != nil { 101 + continue 102 + } 103 + 104 + var finalTime time.Time 105 + switch v := editedAt.(type) { 106 + case time.Time: 107 + finalTime = v 108 + case []byte: 109 + parsed, err := parseTime(string(v)) 110 + if err != nil { 111 + continue 112 + } 113 + finalTime = parsed 114 + case string: 115 + parsed, err := parseTime(v) 116 + if err != nil { 117 + continue 118 + } 119 + finalTime = parsed 120 + default: 121 + continue 122 + } 123 + 124 + result[uri] = finalTime 125 + } 126 + 127 + return result, nil 128 + } 129 + 130 + func parseTime(s string) (time.Time, error) { 131 + formats := []string{ 132 + time.RFC3339, 133 + time.RFC3339Nano, 134 + "2006-01-02 15:04:05.999999999-07:00", 135 + "2006-01-02 15:04:05", 136 + } 137 + 138 + for _, f := range formats { 139 + if t, err := time.Parse(f, s); err == nil { 140 + return t, nil 141 + } 142 + } 143 + return time.Time{}, fmt.Errorf("could not parse time: %s", s) 144 + }
+21
web/src/components/common/Card.tsx
··· 23 23 import ExternalLinkModal from "../modals/ExternalLinkModal"; 24 24 import ReportModal from "../modals/ReportModal"; 25 25 import EditItemModal from "../modals/EditItemModal"; 26 + import EditHistoryModal from "../modals/EditHistoryModal"; 26 27 import { clsx } from "clsx"; 27 28 import { 28 29 likeItem, ··· 123 124 const [externalLinkUrl, setExternalLinkUrl] = useState<string | null>(null); 124 125 const [showReportModal, setShowReportModal] = useState(false); 125 126 const [showEditModal, setShowEditModal] = useState(false); 127 + const [showEditHistory, setShowEditHistory] = useState(false); 126 128 const [contentRevealed, setContentRevealed] = useState(false); 127 129 const [ogData, setOgData] = useState<{ 128 130 title?: string; ··· 371 373 <span className="text-surface-300 dark:text-surface-600">·</span> 372 374 <span className="text-surface-400 dark:text-surface-500 text-sm"> 373 375 {timestamp} 376 + {item.editedAt && ( 377 + <button 378 + onClick={(e) => { 379 + e.preventDefault(); 380 + e.stopPropagation(); 381 + setShowEditHistory(true); 382 + }} 383 + className="ml-1 text-surface-400 dark:text-surface-500 hover:text-surface-600 dark:hover:text-surface-400 hover:underline cursor-pointer" 384 + title={`Edited ${new Date(item.editedAt).toLocaleString()}`} 385 + > 386 + (edited) 387 + </button> 388 + )} 374 389 </span> 390 + 375 391 {isSemble && 376 392 (() => { 377 393 const uri = item.uri || ""; ··· 730 746 setItem(updated); 731 747 onUpdate?.(updated); 732 748 }} 749 + /> 750 + <EditHistoryModal 751 + isOpen={showEditHistory} 752 + onClose={() => setShowEditHistory(false)} 753 + item={item} 733 754 /> 734 755 </article> 735 756 );
+179
web/src/components/modals/EditCollectionModal.tsx
··· 1 + import React, { useState, useEffect } from "react"; 2 + import { X, Loader2 } from "lucide-react"; 3 + import CollectionIcon from "../common/CollectionIcon"; 4 + import { ICON_MAP } from "../common/iconMap"; 5 + import { updateCollection, type Collection } from "../../api/client"; 6 + 7 + interface EditCollectionModalProps { 8 + isOpen: boolean; 9 + onClose: () => void; 10 + collection: Collection; 11 + onUpdate: (updatedCollection: Collection) => void; 12 + } 13 + 14 + export default function EditCollectionModal({ 15 + isOpen, 16 + onClose, 17 + collection, 18 + onUpdate, 19 + }: EditCollectionModalProps) { 20 + const [name, setName] = useState(collection.name); 21 + const [description, setDescription] = useState(collection.description || ""); 22 + const [icon, setIcon] = useState(collection.icon?.replace("icon:", "") || ""); 23 + const [loading, setLoading] = useState(false); 24 + const [error, setError] = useState<string | null>(null); 25 + 26 + useEffect(() => { 27 + if (isOpen) { 28 + setName(collection.name); 29 + setDescription(collection.description || ""); 30 + setIcon(collection.icon?.replace("icon:", "") || ""); 31 + setError(null); 32 + document.body.style.overflow = "hidden"; 33 + } 34 + return () => { 35 + document.body.style.overflow = "unset"; 36 + }; 37 + }, [isOpen, collection]); 38 + 39 + const handleSubmit = async (e: React.FormEvent) => { 40 + e.preventDefault(); 41 + if (!name.trim()) return; 42 + 43 + try { 44 + setLoading(true); 45 + setError(null); 46 + const iconValue = icon ? `icon:${icon}` : undefined; 47 + const updated = await updateCollection( 48 + collection.uri, 49 + name.trim(), 50 + description.trim() || undefined, 51 + iconValue, 52 + ); 53 + 54 + if (updated) { 55 + onUpdate(updated); 56 + onClose(); 57 + } else { 58 + setError("Failed to update collection"); 59 + } 60 + } catch (err) { 61 + console.error(err); 62 + setError("An error occurred while updating"); 63 + } finally { 64 + setLoading(false); 65 + } 66 + }; 67 + 68 + if (!isOpen) return null; 69 + 70 + return ( 71 + <div 72 + className="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm animate-fade-in" 73 + onClick={onClose} 74 + > 75 + <div 76 + className="w-full max-w-md bg-white dark:bg-surface-900 rounded-3xl shadow-2xl overflow-hidden" 77 + onClick={(e) => e.stopPropagation()} 78 + > 79 + <div className="p-4 flex justify-between items-center border-b border-surface-100 dark:border-surface-800"> 80 + <h2 className="text-xl font-display font-bold text-surface-900 dark:text-white"> 81 + Edit Collection 82 + </h2> 83 + <button 84 + onClick={onClose} 85 + className="p-2 text-surface-400 hover:text-surface-900 dark:hover:text-white hover:bg-surface-50 dark:hover:bg-surface-800 rounded-full transition-colors" 86 + > 87 + <X size={20} /> 88 + </button> 89 + </div> 90 + 91 + <div className="p-6"> 92 + <form onSubmit={handleSubmit} className="space-y-4"> 93 + <div> 94 + <label className="block text-sm font-medium text-surface-700 dark:text-surface-300 mb-1"> 95 + Collection name 96 + </label> 97 + <input 98 + type="text" 99 + className="w-full px-4 py-3 bg-surface-50 dark:bg-surface-800 border border-surface-200 dark:border-surface-700 rounded-xl focus:border-primary-500 dark:focus:border-primary-400 focus:ring-4 focus:ring-primary-500/10 outline-none transition-all text-surface-900 dark:text-white placeholder-surface-400 dark:placeholder-surface-500" 100 + value={name} 101 + onChange={(e) => setName(e.target.value)} 102 + placeholder="My Collection" 103 + autoFocus 104 + /> 105 + </div> 106 + 107 + <div> 108 + <label className="block text-sm font-medium text-surface-700 dark:text-surface-300 mb-1"> 109 + Description (optional) 110 + </label> 111 + <textarea 112 + className="w-full px-4 py-3 bg-surface-50 dark:bg-surface-800 border border-surface-200 dark:border-surface-700 rounded-xl focus:border-primary-500 dark:focus:border-primary-400 focus:ring-4 focus:ring-primary-500/10 outline-none transition-all text-surface-900 dark:text-white placeholder-surface-400 dark:placeholder-surface-500 resize-none" 113 + value={description} 114 + onChange={(e) => setDescription(e.target.value)} 115 + placeholder="What's this collection about?" 116 + rows={3} 117 + /> 118 + </div> 119 + 120 + <div> 121 + <label className="block text-sm font-medium text-surface-700 dark:text-surface-300 mb-2"> 122 + Icon 123 + </label> 124 + <div className="grid grid-cols-8 gap-1.5 max-h-32 overflow-y-auto p-2 bg-surface-50 dark:bg-surface-800 rounded-xl border border-surface-200 dark:border-surface-700"> 125 + {Object.keys(ICON_MAP).map((iconName) => { 126 + const isSelected = icon === iconName; 127 + return ( 128 + <button 129 + key={iconName} 130 + type="button" 131 + onClick={() => setIcon(isSelected ? "" : iconName)} 132 + className={`w-8 h-8 flex items-center justify-center rounded-lg transition-all ${ 133 + isSelected 134 + ? "bg-primary-600 text-white" 135 + : "hover:bg-surface-200 dark:hover:bg-surface-700 text-surface-600 dark:text-surface-400" 136 + }`} 137 + title={iconName} 138 + > 139 + <CollectionIcon icon={`icon:${iconName}`} size={16} /> 140 + </button> 141 + ); 142 + })} 143 + </div> 144 + {icon && ( 145 + <p className="mt-1 text-xs text-surface-500"> 146 + Selected: {icon} 147 + </p> 148 + )} 149 + </div> 150 + 151 + {error && ( 152 + <div className="p-3 bg-red-50 dark:bg-red-900/30 text-red-600 dark:text-red-400 text-sm rounded-lg"> 153 + {error} 154 + </div> 155 + )} 156 + 157 + <div className="flex gap-3 pt-2"> 158 + <button 159 + type="button" 160 + className="flex-1 py-3 bg-white dark:bg-surface-800 border border-surface-200 dark:border-surface-700 text-surface-700 dark:text-surface-200 font-semibold rounded-xl hover:bg-surface-50 dark:hover:bg-surface-700 transition-colors" 161 + onClick={onClose} 162 + > 163 + Cancel 164 + </button> 165 + <button 166 + type="submit" 167 + className="flex-1 py-3 bg-primary-600 text-white font-semibold rounded-xl hover:bg-primary-700 transition-colors disabled:opacity-50 flex items-center justify-center gap-2" 168 + disabled={!name.trim() || loading} 169 + > 170 + {loading && <Loader2 size={16} className="animate-spin" />} 171 + {loading ? "Saving..." : "Save Changes"} 172 + </button> 173 + </div> 174 + </form> 175 + </div> 176 + </div> 177 + </div> 178 + ); 179 + }
+140
web/src/components/modals/EditHistoryModal.tsx
··· 1 + import React, { useState, useEffect } from "react"; 2 + import { X, Loader2, History } from "lucide-react"; 3 + import { formatDistanceToNow } from "date-fns"; 4 + import type { AnnotationItem, EditHistoryItem } from "../../types"; 5 + 6 + interface EditHistoryModalProps { 7 + isOpen: boolean; 8 + onClose: () => void; 9 + item: AnnotationItem; 10 + } 11 + 12 + export default function EditHistoryModal({ 13 + isOpen, 14 + onClose, 15 + item, 16 + }: EditHistoryModalProps) { 17 + const [history, setHistory] = useState<EditHistoryItem[]>([]); 18 + const [loading, setLoading] = useState(false); 19 + const [error, setError] = useState<string | null>(null); 20 + 21 + useEffect(() => { 22 + if (isOpen && item.uri) { 23 + fetchHistory(); 24 + document.body.style.overflow = "hidden"; 25 + } 26 + return () => { 27 + document.body.style.overflow = "unset"; 28 + }; 29 + }, [isOpen, item.uri]); 30 + 31 + const fetchHistory = async () => { 32 + try { 33 + setLoading(true); 34 + setError(null); 35 + const res = await fetch( 36 + `/api/annotations/history?uri=${encodeURIComponent(item.uri)}`, 37 + ); 38 + if (!res.ok) throw new Error("Failed to fetch history"); 39 + const data = await res.json(); 40 + setHistory(data); 41 + } catch (err) { 42 + console.error(err); 43 + setError("Failed to load edit history"); 44 + } finally { 45 + setLoading(false); 46 + } 47 + }; 48 + 49 + if (!isOpen) return null; 50 + 51 + return ( 52 + <div 53 + className="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm animate-fade-in" 54 + onClick={onClose} 55 + > 56 + <div 57 + className="w-full max-w-lg bg-white dark:bg-surface-900 rounded-3xl shadow-2xl overflow-hidden flex flex-col max-h-[80vh]" 58 + onClick={(e) => e.stopPropagation()} 59 + > 60 + <div className="p-4 flex justify-between items-center border-b border-surface-100 dark:border-surface-800 shrink-0"> 61 + <div className="flex items-center gap-2"> 62 + <History className="text-surface-500" size={20} /> 63 + <h2 className="text-xl font-display font-bold text-surface-900 dark:text-white"> 64 + Edit History 65 + </h2> 66 + </div> 67 + <button 68 + onClick={onClose} 69 + className="p-2 text-surface-400 hover:text-surface-900 dark:hover:text-white hover:bg-surface-50 dark:hover:bg-surface-800 rounded-full transition-colors" 70 + > 71 + <X size={20} /> 72 + </button> 73 + </div> 74 + 75 + <div className="p-0 overflow-y-auto flex-1 custom-scrollbar"> 76 + {loading ? ( 77 + <div className="flex justify-center p-8"> 78 + <Loader2 className="animate-spin text-primary-500" size={32} /> 79 + </div> 80 + ) : error ? ( 81 + <div className="p-8 text-center text-red-500">{error}</div> 82 + ) : history.length === 0 ? ( 83 + <div className="p-8 text-center text-surface-500"> 84 + No edit history found. 85 + </div> 86 + ) : ( 87 + <div className="divide-y divide-surface-100 dark:divide-surface-800"> 88 + <div className="p-4 bg-primary-50/50 dark:bg-primary-900/10"> 89 + <div className="flex justify-between items-start mb-2"> 90 + <span className="text-xs font-bold uppercase tracking-wider text-primary-600 dark:text-primary-400"> 91 + Current Version 92 + </span> 93 + <span className="text-xs text-surface-400"> 94 + {item.editedAt 95 + ? `Edited ${formatDistanceToNow(new Date(item.editedAt))} ago` 96 + : `Posted ${formatDistanceToNow(new Date(item.createdAt))} ago`} 97 + </span> 98 + </div> 99 + <div className="text-surface-900 dark:text-white whitespace-pre-wrap text-sm"> 100 + {item.text || item.body?.value} 101 + </div> 102 + </div> 103 + 104 + {history.map((edit, index) => ( 105 + <div 106 + key={edit.cid || index} 107 + className="p-4 hover:bg-surface-50 dark:hover:bg-surface-800/50 transition-colors" 108 + > 109 + <div className="flex justify-between items-start mb-2"> 110 + <span className="text-xs font-medium text-surface-500"> 111 + Previous Version 112 + </span> 113 + <span 114 + className="text-xs text-surface-400" 115 + title={new Date(edit.editedAt).toLocaleString()} 116 + > 117 + {formatDistanceToNow(new Date(edit.editedAt))} ago 118 + </span> 119 + </div> 120 + <div className="text-surface-600 dark:text-surface-300 whitespace-pre-wrap text-sm"> 121 + {edit.previousContent} 122 + </div> 123 + </div> 124 + ))} 125 + </div> 126 + )} 127 + </div> 128 + 129 + <div className="p-4 border-t border-surface-100 dark:border-surface-800 bg-surface-50 dark:bg-surface-800/50 shrink-0"> 130 + <button 131 + onClick={onClose} 132 + className="w-full py-2.5 bg-white dark:bg-surface-800 border border-surface-200 dark:border-surface-700 text-surface-700 dark:text-surface-200 font-medium rounded-xl hover:bg-surface-50 dark:hover:bg-surface-700 transition-colors" 133 + > 134 + Close 135 + </button> 136 + </div> 137 + </div> 138 + </div> 139 + ); 140 + }
+13
web/src/styles/global.css
··· 58 58 .transition-default { 59 59 @apply transition-all duration-200 ease-out; 60 60 } 61 + 62 + .custom-scrollbar::-webkit-scrollbar { 63 + width: 6px; 64 + height: 6px; 65 + } 66 + 67 + .custom-scrollbar::-webkit-scrollbar-track { 68 + @apply bg-transparent; 69 + } 70 + 71 + .custom-scrollbar::-webkit-scrollbar-thumb { 72 + @apply bg-surface-300 dark:bg-surface-700 rounded-full hover:bg-surface-400 dark:hover:bg-surface-600 transition-colors; 73 + } 61 74 } 62 75 63 76 @keyframes fadeIn {
+9
web/src/types.ts
··· 56 56 description?: string; 57 57 color?: string; 58 58 tags?: string[]; 59 + editedAt?: string; 59 60 likeCount?: number; 60 61 replyCount?: number; 61 62 repostCount?: number; ··· 241 242 avatar?: string; 242 243 }; 243 244 } 245 + export interface EditHistoryItem { 246 + id: number; 247 + uri: string; 248 + recordType: string; 249 + previousContent: string; 250 + previousCid?: string; 251 + editedAt: string; 252 + }
+26 -6
web/src/views/collections/CollectionDetail.tsx
··· 13 13 import { useStore } from "@nanostores/react"; 14 14 import { $user } from "../../store/auth"; 15 15 import type { Collection, AnnotationItem } from "../../types"; 16 + import EditCollectionModal from "../../components/modals/EditCollectionModal"; 17 + import { Edit3 } from "lucide-react"; 16 18 17 19 interface CollectionDetailProps { 18 20 handle?: string; ··· 30 32 const [items, setItems] = useState<AnnotationItem[]>([]); 31 33 const [loading, setLoading] = useState(true); 32 34 const [error, setError] = useState<string | null>(null); 35 + const [isEditModalOpen, setIsEditModalOpen] = useState(false); 33 36 34 37 useEffect(() => { 35 38 const loadData = async () => { ··· 152 155 text={collection.name} 153 156 /> 154 157 {isOwner && ( 155 - <button 156 - onClick={handleDelete} 157 - className="p-2 text-surface-400 dark:text-surface-500 hover:text-red-500 dark:hover:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-lg transition-colors" 158 - > 159 - <Trash2 size={18} /> 160 - </button> 158 + <> 159 + <button 160 + onClick={() => setIsEditModalOpen(true)} 161 + className="p-2 text-surface-400 dark:text-surface-500 hover:text-primary-600 dark:hover:text-primary-400 hover:bg-primary-50 dark:hover:bg-primary-900/20 rounded-lg transition-colors" 162 + title="Edit collection" 163 + > 164 + <Edit3 size={18} /> 165 + </button> 166 + <button 167 + onClick={handleDelete} 168 + className="p-2 text-surface-400 dark:text-surface-500 hover:text-red-500 dark:hover:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-lg transition-colors" 169 + title="Delete collection" 170 + > 171 + <Trash2 size={18} /> 172 + </button> 173 + </> 161 174 )} 162 175 </div> 163 176 </div> 164 177 </div> 178 + 179 + <EditCollectionModal 180 + isOpen={isEditModalOpen} 181 + onClose={() => setIsEditModalOpen(false)} 182 + collection={collection} 183 + onUpdate={(updated) => setCollection(updated)} 184 + /> 165 185 166 186 <div className="space-y-2"> 167 187 {items.length === 0 ? (