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

Implement tags and better card styles

scanash00 8444ea3f c8d7c372

+1157 -510
+17 -3
backend/internal/api/annotations.go
··· 47 47 return 48 48 } 49 49 50 - if req.URL == "" || req.Text == "" { 51 - http.Error(w, "URL and text are required", http.StatusBadRequest) 50 + if req.URL == "" { 51 + http.Error(w, "URL is required", http.StatusBadRequest) 52 + return 53 + } 54 + 55 + if req.Text == "" && req.Selector == nil && len(req.Tags) == 0 { 56 + http.Error(w, "Must provide text, selector, or tags", http.StatusBadRequest) 52 57 return 53 58 } 54 59 ··· 498 503 Title string `json:"title,omitempty"` 499 504 Selector interface{} `json:"selector"` 500 505 Color string `json:"color,omitempty"` 506 + Tags []string `json:"tags,omitempty"` 501 507 } 502 508 503 509 func (s *AnnotationService) CreateHighlight(w http.ResponseWriter, r *http.Request) { ··· 519 525 } 520 526 521 527 urlHash := db.HashURL(req.URL) 522 - record := xrpc.NewHighlightRecord(req.URL, urlHash, req.Selector, req.Color) 528 + record := xrpc.NewHighlightRecord(req.URL, urlHash, req.Selector, req.Color, req.Tags) 523 529 524 530 var result *xrpc.CreateRecordOutput 525 531 err = s.refresher.ExecuteWithAutoRefresh(r, session, func(client *xrpc.Client, did string) error { ··· 549 555 colorPtr = &req.Color 550 556 } 551 557 558 + var tagsJSONPtr *string 559 + if len(req.Tags) > 0 { 560 + tagsBytes, _ := json.Marshal(req.Tags) 561 + tagsStr := string(tagsBytes) 562 + tagsJSONPtr = &tagsStr 563 + } 564 + 552 565 cid := result.CID 553 566 highlight := &db.Highlight{ 554 567 URI: result.URI, ··· 558 571 TargetTitle: titlePtr, 559 572 SelectorJSON: selectorJSONPtr, 560 573 Color: colorPtr, 574 + TagsJSON: tagsJSONPtr, 561 575 CreatedAt: time.Now(), 562 576 IndexedAt: time.Now(), 563 577 CID: &cid,
+42 -13
backend/internal/api/handler.go
··· 81 81 limit := parseIntParam(r, "limit", 50) 82 82 offset := parseIntParam(r, "offset", 0) 83 83 motivation := r.URL.Query().Get("motivation") 84 + tag := r.URL.Query().Get("tag") 84 85 85 86 var annotations []db.Annotation 86 87 var err error ··· 90 91 annotations, err = h.db.GetAnnotationsByTargetHash(urlHash, limit, offset) 91 92 } else if motivation != "" { 92 93 annotations, err = h.db.GetAnnotationsByMotivation(motivation, limit, offset) 94 + } else if tag != "" { 95 + annotations, err = h.db.GetAnnotationsByTag(tag, limit, offset) 93 96 } else { 94 97 annotations, err = h.db.GetRecentAnnotations(limit, offset) 95 98 } ··· 112 115 113 116 func (h *Handler) GetFeed(w http.ResponseWriter, r *http.Request) { 114 117 limit := parseIntParam(r, "limit", 50) 118 + tag := r.URL.Query().Get("tag") 119 + creator := r.URL.Query().Get("creator") 120 + 121 + var annotations []db.Annotation 122 + var highlights []db.Highlight 123 + var bookmarks []db.Bookmark 124 + var collectionItems []db.CollectionItem 125 + var err error 115 126 116 - annotations, _ := h.db.GetRecentAnnotations(limit, 0) 117 - highlights, _ := h.db.GetRecentHighlights(limit, 0) 118 - bookmarks, _ := h.db.GetRecentBookmarks(limit, 0) 127 + if tag != "" { 128 + if creator != "" { 129 + annotations, _ = h.db.GetAnnotationsByTagAndAuthor(tag, creator, limit, 0) 130 + highlights, _ = h.db.GetHighlightsByTagAndAuthor(tag, creator, limit, 0) 131 + bookmarks, _ = h.db.GetBookmarksByTagAndAuthor(tag, creator, limit, 0) 132 + collectionItems = []db.CollectionItem{} 133 + } else { 134 + annotations, _ = h.db.GetAnnotationsByTag(tag, limit, 0) 135 + highlights, _ = h.db.GetHighlightsByTag(tag, limit, 0) 136 + bookmarks, _ = h.db.GetBookmarksByTag(tag, limit, 0) 137 + collectionItems = []db.CollectionItem{} 138 + } 139 + } else { 140 + annotations, _ = h.db.GetRecentAnnotations(limit, 0) 141 + highlights, _ = h.db.GetRecentHighlights(limit, 0) 142 + bookmarks, _ = h.db.GetRecentBookmarks(limit, 0) 143 + collectionItems, err = h.db.GetRecentCollectionItems(limit, 0) 144 + if err != nil { 145 + log.Printf("Error fetching collection items: %v\n", err) 146 + } 147 + } 119 148 120 149 authAnnos, _ := hydrateAnnotations(annotations) 121 150 authHighs, _ := hydrateHighlights(highlights) 122 151 authBooks, _ := hydrateBookmarks(bookmarks) 123 152 124 - collectionItems, err := h.db.GetRecentCollectionItems(limit, 0) 125 - if err != nil { 126 - log.Printf("Error fetching collection items: %v\n", err) 127 - } 128 - // log.Printf("Fetched %d collection items\n", len(collectionItems)) 129 153 authCollectionItems, _ := hydrateCollectionItems(h.db, collectionItems) 130 - // log.Printf("Hydrated %d collection items\n", len(authCollectionItems)) 131 154 132 155 var feed []interface{} 133 156 for _, a := range authAnnos { ··· 276 299 277 300 func (h *Handler) GetHighlights(w http.ResponseWriter, r *http.Request) { 278 301 did := r.URL.Query().Get("creator") 302 + tag := r.URL.Query().Get("tag") 279 303 limit := parseIntParam(r, "limit", 50) 280 304 offset := parseIntParam(r, "offset", 0) 281 305 282 - if did == "" { 283 - http.Error(w, "creator parameter required", http.StatusBadRequest) 284 - return 306 + var highlights []db.Highlight 307 + var err error 308 + 309 + if did != "" { 310 + highlights, err = h.db.GetHighlightsByAuthor(did, limit, offset) 311 + } else if tag != "" { 312 + highlights, err = h.db.GetHighlightsByTag(tag, limit, offset) 313 + } else { 314 + highlights, err = h.db.GetRecentHighlights(limit, offset) 285 315 } 286 316 287 - highlights, err := h.db.GetHighlightsByAuthor(did, limit, offset) 288 317 if err != nil { 289 318 http.Error(w, err.Error(), http.StatusInternalServerError) 290 319 return
+134
backend/internal/db/queries.go
··· 104 104 return scanAnnotations(rows) 105 105 } 106 106 107 + func (db *DB) GetAnnotationsByTag(tag string, limit, offset int) ([]Annotation, error) { 108 + pattern := "%\"" + tag + "\"%" 109 + rows, err := db.Query(db.Rebind(` 110 + SELECT uri, author_did, motivation, body_value, body_format, body_uri, target_source, target_hash, target_title, selector_json, tags_json, created_at, indexed_at, cid 111 + FROM annotations 112 + WHERE tags_json LIKE ? 113 + ORDER BY created_at DESC 114 + LIMIT ? OFFSET ? 115 + `), pattern, limit, offset) 116 + if err != nil { 117 + return nil, err 118 + } 119 + defer rows.Close() 120 + 121 + return scanAnnotations(rows) 122 + } 123 + 107 124 func (db *DB) DeleteAnnotation(uri string) error { 108 125 _, err := db.Exec(db.Rebind(`DELETE FROM annotations WHERE uri = ?`), uri) 109 126 return err ··· 242 259 return highlights, nil 243 260 } 244 261 262 + func (db *DB) GetHighlightsByTag(tag string, limit, offset int) ([]Highlight, error) { 263 + pattern := "%\"" + tag + "\"%" 264 + rows, err := db.Query(db.Rebind(` 265 + SELECT uri, author_did, target_source, target_hash, target_title, selector_json, color, tags_json, created_at, indexed_at, cid 266 + FROM highlights 267 + WHERE tags_json LIKE ? 268 + ORDER BY created_at DESC 269 + LIMIT ? OFFSET ? 270 + `), pattern, limit, offset) 271 + if err != nil { 272 + return nil, err 273 + } 274 + defer rows.Close() 275 + 276 + var highlights []Highlight 277 + for rows.Next() { 278 + var h Highlight 279 + if err := rows.Scan(&h.URI, &h.AuthorDID, &h.TargetSource, &h.TargetHash, &h.TargetTitle, &h.SelectorJSON, &h.Color, &h.TagsJSON, &h.CreatedAt, &h.IndexedAt, &h.CID); err != nil { 280 + return nil, err 281 + } 282 + highlights = append(highlights, h) 283 + } 284 + return highlights, nil 285 + } 286 + 245 287 func (db *DB) GetRecentBookmarks(limit, offset int) ([]Bookmark, error) { 246 288 rows, err := db.Query(db.Rebind(` 247 289 SELECT uri, author_did, source, source_hash, title, description, tags_json, created_at, indexed_at, cid ··· 249 291 ORDER BY created_at DESC 250 292 LIMIT ? OFFSET ? 251 293 `), limit, offset) 294 + if err != nil { 295 + return nil, err 296 + } 297 + defer rows.Close() 298 + 299 + var bookmarks []Bookmark 300 + for rows.Next() { 301 + var b Bookmark 302 + if err := rows.Scan(&b.URI, &b.AuthorDID, &b.Source, &b.SourceHash, &b.Title, &b.Description, &b.TagsJSON, &b.CreatedAt, &b.IndexedAt, &b.CID); err != nil { 303 + return nil, err 304 + } 305 + bookmarks = append(bookmarks, b) 306 + } 307 + return bookmarks, nil 308 + } 309 + 310 + func (db *DB) GetBookmarksByTag(tag string, limit, offset int) ([]Bookmark, error) { 311 + pattern := "%\"" + tag + "\"%" 312 + rows, err := db.Query(db.Rebind(` 313 + SELECT uri, author_did, source, source_hash, title, description, tags_json, created_at, indexed_at, cid 314 + FROM bookmarks 315 + WHERE tags_json LIKE ? 316 + ORDER BY created_at DESC 317 + LIMIT ? OFFSET ? 318 + `), pattern, limit, offset) 319 + if err != nil { 320 + return nil, err 321 + } 322 + defer rows.Close() 323 + 324 + var bookmarks []Bookmark 325 + for rows.Next() { 326 + var b Bookmark 327 + if err := rows.Scan(&b.URI, &b.AuthorDID, &b.Source, &b.SourceHash, &b.Title, &b.Description, &b.TagsJSON, &b.CreatedAt, &b.IndexedAt, &b.CID); err != nil { 328 + return nil, err 329 + } 330 + bookmarks = append(bookmarks, b) 331 + } 332 + return bookmarks, nil 333 + } 334 + 335 + func (db *DB) GetAnnotationsByTagAndAuthor(tag, authorDID string, limit, offset int) ([]Annotation, error) { 336 + pattern := "%\"" + tag + "\"%" 337 + rows, err := db.Query(db.Rebind(` 338 + SELECT uri, author_did, motivation, body_value, body_format, body_uri, target_source, target_hash, target_title, selector_json, tags_json, created_at, indexed_at, cid 339 + FROM annotations 340 + WHERE author_did = ? AND tags_json LIKE ? 341 + ORDER BY created_at DESC 342 + LIMIT ? OFFSET ? 343 + `), authorDID, pattern, limit, offset) 344 + if err != nil { 345 + return nil, err 346 + } 347 + defer rows.Close() 348 + 349 + return scanAnnotations(rows) 350 + } 351 + 352 + func (db *DB) GetHighlightsByTagAndAuthor(tag, authorDID string, limit, offset int) ([]Highlight, error) { 353 + pattern := "%\"" + tag + "\"%" 354 + rows, err := db.Query(db.Rebind(` 355 + SELECT uri, author_did, target_source, target_hash, target_title, selector_json, color, tags_json, created_at, indexed_at, cid 356 + FROM highlights 357 + WHERE author_did = ? AND tags_json LIKE ? 358 + ORDER BY created_at DESC 359 + LIMIT ? OFFSET ? 360 + `), authorDID, pattern, limit, offset) 361 + if err != nil { 362 + return nil, err 363 + } 364 + defer rows.Close() 365 + 366 + var highlights []Highlight 367 + for rows.Next() { 368 + var h Highlight 369 + if err := rows.Scan(&h.URI, &h.AuthorDID, &h.TargetSource, &h.TargetHash, &h.TargetTitle, &h.SelectorJSON, &h.Color, &h.TagsJSON, &h.CreatedAt, &h.IndexedAt, &h.CID); err != nil { 370 + return nil, err 371 + } 372 + highlights = append(highlights, h) 373 + } 374 + return highlights, nil 375 + } 376 + 377 + func (db *DB) GetBookmarksByTagAndAuthor(tag, authorDID string, limit, offset int) ([]Bookmark, error) { 378 + pattern := "%\"" + tag + "\"%" 379 + rows, err := db.Query(db.Rebind(` 380 + SELECT uri, author_did, source, source_hash, title, description, tags_json, created_at, indexed_at, cid 381 + FROM bookmarks 382 + WHERE author_did = ? AND tags_json LIKE ? 383 + ORDER BY created_at DESC 384 + LIMIT ? OFFSET ? 385 + `), authorDID, pattern, limit, offset) 252 386 if err != nil { 253 387 return nil, err 254 388 }
+1
backend/internal/oauth/handler.go
··· 244 244 245 245 parResp, state, dpopNonce, err := client.SendPAR(meta, req.Handle, scope, dpopKey, pkceChallenge) 246 246 if err != nil { 247 + log.Printf("PAR request failed: %v", err) 247 248 w.Header().Set("Content-Type", "application/json") 248 249 w.WriteHeader(http.StatusInternalServerError) 249 250 json.NewEncoder(w).Encode(map[string]string{"error": "Failed to initiate authentication"})
+2 -1
backend/internal/xrpc/records.go
··· 78 78 CreatedAt string `json:"createdAt"` 79 79 } 80 80 81 - func NewHighlightRecord(url, urlHash string, selector interface{}, color string) *HighlightRecord { 81 + func NewHighlightRecord(url, urlHash string, selector interface{}, color string, tags []string) *HighlightRecord { 82 82 return &HighlightRecord{ 83 83 Type: CollectionHighlight, 84 84 Target: AnnotationTarget{ ··· 87 87 Selector: selector, 88 88 }, 89 89 Color: color, 90 + Tags: tags, 90 91 CreatedAt: time.Now().UTC().Format(time.RFC3339), 91 92 } 92 93 }
+26 -6
web/src/api/client.js
··· 23 23 return request(`${API_BASE}/url-metadata?url=${encodeURIComponent(url)}`); 24 24 } 25 25 26 - export async function getAnnotationFeed(limit = 50, offset = 0) { 27 - return request( 28 - `${API_BASE}/annotations/feed?limit=${limit}&offset=${offset}`, 29 - ); 26 + export async function getAnnotationFeed( 27 + limit = 50, 28 + offset = 0, 29 + tag = "", 30 + creator = "", 31 + ) { 32 + let url = `${API_BASE}/annotations/feed?limit=${limit}&offset=${offset}`; 33 + if (tag) url += `&tag=${encodeURIComponent(tag)}`; 34 + if (creator) url += `&creator=${encodeURIComponent(creator)}`; 35 + return request(url); 30 36 } 31 37 32 38 export async function getAnnotations({ ··· 210 216 }); 211 217 } 212 218 213 - export async function createAnnotation({ url, text, quote, title, selector }) { 219 + export async function createHighlight({ url, title, selector, color, tags }) { 220 + return request(`${API_BASE}/highlights`, { 221 + method: "POST", 222 + body: JSON.stringify({ url, title, selector, color, tags }), 223 + }); 224 + } 225 + 226 + export async function createAnnotation({ 227 + url, 228 + text, 229 + quote, 230 + title, 231 + selector, 232 + tags, 233 + }) { 214 234 return request(`${API_BASE}/annotations`, { 215 235 method: "POST", 216 - body: JSON.stringify({ url, text, quote, title, selector }), 236 + body: JSON.stringify({ url, text, quote, title, selector, tags }), 217 237 }); 218 238 } 219 239
+6 -2
web/src/components/AddToCollectionModal.jsx
··· 23 23 24 24 useEffect(() => { 25 25 if (isOpen && user) { 26 + if (!annotationUri) { 27 + setLoading(false); 28 + return; 29 + } 26 30 loadCollections(); 27 31 setError(null); 28 32 } 29 - }, [isOpen, user]); 33 + }, [isOpen, user, annotationUri]); 30 34 31 35 const loadCollections = async () => { 32 36 try { ··· 71 75 className="modal-container" 72 76 style={{ 73 77 maxWidth: "380px", 74 - maxHeight: "80vh", 78 + maxHeight: "80dvh", 75 79 display: "flex", 76 80 flexDirection: "column", 77 81 }}
+386 -276
web/src/components/AnnotationCard.jsx
··· 27 27 BookmarkIcon, 28 28 } from "./Icons"; 29 29 import { Folder, Edit2, Save, X, Clock } from "lucide-react"; 30 - import AddToCollectionModal from "./AddToCollectionModal"; 31 30 import ShareMenu from "./ShareMenu"; 32 31 33 32 function buildTextFragmentUrl(baseUrl, selector) { ··· 60 59 } 61 60 }; 62 61 63 - export default function AnnotationCard({ annotation, onDelete }) { 62 + export default function AnnotationCard({ 63 + annotation, 64 + onDelete, 65 + onAddToCollection, 66 + }) { 64 67 const { user, login } = useAuth(); 65 68 const data = normalizeAnnotation(annotation); 66 69 67 70 const [likeCount, setLikeCount] = useState(0); 68 71 const [isLiked, setIsLiked] = useState(false); 69 72 const [deleting, setDeleting] = useState(false); 70 - const [showAddToCollection, setShowAddToCollection] = useState(false); 71 73 const [isEditing, setIsEditing] = useState(false); 72 74 const [editText, setEditText] = useState(data.text || ""); 75 + const [editTags, setEditTags] = useState(data.tags?.join(", ") || ""); 73 76 const [saving, setSaving] = useState(false); 74 77 75 78 const [showHistory, setShowHistory] = useState(false); ··· 182 185 const handleSaveEdit = async () => { 183 186 try { 184 187 setSaving(true); 185 - await updateAnnotation(data.uri, editText, data.tags); 188 + const tagList = editTags 189 + .split(",") 190 + .map((t) => t.trim()) 191 + .filter(Boolean); 192 + await updateAnnotation(data.uri, editText, tagList); 186 193 setIsEditing(false); 187 194 if (annotation.body) annotation.body.value = editText; 188 195 else if (annotation.text) annotation.text = editText; 196 + if (annotation.tags) annotation.tags = tagList; 197 + data.tags = tagList; 189 198 } catch (err) { 190 199 alert("Failed to update: " + err.message); 191 200 } finally { ··· 288 297 return ( 289 298 <article className="card annotation-card"> 290 299 <header className="annotation-header"> 291 - <Link to={marginProfileUrl || "#"} className="annotation-avatar-link"> 292 - <div className="annotation-avatar"> 293 - {authorAvatar ? ( 294 - <img src={authorAvatar} alt={authorDisplayName} /> 295 - ) : ( 296 - <span> 297 - {(authorDisplayName || authorHandle || "??") 298 - ?.substring(0, 2) 299 - .toUpperCase()} 300 - </span> 301 - )} 302 - </div> 303 - </Link> 304 - <div className="annotation-meta"> 305 - <div className="annotation-author-row"> 306 - <Link 307 - to={marginProfileUrl || "#"} 308 - className="annotation-author-link" 309 - > 310 - <span className="annotation-author">{authorDisplayName}</span> 311 - </Link> 312 - {authorHandle && ( 313 - <a 314 - href={`https://bsky.app/profile/${authorHandle}`} 315 - target="_blank" 316 - rel="noopener noreferrer" 317 - className="annotation-handle" 300 + <div className="annotation-header-left"> 301 + <Link to={marginProfileUrl || "#"} className="annotation-avatar-link"> 302 + <div className="annotation-avatar"> 303 + {authorAvatar ? ( 304 + <img src={authorAvatar} alt={authorDisplayName} /> 305 + ) : ( 306 + <span> 307 + {(authorDisplayName || authorHandle || "??") 308 + ?.substring(0, 2) 309 + .toUpperCase()} 310 + </span> 311 + )} 312 + </div> 313 + </Link> 314 + <div className="annotation-meta"> 315 + <div className="annotation-author-row"> 316 + <Link 317 + to={marginProfileUrl || "#"} 318 + className="annotation-author-link" 318 319 > 319 - @{authorHandle} <ExternalLinkIcon size={12} /> 320 - </a> 321 - )} 322 - </div> 323 - <div className="annotation-time">{formatDate(data.createdAt)}</div> 324 - </div> 325 - <div className="action-buttons"> 326 - {} 327 - {hasEditHistory && !data.color && !data.description && ( 328 - <button 329 - className="annotation-edit-btn" 330 - onClick={fetchHistory} 331 - title="View Edit History" 332 - > 333 - <Clock size={16} /> 334 - </button> 335 - )} 336 - {} 337 - {isOwner && ( 338 - <> 339 - {!data.color && !data.description && ( 340 - <button 341 - className="annotation-edit-btn" 342 - onClick={() => setIsEditing(!isEditing)} 343 - title="Edit" 320 + <span className="annotation-author">{authorDisplayName}</span> 321 + </Link> 322 + {authorHandle && ( 323 + <a 324 + href={`https://bsky.app/profile/${authorHandle}`} 325 + target="_blank" 326 + rel="noopener noreferrer" 327 + className="annotation-handle" 344 328 > 345 - <Edit2 size={16} /> 346 - </button> 329 + @{authorHandle} 330 + </a> 347 331 )} 332 + </div> 333 + <div className="annotation-time">{formatDate(data.createdAt)}</div> 334 + </div> 335 + </div> 336 + <div className="annotation-header-right"> 337 + <div style={{ display: "flex", gap: "4px" }}> 338 + {hasEditHistory && !data.color && !data.description && ( 348 339 <button 349 - className="annotation-delete" 350 - onClick={handleDelete} 351 - disabled={deleting} 352 - title="Delete" 340 + className="annotation-action action-icon-only" 341 + onClick={fetchHistory} 342 + title="View Edit History" 353 343 > 354 - <TrashIcon size={16} /> 344 + <Clock size={16} /> 355 345 </button> 356 - </> 357 - )} 346 + )} 347 + 348 + {isOwner && ( 349 + <> 350 + {!data.color && !data.description && ( 351 + <button 352 + className="annotation-action action-icon-only" 353 + onClick={() => setIsEditing(!isEditing)} 354 + title="Edit" 355 + > 356 + <Edit2 size={16} /> 357 + </button> 358 + )} 359 + <button 360 + className="annotation-action action-icon-only" 361 + onClick={handleDelete} 362 + disabled={deleting} 363 + title="Delete" 364 + > 365 + <TrashIcon size={16} /> 366 + </button> 367 + </> 368 + )} 369 + </div> 358 370 </div> 359 371 </header> 360 372 361 - {} 362 - {} 363 373 {showHistory && ( 364 374 <div className="history-panel"> 365 375 <div className="history-header"> ··· 391 401 </div> 392 402 )} 393 403 394 - <a 395 - href={data.url} 396 - target="_blank" 397 - rel="noopener noreferrer" 398 - className="annotation-source" 399 - > 400 - {truncateUrl(data.url)} 401 - {data.title && ( 402 - <span className="annotation-source-title"> • {data.title}</span> 403 - )} 404 - </a> 405 - 406 - {highlightedText && ( 404 + <div className="annotation-content"> 407 405 <a 408 - href={fragmentUrl} 406 + href={data.url} 409 407 target="_blank" 410 408 rel="noopener noreferrer" 411 - className="annotation-highlight" 409 + className="annotation-source" 412 410 > 413 - <mark>"{highlightedText}"</mark> 411 + {truncateUrl(data.url)} 412 + {data.title && ( 413 + <span className="annotation-source-title"> • {data.title}</span> 414 + )} 414 415 </a> 415 - )} 416 + 417 + {highlightedText && ( 418 + <a 419 + href={fragmentUrl} 420 + target="_blank" 421 + rel="noopener noreferrer" 422 + className="annotation-highlight" 423 + style={{ 424 + borderLeftColor: isEditing ? editColor : data.color || "#f59e0b", 425 + }} 426 + > 427 + <mark>"{highlightedText}"</mark> 428 + </a> 429 + )} 416 430 417 - {isEditing ? ( 418 - <div className="mt-3"> 419 - <textarea 420 - value={editText} 421 - onChange={(e) => setEditText(e.target.value)} 422 - className="reply-input" 423 - rows={3} 424 - style={{ marginBottom: "8px" }} 425 - /> 426 - <div className="action-buttons-end"> 427 - <button 428 - onClick={() => setIsEditing(false)} 429 - className="btn btn-ghost" 430 - > 431 - Cancel 432 - </button> 433 - <button 434 - onClick={handleSaveEdit} 435 - disabled={saving} 436 - className="btn btn-primary btn-sm" 437 - > 438 - {saving ? ( 439 - "Saving..." 440 - ) : ( 441 - <> 442 - <Save size={14} /> Save 443 - </> 444 - )} 445 - </button> 431 + {isEditing ? ( 432 + <div className="mt-3"> 433 + <textarea 434 + value={editText} 435 + onChange={(e) => setEditText(e.target.value)} 436 + className="reply-input" 437 + rows={3} 438 + style={{ marginBottom: "8px" }} 439 + /> 440 + <input 441 + type="text" 442 + className="reply-input" 443 + placeholder="Tags (comma separated)..." 444 + value={editTags} 445 + onChange={(e) => setEditTags(e.target.value)} 446 + style={{ marginBottom: "8px" }} 447 + /> 448 + <div className="action-buttons-end"> 449 + <button 450 + onClick={() => setIsEditing(false)} 451 + className="btn btn-ghost" 452 + > 453 + Cancel 454 + </button> 455 + <button 456 + onClick={handleSaveEdit} 457 + disabled={saving} 458 + className="btn btn-primary btn-sm" 459 + > 460 + {saving ? ( 461 + "Saving..." 462 + ) : ( 463 + <> 464 + <Save size={14} /> Save 465 + </> 466 + )} 467 + </button> 468 + </div> 446 469 </div> 447 - </div> 448 - ) : ( 449 - data.text && <p className="annotation-text">{data.text}</p> 450 - )} 470 + ) : ( 471 + data.text && <p className="annotation-text">{data.text}</p> 472 + )} 451 473 452 - {data.tags?.length > 0 && ( 453 - <div className="annotation-tags"> 454 - {data.tags.map((tag, i) => ( 455 - <span key={i} className="annotation-tag"> 456 - #{tag} 457 - </span> 458 - ))} 459 - </div> 460 - )} 474 + {data.tags?.length > 0 && ( 475 + <div className="annotation-tags"> 476 + {data.tags.map((tag, i) => ( 477 + <Link 478 + key={i} 479 + to={`/?tag=${encodeURIComponent(tag)}`} 480 + className="annotation-tag" 481 + > 482 + #{tag} 483 + </Link> 484 + ))} 485 + </div> 486 + )} 487 + </div> 461 488 462 489 <footer className="annotation-actions"> 463 - <button 464 - className={`annotation-action ${isLiked ? "liked" : ""}`} 465 - onClick={handleLike} 466 - > 467 - <HeartIcon filled={isLiked} size={16} /> 468 - {likeCount > 0 && <span>{likeCount}</span>} 469 - </button> 470 - <button 471 - className={`annotation-action ${showReplies ? "active" : ""}`} 472 - onClick={() => setShowReplies(!showReplies)} 473 - > 474 - <MessageIcon size={16} /> 475 - <span>{replyCount > 0 ? `${replyCount}` : "Reply"}</span> 476 - </button> 477 - <ShareMenu 478 - uri={data.uri} 479 - text={data.title || data.url} 480 - handle={data.author?.handle} 481 - type="Annotation" 482 - /> 483 - <button 484 - className="annotation-action" 485 - onClick={() => { 486 - if (!user) { 487 - login(); 488 - return; 489 - } 490 - setShowAddToCollection(true); 491 - }} 492 - > 493 - <Folder size={16} /> 494 - <span>Collect</span> 495 - </button> 490 + <div className="annotation-actions-left"> 491 + <button 492 + className={`annotation-action ${isLiked ? "liked" : ""}`} 493 + onClick={handleLike} 494 + > 495 + <HeartIcon filled={isLiked} size={16} /> 496 + {likeCount > 0 && <span>{likeCount}</span>} 497 + </button> 498 + <button 499 + className={`annotation-action ${showReplies ? "active" : ""}`} 500 + onClick={() => setShowReplies(!showReplies)} 501 + > 502 + <MessageIcon size={16} /> 503 + <span>{replyCount > 0 ? `${replyCount}` : "Reply"}</span> 504 + </button> 505 + <ShareMenu 506 + uri={data.uri} 507 + text={data.title || data.url} 508 + handle={data.author?.handle} 509 + type="Annotation" 510 + /> 511 + <button 512 + className="annotation-action" 513 + onClick={() => { 514 + if (!user) { 515 + login(); 516 + return; 517 + } 518 + if (onAddToCollection) onAddToCollection(); 519 + }} 520 + > 521 + <Folder size={16} /> 522 + <span>Collect</span> 523 + </button> 524 + </div> 496 525 </footer> 497 526 498 527 {showReplies && ( ··· 584 613 </div> 585 614 </div> 586 615 )} 587 - 588 - <AddToCollectionModal 589 - isOpen={showAddToCollection} 590 - onClose={() => setShowAddToCollection(false)} 591 - annotationUri={data.uri} 592 - /> 593 616 </article> 594 617 ); 595 618 } 596 619 597 - export function HighlightCard({ highlight, onDelete }) { 620 + export function HighlightCard({ highlight, onDelete, onAddToCollection }) { 598 621 const { user, login } = useAuth(); 599 622 const data = normalizeHighlight(highlight); 600 623 const highlightedText = 601 624 data.selector?.type === "TextQuoteSelector" ? data.selector.exact : null; 602 625 const fragmentUrl = buildTextFragmentUrl(data.url, data.selector); 603 626 const isOwner = user?.did && data.author?.did === user.did; 604 - const [showAddToCollection, setShowAddToCollection] = useState(false); 605 627 const [isEditing, setIsEditing] = useState(false); 606 628 const [editColor, setEditColor] = useState(data.color || "#f59e0b"); 629 + const [editTags, setEditTags] = useState(data.tags?.join(", ") || ""); 607 630 608 631 const handleSaveEdit = async () => { 609 632 try { 610 - await updateHighlight(data.uri, editColor, []); 633 + const tagList = editTags 634 + .split(",") 635 + .map((t) => t.trim()) 636 + .filter(Boolean); 637 + 638 + await updateHighlight(data.uri, editColor, tagList); 611 639 setIsEditing(false); 612 640 613 641 if (highlight.color) highlight.color = editColor; 642 + if (highlight.tags) highlight.tags = tagList; 643 + else highlight.value = { ...highlight.value, tags: tagList }; 614 644 } catch (err) { 615 645 alert("Failed to update: " + err.message); 616 646 } ··· 639 669 return ( 640 670 <article className="card annotation-card"> 641 671 <header className="annotation-header"> 642 - <Link 643 - to={data.author?.did ? `/profile/${data.author.did}` : "#"} 644 - className="annotation-avatar-link" 645 - > 646 - <div className="annotation-avatar"> 647 - {data.author?.avatar ? ( 648 - <img src={data.author.avatar} alt="avatar" /> 649 - ) : ( 650 - <span>??</span> 672 + <div className="annotation-header-left"> 673 + <Link 674 + to={data.author?.did ? `/profile/${data.author.did}` : "#"} 675 + className="annotation-avatar-link" 676 + > 677 + <div className="annotation-avatar"> 678 + {data.author?.avatar ? ( 679 + <img src={data.author.avatar} alt="avatar" /> 680 + ) : ( 681 + <span>??</span> 682 + )} 683 + </div> 684 + </Link> 685 + <div className="annotation-meta"> 686 + <Link to="#" className="annotation-author-link"> 687 + <span className="annotation-author"> 688 + {data.author?.displayName || "Unknown"} 689 + </span> 690 + </Link> 691 + <div className="annotation-time">{formatDate(data.createdAt)}</div> 692 + {data.author?.handle && ( 693 + <a 694 + href={`https://bsky.app/profile/${data.author.handle}`} 695 + target="_blank" 696 + rel="noopener noreferrer" 697 + className="annotation-handle" 698 + > 699 + @{data.author.handle} 700 + </a> 651 701 )} 652 702 </div> 653 - </Link> 654 - <div className="annotation-meta"> 655 - <Link to="#" className="annotation-author-link"> 656 - <span className="annotation-author"> 657 - {data.author?.displayName || "Unknown"} 658 - </span> 659 - </Link> 660 - <div className="annotation-time">{formatDate(data.createdAt)}</div> 661 703 </div> 662 - <div className="action-buttons"> 663 - {isOwner && ( 664 - <> 665 - <button 666 - className="annotation-edit-btn" 667 - onClick={() => setIsEditing(!isEditing)} 668 - title="Edit Color" 669 - > 670 - <Edit2 size={16} /> 671 - </button> 672 - <button 673 - className="annotation-delete" 674 - onClick={(e) => { 675 - e.preventDefault(); 676 - onDelete && onDelete(highlight.id || highlight.uri); 677 - }} 678 - > 679 - <TrashIcon size={16} /> 680 - </button> 681 - </> 682 - )} 704 + 705 + <div className="annotation-header-right"> 706 + <div style={{ display: "flex", gap: "4px" }}> 707 + {isOwner && ( 708 + <> 709 + <button 710 + className="annotation-action action-icon-only" 711 + onClick={() => setIsEditing(!isEditing)} 712 + title="Edit Color" 713 + > 714 + <Edit2 size={16} /> 715 + </button> 716 + <button 717 + className="annotation-action action-icon-only" 718 + onClick={(e) => { 719 + e.preventDefault(); 720 + onDelete && onDelete(highlight.id || highlight.uri); 721 + }} 722 + > 723 + <TrashIcon size={16} /> 724 + </button> 725 + </> 726 + )} 727 + </div> 683 728 </div> 684 729 </header> 685 730 686 - <a 687 - href={data.url} 688 - target="_blank" 689 - rel="noopener noreferrer" 690 - className="annotation-source" 691 - > 692 - {truncateUrl(data.url)} 693 - </a> 694 - 695 - {highlightedText && ( 731 + <div className="annotation-content"> 696 732 <a 697 - href={fragmentUrl} 733 + href={data.url} 698 734 target="_blank" 699 735 rel="noopener noreferrer" 700 - className="annotation-highlight" 701 - style={{ 702 - borderLeftColor: isEditing ? editColor : data.color || "#f59e0b", 703 - }} 736 + className="annotation-source" 704 737 > 705 - <mark>"{highlightedText}"</mark> 738 + {truncateUrl(data.url)} 706 739 </a> 707 - )} 740 + 741 + {highlightedText && ( 742 + <a 743 + href={fragmentUrl} 744 + target="_blank" 745 + rel="noopener noreferrer" 746 + className="annotation-highlight" 747 + style={{ 748 + borderLeftColor: isEditing ? editColor : data.color || "#f59e0b", 749 + }} 750 + > 751 + <mark>"{highlightedText}"</mark> 752 + </a> 753 + )} 754 + 755 + {isEditing && ( 756 + <div 757 + className="mt-3" 758 + style={{ 759 + display: "flex", 760 + gap: "8px", 761 + alignItems: "center", 762 + padding: "8px", 763 + background: "var(--bg-secondary)", 764 + borderRadius: "var(--radius-md)", 765 + border: "1px solid var(--border)", 766 + }} 767 + > 768 + <div 769 + className="color-picker-compact" 770 + style={{ 771 + position: "relative", 772 + width: "28px", 773 + height: "28px", 774 + flexShrink: 0, 775 + }} 776 + > 777 + <div 778 + style={{ 779 + backgroundColor: editColor, 780 + width: "100%", 781 + height: "100%", 782 + borderRadius: "50%", 783 + border: "2px solid var(--bg-card)", 784 + boxShadow: "0 0 0 1px var(--border)", 785 + }} 786 + /> 787 + <input 788 + type="color" 789 + value={editColor} 790 + onChange={(e) => setEditColor(e.target.value)} 791 + style={{ 792 + position: "absolute", 793 + top: 0, 794 + left: 0, 795 + width: "100%", 796 + height: "100%", 797 + opacity: 0, 798 + cursor: "pointer", 799 + }} 800 + title="Change Color" 801 + /> 802 + </div> 803 + 804 + <input 805 + type="text" 806 + className="reply-input" 807 + placeholder="e.g. tag1, tag2" 808 + value={editTags} 809 + onChange={(e) => setEditTags(e.target.value)} 810 + style={{ 811 + margin: 0, 812 + flex: 1, 813 + fontSize: "0.9rem", 814 + padding: "6px 10px", 815 + height: "32px", 816 + border: "none", 817 + background: "transparent", 818 + }} 819 + /> 820 + 821 + <button 822 + onClick={handleSaveEdit} 823 + className="btn btn-primary btn-sm" 824 + style={{ padding: "0 10px", height: "32px", minWidth: "auto" }} 825 + title="Save" 826 + > 827 + <Save size={16} /> 828 + </button> 829 + </div> 830 + )} 831 + 832 + {data.tags?.length > 0 && ( 833 + <div className="annotation-tags"> 834 + {data.tags.map((tag, i) => ( 835 + <Link 836 + key={i} 837 + to={`/?tag=${encodeURIComponent(tag)}`} 838 + className="annotation-tag" 839 + > 840 + #{tag} 841 + </Link> 842 + ))} 843 + </div> 844 + )} 845 + </div> 708 846 709 - {isEditing && ( 710 - <div 711 - className="mt-3" 712 - style={{ display: "flex", alignItems: "center", gap: "8px" }} 713 - > 714 - <span style={{ fontSize: "0.9rem" }}>Color:</span> 715 - <input 716 - type="color" 717 - value={editColor} 718 - onChange={(e) => setEditColor(e.target.value)} 847 + <footer className="annotation-actions"> 848 + <div className="annotation-actions-left"> 849 + <span 850 + className="annotation-action" 719 851 style={{ 720 - height: "32px", 721 - width: "64px", 722 - padding: 0, 723 - border: "none", 724 - borderRadius: "var(--radius-sm)", 725 - overflow: "hidden", 852 + color: data.color || "#f59e0b", 853 + background: "none", 854 + paddingLeft: 0, 726 855 }} 856 + > 857 + <HighlightIcon size={14} /> Highlight 858 + </span> 859 + <ShareMenu 860 + uri={data.uri} 861 + text={data.title || data.description} 862 + handle={data.author?.handle} 863 + type="Highlight" 727 864 /> 728 865 <button 729 - onClick={handleSaveEdit} 730 - className="btn btn-primary btn-sm" 731 - style={{ marginLeft: "auto" }} 866 + className="annotation-action" 867 + onClick={() => { 868 + if (!user) { 869 + login(); 870 + return; 871 + } 872 + if (onAddToCollection) onAddToCollection(); 873 + }} 732 874 > 733 - Save 875 + <Folder size={16} /> 876 + <span>Collect</span> 734 877 </button> 735 878 </div> 736 - )} 737 - 738 - <footer className="annotation-actions"> 739 - <span 740 - className="annotation-action annotation-type-badge" 741 - style={{ color: data.color || "#f59e0b" }} 742 - > 743 - <HighlightIcon size={14} /> Highlight 744 - </span> 745 - <ShareMenu 746 - uri={data.uri} 747 - text={data.title || data.description} 748 - handle={data.author?.handle} 749 - type="Highlight" 750 - /> 751 - <button 752 - className="annotation-action" 753 - onClick={() => { 754 - if (!user) { 755 - login(); 756 - return; 757 - } 758 - setShowAddToCollection(true); 759 - }} 760 - > 761 - <Folder size={16} /> 762 - <span>Collect</span> 763 - </button> 764 879 </footer> 765 - <AddToCollectionModal 766 - isOpen={showAddToCollection} 767 - onClose={() => setShowAddToCollection(false)} 768 - annotationUri={data.uri} 769 - /> 770 880 </article> 771 881 ); 772 882 }
+103 -130
web/src/components/BookmarkCard.jsx
··· 11 11 } from "../api/client"; 12 12 import { HeartIcon, TrashIcon, ExternalLinkIcon, BookmarkIcon } from "./Icons"; 13 13 import { Folder } from "lucide-react"; 14 - import AddToCollectionModal from "./AddToCollectionModal"; 15 14 import ShareMenu from "./ShareMenu"; 16 15 17 - export default function BookmarkCard({ bookmark, annotation, onDelete }) { 16 + export default function BookmarkCard({ bookmark, onAddToCollection }) { 18 17 const { user, login } = useAuth(); 19 - const raw = bookmark || annotation; 18 + const raw = bookmark; 20 19 const data = 21 20 raw.type === "Bookmark" ? normalizeBookmark(raw) : normalizeAnnotation(raw); 22 21 23 22 const [likeCount, setLikeCount] = useState(0); 24 23 const [isLiked, setIsLiked] = useState(false); 25 24 const [deleting, setDeleting] = useState(false); 26 - const [showAddToCollection, setShowAddToCollection] = useState(false); 27 25 28 26 const isOwner = user?.did && data.author?.did === user.did; 29 27 ··· 84 82 } 85 83 }; 86 84 87 - const handleShare = async () => { 88 - const uriParts = data.uri.split("/"); 89 - const did = uriParts[2]; 90 - const rkey = uriParts[uriParts.length - 1]; 91 - const shareUrl = `${window.location.origin}/at/${did}/${rkey}`; 92 - if (navigator.share) { 93 - try { 94 - await navigator.share({ title: "Bookmark", url: shareUrl }); 95 - } catch {} 96 - } else { 97 - try { 98 - await navigator.clipboard.writeText(shareUrl); 99 - alert("Link copied!"); 100 - } catch { 101 - prompt("Copy:", shareUrl); 102 - } 103 - } 104 - }; 105 - 106 85 const formatDate = (dateString) => { 107 86 if (!dateString) return ""; 108 87 const date = new Date(dateString); ··· 131 110 132 111 return ( 133 112 <article className="card bookmark-card"> 134 - {} 135 113 <header className="annotation-header"> 136 - <Link to={marginProfileUrl || "#"} className="annotation-avatar-link"> 137 - <div className="annotation-avatar"> 138 - {authorAvatar ? ( 139 - <img src={authorAvatar} alt={authorDisplayName} /> 140 - ) : ( 141 - <span> 142 - {(authorDisplayName || authorHandle || "??") 143 - ?.substring(0, 2) 144 - .toUpperCase()} 145 - </span> 146 - )} 114 + <div className="annotation-header-left"> 115 + <Link to={marginProfileUrl || "#"} className="annotation-avatar-link"> 116 + <div className="annotation-avatar"> 117 + {authorAvatar ? ( 118 + <img src={authorAvatar} alt={authorDisplayName} /> 119 + ) : ( 120 + <span> 121 + {(authorDisplayName || authorHandle || "??") 122 + ?.substring(0, 2) 123 + .toUpperCase()} 124 + </span> 125 + )} 126 + </div> 127 + </Link> 128 + <div className="annotation-meta"> 129 + <div className="annotation-author-row"> 130 + <Link 131 + to={marginProfileUrl || "#"} 132 + className="annotation-author-link" 133 + > 134 + <span className="annotation-author">{authorDisplayName}</span> 135 + </Link> 136 + {authorHandle && ( 137 + <a 138 + href={`https://bsky.app/profile/${authorHandle}`} 139 + target="_blank" 140 + rel="noopener noreferrer" 141 + className="annotation-handle" 142 + > 143 + @{authorHandle} 144 + </a> 145 + )} 146 + </div> 147 + <div className="annotation-time">{formatDate(data.createdAt)}</div> 147 148 </div> 148 - </Link> 149 - <div className="annotation-meta"> 150 - <div className="annotation-author-row"> 151 - <Link 152 - to={marginProfileUrl || "#"} 153 - className="annotation-author-link" 154 - > 155 - <span className="annotation-author">{authorDisplayName}</span> 156 - </Link> 157 - {authorHandle && ( 158 - <a 159 - href={`https://bsky.app/profile/${authorHandle}`} 160 - target="_blank" 161 - rel="noopener noreferrer" 162 - className="annotation-handle" 149 + </div> 150 + 151 + <div className="annotation-header-right"> 152 + <div style={{ display: "flex", gap: "4px" }}> 153 + {isOwner && ( 154 + <button 155 + className="annotation-action action-icon-only" 156 + onClick={handleDelete} 157 + disabled={deleting} 158 + title="Delete" 163 159 > 164 - @{authorHandle} <ExternalLinkIcon size={12} /> 165 - </a> 160 + <TrashIcon size={16} /> 161 + </button> 166 162 )} 167 163 </div> 168 - <div className="annotation-time">{formatDate(data.createdAt)}</div> 169 - </div> 170 - <div className="action-buttons"> 171 - {isOwner && ( 172 - <button 173 - className="annotation-delete" 174 - onClick={handleDelete} 175 - disabled={deleting} 176 - title="Delete" 177 - > 178 - <TrashIcon size={16} /> 179 - </button> 180 - )} 181 164 </div> 182 165 </header> 183 166 184 - {} 185 - <a 186 - href={data.url} 187 - target="_blank" 188 - rel="noopener noreferrer" 189 - className="bookmark-preview" 190 - > 191 - <div className="bookmark-preview-content"> 192 - <div className="bookmark-preview-site"> 193 - <BookmarkIcon size={14} /> 194 - <span>{domain}</span> 167 + <div className="annotation-content"> 168 + <a 169 + href={data.url} 170 + target="_blank" 171 + rel="noopener noreferrer" 172 + className="bookmark-preview" 173 + > 174 + <div className="bookmark-preview-content"> 175 + <div className="bookmark-preview-site"> 176 + <BookmarkIcon size={14} /> 177 + <span>{domain}</span> 178 + </div> 179 + <h3 className="bookmark-preview-title">{data.title || data.url}</h3> 180 + {data.description && ( 181 + <p className="bookmark-preview-desc">{data.description}</p> 182 + )} 195 183 </div> 196 - <h3 className="bookmark-preview-title">{data.title || data.url}</h3> 197 - {data.description && ( 198 - <p className="bookmark-preview-desc">{data.description}</p> 199 - )} 200 - </div> 201 - <div className="bookmark-preview-arrow"> 202 - <ExternalLinkIcon size={18} /> 203 - </div> 204 - </a> 184 + </a> 205 185 206 - {} 207 - {data.tags?.length > 0 && ( 208 - <div className="annotation-tags"> 209 - {data.tags.map((tag, i) => ( 210 - <span key={i} className="annotation-tag"> 211 - #{tag} 212 - </span> 213 - ))} 214 - </div> 215 - )} 186 + {data.tags?.length > 0 && ( 187 + <div className="annotation-tags"> 188 + {data.tags.map((tag, i) => ( 189 + <span key={i} className="annotation-tag"> 190 + #{tag} 191 + </span> 192 + ))} 193 + </div> 194 + )} 195 + </div> 216 196 217 - {} 218 197 <footer className="annotation-actions"> 219 - <button 220 - className={`annotation-action ${isLiked ? "liked" : ""}`} 221 - onClick={handleLike} 222 - > 223 - <HeartIcon filled={isLiked} size={16} /> 224 - {likeCount > 0 && <span>{likeCount}</span>} 225 - </button> 226 - <ShareMenu 227 - uri={data.uri} 228 - text={data.title || data.description} 229 - handle={data.author?.handle} 230 - type="Bookmark" 231 - /> 232 - <button 233 - className="annotation-action" 234 - onClick={() => { 235 - if (!user) { 236 - login(); 237 - return; 238 - } 239 - setShowAddToCollection(true); 240 - }} 241 - > 242 - <Folder size={16} /> 243 - <span>Collect</span> 244 - </button> 198 + <div className="annotation-actions-left"> 199 + <button 200 + className={`annotation-action ${isLiked ? "liked" : ""}`} 201 + onClick={handleLike} 202 + > 203 + <HeartIcon filled={isLiked} size={16} /> 204 + {likeCount > 0 && <span>{likeCount}</span>} 205 + </button> 206 + <ShareMenu 207 + uri={data.uri} 208 + text={data.title || data.description} 209 + handle={data.author?.handle} 210 + type="Bookmark" 211 + /> 212 + <button 213 + className="annotation-action" 214 + onClick={() => { 215 + if (!user) { 216 + login(); 217 + return; 218 + } 219 + if (onAddToCollection) onAddToCollection(); 220 + }} 221 + > 222 + <Folder size={16} /> 223 + <span>Collect</span> 224 + </button> 225 + </div> 245 226 </footer> 246 - 247 - {showAddToCollection && ( 248 - <AddToCollectionModal 249 - isOpen={showAddToCollection} 250 - annotationUri={data.uri} 251 - onClose={() => setShowAddToCollection(false)} 252 - /> 253 - )} 254 227 </article> 255 228 ); 256 229 }
+37 -9
web/src/components/Composer.jsx
··· 1 1 import { useState } from "react"; 2 - import { createAnnotation } from "../api/client"; 2 + import { createAnnotation, createHighlight } from "../api/client"; 3 3 4 4 export default function Composer({ 5 5 url, ··· 9 9 }) { 10 10 const [text, setText] = useState(""); 11 11 const [quoteText, setQuoteText] = useState(""); 12 + const [tags, setTags] = useState(""); 12 13 const [selector, setSelector] = useState(initialSelector); 13 14 const [loading, setLoading] = useState(false); 14 15 const [error, setError] = useState(null); ··· 19 20 20 21 const handleSubmit = async (e) => { 21 22 e.preventDefault(); 22 - if (!text.trim()) return; 23 + if (!text.trim() && !highlightedText && !quoteText.trim()) return; 23 24 24 25 try { 25 26 setLoading(true); ··· 33 34 }; 34 35 } 35 36 36 - await createAnnotation({ 37 - url, 38 - text, 39 - selector: finalSelector || undefined, 40 - }); 37 + const tagList = tags 38 + .split(",") 39 + .map((t) => t.trim()) 40 + .filter(Boolean); 41 + 42 + if (!text.trim()) { 43 + await createHighlight({ 44 + url, 45 + selector: finalSelector, 46 + color: "yellow", 47 + tags: tagList, 48 + }); 49 + } else { 50 + await createAnnotation({ 51 + url, 52 + text, 53 + selector: finalSelector || undefined, 54 + tags: tagList, 55 + }); 56 + } 41 57 42 58 setText(""); 43 59 setQuoteText(""); ··· 123 139 className="composer-input" 124 140 rows={4} 125 141 maxLength={3000} 126 - required 127 142 disabled={loading} 128 143 /> 144 + 145 + <div className="composer-tags"> 146 + <input 147 + type="text" 148 + value={tags} 149 + onChange={(e) => setTags(e.target.value)} 150 + placeholder="Add tags (comma separated)..." 151 + className="composer-tags-input" 152 + disabled={loading} 153 + /> 154 + </div> 129 155 130 156 <div className="composer-footer"> 131 157 <span className="composer-count">{text.length}/3000</span> ··· 143 169 <button 144 170 type="submit" 145 171 className="btn btn-primary" 146 - disabled={loading || !text.trim()} 172 + disabled={ 173 + loading || (!text.trim() && !highlightedText && !quoteText) 174 + } 147 175 > 148 176 {loading ? "Posting..." : "Post"} 149 177 </button>
+10
web/src/components/ShareMenu.jsx
··· 125 125 setIsOpen(false); 126 126 } 127 127 }; 128 + 129 + const card = menuRef.current?.closest(".card"); 130 + if (card) { 131 + if (isOpen) { 132 + card.style.zIndex = "50"; 133 + } else { 134 + card.style.zIndex = ""; 135 + } 136 + } 137 + 128 138 if (isOpen) { 129 139 document.addEventListener("mousedown", handleClickOutside); 130 140 }
+299 -65
web/src/index.css
··· 140 140 background: var(--bg-card); 141 141 border: 1px solid var(--border); 142 142 border-radius: var(--radius-lg); 143 - padding: 20px; 143 + padding: 24px; 144 144 transition: all 0.2s ease; 145 + position: relative; 145 146 } 146 147 147 148 .card:hover { 148 149 border-color: var(--border-hover); 149 - box-shadow: var(--shadow-sm); 150 + box-shadow: var(--shadow-md); 151 + transform: translateY(-1px); 150 152 } 151 153 152 154 .annotation-card { 153 155 display: flex; 154 156 flex-direction: column; 155 - gap: 12px; 157 + gap: 16px; 156 158 } 157 159 158 160 .annotation-header { 159 161 display: flex; 162 + justify-content: space-between; 163 + align-items: flex-start; 164 + gap: 12px; 165 + } 166 + 167 + .annotation-header-left { 168 + display: flex; 160 169 align-items: center; 161 170 gap: 12px; 171 + flex: 1; 172 + min-width: 0; 162 173 } 163 174 164 175 .annotation-avatar { 165 - width: 42px; 166 - height: 42px; 167 - min-width: 42px; 176 + width: 40px; 177 + height: 40px; 178 + min-width: 40px; 168 179 border-radius: var(--radius-full); 169 180 background: linear-gradient(135deg, var(--accent), #a855f7); 170 181 display: flex; 171 182 align-items: center; 172 183 justify-content: center; 173 184 font-weight: 600; 174 - font-size: 1rem; 185 + font-size: 0.95rem; 175 186 color: white; 176 187 overflow: hidden; 188 + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2); 177 189 } 178 190 179 191 .annotation-avatar img { ··· 183 195 } 184 196 185 197 .annotation-meta { 186 - flex: 1; 187 - min-width: 0; 198 + display: flex; 199 + flex-direction: column; 200 + justify-content: center; 201 + line-height: 1.3; 188 202 } 189 203 190 204 .annotation-avatar-link { 191 205 text-decoration: none; 206 + border-radius: var(--radius-full); 207 + transition: transform 0.15s ease; 208 + } 209 + 210 + .annotation-avatar-link:hover { 211 + transform: scale(1.05); 192 212 } 193 213 194 214 .annotation-author-row { ··· 201 221 .annotation-author { 202 222 font-weight: 600; 203 223 color: var(--text-primary); 224 + font-size: 0.95rem; 204 225 } 205 226 206 227 .annotation-handle { 207 - font-size: 0.9rem; 228 + font-size: 0.85rem; 208 229 color: var(--text-tertiary); 209 230 text-decoration: none; 231 + display: flex; 232 + align-items: center; 233 + gap: 3px; 210 234 } 211 235 212 236 .annotation-handle:hover { 213 237 color: var(--accent); 214 - text-decoration: underline; 215 238 } 216 239 217 240 .annotation-time { 218 - font-size: 0.85rem; 241 + font-size: 0.8rem; 219 242 color: var(--text-tertiary); 243 + } 244 + 245 + .annotation-content { 246 + display: flex; 247 + flex-direction: column; 248 + gap: 12px; 220 249 } 221 250 222 251 .annotation-source { 223 - display: block; 224 - font-size: 0.85rem; 252 + display: inline-flex; 253 + align-items: center; 254 + gap: 6px; 255 + font-size: 0.8rem; 225 256 color: var(--text-tertiary); 226 257 text-decoration: none; 227 - margin-bottom: 8px; 258 + padding: 4px 10px; 259 + background: var(--bg-tertiary); 260 + border-radius: var(--radius-full); 261 + width: fit-content; 262 + transition: all 0.15s ease; 263 + max-width: 100%; 264 + overflow: hidden; 265 + text-overflow: ellipsis; 266 + white-space: nowrap; 228 267 } 229 268 230 269 .annotation-source:hover { 231 - color: var(--accent); 270 + color: var(--text-primary); 271 + background: var(--bg-hover); 232 272 } 233 273 234 274 .annotation-source-title { 235 275 color: var(--text-secondary); 276 + opacity: 0.8; 236 277 } 237 278 238 279 .annotation-highlight { 239 280 display: block; 240 - padding: 12px 16px; 281 + position: relative; 282 + padding: 16px 20px; 241 283 background: linear-gradient( 242 284 135deg, 243 - rgba(79, 70, 229, 0.05), 244 - rgba(168, 85, 247, 0.05) 285 + rgba(79, 70, 229, 0.03), 286 + rgba(168, 85, 247, 0.03) 245 287 ); 246 288 border-left: 3px solid var(--accent); 247 - border-radius: 0 var(--radius-sm) var(--radius-sm) 0; 289 + border-radius: 4px var(--radius-md) var(--radius-md) 4px; 248 290 text-decoration: none; 249 - transition: all 0.15s ease; 250 - margin-bottom: 12px; 291 + transition: all 0.2s ease; 292 + margin: 4px 0; 251 293 } 252 294 253 295 .annotation-highlight:hover { 254 296 background: linear-gradient( 255 297 135deg, 256 - rgba(79, 70, 229, 0.1), 257 - rgba(168, 85, 247, 0.1) 298 + rgba(79, 70, 229, 0.08), 299 + rgba(168, 85, 247, 0.08) 258 300 ); 301 + transform: translateX(2px); 259 302 } 260 303 261 304 .annotation-highlight mark { 262 305 background: transparent; 263 306 color: var(--text-primary); 264 307 font-style: italic; 265 - font-size: 0.95rem; 308 + font-size: 1.05rem; 309 + line-height: 1.6; 310 + font-weight: 400; 311 + display: inline; 266 312 } 267 313 268 314 .annotation-text { 269 315 font-size: 1rem; 270 316 line-height: 1.65; 271 317 color: var(--text-primary); 318 + white-space: pre-wrap; 272 319 } 273 320 274 321 .annotation-actions { 275 322 display: flex; 276 323 align-items: center; 277 - gap: 16px; 278 - padding-top: 8px; 324 + justify-content: space-between; 325 + padding-top: 16px; 326 + margin-top: 8px; 327 + border-top: 1px solid rgba(255, 255, 255, 0.03); 328 + } 329 + 330 + .annotation-actions-left { 331 + display: flex; 332 + align-items: center; 333 + gap: 8px; 279 334 } 280 335 281 336 .annotation-action { ··· 284 339 gap: 6px; 285 340 color: var(--text-tertiary); 286 341 font-size: 0.85rem; 342 + font-weight: 500; 287 343 padding: 6px 10px; 288 - border-radius: var(--radius-sm); 289 - transition: all 0.15s ease; 344 + border-radius: var(--radius-md); 345 + transition: all 0.2s ease; 346 + background: transparent; 347 + cursor: pointer; 290 348 } 291 349 292 350 .annotation-action:hover { 293 351 color: var(--text-secondary); 294 - background: var(--bg-tertiary); 352 + background: var(--bg-elevated); 295 353 } 296 354 297 355 .annotation-action.liked { 298 356 color: #ef4444; 357 + background: rgba(239, 68, 68, 0.05); 358 + } 359 + 360 + .annotation-action.liked:hover { 361 + background: rgba(239, 68, 68, 0.1); 362 + } 363 + 364 + .annotation-action.active { 365 + color: var(--accent); 366 + background: var(--accent-subtle); 367 + } 368 + 369 + .action-icon-only { 370 + padding: 8px; 299 371 } 300 372 301 373 .annotation-delete { 302 374 background: none; 303 375 border: none; 304 376 cursor: pointer; 305 - padding: 6px 8px; 377 + padding: 8px; 306 378 font-size: 1rem; 307 379 color: var(--text-tertiary); 308 - transition: all 0.15s ease; 309 - border-radius: var(--radius-sm); 380 + transition: all 0.2s ease; 381 + border-radius: var(--radius-md); 382 + opacity: 0.6; 310 383 } 311 384 312 385 .annotation-delete:hover { 313 386 color: var(--error); 314 387 background: rgba(239, 68, 68, 0.1); 388 + opacity: 1; 315 389 } 316 390 317 391 .annotation-delete:disabled { ··· 1043 1117 border-bottom-color: var(--accent); 1044 1118 } 1045 1119 1046 - .bookmark-card { 1047 - padding: 16px 20px; 1048 - } 1049 - 1050 - .bookmark-header { 1051 - display: flex; 1052 - align-items: flex-start; 1053 - justify-content: space-between; 1054 - gap: 12px; 1055 - } 1056 - 1057 - .bookmark-link { 1058 - text-decoration: none; 1059 - flex: 1; 1060 - } 1061 - 1062 - .bookmark-title { 1063 - font-size: 1rem; 1064 - font-weight: 600; 1065 - color: var(--text-primary); 1066 - margin: 0 0 4px 0; 1067 - line-height: 1.4; 1068 - } 1069 - 1070 - .bookmark-title:hover { 1071 - color: var(--accent); 1072 - } 1073 - 1074 1120 .bookmark-description { 1075 1121 font-size: 0.9rem; 1076 1122 color: var(--text-secondary); ··· 1368 1414 color: var(--text-tertiary); 1369 1415 } 1370 1416 1417 + .composer-tags { 1418 + margin-top: 12px; 1419 + } 1420 + 1421 + .composer-tags-input { 1422 + width: 100%; 1423 + padding: 12px 16px; 1424 + background: var(--bg-secondary); 1425 + border: 1px solid var(--border); 1426 + border-radius: var(--radius-md); 1427 + color: var(--text-primary); 1428 + font-size: 0.95rem; 1429 + transition: all 0.15s ease; 1430 + } 1431 + 1432 + .composer-tags-input:focus { 1433 + outline: none; 1434 + border-color: var(--accent); 1435 + box-shadow: 0 0 0 3px var(--accent-subtle); 1436 + } 1437 + 1438 + .composer-tags-input::placeholder { 1439 + color: var(--text-tertiary); 1440 + } 1441 + 1371 1442 .composer-footer { 1372 1443 display: flex; 1373 1444 justify-content: space-between; ··· 1393 1464 border-radius: var(--radius-md); 1394 1465 color: var(--error); 1395 1466 font-size: 0.9rem; 1467 + } 1468 + 1469 + .annotation-tags { 1470 + display: flex; 1471 + flex-wrap: wrap; 1472 + gap: 6px; 1473 + margin-top: 12px; 1474 + margin-bottom: 8px; 1475 + } 1476 + 1477 + .annotation-tag { 1478 + display: inline-flex; 1479 + align-items: center; 1480 + padding: 4px 10px; 1481 + background: var(--bg-tertiary); 1482 + color: var(--text-secondary); 1483 + font-size: 0.8rem; 1484 + font-weight: 500; 1485 + border-radius: var(--radius-full); 1486 + transition: all 0.15s ease; 1487 + border: 1px solid transparent; 1488 + text-decoration: none; 1489 + } 1490 + 1491 + .annotation-tag:hover { 1492 + background: var(--bg-hover); 1493 + color: var(--text-primary); 1494 + border-color: var(--border); 1495 + transform: translateY(-1px); 1496 + } 1497 + 1498 + .url-input-wrapper { 1499 + margin-bottom: 24px; 1500 + } 1501 + 1502 + .url-input { 1503 + width: 100%; 1504 + padding: 16px; 1505 + background: var(--bg-secondary); 1506 + border: 1px solid var(--border); 1507 + border-radius: var(--radius-md); 1508 + color: var(--text-primary); 1509 + font-size: 1.1rem; 1510 + transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); 1511 + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05); 1512 + } 1513 + 1514 + .url-input:focus { 1515 + outline: none; 1516 + border-color: var(--accent); 1517 + box-shadow: 0 0 0 4px var(--accent-subtle); 1518 + background: var(--bg-primary); 1519 + } 1520 + 1521 + .url-input::placeholder { 1522 + color: var(--text-tertiary); 1396 1523 } 1397 1524 1398 1525 .annotation-detail-page { ··· 2929 3056 padding: 1rem; 2930 3057 } 2931 3058 3059 + .form-label { 3060 + display: block; 3061 + font-size: 0.85rem; 3062 + font-weight: 600; 3063 + color: var(--text-secondary); 3064 + margin-bottom: 6px; 3065 + } 3066 + 3067 + .color-input-container { 3068 + display: flex; 3069 + align-items: center; 3070 + gap: 12px; 3071 + background: var(--bg-tertiary); 3072 + padding: 8px 12px; 3073 + border-radius: var(--radius-md); 3074 + border: 1px solid var(--border); 3075 + width: fit-content; 3076 + } 3077 + 3078 + .color-input-wrapper { 3079 + position: relative; 3080 + width: 32px; 3081 + height: 32px; 3082 + border-radius: var(--radius-full); 3083 + overflow: hidden; 3084 + border: 2px solid var(--border); 3085 + cursor: pointer; 3086 + transition: transform 0.1s; 3087 + } 3088 + 3089 + .color-input-wrapper:hover { 3090 + transform: scale(1.1); 3091 + border-color: var(--accent); 3092 + } 3093 + 3094 + .color-input-wrapper input[type="color"] { 3095 + position: absolute; 3096 + top: -50%; 3097 + left: -50%; 3098 + width: 200%; 3099 + height: 200%; 3100 + padding: 0; 3101 + margin: 0; 3102 + border: none; 3103 + cursor: pointer; 3104 + opacity: 0; 3105 + } 3106 + 2932 3107 .bookmark-card { 2933 3108 display: flex; 2934 3109 flex-direction: column; 2935 - gap: 12px; 3110 + gap: 16px; 2936 3111 } 2937 3112 2938 3113 .bookmark-preview { 2939 3114 display: flex; 2940 - align-items: stretch; 2941 - gap: 16px; 2942 - padding: 14px 16px; 3115 + flex-direction: column; 2943 3116 background: var(--bg-secondary); 2944 3117 border: 1px solid var(--border); 2945 3118 border-radius: var(--radius-md); 3119 + overflow: hidden; 2946 3120 text-decoration: none; 2947 3121 transition: all 0.2s ease; 3122 + position: relative; 3123 + } 3124 + 3125 + .bookmark-preview:hover { 3126 + border-color: var(--accent); 3127 + box-shadow: var(--shadow-sm); 3128 + transform: translateY(-1px); 3129 + } 3130 + 3131 + .bookmark-preview::before { 3132 + content: ""; 3133 + position: absolute; 3134 + left: 0; 3135 + top: 0; 3136 + bottom: 0; 3137 + width: 4px; 3138 + background: var(--accent); 3139 + opacity: 0.7; 3140 + } 3141 + 3142 + .bookmark-preview-content { 3143 + padding: 16px 20px; 3144 + display: flex; 3145 + flex-direction: column; 3146 + gap: 8px; 3147 + } 3148 + 3149 + .bookmark-preview-header { 3150 + display: flex; 3151 + align-items: center; 3152 + gap: 8px; 3153 + margin-bottom: 4px; 3154 + } 3155 + 3156 + .bookmark-preview-site { 3157 + font-size: 0.75rem; 3158 + color: var(--accent); 3159 + text-transform: uppercase; 3160 + letter-spacing: 0.05em; 3161 + font-weight: 700; 3162 + display: flex; 3163 + align-items: center; 3164 + gap: 6px; 3165 + } 3166 + 3167 + .bookmark-preview-title { 3168 + font-size: 1.15rem; 3169 + font-weight: 700; 3170 + color: var(--text-primary); 3171 + line-height: 1.4; 3172 + } 3173 + 3174 + .bookmark-preview-desc { 3175 + font-size: 0.95rem; 3176 + color: var(--text-secondary); 3177 + line-height: 1.6; 3178 + } 3179 + 3180 + .bookmark-preview-arrow { 3181 + display: none; 2948 3182 } 2949 3183 2950 3184 .bookmark-preview:hover {
+94 -5
web/src/pages/Feed.jsx
··· 1 1 import { useState, useEffect } from "react"; 2 + import { useSearchParams } from "react-router-dom"; 2 3 import AnnotationCard, { HighlightCard } from "../components/AnnotationCard"; 3 4 import BookmarkCard from "../components/BookmarkCard"; 4 5 import CollectionItemCard from "../components/CollectionItemCard"; 5 6 import { getAnnotationFeed, deleteHighlight } from "../api/client"; 6 7 import { AlertIcon, InboxIcon } from "../components/Icons"; 8 + import { useAuth } from "../context/AuthContext"; 9 + 10 + import AddToCollectionModal from "../components/AddToCollectionModal"; 7 11 8 12 export default function Feed() { 13 + const [searchParams, setSearchParams] = useSearchParams(); 14 + const tagFilter = searchParams.get("tag"); 9 15 const [annotations, setAnnotations] = useState([]); 10 16 const [loading, setLoading] = useState(true); 11 17 const [error, setError] = useState(null); 12 18 const [filter, setFilter] = useState("all"); 19 + const [collectionModalState, setCollectionModalState] = useState({ 20 + isOpen: false, 21 + uri: null, 22 + }); 23 + 24 + const { user } = useAuth(); 13 25 14 26 useEffect(() => { 15 27 async function fetchFeed() { 16 28 try { 17 29 setLoading(true); 18 - const data = await getAnnotationFeed(); 30 + let creatorDid = ""; 31 + if (filter === "my-tags" && user?.did) { 32 + creatorDid = user.did; 33 + } 34 + 35 + const data = await getAnnotationFeed( 36 + 50, 37 + 0, 38 + tagFilter || "", 39 + creatorDid, 40 + ); 19 41 setAnnotations(data.items || []); 20 42 } catch (err) { 21 43 setError(err.message); ··· 24 46 } 25 47 } 26 48 fetchFeed(); 27 - }, []); 49 + }, [tagFilter, filter, user]); 28 50 29 51 const filteredAnnotations = 30 - filter === "all" 52 + filter === "all" || filter === "my-tags" 31 53 ? annotations 32 54 : annotations.filter((a) => { 33 55 if (filter === "commenting") ··· 46 68 <p className="page-description"> 47 69 See what people are annotating, highlighting, and bookmarking 48 70 </p> 71 + {tagFilter && ( 72 + <div 73 + style={{ 74 + marginTop: "16px", 75 + display: "flex", 76 + alignItems: "center", 77 + gap: "8px", 78 + }} 79 + > 80 + <span 81 + style={{ fontSize: "0.9rem", color: "var(--text-secondary)" }} 82 + > 83 + Filtering by tag: <strong>#{tagFilter}</strong> 84 + </span> 85 + <button 86 + onClick={() => setSearchParams({})} 87 + className="btn btn-sm" 88 + style={{ padding: "2px 8px", fontSize: "0.8rem" }} 89 + > 90 + Clear 91 + </button> 92 + </div> 93 + )} 49 94 </div> 50 95 51 96 {} ··· 56 101 > 57 102 All 58 103 </button> 104 + {user && ( 105 + <button 106 + className={`filter-tab ${filter === "my-tags" ? "active" : ""}`} 107 + onClick={() => setFilter("my-tags")} 108 + > 109 + My Feed 110 + </button> 111 + )} 59 112 <button 60 113 className={`filter-tab ${filter === "commenting" ? "active" : ""}`} 61 114 onClick={() => setFilter("commenting")} ··· 140 193 prev.filter((a) => a.id !== item.id), 141 194 ); 142 195 }} 196 + onAddToCollection={() => 197 + setCollectionModalState({ 198 + isOpen: true, 199 + uri: item.uri || item.id, 200 + }) 201 + } 143 202 /> 144 203 ); 145 204 } 146 205 if (item.type === "Bookmark" || item.motivation === "bookmarking") { 147 - return <BookmarkCard key={item.id} bookmark={item} />; 206 + return ( 207 + <BookmarkCard 208 + key={item.id} 209 + bookmark={item} 210 + onAddToCollection={() => 211 + setCollectionModalState({ 212 + isOpen: true, 213 + uri: item.uri || item.id, 214 + }) 215 + } 216 + /> 217 + ); 148 218 } 149 - return <AnnotationCard key={item.id} annotation={item} />; 219 + return ( 220 + <AnnotationCard 221 + key={item.id} 222 + annotation={item} 223 + onAddToCollection={() => 224 + setCollectionModalState({ 225 + isOpen: true, 226 + uri: item.uri || item.id, 227 + }) 228 + } 229 + /> 230 + ); 150 231 })} 151 232 </div> 233 + )} 234 + 235 + {collectionModalState.isOpen && ( 236 + <AddToCollectionModal 237 + isOpen={collectionModalState.isOpen} 238 + onClose={() => setCollectionModalState({ isOpen: false, uri: null })} 239 + annotationUri={collectionModalState.uri} 240 + /> 152 241 )} 153 242 </div> 154 243 );