commit 3a7ab819aa149935f8d1e24a45e6cc4d2cc5eb42
parent 3294091290e07bee9d5f9fdace3aebce016c9b23
Author: David Eisinger <[email protected]>
Date: Mon, 6 Oct 2025 22:19:50 -0400
Feed updates
Diffstat:
3 files changed, 63 insertions(+), 12 deletions(-)
diff --git a/internal/api.go b/internal/api.go
@@ -104,12 +104,45 @@ func NewHandler(db *DB) (http.Handler, error) {
_ = json.NewEncoder(w).Encode(resp)
})
- // Static web UI at "/"
- if h, err := StaticHandler(); err != nil {
- return nil, err
- } else {
- mux.Handle("/", h)
- }
+ // Static web UI at "/" with user feeds overlay (/{username}.json and /{username}.rss)
+ static, err := StaticHandler()
+ if err != nil { return nil, err }
+ mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
+ p := strings.TrimPrefix(r.URL.Path, "/")
+ if p != "" {
+ if strings.HasSuffix(p, ".json") || strings.HasSuffix(p, ".rss") {
+ // Extract username and format
+ format := "json"
+ if strings.HasSuffix(p, ".rss") { format = "rss" }
+ name := p
+ if format == "json" { name = strings.TrimSuffix(name, ".json") } else { name = strings.TrimSuffix(name, ".rss") }
+ // deny nested paths
+ if name != "" && !strings.Contains(name, "/") {
+ if u, err := db.GetUserByUsername(r.Context(), name); err == nil && u != nil {
+ // Show private items if this is the logged-in owner's feed
+ var items []Bookmark
+ if uid, ok := CurrentUserID(r); ok && uid == u.ID {
+ items, err = db.ListBookmarks(r.Context(), u.ID, 50, 0)
+ } else {
+ items, err = db.ListPublicBookmarks(r.Context(), u.ID, 50, 0)
+ }
+ if err != nil { http.Error(w, "internal server error", http.StatusInternalServerError); return }
+ resp := make([]bookmarkResponse, 0, len(items))
+ for i := range items { resp = append(resp, toResp(&items[i])) }
+ if format == "json" {
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(resp)
+ } else {
+ w.Header().Set("Content-Type", "application/rss+xml; charset=utf-8")
+ _ = writeRSS(w, r, resp)
+ }
+ return
+ }
+ }
+ }
+ }
+ static.ServeHTTP(w, r)
+ })
// Shared handler for JSON and RSS
bookmarksHandler := func(w http.ResponseWriter, r *http.Request) {
diff --git a/internal/web/index.html b/internal/web/index.html
@@ -15,9 +15,7 @@
<p class="muted">Lightweight bookmarking + plaintext archive</p>
</div>
<nav class="top-links">
- <a :href="q ? `/bookmarks?q=${encodeURIComponent(q)}` : '/bookmarks'" title="JSON feed">JSON</a>
- <span class="sep">|</span>
- <a :href="q ? `/bookmarks.xml?q=${encodeURIComponent(q)}` : '/bookmarks.xml'" title="RSS feed">RSS</a>
+ <a v-if="me && me.username" href="#" @click.prevent="logout">Log out</a>
</nav>
</header>
@@ -74,7 +72,14 @@
</section>
<section v-if="view==='self'">
- <h2>Search</h2>
+ <h2>
+ Bookmarks
+ <span class="small-links" v-if="me && me.username">
+ <a :href="`/${me.username}.json`" title="Your JSON feed (includes private if logged in)">JSON</a>
+ <span class="sep">|</span>
+ <a :href="`/${me.username}.rss`" title="Your RSS feed (includes private if logged in)">RSS</a>
+ </span>
+ </h2>
<form @submit.prevent="search">
<input v-model.trim="q" type="search" placeholder="Search…" />
<button :disabled="busySearch">{{ busySearch ? 'Searching…' : 'Search' }}</button>
@@ -109,8 +114,15 @@
</div>
</section>
- <section v-if="view==='public'">
- <h2>{{ viewUser }}’s Public Bookmarks</h2>
+ <section v-if="view==='public'" class="span2">
+ <h2>
+ {{ viewUser }}’s Public Bookmarks
+ <span class="small-links">
+ <a :href="`/${viewUser}.json`" title="User JSON feed">JSON</a>
+ <span class="sep">|</span>
+ <a :href="`/${viewUser}.rss`" title="User RSS feed">RSS</a>
+ </span>
+ </h2>
<div class="results">
<article v-for="b in results" :key="b.id" class="card">
<h3><a :href="b.url" target="_blank" rel="noopener noreferrer">{{ b.title || b.url }}</a></h3>
diff --git a/internal/web/styles.css b/internal/web/styles.css
@@ -12,8 +12,14 @@ h1 { margin: 0 0 4px 0; font-size: 24px; }
.top-links a { color: inherit; text-decoration: none; }
.top-links a:hover { text-decoration: underline; }
.top-links .sep { margin: 0 6px; opacity: 0.6; }
+.small-links { font-size: 12px; margin-left: 8px; color: #666; }
+.small-links a { color: inherit; text-decoration: none; }
+.small-links a:hover { text-decoration: underline; }
+.small-links .sep { margin: 0 6px; opacity: 0.6; }
.grid { display: grid; grid-template-columns: 1fr 2fr; gap: 24px; align-items: start; }
@media (max-width: 800px) { .grid { grid-template-columns: 1fr; } }
+/* Span full width across both columns when needed (e.g., public view) */
+.span2 { grid-column: 1 / -1; }
form { display: grid; gap: 10px; }
label { display: grid; gap: 6px; font-size: 13px; }
input, textarea { padding: 8px; font-size: 14px; border: 1px solid #ccc; border-radius: 6px; background: transparent; color: inherit; }