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 (
+
+ );
+ }
+
+ if (activeTab === "highlights" && highlights.length === 0) {
+ return (
+
+ );
+ }
+
+ 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 (
+
+
+
+
+
+ {loading && (
+
+ {[1, 2, 3].map((i) => (
+
+ ))}
+
+ )}
+
+ {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()}
+ >
+ )}
+
+ );
+}