tangled
alpha
login
or
join now
margin.at
/
margin
86
fork
atom
Write on the margins of the internet. Powered by the AT Protocol.
margin.at
extension
web
atproto
comments
86
fork
atom
overview
issues
4
pulls
1
pipelines
Implement tags and better card styles
scanash.com
2 months ago
8444ea3f
c8d7c372
+1157
-510
13 changed files
expand all
collapse all
unified
split
backend
internal
api
annotations.go
handler.go
db
queries.go
oauth
handler.go
xrpc
records.go
web
src
api
client.js
components
AddToCollectionModal.jsx
AnnotationCard.jsx
BookmarkCard.jsx
Composer.jsx
ShareMenu.jsx
index.css
pages
Feed.jsx
+17
-3
backend/internal/api/annotations.go
···
47
47
return
48
48
}
49
49
50
50
-
if req.URL == "" || req.Text == "" {
51
51
-
http.Error(w, "URL and text are required", http.StatusBadRequest)
50
50
+
if req.URL == "" {
51
51
+
http.Error(w, "URL is required", http.StatusBadRequest)
52
52
+
return
53
53
+
}
54
54
+
55
55
+
if req.Text == "" && req.Selector == nil && len(req.Tags) == 0 {
56
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
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
522
-
record := xrpc.NewHighlightRecord(req.URL, urlHash, req.Selector, req.Color)
528
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
558
+
var tagsJSONPtr *string
559
559
+
if len(req.Tags) > 0 {
560
560
+
tagsBytes, _ := json.Marshal(req.Tags)
561
561
+
tagsStr := string(tagsBytes)
562
562
+
tagsJSONPtr = &tagsStr
563
563
+
}
564
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
574
+
TagsJSON: tagsJSONPtr,
561
575
CreatedAt: time.Now(),
562
576
IndexedAt: time.Now(),
563
577
CID: &cid,
+42
-13
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
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
94
+
} else if tag != "" {
95
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
118
+
tag := r.URL.Query().Get("tag")
119
119
+
creator := r.URL.Query().Get("creator")
120
120
+
121
121
+
var annotations []db.Annotation
122
122
+
var highlights []db.Highlight
123
123
+
var bookmarks []db.Bookmark
124
124
+
var collectionItems []db.CollectionItem
125
125
+
var err error
115
126
116
116
-
annotations, _ := h.db.GetRecentAnnotations(limit, 0)
117
117
-
highlights, _ := h.db.GetRecentHighlights(limit, 0)
118
118
-
bookmarks, _ := h.db.GetRecentBookmarks(limit, 0)
127
127
+
if tag != "" {
128
128
+
if creator != "" {
129
129
+
annotations, _ = h.db.GetAnnotationsByTagAndAuthor(tag, creator, limit, 0)
130
130
+
highlights, _ = h.db.GetHighlightsByTagAndAuthor(tag, creator, limit, 0)
131
131
+
bookmarks, _ = h.db.GetBookmarksByTagAndAuthor(tag, creator, limit, 0)
132
132
+
collectionItems = []db.CollectionItem{}
133
133
+
} else {
134
134
+
annotations, _ = h.db.GetAnnotationsByTag(tag, limit, 0)
135
135
+
highlights, _ = h.db.GetHighlightsByTag(tag, limit, 0)
136
136
+
bookmarks, _ = h.db.GetBookmarksByTag(tag, limit, 0)
137
137
+
collectionItems = []db.CollectionItem{}
138
138
+
}
139
139
+
} else {
140
140
+
annotations, _ = h.db.GetRecentAnnotations(limit, 0)
141
141
+
highlights, _ = h.db.GetRecentHighlights(limit, 0)
142
142
+
bookmarks, _ = h.db.GetRecentBookmarks(limit, 0)
143
143
+
collectionItems, err = h.db.GetRecentCollectionItems(limit, 0)
144
144
+
if err != nil {
145
145
+
log.Printf("Error fetching collection items: %v\n", err)
146
146
+
}
147
147
+
}
119
148
120
149
authAnnos, _ := hydrateAnnotations(annotations)
121
150
authHighs, _ := hydrateHighlights(highlights)
122
151
authBooks, _ := hydrateBookmarks(bookmarks)
123
152
124
124
-
collectionItems, err := h.db.GetRecentCollectionItems(limit, 0)
125
125
-
if err != nil {
126
126
-
log.Printf("Error fetching collection items: %v\n", err)
127
127
-
}
128
128
-
// log.Printf("Fetched %d collection items\n", len(collectionItems))
129
153
authCollectionItems, _ := hydrateCollectionItems(h.db, collectionItems)
130
130
-
// log.Printf("Hydrated %d collection items\n", len(authCollectionItems))
131
154
132
155
var feed []interface{}
133
156
for _, a := range authAnnos {
···
276
299
277
300
func (h *Handler) GetHighlights(w http.ResponseWriter, r *http.Request) {
278
301
did := r.URL.Query().Get("creator")
302
302
+
tag := r.URL.Query().Get("tag")
279
303
limit := parseIntParam(r, "limit", 50)
280
304
offset := parseIntParam(r, "offset", 0)
281
305
282
282
-
if did == "" {
283
283
-
http.Error(w, "creator parameter required", http.StatusBadRequest)
284
284
-
return
306
306
+
var highlights []db.Highlight
307
307
+
var err error
308
308
+
309
309
+
if did != "" {
310
310
+
highlights, err = h.db.GetHighlightsByAuthor(did, limit, offset)
311
311
+
} else if tag != "" {
312
312
+
highlights, err = h.db.GetHighlightsByTag(tag, limit, offset)
313
313
+
} else {
314
314
+
highlights, err = h.db.GetRecentHighlights(limit, offset)
285
315
}
286
316
287
287
-
highlights, err := h.db.GetHighlightsByAuthor(did, limit, offset)
288
317
if err != nil {
289
318
http.Error(w, err.Error(), http.StatusInternalServerError)
290
319
return
+134
backend/internal/db/queries.go
···
104
104
return scanAnnotations(rows)
105
105
}
106
106
107
107
+
func (db *DB) GetAnnotationsByTag(tag string, limit, offset int) ([]Annotation, error) {
108
108
+
pattern := "%\"" + tag + "\"%"
109
109
+
rows, err := db.Query(db.Rebind(`
110
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
111
+
FROM annotations
112
112
+
WHERE tags_json LIKE ?
113
113
+
ORDER BY created_at DESC
114
114
+
LIMIT ? OFFSET ?
115
115
+
`), pattern, limit, offset)
116
116
+
if err != nil {
117
117
+
return nil, err
118
118
+
}
119
119
+
defer rows.Close()
120
120
+
121
121
+
return scanAnnotations(rows)
122
122
+
}
123
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
262
+
func (db *DB) GetHighlightsByTag(tag string, limit, offset int) ([]Highlight, error) {
263
263
+
pattern := "%\"" + tag + "\"%"
264
264
+
rows, err := db.Query(db.Rebind(`
265
265
+
SELECT uri, author_did, target_source, target_hash, target_title, selector_json, color, tags_json, created_at, indexed_at, cid
266
266
+
FROM highlights
267
267
+
WHERE tags_json LIKE ?
268
268
+
ORDER BY created_at DESC
269
269
+
LIMIT ? OFFSET ?
270
270
+
`), pattern, limit, offset)
271
271
+
if err != nil {
272
272
+
return nil, err
273
273
+
}
274
274
+
defer rows.Close()
275
275
+
276
276
+
var highlights []Highlight
277
277
+
for rows.Next() {
278
278
+
var h Highlight
279
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
280
+
return nil, err
281
281
+
}
282
282
+
highlights = append(highlights, h)
283
283
+
}
284
284
+
return highlights, nil
285
285
+
}
286
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
294
+
if err != nil {
295
295
+
return nil, err
296
296
+
}
297
297
+
defer rows.Close()
298
298
+
299
299
+
var bookmarks []Bookmark
300
300
+
for rows.Next() {
301
301
+
var b Bookmark
302
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
303
+
return nil, err
304
304
+
}
305
305
+
bookmarks = append(bookmarks, b)
306
306
+
}
307
307
+
return bookmarks, nil
308
308
+
}
309
309
+
310
310
+
func (db *DB) GetBookmarksByTag(tag string, limit, offset int) ([]Bookmark, error) {
311
311
+
pattern := "%\"" + tag + "\"%"
312
312
+
rows, err := db.Query(db.Rebind(`
313
313
+
SELECT uri, author_did, source, source_hash, title, description, tags_json, created_at, indexed_at, cid
314
314
+
FROM bookmarks
315
315
+
WHERE tags_json LIKE ?
316
316
+
ORDER BY created_at DESC
317
317
+
LIMIT ? OFFSET ?
318
318
+
`), pattern, limit, offset)
319
319
+
if err != nil {
320
320
+
return nil, err
321
321
+
}
322
322
+
defer rows.Close()
323
323
+
324
324
+
var bookmarks []Bookmark
325
325
+
for rows.Next() {
326
326
+
var b Bookmark
327
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
328
+
return nil, err
329
329
+
}
330
330
+
bookmarks = append(bookmarks, b)
331
331
+
}
332
332
+
return bookmarks, nil
333
333
+
}
334
334
+
335
335
+
func (db *DB) GetAnnotationsByTagAndAuthor(tag, authorDID string, limit, offset int) ([]Annotation, error) {
336
336
+
pattern := "%\"" + tag + "\"%"
337
337
+
rows, err := db.Query(db.Rebind(`
338
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
339
+
FROM annotations
340
340
+
WHERE author_did = ? AND tags_json LIKE ?
341
341
+
ORDER BY created_at DESC
342
342
+
LIMIT ? OFFSET ?
343
343
+
`), authorDID, pattern, limit, offset)
344
344
+
if err != nil {
345
345
+
return nil, err
346
346
+
}
347
347
+
defer rows.Close()
348
348
+
349
349
+
return scanAnnotations(rows)
350
350
+
}
351
351
+
352
352
+
func (db *DB) GetHighlightsByTagAndAuthor(tag, authorDID string, limit, offset int) ([]Highlight, error) {
353
353
+
pattern := "%\"" + tag + "\"%"
354
354
+
rows, err := db.Query(db.Rebind(`
355
355
+
SELECT uri, author_did, target_source, target_hash, target_title, selector_json, color, tags_json, created_at, indexed_at, cid
356
356
+
FROM highlights
357
357
+
WHERE author_did = ? AND tags_json LIKE ?
358
358
+
ORDER BY created_at DESC
359
359
+
LIMIT ? OFFSET ?
360
360
+
`), authorDID, pattern, limit, offset)
361
361
+
if err != nil {
362
362
+
return nil, err
363
363
+
}
364
364
+
defer rows.Close()
365
365
+
366
366
+
var highlights []Highlight
367
367
+
for rows.Next() {
368
368
+
var h Highlight
369
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
370
+
return nil, err
371
371
+
}
372
372
+
highlights = append(highlights, h)
373
373
+
}
374
374
+
return highlights, nil
375
375
+
}
376
376
+
377
377
+
func (db *DB) GetBookmarksByTagAndAuthor(tag, authorDID string, limit, offset int) ([]Bookmark, error) {
378
378
+
pattern := "%\"" + tag + "\"%"
379
379
+
rows, err := db.Query(db.Rebind(`
380
380
+
SELECT uri, author_did, source, source_hash, title, description, tags_json, created_at, indexed_at, cid
381
381
+
FROM bookmarks
382
382
+
WHERE author_did = ? AND tags_json LIKE ?
383
383
+
ORDER BY created_at DESC
384
384
+
LIMIT ? OFFSET ?
385
385
+
`), authorDID, pattern, limit, offset)
252
386
if err != nil {
253
387
return nil, err
254
388
}
+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
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
81
-
func NewHighlightRecord(url, urlHash string, selector interface{}, color string) *HighlightRecord {
81
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
90
+
Tags: tags,
90
91
CreatedAt: time.Now().UTC().Format(time.RFC3339),
91
92
}
92
93
}
+26
-6
web/src/api/client.js
···
23
23
return request(`${API_BASE}/url-metadata?url=${encodeURIComponent(url)}`);
24
24
}
25
25
26
26
-
export async function getAnnotationFeed(limit = 50, offset = 0) {
27
27
-
return request(
28
28
-
`${API_BASE}/annotations/feed?limit=${limit}&offset=${offset}`,
29
29
-
);
26
26
+
export async function getAnnotationFeed(
27
27
+
limit = 50,
28
28
+
offset = 0,
29
29
+
tag = "",
30
30
+
creator = "",
31
31
+
) {
32
32
+
let url = `${API_BASE}/annotations/feed?limit=${limit}&offset=${offset}`;
33
33
+
if (tag) url += `&tag=${encodeURIComponent(tag)}`;
34
34
+
if (creator) url += `&creator=${encodeURIComponent(creator)}`;
35
35
+
return request(url);
30
36
}
31
37
32
38
export async function getAnnotations({
···
210
216
});
211
217
}
212
218
213
213
-
export async function createAnnotation({ url, text, quote, title, selector }) {
219
219
+
export async function createHighlight({ url, title, selector, color, tags }) {
220
220
+
return request(`${API_BASE}/highlights`, {
221
221
+
method: "POST",
222
222
+
body: JSON.stringify({ url, title, selector, color, tags }),
223
223
+
});
224
224
+
}
225
225
+
226
226
+
export async function createAnnotation({
227
227
+
url,
228
228
+
text,
229
229
+
quote,
230
230
+
title,
231
231
+
selector,
232
232
+
tags,
233
233
+
}) {
214
234
return request(`${API_BASE}/annotations`, {
215
235
method: "POST",
216
216
-
body: JSON.stringify({ url, text, quote, title, selector }),
236
236
+
body: JSON.stringify({ url, text, quote, title, selector, tags }),
217
237
});
218
238
}
219
239
+6
-2
web/src/components/AddToCollectionModal.jsx
···
23
23
24
24
useEffect(() => {
25
25
if (isOpen && user) {
26
26
+
if (!annotationUri) {
27
27
+
setLoading(false);
28
28
+
return;
29
29
+
}
26
30
loadCollections();
27
31
setError(null);
28
32
}
29
29
-
}, [isOpen, user]);
33
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
74
-
maxHeight: "80vh",
78
78
+
maxHeight: "80dvh",
75
79
display: "flex",
76
80
flexDirection: "column",
77
81
}}
+386
-276
web/src/components/AnnotationCard.jsx
···
27
27
BookmarkIcon,
28
28
} from "./Icons";
29
29
import { Folder, Edit2, Save, X, Clock } from "lucide-react";
30
30
-
import AddToCollectionModal from "./AddToCollectionModal";
31
30
import ShareMenu from "./ShareMenu";
32
31
33
32
function buildTextFragmentUrl(baseUrl, selector) {
···
60
59
}
61
60
};
62
61
63
63
-
export default function AnnotationCard({ annotation, onDelete }) {
62
62
+
export default function AnnotationCard({
63
63
+
annotation,
64
64
+
onDelete,
65
65
+
onAddToCollection,
66
66
+
}) {
64
67
const { user, login } = useAuth();
65
68
const data = normalizeAnnotation(annotation);
66
69
67
70
const [likeCount, setLikeCount] = useState(0);
68
71
const [isLiked, setIsLiked] = useState(false);
69
72
const [deleting, setDeleting] = useState(false);
70
70
-
const [showAddToCollection, setShowAddToCollection] = useState(false);
71
73
const [isEditing, setIsEditing] = useState(false);
72
74
const [editText, setEditText] = useState(data.text || "");
75
75
+
const [editTags, setEditTags] = useState(data.tags?.join(", ") || "");
73
76
const [saving, setSaving] = useState(false);
74
77
75
78
const [showHistory, setShowHistory] = useState(false);
···
182
185
const handleSaveEdit = async () => {
183
186
try {
184
187
setSaving(true);
185
185
-
await updateAnnotation(data.uri, editText, data.tags);
188
188
+
const tagList = editTags
189
189
+
.split(",")
190
190
+
.map((t) => t.trim())
191
191
+
.filter(Boolean);
192
192
+
await updateAnnotation(data.uri, editText, tagList);
186
193
setIsEditing(false);
187
194
if (annotation.body) annotation.body.value = editText;
188
195
else if (annotation.text) annotation.text = editText;
196
196
+
if (annotation.tags) annotation.tags = tagList;
197
197
+
data.tags = tagList;
189
198
} catch (err) {
190
199
alert("Failed to update: " + err.message);
191
200
} finally {
···
288
297
return (
289
298
<article className="card annotation-card">
290
299
<header className="annotation-header">
291
291
-
<Link to={marginProfileUrl || "#"} className="annotation-avatar-link">
292
292
-
<div className="annotation-avatar">
293
293
-
{authorAvatar ? (
294
294
-
<img src={authorAvatar} alt={authorDisplayName} />
295
295
-
) : (
296
296
-
<span>
297
297
-
{(authorDisplayName || authorHandle || "??")
298
298
-
?.substring(0, 2)
299
299
-
.toUpperCase()}
300
300
-
</span>
301
301
-
)}
302
302
-
</div>
303
303
-
</Link>
304
304
-
<div className="annotation-meta">
305
305
-
<div className="annotation-author-row">
306
306
-
<Link
307
307
-
to={marginProfileUrl || "#"}
308
308
-
className="annotation-author-link"
309
309
-
>
310
310
-
<span className="annotation-author">{authorDisplayName}</span>
311
311
-
</Link>
312
312
-
{authorHandle && (
313
313
-
<a
314
314
-
href={`https://bsky.app/profile/${authorHandle}`}
315
315
-
target="_blank"
316
316
-
rel="noopener noreferrer"
317
317
-
className="annotation-handle"
300
300
+
<div className="annotation-header-left">
301
301
+
<Link to={marginProfileUrl || "#"} className="annotation-avatar-link">
302
302
+
<div className="annotation-avatar">
303
303
+
{authorAvatar ? (
304
304
+
<img src={authorAvatar} alt={authorDisplayName} />
305
305
+
) : (
306
306
+
<span>
307
307
+
{(authorDisplayName || authorHandle || "??")
308
308
+
?.substring(0, 2)
309
309
+
.toUpperCase()}
310
310
+
</span>
311
311
+
)}
312
312
+
</div>
313
313
+
</Link>
314
314
+
<div className="annotation-meta">
315
315
+
<div className="annotation-author-row">
316
316
+
<Link
317
317
+
to={marginProfileUrl || "#"}
318
318
+
className="annotation-author-link"
318
319
>
319
319
-
@{authorHandle} <ExternalLinkIcon size={12} />
320
320
-
</a>
321
321
-
)}
322
322
-
</div>
323
323
-
<div className="annotation-time">{formatDate(data.createdAt)}</div>
324
324
-
</div>
325
325
-
<div className="action-buttons">
326
326
-
{}
327
327
-
{hasEditHistory && !data.color && !data.description && (
328
328
-
<button
329
329
-
className="annotation-edit-btn"
330
330
-
onClick={fetchHistory}
331
331
-
title="View Edit History"
332
332
-
>
333
333
-
<Clock size={16} />
334
334
-
</button>
335
335
-
)}
336
336
-
{}
337
337
-
{isOwner && (
338
338
-
<>
339
339
-
{!data.color && !data.description && (
340
340
-
<button
341
341
-
className="annotation-edit-btn"
342
342
-
onClick={() => setIsEditing(!isEditing)}
343
343
-
title="Edit"
320
320
+
<span className="annotation-author">{authorDisplayName}</span>
321
321
+
</Link>
322
322
+
{authorHandle && (
323
323
+
<a
324
324
+
href={`https://bsky.app/profile/${authorHandle}`}
325
325
+
target="_blank"
326
326
+
rel="noopener noreferrer"
327
327
+
className="annotation-handle"
344
328
>
345
345
-
<Edit2 size={16} />
346
346
-
</button>
329
329
+
@{authorHandle}
330
330
+
</a>
347
331
)}
332
332
+
</div>
333
333
+
<div className="annotation-time">{formatDate(data.createdAt)}</div>
334
334
+
</div>
335
335
+
</div>
336
336
+
<div className="annotation-header-right">
337
337
+
<div style={{ display: "flex", gap: "4px" }}>
338
338
+
{hasEditHistory && !data.color && !data.description && (
348
339
<button
349
349
-
className="annotation-delete"
350
350
-
onClick={handleDelete}
351
351
-
disabled={deleting}
352
352
-
title="Delete"
340
340
+
className="annotation-action action-icon-only"
341
341
+
onClick={fetchHistory}
342
342
+
title="View Edit History"
353
343
>
354
354
-
<TrashIcon size={16} />
344
344
+
<Clock size={16} />
355
345
</button>
356
356
-
</>
357
357
-
)}
346
346
+
)}
347
347
+
348
348
+
{isOwner && (
349
349
+
<>
350
350
+
{!data.color && !data.description && (
351
351
+
<button
352
352
+
className="annotation-action action-icon-only"
353
353
+
onClick={() => setIsEditing(!isEditing)}
354
354
+
title="Edit"
355
355
+
>
356
356
+
<Edit2 size={16} />
357
357
+
</button>
358
358
+
)}
359
359
+
<button
360
360
+
className="annotation-action action-icon-only"
361
361
+
onClick={handleDelete}
362
362
+
disabled={deleting}
363
363
+
title="Delete"
364
364
+
>
365
365
+
<TrashIcon size={16} />
366
366
+
</button>
367
367
+
</>
368
368
+
)}
369
369
+
</div>
358
370
</div>
359
371
</header>
360
372
361
361
-
{}
362
362
-
{}
363
373
{showHistory && (
364
374
<div className="history-panel">
365
375
<div className="history-header">
···
391
401
</div>
392
402
)}
393
403
394
394
-
<a
395
395
-
href={data.url}
396
396
-
target="_blank"
397
397
-
rel="noopener noreferrer"
398
398
-
className="annotation-source"
399
399
-
>
400
400
-
{truncateUrl(data.url)}
401
401
-
{data.title && (
402
402
-
<span className="annotation-source-title"> • {data.title}</span>
403
403
-
)}
404
404
-
</a>
405
405
-
406
406
-
{highlightedText && (
404
404
+
<div className="annotation-content">
407
405
<a
408
408
-
href={fragmentUrl}
406
406
+
href={data.url}
409
407
target="_blank"
410
408
rel="noopener noreferrer"
411
411
-
className="annotation-highlight"
409
409
+
className="annotation-source"
412
410
>
413
413
-
<mark>"{highlightedText}"</mark>
411
411
+
{truncateUrl(data.url)}
412
412
+
{data.title && (
413
413
+
<span className="annotation-source-title"> • {data.title}</span>
414
414
+
)}
414
415
</a>
415
415
-
)}
416
416
+
417
417
+
{highlightedText && (
418
418
+
<a
419
419
+
href={fragmentUrl}
420
420
+
target="_blank"
421
421
+
rel="noopener noreferrer"
422
422
+
className="annotation-highlight"
423
423
+
style={{
424
424
+
borderLeftColor: isEditing ? editColor : data.color || "#f59e0b",
425
425
+
}}
426
426
+
>
427
427
+
<mark>"{highlightedText}"</mark>
428
428
+
</a>
429
429
+
)}
416
430
417
417
-
{isEditing ? (
418
418
-
<div className="mt-3">
419
419
-
<textarea
420
420
-
value={editText}
421
421
-
onChange={(e) => setEditText(e.target.value)}
422
422
-
className="reply-input"
423
423
-
rows={3}
424
424
-
style={{ marginBottom: "8px" }}
425
425
-
/>
426
426
-
<div className="action-buttons-end">
427
427
-
<button
428
428
-
onClick={() => setIsEditing(false)}
429
429
-
className="btn btn-ghost"
430
430
-
>
431
431
-
Cancel
432
432
-
</button>
433
433
-
<button
434
434
-
onClick={handleSaveEdit}
435
435
-
disabled={saving}
436
436
-
className="btn btn-primary btn-sm"
437
437
-
>
438
438
-
{saving ? (
439
439
-
"Saving..."
440
440
-
) : (
441
441
-
<>
442
442
-
<Save size={14} /> Save
443
443
-
</>
444
444
-
)}
445
445
-
</button>
431
431
+
{isEditing ? (
432
432
+
<div className="mt-3">
433
433
+
<textarea
434
434
+
value={editText}
435
435
+
onChange={(e) => setEditText(e.target.value)}
436
436
+
className="reply-input"
437
437
+
rows={3}
438
438
+
style={{ marginBottom: "8px" }}
439
439
+
/>
440
440
+
<input
441
441
+
type="text"
442
442
+
className="reply-input"
443
443
+
placeholder="Tags (comma separated)..."
444
444
+
value={editTags}
445
445
+
onChange={(e) => setEditTags(e.target.value)}
446
446
+
style={{ marginBottom: "8px" }}
447
447
+
/>
448
448
+
<div className="action-buttons-end">
449
449
+
<button
450
450
+
onClick={() => setIsEditing(false)}
451
451
+
className="btn btn-ghost"
452
452
+
>
453
453
+
Cancel
454
454
+
</button>
455
455
+
<button
456
456
+
onClick={handleSaveEdit}
457
457
+
disabled={saving}
458
458
+
className="btn btn-primary btn-sm"
459
459
+
>
460
460
+
{saving ? (
461
461
+
"Saving..."
462
462
+
) : (
463
463
+
<>
464
464
+
<Save size={14} /> Save
465
465
+
</>
466
466
+
)}
467
467
+
</button>
468
468
+
</div>
446
469
</div>
447
447
-
</div>
448
448
-
) : (
449
449
-
data.text && <p className="annotation-text">{data.text}</p>
450
450
-
)}
470
470
+
) : (
471
471
+
data.text && <p className="annotation-text">{data.text}</p>
472
472
+
)}
451
473
452
452
-
{data.tags?.length > 0 && (
453
453
-
<div className="annotation-tags">
454
454
-
{data.tags.map((tag, i) => (
455
455
-
<span key={i} className="annotation-tag">
456
456
-
#{tag}
457
457
-
</span>
458
458
-
))}
459
459
-
</div>
460
460
-
)}
474
474
+
{data.tags?.length > 0 && (
475
475
+
<div className="annotation-tags">
476
476
+
{data.tags.map((tag, i) => (
477
477
+
<Link
478
478
+
key={i}
479
479
+
to={`/?tag=${encodeURIComponent(tag)}`}
480
480
+
className="annotation-tag"
481
481
+
>
482
482
+
#{tag}
483
483
+
</Link>
484
484
+
))}
485
485
+
</div>
486
486
+
)}
487
487
+
</div>
461
488
462
489
<footer className="annotation-actions">
463
463
-
<button
464
464
-
className={`annotation-action ${isLiked ? "liked" : ""}`}
465
465
-
onClick={handleLike}
466
466
-
>
467
467
-
<HeartIcon filled={isLiked} size={16} />
468
468
-
{likeCount > 0 && <span>{likeCount}</span>}
469
469
-
</button>
470
470
-
<button
471
471
-
className={`annotation-action ${showReplies ? "active" : ""}`}
472
472
-
onClick={() => setShowReplies(!showReplies)}
473
473
-
>
474
474
-
<MessageIcon size={16} />
475
475
-
<span>{replyCount > 0 ? `${replyCount}` : "Reply"}</span>
476
476
-
</button>
477
477
-
<ShareMenu
478
478
-
uri={data.uri}
479
479
-
text={data.title || data.url}
480
480
-
handle={data.author?.handle}
481
481
-
type="Annotation"
482
482
-
/>
483
483
-
<button
484
484
-
className="annotation-action"
485
485
-
onClick={() => {
486
486
-
if (!user) {
487
487
-
login();
488
488
-
return;
489
489
-
}
490
490
-
setShowAddToCollection(true);
491
491
-
}}
492
492
-
>
493
493
-
<Folder size={16} />
494
494
-
<span>Collect</span>
495
495
-
</button>
490
490
+
<div className="annotation-actions-left">
491
491
+
<button
492
492
+
className={`annotation-action ${isLiked ? "liked" : ""}`}
493
493
+
onClick={handleLike}
494
494
+
>
495
495
+
<HeartIcon filled={isLiked} size={16} />
496
496
+
{likeCount > 0 && <span>{likeCount}</span>}
497
497
+
</button>
498
498
+
<button
499
499
+
className={`annotation-action ${showReplies ? "active" : ""}`}
500
500
+
onClick={() => setShowReplies(!showReplies)}
501
501
+
>
502
502
+
<MessageIcon size={16} />
503
503
+
<span>{replyCount > 0 ? `${replyCount}` : "Reply"}</span>
504
504
+
</button>
505
505
+
<ShareMenu
506
506
+
uri={data.uri}
507
507
+
text={data.title || data.url}
508
508
+
handle={data.author?.handle}
509
509
+
type="Annotation"
510
510
+
/>
511
511
+
<button
512
512
+
className="annotation-action"
513
513
+
onClick={() => {
514
514
+
if (!user) {
515
515
+
login();
516
516
+
return;
517
517
+
}
518
518
+
if (onAddToCollection) onAddToCollection();
519
519
+
}}
520
520
+
>
521
521
+
<Folder size={16} />
522
522
+
<span>Collect</span>
523
523
+
</button>
524
524
+
</div>
496
525
</footer>
497
526
498
527
{showReplies && (
···
584
613
</div>
585
614
</div>
586
615
)}
587
587
-
588
588
-
<AddToCollectionModal
589
589
-
isOpen={showAddToCollection}
590
590
-
onClose={() => setShowAddToCollection(false)}
591
591
-
annotationUri={data.uri}
592
592
-
/>
593
616
</article>
594
617
);
595
618
}
596
619
597
597
-
export function HighlightCard({ highlight, onDelete }) {
620
620
+
export function HighlightCard({ highlight, onDelete, onAddToCollection }) {
598
621
const { user, login } = useAuth();
599
622
const data = normalizeHighlight(highlight);
600
623
const highlightedText =
601
624
data.selector?.type === "TextQuoteSelector" ? data.selector.exact : null;
602
625
const fragmentUrl = buildTextFragmentUrl(data.url, data.selector);
603
626
const isOwner = user?.did && data.author?.did === user.did;
604
604
-
const [showAddToCollection, setShowAddToCollection] = useState(false);
605
627
const [isEditing, setIsEditing] = useState(false);
606
628
const [editColor, setEditColor] = useState(data.color || "#f59e0b");
629
629
+
const [editTags, setEditTags] = useState(data.tags?.join(", ") || "");
607
630
608
631
const handleSaveEdit = async () => {
609
632
try {
610
610
-
await updateHighlight(data.uri, editColor, []);
633
633
+
const tagList = editTags
634
634
+
.split(",")
635
635
+
.map((t) => t.trim())
636
636
+
.filter(Boolean);
637
637
+
638
638
+
await updateHighlight(data.uri, editColor, tagList);
611
639
setIsEditing(false);
612
640
613
641
if (highlight.color) highlight.color = editColor;
642
642
+
if (highlight.tags) highlight.tags = tagList;
643
643
+
else highlight.value = { ...highlight.value, tags: tagList };
614
644
} catch (err) {
615
645
alert("Failed to update: " + err.message);
616
646
}
···
639
669
return (
640
670
<article className="card annotation-card">
641
671
<header className="annotation-header">
642
642
-
<Link
643
643
-
to={data.author?.did ? `/profile/${data.author.did}` : "#"}
644
644
-
className="annotation-avatar-link"
645
645
-
>
646
646
-
<div className="annotation-avatar">
647
647
-
{data.author?.avatar ? (
648
648
-
<img src={data.author.avatar} alt="avatar" />
649
649
-
) : (
650
650
-
<span>??</span>
672
672
+
<div className="annotation-header-left">
673
673
+
<Link
674
674
+
to={data.author?.did ? `/profile/${data.author.did}` : "#"}
675
675
+
className="annotation-avatar-link"
676
676
+
>
677
677
+
<div className="annotation-avatar">
678
678
+
{data.author?.avatar ? (
679
679
+
<img src={data.author.avatar} alt="avatar" />
680
680
+
) : (
681
681
+
<span>??</span>
682
682
+
)}
683
683
+
</div>
684
684
+
</Link>
685
685
+
<div className="annotation-meta">
686
686
+
<Link to="#" className="annotation-author-link">
687
687
+
<span className="annotation-author">
688
688
+
{data.author?.displayName || "Unknown"}
689
689
+
</span>
690
690
+
</Link>
691
691
+
<div className="annotation-time">{formatDate(data.createdAt)}</div>
692
692
+
{data.author?.handle && (
693
693
+
<a
694
694
+
href={`https://bsky.app/profile/${data.author.handle}`}
695
695
+
target="_blank"
696
696
+
rel="noopener noreferrer"
697
697
+
className="annotation-handle"
698
698
+
>
699
699
+
@{data.author.handle}
700
700
+
</a>
651
701
)}
652
702
</div>
653
653
-
</Link>
654
654
-
<div className="annotation-meta">
655
655
-
<Link to="#" className="annotation-author-link">
656
656
-
<span className="annotation-author">
657
657
-
{data.author?.displayName || "Unknown"}
658
658
-
</span>
659
659
-
</Link>
660
660
-
<div className="annotation-time">{formatDate(data.createdAt)}</div>
661
703
</div>
662
662
-
<div className="action-buttons">
663
663
-
{isOwner && (
664
664
-
<>
665
665
-
<button
666
666
-
className="annotation-edit-btn"
667
667
-
onClick={() => setIsEditing(!isEditing)}
668
668
-
title="Edit Color"
669
669
-
>
670
670
-
<Edit2 size={16} />
671
671
-
</button>
672
672
-
<button
673
673
-
className="annotation-delete"
674
674
-
onClick={(e) => {
675
675
-
e.preventDefault();
676
676
-
onDelete && onDelete(highlight.id || highlight.uri);
677
677
-
}}
678
678
-
>
679
679
-
<TrashIcon size={16} />
680
680
-
</button>
681
681
-
</>
682
682
-
)}
704
704
+
705
705
+
<div className="annotation-header-right">
706
706
+
<div style={{ display: "flex", gap: "4px" }}>
707
707
+
{isOwner && (
708
708
+
<>
709
709
+
<button
710
710
+
className="annotation-action action-icon-only"
711
711
+
onClick={() => setIsEditing(!isEditing)}
712
712
+
title="Edit Color"
713
713
+
>
714
714
+
<Edit2 size={16} />
715
715
+
</button>
716
716
+
<button
717
717
+
className="annotation-action action-icon-only"
718
718
+
onClick={(e) => {
719
719
+
e.preventDefault();
720
720
+
onDelete && onDelete(highlight.id || highlight.uri);
721
721
+
}}
722
722
+
>
723
723
+
<TrashIcon size={16} />
724
724
+
</button>
725
725
+
</>
726
726
+
)}
727
727
+
</div>
683
728
</div>
684
729
</header>
685
730
686
686
-
<a
687
687
-
href={data.url}
688
688
-
target="_blank"
689
689
-
rel="noopener noreferrer"
690
690
-
className="annotation-source"
691
691
-
>
692
692
-
{truncateUrl(data.url)}
693
693
-
</a>
694
694
-
695
695
-
{highlightedText && (
731
731
+
<div className="annotation-content">
696
732
<a
697
697
-
href={fragmentUrl}
733
733
+
href={data.url}
698
734
target="_blank"
699
735
rel="noopener noreferrer"
700
700
-
className="annotation-highlight"
701
701
-
style={{
702
702
-
borderLeftColor: isEditing ? editColor : data.color || "#f59e0b",
703
703
-
}}
736
736
+
className="annotation-source"
704
737
>
705
705
-
<mark>"{highlightedText}"</mark>
738
738
+
{truncateUrl(data.url)}
706
739
</a>
707
707
-
)}
740
740
+
741
741
+
{highlightedText && (
742
742
+
<a
743
743
+
href={fragmentUrl}
744
744
+
target="_blank"
745
745
+
rel="noopener noreferrer"
746
746
+
className="annotation-highlight"
747
747
+
style={{
748
748
+
borderLeftColor: isEditing ? editColor : data.color || "#f59e0b",
749
749
+
}}
750
750
+
>
751
751
+
<mark>"{highlightedText}"</mark>
752
752
+
</a>
753
753
+
)}
754
754
+
755
755
+
{isEditing && (
756
756
+
<div
757
757
+
className="mt-3"
758
758
+
style={{
759
759
+
display: "flex",
760
760
+
gap: "8px",
761
761
+
alignItems: "center",
762
762
+
padding: "8px",
763
763
+
background: "var(--bg-secondary)",
764
764
+
borderRadius: "var(--radius-md)",
765
765
+
border: "1px solid var(--border)",
766
766
+
}}
767
767
+
>
768
768
+
<div
769
769
+
className="color-picker-compact"
770
770
+
style={{
771
771
+
position: "relative",
772
772
+
width: "28px",
773
773
+
height: "28px",
774
774
+
flexShrink: 0,
775
775
+
}}
776
776
+
>
777
777
+
<div
778
778
+
style={{
779
779
+
backgroundColor: editColor,
780
780
+
width: "100%",
781
781
+
height: "100%",
782
782
+
borderRadius: "50%",
783
783
+
border: "2px solid var(--bg-card)",
784
784
+
boxShadow: "0 0 0 1px var(--border)",
785
785
+
}}
786
786
+
/>
787
787
+
<input
788
788
+
type="color"
789
789
+
value={editColor}
790
790
+
onChange={(e) => setEditColor(e.target.value)}
791
791
+
style={{
792
792
+
position: "absolute",
793
793
+
top: 0,
794
794
+
left: 0,
795
795
+
width: "100%",
796
796
+
height: "100%",
797
797
+
opacity: 0,
798
798
+
cursor: "pointer",
799
799
+
}}
800
800
+
title="Change Color"
801
801
+
/>
802
802
+
</div>
803
803
+
804
804
+
<input
805
805
+
type="text"
806
806
+
className="reply-input"
807
807
+
placeholder="e.g. tag1, tag2"
808
808
+
value={editTags}
809
809
+
onChange={(e) => setEditTags(e.target.value)}
810
810
+
style={{
811
811
+
margin: 0,
812
812
+
flex: 1,
813
813
+
fontSize: "0.9rem",
814
814
+
padding: "6px 10px",
815
815
+
height: "32px",
816
816
+
border: "none",
817
817
+
background: "transparent",
818
818
+
}}
819
819
+
/>
820
820
+
821
821
+
<button
822
822
+
onClick={handleSaveEdit}
823
823
+
className="btn btn-primary btn-sm"
824
824
+
style={{ padding: "0 10px", height: "32px", minWidth: "auto" }}
825
825
+
title="Save"
826
826
+
>
827
827
+
<Save size={16} />
828
828
+
</button>
829
829
+
</div>
830
830
+
)}
831
831
+
832
832
+
{data.tags?.length > 0 && (
833
833
+
<div className="annotation-tags">
834
834
+
{data.tags.map((tag, i) => (
835
835
+
<Link
836
836
+
key={i}
837
837
+
to={`/?tag=${encodeURIComponent(tag)}`}
838
838
+
className="annotation-tag"
839
839
+
>
840
840
+
#{tag}
841
841
+
</Link>
842
842
+
))}
843
843
+
</div>
844
844
+
)}
845
845
+
</div>
708
846
709
709
-
{isEditing && (
710
710
-
<div
711
711
-
className="mt-3"
712
712
-
style={{ display: "flex", alignItems: "center", gap: "8px" }}
713
713
-
>
714
714
-
<span style={{ fontSize: "0.9rem" }}>Color:</span>
715
715
-
<input
716
716
-
type="color"
717
717
-
value={editColor}
718
718
-
onChange={(e) => setEditColor(e.target.value)}
847
847
+
<footer className="annotation-actions">
848
848
+
<div className="annotation-actions-left">
849
849
+
<span
850
850
+
className="annotation-action"
719
851
style={{
720
720
-
height: "32px",
721
721
-
width: "64px",
722
722
-
padding: 0,
723
723
-
border: "none",
724
724
-
borderRadius: "var(--radius-sm)",
725
725
-
overflow: "hidden",
852
852
+
color: data.color || "#f59e0b",
853
853
+
background: "none",
854
854
+
paddingLeft: 0,
726
855
}}
856
856
+
>
857
857
+
<HighlightIcon size={14} /> Highlight
858
858
+
</span>
859
859
+
<ShareMenu
860
860
+
uri={data.uri}
861
861
+
text={data.title || data.description}
862
862
+
handle={data.author?.handle}
863
863
+
type="Highlight"
727
864
/>
728
865
<button
729
729
-
onClick={handleSaveEdit}
730
730
-
className="btn btn-primary btn-sm"
731
731
-
style={{ marginLeft: "auto" }}
866
866
+
className="annotation-action"
867
867
+
onClick={() => {
868
868
+
if (!user) {
869
869
+
login();
870
870
+
return;
871
871
+
}
872
872
+
if (onAddToCollection) onAddToCollection();
873
873
+
}}
732
874
>
733
733
-
Save
875
875
+
<Folder size={16} />
876
876
+
<span>Collect</span>
734
877
</button>
735
878
</div>
736
736
-
)}
737
737
-
738
738
-
<footer className="annotation-actions">
739
739
-
<span
740
740
-
className="annotation-action annotation-type-badge"
741
741
-
style={{ color: data.color || "#f59e0b" }}
742
742
-
>
743
743
-
<HighlightIcon size={14} /> Highlight
744
744
-
</span>
745
745
-
<ShareMenu
746
746
-
uri={data.uri}
747
747
-
text={data.title || data.description}
748
748
-
handle={data.author?.handle}
749
749
-
type="Highlight"
750
750
-
/>
751
751
-
<button
752
752
-
className="annotation-action"
753
753
-
onClick={() => {
754
754
-
if (!user) {
755
755
-
login();
756
756
-
return;
757
757
-
}
758
758
-
setShowAddToCollection(true);
759
759
-
}}
760
760
-
>
761
761
-
<Folder size={16} />
762
762
-
<span>Collect</span>
763
763
-
</button>
764
879
</footer>
765
765
-
<AddToCollectionModal
766
766
-
isOpen={showAddToCollection}
767
767
-
onClose={() => setShowAddToCollection(false)}
768
768
-
annotationUri={data.uri}
769
769
-
/>
770
880
</article>
771
881
);
772
882
}
+103
-130
web/src/components/BookmarkCard.jsx
···
11
11
} from "../api/client";
12
12
import { HeartIcon, TrashIcon, ExternalLinkIcon, BookmarkIcon } from "./Icons";
13
13
import { Folder } from "lucide-react";
14
14
-
import AddToCollectionModal from "./AddToCollectionModal";
15
14
import ShareMenu from "./ShareMenu";
16
15
17
17
-
export default function BookmarkCard({ bookmark, annotation, onDelete }) {
16
16
+
export default function BookmarkCard({ bookmark, onAddToCollection }) {
18
17
const { user, login } = useAuth();
19
19
-
const raw = bookmark || annotation;
18
18
+
const raw = bookmark;
20
19
const data =
21
20
raw.type === "Bookmark" ? normalizeBookmark(raw) : normalizeAnnotation(raw);
22
21
23
22
const [likeCount, setLikeCount] = useState(0);
24
23
const [isLiked, setIsLiked] = useState(false);
25
24
const [deleting, setDeleting] = useState(false);
26
26
-
const [showAddToCollection, setShowAddToCollection] = useState(false);
27
25
28
26
const isOwner = user?.did && data.author?.did === user.did;
29
27
···
84
82
}
85
83
};
86
84
87
87
-
const handleShare = async () => {
88
88
-
const uriParts = data.uri.split("/");
89
89
-
const did = uriParts[2];
90
90
-
const rkey = uriParts[uriParts.length - 1];
91
91
-
const shareUrl = `${window.location.origin}/at/${did}/${rkey}`;
92
92
-
if (navigator.share) {
93
93
-
try {
94
94
-
await navigator.share({ title: "Bookmark", url: shareUrl });
95
95
-
} catch {}
96
96
-
} else {
97
97
-
try {
98
98
-
await navigator.clipboard.writeText(shareUrl);
99
99
-
alert("Link copied!");
100
100
-
} catch {
101
101
-
prompt("Copy:", shareUrl);
102
102
-
}
103
103
-
}
104
104
-
};
105
105
-
106
85
const formatDate = (dateString) => {
107
86
if (!dateString) return "";
108
87
const date = new Date(dateString);
···
131
110
132
111
return (
133
112
<article className="card bookmark-card">
134
134
-
{}
135
113
<header className="annotation-header">
136
136
-
<Link to={marginProfileUrl || "#"} className="annotation-avatar-link">
137
137
-
<div className="annotation-avatar">
138
138
-
{authorAvatar ? (
139
139
-
<img src={authorAvatar} alt={authorDisplayName} />
140
140
-
) : (
141
141
-
<span>
142
142
-
{(authorDisplayName || authorHandle || "??")
143
143
-
?.substring(0, 2)
144
144
-
.toUpperCase()}
145
145
-
</span>
146
146
-
)}
114
114
+
<div className="annotation-header-left">
115
115
+
<Link to={marginProfileUrl || "#"} className="annotation-avatar-link">
116
116
+
<div className="annotation-avatar">
117
117
+
{authorAvatar ? (
118
118
+
<img src={authorAvatar} alt={authorDisplayName} />
119
119
+
) : (
120
120
+
<span>
121
121
+
{(authorDisplayName || authorHandle || "??")
122
122
+
?.substring(0, 2)
123
123
+
.toUpperCase()}
124
124
+
</span>
125
125
+
)}
126
126
+
</div>
127
127
+
</Link>
128
128
+
<div className="annotation-meta">
129
129
+
<div className="annotation-author-row">
130
130
+
<Link
131
131
+
to={marginProfileUrl || "#"}
132
132
+
className="annotation-author-link"
133
133
+
>
134
134
+
<span className="annotation-author">{authorDisplayName}</span>
135
135
+
</Link>
136
136
+
{authorHandle && (
137
137
+
<a
138
138
+
href={`https://bsky.app/profile/${authorHandle}`}
139
139
+
target="_blank"
140
140
+
rel="noopener noreferrer"
141
141
+
className="annotation-handle"
142
142
+
>
143
143
+
@{authorHandle}
144
144
+
</a>
145
145
+
)}
146
146
+
</div>
147
147
+
<div className="annotation-time">{formatDate(data.createdAt)}</div>
147
148
</div>
148
148
-
</Link>
149
149
-
<div className="annotation-meta">
150
150
-
<div className="annotation-author-row">
151
151
-
<Link
152
152
-
to={marginProfileUrl || "#"}
153
153
-
className="annotation-author-link"
154
154
-
>
155
155
-
<span className="annotation-author">{authorDisplayName}</span>
156
156
-
</Link>
157
157
-
{authorHandle && (
158
158
-
<a
159
159
-
href={`https://bsky.app/profile/${authorHandle}`}
160
160
-
target="_blank"
161
161
-
rel="noopener noreferrer"
162
162
-
className="annotation-handle"
149
149
+
</div>
150
150
+
151
151
+
<div className="annotation-header-right">
152
152
+
<div style={{ display: "flex", gap: "4px" }}>
153
153
+
{isOwner && (
154
154
+
<button
155
155
+
className="annotation-action action-icon-only"
156
156
+
onClick={handleDelete}
157
157
+
disabled={deleting}
158
158
+
title="Delete"
163
159
>
164
164
-
@{authorHandle} <ExternalLinkIcon size={12} />
165
165
-
</a>
160
160
+
<TrashIcon size={16} />
161
161
+
</button>
166
162
)}
167
163
</div>
168
168
-
<div className="annotation-time">{formatDate(data.createdAt)}</div>
169
169
-
</div>
170
170
-
<div className="action-buttons">
171
171
-
{isOwner && (
172
172
-
<button
173
173
-
className="annotation-delete"
174
174
-
onClick={handleDelete}
175
175
-
disabled={deleting}
176
176
-
title="Delete"
177
177
-
>
178
178
-
<TrashIcon size={16} />
179
179
-
</button>
180
180
-
)}
181
164
</div>
182
165
</header>
183
166
184
184
-
{}
185
185
-
<a
186
186
-
href={data.url}
187
187
-
target="_blank"
188
188
-
rel="noopener noreferrer"
189
189
-
className="bookmark-preview"
190
190
-
>
191
191
-
<div className="bookmark-preview-content">
192
192
-
<div className="bookmark-preview-site">
193
193
-
<BookmarkIcon size={14} />
194
194
-
<span>{domain}</span>
167
167
+
<div className="annotation-content">
168
168
+
<a
169
169
+
href={data.url}
170
170
+
target="_blank"
171
171
+
rel="noopener noreferrer"
172
172
+
className="bookmark-preview"
173
173
+
>
174
174
+
<div className="bookmark-preview-content">
175
175
+
<div className="bookmark-preview-site">
176
176
+
<BookmarkIcon size={14} />
177
177
+
<span>{domain}</span>
178
178
+
</div>
179
179
+
<h3 className="bookmark-preview-title">{data.title || data.url}</h3>
180
180
+
{data.description && (
181
181
+
<p className="bookmark-preview-desc">{data.description}</p>
182
182
+
)}
195
183
</div>
196
196
-
<h3 className="bookmark-preview-title">{data.title || data.url}</h3>
197
197
-
{data.description && (
198
198
-
<p className="bookmark-preview-desc">{data.description}</p>
199
199
-
)}
200
200
-
</div>
201
201
-
<div className="bookmark-preview-arrow">
202
202
-
<ExternalLinkIcon size={18} />
203
203
-
</div>
204
204
-
</a>
184
184
+
</a>
205
185
206
206
-
{}
207
207
-
{data.tags?.length > 0 && (
208
208
-
<div className="annotation-tags">
209
209
-
{data.tags.map((tag, i) => (
210
210
-
<span key={i} className="annotation-tag">
211
211
-
#{tag}
212
212
-
</span>
213
213
-
))}
214
214
-
</div>
215
215
-
)}
186
186
+
{data.tags?.length > 0 && (
187
187
+
<div className="annotation-tags">
188
188
+
{data.tags.map((tag, i) => (
189
189
+
<span key={i} className="annotation-tag">
190
190
+
#{tag}
191
191
+
</span>
192
192
+
))}
193
193
+
</div>
194
194
+
)}
195
195
+
</div>
216
196
217
217
-
{}
218
197
<footer className="annotation-actions">
219
219
-
<button
220
220
-
className={`annotation-action ${isLiked ? "liked" : ""}`}
221
221
-
onClick={handleLike}
222
222
-
>
223
223
-
<HeartIcon filled={isLiked} size={16} />
224
224
-
{likeCount > 0 && <span>{likeCount}</span>}
225
225
-
</button>
226
226
-
<ShareMenu
227
227
-
uri={data.uri}
228
228
-
text={data.title || data.description}
229
229
-
handle={data.author?.handle}
230
230
-
type="Bookmark"
231
231
-
/>
232
232
-
<button
233
233
-
className="annotation-action"
234
234
-
onClick={() => {
235
235
-
if (!user) {
236
236
-
login();
237
237
-
return;
238
238
-
}
239
239
-
setShowAddToCollection(true);
240
240
-
}}
241
241
-
>
242
242
-
<Folder size={16} />
243
243
-
<span>Collect</span>
244
244
-
</button>
198
198
+
<div className="annotation-actions-left">
199
199
+
<button
200
200
+
className={`annotation-action ${isLiked ? "liked" : ""}`}
201
201
+
onClick={handleLike}
202
202
+
>
203
203
+
<HeartIcon filled={isLiked} size={16} />
204
204
+
{likeCount > 0 && <span>{likeCount}</span>}
205
205
+
</button>
206
206
+
<ShareMenu
207
207
+
uri={data.uri}
208
208
+
text={data.title || data.description}
209
209
+
handle={data.author?.handle}
210
210
+
type="Bookmark"
211
211
+
/>
212
212
+
<button
213
213
+
className="annotation-action"
214
214
+
onClick={() => {
215
215
+
if (!user) {
216
216
+
login();
217
217
+
return;
218
218
+
}
219
219
+
if (onAddToCollection) onAddToCollection();
220
220
+
}}
221
221
+
>
222
222
+
<Folder size={16} />
223
223
+
<span>Collect</span>
224
224
+
</button>
225
225
+
</div>
245
226
</footer>
246
246
-
247
247
-
{showAddToCollection && (
248
248
-
<AddToCollectionModal
249
249
-
isOpen={showAddToCollection}
250
250
-
annotationUri={data.uri}
251
251
-
onClose={() => setShowAddToCollection(false)}
252
252
-
/>
253
253
-
)}
254
227
</article>
255
228
);
256
229
}
+37
-9
web/src/components/Composer.jsx
···
1
1
import { useState } from "react";
2
2
-
import { createAnnotation } from "../api/client";
2
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
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
22
-
if (!text.trim()) return;
23
23
+
if (!text.trim() && !highlightedText && !quoteText.trim()) return;
23
24
24
25
try {
25
26
setLoading(true);
···
33
34
};
34
35
}
35
36
36
36
-
await createAnnotation({
37
37
-
url,
38
38
-
text,
39
39
-
selector: finalSelector || undefined,
40
40
-
});
37
37
+
const tagList = tags
38
38
+
.split(",")
39
39
+
.map((t) => t.trim())
40
40
+
.filter(Boolean);
41
41
+
42
42
+
if (!text.trim()) {
43
43
+
await createHighlight({
44
44
+
url,
45
45
+
selector: finalSelector,
46
46
+
color: "yellow",
47
47
+
tags: tagList,
48
48
+
});
49
49
+
} else {
50
50
+
await createAnnotation({
51
51
+
url,
52
52
+
text,
53
53
+
selector: finalSelector || undefined,
54
54
+
tags: tagList,
55
55
+
});
56
56
+
}
41
57
42
58
setText("");
43
59
setQuoteText("");
···
123
139
className="composer-input"
124
140
rows={4}
125
141
maxLength={3000}
126
126
-
required
127
142
disabled={loading}
128
143
/>
144
144
+
145
145
+
<div className="composer-tags">
146
146
+
<input
147
147
+
type="text"
148
148
+
value={tags}
149
149
+
onChange={(e) => setTags(e.target.value)}
150
150
+
placeholder="Add tags (comma separated)..."
151
151
+
className="composer-tags-input"
152
152
+
disabled={loading}
153
153
+
/>
154
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
146
-
disabled={loading || !text.trim()}
172
172
+
disabled={
173
173
+
loading || (!text.trim() && !highlightedText && !quoteText)
174
174
+
}
147
175
>
148
176
{loading ? "Posting..." : "Post"}
149
177
</button>
+10
web/src/components/ShareMenu.jsx
···
125
125
setIsOpen(false);
126
126
}
127
127
};
128
128
+
129
129
+
const card = menuRef.current?.closest(".card");
130
130
+
if (card) {
131
131
+
if (isOpen) {
132
132
+
card.style.zIndex = "50";
133
133
+
} else {
134
134
+
card.style.zIndex = "";
135
135
+
}
136
136
+
}
137
137
+
128
138
if (isOpen) {
129
139
document.addEventListener("mousedown", handleClickOutside);
130
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
143
-
padding: 20px;
143
143
+
padding: 24px;
144
144
transition: all 0.2s ease;
145
145
+
position: relative;
145
146
}
146
147
147
148
.card:hover {
148
149
border-color: var(--border-hover);
149
149
-
box-shadow: var(--shadow-sm);
150
150
+
box-shadow: var(--shadow-md);
151
151
+
transform: translateY(-1px);
150
152
}
151
153
152
154
.annotation-card {
153
155
display: flex;
154
156
flex-direction: column;
155
155
-
gap: 12px;
157
157
+
gap: 16px;
156
158
}
157
159
158
160
.annotation-header {
159
161
display: flex;
162
162
+
justify-content: space-between;
163
163
+
align-items: flex-start;
164
164
+
gap: 12px;
165
165
+
}
166
166
+
167
167
+
.annotation-header-left {
168
168
+
display: flex;
160
169
align-items: center;
161
170
gap: 12px;
171
171
+
flex: 1;
172
172
+
min-width: 0;
162
173
}
163
174
164
175
.annotation-avatar {
165
165
-
width: 42px;
166
166
-
height: 42px;
167
167
-
min-width: 42px;
176
176
+
width: 40px;
177
177
+
height: 40px;
178
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
174
-
font-size: 1rem;
185
185
+
font-size: 0.95rem;
175
186
color: white;
176
187
overflow: hidden;
188
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
186
-
flex: 1;
187
187
-
min-width: 0;
198
198
+
display: flex;
199
199
+
flex-direction: column;
200
200
+
justify-content: center;
201
201
+
line-height: 1.3;
188
202
}
189
203
190
204
.annotation-avatar-link {
191
205
text-decoration: none;
206
206
+
border-radius: var(--radius-full);
207
207
+
transition: transform 0.15s ease;
208
208
+
}
209
209
+
210
210
+
.annotation-avatar-link:hover {
211
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
224
+
font-size: 0.95rem;
204
225
}
205
226
206
227
.annotation-handle {
207
207
-
font-size: 0.9rem;
228
228
+
font-size: 0.85rem;
208
229
color: var(--text-tertiary);
209
230
text-decoration: none;
231
231
+
display: flex;
232
232
+
align-items: center;
233
233
+
gap: 3px;
210
234
}
211
235
212
236
.annotation-handle:hover {
213
237
color: var(--accent);
214
214
-
text-decoration: underline;
215
238
}
216
239
217
240
.annotation-time {
218
218
-
font-size: 0.85rem;
241
241
+
font-size: 0.8rem;
219
242
color: var(--text-tertiary);
243
243
+
}
244
244
+
245
245
+
.annotation-content {
246
246
+
display: flex;
247
247
+
flex-direction: column;
248
248
+
gap: 12px;
220
249
}
221
250
222
251
.annotation-source {
223
223
-
display: block;
224
224
-
font-size: 0.85rem;
252
252
+
display: inline-flex;
253
253
+
align-items: center;
254
254
+
gap: 6px;
255
255
+
font-size: 0.8rem;
225
256
color: var(--text-tertiary);
226
257
text-decoration: none;
227
227
-
margin-bottom: 8px;
258
258
+
padding: 4px 10px;
259
259
+
background: var(--bg-tertiary);
260
260
+
border-radius: var(--radius-full);
261
261
+
width: fit-content;
262
262
+
transition: all 0.15s ease;
263
263
+
max-width: 100%;
264
264
+
overflow: hidden;
265
265
+
text-overflow: ellipsis;
266
266
+
white-space: nowrap;
228
267
}
229
268
230
269
.annotation-source:hover {
231
231
-
color: var(--accent);
270
270
+
color: var(--text-primary);
271
271
+
background: var(--bg-hover);
232
272
}
233
273
234
274
.annotation-source-title {
235
275
color: var(--text-secondary);
276
276
+
opacity: 0.8;
236
277
}
237
278
238
279
.annotation-highlight {
239
280
display: block;
240
240
-
padding: 12px 16px;
281
281
+
position: relative;
282
282
+
padding: 16px 20px;
241
283
background: linear-gradient(
242
284
135deg,
243
243
-
rgba(79, 70, 229, 0.05),
244
244
-
rgba(168, 85, 247, 0.05)
285
285
+
rgba(79, 70, 229, 0.03),
286
286
+
rgba(168, 85, 247, 0.03)
245
287
);
246
288
border-left: 3px solid var(--accent);
247
247
-
border-radius: 0 var(--radius-sm) var(--radius-sm) 0;
289
289
+
border-radius: 4px var(--radius-md) var(--radius-md) 4px;
248
290
text-decoration: none;
249
249
-
transition: all 0.15s ease;
250
250
-
margin-bottom: 12px;
291
291
+
transition: all 0.2s ease;
292
292
+
margin: 4px 0;
251
293
}
252
294
253
295
.annotation-highlight:hover {
254
296
background: linear-gradient(
255
297
135deg,
256
256
-
rgba(79, 70, 229, 0.1),
257
257
-
rgba(168, 85, 247, 0.1)
298
298
+
rgba(79, 70, 229, 0.08),
299
299
+
rgba(168, 85, 247, 0.08)
258
300
);
301
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
265
-
font-size: 0.95rem;
308
308
+
font-size: 1.05rem;
309
309
+
line-height: 1.6;
310
310
+
font-weight: 400;
311
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
318
+
white-space: pre-wrap;
272
319
}
273
320
274
321
.annotation-actions {
275
322
display: flex;
276
323
align-items: center;
277
277
-
gap: 16px;
278
278
-
padding-top: 8px;
324
324
+
justify-content: space-between;
325
325
+
padding-top: 16px;
326
326
+
margin-top: 8px;
327
327
+
border-top: 1px solid rgba(255, 255, 255, 0.03);
328
328
+
}
329
329
+
330
330
+
.annotation-actions-left {
331
331
+
display: flex;
332
332
+
align-items: center;
333
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
342
+
font-weight: 500;
287
343
padding: 6px 10px;
288
288
-
border-radius: var(--radius-sm);
289
289
-
transition: all 0.15s ease;
344
344
+
border-radius: var(--radius-md);
345
345
+
transition: all 0.2s ease;
346
346
+
background: transparent;
347
347
+
cursor: pointer;
290
348
}
291
349
292
350
.annotation-action:hover {
293
351
color: var(--text-secondary);
294
294
-
background: var(--bg-tertiary);
352
352
+
background: var(--bg-elevated);
295
353
}
296
354
297
355
.annotation-action.liked {
298
356
color: #ef4444;
357
357
+
background: rgba(239, 68, 68, 0.05);
358
358
+
}
359
359
+
360
360
+
.annotation-action.liked:hover {
361
361
+
background: rgba(239, 68, 68, 0.1);
362
362
+
}
363
363
+
364
364
+
.annotation-action.active {
365
365
+
color: var(--accent);
366
366
+
background: var(--accent-subtle);
367
367
+
}
368
368
+
369
369
+
.action-icon-only {
370
370
+
padding: 8px;
299
371
}
300
372
301
373
.annotation-delete {
302
374
background: none;
303
375
border: none;
304
376
cursor: pointer;
305
305
-
padding: 6px 8px;
377
377
+
padding: 8px;
306
378
font-size: 1rem;
307
379
color: var(--text-tertiary);
308
308
-
transition: all 0.15s ease;
309
309
-
border-radius: var(--radius-sm);
380
380
+
transition: all 0.2s ease;
381
381
+
border-radius: var(--radius-md);
382
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
388
+
opacity: 1;
315
389
}
316
390
317
391
.annotation-delete:disabled {
···
1043
1117
border-bottom-color: var(--accent);
1044
1118
}
1045
1119
1046
1046
-
.bookmark-card {
1047
1047
-
padding: 16px 20px;
1048
1048
-
}
1049
1049
-
1050
1050
-
.bookmark-header {
1051
1051
-
display: flex;
1052
1052
-
align-items: flex-start;
1053
1053
-
justify-content: space-between;
1054
1054
-
gap: 12px;
1055
1055
-
}
1056
1056
-
1057
1057
-
.bookmark-link {
1058
1058
-
text-decoration: none;
1059
1059
-
flex: 1;
1060
1060
-
}
1061
1061
-
1062
1062
-
.bookmark-title {
1063
1063
-
font-size: 1rem;
1064
1064
-
font-weight: 600;
1065
1065
-
color: var(--text-primary);
1066
1066
-
margin: 0 0 4px 0;
1067
1067
-
line-height: 1.4;
1068
1068
-
}
1069
1069
-
1070
1070
-
.bookmark-title:hover {
1071
1071
-
color: var(--accent);
1072
1072
-
}
1073
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
1417
+
.composer-tags {
1418
1418
+
margin-top: 12px;
1419
1419
+
}
1420
1420
+
1421
1421
+
.composer-tags-input {
1422
1422
+
width: 100%;
1423
1423
+
padding: 12px 16px;
1424
1424
+
background: var(--bg-secondary);
1425
1425
+
border: 1px solid var(--border);
1426
1426
+
border-radius: var(--radius-md);
1427
1427
+
color: var(--text-primary);
1428
1428
+
font-size: 0.95rem;
1429
1429
+
transition: all 0.15s ease;
1430
1430
+
}
1431
1431
+
1432
1432
+
.composer-tags-input:focus {
1433
1433
+
outline: none;
1434
1434
+
border-color: var(--accent);
1435
1435
+
box-shadow: 0 0 0 3px var(--accent-subtle);
1436
1436
+
}
1437
1437
+
1438
1438
+
.composer-tags-input::placeholder {
1439
1439
+
color: var(--text-tertiary);
1440
1440
+
}
1441
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
1467
+
}
1468
1468
+
1469
1469
+
.annotation-tags {
1470
1470
+
display: flex;
1471
1471
+
flex-wrap: wrap;
1472
1472
+
gap: 6px;
1473
1473
+
margin-top: 12px;
1474
1474
+
margin-bottom: 8px;
1475
1475
+
}
1476
1476
+
1477
1477
+
.annotation-tag {
1478
1478
+
display: inline-flex;
1479
1479
+
align-items: center;
1480
1480
+
padding: 4px 10px;
1481
1481
+
background: var(--bg-tertiary);
1482
1482
+
color: var(--text-secondary);
1483
1483
+
font-size: 0.8rem;
1484
1484
+
font-weight: 500;
1485
1485
+
border-radius: var(--radius-full);
1486
1486
+
transition: all 0.15s ease;
1487
1487
+
border: 1px solid transparent;
1488
1488
+
text-decoration: none;
1489
1489
+
}
1490
1490
+
1491
1491
+
.annotation-tag:hover {
1492
1492
+
background: var(--bg-hover);
1493
1493
+
color: var(--text-primary);
1494
1494
+
border-color: var(--border);
1495
1495
+
transform: translateY(-1px);
1496
1496
+
}
1497
1497
+
1498
1498
+
.url-input-wrapper {
1499
1499
+
margin-bottom: 24px;
1500
1500
+
}
1501
1501
+
1502
1502
+
.url-input {
1503
1503
+
width: 100%;
1504
1504
+
padding: 16px;
1505
1505
+
background: var(--bg-secondary);
1506
1506
+
border: 1px solid var(--border);
1507
1507
+
border-radius: var(--radius-md);
1508
1508
+
color: var(--text-primary);
1509
1509
+
font-size: 1.1rem;
1510
1510
+
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
1511
1511
+
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
1512
1512
+
}
1513
1513
+
1514
1514
+
.url-input:focus {
1515
1515
+
outline: none;
1516
1516
+
border-color: var(--accent);
1517
1517
+
box-shadow: 0 0 0 4px var(--accent-subtle);
1518
1518
+
background: var(--bg-primary);
1519
1519
+
}
1520
1520
+
1521
1521
+
.url-input::placeholder {
1522
1522
+
color: var(--text-tertiary);
1396
1523
}
1397
1524
1398
1525
.annotation-detail-page {
···
2929
3056
padding: 1rem;
2930
3057
}
2931
3058
3059
3059
+
.form-label {
3060
3060
+
display: block;
3061
3061
+
font-size: 0.85rem;
3062
3062
+
font-weight: 600;
3063
3063
+
color: var(--text-secondary);
3064
3064
+
margin-bottom: 6px;
3065
3065
+
}
3066
3066
+
3067
3067
+
.color-input-container {
3068
3068
+
display: flex;
3069
3069
+
align-items: center;
3070
3070
+
gap: 12px;
3071
3071
+
background: var(--bg-tertiary);
3072
3072
+
padding: 8px 12px;
3073
3073
+
border-radius: var(--radius-md);
3074
3074
+
border: 1px solid var(--border);
3075
3075
+
width: fit-content;
3076
3076
+
}
3077
3077
+
3078
3078
+
.color-input-wrapper {
3079
3079
+
position: relative;
3080
3080
+
width: 32px;
3081
3081
+
height: 32px;
3082
3082
+
border-radius: var(--radius-full);
3083
3083
+
overflow: hidden;
3084
3084
+
border: 2px solid var(--border);
3085
3085
+
cursor: pointer;
3086
3086
+
transition: transform 0.1s;
3087
3087
+
}
3088
3088
+
3089
3089
+
.color-input-wrapper:hover {
3090
3090
+
transform: scale(1.1);
3091
3091
+
border-color: var(--accent);
3092
3092
+
}
3093
3093
+
3094
3094
+
.color-input-wrapper input[type="color"] {
3095
3095
+
position: absolute;
3096
3096
+
top: -50%;
3097
3097
+
left: -50%;
3098
3098
+
width: 200%;
3099
3099
+
height: 200%;
3100
3100
+
padding: 0;
3101
3101
+
margin: 0;
3102
3102
+
border: none;
3103
3103
+
cursor: pointer;
3104
3104
+
opacity: 0;
3105
3105
+
}
3106
3106
+
2932
3107
.bookmark-card {
2933
3108
display: flex;
2934
3109
flex-direction: column;
2935
2935
-
gap: 12px;
3110
3110
+
gap: 16px;
2936
3111
}
2937
3112
2938
3113
.bookmark-preview {
2939
3114
display: flex;
2940
2940
-
align-items: stretch;
2941
2941
-
gap: 16px;
2942
2942
-
padding: 14px 16px;
3115
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
3119
+
overflow: hidden;
2946
3120
text-decoration: none;
2947
3121
transition: all 0.2s ease;
3122
3122
+
position: relative;
3123
3123
+
}
3124
3124
+
3125
3125
+
.bookmark-preview:hover {
3126
3126
+
border-color: var(--accent);
3127
3127
+
box-shadow: var(--shadow-sm);
3128
3128
+
transform: translateY(-1px);
3129
3129
+
}
3130
3130
+
3131
3131
+
.bookmark-preview::before {
3132
3132
+
content: "";
3133
3133
+
position: absolute;
3134
3134
+
left: 0;
3135
3135
+
top: 0;
3136
3136
+
bottom: 0;
3137
3137
+
width: 4px;
3138
3138
+
background: var(--accent);
3139
3139
+
opacity: 0.7;
3140
3140
+
}
3141
3141
+
3142
3142
+
.bookmark-preview-content {
3143
3143
+
padding: 16px 20px;
3144
3144
+
display: flex;
3145
3145
+
flex-direction: column;
3146
3146
+
gap: 8px;
3147
3147
+
}
3148
3148
+
3149
3149
+
.bookmark-preview-header {
3150
3150
+
display: flex;
3151
3151
+
align-items: center;
3152
3152
+
gap: 8px;
3153
3153
+
margin-bottom: 4px;
3154
3154
+
}
3155
3155
+
3156
3156
+
.bookmark-preview-site {
3157
3157
+
font-size: 0.75rem;
3158
3158
+
color: var(--accent);
3159
3159
+
text-transform: uppercase;
3160
3160
+
letter-spacing: 0.05em;
3161
3161
+
font-weight: 700;
3162
3162
+
display: flex;
3163
3163
+
align-items: center;
3164
3164
+
gap: 6px;
3165
3165
+
}
3166
3166
+
3167
3167
+
.bookmark-preview-title {
3168
3168
+
font-size: 1.15rem;
3169
3169
+
font-weight: 700;
3170
3170
+
color: var(--text-primary);
3171
3171
+
line-height: 1.4;
3172
3172
+
}
3173
3173
+
3174
3174
+
.bookmark-preview-desc {
3175
3175
+
font-size: 0.95rem;
3176
3176
+
color: var(--text-secondary);
3177
3177
+
line-height: 1.6;
3178
3178
+
}
3179
3179
+
3180
3180
+
.bookmark-preview-arrow {
3181
3181
+
display: none;
2948
3182
}
2949
3183
2950
3184
.bookmark-preview:hover {
+94
-5
web/src/pages/Feed.jsx
···
1
1
import { useState, useEffect } from "react";
2
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
6
import { getAnnotationFeed, deleteHighlight } from "../api/client";
6
7
import { AlertIcon, InboxIcon } from "../components/Icons";
8
8
+
import { useAuth } from "../context/AuthContext";
9
9
+
10
10
+
import AddToCollectionModal from "../components/AddToCollectionModal";
7
11
8
12
export default function Feed() {
13
13
+
const [searchParams, setSearchParams] = useSearchParams();
14
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
19
+
const [collectionModalState, setCollectionModalState] = useState({
20
20
+
isOpen: false,
21
21
+
uri: null,
22
22
+
});
23
23
+
24
24
+
const { user } = useAuth();
13
25
14
26
useEffect(() => {
15
27
async function fetchFeed() {
16
28
try {
17
29
setLoading(true);
18
18
-
const data = await getAnnotationFeed();
30
30
+
let creatorDid = "";
31
31
+
if (filter === "my-tags" && user?.did) {
32
32
+
creatorDid = user.did;
33
33
+
}
34
34
+
35
35
+
const data = await getAnnotationFeed(
36
36
+
50,
37
37
+
0,
38
38
+
tagFilter || "",
39
39
+
creatorDid,
40
40
+
);
19
41
setAnnotations(data.items || []);
20
42
} catch (err) {
21
43
setError(err.message);
···
24
46
}
25
47
}
26
48
fetchFeed();
27
27
-
}, []);
49
49
+
}, [tagFilter, filter, user]);
28
50
29
51
const filteredAnnotations =
30
30
-
filter === "all"
52
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
71
+
{tagFilter && (
72
72
+
<div
73
73
+
style={{
74
74
+
marginTop: "16px",
75
75
+
display: "flex",
76
76
+
alignItems: "center",
77
77
+
gap: "8px",
78
78
+
}}
79
79
+
>
80
80
+
<span
81
81
+
style={{ fontSize: "0.9rem", color: "var(--text-secondary)" }}
82
82
+
>
83
83
+
Filtering by tag: <strong>#{tagFilter}</strong>
84
84
+
</span>
85
85
+
<button
86
86
+
onClick={() => setSearchParams({})}
87
87
+
className="btn btn-sm"
88
88
+
style={{ padding: "2px 8px", fontSize: "0.8rem" }}
89
89
+
>
90
90
+
Clear
91
91
+
</button>
92
92
+
</div>
93
93
+
)}
49
94
</div>
50
95
51
96
{}
···
56
101
>
57
102
All
58
103
</button>
104
104
+
{user && (
105
105
+
<button
106
106
+
className={`filter-tab ${filter === "my-tags" ? "active" : ""}`}
107
107
+
onClick={() => setFilter("my-tags")}
108
108
+
>
109
109
+
My Feed
110
110
+
</button>
111
111
+
)}
59
112
<button
60
113
className={`filter-tab ${filter === "commenting" ? "active" : ""}`}
61
114
onClick={() => setFilter("commenting")}
···
140
193
prev.filter((a) => a.id !== item.id),
141
194
);
142
195
}}
196
196
+
onAddToCollection={() =>
197
197
+
setCollectionModalState({
198
198
+
isOpen: true,
199
199
+
uri: item.uri || item.id,
200
200
+
})
201
201
+
}
143
202
/>
144
203
);
145
204
}
146
205
if (item.type === "Bookmark" || item.motivation === "bookmarking") {
147
147
-
return <BookmarkCard key={item.id} bookmark={item} />;
206
206
+
return (
207
207
+
<BookmarkCard
208
208
+
key={item.id}
209
209
+
bookmark={item}
210
210
+
onAddToCollection={() =>
211
211
+
setCollectionModalState({
212
212
+
isOpen: true,
213
213
+
uri: item.uri || item.id,
214
214
+
})
215
215
+
}
216
216
+
/>
217
217
+
);
148
218
}
149
149
-
return <AnnotationCard key={item.id} annotation={item} />;
219
219
+
return (
220
220
+
<AnnotationCard
221
221
+
key={item.id}
222
222
+
annotation={item}
223
223
+
onAddToCollection={() =>
224
224
+
setCollectionModalState({
225
225
+
isOpen: true,
226
226
+
uri: item.uri || item.id,
227
227
+
})
228
228
+
}
229
229
+
/>
230
230
+
);
150
231
})}
151
232
</div>
233
233
+
)}
234
234
+
235
235
+
{collectionModalState.isOpen && (
236
236
+
<AddToCollectionModal
237
237
+
isOpen={collectionModalState.isOpen}
238
238
+
onClose={() => setCollectionModalState({ isOpen: false, uri: null })}
239
239
+
annotationUri={collectionModalState.uri}
240
240
+
/>
152
241
)}
153
242
</div>
154
243
);