Explore the margin.at codebase, lexicons, and more! margin.at

Compare changes

Choose any two refs to compare.

+6
backend/cmd/server/main.go
··· 97 r.Get("/og-image", ogHandler.HandleOGImage) 98 r.Get("/annotation/{did}/{rkey}", ogHandler.HandleAnnotationPage) 99 r.Get("/at/{did}/{rkey}", ogHandler.HandleAnnotationPage) 100 101 staticDir := getEnv("STATIC_DIR", "../web/dist") 102 serveStatic(r, staticDir)
··· 97 r.Get("/og-image", ogHandler.HandleOGImage) 98 r.Get("/annotation/{did}/{rkey}", ogHandler.HandleAnnotationPage) 99 r.Get("/at/{did}/{rkey}", ogHandler.HandleAnnotationPage) 100 + r.Get("/{handle}/annotation/{rkey}", ogHandler.HandleAnnotationPage) 101 + r.Get("/{handle}/highlight/{rkey}", ogHandler.HandleAnnotationPage) 102 + r.Get("/{handle}/bookmark/{rkey}", ogHandler.HandleAnnotationPage) 103 + 104 + r.Get("/collection/{uri}", ogHandler.HandleCollectionPage) 105 + r.Get("/{handle}/collection/{rkey}", ogHandler.HandleCollectionPage) 106 107 staticDir := getEnv("STATIC_DIR", "../web/dist") 108 serveStatic(r, staticDir)
+45 -24
backend/internal/api/annotations.go
··· 47 return 48 } 49 50 - if req.URL == "" || req.Text == "" { 51 - http.Error(w, "URL and text are required", http.StatusBadRequest) 52 return 53 } 54 ··· 67 } 68 69 record := xrpc.NewAnnotationRecordWithMotivation(req.URL, urlHash, req.Text, req.Selector, req.Title, motivation) 70 71 var result *xrpc.CreateRecordOutput 72 err = s.refresher.ExecuteWithAutoRefresh(r, session, func(client *xrpc.Client, did string) error { ··· 93 selectorJSONPtr = &selectorStr 94 } 95 96 cid := result.CID 97 did := session.DID 98 annotation := &db.Annotation{ ··· 105 TargetHash: urlHash, 106 TargetTitle: targetTitlePtr, 107 SelectorJSON: selectorJSONPtr, 108 CreatedAt: time.Now(), 109 IndexedAt: time.Now(), 110 } ··· 203 } 204 rkey := parts[2] 205 206 - var selector interface{} = nil 207 - if annotation.SelectorJSON != nil && *annotation.SelectorJSON != "" { 208 - json.Unmarshal([]byte(*annotation.SelectorJSON), &selector) 209 - } 210 - 211 tagsJSON := "" 212 if len(req.Tags) > 0 { 213 tagsBytes, _ := json.Marshal(req.Tags) 214 tagsJSON = string(tagsBytes) 215 } 216 217 - record := map[string]interface{}{ 218 - "$type": xrpc.CollectionAnnotation, 219 - "text": req.Text, 220 - "url": annotation.TargetSource, 221 - "createdAt": annotation.CreatedAt.Format(time.RFC3339), 222 - } 223 - if selector != nil { 224 - record["selector"] = selector 225 - } 226 - if len(req.Tags) > 0 { 227 - record["tags"] = req.Tags 228 - } 229 - if annotation.TargetTitle != nil { 230 - record["title"] = *annotation.TargetTitle 231 - } 232 - 233 if annotation.BodyValue != nil { 234 previousContent := *annotation.BodyValue 235 s.db.SaveEditHistory(uri, "annotation", previousContent, annotation.CID) ··· 237 238 var result *xrpc.PutRecordOutput 239 err = s.refresher.ExecuteWithAutoRefresh(r, session, func(client *xrpc.Client, did string) error { 240 var updateErr error 241 result, updateErr = client.PutRecord(r.Context(), did, xrpc.CollectionAnnotation, rkey, record) 242 if updateErr != nil { ··· 498 Title string `json:"title,omitempty"` 499 Selector interface{} `json:"selector"` 500 Color string `json:"color,omitempty"` 501 } 502 503 func (s *AnnotationService) CreateHighlight(w http.ResponseWriter, r *http.Request) { ··· 519 } 520 521 urlHash := db.HashURL(req.URL) 522 - record := xrpc.NewHighlightRecord(req.URL, urlHash, req.Selector, req.Color) 523 524 var result *xrpc.CreateRecordOutput 525 err = s.refresher.ExecuteWithAutoRefresh(r, session, func(client *xrpc.Client, did string) error { ··· 549 colorPtr = &req.Color 550 } 551 552 cid := result.CID 553 highlight := &db.Highlight{ 554 URI: result.URI, ··· 558 TargetTitle: titlePtr, 559 SelectorJSON: selectorJSONPtr, 560 Color: colorPtr, 561 CreatedAt: time.Now(), 562 IndexedAt: time.Now(), 563 CID: &cid,
··· 47 return 48 } 49 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) 57 return 58 } 59 ··· 72 } 73 74 record := xrpc.NewAnnotationRecordWithMotivation(req.URL, urlHash, req.Text, req.Selector, req.Title, motivation) 75 + if len(req.Tags) > 0 { 76 + record.Tags = req.Tags 77 + } 78 79 var result *xrpc.CreateRecordOutput 80 err = s.refresher.ExecuteWithAutoRefresh(r, session, func(client *xrpc.Client, did string) error { ··· 101 selectorJSONPtr = &selectorStr 102 } 103 104 + var tagsJSONPtr *string 105 + if len(req.Tags) > 0 { 106 + tagsBytes, _ := json.Marshal(req.Tags) 107 + tagsStr := string(tagsBytes) 108 + tagsJSONPtr = &tagsStr 109 + } 110 + 111 cid := result.CID 112 did := session.DID 113 annotation := &db.Annotation{ ··· 120 TargetHash: urlHash, 121 TargetTitle: targetTitlePtr, 122 SelectorJSON: selectorJSONPtr, 123 + TagsJSON: tagsJSONPtr, 124 CreatedAt: time.Now(), 125 IndexedAt: time.Now(), 126 } ··· 219 } 220 rkey := parts[2] 221 222 tagsJSON := "" 223 if len(req.Tags) > 0 { 224 tagsBytes, _ := json.Marshal(req.Tags) 225 tagsJSON = string(tagsBytes) 226 } 227 228 if annotation.BodyValue != nil { 229 previousContent := *annotation.BodyValue 230 s.db.SaveEditHistory(uri, "annotation", previousContent, annotation.CID) ··· 232 233 var result *xrpc.PutRecordOutput 234 err = s.refresher.ExecuteWithAutoRefresh(r, session, func(client *xrpc.Client, did string) error { 235 + existing, getErr := client.GetRecord(r.Context(), did, xrpc.CollectionAnnotation, rkey) 236 + if getErr != nil { 237 + return fmt.Errorf("failed to fetch existing record: %w", getErr) 238 + } 239 + 240 + var record map[string]interface{} 241 + if err := json.Unmarshal(existing.Value, &record); err != nil { 242 + return fmt.Errorf("failed to parse existing record: %w", err) 243 + } 244 + 245 + record["text"] = req.Text 246 + if req.Tags != nil { 247 + record["tags"] = req.Tags 248 + } else { 249 + delete(record, "tags") 250 + } 251 + 252 var updateErr error 253 result, updateErr = client.PutRecord(r.Context(), did, xrpc.CollectionAnnotation, rkey, record) 254 if updateErr != nil { ··· 510 Title string `json:"title,omitempty"` 511 Selector interface{} `json:"selector"` 512 Color string `json:"color,omitempty"` 513 + Tags []string `json:"tags,omitempty"` 514 } 515 516 func (s *AnnotationService) CreateHighlight(w http.ResponseWriter, r *http.Request) { ··· 532 } 533 534 urlHash := db.HashURL(req.URL) 535 + record := xrpc.NewHighlightRecord(req.URL, urlHash, req.Selector, req.Color, req.Tags) 536 537 var result *xrpc.CreateRecordOutput 538 err = s.refresher.ExecuteWithAutoRefresh(r, session, func(client *xrpc.Client, did string) error { ··· 562 colorPtr = &req.Color 563 } 564 565 + var tagsJSONPtr *string 566 + if len(req.Tags) > 0 { 567 + tagsBytes, _ := json.Marshal(req.Tags) 568 + tagsStr := string(tagsBytes) 569 + tagsJSONPtr = &tagsStr 570 + } 571 + 572 cid := result.CID 573 highlight := &db.Highlight{ 574 URI: result.URI, ··· 578 TargetTitle: titlePtr, 579 SelectorJSON: selectorJSONPtr, 580 Color: colorPtr, 581 + TagsJSON: tagsJSONPtr, 582 CreatedAt: time.Now(), 583 IndexedAt: time.Now(), 584 CID: &cid,
+35 -5
backend/internal/api/collections.go
··· 213 return 214 } 215 216 w.Header().Set("Content-Type", "application/json") 217 json.NewEncoder(w).Encode(map[string]interface{}{ 218 "@context": "http://www.w3.org/ns/anno.jsonld", 219 "type": "Collection", 220 - "items": collections, 221 - "totalItems": len(collections), 222 }) 223 } 224 ··· 254 255 enrichedItems := make([]EnrichedCollectionItem, 0, len(items)) 256 257 for _, item := range items { 258 enriched := EnrichedCollectionItem{ 259 URI: item.URI, ··· 266 if strings.Contains(item.AnnotationURI, "at.margin.annotation") { 267 enriched.Type = "annotation" 268 if a, err := s.db.GetAnnotationByURI(item.AnnotationURI); err == nil { 269 - hydrated, _ := hydrateAnnotations([]db.Annotation{*a}) 270 if len(hydrated) > 0 { 271 enriched.Annotation = &hydrated[0] 272 } ··· 274 } else if strings.Contains(item.AnnotationURI, "at.margin.highlight") { 275 enriched.Type = "highlight" 276 if h, err := s.db.GetHighlightByURI(item.AnnotationURI); err == nil { 277 - hydrated, _ := hydrateHighlights([]db.Highlight{*h}) 278 if len(hydrated) > 0 { 279 enriched.Highlight = &hydrated[0] 280 } ··· 282 } else if strings.Contains(item.AnnotationURI, "at.margin.bookmark") { 283 enriched.Type = "bookmark" 284 if b, err := s.db.GetBookmarkByURI(item.AnnotationURI); err == nil { 285 - hydrated, _ := hydrateBookmarks([]db.Bookmark{*b}) 286 if len(hydrated) > 0 { 287 enriched.Bookmark = &hydrated[0] 288 }
··· 213 return 214 } 215 216 + profiles := fetchProfilesForDIDs([]string{authorDID}) 217 + creator := profiles[authorDID] 218 + 219 + apiCollections := make([]APICollection, len(collections)) 220 + for i, c := range collections { 221 + icon := "" 222 + if c.Icon != nil { 223 + icon = *c.Icon 224 + } 225 + desc := "" 226 + if c.Description != nil { 227 + desc = *c.Description 228 + } 229 + apiCollections[i] = APICollection{ 230 + URI: c.URI, 231 + Name: c.Name, 232 + Description: desc, 233 + Icon: icon, 234 + Creator: creator, 235 + CreatedAt: c.CreatedAt, 236 + IndexedAt: c.IndexedAt, 237 + } 238 + } 239 + 240 w.Header().Set("Content-Type", "application/json") 241 json.NewEncoder(w).Encode(map[string]interface{}{ 242 "@context": "http://www.w3.org/ns/anno.jsonld", 243 "type": "Collection", 244 + "items": apiCollections, 245 + "totalItems": len(apiCollections), 246 }) 247 } 248 ··· 278 279 enrichedItems := make([]EnrichedCollectionItem, 0, len(items)) 280 281 + session, err := s.refresher.GetSessionWithAutoRefresh(r) 282 + viewerDID := "" 283 + if err == nil { 284 + viewerDID = session.DID 285 + } 286 + 287 for _, item := range items { 288 enriched := EnrichedCollectionItem{ 289 URI: item.URI, ··· 296 if strings.Contains(item.AnnotationURI, "at.margin.annotation") { 297 enriched.Type = "annotation" 298 if a, err := s.db.GetAnnotationByURI(item.AnnotationURI); err == nil { 299 + hydrated, _ := hydrateAnnotations(s.db, []db.Annotation{*a}, viewerDID) 300 if len(hydrated) > 0 { 301 enriched.Annotation = &hydrated[0] 302 } ··· 304 } else if strings.Contains(item.AnnotationURI, "at.margin.highlight") { 305 enriched.Type = "highlight" 306 if h, err := s.db.GetHighlightByURI(item.AnnotationURI); err == nil { 307 + hydrated, _ := hydrateHighlights(s.db, []db.Highlight{*h}, viewerDID) 308 if len(hydrated) > 0 { 309 enriched.Highlight = &hydrated[0] 310 } ··· 312 } else if strings.Contains(item.AnnotationURI, "at.margin.bookmark") { 313 enriched.Type = "bookmark" 314 if b, err := s.db.GetBookmarkByURI(item.AnnotationURI); err == nil { 315 + hydrated, _ := hydrateBookmarks(s.db, []db.Bookmark{*b}, viewerDID) 316 if len(hydrated) > 0 { 317 enriched.Bookmark = &hydrated[0] 318 }
+119 -40
backend/internal/api/handler.go
··· 81 limit := parseIntParam(r, "limit", 50) 82 offset := parseIntParam(r, "offset", 0) 83 motivation := r.URL.Query().Get("motivation") 84 85 var annotations []db.Annotation 86 var err error ··· 90 annotations, err = h.db.GetAnnotationsByTargetHash(urlHash, limit, offset) 91 } else if motivation != "" { 92 annotations, err = h.db.GetAnnotationsByMotivation(motivation, limit, offset) 93 } else { 94 annotations, err = h.db.GetRecentAnnotations(limit, offset) 95 } ··· 99 return 100 } 101 102 - enriched, _ := hydrateAnnotations(annotations) 103 104 w.Header().Set("Content-Type", "application/json") 105 json.NewEncoder(w).Encode(map[string]interface{}{ ··· 112 113 func (h *Handler) GetFeed(w http.ResponseWriter, r *http.Request) { 114 limit := parseIntParam(r, "limit", 50) 115 - 116 - annotations, _ := h.db.GetRecentAnnotations(limit, 0) 117 - highlights, _ := h.db.GetRecentHighlights(limit, 0) 118 - bookmarks, _ := h.db.GetRecentBookmarks(limit, 0) 119 120 - authAnnos, _ := hydrateAnnotations(annotations) 121 - authHighs, _ := hydrateHighlights(highlights) 122 - authBooks, _ := hydrateBookmarks(bookmarks) 123 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 - authCollectionItems, _ := hydrateCollectionItems(h.db, collectionItems) 130 - // log.Printf("Hydrated %d collection items\n", len(authCollectionItems)) 131 132 var feed []interface{} 133 for _, a := range authAnnos { ··· 188 return 189 } 190 191 - annotation, err := h.db.GetAnnotationByURI(uri) 192 - if err != nil { 193 - http.Error(w, "Annotation not found", http.StatusNotFound) 194 - return 195 } 196 197 - enriched, _ := hydrateAnnotations([]db.Annotation{*annotation}) 198 - if len(enriched) == 0 { 199 - http.Error(w, "Annotation not found", http.StatusNotFound) 200 - return 201 } 202 203 - w.Header().Set("Content-Type", "application/json") 204 - response := map[string]interface{}{ 205 - "@context": "http://www.w3.org/ns/anno.jsonld", 206 } 207 - annJSON, _ := json.Marshal(enriched[0]) 208 - json.Unmarshal(annJSON, &response) 209 210 - json.NewEncoder(w).Encode(response) 211 } 212 213 func (h *Handler) GetByTarget(w http.ResponseWriter, r *http.Request) { ··· 228 annotations, _ := h.db.GetAnnotationsByTargetHash(urlHash, limit, offset) 229 highlights, _ := h.db.GetHighlightsByTargetHash(urlHash, limit, offset) 230 231 - enrichedAnnotations, _ := hydrateAnnotations(annotations) 232 - enrichedHighlights, _ := hydrateHighlights(highlights) 233 234 w.Header().Set("Content-Type", "application/json") 235 json.NewEncoder(w).Encode(map[string]interface{}{ ··· 243 244 func (h *Handler) GetHighlights(w http.ResponseWriter, r *http.Request) { 245 did := r.URL.Query().Get("creator") 246 limit := parseIntParam(r, "limit", 50) 247 offset := parseIntParam(r, "offset", 0) 248 249 - if did == "" { 250 - http.Error(w, "creator parameter required", http.StatusBadRequest) 251 - return 252 } 253 254 - highlights, err := h.db.GetHighlightsByAuthor(did, limit, offset) 255 if err != nil { 256 http.Error(w, err.Error(), http.StatusInternalServerError) 257 return 258 } 259 260 - enriched, _ := hydrateHighlights(highlights) 261 262 w.Header().Set("Content-Type", "application/json") 263 json.NewEncoder(w).Encode(map[string]interface{}{ ··· 284 return 285 } 286 287 - enriched, _ := hydrateBookmarks(bookmarks) 288 289 w.Header().Set("Content-Type", "application/json") 290 json.NewEncoder(w).Encode(map[string]interface{}{ ··· 309 return 310 } 311 312 - enriched, _ := hydrateAnnotations(annotations) 313 314 w.Header().Set("Content-Type", "application/json") 315 json.NewEncoder(w).Encode(map[string]interface{}{ ··· 335 return 336 } 337 338 - enriched, _ := hydrateHighlights(highlights) 339 340 w.Header().Set("Content-Type", "application/json") 341 json.NewEncoder(w).Encode(map[string]interface{}{ ··· 361 return 362 } 363 364 - enriched, _ := hydrateBookmarks(bookmarks) 365 366 w.Header().Set("Content-Type", "application/json") 367 json.NewEncoder(w).Encode(map[string]interface{}{ ··· 515 return 516 } 517 518 - enriched, err := hydrateNotifications(notifications) 519 if err != nil { 520 log.Printf("Failed to hydrate notifications: %v\n", err) 521 } ··· 560 w.Header().Set("Content-Type", "application/json") 561 json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) 562 }
··· 81 limit := parseIntParam(r, "limit", 50) 82 offset := parseIntParam(r, "offset", 0) 83 motivation := r.URL.Query().Get("motivation") 84 + tag := r.URL.Query().Get("tag") 85 86 var annotations []db.Annotation 87 var err error ··· 91 annotations, err = h.db.GetAnnotationsByTargetHash(urlHash, limit, offset) 92 } else if motivation != "" { 93 annotations, err = h.db.GetAnnotationsByMotivation(motivation, limit, offset) 94 + } else if tag != "" { 95 + annotations, err = h.db.GetAnnotationsByTag(tag, limit, offset) 96 } else { 97 annotations, err = h.db.GetRecentAnnotations(limit, offset) 98 } ··· 102 return 103 } 104 105 + enriched, _ := hydrateAnnotations(h.db, annotations, h.getViewerDID(r)) 106 107 w.Header().Set("Content-Type", "application/json") 108 json.NewEncoder(w).Encode(map[string]interface{}{ ··· 115 116 func (h *Handler) GetFeed(w http.ResponseWriter, r *http.Request) { 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 126 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 if creator != "" { 140 + annotations, _ = h.db.GetAnnotationsByAuthor(creator, limit, 0) 141 + highlights, _ = h.db.GetHighlightsByAuthor(creator, limit, 0) 142 + bookmarks, _ = h.db.GetBookmarksByAuthor(creator, limit, 0) 143 + collectionItems = []db.CollectionItem{} 144 + } else { 145 + annotations, _ = h.db.GetRecentAnnotations(limit, 0) 146 + highlights, _ = h.db.GetRecentHighlights(limit, 0) 147 + bookmarks, _ = h.db.GetRecentBookmarks(limit, 0) 148 + collectionItems, err = h.db.GetRecentCollectionItems(limit, 0) 149 + if err != nil { 150 + log.Printf("Error fetching collection items: %v\n", err) 151 + } 152 } 153 + 154 + viewerDID := h.getViewerDID(r) 155 + authAnnos, _ := hydrateAnnotations(h.db, annotations, viewerDID) 156 + authHighs, _ := hydrateHighlights(h.db, highlights, viewerDID) 157 + authBooks, _ := hydrateBookmarks(h.db, bookmarks, viewerDID) 158 + 159 + authCollectionItems, _ := hydrateCollectionItems(h.db, collectionItems, viewerDID) 160 161 var feed []interface{} 162 for _, a := range authAnnos { ··· 217 return 218 } 219 220 + serveResponse := func(data interface{}, context string) { 221 + w.Header().Set("Content-Type", "application/json") 222 + response := map[string]interface{}{ 223 + "@context": context, 224 + } 225 + jsonData, _ := json.Marshal(data) 226 + json.Unmarshal(jsonData, &response) 227 + json.NewEncoder(w).Encode(response) 228 + } 229 + 230 + if annotation, err := h.db.GetAnnotationByURI(uri); err == nil { 231 + if enriched, _ := hydrateAnnotations(h.db, []db.Annotation{*annotation}, h.getViewerDID(r)); len(enriched) > 0 { 232 + serveResponse(enriched[0], "http://www.w3.org/ns/anno.jsonld") 233 + return 234 + } 235 + } 236 + 237 + if highlight, err := h.db.GetHighlightByURI(uri); err == nil { 238 + if enriched, _ := hydrateHighlights(h.db, []db.Highlight{*highlight}, h.getViewerDID(r)); len(enriched) > 0 { 239 + serveResponse(enriched[0], "http://www.w3.org/ns/anno.jsonld") 240 + return 241 + } 242 + } 243 + 244 + if strings.Contains(uri, "at.margin.annotation") { 245 + highlightURI := strings.Replace(uri, "at.margin.annotation", "at.margin.highlight", 1) 246 + if highlight, err := h.db.GetHighlightByURI(highlightURI); err == nil { 247 + if enriched, _ := hydrateHighlights(h.db, []db.Highlight{*highlight}, h.getViewerDID(r)); len(enriched) > 0 { 248 + serveResponse(enriched[0], "http://www.w3.org/ns/anno.jsonld") 249 + return 250 + } 251 + } 252 } 253 254 + if bookmark, err := h.db.GetBookmarkByURI(uri); err == nil { 255 + if enriched, _ := hydrateBookmarks(h.db, []db.Bookmark{*bookmark}, h.getViewerDID(r)); len(enriched) > 0 { 256 + serveResponse(enriched[0], "http://www.w3.org/ns/anno.jsonld") 257 + return 258 + } 259 } 260 261 + if strings.Contains(uri, "at.margin.annotation") { 262 + bookmarkURI := strings.Replace(uri, "at.margin.annotation", "at.margin.bookmark", 1) 263 + if bookmark, err := h.db.GetBookmarkByURI(bookmarkURI); err == nil { 264 + if enriched, _ := hydrateBookmarks(h.db, []db.Bookmark{*bookmark}, h.getViewerDID(r)); len(enriched) > 0 { 265 + serveResponse(enriched[0], "http://www.w3.org/ns/anno.jsonld") 266 + return 267 + } 268 + } 269 } 270 + 271 + http.Error(w, "Annotation, Highlight, or Bookmark not found", http.StatusNotFound) 272 273 } 274 275 func (h *Handler) GetByTarget(w http.ResponseWriter, r *http.Request) { ··· 290 annotations, _ := h.db.GetAnnotationsByTargetHash(urlHash, limit, offset) 291 highlights, _ := h.db.GetHighlightsByTargetHash(urlHash, limit, offset) 292 293 + enrichedAnnotations, _ := hydrateAnnotations(h.db, annotations, h.getViewerDID(r)) 294 + enrichedHighlights, _ := hydrateHighlights(h.db, highlights, h.getViewerDID(r)) 295 296 w.Header().Set("Content-Type", "application/json") 297 json.NewEncoder(w).Encode(map[string]interface{}{ ··· 305 306 func (h *Handler) GetHighlights(w http.ResponseWriter, r *http.Request) { 307 did := r.URL.Query().Get("creator") 308 + tag := r.URL.Query().Get("tag") 309 limit := parseIntParam(r, "limit", 50) 310 offset := parseIntParam(r, "offset", 0) 311 312 + var highlights []db.Highlight 313 + var err error 314 + 315 + if did != "" { 316 + highlights, err = h.db.GetHighlightsByAuthor(did, limit, offset) 317 + } else if tag != "" { 318 + highlights, err = h.db.GetHighlightsByTag(tag, limit, offset) 319 + } else { 320 + highlights, err = h.db.GetRecentHighlights(limit, offset) 321 } 322 323 if err != nil { 324 http.Error(w, err.Error(), http.StatusInternalServerError) 325 return 326 } 327 328 + enriched, _ := hydrateHighlights(h.db, highlights, h.getViewerDID(r)) 329 330 w.Header().Set("Content-Type", "application/json") 331 json.NewEncoder(w).Encode(map[string]interface{}{ ··· 352 return 353 } 354 355 + enriched, _ := hydrateBookmarks(h.db, bookmarks, h.getViewerDID(r)) 356 357 w.Header().Set("Content-Type", "application/json") 358 json.NewEncoder(w).Encode(map[string]interface{}{ ··· 377 return 378 } 379 380 + enriched, _ := hydrateAnnotations(h.db, annotations, h.getViewerDID(r)) 381 382 w.Header().Set("Content-Type", "application/json") 383 json.NewEncoder(w).Encode(map[string]interface{}{ ··· 403 return 404 } 405 406 + enriched, _ := hydrateHighlights(h.db, highlights, h.getViewerDID(r)) 407 408 w.Header().Set("Content-Type", "application/json") 409 json.NewEncoder(w).Encode(map[string]interface{}{ ··· 429 return 430 } 431 432 + enriched, _ := hydrateBookmarks(h.db, bookmarks, h.getViewerDID(r)) 433 434 w.Header().Set("Content-Type", "application/json") 435 json.NewEncoder(w).Encode(map[string]interface{}{ ··· 583 return 584 } 585 586 + enriched, err := hydrateNotifications(h.db, notifications) 587 if err != nil { 588 log.Printf("Failed to hydrate notifications: %v\n", err) 589 } ··· 628 w.Header().Set("Content-Type", "application/json") 629 json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) 630 } 631 + func (h *Handler) getViewerDID(r *http.Request) string { 632 + cookie, err := r.Cookie("margin_session") 633 + if err != nil { 634 + return "" 635 + } 636 + did, _, _, _, _, err := h.db.GetSession(cookie.Value) 637 + if err != nil { 638 + return "" 639 + } 640 + return did 641 + }
+131 -50
backend/internal/api/hydration.go
··· 50 } 51 52 type APIAnnotation struct { 53 - ID string `json:"id"` 54 - CID string `json:"cid"` 55 - Type string `json:"type"` 56 - Motivation string `json:"motivation,omitempty"` 57 - Author Author `json:"creator"` 58 - Body *APIBody `json:"body,omitempty"` 59 - Target APITarget `json:"target"` 60 - Tags []string `json:"tags,omitempty"` 61 - Generator *APIGenerator `json:"generator,omitempty"` 62 - CreatedAt time.Time `json:"created"` 63 - IndexedAt time.Time `json:"indexed"` 64 } 65 66 type APIHighlight struct { 67 - ID string `json:"id"` 68 - Type string `json:"type"` 69 - Author Author `json:"creator"` 70 - Target APITarget `json:"target"` 71 - Color string `json:"color,omitempty"` 72 - Tags []string `json:"tags,omitempty"` 73 - CreatedAt time.Time `json:"created"` 74 - CID string `json:"cid,omitempty"` 75 } 76 77 type APIBookmark struct { 78 - ID string `json:"id"` 79 - Type string `json:"type"` 80 - Author Author `json:"creator"` 81 - Source string `json:"source"` 82 - Title string `json:"title,omitempty"` 83 - Description string `json:"description,omitempty"` 84 - Tags []string `json:"tags,omitempty"` 85 - CreatedAt time.Time `json:"created"` 86 - CID string `json:"cid,omitempty"` 87 } 88 89 type APIReply struct { ··· 99 } 100 101 type APICollection struct { 102 - URI string `json:"uri"` 103 - Name string `json:"name"` 104 - Icon string `json:"icon,omitempty"` 105 } 106 107 type APICollectionItem struct { ··· 118 } 119 120 type APINotification struct { 121 - ID int `json:"id"` 122 - Recipient Author `json:"recipient"` 123 - Actor Author `json:"actor"` 124 - Type string `json:"type"` 125 - SubjectURI string `json:"subjectUri"` 126 - CreatedAt time.Time `json:"createdAt"` 127 - ReadAt *time.Time `json:"readAt,omitempty"` 128 } 129 130 - func hydrateAnnotations(annotations []db.Annotation) ([]APIAnnotation, error) { 131 if len(annotations) == 0 { 132 return []APIAnnotation{}, nil 133 } ··· 192 CreatedAt: a.CreatedAt, 193 IndexedAt: a.IndexedAt, 194 } 195 } 196 197 return result, nil 198 } 199 200 - func hydrateHighlights(highlights []db.Highlight) ([]APIHighlight, error) { 201 if len(highlights) == 0 { 202 return []APIHighlight{}, nil 203 } ··· 245 Tags: tags, 246 CreatedAt: h.CreatedAt, 247 CID: cid, 248 } 249 } 250 251 return result, nil 252 } 253 254 - func hydrateBookmarks(bookmarks []db.Bookmark) ([]APIBookmark, error) { 255 if len(bookmarks) == 0 { 256 return []APIBookmark{}, nil 257 } ··· 290 Tags: tags, 291 CreatedAt: b.CreatedAt, 292 CID: cid, 293 } 294 } 295 ··· 434 return result, nil 435 } 436 437 - func hydrateCollectionItems(database *db.DB, items []db.CollectionItem) ([]APICollectionItem, error) { 438 if len(items) == 0 { 439 return []APICollectionItem{}, nil 440 } ··· 457 if coll.Icon != nil { 458 icon = *coll.Icon 459 } 460 apiItem.Collection = &APICollection{ 461 - URI: coll.URI, 462 - Name: coll.Name, 463 - Icon: icon, 464 } 465 } 466 467 if strings.Contains(item.AnnotationURI, "at.margin.annotation") { 468 if a, err := database.GetAnnotationByURI(item.AnnotationURI); err == nil { 469 - hydrated, _ := hydrateAnnotations([]db.Annotation{*a}) 470 if len(hydrated) > 0 { 471 apiItem.Annotation = &hydrated[0] 472 } 473 } 474 } else if strings.Contains(item.AnnotationURI, "at.margin.highlight") { 475 if h, err := database.GetHighlightByURI(item.AnnotationURI); err == nil { 476 - hydrated, _ := hydrateHighlights([]db.Highlight{*h}) 477 if len(hydrated) > 0 { 478 apiItem.Highlight = &hydrated[0] 479 } 480 } 481 } else if strings.Contains(item.AnnotationURI, "at.margin.bookmark") { 482 if b, err := database.GetBookmarkByURI(item.AnnotationURI); err == nil { 483 - hydrated, _ := hydrateBookmarks([]db.Bookmark{*b}) 484 if len(hydrated) > 0 { 485 apiItem.Bookmark = &hydrated[0] 486 } else { 487 log.Printf("Failed to hydrate bookmark %s: empty hydration result\n", item.AnnotationURI) 488 } 489 } else { 490 - log.Printf("GetBookmarkByURI failed for %s: %v\n", item.AnnotationURI, err) 491 } 492 } else { 493 log.Printf("Unknown item type for URI: %s\n", item.AnnotationURI) ··· 498 return result, nil 499 } 500 501 - func hydrateNotifications(notifications []db.Notification) ([]APINotification, error) { 502 if len(notifications) == 0 { 503 return []APINotification{}, nil 504 } ··· 518 519 profiles := fetchProfilesForDIDs(dids) 520 521 result := make([]APINotification, len(notifications)) 522 for i, n := range notifications { 523 result[i] = APINotification{ 524 ID: n.ID, 525 Recipient: profiles[n.RecipientDID], 526 Actor: profiles[n.ActorDID], 527 Type: n.Type, 528 SubjectURI: n.SubjectURI, 529 CreatedAt: n.CreatedAt, 530 ReadAt: n.ReadAt, 531 }
··· 50 } 51 52 type APIAnnotation struct { 53 + ID string `json:"id"` 54 + CID string `json:"cid"` 55 + Type string `json:"type"` 56 + Motivation string `json:"motivation,omitempty"` 57 + Author Author `json:"creator"` 58 + Body *APIBody `json:"body,omitempty"` 59 + Target APITarget `json:"target"` 60 + Tags []string `json:"tags,omitempty"` 61 + Generator *APIGenerator `json:"generator,omitempty"` 62 + CreatedAt time.Time `json:"created"` 63 + IndexedAt time.Time `json:"indexed"` 64 + LikeCount int `json:"likeCount"` 65 + ReplyCount int `json:"replyCount"` 66 + ViewerHasLiked bool `json:"viewerHasLiked"` 67 } 68 69 type APIHighlight struct { 70 + ID string `json:"id"` 71 + Type string `json:"type"` 72 + Author Author `json:"creator"` 73 + Target APITarget `json:"target"` 74 + Color string `json:"color,omitempty"` 75 + Tags []string `json:"tags,omitempty"` 76 + CreatedAt time.Time `json:"created"` 77 + CID string `json:"cid,omitempty"` 78 + LikeCount int `json:"likeCount"` 79 + ReplyCount int `json:"replyCount"` 80 + ViewerHasLiked bool `json:"viewerHasLiked"` 81 } 82 83 type APIBookmark struct { 84 + ID string `json:"id"` 85 + Type string `json:"type"` 86 + Author Author `json:"creator"` 87 + Source string `json:"source"` 88 + Title string `json:"title,omitempty"` 89 + Description string `json:"description,omitempty"` 90 + Tags []string `json:"tags,omitempty"` 91 + CreatedAt time.Time `json:"created"` 92 + CID string `json:"cid,omitempty"` 93 + LikeCount int `json:"likeCount"` 94 + ReplyCount int `json:"replyCount"` 95 + ViewerHasLiked bool `json:"viewerHasLiked"` 96 } 97 98 type APIReply struct { ··· 108 } 109 110 type APICollection struct { 111 + URI string `json:"uri"` 112 + Name string `json:"name"` 113 + Description string `json:"description,omitempty"` 114 + Icon string `json:"icon,omitempty"` 115 + Creator Author `json:"creator"` 116 + CreatedAt time.Time `json:"createdAt"` 117 + IndexedAt time.Time `json:"indexedAt"` 118 } 119 120 type APICollectionItem struct { ··· 131 } 132 133 type APINotification struct { 134 + ID int `json:"id"` 135 + Recipient Author `json:"recipient"` 136 + Actor Author `json:"actor"` 137 + Type string `json:"type"` 138 + SubjectURI string `json:"subjectUri"` 139 + Subject interface{} `json:"subject,omitempty"` 140 + CreatedAt time.Time `json:"createdAt"` 141 + ReadAt *time.Time `json:"readAt,omitempty"` 142 } 143 144 + func hydrateAnnotations(database *db.DB, annotations []db.Annotation, viewerDID string) ([]APIAnnotation, error) { 145 if len(annotations) == 0 { 146 return []APIAnnotation{}, nil 147 } ··· 206 CreatedAt: a.CreatedAt, 207 IndexedAt: a.IndexedAt, 208 } 209 + 210 + if database != nil { 211 + result[i].LikeCount, _ = database.GetLikeCount(a.URI) 212 + result[i].ReplyCount, _ = database.GetReplyCount(a.URI) 213 + if viewerDID != "" { 214 + if _, err := database.GetLikeByUserAndSubject(viewerDID, a.URI); err == nil { 215 + result[i].ViewerHasLiked = true 216 + } 217 + } 218 + } 219 } 220 221 return result, nil 222 } 223 224 + func hydrateHighlights(database *db.DB, highlights []db.Highlight, viewerDID string) ([]APIHighlight, error) { 225 if len(highlights) == 0 { 226 return []APIHighlight{}, nil 227 } ··· 269 Tags: tags, 270 CreatedAt: h.CreatedAt, 271 CID: cid, 272 + } 273 + 274 + if database != nil { 275 + result[i].LikeCount, _ = database.GetLikeCount(h.URI) 276 + result[i].ReplyCount, _ = database.GetReplyCount(h.URI) 277 + if viewerDID != "" { 278 + if _, err := database.GetLikeByUserAndSubject(viewerDID, h.URI); err == nil { 279 + result[i].ViewerHasLiked = true 280 + } 281 + } 282 } 283 } 284 285 return result, nil 286 } 287 288 + func hydrateBookmarks(database *db.DB, bookmarks []db.Bookmark, viewerDID string) ([]APIBookmark, error) { 289 if len(bookmarks) == 0 { 290 return []APIBookmark{}, nil 291 } ··· 324 Tags: tags, 325 CreatedAt: b.CreatedAt, 326 CID: cid, 327 + } 328 + if database != nil { 329 + result[i].LikeCount, _ = database.GetLikeCount(b.URI) 330 + result[i].ReplyCount, _ = database.GetReplyCount(b.URI) 331 + if viewerDID != "" { 332 + if _, err := database.GetLikeByUserAndSubject(viewerDID, b.URI); err == nil { 333 + result[i].ViewerHasLiked = true 334 + } 335 + } 336 } 337 } 338 ··· 477 return result, nil 478 } 479 480 + func hydrateCollectionItems(database *db.DB, items []db.CollectionItem, viewerDID string) ([]APICollectionItem, error) { 481 if len(items) == 0 { 482 return []APICollectionItem{}, nil 483 } ··· 500 if coll.Icon != nil { 501 icon = *coll.Icon 502 } 503 + desc := "" 504 + if coll.Description != nil { 505 + desc = *coll.Description 506 + } 507 apiItem.Collection = &APICollection{ 508 + URI: coll.URI, 509 + Name: coll.Name, 510 + Description: desc, 511 + Icon: icon, 512 + Creator: profiles[coll.AuthorDID], 513 + CreatedAt: coll.CreatedAt, 514 + IndexedAt: coll.IndexedAt, 515 } 516 } 517 518 if strings.Contains(item.AnnotationURI, "at.margin.annotation") { 519 if a, err := database.GetAnnotationByURI(item.AnnotationURI); err == nil { 520 + hydrated, _ := hydrateAnnotations(database, []db.Annotation{*a}, viewerDID) 521 if len(hydrated) > 0 { 522 apiItem.Annotation = &hydrated[0] 523 } 524 } 525 } else if strings.Contains(item.AnnotationURI, "at.margin.highlight") { 526 if h, err := database.GetHighlightByURI(item.AnnotationURI); err == nil { 527 + hydrated, _ := hydrateHighlights(database, []db.Highlight{*h}, viewerDID) 528 if len(hydrated) > 0 { 529 apiItem.Highlight = &hydrated[0] 530 } 531 } 532 } else if strings.Contains(item.AnnotationURI, "at.margin.bookmark") { 533 if b, err := database.GetBookmarkByURI(item.AnnotationURI); err == nil { 534 + hydrated, _ := hydrateBookmarks(database, []db.Bookmark{*b}, viewerDID) 535 if len(hydrated) > 0 { 536 apiItem.Bookmark = &hydrated[0] 537 } else { 538 log.Printf("Failed to hydrate bookmark %s: empty hydration result\n", item.AnnotationURI) 539 } 540 } else { 541 } 542 } else { 543 log.Printf("Unknown item type for URI: %s\n", item.AnnotationURI) ··· 548 return result, nil 549 } 550 551 + func hydrateNotifications(database *db.DB, notifications []db.Notification) ([]APINotification, error) { 552 if len(notifications) == 0 { 553 return []APINotification{}, nil 554 } ··· 568 569 profiles := fetchProfilesForDIDs(dids) 570 571 + replyURIs := make([]string, 0) 572 + for _, n := range notifications { 573 + if n.Type == "reply" { 574 + replyURIs = append(replyURIs, n.SubjectURI) 575 + } 576 + } 577 + 578 + replyMap := make(map[string]APIReply) 579 + if len(replyURIs) > 0 { 580 + var replies []db.Reply 581 + for _, uri := range replyURIs { 582 + r, err := database.GetReplyByURI(uri) 583 + if err == nil { 584 + replies = append(replies, *r) 585 + } 586 + } 587 + 588 + hydratedReplies, _ := hydrateReplies(replies) 589 + for _, r := range hydratedReplies { 590 + replyMap[r.ID] = r 591 + } 592 + } 593 + 594 result := make([]APINotification, len(notifications)) 595 for i, n := range notifications { 596 + var subject interface{} 597 + if n.Type == "reply" { 598 + if val, ok := replyMap[n.SubjectURI]; ok { 599 + subject = val 600 + } 601 + } 602 + 603 result[i] = APINotification{ 604 ID: n.ID, 605 Recipient: profiles[n.RecipientDID], 606 Actor: profiles[n.ActorDID], 607 Type: n.Type, 608 SubjectURI: n.SubjectURI, 609 + Subject: subject, 610 CreatedAt: n.CreatedAt, 611 ReadAt: n.ReadAt, 612 }
+691 -60
backend/internal/api/og.go
··· 15 "net/http" 16 "net/url" 17 "os" 18 - "regexp" 19 "strings" 20 21 "golang.org/x/image/font" ··· 101 "Bluesky", 102 } 103 104 func isCrawler(userAgent string) bool { 105 ua := strings.ToLower(userAgent) 106 for _, bot := range crawlerUserAgents { ··· 111 return false 112 } 113 114 func (h *OGHandler) HandleAnnotationPage(w http.ResponseWriter, r *http.Request) { 115 path := r.URL.Path 116 117 - var annotationMatch = regexp.MustCompile(`^/at/([^/]+)/([^/]+)$`) 118 - matches := annotationMatch.FindStringSubmatch(path) 119 120 - if len(matches) != 3 { 121 h.serveIndexHTML(w, r) 122 return 123 } 124 125 - did, _ := url.QueryUnescape(matches[1]) 126 - rkey := matches[2] 127 - 128 if !isCrawler(r.UserAgent()) { 129 h.serveIndexHTML(w, r) 130 return 131 } 132 133 - uri := fmt.Sprintf("at://%s/at.margin.annotation/%s", did, rkey) 134 - annotation, err := h.db.GetAnnotationByURI(uri) 135 - if err == nil && annotation != nil { 136 - h.serveAnnotationOG(w, annotation) 137 - return 138 } 139 140 - bookmarkURI := fmt.Sprintf("at://%s/at.margin.bookmark/%s", did, rkey) 141 - bookmark, err := h.db.GetBookmarkByURI(bookmarkURI) 142 - if err == nil && bookmark != nil { 143 - h.serveBookmarkOG(w, bookmark) 144 return 145 } 146 147 h.serveIndexHTML(w, r) ··· 232 w.Write([]byte(htmlContent)) 233 } 234 235 func (h *OGHandler) serveAnnotationOG(w http.ResponseWriter, annotation *db.Annotation) { 236 title := "Annotation on Margin" 237 description := "" ··· 417 } 418 } 419 } else { 420 - http.Error(w, "Record not found", http.StatusNotFound) 421 - return 422 } 423 } 424 ··· 432 func generateOGImagePNG(author, text, quote, source, avatarURL string) image.Image { 433 width := 1200 434 height := 630 435 - padding := 120 436 437 bgPrimary := color.RGBA{12, 10, 20, 255} 438 accent := color.RGBA{168, 85, 247, 255} 439 textPrimary := color.RGBA{244, 240, 255, 255} 440 textSecondary := color.RGBA{168, 158, 200, 255} 441 - textTertiary := color.RGBA{107, 95, 138, 255} 442 border := color.RGBA{45, 38, 64, 255} 443 444 img := image.NewRGBA(image.Rect(0, 0, width, height)) 445 446 draw.Draw(img, img.Bounds(), &image.Uniform{bgPrimary}, image.Point{}, draw.Src) 447 - draw.Draw(img, image.Rect(0, 0, width, 6), &image.Uniform{accent}, image.Point{}, draw.Src) 448 - 449 - if logoImage != nil { 450 - logoHeight := 50 451 - logoWidth := int(float64(logoImage.Bounds().Dx()) * (float64(logoHeight) / float64(logoImage.Bounds().Dy()))) 452 - drawScaledImage(img, logoImage, padding, 80, logoWidth, logoHeight) 453 - } else { 454 - drawText(img, "Margin", padding, 120, accent, 36, true) 455 - } 456 457 - avatarSize := 80 458 avatarX := padding 459 - avatarY := 180 460 avatarImg := fetchAvatarImage(avatarURL) 461 if avatarImg != nil { 462 drawCircularAvatar(img, avatarImg, avatarX, avatarY, avatarSize) 463 } else { 464 drawDefaultAvatar(img, author, avatarX, avatarY, avatarSize, accent) 465 } 466 - 467 - handleX := avatarX + avatarSize + 24 468 - drawText(img, author, handleX, avatarY+50, textSecondary, 24, false) 469 - 470 - yPos := 280 471 - draw.Draw(img, image.Rect(padding, yPos, width-padding, yPos+1), &image.Uniform{border}, image.Point{}, draw.Src) 472 - yPos += 40 473 474 contentWidth := width - (padding * 2) 475 476 - if quote != "" { 477 - if len(quote) > 100 { 478 - quote = quote[:97] + "..." 479 - } 480 481 - lines := wrapTextToWidth(quote, contentWidth-30, 24) 482 - numLines := min(len(lines), 2) 483 - barHeight := numLines*32 + 10 484 485 - draw.Draw(img, image.Rect(padding, yPos, padding+6, yPos+barHeight), &image.Uniform{accent}, image.Point{}, draw.Src) 486 487 - for i, line := range lines { 488 - if i >= 2 { 489 - break 490 } 491 - drawText(img, "\""+line+"\"", padding+24, yPos+28+(i*32), textTertiary, 24, true) 492 } 493 - yPos += 30 + (numLines * 32) + 30 494 } 495 496 - if text != "" { 497 - if len(text) > 300 { 498 - text = text[:297] + "..." 499 } 500 - lines := wrapTextToWidth(text, contentWidth, 32) 501 - for i, line := range lines { 502 - if i >= 6 { 503 - break 504 } 505 - drawText(img, line, padding, yPos+(i*42), textPrimary, 32, false) 506 } 507 } 508 509 - drawText(img, source, padding, 580, textTertiary, 20, false) 510 511 return img 512 } ··· 662 } 663 return lines 664 }
··· 15 "net/http" 16 "net/url" 17 "os" 18 "strings" 19 20 "golang.org/x/image/font" ··· 100 "Bluesky", 101 } 102 103 + var lucideToEmoji = map[string]string{ 104 + "folder": "๐Ÿ“", 105 + "star": "โญ", 106 + "heart": "โค๏ธ", 107 + "bookmark": "๐Ÿ”–", 108 + "lightbulb": "๐Ÿ’ก", 109 + "zap": "โšก", 110 + "coffee": "โ˜•", 111 + "music": "๐ŸŽต", 112 + "camera": "๐Ÿ“ท", 113 + "code": "๐Ÿ’ป", 114 + "globe": "๐ŸŒ", 115 + "flag": "๐Ÿšฉ", 116 + "tag": "๐Ÿท๏ธ", 117 + "box": "๐Ÿ“ฆ", 118 + "archive": "๐Ÿ—„๏ธ", 119 + "file": "๐Ÿ“„", 120 + "image": "๐Ÿ–ผ๏ธ", 121 + "video": "๐ŸŽฌ", 122 + "mail": "โœ‰๏ธ", 123 + "pin": "๐Ÿ“", 124 + "calendar": "๐Ÿ“…", 125 + "clock": "๐Ÿ•", 126 + "search": "๐Ÿ”", 127 + "settings": "โš™๏ธ", 128 + "user": "๐Ÿ‘ค", 129 + "users": "๐Ÿ‘ฅ", 130 + "home": "๐Ÿ ", 131 + "briefcase": "๐Ÿ’ผ", 132 + "gift": "๐ŸŽ", 133 + "award": "๐Ÿ†", 134 + "target": "๐ŸŽฏ", 135 + "trending": "๐Ÿ“ˆ", 136 + "activity": "๐Ÿ“Š", 137 + "cpu": "๐Ÿ”ฒ", 138 + "database": "๐Ÿ—ƒ๏ธ", 139 + "cloud": "โ˜๏ธ", 140 + "sun": "โ˜€๏ธ", 141 + "moon": "๐ŸŒ™", 142 + "flame": "๐Ÿ”ฅ", 143 + "leaf": "๐Ÿƒ", 144 + } 145 + 146 + func iconToEmoji(icon string) string { 147 + if strings.HasPrefix(icon, "icon:") { 148 + name := strings.TrimPrefix(icon, "icon:") 149 + if emoji, ok := lucideToEmoji[name]; ok { 150 + return emoji 151 + } 152 + return "๐Ÿ“" 153 + } 154 + return icon 155 + } 156 + 157 func isCrawler(userAgent string) bool { 158 ua := strings.ToLower(userAgent) 159 for _, bot := range crawlerUserAgents { ··· 164 return false 165 } 166 167 + func (h *OGHandler) resolveHandle(handle string) (string, error) { 168 + resp, err := http.Get(fmt.Sprintf("https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle?handle=%s", url.QueryEscape(handle))) 169 + if err == nil && resp.StatusCode == http.StatusOK { 170 + var result struct { 171 + Did string `json:"did"` 172 + } 173 + if err := json.NewDecoder(resp.Body).Decode(&result); err == nil && result.Did != "" { 174 + return result.Did, nil 175 + } 176 + } 177 + defer resp.Body.Close() 178 + 179 + return "", fmt.Errorf("failed to resolve handle") 180 + } 181 + 182 func (h *OGHandler) HandleAnnotationPage(w http.ResponseWriter, r *http.Request) { 183 path := r.URL.Path 184 + var did, rkey, collectionType string 185 186 + parts := strings.Split(strings.Trim(path, "/"), "/") 187 + if len(parts) >= 2 { 188 + firstPart, _ := url.QueryUnescape(parts[0]) 189 + 190 + if firstPart == "at" || firstPart == "annotation" { 191 + if len(parts) >= 3 { 192 + did, _ = url.QueryUnescape(parts[1]) 193 + rkey = parts[2] 194 + } 195 + } else { 196 + if len(parts) >= 3 { 197 + var err error 198 + did, err = h.resolveHandle(firstPart) 199 + if err != nil { 200 + h.serveIndexHTML(w, r) 201 + return 202 + } 203 204 + switch parts[1] { 205 + case "highlight": 206 + collectionType = "at.margin.highlight" 207 + case "bookmark": 208 + collectionType = "at.margin.bookmark" 209 + case "annotation": 210 + collectionType = "at.margin.annotation" 211 + } 212 + rkey = parts[2] 213 + } 214 + } 215 + } 216 + 217 + if did == "" || rkey == "" { 218 h.serveIndexHTML(w, r) 219 return 220 } 221 222 if !isCrawler(r.UserAgent()) { 223 h.serveIndexHTML(w, r) 224 return 225 } 226 227 + if collectionType != "" { 228 + uri := fmt.Sprintf("at://%s/%s/%s", did, collectionType, rkey) 229 + if h.tryServeType(w, uri, collectionType) { 230 + return 231 + } 232 + } else { 233 + types := []string{ 234 + "at.margin.annotation", 235 + "at.margin.bookmark", 236 + "at.margin.highlight", 237 + } 238 + for _, t := range types { 239 + uri := fmt.Sprintf("at://%s/%s/%s", did, t, rkey) 240 + if h.tryServeType(w, uri, t) { 241 + return 242 + } 243 + } 244 + 245 + colURI := fmt.Sprintf("at://%s/at.margin.collection/%s", did, rkey) 246 + if h.tryServeType(w, colURI, "at.margin.collection") { 247 + return 248 + } 249 } 250 251 + h.serveIndexHTML(w, r) 252 + } 253 + 254 + func (h *OGHandler) tryServeType(w http.ResponseWriter, uri, colType string) bool { 255 + switch colType { 256 + case "at.margin.annotation": 257 + if item, err := h.db.GetAnnotationByURI(uri); err == nil && item != nil { 258 + h.serveAnnotationOG(w, item) 259 + return true 260 + } 261 + case "at.margin.highlight": 262 + if item, err := h.db.GetHighlightByURI(uri); err == nil && item != nil { 263 + h.serveHighlightOG(w, item) 264 + return true 265 + } 266 + case "at.margin.bookmark": 267 + if item, err := h.db.GetBookmarkByURI(uri); err == nil && item != nil { 268 + h.serveBookmarkOG(w, item) 269 + return true 270 + } 271 + case "at.margin.collection": 272 + if item, err := h.db.GetCollectionByURI(uri); err == nil && item != nil { 273 + h.serveCollectionOG(w, item) 274 + return true 275 + } 276 + } 277 + return false 278 + } 279 + 280 + func (h *OGHandler) HandleCollectionPage(w http.ResponseWriter, r *http.Request) { 281 + path := r.URL.Path 282 + var did, rkey string 283 + 284 + if strings.Contains(path, "/collection/") { 285 + parts := strings.Split(strings.Trim(path, "/"), "/") 286 + if len(parts) == 3 && parts[1] == "collection" { 287 + handle, _ := url.QueryUnescape(parts[0]) 288 + rkey = parts[2] 289 + var err error 290 + did, err = h.resolveHandle(handle) 291 + if err != nil { 292 + h.serveIndexHTML(w, r) 293 + return 294 + } 295 + } else if strings.HasPrefix(path, "/collection/") { 296 + uriParam := strings.TrimPrefix(path, "/collection/") 297 + if uriParam != "" { 298 + uri, err := url.QueryUnescape(uriParam) 299 + if err == nil { 300 + parts := strings.Split(uri, "/") 301 + if len(parts) >= 3 && strings.HasPrefix(uri, "at://") { 302 + did = parts[2] 303 + rkey = parts[len(parts)-1] 304 + } 305 + } 306 + } 307 + } 308 + } 309 + 310 + if did == "" && rkey == "" { 311 + h.serveIndexHTML(w, r) 312 return 313 + } else if did != "" && rkey != "" { 314 + uri := fmt.Sprintf("at://%s/at.margin.collection/%s", did, rkey) 315 + 316 + if !isCrawler(r.UserAgent()) { 317 + h.serveIndexHTML(w, r) 318 + return 319 + } 320 + 321 + collection, err := h.db.GetCollectionByURI(uri) 322 + if err == nil && collection != nil { 323 + h.serveCollectionOG(w, collection) 324 + return 325 + } 326 } 327 328 h.serveIndexHTML(w, r) ··· 413 w.Write([]byte(htmlContent)) 414 } 415 416 + func (h *OGHandler) serveHighlightOG(w http.ResponseWriter, highlight *db.Highlight) { 417 + title := "Highlight on Margin" 418 + description := "" 419 + 420 + if highlight.SelectorJSON != nil && *highlight.SelectorJSON != "" { 421 + var selector struct { 422 + Exact string `json:"exact"` 423 + } 424 + if err := json.Unmarshal([]byte(*highlight.SelectorJSON), &selector); err == nil && selector.Exact != "" { 425 + description = fmt.Sprintf("\"%s\"", selector.Exact) 426 + if len(description) > 200 { 427 + description = description[:197] + "...\"" 428 + } 429 + } 430 + } 431 + 432 + if highlight.TargetTitle != nil && *highlight.TargetTitle != "" { 433 + title = fmt.Sprintf("Highlight on: %s", *highlight.TargetTitle) 434 + if len(title) > 60 { 435 + title = title[:57] + "..." 436 + } 437 + } 438 + 439 + sourceDomain := "" 440 + if highlight.TargetSource != "" { 441 + if parsed, err := url.Parse(highlight.TargetSource); err == nil { 442 + sourceDomain = parsed.Host 443 + } 444 + } 445 + 446 + authorHandle := highlight.AuthorDID 447 + profiles := fetchProfilesForDIDs([]string{highlight.AuthorDID}) 448 + if profile, ok := profiles[highlight.AuthorDID]; ok && profile.Handle != "" { 449 + authorHandle = "@" + profile.Handle 450 + } 451 + 452 + if description == "" { 453 + description = fmt.Sprintf("A highlight by %s", authorHandle) 454 + if sourceDomain != "" { 455 + description += fmt.Sprintf(" on %s", sourceDomain) 456 + } 457 + } 458 + 459 + pageURL := fmt.Sprintf("%s/at/%s", h.baseURL, url.PathEscape(highlight.URI[5:])) 460 + ogImageURL := fmt.Sprintf("%s/og-image?uri=%s", h.baseURL, url.QueryEscape(highlight.URI)) 461 + 462 + htmlContent := fmt.Sprintf(`<!DOCTYPE html> 463 + <html lang="en"> 464 + <head> 465 + <meta charset="UTF-8"> 466 + <meta name="viewport" content="width=device-width, initial-scale=1.0"> 467 + <title>%s - Margin</title> 468 + <meta name="description" content="%s"> 469 + 470 + <!-- Open Graph --> 471 + <meta property="og:type" content="article"> 472 + <meta property="og:title" content="%s"> 473 + <meta property="og:description" content="%s"> 474 + <meta property="og:url" content="%s"> 475 + <meta property="og:image" content="%s"> 476 + <meta property="og:image:width" content="1200"> 477 + <meta property="og:image:height" content="630"> 478 + <meta property="og:site_name" content="Margin"> 479 + 480 + <!-- Twitter Card --> 481 + <meta name="twitter:card" content="summary_large_image"> 482 + <meta name="twitter:title" content="%s"> 483 + <meta name="twitter:description" content="%s"> 484 + <meta name="twitter:image" content="%s"> 485 + 486 + <!-- Author --> 487 + <meta property="article:author" content="%s"> 488 + 489 + <meta http-equiv="refresh" content="0; url=%s"> 490 + </head> 491 + <body> 492 + <p>Redirecting to <a href="%s">%s</a>...</p> 493 + </body> 494 + </html>`, 495 + html.EscapeString(title), 496 + html.EscapeString(description), 497 + html.EscapeString(title), 498 + html.EscapeString(description), 499 + html.EscapeString(pageURL), 500 + html.EscapeString(ogImageURL), 501 + html.EscapeString(title), 502 + html.EscapeString(description), 503 + html.EscapeString(ogImageURL), 504 + html.EscapeString(authorHandle), 505 + html.EscapeString(pageURL), 506 + html.EscapeString(pageURL), 507 + html.EscapeString(title), 508 + ) 509 + 510 + w.Header().Set("Content-Type", "text/html; charset=utf-8") 511 + w.Write([]byte(htmlContent)) 512 + } 513 + 514 + func (h *OGHandler) serveCollectionOG(w http.ResponseWriter, collection *db.Collection) { 515 + icon := "๐Ÿ“" 516 + if collection.Icon != nil && *collection.Icon != "" { 517 + icon = iconToEmoji(*collection.Icon) 518 + } 519 + 520 + title := fmt.Sprintf("%s %s", icon, collection.Name) 521 + description := "" 522 + if collection.Description != nil && *collection.Description != "" { 523 + description = *collection.Description 524 + if len(description) > 200 { 525 + description = description[:197] + "..." 526 + } 527 + } 528 + 529 + authorHandle := collection.AuthorDID 530 + var avatarURL string 531 + profiles := fetchProfilesForDIDs([]string{collection.AuthorDID}) 532 + if profile, ok := profiles[collection.AuthorDID]; ok { 533 + if profile.Handle != "" { 534 + authorHandle = "@" + profile.Handle 535 + } 536 + if profile.Avatar != "" { 537 + avatarURL = profile.Avatar 538 + } 539 + } 540 + 541 + if description == "" { 542 + description = fmt.Sprintf("A collection by %s", authorHandle) 543 + } else { 544 + description = fmt.Sprintf("By %s โ€ข %s", authorHandle, description) 545 + } 546 + 547 + pageURL := fmt.Sprintf("%s/collection/%s", h.baseURL, url.PathEscape(collection.URI)) 548 + ogImageURL := fmt.Sprintf("%s/og-image?uri=%s", h.baseURL, url.QueryEscape(collection.URI)) 549 + 550 + _ = avatarURL 551 + 552 + htmlContent := fmt.Sprintf(`<!DOCTYPE html> 553 + <html lang="en"> 554 + <head> 555 + <meta charset="UTF-8"> 556 + <meta name="viewport" content="width=device-width, initial-scale=1.0"> 557 + <title>%s - Margin</title> 558 + <meta name="description" content="%s"> 559 + 560 + <!-- Open Graph --> 561 + <meta property="og:type" content="article"> 562 + <meta property="og:title" content="%s"> 563 + <meta property="og:description" content="%s"> 564 + <meta property="og:url" content="%s"> 565 + <meta property="og:image" content="%s"> 566 + <meta property="og:image:width" content="1200"> 567 + <meta property="og:image:height" content="630"> 568 + <meta property="og:site_name" content="Margin"> 569 + 570 + <!-- Twitter Card --> 571 + <meta name="twitter:card" content="summary_large_image"> 572 + <meta name="twitter:title" content="%s"> 573 + <meta name="twitter:description" content="%s"> 574 + <meta name="twitter:image" content="%s"> 575 + 576 + <!-- Author --> 577 + <meta property="article:author" content="%s"> 578 + 579 + <meta http-equiv="refresh" content="0; url=%s"> 580 + </head> 581 + <body> 582 + <p>Redirecting to <a href="%s">%s</a>...</p> 583 + </body> 584 + </html>`, 585 + html.EscapeString(title), 586 + html.EscapeString(description), 587 + html.EscapeString(title), 588 + html.EscapeString(description), 589 + html.EscapeString(pageURL), 590 + html.EscapeString(ogImageURL), 591 + html.EscapeString(title), 592 + html.EscapeString(description), 593 + html.EscapeString(ogImageURL), 594 + html.EscapeString(authorHandle), 595 + html.EscapeString(pageURL), 596 + html.EscapeString(pageURL), 597 + html.EscapeString(title), 598 + ) 599 + 600 + w.Header().Set("Content-Type", "text/html; charset=utf-8") 601 + w.Write([]byte(htmlContent)) 602 + } 603 + 604 func (h *OGHandler) serveAnnotationOG(w http.ResponseWriter, annotation *db.Annotation) { 605 title := "Annotation on Margin" 606 description := "" ··· 786 } 787 } 788 } else { 789 + highlight, err := h.db.GetHighlightByURI(uri) 790 + if err == nil && highlight != nil { 791 + authorHandle = highlight.AuthorDID 792 + profiles := fetchProfilesForDIDs([]string{highlight.AuthorDID}) 793 + if profile, ok := profiles[highlight.AuthorDID]; ok { 794 + if profile.Handle != "" { 795 + authorHandle = "@" + profile.Handle 796 + } 797 + if profile.Avatar != "" { 798 + avatarURL = profile.Avatar 799 + } 800 + } 801 + 802 + targetTitle := "" 803 + if highlight.TargetTitle != nil { 804 + targetTitle = *highlight.TargetTitle 805 + } 806 + 807 + if highlight.SelectorJSON != nil && *highlight.SelectorJSON != "" { 808 + var selector struct { 809 + Exact string `json:"exact"` 810 + } 811 + if err := json.Unmarshal([]byte(*highlight.SelectorJSON), &selector); err == nil && selector.Exact != "" { 812 + quote = selector.Exact 813 + } 814 + } 815 + 816 + if highlight.TargetSource != "" { 817 + if parsed, err := url.Parse(highlight.TargetSource); err == nil { 818 + sourceDomain = parsed.Host 819 + } 820 + } 821 + 822 + img := generateHighlightOGImagePNG(authorHandle, targetTitle, quote, sourceDomain, avatarURL) 823 + 824 + w.Header().Set("Content-Type", "image/png") 825 + w.Header().Set("Cache-Control", "public, max-age=86400") 826 + png.Encode(w, img) 827 + return 828 + } else { 829 + collection, err := h.db.GetCollectionByURI(uri) 830 + if err == nil && collection != nil { 831 + authorHandle = collection.AuthorDID 832 + profiles := fetchProfilesForDIDs([]string{collection.AuthorDID}) 833 + if profile, ok := profiles[collection.AuthorDID]; ok { 834 + if profile.Handle != "" { 835 + authorHandle = "@" + profile.Handle 836 + } 837 + if profile.Avatar != "" { 838 + avatarURL = profile.Avatar 839 + } 840 + } 841 + 842 + icon := "๐Ÿ“" 843 + if collection.Icon != nil && *collection.Icon != "" { 844 + icon = iconToEmoji(*collection.Icon) 845 + } 846 + 847 + description := "" 848 + if collection.Description != nil && *collection.Description != "" { 849 + description = *collection.Description 850 + } 851 + 852 + img := generateCollectionOGImagePNG(authorHandle, collection.Name, description, icon, avatarURL) 853 + 854 + w.Header().Set("Content-Type", "image/png") 855 + w.Header().Set("Cache-Control", "public, max-age=86400") 856 + png.Encode(w, img) 857 + return 858 + } else { 859 + http.Error(w, "Record not found", http.StatusNotFound) 860 + return 861 + } 862 + } 863 } 864 } 865 ··· 873 func generateOGImagePNG(author, text, quote, source, avatarURL string) image.Image { 874 width := 1200 875 height := 630 876 + padding := 100 877 878 bgPrimary := color.RGBA{12, 10, 20, 255} 879 accent := color.RGBA{168, 85, 247, 255} 880 textPrimary := color.RGBA{244, 240, 255, 255} 881 textSecondary := color.RGBA{168, 158, 200, 255} 882 border := color.RGBA{45, 38, 64, 255} 883 884 img := image.NewRGBA(image.Rect(0, 0, width, height)) 885 886 draw.Draw(img, img.Bounds(), &image.Uniform{bgPrimary}, image.Point{}, draw.Src) 887 + draw.Draw(img, image.Rect(0, 0, width, 12), &image.Uniform{accent}, image.Point{}, draw.Src) 888 889 + avatarSize := 64 890 avatarX := padding 891 + avatarY := padding 892 + 893 avatarImg := fetchAvatarImage(avatarURL) 894 if avatarImg != nil { 895 drawCircularAvatar(img, avatarImg, avatarX, avatarY, avatarSize) 896 } else { 897 drawDefaultAvatar(img, author, avatarX, avatarY, avatarSize, accent) 898 } 899 + drawText(img, author, avatarX+avatarSize+24, avatarY+42, textSecondary, 28, false) 900 901 contentWidth := width - (padding * 2) 902 + yPos := 220 903 904 + if text != "" { 905 + textLen := len(text) 906 + textSize := 32.0 907 + textLineHeight := 42 908 + maxTextLines := 5 909 910 + if textLen > 200 { 911 + textSize = 28.0 912 + textLineHeight = 36 913 + maxTextLines = 6 914 + } 915 916 + lines := wrapTextToWidth(text, contentWidth, int(textSize)) 917 + numLines := min(len(lines), maxTextLines) 918 919 + for i := 0; i < numLines; i++ { 920 + line := lines[i] 921 + if i == numLines-1 && len(lines) > numLines { 922 + line += "..." 923 } 924 + drawText(img, line, padding, yPos+(i*textLineHeight), textPrimary, textSize, false) 925 } 926 + yPos += (numLines * textLineHeight) + 40 927 } 928 929 + if quote != "" { 930 + quoteLen := len(quote) 931 + quoteSize := 24.0 932 + quoteLineHeight := 32 933 + maxQuoteLines := 3 934 + 935 + if quoteLen > 150 { 936 + quoteSize = 20.0 937 + quoteLineHeight = 28 938 + maxQuoteLines = 4 939 } 940 + 941 + lines := wrapTextToWidth(quote, contentWidth-30, int(quoteSize)) 942 + numLines := min(len(lines), maxQuoteLines) 943 + barHeight := numLines * quoteLineHeight 944 + 945 + draw.Draw(img, image.Rect(padding, yPos, padding+6, yPos+barHeight), &image.Uniform{accent}, image.Point{}, draw.Src) 946 + 947 + for i := 0; i < numLines; i++ { 948 + line := lines[i] 949 + if i == numLines-1 && len(lines) > numLines { 950 + line += "..." 951 } 952 + drawText(img, line, padding+24, yPos+24+(i*quoteLineHeight), textSecondary, quoteSize, true) 953 } 954 + yPos += barHeight + 40 955 } 956 957 + draw.Draw(img, image.Rect(padding, yPos, width-padding, yPos+1), &image.Uniform{border}, image.Point{}, draw.Src) 958 + yPos += 40 959 + drawText(img, source, padding, yPos+32, textSecondary, 24, false) 960 961 return img 962 } ··· 1112 } 1113 return lines 1114 } 1115 + 1116 + func generateCollectionOGImagePNG(author, collectionName, description, icon, avatarURL string) image.Image { 1117 + width := 1200 1118 + height := 630 1119 + padding := 120 1120 + 1121 + bgPrimary := color.RGBA{12, 10, 20, 255} 1122 + accent := color.RGBA{168, 85, 247, 255} 1123 + textPrimary := color.RGBA{244, 240, 255, 255} 1124 + textSecondary := color.RGBA{168, 158, 200, 255} 1125 + textTertiary := color.RGBA{107, 95, 138, 255} 1126 + border := color.RGBA{45, 38, 64, 255} 1127 + 1128 + img := image.NewRGBA(image.Rect(0, 0, width, height)) 1129 + 1130 + draw.Draw(img, img.Bounds(), &image.Uniform{bgPrimary}, image.Point{}, draw.Src) 1131 + draw.Draw(img, image.Rect(0, 0, width, 12), &image.Uniform{accent}, image.Point{}, draw.Src) 1132 + 1133 + iconY := 120 1134 + var iconWidth int 1135 + if icon != "" { 1136 + emojiImg := fetchTwemojiImage(icon) 1137 + if emojiImg != nil { 1138 + iconSize := 96 1139 + drawScaledImage(img, emojiImg, padding, iconY, iconSize, iconSize) 1140 + iconWidth = iconSize + 32 1141 + } else { 1142 + drawText(img, icon, padding, iconY+70, textPrimary, 80, true) 1143 + iconWidth = 100 1144 + } 1145 + } 1146 + 1147 + drawText(img, collectionName, padding+iconWidth, iconY+65, textPrimary, 64, true) 1148 + 1149 + yPos := 280 1150 + contentWidth := width - (padding * 2) 1151 + 1152 + if description != "" { 1153 + if len(description) > 200 { 1154 + description = description[:197] + "..." 1155 + } 1156 + lines := wrapTextToWidth(description, contentWidth, 32) 1157 + for i, line := range lines { 1158 + if i >= 4 { 1159 + break 1160 + } 1161 + drawText(img, line, padding, yPos+(i*42), textSecondary, 32, false) 1162 + } 1163 + } else { 1164 + drawText(img, "A collection on Margin", padding, yPos, textTertiary, 32, false) 1165 + } 1166 + 1167 + yPos = 480 1168 + draw.Draw(img, image.Rect(padding, yPos, width-padding, yPos+1), &image.Uniform{border}, image.Point{}, draw.Src) 1169 + 1170 + avatarSize := 64 1171 + avatarX := padding 1172 + avatarY := yPos + 40 1173 + 1174 + avatarImg := fetchAvatarImage(avatarURL) 1175 + if avatarImg != nil { 1176 + drawCircularAvatar(img, avatarImg, avatarX, avatarY, avatarSize) 1177 + } else { 1178 + drawDefaultAvatar(img, author, avatarX, avatarY, avatarSize, accent) 1179 + } 1180 + 1181 + handleX := avatarX + avatarSize + 24 1182 + drawText(img, author, handleX, avatarY+42, textTertiary, 28, false) 1183 + 1184 + return img 1185 + } 1186 + 1187 + func fetchTwemojiImage(emoji string) image.Image { 1188 + var codes []string 1189 + for _, r := range emoji { 1190 + codes = append(codes, fmt.Sprintf("%x", r)) 1191 + } 1192 + hexCode := strings.Join(codes, "-") 1193 + 1194 + url := fmt.Sprintf("https://cdnjs.cloudflare.com/ajax/libs/twemoji/14.0.2/72x72/%s.png", hexCode) 1195 + 1196 + resp, err := http.Get(url) 1197 + if err != nil || resp.StatusCode != 200 { 1198 + if strings.Contains(hexCode, "-fe0f") { 1199 + simpleHex := strings.ReplaceAll(hexCode, "-fe0f", "") 1200 + url = fmt.Sprintf("https://cdnjs.cloudflare.com/ajax/libs/twemoji/14.0.2/72x72/%s.png", simpleHex) 1201 + resp, err = http.Get(url) 1202 + if err != nil || resp.StatusCode != 200 { 1203 + return nil 1204 + } 1205 + } else { 1206 + return nil 1207 + } 1208 + } 1209 + defer resp.Body.Close() 1210 + 1211 + img, _, err := image.Decode(resp.Body) 1212 + if err != nil { 1213 + return nil 1214 + } 1215 + return img 1216 + } 1217 + 1218 + func generateHighlightOGImagePNG(author, pageTitle, quote, source, avatarURL string) image.Image { 1219 + width := 1200 1220 + height := 630 1221 + padding := 100 1222 + 1223 + bgPrimary := color.RGBA{12, 10, 20, 255} 1224 + accent := color.RGBA{250, 204, 21, 255} 1225 + textPrimary := color.RGBA{244, 240, 255, 255} 1226 + textSecondary := color.RGBA{168, 158, 200, 255} 1227 + border := color.RGBA{45, 38, 64, 255} 1228 + 1229 + img := image.NewRGBA(image.Rect(0, 0, width, height)) 1230 + 1231 + draw.Draw(img, img.Bounds(), &image.Uniform{bgPrimary}, image.Point{}, draw.Src) 1232 + draw.Draw(img, image.Rect(0, 0, width, 12), &image.Uniform{accent}, image.Point{}, draw.Src) 1233 + 1234 + avatarSize := 64 1235 + avatarX := padding 1236 + avatarY := padding 1237 + 1238 + avatarImg := fetchAvatarImage(avatarURL) 1239 + if avatarImg != nil { 1240 + drawCircularAvatar(img, avatarImg, avatarX, avatarY, avatarSize) 1241 + } else { 1242 + drawDefaultAvatar(img, author, avatarX, avatarY, avatarSize, accent) 1243 + } 1244 + drawText(img, author, avatarX+avatarSize+24, avatarY+42, textSecondary, 28, false) 1245 + 1246 + contentWidth := width - (padding * 2) 1247 + yPos := 220 1248 + if quote != "" { 1249 + quoteLen := len(quote) 1250 + fontSize := 42.0 1251 + lineHeight := 56 1252 + maxLines := 4 1253 + 1254 + if quoteLen > 200 { 1255 + fontSize = 32.0 1256 + lineHeight = 44 1257 + maxLines = 6 1258 + } else if quoteLen > 100 { 1259 + fontSize = 36.0 1260 + lineHeight = 48 1261 + maxLines = 5 1262 + } 1263 + 1264 + lines := wrapTextToWidth(quote, contentWidth-40, int(fontSize)) 1265 + numLines := min(len(lines), maxLines) 1266 + barHeight := numLines * lineHeight 1267 + 1268 + draw.Draw(img, image.Rect(padding, yPos, padding+8, yPos+barHeight), &image.Uniform{accent}, image.Point{}, draw.Src) 1269 + 1270 + for i := 0; i < numLines; i++ { 1271 + line := lines[i] 1272 + if i == numLines-1 && len(lines) > numLines { 1273 + line += "..." 1274 + } 1275 + drawText(img, line, padding+40, yPos+42+(i*lineHeight), textPrimary, fontSize, false) 1276 + } 1277 + yPos += barHeight + 40 1278 + } 1279 + 1280 + draw.Draw(img, image.Rect(padding, yPos, width-padding, yPos+1), &image.Uniform{border}, image.Point{}, draw.Src) 1281 + yPos += 40 1282 + 1283 + if pageTitle != "" { 1284 + if len(pageTitle) > 60 { 1285 + pageTitle = pageTitle[:57] + "..." 1286 + } 1287 + drawText(img, pageTitle, padding, yPos+32, textSecondary, 32, true) 1288 + } 1289 + 1290 + if source != "" { 1291 + drawText(img, source, padding, yPos+80, textSecondary, 24, false) 1292 + } 1293 + 1294 + return img 1295 + }
+140
backend/internal/db/queries.go
··· 104 return scanAnnotations(rows) 105 } 106 107 func (db *DB) DeleteAnnotation(uri string) error { 108 _, err := db.Exec(db.Rebind(`DELETE FROM annotations WHERE uri = ?`), uri) 109 return err ··· 242 return highlights, nil 243 } 244 245 func (db *DB) GetRecentBookmarks(limit, offset int) ([]Bookmark, error) { 246 rows, err := db.Query(db.Rebind(` 247 SELECT uri, author_did, source, source_hash, title, description, tags_json, created_at, indexed_at, cid ··· 265 return bookmarks, nil 266 } 267 268 func (db *DB) GetHighlightsByTargetHash(targetHash string, limit, offset int) ([]Highlight, error) { 269 rows, err := db.Query(db.Rebind(` 270 SELECT uri, author_did, target_source, target_hash, target_title, selector_json, color, tags_json, created_at, indexed_at, cid ··· 497 func (db *DB) GetLikeCount(subjectURI string) (int, error) { 498 var count int 499 err := db.QueryRow(db.Rebind(`SELECT COUNT(*) FROM likes WHERE subject_uri = ?`), subjectURI).Scan(&count) 500 return count, err 501 } 502
··· 104 return scanAnnotations(rows) 105 } 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 + 124 func (db *DB) DeleteAnnotation(uri string) error { 125 _, err := db.Exec(db.Rebind(`DELETE FROM annotations WHERE uri = ?`), uri) 126 return err ··· 259 return highlights, nil 260 } 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 + 287 func (db *DB) GetRecentBookmarks(limit, offset int) ([]Bookmark, error) { 288 rows, err := db.Query(db.Rebind(` 289 SELECT uri, author_did, source, source_hash, title, description, tags_json, created_at, indexed_at, cid ··· 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) 386 + if err != nil { 387 + return nil, err 388 + } 389 + defer rows.Close() 390 + 391 + var bookmarks []Bookmark 392 + for rows.Next() { 393 + var b Bookmark 394 + 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 { 395 + return nil, err 396 + } 397 + bookmarks = append(bookmarks, b) 398 + } 399 + return bookmarks, nil 400 + } 401 + 402 func (db *DB) GetHighlightsByTargetHash(targetHash string, limit, offset int) ([]Highlight, error) { 403 rows, err := db.Query(db.Rebind(` 404 SELECT uri, author_did, target_source, target_hash, target_title, selector_json, color, tags_json, created_at, indexed_at, cid ··· 631 func (db *DB) GetLikeCount(subjectURI string) (int, error) { 632 var count int 633 err := db.QueryRow(db.Rebind(`SELECT COUNT(*) FROM likes WHERE subject_uri = ?`), subjectURI).Scan(&count) 634 + return count, err 635 + } 636 + 637 + func (db *DB) GetReplyCount(rootURI string) (int, error) { 638 + var count int 639 + err := db.QueryRow(db.Rebind(`SELECT COUNT(*) FROM replies WHERE root_uri = ?`), rootURI).Scan(&count) 640 return count, err 641 } 642
+2 -2
backend/internal/oauth/client.go
··· 205 "jti": base64.RawURLEncoding.EncodeToString(jti), 206 "htm": method, 207 "htu": uri, 208 - "iat": now.Unix(), 209 "exp": now.Add(5 * time.Minute).Unix(), 210 } 211 if nonce != "" { ··· 243 Issuer: c.ClientID, 244 Subject: c.ClientID, 245 Audience: jwt.Audience{issuer}, 246 - IssuedAt: jwt.NewNumericDate(now), 247 Expiry: jwt.NewNumericDate(now.Add(5 * time.Minute)), 248 ID: base64.RawURLEncoding.EncodeToString(jti), 249 }
··· 205 "jti": base64.RawURLEncoding.EncodeToString(jti), 206 "htm": method, 207 "htu": uri, 208 + "iat": now.Add(-30 * time.Second).Unix(), 209 "exp": now.Add(5 * time.Minute).Unix(), 210 } 211 if nonce != "" { ··· 243 Issuer: c.ClientID, 244 Subject: c.ClientID, 245 Audience: jwt.Audience{issuer}, 246 + IssuedAt: jwt.NewNumericDate(now.Add(-30 * time.Second)), 247 Expiry: jwt.NewNumericDate(now.Add(5 * time.Minute)), 248 ID: base64.RawURLEncoding.EncodeToString(jti), 249 }
+1
backend/internal/oauth/handler.go
··· 244 245 parResp, state, dpopNonce, err := client.SendPAR(meta, req.Handle, scope, dpopKey, pkceChallenge) 246 if err != nil { 247 w.Header().Set("Content-Type", "application/json") 248 w.WriteHeader(http.StatusInternalServerError) 249 json.NewEncoder(w).Encode(map[string]string{"error": "Failed to initiate authentication"})
··· 244 245 parResp, state, dpopNonce, err := client.SendPAR(meta, req.Handle, scope, dpopKey, pkceChallenge) 246 if err != nil { 247 + log.Printf("PAR request failed: %v", err) 248 w.Header().Set("Content-Type", "application/json") 249 w.WriteHeader(http.StatusInternalServerError) 250 json.NewEncoder(w).Encode(map[string]string{"error": "Failed to initiate authentication"})
+2 -1
backend/internal/xrpc/records.go
··· 78 CreatedAt string `json:"createdAt"` 79 } 80 81 - func NewHighlightRecord(url, urlHash string, selector interface{}, color string) *HighlightRecord { 82 return &HighlightRecord{ 83 Type: CollectionHighlight, 84 Target: AnnotationTarget{ ··· 87 Selector: selector, 88 }, 89 Color: color, 90 CreatedAt: time.Now().UTC().Format(time.RFC3339), 91 } 92 }
··· 78 CreatedAt string `json:"createdAt"` 79 } 80 81 + func NewHighlightRecord(url, urlHash string, selector interface{}, color string, tags []string) *HighlightRecord { 82 return &HighlightRecord{ 83 Type: CollectionHighlight, 84 Target: AnnotationTarget{ ··· 87 Selector: selector, 88 }, 89 Color: color, 90 + Tags: tags, 91 CreatedAt: time.Now().UTC().Format(time.RFC3339), 92 } 93 }
+18
web/src/App.jsx
··· 34 <Route path="/annotation/:uri" element={<AnnotationDetail />} /> 35 <Route path="/collections" element={<Collections />} /> 36 <Route path="/collections/:rkey" element={<CollectionDetail />} /> 37 <Route path="/collection/*" element={<CollectionDetail />} /> 38 <Route path="/privacy" element={<Privacy />} /> 39 </Routes>
··· 34 <Route path="/annotation/:uri" element={<AnnotationDetail />} /> 35 <Route path="/collections" element={<Collections />} /> 36 <Route path="/collections/:rkey" element={<CollectionDetail />} /> 37 + <Route 38 + path="/:handle/collection/:rkey" 39 + element={<CollectionDetail />} 40 + /> 41 + 42 + <Route 43 + path="/:handle/annotation/:rkey" 44 + element={<AnnotationDetail />} 45 + /> 46 + <Route 47 + path="/:handle/highlight/:rkey" 48 + element={<AnnotationDetail />} 49 + /> 50 + <Route 51 + path="/:handle/bookmark/:rkey" 52 + element={<AnnotationDetail />} 53 + /> 54 + 55 <Route path="/collection/*" element={<CollectionDetail />} /> 56 <Route path="/privacy" element={<Privacy />} /> 57 </Routes>
+83 -33
web/src/api/client.js
··· 23 return request(`${API_BASE}/url-metadata?url=${encodeURIComponent(url)}`); 24 } 25 26 - export async function getAnnotationFeed(limit = 50, offset = 0) { 27 - return request( 28 - `${API_BASE}/annotations/feed?limit=${limit}&offset=${offset}`, 29 - ); 30 } 31 32 export async function getAnnotations({ ··· 210 }); 211 } 212 213 - export async function createAnnotation({ url, text, quote, title, selector }) { 214 return request(`${API_BASE}/annotations`, { 215 method: "POST", 216 - body: JSON.stringify({ url, text, quote, title, selector }), 217 }); 218 } 219 ··· 283 284 if (item.type === "Annotation") { 285 return { 286 - uri: item.id, 287 - author: item.creator, 288 - url: item.target?.source, 289 - title: item.target?.title, 290 - text: item.body?.value, 291 - selector: item.target?.selector, 292 motivation: item.motivation, 293 tags: item.tags || [], 294 - createdAt: item.created, 295 cid: item.cid || item.CID, 296 }; 297 } 298 299 if (item.type === "Bookmark") { 300 return { 301 - uri: item.id, 302 - author: item.creator, 303 - url: item.source, 304 title: item.title, 305 description: item.description, 306 tags: item.tags || [], 307 - createdAt: item.created, 308 cid: item.cid || item.CID, 309 }; 310 } 311 312 if (item.type === "Highlight") { 313 return { 314 - uri: item.id, 315 - author: item.creator, 316 - url: item.target?.source, 317 - title: item.target?.title, 318 - selector: item.target?.selector, 319 color: item.color, 320 tags: item.tags || [], 321 - createdAt: item.created, 322 cid: item.cid || item.CID, 323 }; 324 } 325 ··· 335 tags: item.tags || [], 336 createdAt: item.createdAt || item.created, 337 cid: item.cid || item.CID, 338 }; 339 } 340 341 export function normalizeHighlight(highlight) { 342 return { 343 - uri: highlight.id, 344 - author: highlight.creator, 345 - url: highlight.target?.source, 346 - title: highlight.target?.title, 347 - selector: highlight.target?.selector, 348 color: highlight.color, 349 tags: highlight.tags || [], 350 - createdAt: highlight.created, 351 }; 352 } 353 354 export function normalizeBookmark(bookmark) { 355 return { 356 - uri: bookmark.id, 357 - author: bookmark.creator, 358 - url: bookmark.source, 359 title: bookmark.title, 360 description: bookmark.description, 361 tags: bookmark.tags || [], 362 - createdAt: bookmark.created, 363 }; 364 } 365 ··· 369 ); 370 if (!res.ok) throw new Error("Search failed"); 371 return res.json(); 372 } 373 374 export async function startLogin(handle, inviteCode) {
··· 23 return request(`${API_BASE}/url-metadata?url=${encodeURIComponent(url)}`); 24 } 25 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); 36 } 37 38 export async function getAnnotations({ ··· 216 }); 217 } 218 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 + }) { 234 return request(`${API_BASE}/annotations`, { 235 method: "POST", 236 + body: JSON.stringify({ url, text, quote, title, selector, tags }), 237 }); 238 } 239 ··· 303 304 if (item.type === "Annotation") { 305 return { 306 + type: item.type, 307 + uri: item.uri || item.id, 308 + author: item.author || item.creator, 309 + url: item.url || item.target?.source, 310 + title: item.title || item.target?.title, 311 + text: item.text || item.body?.value, 312 + selector: item.selector || item.target?.selector, 313 motivation: item.motivation, 314 tags: item.tags || [], 315 + createdAt: item.createdAt || item.created, 316 cid: item.cid || item.CID, 317 + likeCount: item.likeCount || 0, 318 + replyCount: item.replyCount || 0, 319 + viewerHasLiked: item.viewerHasLiked || false, 320 }; 321 } 322 323 if (item.type === "Bookmark") { 324 return { 325 + type: item.type, 326 + uri: item.uri || item.id, 327 + author: item.author || item.creator, 328 + url: item.url || item.source, 329 title: item.title, 330 description: item.description, 331 tags: item.tags || [], 332 + createdAt: item.createdAt || item.created, 333 cid: item.cid || item.CID, 334 + likeCount: item.likeCount || 0, 335 + replyCount: item.replyCount || 0, 336 + viewerHasLiked: item.viewerHasLiked || false, 337 }; 338 } 339 340 if (item.type === "Highlight") { 341 return { 342 + type: item.type, 343 + uri: item.uri || item.id, 344 + author: item.author || item.creator, 345 + url: item.url || item.target?.source, 346 + title: item.title || item.target?.title, 347 + selector: item.selector || item.target?.selector, 348 color: item.color, 349 tags: item.tags || [], 350 + createdAt: item.createdAt || item.created, 351 cid: item.cid || item.CID, 352 + likeCount: item.likeCount || 0, 353 + replyCount: item.replyCount || 0, 354 + viewerHasLiked: item.viewerHasLiked || false, 355 }; 356 } 357 ··· 367 tags: item.tags || [], 368 createdAt: item.createdAt || item.created, 369 cid: item.cid || item.CID, 370 + likeCount: item.likeCount || 0, 371 + replyCount: item.replyCount || 0, 372 + viewerHasLiked: item.viewerHasLiked || false, 373 }; 374 } 375 376 export function normalizeHighlight(highlight) { 377 return { 378 + uri: highlight.uri || highlight.id, 379 + author: highlight.author || highlight.creator, 380 + url: highlight.url || highlight.target?.source, 381 + title: highlight.title || highlight.target?.title, 382 + selector: highlight.selector || highlight.target?.selector, 383 color: highlight.color, 384 tags: highlight.tags || [], 385 + createdAt: highlight.createdAt || highlight.created, 386 + likeCount: highlight.likeCount || 0, 387 + replyCount: highlight.replyCount || 0, 388 + viewerHasLiked: highlight.viewerHasLiked || false, 389 }; 390 } 391 392 export function normalizeBookmark(bookmark) { 393 return { 394 + uri: bookmark.uri || bookmark.id, 395 + author: bookmark.author || bookmark.creator, 396 + url: bookmark.url || bookmark.source, 397 title: bookmark.title, 398 description: bookmark.description, 399 tags: bookmark.tags || [], 400 + createdAt: bookmark.createdAt || bookmark.created, 401 + likeCount: bookmark.likeCount || 0, 402 + replyCount: bookmark.replyCount || 0, 403 + viewerHasLiked: bookmark.viewerHasLiked || false, 404 }; 405 } 406 ··· 410 ); 411 if (!res.ok) throw new Error("Search failed"); 412 return res.json(); 413 + } 414 + 415 + export async function resolveHandle(handle) { 416 + const res = await fetch( 417 + `https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle?handle=${encodeURIComponent(handle)}`, 418 + ); 419 + if (!res.ok) throw new Error("Failed to resolve handle"); 420 + const data = await res.json(); 421 + return data.did; 422 } 423 424 export async function startLogin(handle, inviteCode) {
+6 -2
web/src/components/AddToCollectionModal.jsx
··· 23 24 useEffect(() => { 25 if (isOpen && user) { 26 loadCollections(); 27 setError(null); 28 } 29 - }, [isOpen, user]); 30 31 const loadCollections = async () => { 32 try { ··· 71 className="modal-container" 72 style={{ 73 maxWidth: "380px", 74 - maxHeight: "80vh", 75 display: "flex", 76 flexDirection: "column", 77 }}
··· 23 24 useEffect(() => { 25 if (isOpen && user) { 26 + if (!annotationUri) { 27 + setLoading(false); 28 + return; 29 + } 30 loadCollections(); 31 setError(null); 32 } 33 + }, [isOpen, user, annotationUri]); 34 35 const loadCollections = async () => { 36 try { ··· 75 className="modal-container" 76 style={{ 77 maxWidth: "380px", 78 + maxHeight: "80dvh", 79 display: "flex", 80 flexDirection: "column", 81 }}
+401 -307
web/src/components/AnnotationCard.jsx
··· 5 import { 6 normalizeAnnotation, 7 normalizeHighlight, 8 deleteAnnotation, 9 likeAnnotation, 10 unlikeAnnotation, ··· 26 BookmarkIcon, 27 } from "./Icons"; 28 import { Folder, Edit2, Save, X, Clock } from "lucide-react"; 29 - import AddToCollectionModal from "./AddToCollectionModal"; 30 import ShareMenu from "./ShareMenu"; 31 32 function buildTextFragmentUrl(baseUrl, selector) { ··· 59 } 60 }; 61 62 - export default function AnnotationCard({ annotation, onDelete }) { 63 const { user, login } = useAuth(); 64 const data = normalizeAnnotation(annotation); 65 66 - const [likeCount, setLikeCount] = useState(0); 67 - const [isLiked, setIsLiked] = useState(false); 68 const [deleting, setDeleting] = useState(false); 69 - const [showAddToCollection, setShowAddToCollection] = useState(false); 70 const [isEditing, setIsEditing] = useState(false); 71 const [editText, setEditText] = useState(data.text || ""); 72 const [saving, setSaving] = useState(false); 73 74 const [showHistory, setShowHistory] = useState(false); ··· 76 const [loadingHistory, setLoadingHistory] = useState(false); 77 78 const [replies, setReplies] = useState([]); 79 - const [replyCount, setReplyCount] = useState(0); 80 const [showReplies, setShowReplies] = useState(false); 81 const [replyingTo, setReplyingTo] = useState(null); 82 const [replyText, setReplyText] = useState(""); ··· 86 87 const [hasEditHistory, setHasEditHistory] = useState(false); 88 89 - useEffect(() => { 90 - let mounted = true; 91 - async function fetchData() { 92 - try { 93 - const repliesRes = await getReplies(data.uri); 94 - if (mounted && repliesRes.items) { 95 - setReplies(repliesRes.items); 96 - setReplyCount(repliesRes.items.length); 97 - } 98 - 99 - const likeRes = await getLikeCount(data.uri); 100 - if (mounted) { 101 - if (likeRes.count !== undefined) { 102 - setLikeCount(likeRes.count); 103 - } 104 - if (likeRes.liked !== undefined) { 105 - setIsLiked(likeRes.liked); 106 - } 107 - } 108 - 109 - if (!data.color && !data.description) { 110 - try { 111 - const history = await getEditHistory(data.uri); 112 - if (mounted && history && history.length > 0) { 113 - setHasEditHistory(true); 114 - } 115 - } catch {} 116 - } 117 - } catch (err) { 118 - console.error("Failed to fetch data:", err); 119 - } 120 - } 121 - if (data.uri) { 122 - fetchData(); 123 - } 124 - return () => { 125 - mounted = false; 126 - }; 127 - }, [data.uri]); 128 129 const fetchHistory = async () => { 130 if (showHistory) { ··· 181 const handleSaveEdit = async () => { 182 try { 183 setSaving(true); 184 - await updateAnnotation(data.uri, editText, data.tags); 185 setIsEditing(false); 186 if (annotation.body) annotation.body.value = editText; 187 else if (annotation.text) annotation.text = editText; 188 } catch (err) { 189 alert("Failed to update: " + err.message); 190 } finally { ··· 287 return ( 288 <article className="card annotation-card"> 289 <header className="annotation-header"> 290 - <Link to={marginProfileUrl || "#"} className="annotation-avatar-link"> 291 - <div className="annotation-avatar"> 292 - {authorAvatar ? ( 293 - <img src={authorAvatar} alt={authorDisplayName} /> 294 - ) : ( 295 - <span> 296 - {(authorDisplayName || authorHandle || "??") 297 - ?.substring(0, 2) 298 - .toUpperCase()} 299 - </span> 300 - )} 301 - </div> 302 - </Link> 303 - <div className="annotation-meta"> 304 - <div className="annotation-author-row"> 305 - <Link 306 - to={marginProfileUrl || "#"} 307 - className="annotation-author-link" 308 - > 309 - <span className="annotation-author">{authorDisplayName}</span> 310 - </Link> 311 - {authorHandle && ( 312 - <a 313 - href={`https://bsky.app/profile/${authorHandle}`} 314 - target="_blank" 315 - rel="noopener noreferrer" 316 - className="annotation-handle" 317 > 318 - @{authorHandle} <ExternalLinkIcon size={12} /> 319 - </a> 320 - )} 321 - </div> 322 - <div className="annotation-time">{formatDate(data.createdAt)}</div> 323 - </div> 324 - <div className="action-buttons"> 325 - {} 326 - {hasEditHistory && !data.color && !data.description && ( 327 - <button 328 - className="annotation-edit-btn" 329 - onClick={fetchHistory} 330 - title="View Edit History" 331 - > 332 - <Clock size={16} /> 333 - </button> 334 - )} 335 - {} 336 - {isOwner && ( 337 - <> 338 - {!data.color && !data.description && ( 339 - <button 340 - className="annotation-edit-btn" 341 - onClick={() => setIsEditing(!isEditing)} 342 - title="Edit" 343 > 344 - <Edit2 size={16} /> 345 - </button> 346 )} 347 <button 348 - className="annotation-delete" 349 - onClick={handleDelete} 350 - disabled={deleting} 351 - title="Delete" 352 > 353 - <TrashIcon size={16} /> 354 </button> 355 - </> 356 - )} 357 </div> 358 </header> 359 360 - {} 361 - {} 362 {showHistory && ( 363 <div className="history-panel"> 364 <div className="history-header"> ··· 390 </div> 391 )} 392 393 - <a 394 - href={data.url} 395 - target="_blank" 396 - rel="noopener noreferrer" 397 - className="annotation-source" 398 - > 399 - {truncateUrl(data.url)} 400 - {data.title && ( 401 - <span className="annotation-source-title"> โ€ข {data.title}</span> 402 - )} 403 - </a> 404 - 405 - {highlightedText && ( 406 <a 407 - href={fragmentUrl} 408 target="_blank" 409 rel="noopener noreferrer" 410 - className="annotation-highlight" 411 > 412 - <mark>"{highlightedText}"</mark> 413 </a> 414 - )} 415 416 - {isEditing ? ( 417 - <div className="mt-3"> 418 - <textarea 419 - value={editText} 420 - onChange={(e) => setEditText(e.target.value)} 421 - className="reply-input" 422 - rows={3} 423 - style={{ marginBottom: "8px" }} 424 - /> 425 - <div className="action-buttons-end"> 426 - <button 427 - onClick={() => setIsEditing(false)} 428 - className="btn btn-ghost" 429 - > 430 - Cancel 431 - </button> 432 - <button 433 - onClick={handleSaveEdit} 434 - disabled={saving} 435 - className="btn btn-primary btn-sm" 436 - > 437 - {saving ? ( 438 - "Saving..." 439 - ) : ( 440 - <> 441 - <Save size={14} /> Save 442 - </> 443 - )} 444 - </button> 445 </div> 446 - </div> 447 - ) : ( 448 - data.text && <p className="annotation-text">{data.text}</p> 449 - )} 450 451 - {data.tags?.length > 0 && ( 452 - <div className="annotation-tags"> 453 - {data.tags.map((tag, i) => ( 454 - <span key={i} className="annotation-tag"> 455 - #{tag} 456 - </span> 457 - ))} 458 - </div> 459 - )} 460 461 <footer className="annotation-actions"> 462 - <button 463 - className={`annotation-action ${isLiked ? "liked" : ""}`} 464 - onClick={handleLike} 465 - > 466 - <HeartIcon filled={isLiked} size={16} /> 467 - {likeCount > 0 && <span>{likeCount}</span>} 468 - </button> 469 - <button 470 - className={`annotation-action ${showReplies ? "active" : ""}`} 471 - onClick={() => setShowReplies(!showReplies)} 472 - > 473 - <MessageIcon size={16} /> 474 - <span>{replyCount > 0 ? `${replyCount}` : "Reply"}</span> 475 - </button> 476 - <ShareMenu uri={data.uri} text={data.text} /> 477 - <button 478 - className="annotation-action" 479 - onClick={() => { 480 - if (!user) { 481 - login(); 482 - return; 483 - } 484 - setShowAddToCollection(true); 485 - }} 486 - > 487 - <Folder size={16} /> 488 - <span>Collect</span> 489 - </button> 490 </footer> 491 492 {showReplies && ( ··· 578 </div> 579 </div> 580 )} 581 - 582 - <AddToCollectionModal 583 - isOpen={showAddToCollection} 584 - onClose={() => setShowAddToCollection(false)} 585 - annotationUri={data.uri} 586 - /> 587 </article> 588 ); 589 } 590 591 - export function HighlightCard({ highlight, onDelete }) { 592 const { user, login } = useAuth(); 593 const data = normalizeHighlight(highlight); 594 const highlightedText = 595 data.selector?.type === "TextQuoteSelector" ? data.selector.exact : null; 596 const fragmentUrl = buildTextFragmentUrl(data.url, data.selector); 597 const isOwner = user?.did && data.author?.did === user.did; 598 - const [showAddToCollection, setShowAddToCollection] = useState(false); 599 const [isEditing, setIsEditing] = useState(false); 600 const [editColor, setEditColor] = useState(data.color || "#f59e0b"); 601 602 const handleSaveEdit = async () => { 603 try { 604 - await updateHighlight(data.uri, editColor, []); 605 setIsEditing(false); 606 607 if (highlight.color) highlight.color = editColor; 608 } catch (err) { 609 alert("Failed to update: " + err.message); 610 } ··· 633 return ( 634 <article className="card annotation-card"> 635 <header className="annotation-header"> 636 - <Link 637 - to={data.author?.did ? `/profile/${data.author.did}` : "#"} 638 - className="annotation-avatar-link" 639 - > 640 - <div className="annotation-avatar"> 641 - {data.author?.avatar ? ( 642 - <img src={data.author.avatar} alt="avatar" /> 643 - ) : ( 644 - <span>??</span> 645 )} 646 </div> 647 - </Link> 648 - <div className="annotation-meta"> 649 - <Link to="#" className="annotation-author-link"> 650 - <span className="annotation-author"> 651 - {data.author?.displayName || "Unknown"} 652 - </span> 653 - </Link> 654 - <div className="annotation-time">{formatDate(data.createdAt)}</div> 655 </div> 656 - <div className="action-buttons"> 657 - {isOwner && ( 658 - <> 659 - <button 660 - className="annotation-edit-btn" 661 - onClick={() => setIsEditing(!isEditing)} 662 - title="Edit Color" 663 - > 664 - <Edit2 size={16} /> 665 - </button> 666 - <button 667 - className="annotation-delete" 668 - onClick={(e) => { 669 - e.preventDefault(); 670 - onDelete && onDelete(highlight.id || highlight.uri); 671 - }} 672 - > 673 - <TrashIcon size={16} /> 674 - </button> 675 - </> 676 - )} 677 </div> 678 </header> 679 680 - <a 681 - href={data.url} 682 - target="_blank" 683 - rel="noopener noreferrer" 684 - className="annotation-source" 685 - > 686 - {truncateUrl(data.url)} 687 - </a> 688 - 689 - {highlightedText && ( 690 <a 691 - href={fragmentUrl} 692 target="_blank" 693 rel="noopener noreferrer" 694 - className="annotation-highlight" 695 - style={{ 696 - borderLeftColor: isEditing ? editColor : data.color || "#f59e0b", 697 - }} 698 > 699 - <mark>"{highlightedText}"</mark> 700 </a> 701 - )} 702 703 - {isEditing && ( 704 - <div 705 - className="mt-3" 706 - style={{ display: "flex", alignItems: "center", gap: "8px" }} 707 - > 708 - <span style={{ fontSize: "0.9rem" }}>Color:</span> 709 - <input 710 - type="color" 711 - value={editColor} 712 - onChange={(e) => setEditColor(e.target.value)} 713 style={{ 714 - height: "32px", 715 - width: "64px", 716 - padding: 0, 717 - border: "none", 718 - borderRadius: "var(--radius-sm)", 719 - overflow: "hidden", 720 }} 721 /> 722 <button 723 - onClick={handleSaveEdit} 724 - className="btn btn-primary btn-sm" 725 - style={{ marginLeft: "auto" }} 726 > 727 - Save 728 </button> 729 </div> 730 - )} 731 - 732 - <footer className="annotation-actions"> 733 - <span 734 - className="annotation-action annotation-type-badge" 735 - style={{ color: data.color || "#f59e0b" }} 736 - > 737 - <HighlightIcon size={14} /> Highlight 738 - </span> 739 - <button 740 - className="annotation-action" 741 - onClick={() => { 742 - if (!user) { 743 - login(); 744 - return; 745 - } 746 - setShowAddToCollection(true); 747 - }} 748 - > 749 - <Folder size={16} /> 750 - <span>Collect</span> 751 - </button> 752 </footer> 753 - <AddToCollectionModal 754 - isOpen={showAddToCollection} 755 - onClose={() => setShowAddToCollection(false)} 756 - annotationUri={data.uri} 757 - /> 758 </article> 759 ); 760 }
··· 5 import { 6 normalizeAnnotation, 7 normalizeHighlight, 8 + normalizeBookmark, 9 deleteAnnotation, 10 likeAnnotation, 11 unlikeAnnotation, ··· 27 BookmarkIcon, 28 } from "./Icons"; 29 import { Folder, Edit2, Save, X, Clock } from "lucide-react"; 30 import ShareMenu from "./ShareMenu"; 31 32 function buildTextFragmentUrl(baseUrl, selector) { ··· 59 } 60 }; 61 62 + export default function AnnotationCard({ 63 + annotation, 64 + onDelete, 65 + onAddToCollection, 66 + }) { 67 const { user, login } = useAuth(); 68 const data = normalizeAnnotation(annotation); 69 70 + const [likeCount, setLikeCount] = useState(data.likeCount || 0); 71 + const [isLiked, setIsLiked] = useState(data.viewerHasLiked || false); 72 const [deleting, setDeleting] = useState(false); 73 const [isEditing, setIsEditing] = useState(false); 74 const [editText, setEditText] = useState(data.text || ""); 75 + const [editTags, setEditTags] = useState(data.tags?.join(", ") || ""); 76 const [saving, setSaving] = useState(false); 77 78 const [showHistory, setShowHistory] = useState(false); ··· 80 const [loadingHistory, setLoadingHistory] = useState(false); 81 82 const [replies, setReplies] = useState([]); 83 + const [replyCount, setReplyCount] = useState(data.replyCount || 0); 84 const [showReplies, setShowReplies] = useState(false); 85 const [replyingTo, setReplyingTo] = useState(null); 86 const [replyText, setReplyText] = useState(""); ··· 90 91 const [hasEditHistory, setHasEditHistory] = useState(false); 92 93 + useEffect(() => {}, []); 94 95 const fetchHistory = async () => { 96 if (showHistory) { ··· 147 const handleSaveEdit = async () => { 148 try { 149 setSaving(true); 150 + const tagList = editTags 151 + .split(",") 152 + .map((t) => t.trim()) 153 + .filter(Boolean); 154 + await updateAnnotation(data.uri, editText, tagList); 155 setIsEditing(false); 156 if (annotation.body) annotation.body.value = editText; 157 else if (annotation.text) annotation.text = editText; 158 + if (annotation.tags) annotation.tags = tagList; 159 + data.tags = tagList; 160 } catch (err) { 161 alert("Failed to update: " + err.message); 162 } finally { ··· 259 return ( 260 <article className="card annotation-card"> 261 <header className="annotation-header"> 262 + <div className="annotation-header-left"> 263 + <Link to={marginProfileUrl || "#"} className="annotation-avatar-link"> 264 + <div className="annotation-avatar"> 265 + {authorAvatar ? ( 266 + <img src={authorAvatar} alt={authorDisplayName} /> 267 + ) : ( 268 + <span> 269 + {(authorDisplayName || authorHandle || "??") 270 + ?.substring(0, 2) 271 + .toUpperCase()} 272 + </span> 273 + )} 274 + </div> 275 + </Link> 276 + <div className="annotation-meta"> 277 + <div className="annotation-author-row"> 278 + <Link 279 + to={marginProfileUrl || "#"} 280 + className="annotation-author-link" 281 > 282 + <span className="annotation-author">{authorDisplayName}</span> 283 + </Link> 284 + {authorHandle && ( 285 + <a 286 + href={`https://bsky.app/profile/${authorHandle}`} 287 + target="_blank" 288 + rel="noopener noreferrer" 289 + className="annotation-handle" 290 > 291 + @{authorHandle} 292 + </a> 293 )} 294 + </div> 295 + <div className="annotation-time">{formatDate(data.createdAt)}</div> 296 + </div> 297 + </div> 298 + <div className="annotation-header-right"> 299 + <div style={{ display: "flex", gap: "4px" }}> 300 + {hasEditHistory && !data.color && !data.description && ( 301 <button 302 + className="annotation-action action-icon-only" 303 + onClick={fetchHistory} 304 + title="View Edit History" 305 > 306 + <Clock size={16} /> 307 </button> 308 + )} 309 + 310 + {isOwner && ( 311 + <> 312 + {!data.color && !data.description && ( 313 + <button 314 + className="annotation-action action-icon-only" 315 + onClick={() => setIsEditing(!isEditing)} 316 + title="Edit" 317 + > 318 + <Edit2 size={16} /> 319 + </button> 320 + )} 321 + <button 322 + className="annotation-action action-icon-only" 323 + onClick={handleDelete} 324 + disabled={deleting} 325 + title="Delete" 326 + > 327 + <TrashIcon size={16} /> 328 + </button> 329 + </> 330 + )} 331 + </div> 332 </div> 333 </header> 334 335 {showHistory && ( 336 <div className="history-panel"> 337 <div className="history-header"> ··· 363 </div> 364 )} 365 366 + <div className="annotation-content"> 367 <a 368 + href={data.url} 369 target="_blank" 370 rel="noopener noreferrer" 371 + className="annotation-source" 372 > 373 + {truncateUrl(data.url)} 374 + {data.title && ( 375 + <span className="annotation-source-title"> โ€ข {data.title}</span> 376 + )} 377 </a> 378 + 379 + {highlightedText && ( 380 + <a 381 + href={fragmentUrl} 382 + target="_blank" 383 + rel="noopener noreferrer" 384 + className="annotation-highlight" 385 + style={{ 386 + borderLeftColor: data.color || "var(--accent)", 387 + }} 388 + > 389 + <mark>"{highlightedText}"</mark> 390 + </a> 391 + )} 392 393 + {isEditing ? ( 394 + <div className="mt-3"> 395 + <textarea 396 + value={editText} 397 + onChange={(e) => setEditText(e.target.value)} 398 + className="reply-input" 399 + rows={3} 400 + style={{ marginBottom: "8px" }} 401 + /> 402 + <input 403 + type="text" 404 + className="reply-input" 405 + placeholder="Tags (comma separated)..." 406 + value={editTags} 407 + onChange={(e) => setEditTags(e.target.value)} 408 + style={{ marginBottom: "8px" }} 409 + /> 410 + <div className="action-buttons-end"> 411 + <button 412 + onClick={() => setIsEditing(false)} 413 + className="btn btn-ghost" 414 + > 415 + Cancel 416 + </button> 417 + <button 418 + onClick={handleSaveEdit} 419 + disabled={saving} 420 + className="btn btn-primary btn-sm" 421 + > 422 + {saving ? ( 423 + "Saving..." 424 + ) : ( 425 + <> 426 + <Save size={14} /> Save 427 + </> 428 + )} 429 + </button> 430 + </div> 431 </div> 432 + ) : ( 433 + data.text && <p className="annotation-text">{data.text}</p> 434 + )} 435 436 + {data.tags?.length > 0 && ( 437 + <div className="annotation-tags"> 438 + {data.tags.map((tag, i) => ( 439 + <Link 440 + key={i} 441 + to={`/?tag=${encodeURIComponent(tag)}`} 442 + className="annotation-tag" 443 + > 444 + #{tag} 445 + </Link> 446 + ))} 447 + </div> 448 + )} 449 + </div> 450 451 <footer className="annotation-actions"> 452 + <div className="annotation-actions-left"> 453 + <button 454 + className={`annotation-action ${isLiked ? "liked" : ""}`} 455 + onClick={handleLike} 456 + > 457 + <HeartIcon filled={isLiked} size={16} /> 458 + {likeCount > 0 && <span>{likeCount}</span>} 459 + </button> 460 + <button 461 + className={`annotation-action ${showReplies ? "active" : ""}`} 462 + onClick={async () => { 463 + if (!showReplies && replies.length === 0) { 464 + try { 465 + const res = await getReplies(data.uri); 466 + if (res.items) setReplies(res.items); 467 + } catch (err) { 468 + console.error("Failed to load replies:", err); 469 + } 470 + } 471 + setShowReplies(!showReplies); 472 + }} 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 + if (onAddToCollection) onAddToCollection(); 491 + }} 492 + > 493 + <Folder size={16} /> 494 + <span>Collect</span> 495 + </button> 496 + </div> 497 </footer> 498 499 {showReplies && ( ··· 585 </div> 586 </div> 587 )} 588 </article> 589 ); 590 } 591 592 + export function HighlightCard({ highlight, onDelete, onAddToCollection }) { 593 const { user, login } = useAuth(); 594 const data = normalizeHighlight(highlight); 595 const highlightedText = 596 data.selector?.type === "TextQuoteSelector" ? data.selector.exact : null; 597 const fragmentUrl = buildTextFragmentUrl(data.url, data.selector); 598 const isOwner = user?.did && data.author?.did === user.did; 599 const [isEditing, setIsEditing] = useState(false); 600 const [editColor, setEditColor] = useState(data.color || "#f59e0b"); 601 + const [editTags, setEditTags] = useState(data.tags?.join(", ") || ""); 602 603 const handleSaveEdit = async () => { 604 try { 605 + const tagList = editTags 606 + .split(",") 607 + .map((t) => t.trim()) 608 + .filter(Boolean); 609 + 610 + await updateHighlight(data.uri, editColor, tagList); 611 setIsEditing(false); 612 613 if (highlight.color) highlight.color = editColor; 614 + if (highlight.tags) highlight.tags = tagList; 615 + else highlight.value = { ...highlight.value, tags: tagList }; 616 } catch (err) { 617 alert("Failed to update: " + err.message); 618 } ··· 641 return ( 642 <article className="card annotation-card"> 643 <header className="annotation-header"> 644 + <div className="annotation-header-left"> 645 + <Link 646 + to={data.author?.did ? `/profile/${data.author.did}` : "#"} 647 + className="annotation-avatar-link" 648 + > 649 + <div className="annotation-avatar"> 650 + {data.author?.avatar ? ( 651 + <img src={data.author.avatar} alt="avatar" /> 652 + ) : ( 653 + <span>??</span> 654 + )} 655 + </div> 656 + </Link> 657 + <div className="annotation-meta"> 658 + <Link to="#" className="annotation-author-link"> 659 + <span className="annotation-author"> 660 + {data.author?.displayName || "Unknown"} 661 + </span> 662 + </Link> 663 + <div className="annotation-time">{formatDate(data.createdAt)}</div> 664 + {data.author?.handle && ( 665 + <a 666 + href={`https://bsky.app/profile/${data.author.handle}`} 667 + target="_blank" 668 + rel="noopener noreferrer" 669 + className="annotation-handle" 670 + > 671 + @{data.author.handle} 672 + </a> 673 )} 674 </div> 675 </div> 676 + 677 + <div className="annotation-header-right"> 678 + <div style={{ display: "flex", gap: "4px" }}> 679 + {isOwner && ( 680 + <> 681 + <button 682 + className="annotation-action action-icon-only" 683 + onClick={() => setIsEditing(!isEditing)} 684 + title="Edit Color" 685 + > 686 + <Edit2 size={16} /> 687 + </button> 688 + <button 689 + className="annotation-action action-icon-only" 690 + onClick={(e) => { 691 + e.preventDefault(); 692 + onDelete && onDelete(highlight.id || highlight.uri); 693 + }} 694 + > 695 + <TrashIcon size={16} /> 696 + </button> 697 + </> 698 + )} 699 + </div> 700 </div> 701 </header> 702 703 + <div className="annotation-content"> 704 <a 705 + href={data.url} 706 target="_blank" 707 rel="noopener noreferrer" 708 + className="annotation-source" 709 > 710 + {truncateUrl(data.url)} 711 </a> 712 713 + {highlightedText && ( 714 + <a 715 + href={fragmentUrl} 716 + target="_blank" 717 + rel="noopener noreferrer" 718 + className="annotation-highlight" 719 + style={{ 720 + borderLeftColor: isEditing ? editColor : data.color || "#f59e0b", 721 + }} 722 + > 723 + <mark>"{highlightedText}"</mark> 724 + </a> 725 + )} 726 + 727 + {isEditing && ( 728 + <div 729 + className="mt-3" 730 + style={{ 731 + display: "flex", 732 + gap: "8px", 733 + alignItems: "center", 734 + padding: "8px", 735 + background: "var(--bg-secondary)", 736 + borderRadius: "var(--radius-md)", 737 + border: "1px solid var(--border)", 738 + }} 739 + > 740 + <div 741 + className="color-picker-compact" 742 + style={{ 743 + position: "relative", 744 + width: "28px", 745 + height: "28px", 746 + flexShrink: 0, 747 + }} 748 + > 749 + <div 750 + style={{ 751 + backgroundColor: editColor, 752 + width: "100%", 753 + height: "100%", 754 + borderRadius: "50%", 755 + border: "2px solid var(--bg-card)", 756 + boxShadow: "0 0 0 1px var(--border)", 757 + }} 758 + /> 759 + <input 760 + type="color" 761 + value={editColor} 762 + onChange={(e) => setEditColor(e.target.value)} 763 + style={{ 764 + position: "absolute", 765 + top: 0, 766 + left: 0, 767 + width: "100%", 768 + height: "100%", 769 + opacity: 0, 770 + cursor: "pointer", 771 + }} 772 + title="Change Color" 773 + /> 774 + </div> 775 + 776 + <input 777 + type="text" 778 + className="reply-input" 779 + placeholder="e.g. tag1, tag2" 780 + value={editTags} 781 + onChange={(e) => setEditTags(e.target.value)} 782 + style={{ 783 + margin: 0, 784 + flex: 1, 785 + fontSize: "0.9rem", 786 + padding: "6px 10px", 787 + height: "32px", 788 + border: "none", 789 + background: "transparent", 790 + }} 791 + /> 792 + 793 + <button 794 + onClick={handleSaveEdit} 795 + className="btn btn-primary btn-sm" 796 + style={{ padding: "0 10px", height: "32px", minWidth: "auto" }} 797 + title="Save" 798 + > 799 + <Save size={16} /> 800 + </button> 801 + </div> 802 + )} 803 + 804 + {data.tags?.length > 0 && ( 805 + <div className="annotation-tags"> 806 + {data.tags.map((tag, i) => ( 807 + <Link 808 + key={i} 809 + to={`/?tag=${encodeURIComponent(tag)}`} 810 + className="annotation-tag" 811 + > 812 + #{tag} 813 + </Link> 814 + ))} 815 + </div> 816 + )} 817 + </div> 818 + 819 + <footer className="annotation-actions"> 820 + <div className="annotation-actions-left"> 821 + <span 822 + className="annotation-action" 823 style={{ 824 + color: data.color || "#f59e0b", 825 + background: "none", 826 + paddingLeft: 0, 827 }} 828 + > 829 + <HighlightIcon size={14} /> Highlight 830 + </span> 831 + <ShareMenu 832 + uri={data.uri} 833 + text={data.title || data.description} 834 + handle={data.author?.handle} 835 + type="Highlight" 836 /> 837 <button 838 + className="annotation-action" 839 + onClick={() => { 840 + if (!user) { 841 + login(); 842 + return; 843 + } 844 + if (onAddToCollection) onAddToCollection(); 845 + }} 846 > 847 + <Folder size={16} /> 848 + <span>Collect</span> 849 </button> 850 </div> 851 </footer> 852 </article> 853 ); 854 }
+106 -125
web/src/components/BookmarkCard.jsx
··· 3 import { Link } from "react-router-dom"; 4 import { 5 normalizeAnnotation, 6 likeAnnotation, 7 unlikeAnnotation, 8 getLikeCount, ··· 10 } from "../api/client"; 11 import { HeartIcon, TrashIcon, ExternalLinkIcon, BookmarkIcon } from "./Icons"; 12 import { Folder } from "lucide-react"; 13 - import AddToCollectionModal from "./AddToCollectionModal"; 14 import ShareMenu from "./ShareMenu"; 15 16 - export default function BookmarkCard({ bookmark, annotation, onDelete }) { 17 const { user, login } = useAuth(); 18 - const data = normalizeAnnotation(bookmark || annotation); 19 20 const [likeCount, setLikeCount] = useState(0); 21 const [isLiked, setIsLiked] = useState(false); 22 const [deleting, setDeleting] = useState(false); 23 - const [showAddToCollection, setShowAddToCollection] = useState(false); 24 25 const isOwner = user?.did && data.author?.did === user.did; 26 ··· 81 } 82 }; 83 84 - const handleShare = async () => { 85 - const uriParts = data.uri.split("/"); 86 - const did = uriParts[2]; 87 - const rkey = uriParts[uriParts.length - 1]; 88 - const shareUrl = `${window.location.origin}/at/${did}/${rkey}`; 89 - if (navigator.share) { 90 - try { 91 - await navigator.share({ title: "Bookmark", url: shareUrl }); 92 - } catch {} 93 - } else { 94 - try { 95 - await navigator.clipboard.writeText(shareUrl); 96 - alert("Link copied!"); 97 - } catch { 98 - prompt("Copy:", shareUrl); 99 - } 100 - } 101 - }; 102 - 103 const formatDate = (dateString) => { 104 if (!dateString) return ""; 105 const date = new Date(dateString); ··· 128 129 return ( 130 <article className="card bookmark-card"> 131 - {} 132 <header className="annotation-header"> 133 - <Link to={marginProfileUrl || "#"} className="annotation-avatar-link"> 134 - <div className="annotation-avatar"> 135 - {authorAvatar ? ( 136 - <img src={authorAvatar} alt={authorDisplayName} /> 137 - ) : ( 138 - <span> 139 - {(authorDisplayName || authorHandle || "??") 140 - ?.substring(0, 2) 141 - .toUpperCase()} 142 - </span> 143 - )} 144 </div> 145 - </Link> 146 - <div className="annotation-meta"> 147 - <div className="annotation-author-row"> 148 - <Link 149 - to={marginProfileUrl || "#"} 150 - className="annotation-author-link" 151 - > 152 - <span className="annotation-author">{authorDisplayName}</span> 153 - </Link> 154 - {authorHandle && ( 155 - <a 156 - href={`https://bsky.app/profile/${authorHandle}`} 157 - target="_blank" 158 - rel="noopener noreferrer" 159 - className="annotation-handle" 160 > 161 - @{authorHandle} <ExternalLinkIcon size={12} /> 162 - </a> 163 )} 164 </div> 165 - <div className="annotation-time">{formatDate(data.createdAt)}</div> 166 - </div> 167 - <div className="action-buttons"> 168 - {isOwner && ( 169 - <button 170 - className="annotation-delete" 171 - onClick={handleDelete} 172 - disabled={deleting} 173 - title="Delete" 174 - > 175 - <TrashIcon size={16} /> 176 - </button> 177 - )} 178 </div> 179 </header> 180 181 - {} 182 - <a 183 - href={data.url} 184 - target="_blank" 185 - rel="noopener noreferrer" 186 - className="bookmark-preview" 187 - > 188 - <div className="bookmark-preview-content"> 189 - <div className="bookmark-preview-site"> 190 - <BookmarkIcon size={14} /> 191 - <span>{domain}</span> 192 </div> 193 - <h3 className="bookmark-preview-title">{data.title || data.url}</h3> 194 - {data.description && ( 195 - <p className="bookmark-preview-desc">{data.description}</p> 196 - )} 197 - </div> 198 - <div className="bookmark-preview-arrow"> 199 - <ExternalLinkIcon size={18} /> 200 - </div> 201 - </a> 202 203 - {} 204 - {data.tags?.length > 0 && ( 205 - <div className="annotation-tags"> 206 - {data.tags.map((tag, i) => ( 207 - <span key={i} className="annotation-tag"> 208 - #{tag} 209 - </span> 210 - ))} 211 - </div> 212 - )} 213 214 - {} 215 <footer className="annotation-actions"> 216 - <button 217 - className={`annotation-action ${isLiked ? "liked" : ""}`} 218 - onClick={handleLike} 219 - > 220 - <HeartIcon filled={isLiked} size={16} /> 221 - {likeCount > 0 && <span>{likeCount}</span>} 222 - </button> 223 - <ShareMenu uri={data.uri} text={data.title || data.description} /> 224 - <button 225 - className="annotation-action" 226 - onClick={() => { 227 - if (!user) { 228 - login(); 229 - return; 230 - } 231 - setShowAddToCollection(true); 232 - }} 233 - > 234 - <Folder size={16} /> 235 - <span>Collect</span> 236 - </button> 237 </footer> 238 - 239 - {showAddToCollection && ( 240 - <AddToCollectionModal 241 - isOpen={showAddToCollection} 242 - annotationUri={data.uri} 243 - onClose={() => setShowAddToCollection(false)} 244 - /> 245 - )} 246 </article> 247 ); 248 }
··· 3 import { Link } from "react-router-dom"; 4 import { 5 normalizeAnnotation, 6 + normalizeBookmark, 7 likeAnnotation, 8 unlikeAnnotation, 9 getLikeCount, ··· 11 } from "../api/client"; 12 import { HeartIcon, TrashIcon, ExternalLinkIcon, BookmarkIcon } from "./Icons"; 13 import { Folder } from "lucide-react"; 14 import ShareMenu from "./ShareMenu"; 15 16 + export default function BookmarkCard({ bookmark, onAddToCollection }) { 17 const { user, login } = useAuth(); 18 + const raw = bookmark; 19 + const data = 20 + raw.type === "Bookmark" ? normalizeBookmark(raw) : normalizeAnnotation(raw); 21 22 const [likeCount, setLikeCount] = useState(0); 23 const [isLiked, setIsLiked] = useState(false); 24 const [deleting, setDeleting] = useState(false); 25 26 const isOwner = user?.did && data.author?.did === user.did; 27 ··· 82 } 83 }; 84 85 const formatDate = (dateString) => { 86 if (!dateString) return ""; 87 const date = new Date(dateString); ··· 110 111 return ( 112 <article className="card bookmark-card"> 113 <header className="annotation-header"> 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> 148 </div> 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" 159 > 160 + <TrashIcon size={16} /> 161 + </button> 162 )} 163 </div> 164 </div> 165 </header> 166 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 + )} 183 </div> 184 + </a> 185 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> 196 197 <footer className="annotation-actions"> 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> 226 </footer> 227 </article> 228 ); 229 }
+4 -2
web/src/components/CollectionItemCard.jsx
··· 54 </span>{" "} 55 added to{" "} 56 <Link 57 - to={`/collection/${encodeURIComponent(collection.uri)}?author=${encodeURIComponent(author.did)}`} 58 style={{ 59 display: "inline-flex", 60 alignItems: "center", ··· 70 </span> 71 <div style={{ marginLeft: "auto" }}> 72 <ShareMenu 73 - customUrl={`${window.location.origin}/collection/${encodeURIComponent(collection.uri)}?author=${encodeURIComponent(author.did)}`} 74 text={`Check out this collection by ${author.displayName}: ${collection.name}`} 75 /> 76 </div>
··· 54 </span>{" "} 55 added to{" "} 56 <Link 57 + to={`/${author.handle}/collection/${collection.uri.split("/").pop()}`} 58 style={{ 59 display: "inline-flex", 60 alignItems: "center", ··· 70 </span> 71 <div style={{ marginLeft: "auto" }}> 72 <ShareMenu 73 + uri={collection.uri} 74 + handle={author.handle} 75 + type="Collection" 76 text={`Check out this collection by ${author.displayName}: ${collection.name}`} 77 /> 78 </div>
+5 -3
web/src/components/CollectionRow.jsx
··· 6 return ( 7 <div className="collection-row"> 8 <Link 9 - to={`/collection/${encodeURIComponent(collection.uri)}?author=${encodeURIComponent( 10 - collection.authorDid || collection.author?.did, 11 - )}`} 12 className="collection-row-content" 13 > 14 <div className="collection-row-icon">
··· 6 return ( 7 <div className="collection-row"> 8 <Link 9 + to={ 10 + collection.creator?.handle 11 + ? `/${collection.creator.handle}/collection/${collection.uri.split("/").pop()}` 12 + : `/collection/${encodeURIComponent(collection.uri)}` 13 + } 14 className="collection-row-content" 15 > 16 <div className="collection-row-icon">
+37 -9
web/src/components/Composer.jsx
··· 1 import { useState } from "react"; 2 - import { createAnnotation } from "../api/client"; 3 4 export default function Composer({ 5 url, ··· 9 }) { 10 const [text, setText] = useState(""); 11 const [quoteText, setQuoteText] = useState(""); 12 const [selector, setSelector] = useState(initialSelector); 13 const [loading, setLoading] = useState(false); 14 const [error, setError] = useState(null); ··· 19 20 const handleSubmit = async (e) => { 21 e.preventDefault(); 22 - if (!text.trim()) return; 23 24 try { 25 setLoading(true); ··· 33 }; 34 } 35 36 - await createAnnotation({ 37 - url, 38 - text, 39 - selector: finalSelector || undefined, 40 - }); 41 42 setText(""); 43 setQuoteText(""); ··· 123 className="composer-input" 124 rows={4} 125 maxLength={3000} 126 - required 127 disabled={loading} 128 /> 129 130 <div className="composer-footer"> 131 <span className="composer-count">{text.length}/3000</span> ··· 143 <button 144 type="submit" 145 className="btn btn-primary" 146 - disabled={loading || !text.trim()} 147 > 148 {loading ? "Posting..." : "Post"} 149 </button>
··· 1 import { useState } from "react"; 2 + import { createAnnotation, createHighlight } from "../api/client"; 3 4 export default function Composer({ 5 url, ··· 9 }) { 10 const [text, setText] = useState(""); 11 const [quoteText, setQuoteText] = useState(""); 12 + const [tags, setTags] = useState(""); 13 const [selector, setSelector] = useState(initialSelector); 14 const [loading, setLoading] = useState(false); 15 const [error, setError] = useState(null); ··· 20 21 const handleSubmit = async (e) => { 22 e.preventDefault(); 23 + if (!text.trim() && !highlightedText && !quoteText.trim()) return; 24 25 try { 26 setLoading(true); ··· 34 }; 35 } 36 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 + } 57 58 setText(""); 59 setQuoteText(""); ··· 139 className="composer-input" 140 rows={4} 141 maxLength={3000} 142 disabled={loading} 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> 155 156 <div className="composer-footer"> 157 <span className="composer-count">{text.length}/3000</span> ··· 169 <button 170 type="submit" 171 className="btn btn-primary" 172 + disabled={ 173 + loading || (!text.trim() && !highlightedText && !quoteText) 174 + } 175 > 176 {loading ? "Posting..." : "Post"} 177 </button>
+18 -2
web/src/components/ShareMenu.jsx
··· 97 { name: "Deer", domain: "deer.social", Icon: DeerIcon }, 98 ]; 99 100 - export default function ShareMenu({ uri, text, customUrl }) { 101 const [isOpen, setIsOpen] = useState(false); 102 const [copied, setCopied] = useState(false); 103 const menuRef = useRef(null); ··· 105 const getShareUrl = () => { 106 if (customUrl) return customUrl; 107 if (!uri) return ""; 108 const uriParts = uri.split("/"); 109 - const did = uriParts[2]; 110 const rkey = uriParts[uriParts.length - 1]; 111 return `${window.location.origin}/at/${did}/${rkey}`; 112 }; 113 ··· 119 setIsOpen(false); 120 } 121 }; 122 if (isOpen) { 123 document.addEventListener("mousedown", handleClickOutside); 124 }
··· 97 { name: "Deer", domain: "deer.social", Icon: DeerIcon }, 98 ]; 99 100 + export default function ShareMenu({ uri, text, customUrl, handle, type }) { 101 const [isOpen, setIsOpen] = useState(false); 102 const [copied, setCopied] = useState(false); 103 const menuRef = useRef(null); ··· 105 const getShareUrl = () => { 106 if (customUrl) return customUrl; 107 if (!uri) return ""; 108 + 109 const uriParts = uri.split("/"); 110 const rkey = uriParts[uriParts.length - 1]; 111 + 112 + if (handle && type) { 113 + return `${window.location.origin}/${handle}/${type.toLowerCase()}/${rkey}`; 114 + } 115 + 116 + const did = uriParts[2]; 117 return `${window.location.origin}/at/${did}/${rkey}`; 118 }; 119 ··· 125 setIsOpen(false); 126 } 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 + 138 if (isOpen) { 139 document.addEventListener("mousedown", handleClickOutside); 140 }
+299 -65
web/src/index.css
··· 140 background: var(--bg-card); 141 border: 1px solid var(--border); 142 border-radius: var(--radius-lg); 143 - padding: 20px; 144 transition: all 0.2s ease; 145 } 146 147 .card:hover { 148 border-color: var(--border-hover); 149 - box-shadow: var(--shadow-sm); 150 } 151 152 .annotation-card { 153 display: flex; 154 flex-direction: column; 155 - gap: 12px; 156 } 157 158 .annotation-header { 159 display: flex; 160 align-items: center; 161 gap: 12px; 162 } 163 164 .annotation-avatar { 165 - width: 42px; 166 - height: 42px; 167 - min-width: 42px; 168 border-radius: var(--radius-full); 169 background: linear-gradient(135deg, var(--accent), #a855f7); 170 display: flex; 171 align-items: center; 172 justify-content: center; 173 font-weight: 600; 174 - font-size: 1rem; 175 color: white; 176 overflow: hidden; 177 } 178 179 .annotation-avatar img { ··· 183 } 184 185 .annotation-meta { 186 - flex: 1; 187 - min-width: 0; 188 } 189 190 .annotation-avatar-link { 191 text-decoration: none; 192 } 193 194 .annotation-author-row { ··· 201 .annotation-author { 202 font-weight: 600; 203 color: var(--text-primary); 204 } 205 206 .annotation-handle { 207 - font-size: 0.9rem; 208 color: var(--text-tertiary); 209 text-decoration: none; 210 } 211 212 .annotation-handle:hover { 213 color: var(--accent); 214 - text-decoration: underline; 215 } 216 217 .annotation-time { 218 - font-size: 0.85rem; 219 color: var(--text-tertiary); 220 } 221 222 .annotation-source { 223 - display: block; 224 - font-size: 0.85rem; 225 color: var(--text-tertiary); 226 text-decoration: none; 227 - margin-bottom: 8px; 228 } 229 230 .annotation-source:hover { 231 - color: var(--accent); 232 } 233 234 .annotation-source-title { 235 color: var(--text-secondary); 236 } 237 238 .annotation-highlight { 239 display: block; 240 - padding: 12px 16px; 241 background: linear-gradient( 242 135deg, 243 - rgba(79, 70, 229, 0.05), 244 - rgba(168, 85, 247, 0.05) 245 ); 246 border-left: 3px solid var(--accent); 247 - border-radius: 0 var(--radius-sm) var(--radius-sm) 0; 248 text-decoration: none; 249 - transition: all 0.15s ease; 250 - margin-bottom: 12px; 251 } 252 253 .annotation-highlight:hover { 254 background: linear-gradient( 255 135deg, 256 - rgba(79, 70, 229, 0.1), 257 - rgba(168, 85, 247, 0.1) 258 ); 259 } 260 261 .annotation-highlight mark { 262 background: transparent; 263 color: var(--text-primary); 264 font-style: italic; 265 - font-size: 0.95rem; 266 } 267 268 .annotation-text { 269 font-size: 1rem; 270 line-height: 1.65; 271 color: var(--text-primary); 272 } 273 274 .annotation-actions { 275 display: flex; 276 align-items: center; 277 - gap: 16px; 278 - padding-top: 8px; 279 } 280 281 .annotation-action { ··· 284 gap: 6px; 285 color: var(--text-tertiary); 286 font-size: 0.85rem; 287 padding: 6px 10px; 288 - border-radius: var(--radius-sm); 289 - transition: all 0.15s ease; 290 } 291 292 .annotation-action:hover { 293 color: var(--text-secondary); 294 - background: var(--bg-tertiary); 295 } 296 297 .annotation-action.liked { 298 color: #ef4444; 299 } 300 301 .annotation-delete { 302 background: none; 303 border: none; 304 cursor: pointer; 305 - padding: 6px 8px; 306 font-size: 1rem; 307 color: var(--text-tertiary); 308 - transition: all 0.15s ease; 309 - border-radius: var(--radius-sm); 310 } 311 312 .annotation-delete:hover { 313 color: var(--error); 314 background: rgba(239, 68, 68, 0.1); 315 } 316 317 .annotation-delete:disabled { ··· 1043 border-bottom-color: var(--accent); 1044 } 1045 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 .bookmark-description { 1075 font-size: 0.9rem; 1076 color: var(--text-secondary); ··· 1368 color: var(--text-tertiary); 1369 } 1370 1371 .composer-footer { 1372 display: flex; 1373 justify-content: space-between; ··· 1393 border-radius: var(--radius-md); 1394 color: var(--error); 1395 font-size: 0.9rem; 1396 } 1397 1398 .annotation-detail-page { ··· 2929 padding: 1rem; 2930 } 2931 2932 .bookmark-card { 2933 display: flex; 2934 flex-direction: column; 2935 - gap: 12px; 2936 } 2937 2938 .bookmark-preview { 2939 display: flex; 2940 - align-items: stretch; 2941 - gap: 16px; 2942 - padding: 14px 16px; 2943 background: var(--bg-secondary); 2944 border: 1px solid var(--border); 2945 border-radius: var(--radius-md); 2946 text-decoration: none; 2947 transition: all 0.2s ease; 2948 } 2949 2950 .bookmark-preview:hover {
··· 140 background: var(--bg-card); 141 border: 1px solid var(--border); 142 border-radius: var(--radius-lg); 143 + padding: 24px; 144 transition: all 0.2s ease; 145 + position: relative; 146 } 147 148 .card:hover { 149 border-color: var(--border-hover); 150 + box-shadow: var(--shadow-md); 151 + transform: translateY(-1px); 152 } 153 154 .annotation-card { 155 display: flex; 156 flex-direction: column; 157 + gap: 16px; 158 } 159 160 .annotation-header { 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; 169 align-items: center; 170 gap: 12px; 171 + flex: 1; 172 + min-width: 0; 173 } 174 175 .annotation-avatar { 176 + width: 40px; 177 + height: 40px; 178 + min-width: 40px; 179 border-radius: var(--radius-full); 180 background: linear-gradient(135deg, var(--accent), #a855f7); 181 display: flex; 182 align-items: center; 183 justify-content: center; 184 font-weight: 600; 185 + font-size: 0.95rem; 186 color: white; 187 overflow: hidden; 188 + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2); 189 } 190 191 .annotation-avatar img { ··· 195 } 196 197 .annotation-meta { 198 + display: flex; 199 + flex-direction: column; 200 + justify-content: center; 201 + line-height: 1.3; 202 } 203 204 .annotation-avatar-link { 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); 212 } 213 214 .annotation-author-row { ··· 221 .annotation-author { 222 font-weight: 600; 223 color: var(--text-primary); 224 + font-size: 0.95rem; 225 } 226 227 .annotation-handle { 228 + font-size: 0.85rem; 229 color: var(--text-tertiary); 230 text-decoration: none; 231 + display: flex; 232 + align-items: center; 233 + gap: 3px; 234 } 235 236 .annotation-handle:hover { 237 color: var(--accent); 238 } 239 240 .annotation-time { 241 + font-size: 0.8rem; 242 color: var(--text-tertiary); 243 + } 244 + 245 + .annotation-content { 246 + display: flex; 247 + flex-direction: column; 248 + gap: 12px; 249 } 250 251 .annotation-source { 252 + display: inline-flex; 253 + align-items: center; 254 + gap: 6px; 255 + font-size: 0.8rem; 256 color: var(--text-tertiary); 257 text-decoration: none; 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; 267 } 268 269 .annotation-source:hover { 270 + color: var(--text-primary); 271 + background: var(--bg-hover); 272 } 273 274 .annotation-source-title { 275 color: var(--text-secondary); 276 + opacity: 0.8; 277 } 278 279 .annotation-highlight { 280 display: block; 281 + position: relative; 282 + padding: 16px 20px; 283 background: linear-gradient( 284 135deg, 285 + rgba(79, 70, 229, 0.03), 286 + rgba(168, 85, 247, 0.03) 287 ); 288 border-left: 3px solid var(--accent); 289 + border-radius: 4px var(--radius-md) var(--radius-md) 4px; 290 text-decoration: none; 291 + transition: all 0.2s ease; 292 + margin: 4px 0; 293 } 294 295 .annotation-highlight:hover { 296 background: linear-gradient( 297 135deg, 298 + rgba(79, 70, 229, 0.08), 299 + rgba(168, 85, 247, 0.08) 300 ); 301 + transform: translateX(2px); 302 } 303 304 .annotation-highlight mark { 305 background: transparent; 306 color: var(--text-primary); 307 font-style: italic; 308 + font-size: 1.05rem; 309 + line-height: 1.6; 310 + font-weight: 400; 311 + display: inline; 312 } 313 314 .annotation-text { 315 font-size: 1rem; 316 line-height: 1.65; 317 color: var(--text-primary); 318 + white-space: pre-wrap; 319 } 320 321 .annotation-actions { 322 display: flex; 323 align-items: center; 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; 334 } 335 336 .annotation-action { ··· 339 gap: 6px; 340 color: var(--text-tertiary); 341 font-size: 0.85rem; 342 + font-weight: 500; 343 padding: 6px 10px; 344 + border-radius: var(--radius-md); 345 + transition: all 0.2s ease; 346 + background: transparent; 347 + cursor: pointer; 348 } 349 350 .annotation-action:hover { 351 color: var(--text-secondary); 352 + background: var(--bg-elevated); 353 } 354 355 .annotation-action.liked { 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; 371 } 372 373 .annotation-delete { 374 background: none; 375 border: none; 376 cursor: pointer; 377 + padding: 8px; 378 font-size: 1rem; 379 color: var(--text-tertiary); 380 + transition: all 0.2s ease; 381 + border-radius: var(--radius-md); 382 + opacity: 0.6; 383 } 384 385 .annotation-delete:hover { 386 color: var(--error); 387 background: rgba(239, 68, 68, 0.1); 388 + opacity: 1; 389 } 390 391 .annotation-delete:disabled { ··· 1117 border-bottom-color: var(--accent); 1118 } 1119 1120 .bookmark-description { 1121 font-size: 0.9rem; 1122 color: var(--text-secondary); ··· 1414 color: var(--text-tertiary); 1415 } 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 + 1442 .composer-footer { 1443 display: flex; 1444 justify-content: space-between; ··· 1464 border-radius: var(--radius-md); 1465 color: var(--error); 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); 1523 } 1524 1525 .annotation-detail-page { ··· 3056 padding: 1rem; 3057 } 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 + 3107 .bookmark-card { 3108 display: flex; 3109 flex-direction: column; 3110 + gap: 16px; 3111 } 3112 3113 .bookmark-preview { 3114 display: flex; 3115 + flex-direction: column; 3116 background: var(--bg-secondary); 3117 border: 1px solid var(--border); 3118 border-radius: var(--radius-md); 3119 + overflow: hidden; 3120 text-decoration: none; 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; 3182 } 3183 3184 .bookmark-preview:hover {
+121 -62
web/src/pages/AnnotationDetail.jsx
··· 1 import { useState, useEffect } from "react"; 2 - import { useParams, Link } from "react-router-dom"; 3 - import AnnotationCard from "../components/AnnotationCard"; 4 import ReplyList from "../components/ReplyList"; 5 import { 6 getAnnotation, 7 getReplies, 8 createReply, 9 deleteReply, 10 } from "../api/client"; 11 import { useAuth } from "../context/AuthContext"; 12 import { MessageSquare } from "lucide-react"; 13 14 export default function AnnotationDetail() { 15 - const { uri, did, rkey } = useParams(); 16 const { isAuthenticated, user } = useAuth(); 17 const [annotation, setAnnotation] = useState(null); 18 const [replies, setReplies] = useState([]); ··· 23 const [posting, setPosting] = useState(false); 24 const [replyingTo, setReplyingTo] = useState(null); 25 26 - const annotationUri = uri || `at://${did}/at.margin.annotation/${rkey}`; 27 28 const refreshReplies = async () => { 29 - const repliesData = await getReplies(annotationUri); 30 setReplies(repliesData.items || []); 31 }; 32 33 useEffect(() => { 34 async function fetchData() { 35 try { 36 setLoading(true); 37 const [annData, repliesData] = await Promise.all([ 38 - getAnnotation(annotationUri), 39 - getReplies(annotationUri).catch(() => ({ items: [] })), 40 ]); 41 - setAnnotation(annData); 42 setReplies(repliesData.items || []); 43 } catch (err) { 44 setError(err.message); ··· 47 } 48 } 49 fetchData(); 50 - }, [annotationUri]); 51 52 const handleReply = async (e) => { 53 if (e) e.preventDefault(); ··· 57 setPosting(true); 58 const parentUri = replyingTo 59 ? replyingTo.id || replyingTo.uri 60 - : annotationUri; 61 const parentCid = replyingTo 62 ? replyingTo.cid || "" 63 : annotation?.cid || ""; ··· 65 await createReply({ 66 parentUri, 67 parentCid, 68 - rootUri: annotationUri, 69 rootCid: annotation?.cid || "", 70 text: replyText, 71 }); ··· 130 </Link> 131 </div> 132 133 - <AnnotationCard annotation={annotation} /> 134 135 - {} 136 - <div className="replies-section"> 137 - <h3 className="replies-title"> 138 - <MessageSquare size={18} /> 139 - Replies ({replies.length}) 140 - </h3> 141 142 - {isAuthenticated && ( 143 - <div className="reply-form card"> 144 - {replyingTo && ( 145 - <div className="replying-to-banner"> 146 - <span> 147 - Replying to @ 148 - {(replyingTo.creator || replyingTo.author)?.handle || 149 - "unknown"} 150 - </span> 151 <button 152 - onClick={() => setReplyingTo(null)} 153 - className="cancel-reply" 154 > 155 - ร— 156 </button> 157 </div> 158 - )} 159 - <textarea 160 - value={replyText} 161 - onChange={(e) => setReplyText(e.target.value)} 162 - placeholder={ 163 - replyingTo 164 - ? `Reply to @${(replyingTo.creator || replyingTo.author)?.handle}...` 165 - : "Write a reply..." 166 - } 167 - className="reply-input" 168 - rows={3} 169 - disabled={posting} 170 - /> 171 - <div className="reply-form-actions"> 172 - <button 173 - className="btn btn-primary" 174 - disabled={posting || !replyText.trim()} 175 - onClick={() => handleReply()} 176 - > 177 - {posting ? "Posting..." : "Reply"} 178 - </button> 179 </div> 180 - </div> 181 - )} 182 183 - <ReplyList 184 - replies={replies} 185 - rootUri={annotationUri} 186 - user={user} 187 - onReply={(reply) => setReplyingTo(reply)} 188 - onDelete={handleDeleteReply} 189 - isInline={false} 190 - /> 191 - </div> 192 </div> 193 ); 194 }
··· 1 import { useState, useEffect } from "react"; 2 + import { useParams, Link, useLocation } from "react-router-dom"; 3 + import AnnotationCard, { HighlightCard } from "../components/AnnotationCard"; 4 + import BookmarkCard from "../components/BookmarkCard"; 5 import ReplyList from "../components/ReplyList"; 6 import { 7 getAnnotation, 8 getReplies, 9 createReply, 10 deleteReply, 11 + resolveHandle, 12 + normalizeAnnotation, 13 } from "../api/client"; 14 import { useAuth } from "../context/AuthContext"; 15 import { MessageSquare } from "lucide-react"; 16 17 export default function AnnotationDetail() { 18 + const { uri, did, rkey, handle, type } = useParams(); 19 + const location = useLocation(); 20 const { isAuthenticated, user } = useAuth(); 21 const [annotation, setAnnotation] = useState(null); 22 const [replies, setReplies] = useState([]); ··· 27 const [posting, setPosting] = useState(false); 28 const [replyingTo, setReplyingTo] = useState(null); 29 30 + const [targetUri, setTargetUri] = useState(uri); 31 + 32 + useEffect(() => { 33 + async function resolve() { 34 + if (uri) { 35 + setTargetUri(uri); 36 + return; 37 + } 38 + 39 + if (handle && rkey) { 40 + let collection = "at.margin.annotation"; 41 + if (type === "highlight") collection = "at.margin.highlight"; 42 + if (type === "bookmark") collection = "at.margin.bookmark"; 43 + 44 + try { 45 + const resolvedDid = await resolveHandle(handle); 46 + if (resolvedDid) { 47 + setTargetUri(`at://${resolvedDid}/${collection}/${rkey}`); 48 + } 49 + } catch (e) { 50 + console.error("Failed to resolve handle:", e); 51 + } 52 + } else if (did && rkey) { 53 + setTargetUri(`at://${did}/at.margin.annotation/${rkey}`); 54 + } else { 55 + const pathParts = location.pathname.split("/"); 56 + const atIndex = pathParts.indexOf("at"); 57 + if ( 58 + atIndex !== -1 && 59 + pathParts[atIndex + 1] && 60 + pathParts[atIndex + 2] 61 + ) { 62 + setTargetUri( 63 + `at://${pathParts[atIndex + 1]}/at.margin.annotation/${pathParts[atIndex + 2]}`, 64 + ); 65 + } 66 + } 67 + } 68 + resolve(); 69 + }, [uri, did, rkey, handle, type, location.pathname]); 70 71 const refreshReplies = async () => { 72 + if (!targetUri) return; 73 + const repliesData = await getReplies(targetUri); 74 setReplies(repliesData.items || []); 75 }; 76 77 useEffect(() => { 78 async function fetchData() { 79 + if (!targetUri) return; 80 + 81 try { 82 setLoading(true); 83 const [annData, repliesData] = await Promise.all([ 84 + getAnnotation(targetUri), 85 + getReplies(targetUri).catch(() => ({ items: [] })), 86 ]); 87 + setAnnotation(normalizeAnnotation(annData)); 88 setReplies(repliesData.items || []); 89 } catch (err) { 90 setError(err.message); ··· 93 } 94 } 95 fetchData(); 96 + }, [targetUri]); 97 98 const handleReply = async (e) => { 99 if (e) e.preventDefault(); ··· 103 setPosting(true); 104 const parentUri = replyingTo 105 ? replyingTo.id || replyingTo.uri 106 + : targetUri; 107 const parentCid = replyingTo 108 ? replyingTo.cid || "" 109 : annotation?.cid || ""; ··· 111 await createReply({ 112 parentUri, 113 parentCid, 114 + rootUri: targetUri, 115 rootCid: annotation?.cid || "", 116 text: replyText, 117 }); ··· 176 </Link> 177 </div> 178 179 + {annotation.type === "Highlight" ? ( 180 + <HighlightCard 181 + highlight={annotation} 182 + onDelete={() => (window.location.href = "/")} 183 + /> 184 + ) : annotation.type === "Bookmark" ? ( 185 + <BookmarkCard 186 + bookmark={annotation} 187 + onDelete={() => (window.location.href = "/")} 188 + /> 189 + ) : ( 190 + <AnnotationCard annotation={annotation} /> 191 + )} 192 193 + {annotation.type !== "Bookmark" && annotation.type !== "Highlight" && ( 194 + <div className="replies-section"> 195 + <h3 className="replies-title"> 196 + <MessageSquare size={18} /> 197 + Replies ({replies.length}) 198 + </h3> 199 200 + {isAuthenticated && ( 201 + <div className="reply-form card"> 202 + {replyingTo && ( 203 + <div className="replying-to-banner"> 204 + <span> 205 + Replying to @ 206 + {(replyingTo.creator || replyingTo.author)?.handle || 207 + "unknown"} 208 + </span> 209 + <button 210 + onClick={() => setReplyingTo(null)} 211 + className="cancel-reply" 212 + > 213 + ร— 214 + </button> 215 + </div> 216 + )} 217 + <textarea 218 + value={replyText} 219 + onChange={(e) => setReplyText(e.target.value)} 220 + placeholder={ 221 + replyingTo 222 + ? `Reply to @${(replyingTo.creator || replyingTo.author)?.handle}...` 223 + : "Write a reply..." 224 + } 225 + className="reply-input" 226 + rows={3} 227 + disabled={posting} 228 + /> 229 + <div className="reply-form-actions"> 230 <button 231 + className="btn btn-primary" 232 + disabled={posting || !replyText.trim()} 233 + onClick={() => handleReply()} 234 > 235 + {posting ? "Posting..." : "Reply"} 236 </button> 237 </div> 238 </div> 239 + )} 240 241 + <ReplyList 242 + replies={replies} 243 + rootUri={targetUri} 244 + user={user} 245 + onReply={(reply) => setReplyingTo(reply)} 246 + onDelete={handleDeleteReply} 247 + isInline={false} 248 + /> 249 + </div> 250 + )} 251 </div> 252 ); 253 }
+52 -39
web/src/pages/CollectionDetail.jsx
··· 6 getCollectionItems, 7 removeItemFromCollection, 8 deleteCollection, 9 } from "../api/client"; 10 import { useAuth } from "../context/AuthContext"; 11 import CollectionModal from "../components/CollectionModal"; ··· 15 import ShareMenu from "../components/ShareMenu"; 16 17 export default function CollectionDetail() { 18 - const { rkey, "*": wildcardPath } = useParams(); 19 const location = useLocation(); 20 const navigate = useNavigate(); 21 const { user } = useAuth(); ··· 27 const [isEditModalOpen, setIsEditModalOpen] = useState(false); 28 29 const searchParams = new URLSearchParams(location.search); 30 - const authorDid = searchParams.get("author") || user?.did; 31 32 - const getCollectionUri = () => { 33 - if (wildcardPath) { 34 - return decodeURIComponent(wildcardPath); 35 - } 36 - if (rkey && authorDid) { 37 - return `at://${authorDid}/at.margin.collection/${rkey}`; 38 - } 39 - return null; 40 - }; 41 - 42 - const collectionUri = getCollectionUri(); 43 - const isOwner = user?.did && authorDid === user.did; 44 45 const fetchContext = async () => { 46 - if (!collectionUri || !authorDid) { 47 - setError("Invalid collection URL"); 48 - setLoading(false); 49 - return; 50 - } 51 - 52 try { 53 setLoading(true); 54 const [cols, itemsData] = await Promise.all([ 55 - getCollections(authorDid), 56 - getCollectionItems(collectionUri), 57 ]); 58 59 const found = 60 - cols.items?.find((c) => c.uri === collectionUri) || 61 cols.items?.find( 62 - (c) => 63 - collectionUri && c.uri.endsWith(collectionUri.split("/").pop()), 64 ); 65 if (!found) { 66 - console.error( 67 - "Collection not found. Looking for:", 68 - collectionUri, 69 - "Available:", 70 - cols.items?.map((c) => c.uri), 71 - ); 72 setError("Collection not found"); 73 return; 74 } ··· 83 }; 84 85 useEffect(() => { 86 - if (collectionUri && authorDid) { 87 - fetchContext(); 88 - } else if (!user && !searchParams.get("author")) { 89 - setLoading(false); 90 - setError("Please log in to view your collections"); 91 - } 92 - }, [rkey, wildcardPath, authorDid, user]); 93 94 const handleEditSuccess = () => { 95 fetchContext(); ··· 171 </div> 172 <div className="collection-detail-actions"> 173 <ShareMenu 174 - customUrl={`${window.location.origin}/collection/${encodeURIComponent(collection.uri)}?author=${encodeURIComponent(authorDid)}`} 175 text={`Check out this collection: ${collection.name}`} 176 /> 177 {isOwner && (
··· 6 getCollectionItems, 7 removeItemFromCollection, 8 deleteCollection, 9 + resolveHandle, 10 } from "../api/client"; 11 import { useAuth } from "../context/AuthContext"; 12 import CollectionModal from "../components/CollectionModal"; ··· 16 import ShareMenu from "../components/ShareMenu"; 17 18 export default function CollectionDetail() { 19 + const { rkey, handle, "*": wildcardPath } = useParams(); 20 const location = useLocation(); 21 const navigate = useNavigate(); 22 const { user } = useAuth(); ··· 28 const [isEditModalOpen, setIsEditModalOpen] = useState(false); 29 30 const searchParams = new URLSearchParams(location.search); 31 + const paramAuthorDid = searchParams.get("author"); 32 33 + const isOwner = 34 + user?.did && 35 + (collection?.creator?.did === user.did || paramAuthorDid === user.did); 36 37 const fetchContext = async () => { 38 try { 39 setLoading(true); 40 + 41 + let targetUri = null; 42 + let targetDid = paramAuthorDid || user?.did; 43 + 44 + if (handle && rkey) { 45 + try { 46 + targetDid = await resolveHandle(handle); 47 + targetUri = `at://${targetDid}/at.margin.collection/${rkey}`; 48 + } catch (e) { 49 + console.error("Failed to resolve handle", e); 50 + } 51 + } else if (wildcardPath) { 52 + targetUri = decodeURIComponent(wildcardPath); 53 + } else if (rkey && targetDid) { 54 + targetUri = `at://${targetDid}/at.margin.collection/${rkey}`; 55 + } 56 + 57 + if (!targetUri) { 58 + if (!user && !handle && !paramAuthorDid) { 59 + setError("Please log in to view your collections"); 60 + return; 61 + } 62 + setError("Invalid collection URL"); 63 + return; 64 + } 65 + 66 + if (!targetDid && targetUri.startsWith("at://")) { 67 + const parts = targetUri.split("/"); 68 + if (parts.length > 2) targetDid = parts[2]; 69 + } 70 + 71 + if (!targetDid) { 72 + setError("Could not determine collection owner"); 73 + return; 74 + } 75 + 76 const [cols, itemsData] = await Promise.all([ 77 + getCollections(targetDid), 78 + getCollectionItems(targetUri), 79 ]); 80 81 const found = 82 + cols.items?.find((c) => c.uri === targetUri) || 83 cols.items?.find( 84 + (c) => targetUri && c.uri.endsWith(targetUri.split("/").pop()), 85 ); 86 + 87 if (!found) { 88 setError("Collection not found"); 89 return; 90 } ··· 99 }; 100 101 useEffect(() => { 102 + fetchContext(); 103 + }, [rkey, wildcardPath, handle, paramAuthorDid, user?.did]); 104 105 const handleEditSuccess = () => { 106 fetchContext(); ··· 182 </div> 183 <div className="collection-detail-actions"> 184 <ShareMenu 185 + uri={collection.uri} 186 + handle={collection.creator?.handle} 187 + type="Collection" 188 text={`Check out this collection: ${collection.name}`} 189 /> 190 {isOwner && (
+131 -8
web/src/pages/Feed.jsx
··· 1 import { useState, useEffect } from "react"; 2 import AnnotationCard, { HighlightCard } from "../components/AnnotationCard"; 3 import BookmarkCard from "../components/BookmarkCard"; 4 import CollectionItemCard from "../components/CollectionItemCard"; 5 - import { getAnnotationFeed } from "../api/client"; 6 import { AlertIcon, InboxIcon } from "../components/Icons"; 7 8 export default function Feed() { 9 const [annotations, setAnnotations] = useState([]); 10 const [loading, setLoading] = useState(true); 11 const [error, setError] = useState(null); 12 - const [filter, setFilter] = useState("all"); 13 14 useEffect(() => { 15 async function fetchFeed() { 16 try { 17 setLoading(true); 18 - const data = await getAnnotationFeed(); 19 setAnnotations(data.items || []); 20 } catch (err) { 21 setError(err.message); ··· 24 } 25 } 26 fetchFeed(); 27 - }, []); 28 29 const filteredAnnotations = 30 - filter === "all" 31 ? annotations 32 : annotations.filter((a) => { 33 if (filter === "commenting") ··· 46 <p className="page-description"> 47 See what people are annotating, highlighting, and bookmarking 48 </p> 49 </div> 50 51 {} ··· 56 > 57 All 58 </button> 59 <button 60 className={`filter-tab ${filter === "commenting" ? "active" : ""}`} 61 onClick={() => setFilter("commenting")} ··· 129 item.type === "Highlight" || 130 item.motivation === "highlighting" 131 ) { 132 - return <HighlightCard key={item.id} highlight={item} />; 133 } 134 if (item.type === "Bookmark" || item.motivation === "bookmarking") { 135 - return <BookmarkCard key={item.id} bookmark={item} />; 136 } 137 - return <AnnotationCard key={item.id} annotation={item} />; 138 })} 139 </div> 140 )} 141 </div> 142 );
··· 1 import { useState, useEffect } from "react"; 2 + import { useSearchParams } from "react-router-dom"; 3 import AnnotationCard, { HighlightCard } from "../components/AnnotationCard"; 4 import BookmarkCard from "../components/BookmarkCard"; 5 import CollectionItemCard from "../components/CollectionItemCard"; 6 + import { getAnnotationFeed, deleteHighlight } from "../api/client"; 7 import { AlertIcon, InboxIcon } from "../components/Icons"; 8 + import { useAuth } from "../context/AuthContext"; 9 + 10 + import AddToCollectionModal from "../components/AddToCollectionModal"; 11 12 export default function Feed() { 13 + const [searchParams, setSearchParams] = useSearchParams(); 14 + const tagFilter = searchParams.get("tag"); 15 + 16 + const [filter, setFilter] = useState(() => { 17 + return localStorage.getItem("feedFilter") || "all"; 18 + }); 19 + 20 const [annotations, setAnnotations] = useState([]); 21 const [loading, setLoading] = useState(true); 22 const [error, setError] = useState(null); 23 + 24 + useEffect(() => { 25 + localStorage.setItem("feedFilter", filter); 26 + }, [filter]); 27 + 28 + const [collectionModalState, setCollectionModalState] = useState({ 29 + isOpen: false, 30 + uri: null, 31 + }); 32 + 33 + const { user } = useAuth(); 34 35 useEffect(() => { 36 async function fetchFeed() { 37 try { 38 setLoading(true); 39 + let creatorDid = ""; 40 + 41 + if (filter === "my-tags") { 42 + if (user?.did) { 43 + creatorDid = user.did; 44 + } else { 45 + setAnnotations([]); 46 + setLoading(false); 47 + return; 48 + } 49 + } 50 + 51 + const data = await getAnnotationFeed( 52 + 50, 53 + 0, 54 + tagFilter || "", 55 + creatorDid, 56 + ); 57 setAnnotations(data.items || []); 58 } catch (err) { 59 setError(err.message); ··· 62 } 63 } 64 fetchFeed(); 65 + }, [tagFilter, filter, user]); 66 67 const filteredAnnotations = 68 + filter === "all" || filter === "my-tags" 69 ? annotations 70 : annotations.filter((a) => { 71 if (filter === "commenting") ··· 84 <p className="page-description"> 85 See what people are annotating, highlighting, and bookmarking 86 </p> 87 + {tagFilter && ( 88 + <div 89 + style={{ 90 + marginTop: "16px", 91 + display: "flex", 92 + alignItems: "center", 93 + gap: "8px", 94 + }} 95 + > 96 + <span 97 + style={{ fontSize: "0.9rem", color: "var(--text-secondary)" }} 98 + > 99 + Filtering by tag: <strong>#{tagFilter}</strong> 100 + </span> 101 + <button 102 + onClick={() => 103 + setSearchParams((prev) => { 104 + const next = new URLSearchParams(prev); 105 + next.delete("tag"); 106 + return next; 107 + }) 108 + } 109 + className="btn btn-sm" 110 + style={{ padding: "2px 8px", fontSize: "0.8rem" }} 111 + > 112 + Clear 113 + </button> 114 + </div> 115 + )} 116 </div> 117 118 {} ··· 123 > 124 All 125 </button> 126 + {user && ( 127 + <button 128 + className={`filter-tab ${filter === "my-tags" ? "active" : ""}`} 129 + onClick={() => setFilter("my-tags")} 130 + > 131 + My Feed 132 + </button> 133 + )} 134 <button 135 className={`filter-tab ${filter === "commenting" ? "active" : ""}`} 136 onClick={() => setFilter("commenting")} ··· 204 item.type === "Highlight" || 205 item.motivation === "highlighting" 206 ) { 207 + return ( 208 + <HighlightCard 209 + key={item.id} 210 + highlight={item} 211 + onDelete={async (uri) => { 212 + const rkey = uri.split("/").pop(); 213 + await deleteHighlight(rkey); 214 + setAnnotations((prev) => 215 + prev.filter((a) => a.id !== item.id), 216 + ); 217 + }} 218 + onAddToCollection={() => 219 + setCollectionModalState({ 220 + isOpen: true, 221 + uri: item.uri || item.id, 222 + }) 223 + } 224 + /> 225 + ); 226 } 227 if (item.type === "Bookmark" || item.motivation === "bookmarking") { 228 + return ( 229 + <BookmarkCard 230 + key={item.id} 231 + bookmark={item} 232 + onAddToCollection={() => 233 + setCollectionModalState({ 234 + isOpen: true, 235 + uri: item.uri || item.id, 236 + }) 237 + } 238 + /> 239 + ); 240 } 241 + return ( 242 + <AnnotationCard 243 + key={item.id} 244 + annotation={item} 245 + onAddToCollection={() => 246 + setCollectionModalState({ 247 + isOpen: true, 248 + uri: item.uri || item.id, 249 + }) 250 + } 251 + /> 252 + ); 253 })} 254 </div> 255 + )} 256 + 257 + {collectionModalState.isOpen && ( 258 + <AddToCollectionModal 259 + isOpen={collectionModalState.isOpen} 260 + onClose={() => setCollectionModalState({ isOpen: false, uri: null })} 261 + annotationUri={collectionModalState.uri} 262 + /> 263 )} 264 </div> 265 );
+5 -1
web/src/pages/New.jsx
··· 84 85 <div className="card"> 86 <Composer 87 - url={url || initialUrl} 88 selector={initialSelector} 89 onSuccess={handleSuccess} 90 onCancel={() => navigate(-1)}
··· 84 85 <div className="card"> 86 <Composer 87 + url={ 88 + (url || initialUrl) && !/^(?:f|ht)tps?:\/\//.test(url || initialUrl) 89 + ? `https://${url || initialUrl}` 90 + : url || initialUrl 91 + } 92 selector={initialSelector} 93 onSuccess={handleSuccess} 94 onCancel={() => navigate(-1)}
+10 -7
web/src/pages/Notifications.jsx
··· 4 import { getNotifications, markNotificationsRead } from "../api/client"; 5 import { BellIcon, HeartIcon, ReplyIcon } from "../components/Icons"; 6 7 - function getContentRoute(subjectUri) { 8 - if (!subjectUri) return "/"; 9 - if (subjectUri.includes("at.margin.bookmark")) { 10 return `/bookmarks`; 11 } 12 - if (subjectUri.includes("at.margin.highlight")) { 13 return `/highlights`; 14 } 15 - return `/annotation/${encodeURIComponent(subjectUri)}`; 16 } 17 18 export default function Notifications() { ··· 163 {notifications.map((n, i) => ( 164 <Link 165 key={n.id || i} 166 - to={getContentRoute(n.subjectUri)} 167 className="notification-item card" 168 style={{ alignItems: "center" }} 169 > 170 <div 171 className="notification-avatar-container" 172 - style={{ marginRight: 12 }} 173 > 174 {n.actor?.avatar ? ( 175 <img
··· 4 import { getNotifications, markNotificationsRead } from "../api/client"; 5 import { BellIcon, HeartIcon, ReplyIcon } from "../components/Icons"; 6 7 + function getNotificationRoute(n) { 8 + if (n.type === "reply" && n.subject?.inReplyTo) { 9 + return `/annotation/${encodeURIComponent(n.subject.inReplyTo)}`; 10 + } 11 + if (!n.subjectUri) return "/"; 12 + if (n.subjectUri.includes("at.margin.bookmark")) { 13 return `/bookmarks`; 14 } 15 + if (n.subjectUri.includes("at.margin.highlight")) { 16 return `/highlights`; 17 } 18 + return `/annotation/${encodeURIComponent(n.subjectUri)}`; 19 } 20 21 export default function Notifications() { ··· 166 {notifications.map((n, i) => ( 167 <Link 168 key={n.id || i} 169 + to={getNotificationRoute(n)} 170 className="notification-item card" 171 style={{ alignItems: "center" }} 172 > 173 <div 174 className="notification-avatar-container" 175 + style={{ marginRight: 12, position: "relative" }} 176 > 177 {n.actor?.avatar ? ( 178 <img
+1 -17
web/src/pages/Profile.jsx
··· 130 </div> 131 ); 132 } 133 - return bookmarks.map((b) => <BookmarkCard key={b.id} annotation={b} />); 134 - } 135 - if (activeTab === "bookmarks") { 136 - if (bookmarks.length === 0) { 137 - return ( 138 - <div className="empty-state"> 139 - <div className="empty-state-icon"> 140 - <BookmarkIcon size={32} /> 141 - </div> 142 - <h3 className="empty-state-title">No bookmarks</h3> 143 - <p className="empty-state-text"> 144 - This user hasn't bookmarked any pages. 145 - </p> 146 - </div> 147 - ); 148 - } 149 - return bookmarks.map((b) => <BookmarkCard key={b.id} annotation={b} />); 150 } 151 152 if (activeTab === "collections") {
··· 130 </div> 131 ); 132 } 133 + return bookmarks.map((b) => <BookmarkCard key={b.uri} bookmark={b} />); 134 } 135 136 if (activeTab === "collections") {