marky

Lightweight bookmarking + plaintext archive
Log | Files | Refs | README | LICENSE

commit 3a7ab819aa149935f8d1e24a45e6cc4d2cc5eb42
parent 3294091290e07bee9d5f9fdace3aebce016c9b23
Author: David Eisinger <[email protected]>
Date:   Mon,  6 Oct 2025 22:19:50 -0400

Feed updates

Diffstat:
Minternal/api.go | 45+++++++++++++++++++++++++++++++++++++++------
Minternal/web/index.html | 24++++++++++++++++++------
Minternal/web/styles.css | 6++++++
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; }