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