diff --git a/backend/internal/api/handler.go b/backend/internal/api/handler.go index f4594f7..72c8306 100644 --- a/backend/internal/api/handler.go +++ b/backend/internal/api/handler.go @@ -66,6 +66,7 @@ func (h *Handler) RegisterRoutes(r chi.Router) { r.Get("/users/{did}/annotations", h.GetUserAnnotations) r.Get("/users/{did}/highlights", h.GetUserHighlights) r.Get("/users/{did}/bookmarks", h.GetUserBookmarks) + r.Get("/users/{did}/targets", h.GetUserTargetItems) r.Get("/replies", h.GetReplies) r.Get("/likes", h.GetLikeCount) @@ -665,6 +666,43 @@ func (h *Handler) GetUserBookmarks(w http.ResponseWriter, r *http.Request) { }) } +func (h *Handler) GetUserTargetItems(w http.ResponseWriter, r *http.Request) { + did := chi.URLParam(r, "did") + if decoded, err := url.QueryUnescape(did); err == nil { + did = decoded + } + + source := r.URL.Query().Get("source") + if source == "" { + source = r.URL.Query().Get("url") + } + if source == "" { + http.Error(w, "source or url parameter required", http.StatusBadRequest) + return + } + + limit := parseIntParam(r, "limit", 50) + offset := parseIntParam(r, "offset", 0) + + urlHash := db.HashURL(source) + + annotations, _ := h.db.GetAnnotationsByAuthorAndTargetHash(did, urlHash, limit, offset) + highlights, _ := h.db.GetHighlightsByAuthorAndTargetHash(did, urlHash, limit, offset) + + enrichedAnnotations, _ := hydrateAnnotations(h.db, annotations, h.getViewerDID(r)) + enrichedHighlights, _ := hydrateHighlights(h.db, highlights, h.getViewerDID(r)) + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "@context": "http://www.w3.org/ns/anno.jsonld", + "creator": did, + "source": source, + "sourceHash": urlHash, + "annotations": enrichedAnnotations, + "highlights": enrichedHighlights, + }) +} + func (h *Handler) GetReplies(w http.ResponseWriter, r *http.Request) { uri := r.URL.Query().Get("uri") if uri == "" { diff --git a/backend/internal/db/queries_annotations.go b/backend/internal/db/queries_annotations.go index c075c8b..ace5c17 100644 --- a/backend/internal/db/queries_annotations.go +++ b/backend/internal/db/queries_annotations.go @@ -146,6 +146,22 @@ func (db *DB) GetAnnotationsByTagAndAuthor(tag, authorDID string, limit, offset return scanAnnotations(rows) } +func (db *DB) GetAnnotationsByAuthorAndTargetHash(authorDID, targetHash string, limit, offset int) ([]Annotation, error) { + 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 target_hash = ? + ORDER BY created_at DESC + LIMIT ? OFFSET ? + `), authorDID, targetHash, limit, offset) + if err != nil { + return nil, err + } + defer rows.Close() + + return scanAnnotations(rows) +} + func (db *DB) GetAnnotationsByURIs(uris []string) ([]Annotation, error) { if len(uris) == 0 { return []Annotation{}, nil diff --git a/backend/internal/db/queries_highlights.go b/backend/internal/db/queries_highlights.go index 91435a8..9ed5cc9 100644 --- a/backend/internal/db/queries_highlights.go +++ b/backend/internal/db/queries_highlights.go @@ -153,6 +153,30 @@ func (db *DB) GetHighlightsByAuthor(authorDID string, limit, offset int) ([]High return highlights, nil } +func (db *DB) GetHighlightsByAuthorAndTargetHash(authorDID, 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 + FROM highlights + WHERE author_did = ? AND target_hash = ? + ORDER BY created_at DESC + LIMIT ? OFFSET ? + `), authorDID, targetHash, 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) DeleteHighlight(uri string) error { _, err := db.Exec(db.Rebind(`DELETE FROM highlights WHERE uri = ?`), uri) return err diff --git a/web/src/App.jsx b/web/src/App.jsx index 3670e73..0034f26 100644 --- a/web/src/App.jsx +++ b/web/src/App.jsx @@ -6,6 +6,7 @@ import RightSidebar from "./components/RightSidebar"; import MobileNav from "./components/MobileNav"; import Feed from "./pages/Feed"; import Url from "./pages/Url"; +import UserUrl from "./pages/UserUrl"; import Profile from "./pages/Profile"; import Login from "./pages/Login"; import New from "./pages/New"; @@ -64,6 +65,7 @@ function AppContent() { path="/:handle/bookmark/:rkey" element={} /> + } /> } /> } /> } /> diff --git a/web/src/api/client.js b/web/src/api/client.js index 8e6fc95..a8db4d8 100644 --- a/web/src/api/client.js +++ b/web/src/api/client.js @@ -75,6 +75,12 @@ export async function getUserBookmarks(did, limit = 50, offset = 0) { ); } +export async function getUserTargetItems(did, url, limit = 50, offset = 0) { + return request( + `${API_BASE}/users/${encodeURIComponent(did)}/targets?source=${encodeURIComponent(url)}&limit=${limit}&offset=${offset}`, + ); +} + export async function getHighlights(creatorDid, limit = 50, offset = 0) { return request( `${API_BASE}/highlights?creator=${encodeURIComponent(creatorDid)}&limit=${limit}&offset=${offset}`, diff --git a/web/src/context/ThemeContext.jsx b/web/src/context/ThemeContext.jsx index 44e1d8f..e2500b4 100644 --- a/web/src/context/ThemeContext.jsx +++ b/web/src/context/ThemeContext.jsx @@ -1,75 +1,75 @@ import { createContext, useContext, useEffect, useState } from "react"; const ThemeContext = createContext({ - theme: "system", - setTheme: () => null, + theme: "system", + setTheme: () => null, }); export function ThemeProvider({ children }) { - const [theme, setTheme] = useState(() => { - return localStorage.getItem("theme") || "system"; - }); + const [theme, setTheme] = useState(() => { + return localStorage.getItem("theme") || "system"; + }); - useEffect(() => { - localStorage.setItem("theme", theme); + useEffect(() => { + localStorage.setItem("theme", theme); - const root = window.document.documentElement; - root.classList.remove("light", "dark"); + const root = window.document.documentElement; + root.classList.remove("light", "dark"); - delete root.dataset.theme; + delete root.dataset.theme; - if (theme === "system") { - const systemTheme = window.matchMedia("(prefers-color-scheme: dark)") - .matches - ? "dark" - : "light"; + if (theme === "system") { + const systemTheme = window.matchMedia("(prefers-color-scheme: dark)") + .matches + ? "dark" + : "light"; - if (systemTheme === "light") { - root.dataset.theme = "light"; - } else { - root.dataset.theme = "dark"; - } - return; - } + if (systemTheme === "light") { + root.dataset.theme = "light"; + } else { + root.dataset.theme = "dark"; + } + return; + } - if (theme === "light") { - root.dataset.theme = "light"; - } - }, [theme]); + if (theme === "light") { + root.dataset.theme = "light"; + } + }, [theme]); - useEffect(() => { - if (theme !== "system") return; + useEffect(() => { + if (theme !== "system") return; - const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)"); - const handleChange = () => { - const root = window.document.documentElement; - if (mediaQuery.matches) { - delete root.dataset.theme; - } else { - root.dataset.theme = "light"; - } - }; + const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)"); + const handleChange = () => { + const root = window.document.documentElement; + if (mediaQuery.matches) { + delete root.dataset.theme; + } else { + root.dataset.theme = "light"; + } + }; - mediaQuery.addEventListener("change", handleChange); - return () => mediaQuery.removeEventListener("change", handleChange); - }, [theme]); + mediaQuery.addEventListener("change", handleChange); + return () => mediaQuery.removeEventListener("change", handleChange); + }, [theme]); - const value = { - theme, - setTheme: (newTheme) => { - setTheme(newTheme); - }, - }; + const value = { + theme, + setTheme: (newTheme) => { + setTheme(newTheme); + }, + }; - return ( - {children} - ); + return ( + {children} + ); } // eslint-disable-next-line react-refresh/only-export-components export function useTheme() { - const context = useContext(ThemeContext); - if (context === undefined) - throw new Error("useTheme must be used within a ThemeProvider"); - return context; + const context = useContext(ThemeContext); + if (context === undefined) + throw new Error("useTheme must be used within a ThemeProvider"); + return context; } diff --git a/web/src/css/feed.css b/web/src/css/feed.css index 2f8738a..262f57c 100644 --- a/web/src/css/feed.css +++ b/web/src/css/feed.css @@ -139,3 +139,70 @@ font-size: 1.5rem; } } + +.user-url-page { + max-width: 800px; +} + +.url-target-info { + display: flex; + flex-direction: column; + gap: 4px; + padding: 16px; + background: var(--bg-secondary); + border: 1px solid var(--border); + border-radius: var(--radius-md); + margin-bottom: 24px; +} + +.url-target-label { + font-size: 0.875rem; + color: var(--text-secondary); +} + +.url-target-link { + color: var(--accent); + font-size: 0.95rem; + word-break: break-all; + text-decoration: none; +} + +.url-target-link:hover { + text-decoration: underline; +} + +.share-notes-banner { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 12px 16px; + background: var(--accent-subtle); + border: 1px solid var(--accent); + border-radius: var(--radius-md); + margin-bottom: 16px; +} + +.share-notes-info { + display: flex; + align-items: center; + gap: 8px; + color: var(--text-primary); + font-size: 0.9rem; +} + +.share-notes-actions { + display: flex; + gap: 8px; +} + +@media (max-width: 640px) { + .share-notes-banner { + flex-direction: column; + align-items: stretch; + } + + .share-notes-actions { + justify-content: flex-end; + } +} diff --git a/web/src/css/login.css b/web/src/css/login.css index c92f85d..aea3a1a 100644 --- a/web/src/css/login.css +++ b/web/src/css/login.css @@ -334,4 +334,4 @@ width: 48px; height: 48px; } -} \ No newline at end of file +} diff --git a/web/src/pages/Url.jsx b/web/src/pages/Url.jsx index befa8cb..c87e1a2 100644 --- a/web/src/pages/Url.jsx +++ b/web/src/pages/Url.jsx @@ -1,9 +1,13 @@ import { useState } from "react"; +import { Link } from "react-router-dom"; import AnnotationCard, { HighlightCard } from "../components/AnnotationCard"; import { getByTarget } from "../api/client"; +import { useAuth } from "../context/AuthContext"; import { PenIcon, AlertIcon, SearchIcon } from "../components/Icons"; +import { Copy, Check, ExternalLink } from "lucide-react"; export default function Url() { + const { user } = useAuth(); const [url, setUrl] = useState(""); const [annotations, setAnnotations] = useState([]); const [highlights, setHighlights] = useState([]); @@ -11,6 +15,7 @@ export default function Url() { const [searched, setSearched] = useState(false); const [error, setError] = useState(null); const [activeTab, setActiveTab] = useState("all"); + const [copied, setCopied] = useState(false); const handleSearch = async (e) => { e.preventDefault(); @@ -30,6 +35,31 @@ export default function Url() { } }; + const myAnnotations = user + ? annotations.filter((a) => (a.creator?.did || a.author?.did) === user.did) + : []; + const myHighlights = user + ? highlights.filter((h) => (h.creator?.did || h.author?.did) === user.did) + : []; + const myItemsCount = myAnnotations.length + myHighlights.length; + + const getShareUrl = () => { + if (!user?.handle || !url) return null; + return `${window.location.origin}/${user.handle}/url/${url}`; + }; + + const handleCopyShareLink = async () => { + const shareUrl = getShareUrl(); + if (!shareUrl) return; + try { + await navigator.clipboard.writeText(shareUrl); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch { + prompt("Copy this link:", shareUrl); + } + }; + const totalItems = annotations.length + highlights.length; const renderResults = () => { @@ -128,6 +158,41 @@ export default function Url() { + + {user && myItemsCount > 0 && ( +
+
+ + + You have {myItemsCount} note{myItemsCount !== 1 ? "s" : ""} on + this page + +
+
+ + View + + +
+
+ )} +
{renderResults()}
)} diff --git a/web/src/pages/UserUrl.jsx b/web/src/pages/UserUrl.jsx new file mode 100644 index 0000000..ff4a6ee --- /dev/null +++ b/web/src/pages/UserUrl.jsx @@ -0,0 +1,235 @@ +import { useState, useEffect } from "react"; +import { useParams } from "react-router-dom"; +import AnnotationCard, { HighlightCard } from "../components/AnnotationCard"; +import { getUserTargetItems } from "../api/client"; +import { + PenIcon, + HighlightIcon, + SearchIcon, + BlueskyIcon, +} from "../components/Icons"; + +export default function UserUrl() { + const { handle, "*": urlPath } = useParams(); + const targetUrl = urlPath || ""; + + const [profile, setProfile] = useState(null); + const [annotations, setAnnotations] = useState([]); + const [highlights, setHighlights] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [activeTab, setActiveTab] = useState("all"); + + useEffect(() => { + async function fetchData() { + if (!targetUrl) { + setLoading(false); + return; + } + + try { + setLoading(true); + setError(null); + + const profileRes = await fetch( + `https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(handle)}`, + ); + let did = handle; + if (profileRes.ok) { + const profileData = await profileRes.json(); + setProfile(profileData); + did = profileData.did; + } + + const data = await getUserTargetItems(did, targetUrl); + setAnnotations(data.annotations || []); + setHighlights(data.highlights || []); + } catch (err) { + setError(err.message); + } finally { + setLoading(false); + } + } + fetchData(); + }, [handle, targetUrl]); + + const displayName = profile?.displayName || profile?.handle || handle; + const displayHandle = + profile?.handle || (handle?.startsWith("did:") ? null : handle); + const avatarUrl = profile?.avatar; + + const getInitial = () => { + return (displayName || displayHandle || "??") + ?.substring(0, 2) + .toUpperCase(); + }; + + const totalItems = annotations.length + highlights.length; + const bskyProfileUrl = displayHandle + ? `https://bsky.app/profile/${displayHandle}` + : `https://bsky.app/profile/${handle}`; + + const renderResults = () => { + if (activeTab === "annotations" && annotations.length === 0) { + return ( +
+
+ +
+

No annotations

+
+ ); + } + + if (activeTab === "highlights" && highlights.length === 0) { + return ( +
+
+ +
+

No highlights

+
+ ); + } + + return ( + <> + {(activeTab === "all" || activeTab === "annotations") && + annotations.map((a) => )} + {(activeTab === "all" || activeTab === "highlights") && + highlights.map((h) => )} + + ); + }; + + if (!targetUrl) { + return ( +
+
+
+ +
+

No URL specified

+

+ Please provide a URL to view annotations. +

+
+
+ ); + } + + return ( +
+
+ +
+ {avatarUrl ? ( + {displayName} + ) : ( + {getInitial()} + )} +
+
+
+

{displayName}

+ {displayHandle && ( + + @{displayHandle} + + )} +
+
+ +
+ Annotations on: + + {targetUrl} + +
+ + {loading && ( +
+ {[1, 2, 3].map((i) => ( +
+
+
+
+
+ ))} +
+ )} + + {error && ( +
+
⚠️
+

Error

+

{error}

+
+ )} + + {!loading && !error && totalItems === 0 && ( +
+
+ +
+

No items found

+

+ {displayName} hasn't annotated this page yet. +

+
+ )} + + {!loading && !error && totalItems > 0 && ( + <> +
+

+ {totalItems} item{totalItems !== 1 ? "s" : ""} +

+
+ + + +
+
+
{renderResults()}
+ + )} +
+ ); +}