marky

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

commit 30d957b154c4b9e96e32e707ee273000589f90eb
parent 17fea11d6978da13b2acdb4d5adba8f644875bf3
Author: David Eisinger <[email protected]>
Date:   Mon,  6 Oct 2025 14:17:31 -0400

Vue frontend

Diffstat:
Mcmd/marky.go | 6++++++
Ainternal/static.go | 20++++++++++++++++++++
Ainternal/web/app.js | 76++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/web/index.html | 63+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/web/styles.css | 25+++++++++++++++++++++++++
5 files changed, 190 insertions(+), 0 deletions(-)

diff --git a/cmd/marky.go b/cmd/marky.go @@ -59,6 +59,12 @@ func main() { defer db.Close() mux := http.NewServeMux() + // Static web UI at "/" + if h, err := dbpkg.StaticHandler(); err != nil { + log.Fatalf("static handler: %v", err) + } else { + mux.Handle("/", h) + } mux.HandleFunc("/bookmarks", func(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodPost: diff --git a/internal/static.go b/internal/static.go @@ -0,0 +1,20 @@ +package internal + +import ( + "embed" + "io/fs" + "net/http" +) + +//go:embed web/* +var webAssets embed.FS + +// StaticHandler returns an http.Handler serving the embedded web UI. +func StaticHandler() (http.Handler, error) { + sub, err := fs.Sub(webAssets, "web") + if err != nil { + return nil, err + } + return http.FileServer(http.FS(sub)), nil +} + diff --git a/internal/web/app.js b/internal/web/app.js @@ -0,0 +1,76 @@ +const { createApp } = Vue; + +createApp({ + data() { + return { + form: { url: "", title: "", description: "" }, + busy: false, + error: "", + message: "", + q: "", + busySearch: false, + results: [], + }; + }, + mounted() { + // Initial list + this.search(); + }, + methods: { + async createBookmark() { + this.error = ""; + this.message = ""; + this.busy = true; + try { + const payload = { url: this.form.url }; + if (this.form.title) payload.title = this.form.title; + if (this.form.description) payload.description = this.form.description; + const res = await fetch("/bookmarks", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + if (!res.ok) { + const text = await res.text(); + throw new Error(text || `HTTP ${res.status}`); + } + const data = await res.json(); + this.message = `Saved: ${data.title || data.url}`; + this.form.url = ""; + this.form.title = ""; + this.form.description = ""; + // refresh results (use current query) + this.search(); + } catch (e) { + this.error = e.message || String(e); + } finally { + this.busy = false; + } + }, + async search() { + this.busySearch = true; + try { + const url = this.q ? `/bookmarks?q=${encodeURIComponent(this.q)}` : "/bookmarks"; + const res = await fetch(url); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const arr = await res.json(); + this.results = arr.map((x) => ({ + ...x, + description: x.description ?? null, + })); + } catch (e) { + console.error(e); + } finally { + this.busySearch = false; + } + }, + formatDate(iso) { + try { return new Date(iso).toLocaleString(); } catch { return iso; } + }, + formatExcerpt(ex) { + // Our API wraps matches with [...] — convert to <mark> + return ex.replace(/\[/g, '<mark>').replace(/\]/g, '</mark>'); + }, + }, +}).mount("#app"); + diff --git a/internal/web/index.html b/internal/web/index.html @@ -0,0 +1,63 @@ +<!DOCTYPE html> +<html lang="en"> + <head> + <meta charset="utf-8" /> + <meta name="viewport" content="width=device-width, initial-scale=1" /> + <title>marky</title> + <link rel="stylesheet" href="/styles.css" /> + <script src="https://unpkg.com/vue@3/dist/vue.global.prod.js"></script> + </head> + <body> + <div id="app"> + <header class="container"> + <h1>marky</h1> + <p class="muted">Lightweight bookmarking + plaintext archive</p> + </header> + + <main class="container grid"> + <section> + <h2>Add Bookmark</h2> + <form @submit.prevent="createBookmark"> + <label> + URL + <input v-model.trim="form.url" type="url" required placeholder="https://example.com" /> + </label> + <label> + Title (optional) + <input v-model.trim="form.title" type="text" placeholder="Page title" /> + </label> + <label> + Description (optional) + <textarea v-model.trim="form.description" rows="2" placeholder="Notes..."></textarea> + </label> + <button :disabled="busy">{{ busy ? 'Working…' : 'Save' }}</button> + <p v-if="error" class="error">{{ error }}</p> + <p v-if="message" class="ok">{{ message }}</p> + </form> + </section> + + <section> + <h2>Search</h2> + <form @submit.prevent="search"> + <input v-model.trim="q" type="search" placeholder="Search…" /> + <button :disabled="busySearch">{{ busySearch ? 'Searching…' : 'Search' }}</button> + </form> + <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> + <a :href="`/bookmarks/${b.id}/archive`" target="_blank" class="archive-link" title="View archived text">🗃️</a> + </h3> + <p class="url">{{ b.url }}</p> + <p v-if="b.excerpt" class="excerpt" v-html="formatExcerpt(b.excerpt)"></p> + <p class="meta">Updated: {{ formatDate(b.updated_at) }}</p> + <p v-if="!b.excerpt && b.description" class="desc">{{ b.description }}</p> + </article> + </div> + </section> + </main> + </div> + + <script src="/app.js"></script> + </body> + </html> diff --git a/internal/web/styles.css b/internal/web/styles.css @@ -0,0 +1,25 @@ +:root { color-scheme: light dark; } +body { font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, "Helvetica Neue", Arial, "Noto Sans", "Apple Color Emoji", "Segoe UI Emoji"; margin: 0; } +.container { max-width: 960px; margin: 0 auto; padding: 16px; } +header { border-bottom: 1px solid #ddd3; } +h1 { margin: 0 0 4px 0; font-size: 24px; } +.muted { color: #666; margin: 0 0 8px 0; font-size: 13px; } +.grid { display: grid; grid-template-columns: 1fr 2fr; gap: 24px; align-items: start; } +@media (max-width: 800px) { .grid { grid-template-columns: 1fr; } } +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; } +button { padding: 8px 12px; font-size: 14px; border-radius: 6px; border: 1px solid #888; background: #eee; color: #111; cursor: pointer; } +@media (prefers-color-scheme: dark) { button { background: #333; color: #eee; border-color: #777; } } +.error { color: #b00020; } +.ok { color: #0a7f2e; } +.results { display: grid; gap: 12px; margin-top: 12px; } +.card { border: 1px solid #ddd3; border-radius: 8px; padding: 12px; } +.card h3 { margin: 0 0 6px 0; font-size: 16px; } +.archive-link { font-size: 12px; margin-left: 8px; color: #555; text-decoration: none; } +.archive-link:hover { text-decoration: underline; } +.url { color: #666; font-size: 12px; margin: 0 0 6px 0; word-break: break-all; } +.excerpt { margin: 6px 0; white-space: pre-wrap; } +mark { background: #fff3b0; color: inherit; padding: 0 2px; border-radius: 2px; } +.meta { color: #777; font-size: 12px; margin: 8px 0 0 0; } +.desc { margin: 6px 0 0; color: #444; }