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

Implement tags and better card styles

+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 );