nandi/gleanpublic⑂ Fork 0
⑂ 0c15e0d
Commits
⬇ Clone ▾
git clone https://git.rickub.com/nandi/glean.git
git clone ssh://git@rickub.com/nandi/glean.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

Improve article links and annotation enhancementsUnverified

Julien Robert committed 2026-04-21T23:38:08+02:00 Browse files
0c15e0d parent: 6bc544d
modified internal/db/article.go +21 -0
@@ -355,6 +355,27 @@ func (db *DB) GetArticleByURL(ctx context.Context, url string) (*Article, error)
355355 return a, nil
356356 }
357357
358+func (db *DB) GetReadCount(ctx context.Context, userDID, feedURL string) (int, error) {
359+ var count int
360+ if feedURL != "" {
361+ err := db.QueryRowContext(ctx, `
362+ SELECT COUNT(*)
363+ FROM articles a
364+ JOIN read_state r ON r.user_did = ? AND r.article_id = a.id
365+ WHERE a.feed_url = ? AND r.is_read = 1
366+ `, userDID, feedURL).Scan(&count)
367+ return count, err
368+ }
369+ err := db.QueryRowContext(ctx, `
370+ SELECT COUNT(*)
371+ FROM articles a
372+ JOIN subscriptions s ON a.feed_url = s.feed_url AND s.user_did = ?
373+ JOIN read_state r ON r.user_did = ? AND r.article_id = a.id
374+ WHERE r.is_read = 1
375+ `, userDID, userDID).Scan(&count)
376+ return count, err
377+}
378+
358379 func (db *DB) CountNewArticles(ctx context.Context, userDID string, since time.Time) (int, error) {
359380 var count int
360381 err := db.QueryRowContext(ctx, `
@@ -355,6 +355,27 @@ func (db *DB) GetArticleByURL(ctx context.Context, url string) (*Article, error)
355 return a, nil355 return a, nil
356 }356 }
357 357
358+func (db *DB) GetReadCount(ctx context.Context, userDID, feedURL string) (int, error) {
359+ var count int
360+ if feedURL != "" {
361+ err := db.QueryRowContext(ctx, `
362+ SELECT COUNT(*)
363+ FROM articles a
364+ JOIN read_state r ON r.user_did = ? AND r.article_id = a.id
365+ WHERE a.feed_url = ? AND r.is_read = 1
366+ `, userDID, feedURL).Scan(&count)
367+ return count, err
368+ }
369+ err := db.QueryRowContext(ctx, `
370+ SELECT COUNT(*)
371+ FROM articles a
372+ JOIN subscriptions s ON a.feed_url = s.feed_url AND s.user_did = ?
373+ JOIN read_state r ON r.user_did = ? AND r.article_id = a.id
374+ WHERE r.is_read = 1
375+ `, userDID, userDID).Scan(&count)
376+ return count, err
377+}
378+
358 func (db *DB) CountNewArticles(ctx context.Context, userDID string, since time.Time) (int, error) {379 func (db *DB) CountNewArticles(ctx context.Context, userDID string, since time.Time) (int, error) {
359 var count int380 var count int
360 err := db.QueryRowContext(ctx, `381 err := db.QueryRowContext(ctx, `
modified internal/db/social.go +11 -8
@@ -16,6 +16,7 @@ type Annotation struct {
1616 AuthorHandle string
1717 FeedURL string
1818 ArticleURL string
19+ ArticleID sql.NullInt64
1920 Quote sql.NullString
2021 Note sql.NullString
2122 Tags sql.NullString
@@ -45,11 +46,12 @@ func (db *DB) CreateAnnotation(ctx context.Context, a *Annotation) error {
4546 func (db *DB) GetAnnotation(ctx context.Context, id int64) (*Annotation, error) {
4647 a := &Annotation{}
4748 err := db.QueryRowContext(ctx, `
48- SELECT a.id, a.uri, a.author_did, COALESCE(u.handle, ''), a.feed_url, a.article_url, a.quote, a.note, a.tags, a.rating, a.created_at, a.cid
49+ SELECT a.id, a.uri, a.author_did, COALESCE(u.handle, ''), a.feed_url, a.article_url, ar.id, a.quote, a.note, a.tags, a.rating, a.created_at, a.cid
4950 FROM annotations a
5051 LEFT JOIN users u ON a.author_did = u.did
52+ LEFT JOIN articles ar ON ar.url = a.article_url AND ar.feed_url = a.feed_url
5153 WHERE a.id = ?
52- `, id).Scan(&a.ID, &a.URI, &a.AuthorDID, &a.AuthorHandle, &a.FeedURL, &a.ArticleURL,
54+ `, id).Scan(&a.ID, &a.URI, &a.AuthorDID, &a.AuthorHandle, &a.FeedURL, &a.ArticleURL, &a.ArticleID,
5355 &a.Quote, &a.Note, &a.Tags, &a.Rating, &a.CreatedAt, &a.CID)
5456 if err != nil {
5557 return nil, err
@@ -79,21 +81,22 @@ func (db *DB) ListAnnotations(ctx context.Context, feedURL, articleURL, authorDI
7981 var args []any
8082
8183 if feedURL != "" {
82- conds = append(conds, "feed_url = ?")
84+ conds = append(conds, "a.feed_url = ?")
8385 args = append(args, feedURL)
8486 }
8587 if articleURL != "" {
86- conds = append(conds, "article_url = ?")
88+ conds = append(conds, "a.article_url = ?")
8789 args = append(args, articleURL)
8890 }
8991 if authorDID != "" {
90- conds = append(conds, "author_did = ?")
92+ conds = append(conds, "a.author_did = ?")
9193 args = append(args, authorDID)
9294 }
9395
94- query := `SELECT a.id, a.uri, a.author_did, COALESCE(u.handle, ''), a.feed_url, a.article_url, a.quote, a.note, a.tags, a.rating, a.created_at, a.cid
96+ query := `SELECT a.id, a.uri, a.author_did, COALESCE(u.handle, ''), a.feed_url, a.article_url, ar.id, a.quote, a.note, a.tags, a.rating, a.created_at, a.cid
9597 FROM annotations a
96- LEFT JOIN users u ON a.author_did = u.did`
98+ LEFT JOIN users u ON a.author_did = u.did
99+ LEFT JOIN articles ar ON ar.url = a.article_url AND ar.feed_url = a.feed_url`
97100 if len(conds) > 0 {
98101 query += ` WHERE ` + strings.Join(conds, " AND ")
99102 }
@@ -109,7 +112,7 @@ func (db *DB) ListAnnotations(ctx context.Context, feedURL, articleURL, authorDI
109112 var annotations []*Annotation
110113 for rows.Next() {
111114 a := &Annotation{}
112- if err := rows.Scan(&a.ID, &a.URI, &a.AuthorDID, &a.AuthorHandle, &a.FeedURL, &a.ArticleURL,
115+ if err := rows.Scan(&a.ID, &a.URI, &a.AuthorDID, &a.AuthorHandle, &a.FeedURL, &a.ArticleURL, &a.ArticleID,
113116 &a.Quote, &a.Note, &a.Tags, &a.Rating, &a.CreatedAt, &a.CID); err != nil {
114117 return nil, err
115118 }
@@ -16,6 +16,7 @@ type Annotation struct {
16 AuthorHandle string16 AuthorHandle string
17 FeedURL string17 FeedURL string
18 ArticleURL string18 ArticleURL string
19+ ArticleID sql.NullInt64
19 Quote sql.NullString20 Quote sql.NullString
20 Note sql.NullString21 Note sql.NullString
21 Tags sql.NullString22 Tags sql.NullString
@@ -45,11 +46,12 @@ func (db *DB) CreateAnnotation(ctx context.Context, a *Annotation) error {
45 func (db *DB) GetAnnotation(ctx context.Context, id int64) (*Annotation, error) {46 func (db *DB) GetAnnotation(ctx context.Context, id int64) (*Annotation, error) {
46 a := &Annotation{}47 a := &Annotation{}
47 err := db.QueryRowContext(ctx, `48 err := db.QueryRowContext(ctx, `
48- SELECT a.id, a.uri, a.author_did, COALESCE(u.handle, ''), a.feed_url, a.article_url, a.quote, a.note, a.tags, a.rating, a.created_at, a.cid49+ SELECT a.id, a.uri, a.author_did, COALESCE(u.handle, ''), a.feed_url, a.article_url, ar.id, a.quote, a.note, a.tags, a.rating, a.created_at, a.cid
49 FROM annotations a50 FROM annotations a
50 LEFT JOIN users u ON a.author_did = u.did51 LEFT JOIN users u ON a.author_did = u.did
52+ LEFT JOIN articles ar ON ar.url = a.article_url AND ar.feed_url = a.feed_url
51 WHERE a.id = ?53 WHERE a.id = ?
52- `, id).Scan(&a.ID, &a.URI, &a.AuthorDID, &a.AuthorHandle, &a.FeedURL, &a.ArticleURL,54+ `, id).Scan(&a.ID, &a.URI, &a.AuthorDID, &a.AuthorHandle, &a.FeedURL, &a.ArticleURL, &a.ArticleID,
53 &a.Quote, &a.Note, &a.Tags, &a.Rating, &a.CreatedAt, &a.CID)55 &a.Quote, &a.Note, &a.Tags, &a.Rating, &a.CreatedAt, &a.CID)
54 if err != nil {56 if err != nil {
55 return nil, err57 return nil, err
@@ -79,21 +81,22 @@ func (db *DB) ListAnnotations(ctx context.Context, feedURL, articleURL, authorDI
79 var args []any81 var args []any
80 82
81 if feedURL != "" {83 if feedURL != "" {
82- conds = append(conds, "feed_url = ?")84+ conds = append(conds, "a.feed_url = ?")
83 args = append(args, feedURL)85 args = append(args, feedURL)
84 }86 }
85 if articleURL != "" {87 if articleURL != "" {
86- conds = append(conds, "article_url = ?")88+ conds = append(conds, "a.article_url = ?")
87 args = append(args, articleURL)89 args = append(args, articleURL)
88 }90 }
89 if authorDID != "" {91 if authorDID != "" {
90- conds = append(conds, "author_did = ?")92+ conds = append(conds, "a.author_did = ?")
91 args = append(args, authorDID)93 args = append(args, authorDID)
92 }94 }
93 95
94- query := `SELECT a.id, a.uri, a.author_did, COALESCE(u.handle, ''), a.feed_url, a.article_url, a.quote, a.note, a.tags, a.rating, a.created_at, a.cid96+ query := `SELECT a.id, a.uri, a.author_did, COALESCE(u.handle, ''), a.feed_url, a.article_url, ar.id, a.quote, a.note, a.tags, a.rating, a.created_at, a.cid
95 FROM annotations a97 FROM annotations a
96- LEFT JOIN users u ON a.author_did = u.did`98+ LEFT JOIN users u ON a.author_did = u.did
99+ LEFT JOIN articles ar ON ar.url = a.article_url AND ar.feed_url = a.feed_url`
97 if len(conds) > 0 {100 if len(conds) > 0 {
98 query += ` WHERE ` + strings.Join(conds, " AND ")101 query += ` WHERE ` + strings.Join(conds, " AND ")
99 }102 }
@@ -109,7 +112,7 @@ func (db *DB) ListAnnotations(ctx context.Context, feedURL, articleURL, authorDI
109 var annotations []*Annotation112 var annotations []*Annotation
110 for rows.Next() {113 for rows.Next() {
111 a := &Annotation{}114 a := &Annotation{}
112- if err := rows.Scan(&a.ID, &a.URI, &a.AuthorDID, &a.AuthorHandle, &a.FeedURL, &a.ArticleURL,115+ if err := rows.Scan(&a.ID, &a.URI, &a.AuthorDID, &a.AuthorHandle, &a.FeedURL, &a.ArticleURL, &a.ArticleID,
113 &a.Quote, &a.Note, &a.Tags, &a.Rating, &a.CreatedAt, &a.CID); err != nil {116 &a.Quote, &a.Note, &a.Tags, &a.Rating, &a.CreatedAt, &a.CID); err != nil {
114 return nil, err117 return nil, err
115 }118 }
modified internal/server/articles_handler.go +7 -0
@@ -38,6 +38,13 @@ func (s *Server) handleArticles(w http.ResponseWriter, r *http.Request) {
3838
3939 page := pageFromRequest(r, 50)
4040
41+ if status == "" && searchQuery == "" {
42+ readCount, _ := s.db.GetReadCount(r.Context(), user.DID, feedURL)
43+ if readCount > 0 {
44+ status = "unread"
45+ }
46+ }
47+
4148 var articles []*db.Article
4249 var err error
4350
@@ -38,6 +38,13 @@ func (s *Server) handleArticles(w http.ResponseWriter, r *http.Request) {
38 38
39 page := pageFromRequest(r, 50)39 page := pageFromRequest(r, 50)
40 40
41+ if status == "" && searchQuery == "" {
42+ readCount, _ := s.db.GetReadCount(r.Context(), user.DID, feedURL)
43+ if readCount > 0 {
44+ status = "unread"
45+ }
46+ }
47+
41 var articles []*db.Article48 var articles []*db.Article
42 var err error49 var err error
43 50
modified internal/tmpl/article_detail.html +5 -5
@@ -1,8 +1,8 @@
11 {{define "article_detail.html"}}
22 <div class="max-w-3xl mx-auto">
3- <a href="/articles" class="text-sm text-spot-secondary hover:text-spot-text mb-6 inline-flex items-center gap-1.5 transition">
3+ <a href="javascript:history.back()" class="text-sm text-spot-secondary hover:text-spot-text mb-6 inline-flex items-center gap-1.5 transition">
44 <svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5"/></svg>
5- Back to articles
5+ Back
66 </a>
77
88 <article>
@@ -138,9 +138,9 @@
138138 </div>
139139 </section>
140140
141- <a href="/articles" class="text-sm text-spot-secondary hover:text-spot-text mt-8 inline-flex items-center gap-1.5 transition">
141+ <a href="javascript:history.back()" class="text-sm text-spot-secondary hover:text-spot-text mt-8 inline-flex items-center gap-1.5 transition">
142142 <svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5"/></svg>
143- Back to articles
143+ Back
144144 </a>
145145 </div>
146146
@@ -255,7 +255,7 @@
255255 var rbtn = document.getElementById('read-btn');
256256 if (rbtn) rbtn.click();
257257 } else if (e.key === 'Escape') {
258- window.location.href = '/articles';
258+ history.back();
259259 } else if (e.key === 'o') {
260260 var origLink = document.querySelector('a[target="_blank"]');
261261 if (origLink && origLink.href) window.open(origLink.href, '_blank');
@@ -1,8 +1,8 @@
1 {{define "article_detail.html"}}1 {{define "article_detail.html"}}
2 <div class="max-w-3xl mx-auto">2 <div class="max-w-3xl mx-auto">
3- <a href="/articles" class="text-sm text-spot-secondary hover:text-spot-text mb-6 inline-flex items-center gap-1.5 transition">3+ <a href="javascript:history.back()" class="text-sm text-spot-secondary hover:text-spot-text mb-6 inline-flex items-center gap-1.5 transition">
4 <svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5"/></svg>4 <svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5"/></svg>
5- Back to articles5+ Back
6 </a>6 </a>
7 7
8 <article>8 <article>
@@ -138,9 +138,9 @@
138 </div>138 </div>
139 </section>139 </section>
140 140
141- <a href="/articles" class="text-sm text-spot-secondary hover:text-spot-text mt-8 inline-flex items-center gap-1.5 transition">141+ <a href="javascript:history.back()" class="text-sm text-spot-secondary hover:text-spot-text mt-8 inline-flex items-center gap-1.5 transition">
142 <svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5"/></svg>142 <svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5"/></svg>
143- Back to articles143+ Back
144 </a>144 </a>
145 </div>145 </div>
146 146
@@ -255,7 +255,7 @@
255 var rbtn = document.getElementById('read-btn');255 var rbtn = document.getElementById('read-btn');
256 if (rbtn) rbtn.click();256 if (rbtn) rbtn.click();
257 } else if (e.key === 'Escape') {257 } else if (e.key === 'Escape') {
258- window.location.href = '/articles';258+ history.back();
259 } else if (e.key === 'o') {259 } else if (e.key === 'o') {
260 var origLink = document.querySelector('a[target="_blank"]');260 var origLink = document.querySelector('a[target="_blank"]');
261 if (origLink && origLink.href) window.open(origLink.href, '_blank');261 if (origLink && origLink.href) window.open(origLink.href, '_blank');
modified internal/tmpl/articles.html +10 -10
@@ -48,16 +48,16 @@
4848 </div>
4949 </form>
5050 <div class="flex items-center gap-2 shrink-0">
51- <a href="/articles?status=all{{if .FeedURL}}&feed={{.FeedURL}}{{end}}"
52- class="px-4 py-1.5 rounded-full text-xs font-bold uppercase tracking-button transition
53- {{if or (eq .Status "") (eq .Status "all")}}bg-spot-active-pill-bg text-spot-active-pill-text{{else}}bg-spot-hover text-spot-secondary hover:text-spot-text{{end}}">
54- All
55- </a>
56- <a href="/articles?status=unread{{if .FeedURL}}&feed={{.FeedURL}}{{end}}"
57- class="px-4 py-1.5 rounded-full text-xs font-bold uppercase tracking-button transition
58- {{if eq .Status "unread"}}bg-spot-active-pill-bg text-spot-active-pill-text{{else}}bg-spot-hover text-spot-secondary hover:text-spot-text{{end}}">
59- Unread
60- </a>
51+ <a href="/articles?status=all{{if .FeedURL}}&feed={{.FeedURL}}{{end}}"
52+ class="px-4 py-1.5 rounded-full text-xs font-bold uppercase tracking-button transition
53+ {{if eq .Status "all"}}bg-spot-active-pill-bg text-spot-active-pill-text{{else}}bg-spot-hover text-spot-secondary hover:text-spot-text{{end}}">
54+ All
55+ </a>
56+ <a href="/articles?status=unread{{if .FeedURL}}&feed={{.FeedURL}}{{end}}"
57+ class="px-4 py-1.5 rounded-full text-xs font-bold uppercase tracking-button transition
58+ {{if or (eq .Status "unread") (eq .Status "")}}bg-spot-active-pill-bg text-spot-active-pill-text{{else}}bg-spot-hover text-spot-secondary hover:text-spot-text{{end}}">
59+ Unread
60+ </a>
6161 <a href="/articles?status=read{{if .FeedURL}}&feed={{.FeedURL}}{{end}}"
6262 class="px-4 py-1.5 rounded-full text-xs font-bold uppercase tracking-button transition
6363 {{if eq .Status "read"}}bg-spot-active-pill-bg text-spot-active-pill-text{{else}}bg-spot-hover text-spot-secondary hover:text-spot-text{{end}}">
@@ -48,16 +48,16 @@
48 </div>48 </div>
49 </form>49 </form>
50 <div class="flex items-center gap-2 shrink-0">50 <div class="flex items-center gap-2 shrink-0">
51- <a href="/articles?status=all{{if .FeedURL}}&feed={{.FeedURL}}{{end}}"51+ <a href="/articles?status=all{{if .FeedURL}}&feed={{.FeedURL}}{{end}}"
52- class="px-4 py-1.5 rounded-full text-xs font-bold uppercase tracking-button transition52+ class="px-4 py-1.5 rounded-full text-xs font-bold uppercase tracking-button transition
53- {{if or (eq .Status "") (eq .Status "all")}}bg-spot-active-pill-bg text-spot-active-pill-text{{else}}bg-spot-hover text-spot-secondary hover:text-spot-text{{end}}">53+ {{if eq .Status "all"}}bg-spot-active-pill-bg text-spot-active-pill-text{{else}}bg-spot-hover text-spot-secondary hover:text-spot-text{{end}}">
54- All54+ All
55- </a>55+ </a>
56- <a href="/articles?status=unread{{if .FeedURL}}&feed={{.FeedURL}}{{end}}"56+ <a href="/articles?status=unread{{if .FeedURL}}&feed={{.FeedURL}}{{end}}"
57- class="px-4 py-1.5 rounded-full text-xs font-bold uppercase tracking-button transition57+ class="px-4 py-1.5 rounded-full text-xs font-bold uppercase tracking-button transition
58- {{if eq .Status "unread"}}bg-spot-active-pill-bg text-spot-active-pill-text{{else}}bg-spot-hover text-spot-secondary hover:text-spot-text{{end}}">58+ {{if or (eq .Status "unread") (eq .Status "")}}bg-spot-active-pill-bg text-spot-active-pill-text{{else}}bg-spot-hover text-spot-secondary hover:text-spot-text{{end}}">
59- Unread59+ Unread
60- </a>60+ </a>
61 <a href="/articles?status=read{{if .FeedURL}}&feed={{.FeedURL}}{{end}}"61 <a href="/articles?status=read{{if .FeedURL}}&feed={{.FeedURL}}{{end}}"
62 class="px-4 py-1.5 rounded-full text-xs font-bold uppercase tracking-button transition62 class="px-4 py-1.5 rounded-full text-xs font-bold uppercase tracking-button transition
63 {{if eq .Status "read"}}bg-spot-active-pill-bg text-spot-active-pill-text{{else}}bg-spot-hover text-spot-secondary hover:text-spot-text{{end}}">63 {{if eq .Status "read"}}bg-spot-active-pill-bg text-spot-active-pill-text{{else}}bg-spot-hover text-spot-secondary hover:text-spot-text{{end}}">
modified internal/tmpl/partials/annotation-card.html +5 -2
@@ -1,8 +1,11 @@
11 {{define "annotation-card.html"}}
22 <div id="annotation-{{.annotation.ID}}" class="bg-spot-surface rounded-xl p-5 border border-spot-divider shadow-sm">
33 {{if .annotation.ArticleURL}}
4- <div class="text-xs mb-3">
5- <a href="{{.annotation.ArticleURL}}" class="text-spot-secondary hover:text-spot-green transition truncate block">{{.annotation.ArticleURL}}</a>
4+ <div class="text-xs mb-3 flex items-center gap-2">
5+ {{if .annotation.ArticleID.Valid}}<a href="/articles/{{.annotation.ArticleID.Int64}}" class="text-spot-green hover:text-spot-text transition truncate inline-flex items-center gap-1">
6+ <svg class="w-3 h-3 shrink-0" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z"/></svg>
7+ </a>{{end}}
8+ <a href="{{.annotation.ArticleURL}}" class="text-spot-secondary hover:text-spot-green transition truncate">{{.annotation.ArticleURL}}</a>
69 </div>
710 {{end}}
811 {{if .annotation.Quote.Valid}}
@@ -1,8 +1,11 @@
1 {{define "annotation-card.html"}}1 {{define "annotation-card.html"}}
2 <div id="annotation-{{.annotation.ID}}" class="bg-spot-surface rounded-xl p-5 border border-spot-divider shadow-sm">2 <div id="annotation-{{.annotation.ID}}" class="bg-spot-surface rounded-xl p-5 border border-spot-divider shadow-sm">
3 {{if .annotation.ArticleURL}}3 {{if .annotation.ArticleURL}}
4- <div class="text-xs mb-3">4+ <div class="text-xs mb-3 flex items-center gap-2">
5- <a href="{{.annotation.ArticleURL}}" class="text-spot-secondary hover:text-spot-green transition truncate block">{{.annotation.ArticleURL}}</a>5+ {{if .annotation.ArticleID.Valid}}<a href="/articles/{{.annotation.ArticleID.Int64}}" class="text-spot-green hover:text-spot-text transition truncate inline-flex items-center gap-1">
6+ <svg class="w-3 h-3 shrink-0" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z"/></svg>
7+ </a>{{end}}
8+ <a href="{{.annotation.ArticleURL}}" class="text-spot-secondary hover:text-spot-green transition truncate">{{.annotation.ArticleURL}}</a>
6 </div>9 </div>
7 {{end}}10 {{end}}
8 {{if .annotation.Quote.Valid}}11 {{if .annotation.Quote.Valid}}