diff --git a/backend/internal/api/annotations.go b/backend/internal/api/annotations.go index b9e618e..8806b73 100644 --- a/backend/internal/api/annotations.go +++ b/backend/internal/api/annotations.go @@ -47,8 +47,13 @@ func (s *AnnotationService) CreateAnnotation(w http.ResponseWriter, r *http.Requ return } - if req.URL == "" || req.Text == "" { - http.Error(w, "URL and text are required", http.StatusBadRequest) + if req.URL == "" { + http.Error(w, "URL is required", http.StatusBadRequest) + return + } + + if req.Text == "" && req.Selector == nil && len(req.Tags) == 0 { + http.Error(w, "Must provide text, selector, or tags", http.StatusBadRequest) return } @@ -498,6 +503,7 @@ type CreateHighlightRequest struct { Title string `json:"title,omitempty"` Selector interface{} `json:"selector"` Color string `json:"color,omitempty"` + Tags []string `json:"tags,omitempty"` } func (s *AnnotationService) CreateHighlight(w http.ResponseWriter, r *http.Request) { @@ -519,7 +525,7 @@ func (s *AnnotationService) CreateHighlight(w http.ResponseWriter, r *http.Reque } urlHash := db.HashURL(req.URL) - record := xrpc.NewHighlightRecord(req.URL, urlHash, req.Selector, req.Color) + record := xrpc.NewHighlightRecord(req.URL, urlHash, req.Selector, req.Color, req.Tags) var result *xrpc.CreateRecordOutput err = s.refresher.ExecuteWithAutoRefresh(r, session, func(client *xrpc.Client, did string) error { @@ -549,6 +555,13 @@ func (s *AnnotationService) CreateHighlight(w http.ResponseWriter, r *http.Reque colorPtr = &req.Color } + var tagsJSONPtr *string + if len(req.Tags) > 0 { + tagsBytes, _ := json.Marshal(req.Tags) + tagsStr := string(tagsBytes) + tagsJSONPtr = &tagsStr + } + cid := result.CID highlight := &db.Highlight{ URI: result.URI, @@ -558,6 +571,7 @@ func (s *AnnotationService) CreateHighlight(w http.ResponseWriter, r *http.Reque TargetTitle: titlePtr, SelectorJSON: selectorJSONPtr, Color: colorPtr, + TagsJSON: tagsJSONPtr, CreatedAt: time.Now(), IndexedAt: time.Now(), CID: &cid, diff --git a/backend/internal/api/handler.go b/backend/internal/api/handler.go index 8b60773..8cbad30 100644 --- a/backend/internal/api/handler.go +++ b/backend/internal/api/handler.go @@ -81,6 +81,7 @@ func (h *Handler) GetAnnotations(w http.ResponseWriter, r *http.Request) { limit := parseIntParam(r, "limit", 50) offset := parseIntParam(r, "offset", 0) motivation := r.URL.Query().Get("motivation") + tag := r.URL.Query().Get("tag") var annotations []db.Annotation var err error @@ -90,6 +91,8 @@ func (h *Handler) GetAnnotations(w http.ResponseWriter, r *http.Request) { annotations, err = h.db.GetAnnotationsByTargetHash(urlHash, limit, offset) } else if motivation != "" { annotations, err = h.db.GetAnnotationsByMotivation(motivation, limit, offset) + } else if tag != "" { + annotations, err = h.db.GetAnnotationsByTag(tag, limit, offset) } else { annotations, err = h.db.GetRecentAnnotations(limit, offset) } @@ -112,22 +115,42 @@ func (h *Handler) GetAnnotations(w http.ResponseWriter, r *http.Request) { func (h *Handler) GetFeed(w http.ResponseWriter, r *http.Request) { limit := parseIntParam(r, "limit", 50) + tag := r.URL.Query().Get("tag") + creator := r.URL.Query().Get("creator") - annotations, _ := h.db.GetRecentAnnotations(limit, 0) - highlights, _ := h.db.GetRecentHighlights(limit, 0) - bookmarks, _ := h.db.GetRecentBookmarks(limit, 0) + var annotations []db.Annotation + var highlights []db.Highlight + var bookmarks []db.Bookmark + var collectionItems []db.CollectionItem + var err error + + if tag != "" { + if creator != "" { + annotations, _ = h.db.GetAnnotationsByTagAndAuthor(tag, creator, limit, 0) + highlights, _ = h.db.GetHighlightsByTagAndAuthor(tag, creator, limit, 0) + bookmarks, _ = h.db.GetBookmarksByTagAndAuthor(tag, creator, limit, 0) + collectionItems = []db.CollectionItem{} + } else { + annotations, _ = h.db.GetAnnotationsByTag(tag, limit, 0) + highlights, _ = h.db.GetHighlightsByTag(tag, limit, 0) + bookmarks, _ = h.db.GetBookmarksByTag(tag, limit, 0) + collectionItems = []db.CollectionItem{} + } + } else { + annotations, _ = h.db.GetRecentAnnotations(limit, 0) + highlights, _ = h.db.GetRecentHighlights(limit, 0) + bookmarks, _ = h.db.GetRecentBookmarks(limit, 0) + collectionItems, err = h.db.GetRecentCollectionItems(limit, 0) + if err != nil { + log.Printf("Error fetching collection items: %v\n", err) + } + } authAnnos, _ := hydrateAnnotations(annotations) authHighs, _ := hydrateHighlights(highlights) authBooks, _ := hydrateBookmarks(bookmarks) - collectionItems, err := h.db.GetRecentCollectionItems(limit, 0) - if err != nil { - log.Printf("Error fetching collection items: %v\n", err) - } - // log.Printf("Fetched %d collection items\n", len(collectionItems)) authCollectionItems, _ := hydrateCollectionItems(h.db, collectionItems) - // log.Printf("Hydrated %d collection items\n", len(authCollectionItems)) var feed []interface{} for _, a := range authAnnos { @@ -276,15 +299,21 @@ func (h *Handler) GetByTarget(w http.ResponseWriter, r *http.Request) { func (h *Handler) GetHighlights(w http.ResponseWriter, r *http.Request) { did := r.URL.Query().Get("creator") + tag := r.URL.Query().Get("tag") limit := parseIntParam(r, "limit", 50) offset := parseIntParam(r, "offset", 0) - if did == "" { - http.Error(w, "creator parameter required", http.StatusBadRequest) - return + var highlights []db.Highlight + var err error + + if did != "" { + highlights, err = h.db.GetHighlightsByAuthor(did, limit, offset) + } else if tag != "" { + highlights, err = h.db.GetHighlightsByTag(tag, limit, offset) + } else { + highlights, err = h.db.GetRecentHighlights(limit, offset) } - highlights, err := h.db.GetHighlightsByAuthor(did, limit, offset) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return diff --git a/backend/internal/db/queries.go b/backend/internal/db/queries.go index b951995..8591be6 100644 --- a/backend/internal/db/queries.go +++ b/backend/internal/db/queries.go @@ -104,6 +104,23 @@ func (db *DB) GetRecentAnnotations(limit, offset int) ([]Annotation, error) { return scanAnnotations(rows) } +func (db *DB) GetAnnotationsByTag(tag string, limit, offset int) ([]Annotation, error) { + pattern := "%\"" + tag + "\"%" + rows, err := db.Query(db.Rebind(` + 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 + FROM annotations + WHERE tags_json LIKE ? + ORDER BY created_at DESC + LIMIT ? OFFSET ? + `), pattern, limit, offset) + if err != nil { + return nil, err + } + defer rows.Close() + + return scanAnnotations(rows) +} + func (db *DB) DeleteAnnotation(uri string) error { _, err := db.Exec(db.Rebind(`DELETE FROM annotations WHERE uri = ?`), uri) return err @@ -242,6 +259,31 @@ func (db *DB) GetRecentHighlights(limit, offset int) ([]Highlight, error) { return highlights, nil } +func (db *DB) GetHighlightsByTag(tag string, limit, offset int) ([]Highlight, error) { + pattern := "%\"" + tag + "\"%" + rows, err := db.Query(db.Rebind(` + SELECT uri, author_did, target_source, target_hash, target_title, selector_json, color, tags_json, created_at, indexed_at, cid + FROM highlights + WHERE tags_json LIKE ? + ORDER BY created_at DESC + LIMIT ? OFFSET ? + `), pattern, limit, offset) + if err != nil { + return nil, err + } + defer rows.Close() + + var highlights []Highlight + for rows.Next() { + var h Highlight + 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 { + return nil, err + } + highlights = append(highlights, h) + } + return highlights, nil +} + func (db *DB) GetRecentBookmarks(limit, offset int) ([]Bookmark, error) { rows, err := db.Query(db.Rebind(` SELECT uri, author_did, source, source_hash, title, description, tags_json, created_at, indexed_at, cid @@ -265,6 +307,98 @@ func (db *DB) GetRecentBookmarks(limit, offset int) ([]Bookmark, error) { return bookmarks, nil } +func (db *DB) GetBookmarksByTag(tag string, limit, offset int) ([]Bookmark, error) { + pattern := "%\"" + tag + "\"%" + rows, err := db.Query(db.Rebind(` + SELECT uri, author_did, source, source_hash, title, description, tags_json, created_at, indexed_at, cid + FROM bookmarks + WHERE tags_json LIKE ? + ORDER BY created_at DESC + LIMIT ? OFFSET ? + `), pattern, limit, offset) + if err != nil { + return nil, err + } + defer rows.Close() + + var bookmarks []Bookmark + for rows.Next() { + var b Bookmark + 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 { + return nil, err + } + bookmarks = append(bookmarks, b) + } + return bookmarks, nil +} + +func (db *DB) GetAnnotationsByTagAndAuthor(tag, authorDID string, limit, offset int) ([]Annotation, error) { + pattern := "%\"" + tag + "\"%" + rows, err := db.Query(db.Rebind(` + 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 + FROM annotations + WHERE author_did = ? AND tags_json LIKE ? + ORDER BY created_at DESC + LIMIT ? OFFSET ? + `), authorDID, pattern, limit, offset) + if err != nil { + return nil, err + } + defer rows.Close() + + return scanAnnotations(rows) +} + +func (db *DB) GetHighlightsByTagAndAuthor(tag, authorDID string, limit, offset int) ([]Highlight, error) { + pattern := "%\"" + tag + "\"%" + rows, err := db.Query(db.Rebind(` + SELECT uri, author_did, target_source, target_hash, target_title, selector_json, color, tags_json, created_at, indexed_at, cid + FROM highlights + WHERE author_did = ? AND tags_json LIKE ? + ORDER BY created_at DESC + LIMIT ? OFFSET ? + `), authorDID, pattern, limit, offset) + if err != nil { + return nil, err + } + defer rows.Close() + + var highlights []Highlight + for rows.Next() { + var h Highlight + 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 { + return nil, err + } + highlights = append(highlights, h) + } + return highlights, nil +} + +func (db *DB) GetBookmarksByTagAndAuthor(tag, authorDID string, limit, offset int) ([]Bookmark, error) { + pattern := "%\"" + tag + "\"%" + rows, err := db.Query(db.Rebind(` + SELECT uri, author_did, source, source_hash, title, description, tags_json, created_at, indexed_at, cid + FROM bookmarks + WHERE author_did = ? AND tags_json LIKE ? + ORDER BY created_at DESC + LIMIT ? OFFSET ? + `), authorDID, pattern, limit, offset) + if err != nil { + return nil, err + } + defer rows.Close() + + var bookmarks []Bookmark + for rows.Next() { + var b Bookmark + 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 { + return nil, err + } + bookmarks = append(bookmarks, b) + } + return bookmarks, nil +} + func (db *DB) GetHighlightsByTargetHash(targetHash string, limit, offset int) ([]Highlight, error) { rows, err := db.Query(db.Rebind(` SELECT uri, author_did, target_source, target_hash, target_title, selector_json, color, tags_json, created_at, indexed_at, cid diff --git a/backend/internal/oauth/handler.go b/backend/internal/oauth/handler.go index b9e629b..34f8971 100644 --- a/backend/internal/oauth/handler.go +++ b/backend/internal/oauth/handler.go @@ -244,6 +244,7 @@ func (h *Handler) HandleStart(w http.ResponseWriter, r *http.Request) { parResp, state, dpopNonce, err := client.SendPAR(meta, req.Handle, scope, dpopKey, pkceChallenge) if err != nil { + log.Printf("PAR request failed: %v", err) w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusInternalServerError) json.NewEncoder(w).Encode(map[string]string{"error": "Failed to initiate authentication"}) diff --git a/backend/internal/xrpc/records.go b/backend/internal/xrpc/records.go index 41dfa7c..4fe1d9a 100644 --- a/backend/internal/xrpc/records.go +++ b/backend/internal/xrpc/records.go @@ -78,7 +78,7 @@ type HighlightRecord struct { CreatedAt string `json:"createdAt"` } -func NewHighlightRecord(url, urlHash string, selector interface{}, color string) *HighlightRecord { +func NewHighlightRecord(url, urlHash string, selector interface{}, color string, tags []string) *HighlightRecord { return &HighlightRecord{ Type: CollectionHighlight, Target: AnnotationTarget{ @@ -87,6 +87,7 @@ func NewHighlightRecord(url, urlHash string, selector interface{}, color string) Selector: selector, }, Color: color, + Tags: tags, CreatedAt: time.Now().UTC().Format(time.RFC3339), } } diff --git a/web/src/api/client.js b/web/src/api/client.js index 5761533..7cd9e3b 100644 --- a/web/src/api/client.js +++ b/web/src/api/client.js @@ -23,10 +23,16 @@ export async function getURLMetadata(url) { return request(`${API_BASE}/url-metadata?url=${encodeURIComponent(url)}`); } -export async function getAnnotationFeed(limit = 50, offset = 0) { - return request( - `${API_BASE}/annotations/feed?limit=${limit}&offset=${offset}`, - ); +export async function getAnnotationFeed( + limit = 50, + offset = 0, + tag = "", + creator = "", +) { + let url = `${API_BASE}/annotations/feed?limit=${limit}&offset=${offset}`; + if (tag) url += `&tag=${encodeURIComponent(tag)}`; + if (creator) url += `&creator=${encodeURIComponent(creator)}`; + return request(url); } export async function getAnnotations({ @@ -210,10 +216,24 @@ export async function deleteBookmark(rkey) { }); } -export async function createAnnotation({ url, text, quote, title, selector }) { +export async function createHighlight({ url, title, selector, color, tags }) { + return request(`${API_BASE}/highlights`, { + method: "POST", + body: JSON.stringify({ url, title, selector, color, tags }), + }); +} + +export async function createAnnotation({ + url, + text, + quote, + title, + selector, + tags, +}) { return request(`${API_BASE}/annotations`, { method: "POST", - body: JSON.stringify({ url, text, quote, title, selector }), + body: JSON.stringify({ url, text, quote, title, selector, tags }), }); } diff --git a/web/src/components/AddToCollectionModal.jsx b/web/src/components/AddToCollectionModal.jsx index b6d86a9..8f02d7a 100644 --- a/web/src/components/AddToCollectionModal.jsx +++ b/web/src/components/AddToCollectionModal.jsx @@ -23,10 +23,14 @@ export default function AddToCollectionModal({ useEffect(() => { if (isOpen && user) { + if (!annotationUri) { + setLoading(false); + return; + } loadCollections(); setError(null); } - }, [isOpen, user]); + }, [isOpen, user, annotationUri]); const loadCollections = async () => { try { @@ -71,7 +75,7 @@ export default function AddToCollectionModal({ className="modal-container" style={{ maxWidth: "380px", - maxHeight: "80vh", + maxHeight: "80dvh", display: "flex", flexDirection: "column", }} diff --git a/web/src/components/AnnotationCard.jsx b/web/src/components/AnnotationCard.jsx index a84e1c9..36a1616 100644 --- a/web/src/components/AnnotationCard.jsx +++ b/web/src/components/AnnotationCard.jsx @@ -27,7 +27,6 @@ import { BookmarkIcon, } from "./Icons"; import { Folder, Edit2, Save, X, Clock } from "lucide-react"; -import AddToCollectionModal from "./AddToCollectionModal"; import ShareMenu from "./ShareMenu"; function buildTextFragmentUrl(baseUrl, selector) { @@ -60,16 +59,20 @@ const truncateUrl = (url, maxLength = 60) => { } }; -export default function AnnotationCard({ annotation, onDelete }) { +export default function AnnotationCard({ + annotation, + onDelete, + onAddToCollection, +}) { const { user, login } = useAuth(); const data = normalizeAnnotation(annotation); const [likeCount, setLikeCount] = useState(0); const [isLiked, setIsLiked] = useState(false); const [deleting, setDeleting] = useState(false); - const [showAddToCollection, setShowAddToCollection] = useState(false); const [isEditing, setIsEditing] = useState(false); const [editText, setEditText] = useState(data.text || ""); + const [editTags, setEditTags] = useState(data.tags?.join(", ") || ""); const [saving, setSaving] = useState(false); const [showHistory, setShowHistory] = useState(false); @@ -182,10 +185,16 @@ export default function AnnotationCard({ annotation, onDelete }) { const handleSaveEdit = async () => { try { setSaving(true); - await updateAnnotation(data.uri, editText, data.tags); + const tagList = editTags + .split(",") + .map((t) => t.trim()) + .filter(Boolean); + await updateAnnotation(data.uri, editText, tagList); setIsEditing(false); if (annotation.body) annotation.body.value = editText; else if (annotation.text) annotation.text = editText; + if (annotation.tags) annotation.tags = tagList; + data.tags = tagList; } catch (err) { alert("Failed to update: " + err.message); } finally { @@ -288,78 +297,79 @@ export default function AnnotationCard({ annotation, onDelete }) { return (
- -
- {authorAvatar ? ( - {authorDisplayName} - ) : ( - - {(authorDisplayName || authorHandle || "??") - ?.substring(0, 2) - .toUpperCase()} - - )} -
- -
-
- - {authorDisplayName} - - {authorHandle && ( - + +
+ {authorAvatar ? ( + {authorDisplayName} + ) : ( + + {(authorDisplayName || authorHandle || "??") + ?.substring(0, 2) + .toUpperCase()} + + )} +
+ +
+ -
{formatDate(data.createdAt)}
-
-
- {} - {hasEditHistory && !data.color && !data.description && ( - - )} - {} - {isOwner && ( - <> - {!data.color && !data.description && ( - + @{authorHandle} + )} +
+
{formatDate(data.createdAt)}
+
+
+
+
+ {hasEditHistory && !data.color && !data.description && ( - - )} + )} + + {isOwner && ( + <> + {!data.color && !data.description && ( + + )} + + + )} +
- {} - {} {showHistory && (
@@ -391,108 +401,127 @@ export default function AnnotationCard({ annotation, onDelete }) {
)} - - {truncateUrl(data.url)} - {data.title && ( - • {data.title} - )} - - - {highlightedText && ( +
- "{highlightedText}" + {truncateUrl(data.url)} + {data.title && ( + • {data.title} + )} - )} - {isEditing ? ( -
-