commit 30d957b154c4b9e96e32e707ee273000589f90eb
parent 17fea11d6978da13b2acdb4d5adba8f644875bf3
Author: David Eisinger <[email protected]>
Date: Mon, 6 Oct 2025 14:17:31 -0400
Vue frontend
Diffstat:
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; }