(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.

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

scanash00 64f3aa46 bf79686a

+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 ? (